This commit is contained in:
TuanVT 2026-05-31 09:38:10 +07:00
parent 56e2733d8f
commit 62ca6dd0f9
32 changed files with 1785 additions and 381 deletions

View File

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

View File

@ -0,0 +1,14 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPageStatistical from "@/components/page/statistical/MainPageStatistical";
import React from "react";
const page = () => {
return (
<BaseLayout title="Thống kê chuyên khoa">
<MainPageStatistical />
</BaseLayout>
);
};
export default page;

View File

@ -0,0 +1,194 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { Check, ChevronDown } from "lucide-react";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
interface OptionItem {
uuid: string | number;
name: string;
code?: string;
}
interface PropsFilterCustom<T extends string | number | null> {
styleRounded?: boolean;
showOptionAll?: boolean;
name: string;
listOption: OptionItem[];
value: T;
onChange: (value: T) => void;
}
function FilterCustom<T extends string | number | null>({
styleRounded = false,
showOptionAll = true,
name,
listOption,
value,
onChange,
}: PropsFilterCustom<T>) {
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const normalizedKeyword = removeVietnameseTones(keyword);
useEffect(() => {
if (open) {
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}
}, [open]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const filteredOptions = listOption.filter((item) =>
removeVietnameseTones(item.name).includes(normalizedKeyword),
);
const selectedLabel =
listOption.find((item) => item.uuid === value)?.name || "Tất cả";
return (
<div ref={wrapperRef} className="relative">
{/* Trigger */}
<div
onClick={() => setOpen((prev) => !prev)}
className={clsx(
"flex items-center justify-between gap-2 rounded-full border",
"h-12 min-w-[240px] px-4",
"cursor-pointer select-none",
"border border-[#e1e5ed] bg-white",
"transition-all",
"hover:border-[#0019f6]",
styleRounded ? "rounded-full" : "rounded-md",
open && "border-[#0019f6]",
)}
>
<div className="flex items-center overflow-hidden">
<span className="text-sm font-medium text-[#777e90]">{name}:</span>
<span className="ml-2 truncate text-sm font-medium">
{selectedLabel}
</span>
</div>
<ChevronDown
size={16}
className={clsx(
"transition-transform duration-200",
open && "rotate-180",
)}
/>
</div>
{/* Dropdown */}
{open && (
<div
className={clsx(
"absolute left-0 top-full z-50 mt-2",
"min-w-[240px] overflow-hidden",
"rounded-2xl bg-white shadow-xl",
styleRounded && "min-w-[200px]",
)}
>
<div className="p-3">
{/* Search */}
<input
ref={inputRef}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm kiếm..."
className="
mb-2 w-full
rounded-lg border border-[#e1e5ed]
px-3 py-2 text-sm
outline-none
focus:border-[#0019f6]
"
/>
{/* Options */}
<div className="max-h-[320px] overflow-y-auto">
{showOptionAll && (
<div
onClick={() => {
onChange(null as T);
setOpen(false);
}}
className={clsx(
"flex cursor-pointer items-center justify-between",
"border-b border-[#e2e0ec]",
"px-3 py-2 text-sm",
"hover:text-[#0019f6]",
(value === null || value === "") && "text-[#0019f6]",
)}
>
<span>Tất cả</span>
{(value === null || value === "") && (
<Check size={18} className="text-[#5755FF]" />
)}
</div>
)}
{filteredOptions.map((item) => {
const isActive = item.uuid === value;
return (
<div
key={item.uuid}
onClick={() => {
onChange(item.uuid as T);
setOpen(false);
}}
className={clsx(
"mt-1 flex cursor-pointer items-center justify-between",
"border-b border-[#e2e0ec]",
"px-3 py-2 text-sm",
"hover:text-[#0019f6]",
"last:border-none",
isActive && "text-[#0019f6]",
)}
>
<span>{item.name}</span>
{isActive && <Check size={18} className="text-[#5755FF]" />}
</div>
);
})}
{filteredOptions.length === 0 && (
<div className="py-3 text-center text-sm text-gray-400">
Không tìm thấy dữ liệu
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
export default FilterCustom;

View File

@ -0,0 +1,205 @@
"use client";
import React, {
Dispatch,
SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import clsx from "clsx";
import { Check, ChevronDown } from "lucide-react";
import Button from "./custom-button";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
interface OptionItem<T> {
uuid: T;
name: string;
code?: string;
}
interface PropsFilterMany<T extends string | number> {
styleRounded?: boolean;
small?: boolean;
name: string;
listOption: OptionItem<T>[];
value: T[];
setValue: Dispatch<SetStateAction<T[]>>;
}
function FilterMany<T extends string | number>({
styleRounded = false,
small = false,
name,
listOption,
value,
setValue,
}: PropsFilterMany<T>) {
const inputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const [tempSelected, setTempSelected] = useState<T[]>([]);
const wrapperRef = useRef<HTMLDivElement>(null);
// sync khi mở dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleOpen = () => {
setOpen((prev) => !prev);
};
useEffect(() => {
if (open) {
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}
}, [open]);
const isAllSelected =
tempSelected.length === 0 || tempSelected.length === listOption.length;
const filteredList = listOption.filter((item) =>
removeVietnameseTones(item.name).includes(
removeVietnameseTones(keyword || ""),
),
);
return (
<div ref={wrapperRef} className="relative inline-block">
{/* TRIGGER */}
<div
onClick={handleOpen}
className={clsx(
"flex items-center justify-between px-4 cursor-pointer border transition",
"border-gray-200 bg-white hover:border-blue-600",
small ? "h-10 text-sm" : "h-12",
styleRounded ? "rounded-full bg-gray-100" : "rounded-md",
open && "border-blue-600",
)}
>
<div className="flex items-center">
<p className="text-gray-400 text-sm font-medium">{name}:</p>
<p className="ml-2 text-sm font-medium">
{value.length === 0 || value.length === listOption.length
? "Tất cả"
: `${value.length} ${name.toLowerCase()}`}
</p>
</div>
<ChevronDown
size={16}
className={clsx("transition duration-300", open && "rotate-180")}
/>
</div>
{/* DROPDOWN */}
{open && (
<div
className={clsx(
"absolute left-0 top-full z-50 mt-2",
"w-[280px] bg-white border border-gray-200 shadow-lg p-3 space-y-3",
styleRounded && "rounded-2xl",
)}
>
{/* SEARCH */}
<input
ref={inputRef}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm kiếm..."
className="
w-full px-3 py-2 text-sm
border border-gray-200 rounded-lg outline-none
focus:border-blue-500
"
/>
{/* LIST */}
<div className="max-h-60 overflow-y-auto space-y-1">
{/* ALL */}
<div
onClick={() => setTempSelected([])}
className={clsx(
"flex items-center justify-between px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-100",
isAllSelected && "bg-blue-50",
)}
>
<p>Tất cả</p>
{isAllSelected && <Check size={16} className="text-blue-600" />}
</div>
{/* OPTIONS */}
{filteredList.map((item) => {
const active = tempSelected.includes(item.uuid);
return (
<div
key={String(item.uuid)}
onClick={() => {
setTempSelected((prev) =>
prev.includes(item.uuid)
? prev.filter((x) => x !== item.uuid)
: [...prev, item.uuid],
);
}}
className={clsx(
"flex items-center justify-between px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-100",
active && "bg-blue-50",
)}
>
<p>{item.name}</p>
{active && <Check size={16} className="text-blue-600" />}
</div>
);
})}
</div>
{/* ACTION */}
<div className="flex gap-2">
<Button
variant="grey"
rounded="full"
onClick={() => setOpen(false)}
>
Hủy bỏ
</Button>
<Button
variant="midnightBlue"
rounded="full"
onClick={() => {
setValue(tempSelected);
setOpen(false);
}}
>
Xác nhận
</Button>
</div>
</div>
)}
</div>
);
}
export default FilterMany;

View File

@ -9,18 +9,9 @@ import RequireAuth from "@/components/protected/RequiredAuth";
export const ContextBaseLayout = React.createContext<TContextBaseLayout>({});
const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
const [showFull, setShowFull] = React.useState(false);
const [showFull, setShowFull] = React.useState(true);
const [openMenuMobile, setOpenMenuMobile] = React.useState(false);
// Khi mount trên client, mở sidebar mặc định nếu là màn hình desktop (xl)
// React.useEffect(() => {
// try {
// if (window?.innerWidth >= 1280) {
// setShowFull(true);
// }
// } catch (e) {
// // ignore
// }
// }, []);
return (
<RequireAuth>
<ContextBaseLayout
@ -39,11 +30,17 @@ const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
{/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
<nav
className={clsx(
"fixed top-0 left-0 h-full w-[240px] z-[21] bg-white transition-all duration-300 border-r border-[#f4f7fa]",
// Desktop logic
showFull ? "xl:translate-x-0" : "xl:-translate-x-full",
// Mobile logic
openMenuMobile ? "translate-x-0" : "-translate-x-full",
"fixed top-0 left-0 h-screen bg-white border-r border-[#f4f7fa] z-[30] transition-all duration-300 ease-in-out",
/* DESKTOP */
showFull
? "xl:w-[240px] xl:translate-x-0"
: "xl:w-[240px] xl:-translate-x-full",
/* MOBILE + TABLET */
openMenuMobile
? "translate-x-0 w-[240px]"
: "-translate-x-full w-[240px]",
)}
>
<Navbar />
@ -53,10 +50,14 @@ const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
<header
className={clsx(
"fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]",
/* DESKTOP */
showFull
? "xl:left-[240px] xl:w-[calc(100%-240px)]"
: "left-0 w-full",
"left-0 w-full", // Mặc định full width trên mobile
: "xl:left-0 xl:w-full",
/* MOBILE */
"left-0 w-full",
)}
>
<Header title={title} breadcrumb={breadcrumb} />
@ -65,9 +66,13 @@ const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
{/* MAIN CONTENT */}
<main
className={clsx(
"pt-[92px] pb-6 px-6 transition-all duration-300",
showFull ? "xl:pl-[264px]" : "xl:pl-6",
"pl-6", // Mặc định padding trên mobile
"pt-[92px] pb-6 px-6 transition-all duration-300 ease-in-out",
/* DESKTOP */
showFull ? "xl:ml-[240px]" : "xl:ml-0",
/* MOBILE */
"ml-0",
)}
>
{children}

View File

@ -20,8 +20,10 @@ const Header = ({ title, breadcrumb }: PropsHeader) => {
//Đóng menu mobile khi đổi route
useEffect(() => {
context.setOpenMenuMobile?.(false);
}, [pathname, context]);
if (window.innerWidth < 1280) {
context.setOpenMenuMobile?.(false);
}
}, [pathname]);
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
@ -37,7 +39,7 @@ const Header = ({ title, breadcrumb }: PropsHeader) => {
{/* Nút bấm Mobile: Mở menu mobile */}
<div
className="xl:hidden cursor-pointer"
onClick={() => context?.setOpenMenuMobile?.(true)}
onClick={() => context?.setOpenMenuMobile?.(!context?.openMenuMobile)}
>
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
</div>

View File

@ -51,7 +51,7 @@ const MenuProfile = ({ onClose }: PropsMenuProfile) => {
return (
<div className="w-[260px] bg-white rounded-xl shadow-md p-2">
{/* PROFILE */}
{/* <Link
<Link
href={PATH.PROFILE}
onClick={onClose}
className={clsx(
@ -65,7 +65,7 @@ const MenuProfile = ({ onClose }: PropsMenuProfile) => {
<p className="text-xs text-gray-500">Chi tiết tài khoản</p>
</div>
</Link>
<div className="h-px bg-gray-200 my-1" /> */}
<div className="h-px bg-gray-200 my-1" />
{/* CHANGE PASSWORD */}
{/* <Link

View File

@ -22,13 +22,17 @@ import patientServices, {
import { QUERY_KEY } from "@/constant/config/enum";
import CustomButton from "@/components/customs/custom-button";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import GridColumn from "@/components/layouts/GridColumn";
import { toastWarn } from "@/common/funcs/toast";
import specialtyServices, { SpecialtyItem } from "@/services/specialtyServices";
import SelectMany from "@/components/utils/FormCustom/components/SelectMany";
const CreateConsultation = () => {
const router = useRouter();
const searchParams = useSearchParams();
const patientId = searchParams.get("patientId");
const queryClient = useQueryClient();
const [form, setForm] = useState<{
@ -36,17 +40,13 @@ const CreateConsultation = () => {
chiefComplaint: string;
symptomsText: string;
vitalSigns: string;
diagnosis: string;
treatmentPlan: string;
doctorNotes: string;
specialtyIds: string[];
}>({
patientId: "",
patientId: patientId || "",
chiefComplaint: "",
symptomsText: "",
vitalSigns: "",
diagnosis: "",
treatmentPlan: "",
doctorNotes: "",
specialtyIds: [],
});
/* =========================
@ -80,6 +80,24 @@ const CreateConsultation = () => {
},
});
/* =========================
GET LIST SPECIALTY
========================= */
const specialtyQuery = useQuery<SpecialtyItem[]>({
queryKey: [QUERY_KEY.table_list_specialty],
queryFn: async () => {
const res = await httpRequest<SpecialtyItem[]>({
showMessageFailed: true,
http: specialtyServices.getSpecialty(),
});
return res || [];
},
});
const specialties: SpecialtyItem[] = specialtyQuery.data || [];
/* =========================
OPTIONS SELECT
========================= */
@ -99,6 +117,10 @@ const CreateConsultation = () => {
http: consultationServices.createConsultations({
patientId: form.patientId,
chiefComplaint: form.chiefComplaint,
symptomsText: form.symptomsText,
vitalSigns: form.vitalSigns,
specialtyIds: form.specialtyIds,
}),
});
},
@ -123,7 +145,6 @@ const CreateConsultation = () => {
<div
className="
max-w-[95vw]
h-[485px]
border
rounded-lg
bg-white
@ -158,6 +179,7 @@ const CreateConsultation = () => {
<span className="text-red-500">*</span>
</span>
}
readOnly={!!patientId}
placeholder="Chọn bệnh nhân"
value={form.patientId}
options={patientOptions}
@ -180,6 +202,63 @@ const CreateConsultation = () => {
getOptionValue={(item: PatientItem) => item.id}
/>
</div>
<div className="space-y-5 mb-2">
<GridColumn col={3}>
<InputForm
label="Lý do khám"
name="chiefComplaint"
type="text"
value={form.chiefComplaint}
placeholder="Nhập lý do khám"
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, chiefComplaint: String(v) }))
}
/>
<InputForm
type="text"
label="Triệu chứng"
name="symptomsText"
placeholder="Nhập triệu chứng"
isRequired
value={form.symptomsText}
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, symptomsText: String(v) }))
}
/>
<InputForm
type="text"
label="Chỉ số huyết áp, chỉ số mạch"
name="vitalSigns"
placeholder="Nhập chỉ số huyết áp, chỉ số mạch"
value={form.vitalSigns}
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, vitalSigns: String(v) }))
}
/>
</GridColumn>
</div>
<div className="space-y-5 mb2">
<SelectMany<SpecialtyItem>
label="Chuyên khoa"
text="chuyên khoa"
title="Chọn chuyên khoa"
placeholder="Chọn nhiều chuyên khoa..."
options={specialties}
selectedItems={form.specialtyIds}
setSelectedItems={(value) =>
setForm((prev) => ({
...prev,
specialtyIds: value as string[],
}))
}
showSelectedItems="default"
getOptionLabel={(item) => item.name}
getOptionValue={(item) => item.id}
/>
</div>
</div>
{/* FOOTER */}
@ -207,12 +286,10 @@ const CreateConsultation = () => {
<CustomButton
disabled={
!form.patientId ||
// !form.chiefComplaint ||
// !form.diagnosis ||
// !form.doctorNotes ||
// !form.symptomsText ||
// !form.treatmentPlan ||
// !form.vitalSigns ||
!form.chiefComplaint ||
!form.symptomsText ||
!form.vitalSigns ||
!form.specialtyIds ||
createConsultationMutation.isPending
}
variant="midnightBlue"

View File

@ -134,10 +134,10 @@ const DetailConsultation = () => {
</p>
</div>
{/* DẤU HIỆU SINH TỒN */}
{/* CHỈ SỐ HUYẾT ÁP, CHỈ SỐ MẠCH */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
DẤU HIỆU SINH TỒN
CHỈ SỐ HUYẾT ÁP, CHỈ SỐ MẠCH
</p>
<p className="text-[15px] font-medium text-[#202939]">
@ -189,6 +189,32 @@ const DetailConsultation = () => {
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
CÁC CHUYÊN KHOA ĐANG CHỜ KHÁM
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.specialtyClinics
?.filter((specialty) => specialty.specialtyStatus === 1)
.map((specialty) => specialty.specialtyName)
.join(", ") || "---"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
CÁC CHUYÊN KHOA ĐÃ KHÁM
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.specialtyClinics
?.filter((specialty) => specialty.specialtyStatus === 3)
.map((specialty) => specialty.specialtyName)
.join(", ") || "---"}
</p>
</div>
{/* TRẠNG THÁI */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
@ -200,29 +226,44 @@ const DetailConsultation = () => {
listState={[
{
state: TYPE_STATUS.Draft,
text: "Đang chờ",
textColor: "#000",
backgroundColor: "#FFE4C4",
text: "Đang chờ khám",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
},
{
state: TYPE_STATUS.InProgress,
text: "Đang thực hiện",
textColor: "#000",
backgroundColor: "#ff3300",
text: "Đang khám tổng quát",
textColor: "#1E3A8A",
backgroundColor: "#DBEAFE",
},
{
state: TYPE_STATUS.ToSpecialty,
text: "Chờ khám chuyên khoa",
textColor: "#6B21A8",
backgroundColor: "#E9D5FF",
},
{
state: TYPE_STATUS.PendingPrescription,
text: "Chờ nhận thuốc/kính",
textColor: "#9A3412",
backgroundColor: "#FED7AA",
},
{
state: TYPE_STATUS.Completed,
text: "Hoàn thành",
textColor: "#000",
backgroundColor: "#3d69eb",
textColor: "#166534",
backgroundColor: "#DCFCE7",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#000",
backgroundColor: "#cccc",
textColor: "#991B1B",
backgroundColor: "#FEE2E2",
},
]}
/>

View File

@ -2,7 +2,6 @@
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";
@ -27,8 +26,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { PATH } from "@/constant/config";
import Pagination from "@/components/customs/custom-pagination";
import moment from "moment";
import specialtyServices, { SpecialtyItem } from "@/services/specialtyServices";
import FilterCustom from "@/components/customs/FilterCustom";
const MainPageConsultation = () => {
const router = useRouter();
@ -49,6 +51,10 @@ const MainPageConsultation = () => {
const _keyword = searchParams.get("_keyword");
const _status = searchParams.get("_status");
const _specialtyId = searchParams.get("_specialtyId");
/* =========================
STATE
========================= */
@ -78,7 +84,7 @@ const MainPageConsultation = () => {
Search: _keyword || "",
SortBy: "id",
SortBy: _specialtyId || undefined,
Desc: true,
}),
@ -100,9 +106,35 @@ const MainPageConsultation = () => {
DATA
========================= */
const specialtyQuery = useQuery({
queryKey: [QUERY_KEY.list_specialty_lookup],
queryFn: async () => {
const res = await httpRequest<SpecialtyItem[]>({
http: specialtyServices.getSpecialty(),
showMessageFailed: true,
});
return res || [];
},
});
const consultationData = useMemo(() => {
return consultationQuery.data?.items || [];
}, [consultationQuery.data]);
let data = consultationQuery.data?.items || [];
if (_status) {
data = data.filter((item) => item.status === Number(_status));
}
if (_specialtyId) {
data = data.filter((item) =>
item.specialtyClinics?.some(
(specialty) => specialty.specialtyId === _specialtyId,
),
);
}
return data;
}, [consultationQuery.data, _status, _specialtyId]);
/* =========================
UPDATE QUERY
@ -114,13 +146,18 @@ const MainPageConsultation = () => {
const updateQuery = (key: string, value: string | number) => {
const params = new URLSearchParams(searchParams.toString());
if (!value || value === "" || (key === "_page" && value === 1)) {
if (!value || value === "") {
params.delete(key);
} else {
params.set(key, String(value));
}
if (key === "_pageSize") {
if (
key === "_pageSize" ||
key === "_keyword" ||
key === "_status" ||
key === "_specialtyId"
) {
params.delete("_page");
}
@ -259,6 +296,51 @@ const MainPageConsultation = () => {
placeholder="Tìm kiếm phiên khám..."
/>
</div>
<FilterCustom<string | null>
name="Chuyên khoa"
value={_specialtyId}
onChange={(value) => updateQuery("_specialtyId", value ?? "")}
listOption={useMemo(() => {
return (
specialtyQuery.data?.map((item) => ({
uuid: item.id,
name: item.name,
})) || []
);
}, [specialtyQuery.data])}
/>
<FilterCustom<number | null>
name="Trạng thái"
value={_status ? Number(_status) : null}
onChange={(value) => updateQuery("_status", value ?? "")}
listOption={[
{
uuid: TYPE_STATUS.Draft,
name: "Đang chờ khám",
},
{
uuid: TYPE_STATUS.InProgress,
name: "Đang khám tổng quát",
},
{
uuid: TYPE_STATUS.ToSpecialty,
name: "Chờ khám chuyên khoa",
},
{
uuid: TYPE_STATUS.PendingPrescription,
name: "Chờ nhận thuốc/kính",
},
{
uuid: TYPE_STATUS.Completed,
name: "Hoàn thành",
},
{
uuid: TYPE_STATUS.Cancelled,
name: "Đã hủy",
},
]}
/>
</div>
{/* TABLE */}
@ -295,8 +377,13 @@ const MainPageConsultation = () => {
{
title: "NGÀY KHÁM",
render: (item: ConsultationItem) => <span>{item.visitDate}</span>,
render: (item: ConsultationItem) => (
<span>
{item.visitDate
? moment(item.visitDate).format("DD/MM/YYYY")
: "---"}
</span>
),
},
{
@ -330,7 +417,30 @@ const MainPageConsultation = () => {
<span>{item.doctorNotes || "---"}</span>
),
},
{
title: "CÁC CHUYÊN KHOA ĐANG CHỜ KHÁM",
render: (item: ConsultationItem) => {
const waitingSpecialties = item.specialtyClinics
?.filter((specialty) => specialty.specialtyStatus === 1)
.map((specialty) => specialty.specialtyName)
.join(", ");
return <span>{waitingSpecialties || "---"}</span>;
},
},
{
title: "CÁC CHUYÊN KHOA ĐÃ KHÁM",
render: (item: ConsultationItem) => {
const completedSpecialties = item.specialtyClinics
?.filter((specialty) => specialty.specialtyStatus === 3)
.map((specialty) => specialty.specialtyName)
.join(", ");
return <span>{completedSpecialties || "---"}</span>;
},
},
{
title: "TRẠNG THÁI",
@ -340,29 +450,44 @@ const MainPageConsultation = () => {
listState={[
{
state: TYPE_STATUS.Draft,
text: "Đang chờ",
textColor: "#000",
backgroundColor: "#FFE4C4",
text: "Đang chờ khám",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
},
{
state: TYPE_STATUS.InProgress,
text: "Đang thực hiện",
textColor: "#000",
backgroundColor: "#ff3300",
text: "Đang khám tổng quát",
textColor: "#1E3A8A",
backgroundColor: "#DBEAFE",
},
{
state: TYPE_STATUS.ToSpecialty,
text: "Chờ khám chuyên khoa",
textColor: "#6B21A8",
backgroundColor: "#E9D5FF",
},
{
state: TYPE_STATUS.PendingPrescription,
text: "Chờ nhận thuốc/kính",
textColor: "#9A3412",
backgroundColor: "#FED7AA",
},
{
state: TYPE_STATUS.Completed,
text: "Hoàn thành",
textColor: "#000",
backgroundColor: "#3d69eb",
textColor: "#166534",
backgroundColor: "#DCFCE7",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#000",
backgroundColor: "#cccc",
textColor: "#991B1B",
backgroundColor: "#FEE2E2",
},
]}
/>
@ -381,15 +506,32 @@ const MainPageConsultation = () => {
const isDisabled = isCancelled || isCompleted;
// Tìm chuyên khoa có specialtyStatus = 1
const specialtyClinicActive = item.specialtyClinics?.find(
(specialty) => specialty.specialtyStatus === 1,
);
return (
<div className="flex items-center gap-2">
{/* UPDATE */}
<CustomButton
size="sm"
disabled={isDisabled}
icon={<Pencil className="text-blue-400" />}
href={`/consultation/update?_id=${item?.id}`}
/>
icon={
specialtyClinicActive ? undefined : (
<Pencil className="text-blue-400" />
)
}
href={
specialtyClinicActive
? `/consultation/update-specialty?_id=${item.id}&specialtyClinicId=${specialtyClinicActive.id}`
: `/consultation/update?_id=${item.id}`
}
>
{specialtyClinicActive
? `Cập nhật ${specialtyClinicActive.specialtyName}`
: undefined}
</CustomButton>
{/* CANCEL */}
<CustomButton
@ -423,7 +565,7 @@ const MainPageConsultation = () => {
pageSize={pageSize}
onSetPage={(value) => updateQuery("_page", value)}
onSetPageSize={(value) => updateQuery("_pageSize", value)}
dependencies={[_pageSize, _keyword]}
dependencies={[_pageSize, _keyword, _specialtyId, _status]}
/>
{/* CANCEL DIALOG */}

View File

@ -18,6 +18,7 @@ import Table from "@/components/customs/custom-table";
import StateActive from "@/components/customs/StateActive";
import Pagination from "@/components/customs/custom-pagination";
import Search from "@/components/customs/custom-search";
import moment from "moment";
const TableHistoryPatient = () => {
const searchParams = useSearchParams();
@ -129,7 +130,13 @@ const TableHistoryPatient = () => {
},
{
title: "NGÀY KHÁM",
render: (item: ConsultationItem) => <span>{item.visitDate}</span>,
render: (item: ConsultationItem) => (
<span>
{item.visitDate
? moment(item.visitDate).format("DD/MM/YYYY")
: "---"}
</span>
),
},
{
title: "DẤU HIỆU",
@ -151,27 +158,44 @@ const TableHistoryPatient = () => {
listState={[
{
state: TYPE_STATUS.Draft,
text: "Đang chờ",
textColor: "#000",
backgroundColor: "#FFE4C4",
text: "Đang chờ khám",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
},
{
state: TYPE_STATUS.InProgress,
text: "Đang thực hiện",
textColor: "#000",
backgroundColor: "#ff3300",
text: "Đang khám tổng quát",
textColor: "#1E3A8A",
backgroundColor: "#DBEAFE",
},
{
state: TYPE_STATUS.ToSpecialty,
text: "Chờ khám chuyên khoa",
textColor: "#6B21A8",
backgroundColor: "#E9D5FF",
},
{
state: TYPE_STATUS.PendingPrescription,
text: "Chờ nhận thuốc/kính",
textColor: "#9A3412",
backgroundColor: "#FED7AA",
},
{
state: TYPE_STATUS.Completed,
text: "Hoàn thành",
textColor: "#000",
backgroundColor: "#3d69eb",
textColor: "#166534",
backgroundColor: "#DCFCE7",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#000",
backgroundColor: "#cccccc",
textColor: "#991B1B",
backgroundColor: "#FEE2E2",
},
]}
/>

View File

@ -27,6 +27,7 @@ interface IUpdateConsul {
diagnosis: string;
treatmentPlan: string;
doctorNotes: string;
isGlassesDelivered: boolean;
}
/* =========================
@ -39,6 +40,7 @@ const defaultForm: IUpdateConsul = {
diagnosis: "",
treatmentPlan: "",
doctorNotes: "",
isGlassesDelivered: false,
};
/* =========================
@ -103,6 +105,7 @@ const UpdateConsultation = () => {
diagnosis: consul.diagnosis || "",
treatmentPlan: consul.treatmentPlan || "",
doctorNotes: consul.doctorNotes || "",
isGlassesDelivered: consul.isGlassesDelivered,
});
}, [consulDetailQuery.data]);
@ -126,6 +129,7 @@ const UpdateConsultation = () => {
diagnosis: form.diagnosis,
treatmentPlan: form.treatmentPlan,
doctorNotes: form.doctorNotes,
isGlassesDelivered: form?.isGlassesDelivered,
}),
});
},

View File

@ -0,0 +1,341 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import GridColumn from "@/components/layouts/GridColumn";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import { PATH } from "@/constant/config";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import consultationServices, {
ConsultationDetail,
} from "@/services/consultationServices";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
const UpdateSpecialtyClinicId = () => {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const consultationId = searchParams.get("_id") || "";
const specialtyClinicId = searchParams.get("specialtyClinicId") || "";
const [form, setForm] = useState<{
specialtyAssignmentId: string;
specialtyNotes: string;
diagnosis: string;
procedureNotes: string;
glassRequired: boolean;
glassType: string;
}>({
specialtyAssignmentId: "",
specialtyNotes: "",
diagnosis: "",
procedureNotes: "",
glassRequired: false,
glassType: "",
});
/* =========================
GET DETAIL API
========================= */
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,
specialtyClinics: [
{
id: "",
specialtyId: "",
specialtyName: "",
specialtyNotes: null,
diagnosis: null,
procedureNotes: null,
glassRequired: null,
glassType: null,
examinerName: "",
specialtyStatus: 1,
sharedItems: [],
},
],
aggregatedPrescriptionItems: [],
}
);
},
});
// Tìm chuyên khoa hiện tại từ data trả về
const selectedSpecialtyClinic =
consulDetailQuery.data?.specialtyClinics?.find(
(item) => item.id === specialtyClinicId,
);
/**
* ĐIỀU KIỆN KIỂM TRA KHOA MẮT
* Bạn hãy thay đi điều kiện này tùy thuộc vào database của bạn.
* dụ: selectedSpecialtyClinic?.specialtyId === "MẮT" hoặc kiểm tra theo tên.
*/
const isEyeClinic =
selectedSpecialtyClinic?.specialtyName?.toLowerCase().includes("mắt") ||
selectedSpecialtyClinic?.specialtyId?.toLowerCase().includes("mat");
useEffect(() => {
const consul = consulDetailQuery.data;
if (!consul) return;
const specialtyClinic = consul.specialtyClinics?.find(
(item) => item.id === specialtyClinicId,
);
if (!specialtyClinic) return;
setForm({
specialtyAssignmentId: specialtyClinic.id || "",
specialtyNotes: specialtyClinic.specialtyNotes || "",
diagnosis: specialtyClinic.diagnosis || "",
procedureNotes: specialtyClinic.procedureNotes || "",
glassRequired: specialtyClinic.glassRequired || false,
glassType: specialtyClinic.glassType || "",
});
}, [consulDetailQuery.data, specialtyClinicId]);
/* =========================
UPDATE MUTATION
========================= */
const specialtyResultsMutation = useMutation({
mutationFn: async () => {
if (!consultationId) {
throw new Error("Không tìm thấy ID phiên khám!");
}
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Cập nhật phiên khám thành công!",
http: consultationServices.specialtyResults(
form.specialtyAssignmentId,
{
specialtyNotes: form.specialtyNotes,
diagnosis: form.diagnosis,
procedureNotes: form.procedureNotes,
// Nếu không phải khoa mắt, gán mặc định false và chuỗi rỗng khi gửi API
glassRequired: isEyeClinic ? form.glassRequired : false,
glassType: isEyeClinic ? form.glassType : "",
},
),
});
},
onSuccess(data) {
if (!data) return;
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.chi_tiet_phien_kham, consultationId],
});
router.push(PATH.CONSULTATION || "/consultation");
},
});
const handleSubmit = () => {
specialtyResultsMutation.mutate();
};
const isLoading =
consulDetailQuery.isLoading || specialtyResultsMutation.isPending;
if (consulDetailQuery.isLoading) {
return (
<div className="flex items-center justify-center p-10 font-medium text-gray-500">
Đang tải dữ liệu phiên khám...
</div>
);
}
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="flex flex-col gap-6 rounded-2xl bg-white p-6 shadow-xl">
{/* HEADER */}
<div className="flex items-center justify-between border-b pb-5">
<div>
<h2 className="text-[24px] font-bold text-[#111827]">
Phiên khám chuyên khoa
</h2>
<p className="mt-1 text-sm text-[#697586]">
Cập nhật kết quả chi tiết quá trình khám bệnh của bệnh nhân
</p>
</div>
</div>
{/* CHUYÊN KHOA INFO */}
<InputForm
label="Chuyên khoa đảm nhận"
name="specialtyAssignmentId"
type="text"
value={
selectedSpecialtyClinic
? `${selectedSpecialtyClinic.specialtyName}`
: "Chưa có thông tin chuyên khoa"
}
placeholder="Thông tin chuyên khoa"
isRequired
readOnly
/>
{/* KHỐI THÔNG TIN KHÁM CHUNG (Luôn cố định 3 cột đẹp mắt) */}
<div className="mt-2">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
Thông tin khám chung
</h3>
<GridColumn col={3}>
<InputForm
label="Ghi chú chuyên khoa"
name="specialtyNotes"
type="text"
value={form.specialtyNotes}
placeholder="Nhập ghi chú chuyên khoa"
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, specialtyNotes: String(v) }))
}
/>
<InputForm
type="text"
label="Chuẩn đoán chuyên khoa"
name="diagnosis"
placeholder="Nhập Chuẩn đoán"
isRequired
value={form.diagnosis}
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, diagnosis: String(v) }))
}
/>
<InputForm
type="text"
label="Ghi chú thủ thuật / can thiệp"
name="procedureNotes"
placeholder="Nhập ghi chú thủ thuật / can thiệp"
value={form.procedureNotes}
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, procedureNotes: String(v) }))
}
/>
</GridColumn>
</div>
{/* KHỐI THÔNG TIN KHÁM MẮT (Chỉ hiển thị khi là khoa mắt) */}
{isEyeClinic && (
<div className="mt-2 rounded-xl border border-blue-100 bg-[#f8fafc] p-5 transition-all">
<h3 className="mb-4 text-sm font-bold uppercase tracking-wider text-blue-600">
Chỉ đnh thị lực (Khoa Mắt)
</h3>
<div className="flex flex-col gap-5">
{/* Radio Chọn Có/Không */}
<div>
<label className="text-sm font-medium text-gray-700">
Bệnh nhân chỉ đnh đeo kính không?
</label>
<div className="mt-2.5 flex items-center gap-8">
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-gray-900">
<input
type="radio"
name="glassRequired"
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
checked={form.glassRequired === true}
onChange={() =>
setForm((prev) => ({ ...prev, glassRequired: true }))
}
/>
chỉ đnh
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-gray-900">
<input
type="radio"
name="glassRequired"
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
checked={form.glassRequired === false}
onChange={() =>
setForm((prev) => ({ ...prev, glassRequired: false }))
}
/>
Không chỉ đnh
</label>
</div>
</div>
{/* TextArea Loại Kính (Trải rộng ra 100% dòng tạo cảm giác thoáng đãng) */}
{form.glassRequired && (
<div className="mt-2 animate-fadeIn ">
<TextArea
name="glassType"
placeholder="Nhập thông số chi tiết loại kính được chỉ định (Cận, viễn, loạn, độ kính...)"
label="Thông số / Loại kính chỉ định"
value={form.glassType}
isRequired
isBlur
max={5000}
/>
</div>
)}
</div>
</div>
)}
{/* BUTTON ACTION */}
<div className="mt-4 flex items-center justify-end gap-3 border-t pt-5">
<CustomButton
type="button"
variant="grey"
rounded="md"
onClick={() => router.back()}
className="min-w-[120px]"
>
Hủy bỏ
</CustomButton>
<CustomButton
type="submit"
variant="midnightBlue"
rounded="md"
disabled={isLoading || !consultationId}
className="min-w-[140px]"
>
{specialtyResultsMutation.isPending
? "Đang cập nhật..."
: "Cập nhật kết quả"}
</CustomButton>
</div>
</div>
</FormCustom>
);
};
export default UpdateSpecialtyClinicId;

View File

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

View File

@ -20,18 +20,16 @@ import CustomButton from "@/components/customs/custom-button";
import StateActive from "@/components/customs/StateActive";
import DataWrapper from "@/components/customs/DataWrapper";
import Table from "@/components/customs/custom-table";
import CustomDialog from "@/components/customs/custom-dialog";
import moment from "moment";
// import CustomDialog from "@/components/customs/custom-dialog";
const DetailPatient = () => {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
// const queryClient = useQueryClient();
const patientId = params?.id as string;
// State quản lý đóng/mở Dialog xác nhận tạo phiên khám
const [openCreateDialog, setOpenCreateDialog] = useState<boolean>(false);
/* =========================
GET DETAIL PATIENT
========================= */
@ -95,54 +93,9 @@ const DetailPatient = () => {
},
});
/* =========================
CREATE MUTATION
========================= */
const createConsultationMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Tạo phiên khám mới thành công!",
// Gọi API với payload chứa patientId của bệnh nhân hiện tại
http: consultationServices.createConsultations({
patientId: patientId,
}),
});
},
onSuccess: (res: any) => {
// Đóng dialog
setOpenCreateDialog(false);
// Làm mới danh sách lịch sử phiên khám của bệnh nhân này
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation, patientId],
});
if (res?.id) {
router.push(`${PATH.CONSULTATION}/${res.id}`); // detail
}
},
});
const patient = patientDetailQuery.data;
const consultationData = consultationQuery.data?.items || [];
/* =========================
HANDLERS DIALOG
========================= */
const handleOpenCreate = () => {
setOpenCreateDialog(true);
};
const handleCloseDialog = () => {
setOpenCreateDialog(false);
};
const handleCreateDialog = () => {
createConsultationMutation.mutate();
};
if (patientDetailQuery.isLoading) {
return (
<div className="flex items-center justify-center p-10 font-medium text-gray-500">
@ -169,8 +122,11 @@ const DetailPatient = () => {
variant="midnightBlue"
fullWidth={false}
icon={<ClipboardPlus size={18} />}
onClick={handleOpenCreate}
disabled={createConsultationMutation.isPending}
onClick={() =>
router.push(
`${PATH.CONSULTATION}/create?patientId=${patient?.id}`,
)
}
>
Tạo phiên khám
</CustomButton>
@ -209,7 +165,9 @@ const DetailPatient = () => {
Ngày sinh
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.dateOfBirth}
{patient?.dateOfBirth
? moment(patient?.dateOfBirth).format("DD/MM/YYYY")
: "---"}
</p>
</div>
@ -289,7 +247,7 @@ const DetailPatient = () => {
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-black">
Lịch sử phiên khám
Lịch sử phiên khám của {patient?.fullName}
</h2>
<Link
href={`/consultation/history?_id=${patientId}`}
@ -308,6 +266,14 @@ const DetailPatient = () => {
rowKey={(item) => item.id}
data={consultationData}
column={[
{
title: "TÊN BỆNH NHÂN",
render: (item: ConsultationItem) => (
<span>{item.patientName}</span>
),
},
{
title: "MÃ PHIÊN KHÁM",
fixedLeft: true,
@ -320,65 +286,120 @@ const DetailPatient = () => {
</Link>
),
},
{
title: "NGÀY KHÁM",
render: (item: ConsultationItem) => (
<span>{item.visitDate}</span>
<span>
{item.visitDate
? moment(item.visitDate).format("DD/MM/YYYY")
: "---"}
</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: "CÁC CHUYÊN KHOA ĐANG CHỜ KHÁM",
render: (item: ConsultationItem) => {
const waitingSpecialties = item.specialtyClinics
?.filter((specialty) => specialty.specialtyStatus === 1)
.map((specialty) => specialty.specialtyName)
.join(", ");
return <span>{waitingSpecialties || "---"}</span>;
},
},
{
title: "CÁC CHUYÊN KHOA ĐÃ KHÁM",
render: (item: ConsultationItem) => {
const completedSpecialties = item.specialtyClinics
?.filter((specialty) => specialty.specialtyStatus === 3)
.map((specialty) => specialty.specialtyName)
.join(", ");
return <span>{completedSpecialties || "---"}</span>;
},
},
{
title: "TRẠNG THÁI",
fixedRight: true,
render: (item: ConsultationItem) => (
<StateActive
stateActive={item.status}
listState={[
{
state: TYPE_STATUS.Draft,
text: "Đang chờ",
textColor: "#000",
backgroundColor: "#FFE4C4",
text: "Đang chờ khám",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
},
{
state: TYPE_STATUS.InProgress,
text: "Đang thực hiện",
textColor: "#000",
backgroundColor: "#ff3300",
text: "Đang khám tổng quát",
textColor: "#1E3A8A",
backgroundColor: "#DBEAFE",
},
{
state: TYPE_STATUS.ToSpecialty,
text: "Chờ khám chuyên khoa",
textColor: "#6B21A8",
backgroundColor: "#E9D5FF",
},
{
state: TYPE_STATUS.PendingPrescription,
text: "Chờ nhận thuốc/kính",
textColor: "#9A3412",
backgroundColor: "#FED7AA",
},
{
state: TYPE_STATUS.Completed,
text: "Hoàn thành",
textColor: "#000",
backgroundColor: "#3d69eb",
textColor: "#166534",
backgroundColor: "#DCFCE7",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#000",
backgroundColor: "#cccc",
textColor: "#991B1B",
backgroundColor: "#FEE2E2",
},
]}
/>
@ -388,20 +409,6 @@ const DetailPatient = () => {
/>
</DataWrapper>
</div>
{/* DIALOG XÁC NHẬN */}
<CustomDialog
open={openCreateDialog}
title="Tạo phiên khám"
note={`Bạn có chắc chắn muốn tạo phiên khám cho bệnh nhân ${patient?.fullName} không?`}
type="successPlay" // Giữ nguyên type của bạn, hoặc đổi thành "warning"/"info" tùy UI component quy định
onClose={handleCloseDialog}
onSubmit={handleCreateDialog}
titleCancel="Đóng"
titleSubmit={
createConsultationMutation.isPending ? "Đang xử lý..." : "Xác nhận"
}
/>
</div>
);
};

View File

@ -221,7 +221,7 @@ const MainPagePatient = () => {
href={`/patient/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.fullName}
{item.fullName || "---"}
</Link>
),
},
@ -230,26 +230,28 @@ const MainPagePatient = () => {
title: "MÃ ĐỊNH DANH",
render: (item: PatientItem) => (
<span>{item.identificationNumber}</span>
<span>{item.identificationNumber || "---"}</span>
),
},
{
title: "SỐ ĐIỆN THOẠI",
render: (item: PatientItem) => <span>{item.phone}</span>,
render: (item: PatientItem) => <span>{item.phone || "---"}</span>,
},
{
title: "EMAIL",
render: (item: PatientItem) => <span>{item.email}</span>,
render: (item: PatientItem) => <span>{item.email || "---"}</span>,
},
{
title: "GIỚI TÍNH",
render: (item: PatientItem) => <span>{item.gender}</span>,
render: (item: PatientItem) => (
<span>{item.gender || "---"}</span>
),
},
{

View File

@ -286,7 +286,7 @@ const PopupUpdatePatient = ({ onClose, id }: PopupUpdatePatientProps) => {
}
placeholder="Số điện thoại"
name="phone"
type="number"
type="phone"
isPhone
value={form.phone}
isRequired
@ -324,7 +324,7 @@ const PopupUpdatePatient = ({ onClose, id }: PopupUpdatePatientProps) => {
label="SĐT khẩn cấp"
placeholder="SĐT khẩn cấp"
name="emergencyContactPhone"
type="number"
type="phone"
isRequired
isPhone
value={form.emergencyContactPhone}

View File

@ -1,44 +1,26 @@
"use client";
import React, { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } 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 UploadImage from "@/components/utils/UploadImage";
import { httpRequest } from "@/services";
import prescriptionServices from "@/services/prescriptionServices";
import consultationServices, {
ConsultationItem,
ConsultationResponse,
} from "@/services/consultationServices";
import { QUERY_KEY, TYPE_STATUS } from "@/constant/config/enum";
import { QUERY_KEY } from "@/constant/config/enum";
import { toastWarn } from "@/common/funcs/toast";
import { PATH } from "@/constant/config";
/* =========================
TYPES
========================= */
interface PrescriptionItem {
medicineName: string;
dosage: string;
@ -48,15 +30,15 @@ interface PrescriptionItem {
}
interface PrescriptionForm {
examinationSessionId: string;
examinationSessionSpecialtyId: string;
notes: string;
prescriptionImg: string;
items: PrescriptionItem[];
}
/* =========================
DEFAULT
========================= */
const defaultMedicine: PrescriptionItem = {
medicineName: "",
dosage: "",
@ -68,29 +50,28 @@ const defaultMedicine: PrescriptionItem = {
/* =========================
COMPONENT
========================= */
const CreatePrescription = () => {
const router = useRouter();
const queryClient = useQueryClient();
const [loading, setLoading] = useState<boolean>(false);
const queryKeys = [QUERY_KEY.table_list_consultation];
const [file, setFile] = useState<File | null>(null);
const [form, setForm] = useState<PrescriptionForm>({
examinationSessionId: "",
examinationSessionSpecialtyId: "",
notes: "",
prescriptionImg: "",
items: [defaultMedicine],
});
/* =========================
GET EXAMINATION SESSION
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,
@ -112,92 +93,82 @@ const CreatePrescription = () => {
});
const examinationSessionOptions =
examinationSessionQuery.data?.items?.filter(
(item) =>
item.status === TYPE_STATUS.Draft ||
item.status === TYPE_STATUS.InProgress,
) || [];
examinationSessionQuery.data?.items
?.filter((session) => {
const isWaitingPrescription = session.status === 4;
const allSpecialtyCompleted = session.specialtyClinics?.every(
(specialty) => specialty.specialtyStatus === 3,
);
return isWaitingPrescription && allSpecialtyCompleted;
})
.flatMap((session) =>
session.specialtyClinics
.filter(
(specialty) =>
specialty.specialtyStatus === 3 &&
specialty.sharedItems.length === 0,
)
.map((specialty) => ({
id: specialty.id,
specialtyName: specialty.specialtyName,
sessionCode: session.sessionCode,
patientName: session.patientName,
})),
) || [];
/* =========================
CREATE MUTATION
CREATE MUTATION
========================= */
const funcCreatePrescription = useMutation({
mutationFn: (payload: any) => {
console.log("=== GỬI PAYLOAD LÊN API TẠO ĐƠN THUỐC ===", payload);
const createPrescriptionMutation = useMutation({
mutationFn: async () => {
/**
* CREATE FORMDATA
*/
const formData = new FormData();
/**
* BASIC INFO
*/
formData.append("ExaminationSessionId", form.examinationSessionId);
formData.append("Notes", form.notes);
/**
* FILE
*/
if (file) {
formData.append("PrescriptionFile", file);
}
/**
* ITEMS
* BACKEND EXPECT:
* Items = JSON.stringify([...])
*/
const validItems = form.items.filter((x) => x.medicineName.trim() !== "");
formData.append("Items", JSON.stringify(validItems));
/**
* API
*/
return await httpRequest({
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Tạo đơn thuốc thành công!",
http: prescriptionServices.createPrescription(formData),
http: prescriptionServices.createPrescription(payload),
});
},
onSuccess: () => {
router.push(PATH.PRESCRIPTION);
},
onSuccess(data) {
console.log("TẠO ĐƠN THUỐC THÀNH CÔNG:", data);
onError: (error: any) => {
const message =
error?.response?.data?.message || error?.message || "Có lỗi xảy ra";
toastWarn({
msg: message,
queryKeys.forEach((key) => {
queryClient.invalidateQueries({
queryKey: [key],
});
});
console.log("CREATE PRESCRIPTION ERROR:", error);
setForm({
examinationSessionSpecialtyId: "",
notes: "",
prescriptionImg: "",
items: [defaultMedicine],
});
setFile(null);
// Điều hướng quay lại danh sách
router.back();
},
onError(error: any) {
console.error("LỖI HỆ THỐNG API TẠO ĐƠN THUỐC:", error);
},
});
/* =========================
CHANGE MEDICINE FIELD
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,
@ -205,33 +176,20 @@ const CreatePrescription = () => {
};
/* =========================
ADD MEDICINE
ADD MEDICINE
========================= */
const handleAddMedicine = () => {
setForm((prev) => ({
...prev,
items: [
...prev.items,
{
medicineName: "",
dosage: "",
frequency: "",
duration: "",
instructions: "",
},
],
items: [...prev.items, { ...defaultMedicine }],
}));
};
/* =========================
REMOVE MEDICINE
REMOVE MEDICINE
========================= */
const handleRemoveMedicine = (index: number) => {
const newItems = form.items.filter((_, i) => i !== index);
setForm((prev) => ({
...prev,
items: newItems,
@ -239,44 +197,103 @@ const CreatePrescription = () => {
};
/* =========================
SUBMIT
SUBMIT VALIDATION
========================= */
const handleSubmit = async () => {
try {
// Kiểm tra dữ liệu đầu vào phía client
if (!form.examinationSessionSpecialtyId) {
return toastWarn({
msg: "Vui lòng chọn phiên khám chuyên khoa",
});
}
const handleSubmit = () => {
/**
* VALIDATE
*/
if (!file) {
return toastWarn({
msg: "Vui lòng chọn ảnh đơn thuốc",
});
}
if (!form.examinationSessionId) {
return toastWarn({
msg: "Vui lòng chọn phiên khám",
const validMedicineList = form.items.filter(
(item) => item.medicineName && item.medicineName.trim() !== "",
);
if (validMedicineList.length === 0) {
return toastWarn({
msg: "Vui lòng nhập ít nhất 1 loại thuốc hợp lệ",
});
}
// 1. Thực hiện gọi API upload ảnh
setLoading(true);
const uploadResponse: any = await prescriptionServices.uploadImage(
file,
"prescriptions",
);
console.log("KẾT QUẢ PHẢN HỒI GỐC TỪ API UPLOAD:", uploadResponse);
// Trích xuất URL linh hoạt tùy thuộc vào bộ bọc interceptor của axiosClient
let imageUrl = "";
// Trường hợp 1: axiosClient trả về thẳng dữ liệu (hoặc chuỗi URL) từ server
if (typeof uploadResponse === "string") {
imageUrl = uploadResponse;
} else if (uploadResponse?.fileUrl) {
imageUrl = uploadResponse.fileUrl;
} else if (uploadResponse?.url) {
imageUrl = uploadResponse.url;
}
// Trường hợp 2: axiosClient trả về nguyên bản cấu trúc AxiosResponse chứa thuộc tính .data
else if (uploadResponse?.data) {
const innerData = uploadResponse.data;
if (typeof innerData === "string") {
imageUrl = innerData;
} else {
imageUrl =
innerData?.fileUrl ||
innerData?.url ||
innerData?.data?.fileUrl ||
innerData?.data?.url ||
"";
}
}
console.log("ĐƯỜNG DẪN ẢNH CUỐI CÙNG TRÍCH XUẤT ĐƯỢC:", imageUrl);
if (!imageUrl) {
setLoading(false);
return toastWarn({
msg: "Không tìm thấy đường dẫn ảnh từ phản hồi của máy chủ",
});
}
// 2. Thiết lập cấu trúc dữ liệu Payload chuẩn xác theo interface của createPrescription
const finalPayload = {
examinationSessionSpecialtyId: form.examinationSessionSpecialtyId,
notes: form.notes || "",
prescriptionImg: imageUrl,
items: validMedicineList.map((item) => ({
medicineName: item.medicineName.trim(),
dosage: item.dosage || "",
frequency: item.frequency || "",
duration: item.duration || "",
instructions: item.instructions || "",
})),
};
// 3. Kích hoạt gọi Mutation gửi yêu cầu tạo đơn thuốc mới lên Server
funcCreatePrescription.mutate(finalPayload);
} catch (error) {
console.error("LỖI XỬ LÝ SUBMIT HOẶC UPLOAD ĐƠN THUỐC:", error);
toastWarn({
msg: "Có lỗi xảy ra trong quá trình xử lý tải ảnh lên hoặc lưu đơn thuốc",
});
} finally {
setLoading(false);
}
if (!file) {
return toastWarn({
msg: "Vui lòng chọn ảnh đơn thuốc",
});
}
const validMedicine = form.items.some((x) => x.medicineName.trim() !== "");
if (!validMedicine) {
return toastWarn({
msg: "Vui lòng nhập ít nhất 1 loại thuốc",
});
}
/**
* MUTATE
*/
createPrescriptionMutation.mutate();
};
/* =========================
UI
========================= */
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="flex flex-col gap-6 rounded-2xl bg-white p-6 shadow">
@ -286,7 +303,6 @@ const CreatePrescription = () => {
<h2 className="text-[28px] font-semibold text-[#111827]">
Tạo đơ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>
@ -296,11 +312,11 @@ const CreatePrescription = () => {
<CustomButton
type="submit"
variant="midnightBlue"
disabled={createPrescriptionMutation.isPending}
disabled={funcCreatePrescription.isPending || loading}
className="min-w-[180px]"
>
{createPrescriptionMutation.isPending
? "Đang tạo..."
{funcCreatePrescription.isPending || loading
? "Đang tạo đơn..."
: "Tạo đơn thuốc"}
</CustomButton>
</div>
@ -317,30 +333,32 @@ const CreatePrescription = () => {
<SelectForm
label={
<span className="text-[14px] font-medium text-[#374151]">
Phiên khám
Phiên khám chuyên khoa
<span className="text-red-500">*</span>
</span>
}
placeholder="Chọn phiên khám"
value={form.examinationSessionId}
placeholder="Chọn phiên khám chuyên khoa"
value={form.examinationSessionSpecialtyId}
options={examinationSessionOptions}
onSelect={(item: ConsultationItem) =>
onSelect={(item: any) =>
setForm((prev) => ({
...prev,
examinationSessionId: item.id,
examinationSessionSpecialtyId: item.id,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
examinationSessionId: "",
examinationSessionSpecialtyId: "",
}))
}
getOptionLabel={(item: ConsultationItem) => `${item.sessionCode}`}
getOptionValue={(item: ConsultationItem) => item.id}
getOptionLabel={(item: any) =>
`${item.sessionCode} - ${item.specialtyName} (${item.patientName})`
}
getOptionValue={(item: any) => item.id}
/>
{/* IMAGE */}
{/* IMAGE UPLOAD */}
<div className="flex flex-col gap-2">
<UploadImage
label={
@ -348,10 +366,10 @@ const CreatePrescription = () => {
Hình nh đơn thuốc <span style={{ color: "red" }}>*</span>
</span>
}
name="PrescriptionFile"
name="prescriptionImg"
file={file}
setFile={setFile}
path=""
path={""}
/>
</div>

View File

@ -78,7 +78,7 @@ const DetailPrescription = () => {
{data.prescriptionImg ? (
<>
<Image
src={`${process.env.NEXT_PUBLIC_IMAGE}${data.prescriptionImg}`}
src={`${data.prescriptionImg}`}
alt="prescription"
width={140}
height={140}

View File

@ -142,7 +142,8 @@ const MainPrescription = () => {
render: (item: any) =>
item.prescriptionImg ? (
<Image
src={`${process.env.NEXT_PUBLIC_IMAGE}${item.prescriptionImg}`}
// src={`${process.env.NEXT_PUBLIC_IMAGE}${item.prescriptionImg}`}
src={item.prescriptionImg}
alt="prescription"
width={40}
height={40}

View File

@ -0,0 +1,107 @@
"use client";
import Table from "@/components/customs/custom-table";
import DataWrapper from "@/components/customs/DataWrapper";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import consultationServices, {
ReportResponse,
} from "@/services/consultationServices";
import { useQuery } from "@tanstack/react-query";
import moment from "moment";
// import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useMemo } from "react";
const MainPageStatistical = () => {
// const router = useRouter();
// const pathname = usePathname();
// const searchParams = useSearchParams();
// const _page = searchParams.get("_page");
// const _pageSize = searchParams.get("_pageSize");
const reportQuery = useQuery<ReportResponse>({
queryKey: [QUERY_KEY.table_list_report],
queryFn: async () => {
const toDate = moment().format("YYYY-MM-DD");
const fromDate = moment().subtract(14, "days").format("YYYY-MM-DD");
const res = await httpRequest<ReportResponse>({
showMessageFailed: true,
http: consultationServices.getConsultationsReport({
FormDate: fromDate,
ToDate: toDate,
}),
});
return res as ReportResponse;
},
});
const reportData = useMemo(() => {
return reportQuery.data?.specialtyDetails || [];
}, [reportQuery.data]);
// const page = _page ? Number(_page) : 1;
// const pageSize = _pageSize ? Number(_pageSize) : 10;
// 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">
Thống các phiên khám
</h2>
</div>
<DataWrapper
data={reportData}
loading={reportQuery.isLoading}
title="Không có dữ liệu"
note="Vui lòng thử lại sau"
>
<Table
rowKey={(item) => item.specialtyId}
data={reportData}
column={[
{
title: "MÃ CHUYÊN KHOA",
render: (item) => <span>{item.specialtyId}</span>,
},
{
title: "TÊN CHUYÊN KHOA",
render: (item) => <span>{item.specialtyName}</span>,
},
{
title: "SỐ CA KHÁM",
render: (item) => <span>{item.totalCases}</span>,
},
]}
/>
</DataWrapper>
{/* <Pagination
total={reportData.length}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery("_page", value)}
onSetPageSize={(value) => updateQuery("_pageSize", value)}
dependencies={[_pageSize]}
/> */}
</div>
);
};
export default MainPageStatistical;

View File

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

View File

@ -5,7 +5,7 @@ export interface PropsSelectForm<OptionType> {
isSearch?: boolean;
readOnly?: boolean;
value: string | number;
value: string | number | React.ReactNode;
options: OptionType[];
onClean?: () => void;

View File

@ -2,17 +2,29 @@ export enum QUERY_KEY {
table_list_patient,
table_list_consultation,
table_list_patientrescription,
table_list_specialty,
table_list_report,
list_specialty_lookup,
chi_tiet_benh_nhan,
chi_tiet_don_thuoc,
chi_tiet_phien_kham,
user_me,
}
// Draft = 1,
// InProgress = 2,
// Completed = 3,
// Cancelled = 4,
export enum TYPE_STATUS {
Draft = 1,
InProgress = 2,
Completed = 3,
Cancelled = 4,
Draft = 1, // Phiên tạm (mới tiếp nhận, chưa khám)
InProgress = 2, // Đang khám tổng quát
ToSpecialty = 3, // Đã chỉ định & Đang chờ khám chuyên khoa 💡 (MỚI)
PendingPrescription = 4, // Đã khám xong hết, Chờ quầy dược phát thuốc/kính 💡 (MỚI)
Completed = 5, // Đã hoàn thành hoàn toàn (Đã khám + Đã nhận thuốc)
Cancelled = 6, // Phiên khám bị hủy
}
export enum TYPE_GENDER {

View File

@ -1,4 +1,10 @@
import { CircleGauge, HeartPulse, Pill, Users } from "lucide-react";
import {
ChartNoAxesCombined,
CircleGauge,
HeartPulse,
Pill,
Users,
} from "lucide-react";
export enum PATH {
HOME = "/",
@ -8,6 +14,8 @@ export enum PATH {
PRESCRIPTION = "/prescription",
CREATE_PRESCRIPTION = "/prescription/create",
STATISTICAL = "/statistical",
LOGIN = "/auth/login",
REGISTER = "/auth/register",
FORGOT_PASSWORD = "/auth/forgot-password",
@ -51,6 +59,12 @@ export const Menus: {
path: PATH.PRESCRIPTION,
pathActive: PATH.PRESCRIPTION,
},
{
title: "Thống kê chuyên khoa",
icon: ChartNoAxesCombined,
path: PATH.STATISTICAL,
pathActive: PATH.STATISTICAL,
},
],
},
];

View File

@ -9,7 +9,7 @@ const authServices = {
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Auth/login`, data, {
return axiosClient.post(`/api/v1/Auth/login`, data, {
cancelToken: tokenAxios,
});
},
@ -22,7 +22,7 @@ const authServices = {
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Auth/register`, data, {
return axiosClient.post(`/api/v1/Auth/register`, data, {
cancelToken: tokenAxios,
});
},
@ -32,13 +32,13 @@ const authServices = {
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Auth/refresh`, data, {
return axiosClient.post(`/api/v1/Auth/refresh`, data, {
cancelToken: tokenAxios,
});
},
logout: (tokenAxios?: any) => {
return axiosClient.post(
`/Auth/logout`,
`/api/v1/Auth/logout`,
{},
{
cancelToken: tokenAxios,

View File

@ -24,6 +24,20 @@ export interface ConsultationItem {
doctorNotes: string | null;
createdBy: string;
isDeleted: boolean;
specialtyClinics: {
id: string;
specialtyId: string;
specialtyName: string;
specialtyNotes: string | null;
diagnosis: string | null;
procedureNotes: string | null;
glassRequired: string | null;
glassType: string | null;
examinerName: string;
specialtyStatus: number;
sharedItems: any[];
}[];
aggregatedPrescriptionItems: any[];
}
export interface ConsultationDetail {
@ -39,8 +53,23 @@ export interface ConsultationDetail {
diagnosis: string | null;
treatmentPlan: string | null;
doctorNotes: string | null;
isGlassesDelivered: boolean;
createdBy: string;
isDeleted: boolean;
specialtyClinics: {
id: string;
specialtyId: string;
specialtyName: string;
specialtyNotes: string | null;
diagnosis: string | null;
procedureNotes: string | null;
glassRequired: boolean | null;
glassType: string | null;
examinerName: string;
specialtyStatus: number;
sharedItems: any[];
}[];
aggregatedPrescriptionItems: any[];
}
export interface ConsultationResponse {
@ -51,23 +80,63 @@ export interface ConsultationResponse {
totalPages: number;
}
export interface ReportResponse {
fromDate: string;
toDate: string;
totalSessions: number;
totalSpecialtySubSessions: number;
specialtyDetails: {
specialtyId: string;
specialtyName: string;
totalCases: number;
}[];
totalPrescriptionsIssued: number;
}
const consultationServices = {
getConsultations: (params?: GetConsultationParams, tokenAxios?: any) => {
return axiosClient.get<ConsultationResponse>(`/ExaminationSession/list`, {
params,
cancelToken: tokenAxios,
});
return axiosClient.get<ConsultationResponse>(
`/api/v1/ExaminationSession/list`,
{
params,
cancelToken: tokenAxios,
},
);
},
createConsultations: (data?: { patientId: string }, tokenAxios?: any) => {
return axiosClient.post(`/ExaminationSession`, data, {
getConsultationsReport: (
params: { FormDate: string; ToDate: string },
tokenAxios?: any,
) => {
return axiosClient.get<ReportResponse>(
`/api/v1/ExaminationSession/report`,
{
params,
cancelToken: tokenAxios,
},
);
},
createConsultations: (
data?: {
patientId: string;
chiefComplaint: string;
symptomsText: string;
vitalSigns: string;
specialtyIds: string[];
},
tokenAxios?: any,
) => {
return axiosClient.post(`/api/v1/ExaminationSession`, data, {
cancelToken: tokenAxios,
});
},
detailConsultations: (id: string, tokenAxios?: any) => {
return axiosClient.get<ConsultationDetail>(`/ExaminationSession/${id}`, {
cancelToken: tokenAxios,
});
return axiosClient.get<ConsultationDetail>(
`/api/v1/ExaminationSession/${id}`,
{
cancelToken: tokenAxios,
},
);
},
putConsultations: (
@ -79,25 +148,46 @@ const consultationServices = {
diagnosis: string | null;
treatmentPlan: string | null;
doctorNotes: string | null;
isGlassesDelivered: boolean;
},
tokenAxios?: any,
) => {
return axiosClient.put(`/ExaminationSession/${id}`, data, {
return axiosClient.put(`/api/v1/ExaminationSession/${id}`, data, {
cancelToken: tokenAxios,
});
},
cancelConsultations: (id: string, tokenAxios?: any) => {
return axiosClient.post(`/ExaminationSession/${id}/cancel`, {
return axiosClient.post(`/api/v1/ExaminationSession/${id}/cancel`, {
cancelToken: tokenAxios,
});
},
completeConsultations: (id: string, tokenAxios?: any) => {
return axiosClient.post(`/ExaminationSession/${id}/complete`, {
return axiosClient.post(`/api/v1/ExaminationSession/${id}/complete`, {
cancelToken: tokenAxios,
});
},
specialtyResults: (
specialtyAssignmentId: string,
data: {
specialtyNotes: string;
diagnosis: string;
procedureNotes: string;
glassRequired: boolean;
glassType: string;
},
tokenAxios?: any,
) => {
return axiosClient.put(
`/api/v1/ExaminationSession/specialty-results/${specialtyAssignmentId}`,
data,
{
cancelToken: tokenAxios,
},
);
},
};
export default consultationServices;

View File

@ -256,6 +256,7 @@ axiosClient.interceptors.response.use(
if ((status === 401 || status === 403) && !data) {
return Promise.reject({
status,
success: false,
error: {
@ -265,15 +266,36 @@ axiosClient.interceptors.response.use(
});
}
return Promise.reject(
data || {
// If server provided a body (data), forward it and attach HTTP status so
// callers receive the actual error and the numeric status code.
const serverBody = error.response?.data;
let payload: any;
if (serverBody && typeof serverBody === "object") {
payload = { ...serverBody, status };
} else if (serverBody) {
payload = {
status,
success: false,
error: {
code: "SERVER_ERROR",
message: "Có lỗi xảy ra",
code: status ?? "SERVER_ERROR",
message: String(serverBody),
},
},
);
};
} else {
payload = {
status,
success: false,
error: {
code: status ?? "SERVER_ERROR",
message:
error.response?.statusText || error.message || "Có lỗi xảy ra",
},
};
}
return Promise.reject(payload);
},
);
@ -288,7 +310,7 @@ export const httpRequest = async <T>({
setLoading,
msgSuccess = "Thành công",
showMessageSuccess = false,
showMessageFailed = false,
showMessageFailed = true,
onError,
}: HttpRequestOptions<T>): Promise<T> => {
try {
@ -308,16 +330,20 @@ export const httpRequest = async <T>({
},
} as ApiResponse<T>);
// ================= SUCCESS =================
/* ================= SUCCESS ================= */
if (safeRes.success) {
if (showMessageSuccess) {
toastSuccess({ msg: msgSuccess });
toastSuccess({
msg: msgSuccess,
});
}
return safeRes.data as T;
}
// ================= FAIL =================
/* ================= FAIL ================= */
const error = {
message: safeRes.error?.message || "Có lỗi xảy ra",
code: safeRes.error?.code,
@ -325,9 +351,20 @@ export const httpRequest = async <T>({
onError?.();
throw error; // 👈 QUAN TRỌNG
} catch (error) {
throw error; // 👈 QUAN TRỌNG: KHÔNG NUỐT
throw error;
} catch (error: any) {
console.log("HTTP ERROR:", error);
const message = error?.error?.message || error?.message || "Có lỗi xảy ra";
/* SHOW TOAST */
if (showMessageFailed) {
toastWarn({
msg: message,
});
}
throw error;
} finally {
setLoading?.(false);
}

View File

@ -57,7 +57,7 @@ export interface PatientResponse {
const patientServices = {
getPatient: (params?: GetPatientParams, tokenAxios?: any) => {
return axiosClient.get<PatientResponse>(`/Patient/list`, {
return axiosClient.get<PatientResponse>(`/api/v1/Patient/list`, {
params,
cancelToken: tokenAxios,
});
@ -75,7 +75,7 @@ const patientServices = {
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Patient`, data, {
return axiosClient.post(`/api/v1/Patient`, data, {
cancelToken: tokenAxios,
});
},
@ -97,19 +97,19 @@ const patientServices = {
},
tokenAxios?: any,
) => {
return axiosClient.put(`/Patient/${id}`, data, {
return axiosClient.put(`/api/v1/Patient/${id}`, data, {
cancelToken: tokenAxios,
});
},
detailPatient: (id: string, tokenAxios?: any) => {
return axiosClient.get<PatientDetail>(`/Patient/${id}`, {
return axiosClient.get<PatientDetail>(`/api/v1/Patient/${id}`, {
cancelToken: tokenAxios,
});
},
deletePatient: (id: string, tokenAxios?: any) => {
return axiosClient.delete(`/Patient/${id}`, {
return axiosClient.delete(`/api/v1/Patient/${id}`, {
cancelToken: tokenAxios,
});
},

View File

@ -56,12 +56,22 @@ const prescriptionServices = {
/**
* CREATE
*/
createPrescription: (data: FormData, tokenAxios?: any) => {
return axiosClient.post(`/Prescription`, data, {
headers: {
"Content-Type": "multipart/form-data",
},
createPrescription: (
data: {
examinationSessionSpecialtyId: string;
notes: string;
prescriptionImg: string;
items: {
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
},
tokenAxios?: any,
) => {
return axiosClient.post(`/api/v1/Prescription`, data, {
cancelToken: tokenAxios,
});
},
@ -70,7 +80,7 @@ const prescriptionServices = {
* GET LIST
*/
getPrescription: (params?: GetPrescriptionParams, tokenAxios?: any) => {
return axiosClient.get<PrescriptionResponse>(`/Prescription`, {
return axiosClient.get<PrescriptionResponse>(`/api/v1/Prescription`, {
params,
cancelToken: tokenAxios,
@ -81,7 +91,7 @@ const prescriptionServices = {
* UPDATE
*/
putPrescription: (id: string, data: FormData, tokenAxios?: any) => {
return axiosClient.put(`/Prescription/${id}`, data, {
return axiosClient.put(`/api/v1/Prescription/${id}`, data, {
headers: {
"Content-Type": "multipart/form-data",
},
@ -94,7 +104,7 @@ const prescriptionServices = {
* DETAIL
*/
detailPrescription: (id: string, tokenAxios?: any) => {
return axiosClient.get<PrescriptionDetail>(`/Prescription/${id}`, {
return axiosClient.get<PrescriptionDetail>(`/api/v1/Prescription/${id}`, {
cancelToken: tokenAxios,
});
},
@ -102,8 +112,22 @@ const prescriptionServices = {
/**
* UPLOAD IMAGE
*/
uploadImage: (body: FormData) => {
return axiosClient.post("/upload", body, {
/**
* UPLOAD IMAGE
* @param file Đi tượng File lấy từ thẻ input hoặc upload component
* @param folderName Tên thư mục lưu trữ (Không bắt buộc)
*/
uploadImage: (file: File, folderName?: string) => {
const formData = new FormData();
// Key phải viết hoa chữ cái đầu đúng như Swagger yêu cầu (File và FolderName)
formData.append("File", file);
if (folderName) {
formData.append("FolderName", folderName);
}
return axiosClient.post("/api/Media/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},

View File

@ -0,0 +1,16 @@
import axiosClient from ".";
export interface SpecialtyItem {
id: string;
name: string;
}
const specialtyServices = {
getSpecialty: (tokenAxios?: any) => {
return axiosClient.get<SpecialtyItem[]>("/api/v1/Specialty/lookup", {
cancelToken: tokenAxios,
});
},
};
export default specialtyServices;

View File

@ -10,9 +10,20 @@ interface GetUserParams {
Desc?: boolean;
}
export interface UserMe {
id: string;
username: string;
role: number;
roleType: number | string | null;
isActive: boolean;
fullName: string | null;
email: string | null;
phone: string | number | null;
}
const userServices = {
getUser: (params?: GetUserParams, tokenAxios?: any) => {
return axiosClient.get(`/User/list`, {
return axiosClient.get(`/api/v1/User/list`, {
params,
cancelToken: tokenAxios,
});
@ -29,7 +40,7 @@ const userServices = {
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Users/`, data, {
return axiosClient.post(`/api/v1/Users/`, data, {
cancelToken: tokenAxios,
});
},
@ -44,22 +55,22 @@ const userServices = {
},
tokenAxios?: any,
) => {
return axiosClient.put(`/Users/${id}`, data, {
return axiosClient.put(`/api/v1/Users/${id}`, data, {
cancelToken: tokenAxios,
});
},
deleteUser: (id: string, tokenAxios?: any) => {
return axiosClient.delete(`/Users/${id}`, {
return axiosClient.delete(`/api/v1/Users/${id}`, {
cancelToken: tokenAxios,
});
},
detailUser: (id: string, tokenAxios?: any) => {
return axiosClient.get(`/Users/${id}`, {
return axiosClient.get(`/api/v1/Users/${id}`, {
cancelToken: tokenAxios,
});
},
meUser: (tokenAxios?: any) => {
return axiosClient.get(`/Users/me`, {
return axiosClient.get<UserMe>(`/api/v1/Users/me`, {
cancelToken: tokenAxios,
});
},