feat:update all

This commit is contained in:
TuanVT
2026-06-03 16:42:07 +07:00
parent 62ca6dd0f9
commit 77dd05932e
36 changed files with 3098 additions and 693 deletions
+164
View File
@@ -0,0 +1,164 @@
"use client";
import React, { useState } from "react";
import clsx from "clsx";
import * as Popover from "@radix-ui/react-popover";
import Moment from "react-moment";
import { CalendarDays, CircleX } from "lucide-react";
import RangeDatePicker from "../utils/FilterDateRage/components/RangeDatePicker";
interface DateRangeValue {
from: Date | null;
to: Date | null;
}
interface PropsDateRangeField {
name: string;
label?: string | React.ReactNode;
placeholder?: string;
value: DateRangeValue;
required?: boolean;
readOnly?: boolean;
note?: string;
onChange: (value: DateRangeValue) => void;
onClean?: () => void;
}
function DateRangeField({
name,
label,
placeholder = "Chọn khoảng thời gian",
value,
required,
readOnly,
note,
onChange,
onClean,
}: PropsDateRangeField) {
const [open, setOpen] = useState(false);
const [isFocus, setIsFocus] = useState(false);
return (
<div className="w-full">
{/* LABEL */}
{label && (
<label className="block mb-2 text-[16px] font-medium">{label}</label>
)}
<Popover.Root
open={open}
onOpenChange={(value) => {
if (readOnly) return;
setOpen(value);
setIsFocus(value);
}}
>
<Popover.Trigger asChild>
<button
type="button"
disabled={readOnly}
className={clsx(
"flex items-center justify-between",
"h-12 w-full px-4",
"border rounded-full transition",
"bg-white border-[#cdd5df]",
"hover:border-blue-600",
isFocus && "border-blue-600",
readOnly &&
"bg-gray-100 border-gray-200 cursor-not-allowed opacity-70",
)}
>
{/* VALUE */}
<div className="flex-1 text-left">
{value?.from && value?.to ? (
<p className="text-[16px] font-medium text-[#23262f]">
<Moment format="DD/MM/YYYY">{value.from}</Moment>
{" - "}
<Moment format="DD/MM/YYYY">{value.to}</Moment>
</p>
) : (
<p className="text-[16px] font-medium text-gray-400">
{placeholder}
</p>
)}
</div>
{/* ICON */}
<div className="flex items-center gap-2">
{(value?.from || value?.to) && onClean && (
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onClean();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClean();
}
}}
className="
flex
h-7
w-7
cursor-pointer
items-center
justify-center
rounded-full
text-gray-400
transition
hover:bg-gray-100
"
>
<CircleX size={16} />
</div>
)}
<div
className={clsx(
"transition",
isFocus ? "text-blue-600" : "text-gray-400",
)}
>
<CalendarDays size={20} />
</div>
</div>
</button>
</Popover.Trigger>
{/* POPUP */}
<Popover.Content align="start" sideOffset={8} className="z-50">
<RangeDatePicker
value={value}
open={open}
onSetValue={(data) => {
onChange(data);
}}
onClose={() => {
setOpen(false);
setIsFocus(false);
}}
/>
</Popover.Content>
</Popover.Root>
{/* NOTE */}
{note && <p className="mt-1 text-xs font-medium text-gray-500">{note}</p>}
</div>
);
}
export default DateRangeField;
+1 -1
View File
@@ -81,7 +81,7 @@ function FilterCustom<T extends string | number | null>({
"border border-[#e1e5ed] bg-white",
"transition-all",
"hover:border-[#0019f6]",
styleRounded ? "rounded-full" : "rounded-md",
styleRounded ? "rounded-full" : "rounded-full",
open && "border-[#0019f6]",
)}
>
+1 -1
View File
@@ -11,7 +11,7 @@ interface StateItem {
interface PropsStateActive {
isBox?: boolean;
stateActive: number | string;
stateActive: number | string | undefined;
listState: StateItem[];
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import React from "react";
import clsx from "clsx";
import { useRouter, useSearchParams } from "next/navigation";
interface PropsTabNavLink {
query: string;
listHref: {
title: string;
pathname: string;
query: string | null;
}[];
}
export default function TabNavLink({ query, listHref }: PropsTabNavLink) {
const router = useRouter();
const searchParams = useSearchParams();
const currentValue = searchParams.get(query);
const handleActive = (value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value === null) {
params.delete(query);
} else {
params.set(query, value);
}
router.replace(`?${params.toString()}`, { scroll: false });
};
return (
<div className="overflow-x-auto whitespace-nowrap pb-1 scrollbar-thin">
{listHref.map((item, i) => {
const isActive =
currentValue === item.query ||
(!currentValue && item.query === null && i === 0);
return (
<button
key={i}
type="button"
onClick={() => handleActive(item.query)}
className={clsx(
"inline-flex items-center justify-center",
"min-w-[60px] px-6 py-2.5 rounded-full border",
"select-none cursor-pointer transition",
"text-[13px] sm:text-[14px] md:text-[16px]",
i !== 0 && "ml-2",
isActive
? "bg-[#0019F6] text-white border-[#0019F6]"
: "bg-white text-[#202939] border-[#EAEDF2] hover:border-[#0019F6]",
)}
>
{item.title}
</button>
);
})}
</div>
);
}
@@ -4,8 +4,6 @@ import React, { useCallback } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { Menus, PATH } from "@/constant/config";
import Image from "next/image";
import icons from "@/constant/images/icons";
import clsx from "clsx";
import { Hospital } from "lucide-react";
@@ -14,6 +12,11 @@ const Navbar = () => {
const checkActive = useCallback(
(pathActive: string) => {
// Handle root path "/" as dashboard
if (pathname === "/" && pathActive === "/dashboard") {
return true;
}
const currentRoute = pathname.split("/")[1];
return pathActive === `/${currentRoute}`;
},
@@ -1,8 +1,340 @@
"use client";
import React from "react";
import React, { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import moment from "moment";
import {
Activity,
CalendarDays,
ClipboardList,
Hospital,
Stethoscope,
Users,
} from "lucide-react";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import consultationServices, {
ConsultationItem,
ConsultationResponse,
ReportResponse,
} from "@/services/consultationServices";
import specialtyServices, { SpecialtyItem } from "@/services/specialtyServices";
import DataWrapper from "@/components/customs/DataWrapper";
const MainPageHome = () => {
return <div>MainPageHome</div>;
const today = moment().format("YYYY-MM-DD");
const consultationQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation, "dashboard"],
queryFn: async () => {
const res = await httpRequest<ConsultationResponse>({
showMessageFailed: true,
http: consultationServices.getConsultations({
Page: 1,
PageSize: 100,
SortBy: "visitDate",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 100,
total: 0,
totalPages: 0,
}
);
},
});
const reportQuery = useQuery<ReportResponse>({
queryKey: [QUERY_KEY.table_list_report, today],
queryFn: async () => {
const res = await httpRequest<ReportResponse>({
showMessageFailed: true,
http: consultationServices.getConsultationsReport({
FormDate: today,
ToDate: today,
}),
});
return res as ReportResponse;
},
});
const specialtyQuery = useQuery<SpecialtyItem[]>({
queryKey: [QUERY_KEY.list_specialty_lookup],
queryFn: async () => {
const res = await httpRequest<SpecialtyItem[]>({
showMessageFailed: true,
http: specialtyServices.getSpecialty(),
});
return res || [];
},
});
const consultationItems = consultationQuery.data?.items || [];
const consultationReport = reportQuery.data;
const specialtyItems = specialtyQuery.data || [];
const todayConsultations = useMemo(() => {
return consultationItems.filter((item) => {
if (!item.visitDate) return false;
return moment(item.visitDate).format("YYYY-MM-DD") === today;
});
}, [consultationItems, today]);
const statusCounts = useMemo(() => {
return {
total: todayConsultations.length,
waiting: todayConsultations.filter((item) => item.status === 1).length,
inProgress: todayConsultations.filter((item) => item.status === 2).length,
completed: todayConsultations.filter((item) => item.status === 5).length,
cancelled: todayConsultations.filter((item) => item.status === 6).length,
};
}, [todayConsultations]);
const specialtyActiveCounts = useMemo(() => {
const map = new Map<string, number>();
todayConsultations.forEach((item) => {
item.specialtyClinics?.forEach((specialty) => {
const count = map.get(specialty.specialtyId) || 0;
map.set(specialty.specialtyId, count + 1);
});
});
return specialtyItems.map((specialty) => ({
...specialty,
active: map.get(specialty.id) || 0,
}));
}, [todayConsultations, specialtyItems]);
const summaryCards = useMemo(
() => [
{
title: "Tất cả đợt khám",
value: statusCounts.total.toString(),
subtitle: "Số đợt khám trong ngày",
icon: CalendarDays,
color: "bg-blue-50 text-blue-600",
},
{
title: "Đang khám",
value: statusCounts.inProgress.toString(),
subtitle: "Đang khám tổng quát",
icon: Stethoscope,
color: "bg-emerald-50 text-emerald-600",
},
{
title: "Hoàn tất",
value: statusCounts.completed.toString(),
subtitle: "Đã hoàn thành",
icon: ClipboardList,
color: "bg-sky-50 text-sky-600",
},
{
title: "Chờ khám",
value: statusCounts.waiting.toString(),
subtitle: "Đang chờ bác sĩ",
icon: Activity,
color: "bg-orange-50 text-orange-600",
},
],
[statusCounts],
);
const generalInfo = useMemo(
() => [
{
label: "Tổng đợt khám",
value: statusCounts.total.toString(),
icon: Users,
color: "bg-violet-50 text-violet-600",
},
{
label: "Đợt khám chuyên khoa",
value: specialtyActiveCounts
.reduce((sum, item) => sum + item.active, 0)
.toString(),
icon: Hospital,
color: "bg-cyan-50 text-cyan-600",
},
{
label: "Đơn thuốc",
value: consultationReport?.totalPrescriptionsIssued?.toString() || "0",
icon: ClipboardList,
color: "bg-amber-50 text-amber-600",
},
{
label: "Chuyên khoa",
value: specialtyItems.length.toString(),
icon: Activity,
color: "bg-teal-50 text-teal-600",
},
],
[
consultationReport,
statusCounts.total,
specialtyActiveCounts,
specialtyItems.length,
],
);
return (
<section className="space-y-6">
<div className="rounded-[20px] bg-white p-6 shadow-sm border border-gray-100">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-900">
Dashboard khám bệnh
</h1>
<p className="mt-2 text-sm text-slate-500">
Theo dõi nhanh các đt khám, chuyên khoa thông tin tổng quát
trong ngày.
</p>
</div>
<div className="rounded-full bg-slate-100 px-4 py-2 text-sm text-slate-700">
Cập nhật: {moment().format("DD/MM/YYYY")}
</div>
</div>
<DataWrapper
loading={
consultationQuery.isLoading ||
reportQuery.isLoading ||
specialtyQuery.isLoading
}
data={summaryCards}
title="Không có dữ liệu"
note="Vui lòng thử lại sau"
>
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{summaryCards.map((item) => {
const Icon = item.icon;
return (
<div
key={item.title}
className="rounded-[20px] border border-slate-200 bg-slate-50 p-4"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-slate-500">
{item.title}
</div>
<div
className={`inline-flex h-10 w-10 items-center justify-center rounded-2xl ${item.color}`}
>
<Icon size={20} />
</div>
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<div>
<p className="text-3xl font-semibold text-slate-900">
{item.value}
</p>
<p className="mt-1 text-sm text-slate-500">
{item.subtitle}
</p>
</div>
</div>
</div>
);
})}
</div>
</DataWrapper>
</div>
<div className="grid gap-6 xl:grid-cols-[1.4fr_1fr]">
<div className="rounded-[20px] bg-white p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">
Chuyên khoa hiện
</h2>
<p className="mt-2 text-sm text-slate-500">
Danh sách chuyên khoa.
</p>
</div>
<div className="rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700">
{specialtyItems.length} chuyên khoa
</div>
</div>
<div className="mt-6 divide-y divide-slate-200">
{specialtyActiveCounts.map((specialty, idx) => (
<div
key={specialty.id}
className={`flex items-center justify-between py-4 ${idx !== 0 ? "border-t border-slate-200" : ""}`}
>
<div>
<p className="text-base font-medium text-slate-900">
{specialty.name}
</p>
<p className="mt-1 text-sm text-slate-500">
Số chuyên khoa: {specialty.active} ca khám hôm nay
</p>
</div>
<span className="rounded-full bg-slate-100 px-3 py-1 text-sm font-semibold text-slate-700">
{specialty.active}
</span>
</div>
))}
</div>
</div>
<div>
<div className="rounded-[20px] bg-white p-6 shadow-sm border border-gray-100">
<div>
<h2 className="text-xl font-semibold text-slate-900">
Thông tin khám tổng quát
</h2>
<p className="mt-2 text-sm text-slate-500">
Tổng số đt khám, ca khám chuyên khoa đơn thuốc trong ngày.
</p>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
{generalInfo.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="rounded-[18px] border border-slate-200 bg-slate-50 p-4"
>
<div className="flex items-center gap-3">
<div
className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl ${item.color}`}
>
<Icon size={20} />
</div>
<div>
<p className="text-sm font-medium text-slate-500">
{item.label}
</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">
{item.value}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</section>
);
};
export default MainPageHome;
@@ -1,23 +1,31 @@
"use client";
import React from "react";
import { useParams, useRouter } from "next/navigation";
import React, { useEffect, useMemo } from "react";
import { useParams, useRouter, useSearchParams } 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 { ArrowLeft, Pencil } from "lucide-react";
import consultationServices, {
ConsultationDetail,
} from "@/services/consultationServices";
import StateActive from "@/components/customs/StateActive";
import TabNavLink from "@/components/customs/custom-tabs";
import moment from "moment";
import GridColumn from "@/components/layouts/GridColumn";
import DataWrapper from "@/components/customs/DataWrapper";
import Table from "@/components/customs/custom-table";
import CustomButton from "@/components/customs/custom-button";
const DetailConsultation = () => {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const specialtyId = searchParams.get("_type");
const consultationId = params?.id as string;
@@ -48,6 +56,7 @@ const DetailConsultation = () => {
doctorNotes: null,
createdBy: "",
isDeleted: false,
specialtyClinics: [],
}
);
},
@@ -55,6 +64,36 @@ const DetailConsultation = () => {
const consultation = consulDetailQuery.data;
const canUpdateConsultation =
consultation?.status !== TYPE_STATUS.Completed &&
consultation?.status !== TYPE_STATUS.Cancelled;
const specialtyTabs = useMemo(() => {
return (
consultation?.specialtyClinics?.map((item) => ({
pathname: `/consultation/${consultationId}`,
query: item.id,
title: item.specialtyName,
})) || []
);
}, [consultation?.specialtyClinics, consultationId]);
const specialtyClinics = consultation?.specialtyClinics;
const selectedSpecialty = useMemo(() => {
if (!specialtyClinics?.length) return null;
return (
specialtyClinics.find((item) => item.id === specialtyId) ||
specialtyClinics[0]
);
}, [specialtyClinics, specialtyId]);
useEffect(() => {
if (consultation?.specialtyClinics?.length && !specialtyId) {
router.replace(`?&_type=${consultation.specialtyClinics[0].id}`);
}
}, [consultation?.specialtyClinics, specialtyId, router]);
if (consulDetailQuery.isLoading) {
return <div>Đang tải dữ liệu...</div>;
}
@@ -62,215 +101,360 @@ const DetailConsultation = () => {
if (!consultation) {
return <div>Không tìm thấy dữ liệu phiên khám</div>;
}
type PrescriptionItem =
ConsultationDetail["aggregatedPrescriptionItems"][number];
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"
/>
<div className="flex flex-row 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 justify-between">
<div
className="flex cursor-pointer items-center gap-3"
onClick={() => router.back()}
>
<ArrowLeft className="h-6 w-6" />
<h2 className="text-[28px] font-semibold text-[#111827]">
Chi tiết phiên khám
</h2>
<h2 className="text-[28px] font-semibold text-[#111827]">
Chi tiết phiên khám
</h2>
</div>
{consultation.status === TYPE_STATUS.PendingPrescription && canUpdateConsultation && (
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Pencil />}
href={`/consultation/update?_id=${consultation.id}`}
>
Cập nhật tổng quát
</CustomButton>
)}
</div>
<GridColumn col={3}>
{/* 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>
{/* TÊN BỆNH NHÂN */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
TÊN BỆNH NHÂN
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.patientName}
</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
? moment(consultation.visitDate).format("DD/MM/YYYY")
: "---"}
</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>
{/* CHỈ SỐ HUYẾT ÁP, CHỈ SỐ MẠCH */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
CHỈ SỐ HUYẾT ÁP, CHỈ SỐ MẠCH
</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>
<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>
<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ờ khám",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
},
{
state: TYPE_STATUS.InProgress,
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: "#166534",
backgroundColor: "#DCFCE7",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#991B1B",
backgroundColor: "#FEE2E2",
},
]}
/>
</div>
</GridColumn>
</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>
<div>
<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">
Danh sách đơn thuốc
</h2>
</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>
{/* CHỈ SỐ HUYẾT ÁP, CHỈ SỐ MẠCH */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
CHỈ SỐ HUYẾT ÁP, CHỈ SỐ MẠCH
</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>
<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]">
TRẠNG THÁI
</p>
<StateActive
stateActive={consultation.status}
listState={[
<DataWrapper
data={consultation.aggregatedPrescriptionItems}
loading={consulDetailQuery.isLoading}
title="Không có dữ liệu"
note="Vui lòng thử lại sau"
>
<Table
rowKey={(item) => item.id}
data={consultation.aggregatedPrescriptionItems}
column={[
{
state: TYPE_STATUS.Draft,
text: "Đang chờ khám",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
title: "Tên thuốc",
render: (item: PrescriptionItem) => (
<span>{item.medicineName}</span>
),
},
{
state: TYPE_STATUS.InProgress,
text: "Đang khám tổng quát",
textColor: "#1E3A8A",
backgroundColor: "#DBEAFE",
title: "Liều dùng",
render: (item: PrescriptionItem) => (
<span>{item.dosage}</span>
),
},
{
state: TYPE_STATUS.ToSpecialty,
text: "Chờ khám chuyên khoa",
textColor: "#6B21A8",
backgroundColor: "#E9D5FF",
title: "Tần suất",
render: (item: PrescriptionItem) => (
<span>{item.frequency}</span>
),
},
{
state: TYPE_STATUS.PendingPrescription,
text: "Chờ nhận thuốc/kính",
textColor: "#9A3412",
backgroundColor: "#FED7AA",
title: "Thời gian",
render: (item: PrescriptionItem) => (
<span>{item.duration}</span>
),
},
{
state: TYPE_STATUS.Completed,
text: "Hoàn thành",
textColor: "#166534",
backgroundColor: "#DCFCE7",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#991B1B",
backgroundColor: "#FEE2E2",
title: "Hướng dẫn",
render: (item: PrescriptionItem) => (
<span>{item.instructions || "---"}</span>
),
},
]}
/>
</div>
</DataWrapper>
</div>
</div>
</div>
{/* TAB CHUYÊN KHOA */}
{specialtyTabs.length > 0 && (
<div className="mt-4">
<TabNavLink query="_type" listHref={specialtyTabs} />
</div>
)}
{/* CHI TIẾT CHUYÊN KHOA */}
{selectedSpecialty && (
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<h3 className="mb-6 text-xl font-semibold">
Thông tin chuyên khoa: {selectedSpecialty.specialtyName}
</h3>
{canUpdateConsultation && (
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Pencil />}
href={`/consultation/update-specialty?_id=${consultation.id}&specialtyClinicId=${selectedSpecialty.id}`}
>
Cập nhật chuyên khoa
</CustomButton>
)}
</div>
<GridColumn col={3}>
<div>
<p className="text-sm text-[#697586]">CHUYÊN KHOA</p>
<p>{selectedSpecialty.specialtyName || "---"}</p>
</div>
<div>
<p className="text-sm text-[#697586]">CHẨN ĐOÁN</p>
<p>{selectedSpecialty.diagnosis || "---"}</p>
</div>
<div>
<p className="text-sm text-[#697586]">GHI CHÚ KHÁM</p>
<p>{selectedSpecialty.specialtyNotes || "---"}</p>
</div>
<div>
<p className="text-sm text-[#697586]">THỦ THUẬT</p>
<p>{selectedSpecialty.procedureNotes || "---"}</p>
</div>
{selectedSpecialty.glassRequired && (
<>
<div>
<p className="text-sm text-[#697586]">CẦN ĐEO KÍNH</p>
<p></p>
</div>
<div>
<p className="text-sm text-[#697586]">LOẠI KÍNH</p>
<p>{selectedSpecialty.glassType || "---"}</p>
</div>
</>
)}
</GridColumn>
</div>
)}
</div>
);
};
@@ -20,44 +20,44 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Pencil, ClipboardPlus } from "lucide-react";
import { Pencil, ClipboardPlus, Ellipsis } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import Pagination from "@/components/customs/custom-pagination";
import moment from "moment";
import specialtyServices, { SpecialtyItem } from "@/services/specialtyServices";
import FilterCustom from "@/components/customs/FilterCustom";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import TabNavLink from "@/components/customs/custom-tabs";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
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 _status = searchParams.get("_status");
const _specialtyId = searchParams.get("_specialtyId");
/* =========================
STATE
========================= */
const _type = searchParams.get("_type");
const isAllTab = !_type || _type === "all";
const [selectedConsultationId, setSelectedConsultationId] =
useState<string>("");
@@ -66,10 +66,6 @@ const MainPageConsultation = () => {
const [openCompleteDialog, setOpenCompleteDialog] = useState(false);
/* =========================
GET LIST
========================= */
const consultationQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation, _page, _pageSize, _keyword],
@@ -102,10 +98,6 @@ const MainPageConsultation = () => {
},
});
/* =========================
DATA
========================= */
const specialtyQuery = useQuery({
queryKey: [QUERY_KEY.list_specialty_lookup],
queryFn: async () => {
@@ -121,10 +113,20 @@ const MainPageConsultation = () => {
const consultationData = useMemo(() => {
let data = consultationQuery.data?.items || [];
if (_status) {
data = data.filter((item) => item.status === Number(_status));
// =========================
// TAB CHUYÊN KHOA
// =========================
if (_type && _type !== "all") {
data = data.filter((item) =>
item.specialtyClinics?.some(
(specialty) => specialty.specialtyId === _type,
),
);
}
// =========================
// FILTER CHUYÊN KHOA
// =========================
if (_specialtyId) {
data = data.filter((item) =>
item.specialtyClinics?.some(
@@ -133,16 +135,29 @@ const MainPageConsultation = () => {
);
}
return data;
}, [consultationQuery.data, _status, _specialtyId]);
// =========================
// FILTER TRẠNG THÁI
// =========================
if (_status) {
data = data.filter((item) => item.status === Number(_status));
}
/* =========================
UPDATE QUERY
========================= */
return data;
}, [consultationQuery.data, _type, _specialtyId, _status]); // Bỏ _keyword khỏi dependencies vì không dùng lọc client nữa
const page = _page ? Number(_page) : 1;
const pageSize = _pageSize ? Number(_pageSize) : 10;
useEffect(() => {
if (!_type) {
const params = new URLSearchParams(searchParams.toString());
params.set("_type", "all");
router.replace(`${pathname}?${params.toString()}`);
}
}, [_type, pathname, router, searchParams]);
const updateQuery = (key: string, value: string | number) => {
const params = new URLSearchParams(searchParams.toString());
@@ -166,25 +181,32 @@ const MainPageConsultation = () => {
router.push(queryString ? `?${queryString}` : pathname);
};
/* =========================
CLOSE POPUP
========================= */
const consultation = consultationQuery.data;
// const handleClosePopup = () => {
// const params = new URLSearchParams(searchParams.toString());
const specialtyTabs = useMemo(() => {
const specialtyMap = new Map();
// params.delete("_create");
consultation?.items?.forEach((consultationItem) => {
consultationItem.specialtyClinics?.forEach((specialty) => {
if (!specialtyMap.has(specialty.specialtyId)) {
specialtyMap.set(specialty.specialtyId, {
pathname: "/consultation",
query: specialty.specialtyId,
title: specialty.specialtyName,
});
}
});
});
// params.delete("_update");
// const queryString = params.toString();
// router.push(queryString ? `?${queryString}` : pathname);
// };
/* =========================
DIALOG
========================= */
return [
{
pathname: "/consultation",
query: "all",
title: "Tất cả",
},
...Array.from(specialtyMap.values()),
];
}, [consultation?.items]);
const handleOpenCancel = (item: ConsultationItem) => {
setSelectedConsultationId(item.id);
@@ -204,10 +226,6 @@ const MainPageConsultation = () => {
setOpenCompleteDialog(false);
};
/* =========================
CANCEL
========================= */
const cancelConsultationMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
@@ -230,10 +248,6 @@ const MainPageConsultation = () => {
},
});
/* =========================
COMPLETE
========================= */
const completeConsultationMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
@@ -258,10 +272,6 @@ const MainPageConsultation = () => {
},
});
/* =========================
CONFIRM
========================= */
const handleConfirmCancel = () => {
cancelConsultationMutation.mutate();
};
@@ -271,303 +281,412 @@ const MainPageConsultation = () => {
};
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}
href="/consultation/create"
>
Tạo phiên khám
</CustomButton>
<div>
<div className="rounded-lg bg-white shadow mb-6 p-6">
<TabNavLink query="_type" listHref={specialtyTabs} />
</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 className="flex flex-col gap-4 rounded-lg bg-white shadow p-6">
{/* HEADER */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-black">Phiên khám</h2>
{isAllTab && (
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<ClipboardPlus />}
// onClick={handleOpenCreate}
href="/consultation/create"
>
Tạo phiên khám
</CustomButton>
)}
</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",
},
]}
{/* SEARCH */}
<div
className={clsx(
"flex gap-4",
isAllTab
? "flex-col lg:flex-row lg:items-center lg:justify-between"
: "flex-col",
)}
>
<div className="w-full lg:min-w-[400px]">
<Search
keyword={_keyword ?? ""}
setKeyword={(value) => updateQuery("_keyword", value)}
placeholder="Tìm kiếm phiên khám..."
/>
</div>
{isAllTab && (
<div className="flex flex-col gap-4 sm:flex-row">
<FilterCustom<string | null>
name="Chuyên khoa"
value={_specialtyId}
onChange={(value) => updateQuery("_specialtyId", value ?? "")}
listOption={
specialtyQuery.data?.map((item) => ({
uuid: item.id,
name: item.name,
})) || []
}
/>
<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>
)}
</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={[
{
title: "TÊN BỆNH NHÂN",
render: (item: ConsultationItem) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/patient/${item.patientId}`}
className="text-blue-500 hover:underline"
>
{item.patientName}
</Link>
</TooltipTrigger>
<TooltipContent>
<p className="p-2 bg-gray-400 border rounded-full">
Chi tiết bệnh nhân
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
},
{
title: "MÃ PHIÊN KHÁM",
fixedLeft: true,
render: (item: ConsultationItem) => {
const specialty = item.specialtyClinics.find(
(x) => x.specialtyId === _type,
);
const detailHref = isAllTab
? `/consultation/${item.id}`
: `/consultation/${item.id}?_type=${specialty?.id ?? ""}`;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={detailHref}
className="text-blue-500 hover:underline"
>
{item.sessionCode}
</Link>
</TooltipTrigger>
<TooltipContent>
<p className="rounded-full border bg-gray-400 p-2">
Chi tiết phiên khám
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
title: "NGÀY KHÁM",
render: (item: ConsultationItem) => (
<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: "TRẠNG THÁI",
render: (item: ConsultationItem) => {
// ===== TAB CHUYÊN KHOA =====
if (!isAllTab) {
const specialty = item.specialtyClinics.find(
(x) => x.specialtyId === _type,
);
return (
<StateActive
stateActive={specialty?.specialtyStatus}
listState={[
{
state: 1,
text: "Mới được chỉ định",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
},
{
state: 2,
text: "Đang khám",
textColor: "#1E3A8A",
backgroundColor: "#DBEAFE",
},
{
state: 3,
text: "Đã Hoàn thành",
textColor: "#166534",
backgroundColor: "#DCFCE7",
},
]}
/>
);
}
// ===== TAB TẤT CẢ =====
return (
<StateActive
stateActive={item.status}
listState={[
{
state: TYPE_STATUS.Draft,
text: "Đang chờ khám",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
},
{
state: TYPE_STATUS.InProgress,
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: "#166534",
backgroundColor: "#DCFCE7",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#991B1B",
backgroundColor: "#FEE2E2",
},
]}
/>
);
},
},
{
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;
// ===== TAB CHUYÊN KHOA =====
if (!isAllTab) {
const specialty = item.specialtyClinics.find(
(x) => x.specialtyId === _type,
);
if (!specialty) return null;
return (
<CustomButton
size="sm"
variant="midnightBlue"
disabled={isDisabled}
href={
!isDisabled
? `/consultation/update-specialty?_id=${item.id}&specialtyClinicId=${specialty.id}`
: undefined
}
>
Cập nhật
</CustomButton>
);
}
// ===== TAB TẤT CẢ =====
return (
<div className="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger asChild>
<button
disabled={isDisabled}
className={clsx(
"flex h-8 w-8 items-center justify-center rounded-full border border-gray-200",
isDisabled
? "cursor-not-allowed opacity-50"
: "hover:bg-gray-100",
)}
>
<Ellipsis size={20} />
</button>
</Popover.Trigger>
{!isDisabled && (
<Popover.Content
side="bottom"
align="end"
sideOffset={8}
className="
z-50
min-w-[300px]
rounded-xl
border
bg-white
p-2
shadow-xl
"
>
<div className="space-y-1">
{item.specialtyClinics.map((specialty) => (
<Link
key={specialty.id}
href={`/consultation/update-specialty?_id=${item.id}&specialtyClinicId=${specialty.id}`}
className="
flex items-center gap-2
rounded-lg px-3 py-2
text-sm
hover:bg-blue-50
"
>
<Pencil size={15} />
<span>{specialty.specialtyName}</span>
</Link>
))}
</div>
</Popover.Content>
)}
</Popover.Root>
<CustomButton
size="sm"
variant="red"
disabled={isDisabled}
onClick={() => handleOpenCancel(item)}
>
Hủy
</CustomButton>
<CustomButton
size="sm"
variant="green"
disabled={isDisabled}
onClick={() => handleOpenComplete(item)}
>
Hoàn thành
</CustomButton>
</div>
);
},
},
]}
/>
</DataWrapper>
<Pagination
total={consultationData.length}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery("_page", value)}
onSetPageSize={(value) => updateQuery("_pageSize", value)}
dependencies={[_pageSize, _keyword, _specialtyId, _status]}
/>
</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={[
{
title: "TÊN BỆNH NHÂN",
render: (item: ConsultationItem) => (
<span>{item.patientName}</span>
),
},
{
title: "MÃ PHIÊN KHÁM",
fixedLeft: true,
render: (item: ConsultationItem) => (
<Link
href={`/consultation/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.sessionCode}
</Link>
),
},
{
title: "NGÀY KHÁM",
render: (item: ConsultationItem) => (
<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",
render: (item: ConsultationItem) => (
<StateActive
stateActive={item.status}
listState={[
{
state: TYPE_STATUS.Draft,
text: "Đang chờ khám",
textColor: "#92400E",
backgroundColor: "#FEF3C7",
},
{
state: TYPE_STATUS.InProgress,
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: "#166534",
backgroundColor: "#DCFCE7",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#991B1B",
backgroundColor: "#FEE2E2",
},
]}
/>
),
},
{
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;
// 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={
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
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>
<Pagination
total={consultationData.length}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery("_page", value)}
onSetPageSize={(value) => updateQuery("_pageSize", value)}
dependencies={[_pageSize, _keyword, _specialtyId, _status]}
/>
{/* CANCEL DIALOG */}
<CustomDialog
open={openCancelDialog}
@@ -145,6 +145,8 @@ const UpdateConsultation = () => {
queryKey: [QUERY_KEY.chi_tiet_phien_kham, id],
});
router.back();
// Quay lại trang quản lý danh sách phiên khám
router.push(PATH.CONSULTATION || "/consultation");
},
@@ -159,6 +159,8 @@ const UpdateSpecialtyClinicId = () => {
queryKey: [QUERY_KEY.chi_tiet_phien_kham, consultationId],
});
router.back();
router.push(PATH.CONSULTATION || "/consultation");
},
});
@@ -21,6 +21,12 @@ import StateActive from "@/components/customs/StateActive";
import DataWrapper from "@/components/customs/DataWrapper";
import Table from "@/components/customs/custom-table";
import moment from "moment";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
// import CustomDialog from "@/components/customs/custom-dialog";
const DetailPatient = () => {
@@ -110,12 +116,11 @@ const DetailPatient = () => {
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2 className="text-[28px] flex items-center gap-2 font-semibold text-[#111827]">
<ArrowLeft
onClick={() => router.back()}
className="cursor-pointer"
/>{" "}
{patient?.fullName}
<h2
className="text-[28px] flex items-center gap-2 font-semibold text-[#111827] cursor-pointer"
onClick={() => router.back()}
>
<ArrowLeft /> {patient?.fullName}
</h2>
<div>
<CustomButton
@@ -278,12 +283,24 @@ const DetailPatient = () => {
title: "MÃ PHIÊN KHÁM",
fixedLeft: true,
render: (item: ConsultationItem) => (
<Link
href={`/consultation/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.sessionCode}
</Link>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/consultation/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.sessionCode}
</Link>
</TooltipTrigger>
<TooltipContent>
<p className="p-2 bg-gray-400 border rounded-full">
Chi tiết phiên khám
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
},
@@ -20,6 +20,13 @@ import React, { useMemo, useState } from "react";
import PopupCreatePatient from "../PopupCreatePatient";
import PopupUpdatePatient from "../PopupUpdatePatient";
import Pagination from "@/components/customs/custom-pagination";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import moment from "moment";
const MainPagePatient = () => {
const router = useRouter();
@@ -61,6 +68,7 @@ const MainPagePatient = () => {
Page: Number(_page || 1),
PageSize: Number(_pageSize || 10),
Search: _keyword || "",
// IdentificationNumber: _keyword || "",
SortBy: "id",
Desc: true,
}),
@@ -171,8 +179,29 @@ const MainPagePatient = () => {
* DATA
*/
const patientData = useMemo(() => {
return patientQuery.data?.items || [];
}, [patientQuery.data]);
const items = patientQuery.data?.items || [];
if (!_keyword) return items;
const keyword = _keyword.toLowerCase().trim();
return items.filter((item) => {
return (
item.fullName?.toLowerCase().includes(keyword) ||
item.identificationNumber?.toLowerCase().includes(keyword) ||
item.phone?.toLowerCase().includes(keyword) ||
item.email?.toLowerCase().includes(keyword)
);
});
}, [patientQuery.data, _keyword]);
// const isReturnWithin14Days = (lastVisitDate?: string | null) => {
// if (!lastVisitDate) return false;
// const diffDays = moment().diff(moment(lastVisitDate), "days");
// return diffDays <= 14;
// };
return (
<div className="flex flex-col gap-4 rounded-lg bg-white p-6 shadow">
@@ -217,12 +246,34 @@ const MainPagePatient = () => {
title: "TÊN BỆNH NHÂN",
render: (item: PatientItem) => (
<Link
href={`/patient/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.fullName || "---"}
</Link>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/patient/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.fullName}
</Link>
{/* <Link
href={`/patient/${item.id}`}
className={
isReturnWithin14Days(item.lastVisitDate)
? "text-red-500 font-semibold hover:underline"
: "text-blue-500 hover:underline"
}
>
{item.fullName}
</Link> */}
</TooltipTrigger>
<TooltipContent>
<p className="p-2 bg-gray-400 border rounded-full">
Chi tiết bệnh nhân
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
},
@@ -56,6 +56,7 @@ const CreatePrescription = () => {
const [loading, setLoading] = useState<boolean>(false);
const queryKeys = [QUERY_KEY.table_list_consultation];
const [file, setFile] = useState<File | null>(null);
const [folderName, setFolderName] = useState("prescriptions");
const [form, setForm] = useState<PrescriptionForm>({
examinationSessionSpecialtyId: "",
@@ -181,7 +182,7 @@ const CreatePrescription = () => {
const handleAddMedicine = () => {
setForm((prev) => ({
...prev,
items: [...prev.items, { ...defaultMedicine }],
items: [{ ...defaultMedicine }, ...prev.items],
}));
};
@@ -228,7 +229,7 @@ const CreatePrescription = () => {
setLoading(true);
const uploadResponse: any = await prescriptionServices.uploadImage(
file,
"prescriptions",
folderName,
);
console.log("KẾT QUẢ PHẢN HỒI GỐC TỪ API UPLOAD:", uploadResponse);
@@ -412,8 +413,9 @@ const CreatePrescription = () => {
<div className="flex flex-col gap-6">
{form.items.map((medicine, index) => (
// Lưu ý: Nếu có trường id duy nhất thì nên dùng thay cho index ở đây
<div
key={index}
key={`${index}-${form.items.length}`}
className="rounded-2xl border border-gray-200 p-5"
>
{/* TITLE */}
@@ -5,13 +5,18 @@ import { httpRequest } from "@/services";
import prescriptionServices, {
PrescriptionDetail,
} from "@/services/prescriptionServices";
import consultationServices, {
ConsultationItem,
} from "@/services/consultationServices";
import { useQuery } from "@tanstack/react-query";
import Image from "next/image";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import React, { useState } from "react";
import CustomPopup from "@/components/customs/custom-popup";
import { ArrowLeft } from "lucide-react";
const DetailPrescription = () => {
const router = useRouter();
const params = useParams();
const PrescriptionId = params?.id as string;
const [openImagePreview, setOpenImagePreview] = useState(false);
@@ -31,7 +36,7 @@ const DetailPrescription = () => {
return (
res || {
id: "",
examinationSessionId: "",
examinationSessionSpecialtyId: "",
prescriptionCode: "",
prescriptionImg: null,
notes: "",
@@ -44,9 +49,46 @@ const DetailPrescription = () => {
const data = prescriptionDetailQuery.data;
const specialtyListQuery = useQuery<ConsultationItem[]>({
queryKey: [QUERY_KEY.examination_specialty_list],
enabled: !!data?.examinationSessionSpecialtyId,
queryFn: async () => {
const res = await httpRequest<{
items: ConsultationItem[];
}>({
showMessageFailed: false,
http: consultationServices.getConsultations({ PageSize: 1000 }),
});
return res?.items || [];
},
});
const specialtyName = React.useMemo(() => {
const specialtyId = data?.examinationSessionSpecialtyId;
if (!specialtyId) return "";
const items = specialtyListQuery.data || [];
for (const session of items) {
const found = session.specialtyClinics?.find((s) => s.id === specialtyId);
if (found) return found.specialtyName || "";
}
return "";
}, [data?.examinationSessionSpecialtyId, specialtyListQuery.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>
<div
className="flex items-center gap-1 cursor-pointer"
onClick={() => router.back()}
>
<ArrowLeft />
<h2 className="text-xl font-semibold text-[#111827]">
Chi tiết đơn thuốc
</h2>
</div>
{/* LOADING */}
{prescriptionDetailQuery.isLoading && <p>Đang tải...</p>}
@@ -64,11 +106,12 @@ const DetailPrescription = () => {
</div>
<div>
<strong>Id phiên khám:</strong> {data.examinationSessionId}
<strong>Chuyên khoa:</strong>{" "}
{specialtyName || data.examinationSessionSpecialtyId || "---"}
</div>
<div>
<strong>Ghi chú:</strong> {data.notes || "-"}
<strong>Ghi chú:</strong> {data.notes || "---"}
</div>
{/* IMAGE */}
@@ -10,8 +10,14 @@ import prescriptionServices, {
PrescriptionItem,
PrescriptionResponse,
} from "@/services/prescriptionServices";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query";
import { Pencil, Pill, Trash2 } from "lucide-react";
import { Eye, Pencil, Pill, Trash2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -121,12 +127,24 @@ const MainPrescription = () => {
{
title: "MÃ ĐƠN THUỐC",
render: (item: any) => (
<Link
href={`/prescription/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.prescriptionCode}
</Link>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/prescription/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.prescriptionCode}
</Link>
</TooltipTrigger>
<TooltipContent>
<p className="p-2 bg-gray-400 border rounded-full">
Chi tiết đơn thuốc
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
},
@@ -162,20 +180,20 @@ const MainPrescription = () => {
},
// ================= ACTION =================
// {
// fixedRight: true,
// title: "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>
// ),
// },
render: (item: any) => (
<div className="flex items-center gap-2">
<CustomButton
size="sm"
icon={<Eye className="text-blue-400" />}
href={`${PATH.PRESCRIPTION}/${item.id}`}
/>
</div>
),
},
]}
/>
</DataWrapper>
@@ -1,30 +1,49 @@
"use client";
import React, { useMemo, useState } from "react";
import moment from "moment";
import { useQuery } from "@tanstack/react-query";
import { Users, Stethoscope, Pill } from "lucide-react";
import Table from "@/components/customs/custom-table";
import DataWrapper from "@/components/customs/DataWrapper";
import { QUERY_KEY } from "@/constant/config/enum";
import { QUERY_KEY, TYPE_DATE } 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";
import FilterDateRange from "@/components/utils/FilterDateRage";
const MainPageStatistical = () => {
// const router = useRouter();
// const pathname = usePathname();
// const searchParams = useSearchParams();
const [date, setDate] = useState<{
from: Date | null;
to: Date | null;
} | null>(null);
const [typeDate, setTypeDate] = useState<TYPE_DATE>(TYPE_DATE.TODAY);
// const _page = searchParams.get("_page");
const isInvalidDate =
!!date?.from && moment(date.from).isAfter(moment().endOf("day"));
// const _pageSize = searchParams.get("_pageSize");
const reportQuery = useQuery<ReportResponse>({
queryKey: [QUERY_KEY.table_list_report],
queryKey: [
QUERY_KEY.table_list_report,
date?.from?.toISOString(),
date?.to?.toISOString(),
],
enabled: !!date?.from && !!date?.to && !isInvalidDate,
queryFn: async () => {
const toDate = moment().format("YYYY-MM-DD");
const fromDate = moment().subtract(14, "days").format("YYYY-MM-DD");
const fromDate = moment(date?.from).format("YYYY-MM-DD");
const toDate = moment(date?.to).format("YYYY-MM-DD");
console.log({
FormDate: fromDate,
ToDate: toDate,
});
const res = await httpRequest<ReportResponse>({
showMessageFailed: true,
@@ -38,68 +57,132 @@ const MainPageStatistical = () => {
},
});
const report = reportQuery.data;
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 report?.specialtyDetails || [];
}, [report]);
return (
<div className="flex flex-col gap-4 rounded-lg bg-white p-6 shadow">
<div className="flex flex-col gap-6">
{/* 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 className="rounded-xl bg-white p-6 shadow">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">Thống phiên khám</h2>
<FilterDateRange
date={date}
setDate={setDate}
typeDate={typeDate}
setTypeDate={setTypeDate}
/>
</div>
</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}
{/* CARDS */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl bg-white p-5 shadow">
<div className="mb-3 flex items-center justify-between">
<Users size={22} />
</div>
<p className="text-sm text-gray-500">Tổng lượt khám</p>
<h3 className="mt-2 text-3xl font-bold">
{report?.totalSessions || 0}
</h3>
</div>
<div className="rounded-xl bg-white p-5 shadow">
<div className="mb-3 flex items-center justify-between">
<Stethoscope size={22} />
</div>
<p className="text-sm text-gray-500">Lượt khám chuyên khoa</p>
<h3 className="mt-2 text-3xl font-bold">
{report?.totalSpecialtySubSessions || 0}
</h3>
</div>
<div className="rounded-xl bg-white p-5 shadow">
<div className="mb-3 flex items-center justify-between">
<Pill size={22} />
</div>
<p className="text-sm text-gray-500">Đơn thuốc đã cấp</p>
<h3 className="mt-2 text-3xl font-bold">
{report?.totalPrescriptionsIssued || 0}
</h3>
</div>
</div>
{/* BẢNG THỐNG KÊ */}
<div className="rounded-xl bg-white p-6 shadow">
<h3 className="mb-4 text-lg font-semibold">
Thống theo chuyên khoa
</h3>
<DataWrapper
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]}
/> */}
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 className="font-semibold">{item.totalCases}</span>
),
},
]}
/>
</DataWrapper>
</div>
{/* BÁO CÁO TỔNG HỢP */}
<div className="rounded-xl bg-white p-6 shadow">
<h3 className="mb-4 text-lg font-semibold">Báo cáo tổng hợp</h3>
<div className="space-y-3 text-[15px]">
<p>
Số lượt khám bệnh nhân đo:
<strong> {report?.totalSpecialtySubSessions || 0}</strong> lượt
người dân đưc khám chuyên khoa, cụ thể như sau:
</p>
<ul className="list-disc space-y-2 pl-6">
{reportData.map((item) => (
<li key={item.specialtyId}>
<strong>{item.specialtyName}</strong>: {item.totalCases} ca
</li>
))}
</ul>
<p>
Tổng số đơn thuốc đã cấp:
<strong> {report?.totalPrescriptionsIssued || 0}</strong> đơn.
</p>
{/* <p>
Số suất quà được phát:
<strong> {report?.totalGiftPackagesIssued || 0}</strong> suất.
</p> */}
</div>
</div>
</div>
);
};
@@ -0,0 +1,85 @@
"use client";
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import * as Popover from "@radix-ui/react-popover";
import { PropsFilterDateRange } from "./interface";
import Moment from "react-moment";
import { TYPE_DATE } from "@/constant/config/enum";
import { getDateRange } from "@/common/funcs/selectData";
import { ListOptionFilterDate } from "@/constant/config";
import { ChevronDown } from "lucide-react";
import DateOption from "./components/DateOption";
function FilterDateRange({
styleRounded = false,
showOptionAll = false,
date,
setDate,
typeDate,
setTypeDate,
title = "Thời gian",
}: PropsFilterDateRange) {
const [openDate, setOpenDate] = useState(false);
useEffect(() => {
if (typeDate !== null && typeDate !== TYPE_DATE.LUA_CHON) {
setDate(getDateRange(typeDate));
}
}, [typeDate, setDate]);
return (
<Popover.Root open={openDate} onOpenChange={setOpenDate}>
<Popover.Trigger asChild>
<button
type="button"
className={clsx(
"flex h-12 min-w-[240px] items-center justify-between gap-1 rounded-full border border-[#E1E5ED] bg-white px-4 transition",
"hover:border-[#0011ab]",
openDate && "border-[#0011ab]",
styleRounded && "h-10 min-w-[200px] bg-[#f0f2fa]",
)}
>
<div className="flex items-center">
<p className="text-sm font-medium text-[#777E90]">{title}:</p>
<p className="ml-2 text-sm font-medium">
{typeDate === TYPE_DATE.LUA_CHON && date?.from && date?.to ? (
<>
<Moment date={date.from} format="DD/MM/YYYY" />
{" - "}
<Moment date={date.to} format="DD/MM/YYYY" />
</>
) : (
ListOptionFilterDate.find((v) => v.value === typeDate)?.name
)}
</p>
</div>
<ChevronDown
size={16}
className={clsx(
"transition duration-300",
openDate && "rotate-180",
)}
/>
</button>
</Popover.Trigger>
<Popover.Content align="start" sideOffset={8} className="z-50">
<DateOption
showOptionAll={showOptionAll}
date={date}
setDate={setDate}
typeDate={typeDate}
setTypeDate={setTypeDate}
show={openDate}
setShow={setOpenDate}
/>
</Popover.Content>
</Popover.Root>
);
}
export default FilterDateRange;
@@ -0,0 +1,230 @@
"use client";
import { memo, useCallback, useContext, useMemo } from "react";
import clsx from "clsx";
import { ContextCalendar } from "../RangeDatePicker/RangeDatePicker";
import { PropsCalendarMain } from "./interface";
const months = Array.from({ length: 12 }, (_, i) => i + 1);
function CalendarMain({
date,
setDate,
setType,
type,
year,
}: PropsCalendarMain) {
// ✅ destructure context (fix React Compiler)
const { datePicker, dateHover, setDatePicker, setDateHover } =
useContext<any>(ContextCalendar);
// ===================== HOVER RANGE =====================
const hoverRange = useMemo(() => {
if (datePicker?.from && dateHover) {
return dateHover > datePicker.from
? { start: datePicker.from, end: dateHover }
: { start: dateHover, end: datePicker.from };
}
return null;
}, [datePicker?.from, dateHover]);
// ===================== HANDLE PICK =====================
const handleDatePick = useCallback(
(datePick: Date) => {
if (!datePicker?.from || datePicker?.to) {
return setDatePicker({
from: datePick,
to: null,
});
}
setDateHover(null);
if (datePick < datePicker.from) {
return setDatePicker({
from: datePick,
to: datePicker.from,
});
}
return setDatePicker({
...datePicker,
to: datePick,
});
},
[datePicker, setDatePicker, setDateHover],
);
// ===================== HANDLE HOVER =====================
const handleHover = useCallback(
(d: Date) => {
if (datePicker?.from && !datePicker?.to) {
setDateHover(d);
}
},
[datePicker?.from, datePicker?.to, setDateHover],
);
// ===================== BUILD CALENDAR =====================
const rows = useMemo(() => {
const currentDate = new Date();
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0);
const startDate = new Date(monthStart);
const result: React.ReactNode[] = [];
// lùi về CN
while (startDate.getDay() !== 0) {
startDate.setDate(startDate.getDate() - 1);
}
while (startDate <= monthEnd) {
const cells: React.ReactNode[] = [];
for (let i = 0; i < 7; i++) {
const currDate = new Date(startDate);
const isStart = currDate.getTime() === datePicker?.from?.getTime();
const isEnd = currDate.getTime() === datePicker?.to?.getTime();
const isBetween =
datePicker?.from &&
datePicker?.to &&
currDate > datePicker.from &&
currDate < datePicker.to;
const isHover =
hoverRange &&
currDate > hoverRange.start &&
currDate < hoverRange.end;
const isStartHover =
hoverRange && currDate.getTime() === hoverRange.start.getTime();
const isEndHover =
hoverRange && currDate.getTime() === hoverRange.end.getTime();
const isDisabled = startDate.getMonth() !== date.getMonth();
const isToday =
startDate.toDateString() === currentDate.toDateString() &&
startDate.getMonth() === date.getMonth();
cells.push(
<div
key={startDate.getTime()}
onClick={() => handleDatePick(currDate)}
onMouseOver={() => handleHover(currDate)}
className={clsx(
"flex items-center justify-center px-2 py-1 text-sm font-medium rounded border border-transparent transition",
"cursor-pointer select-none hover:opacity-60",
isDisabled && "pointer-events-none opacity-30",
isToday && "border-blue-700",
isStart && "bg-blue-700 text-white rounded-l-md",
isEnd && "bg-blue-700 text-white rounded-r-md",
isBetween && "bg-gray-100",
isHover && "border-y border-blue-700",
isStartHover && "border border-blue-700 rounded-l-md",
isEndHover && "border border-blue-700 rounded-r-md",
)}
>
{startDate.getDate()}
</div>,
);
startDate.setDate(startDate.getDate() + 1);
}
result.push(
<div key={startDate.getTime()} className="grid grid-cols-7 gap-[2px]">
{cells}
</div>,
);
}
return result;
}, [
date,
datePicker?.from,
datePicker?.to,
handleDatePick,
handleHover,
hoverRange,
]);
// ===================== YEAR LIST =====================
const listYear = useMemo(() => {
if (!year) return [];
return Array.from(
{ length: year.last - year.first + 1 },
(_, i) => year.first + i,
);
}, [year]);
// ===================== UI =====================
return (
<div onMouseLeave={() => setDateHover(null)}>
{/* DATE */}
{type === 0 && rows}
{/* MONTH */}
{type === 1 && (
<div className="grid grid-cols-3 gap-1.5 min-w-[280px]">
{months.map((m) => (
<div
key={m}
onClick={() => {
const newDate = new Date(date);
newDate.setMonth(m - 1);
setDate(newDate);
setType(0);
}}
className={clsx(
"cursor-pointer select-none rounded px-2 py-1 text-center text-sm font-semibold transition",
"hover:bg-gray-100 hover:opacity-70",
m === date.getMonth() + 1 &&
"bg-blue-500 text-white hover:bg-blue-500 hover:opacity-100",
)}
>
Tháng {m}
</div>
))}
</div>
)}
{/* YEAR */}
{type === 2 && (
<div className="grid grid-cols-3 gap-1.5 min-w-[280px]">
{listYear.map((y) => (
<div
key={y}
onClick={() => {
const newDate = new Date(date);
newDate.setFullYear(y);
setDate(newDate);
setType(1);
}}
className={clsx(
"cursor-pointer select-none rounded px-2 py-1 text-center text-sm font-semibold transition",
"hover:bg-gray-100 hover:opacity-70",
y === date.getFullYear() &&
"bg-blue-500 text-white hover:bg-blue-500 hover:opacity-100",
)}
>
{y}
</div>
))}
</div>
)}
</div>
);
}
export default memo(CalendarMain);
@@ -0,0 +1 @@
export { default } from "./CalendarMain";
@@ -0,0 +1,7 @@
export interface PropsCalendarMain {
date: Date;
setDate: (date: Date) => void;
setType: (num: number) => void;
type: number;
year: any;
}
@@ -0,0 +1,80 @@
"use client";
import clsx from "clsx";
import { Check } from "lucide-react";
import { PropsDateOption } from "./interface";
import { TYPE_DATE } from "@/constant/config/enum";
import { getDateRange } from "@/common/funcs/selectData";
import { ListOptionFilterDate } from "@/constant/config";
import RangeDatePicker from "../RangeDatePicker";
export default function DateOption({
showOptionAll,
date,
setDate,
typeDate,
setTypeDate,
show,
setShow,
}: PropsDateOption) {
const isCustom = Number(typeDate) === TYPE_DATE.LUA_CHON;
return (
<div className="flex gap-2">
{/* MENU */}
<div
className="
w-[180px]
rounded-2xl
bg-white
p-2
shadow-xl
"
>
{(showOptionAll
? ListOptionFilterDate
: ListOptionFilterDate.filter((item) => item.value !== TYPE_DATE.ALL)
).map((item) => {
const active = item.value === typeDate;
return (
<div
key={item.value}
onClick={() => {
setTypeDate(item.value);
if (item.value !== TYPE_DATE.LUA_CHON) {
setDate(getDateRange(item.value));
setShow(false);
}
}}
className={clsx(
"flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm",
active ? "bg-blue-50 text-blue-600" : "hover:bg-gray-50",
)}
>
<span>{item.name}</span>
{active && item.value !== TYPE_DATE.LUA_CHON && (
<Check size={16} />
)}
</div>
);
})}
</div>
{/* DATE PICKER */}
{isCustom && (
<RangeDatePicker
value={date}
onSetValue={setDate}
open={show}
onClose={() => setShow(false)}
/>
)}
</div>
);
}
@@ -0,0 +1 @@
export { default } from "./DateOption";
@@ -0,0 +1,12 @@
export interface PropsDateOption {
showOptionAll?: boolean;
date: {
from: Date | null;
to: Date | null;
} | null;
setDate: (any: any) => void;
typeDate: number | null;
setTypeDate: (any: any) => void;
show: boolean;
setShow: (any: any) => void;
}
@@ -0,0 +1,219 @@
"use client";
import { createContext, memo, useCallback, useEffect, useState } from "react";
import { PropsRangeDatePicker } from "./interface";
import clsx from "clsx";
import { ChevronLeft, MoveRight, ChevronRight } from "lucide-react";
import CalendarMain from "../CalendarMain";
import Button from "@/components/customs/custom-button";
import Moment from "react-moment";
const daysOfWeek = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
export const ContextCalendar = createContext<any>(null);
function RangeDatePicker({
onClose,
onSetValue,
value,
open,
onSubmit,
}: PropsRangeDatePicker) {
const [typeCalendarLeft, setTypeCalendarLeft] = useState(0);
const [typeCalendarRight, setTypeCalendarRight] = useState(0);
const [dateLeft, setDateLeft] = useState(new Date());
const [dateRight, setDateRight] = useState(
new Date(new Date().getFullYear(), new Date().getMonth() + 1),
);
const [dateHover, setDateHover] = useState<Date | null>(null);
const [yearTableLeft, setYearTableLeft] = useState<any>();
const [yearTableRight, setYearTableRight] = useState<any>();
const [datePicker, setDatePicker] = useState({
from: null as Date | null,
to: null as Date | null,
});
// ===== HANDLE =====
const handleNext = useCallback(
(isRight?: boolean) => {
if (isRight) {
if (typeCalendarRight === 0) {
setDateRight((prev) => {
const newDate = new Date(prev.getFullYear(), prev.getMonth() + 1);
return newDate;
});
}
} else {
if (typeCalendarLeft === 0) {
setDateLeft((prev) => {
const newDate = new Date(prev.getFullYear(), prev.getMonth() + 1);
return newDate < dateRight ? newDate : prev;
});
}
}
},
[dateRight, typeCalendarLeft, typeCalendarRight],
);
const handlePrev = useCallback(
(isRight?: boolean) => {
if (isRight) {
if (typeCalendarRight === 0) {
setDateRight((prev) => {
const newDate = new Date(prev.getFullYear(), prev.getMonth() - 1);
return dateLeft < newDate ? newDate : prev;
});
}
} else {
if (typeCalendarLeft === 0) {
setDateLeft((prev) => {
return new Date(prev.getFullYear(), prev.getMonth() - 1);
});
}
}
},
[dateLeft, typeCalendarLeft, typeCalendarRight],
);
const handleSubmit = useCallback(() => {
if (datePicker.from && datePicker.to) {
onSetValue(datePicker);
onClose();
onSubmit && onSubmit();
}
}, [datePicker]);
// ===== EFFECT =====
useEffect(() => {
if (!open) {
setDatePicker({ from: value?.from, to: value?.to });
}
}, [open, value]);
useEffect(() => {
setYearTableLeft({
first: dateLeft.getFullYear() - 5,
last: dateLeft.getFullYear() + 6,
});
setYearTableRight({
first: dateRight.getFullYear() - 5,
last: dateRight.getFullYear() + 6,
});
}, [dateLeft, dateRight]);
return (
<ContextCalendar.Provider
value={{ datePicker, setDateHover, dateHover, setDatePicker }}
>
<div className="rounded bg-white shadow-[0px_137px_123px_rgba(175,175,175,0.11),0px_59px_64px_rgba(175,175,175,0.08),0px_29px_43px_rgba(175,175,175,0.07)]">
{/* HEADER DATE */}
<div className="flex items-center gap-3 border-b p-3">
<span
className={clsx(
"text-sm font-medium text-gray-400",
datePicker.from && "font-semibold text-[#23262f]",
)}
>
{datePicker.from ? (
<Moment format="DD/MM/YYYY">{datePicker.from}</Moment>
) : (
"Ngày bắt đầu"
)}
</span>
<MoveRight size={20} />
<span
className={clsx(
"text-sm font-medium text-gray-400",
datePicker.to && "font-semibold text-[#23262f]",
)}
>
{datePicker.to ? (
<Moment format="DD/MM/YYYY">{datePicker.to}</Moment>
) : (
"Ngày kết thúc"
)}
</span>
</div>
{/* CALENDAR */}
<div className="flex max-md:flex-col">
{[false, true].map((isRight, idx) => {
const date = isRight ? dateRight : dateLeft;
const type = isRight ? typeCalendarRight : typeCalendarLeft;
const setType = isRight
? setTypeCalendarRight
: setTypeCalendarLeft;
const year = isRight ? yearTableRight : yearTableLeft;
return (
<div key={idx} className="p-3">
{/* TITLE */}
<div className="mb-3 flex items-center justify-between border-b pb-3">
<button
onClick={() => handlePrev(isRight)}
className="cursor-pointer disabled:opacity-10"
>
<ChevronLeft size={20} />
</button>
<p
className="cursor-pointer text-base font-semibold"
onClick={() => setType(type === 0 ? 1 : type === 1 ? 2 : 0)}
>
Tháng {date.getMonth() + 1}, {date.getFullYear()}
</p>
<button
onClick={() => handleNext(isRight)}
className="cursor-pointer"
>
<ChevronRight size={20} />
</button>
</div>
{/* DAYS */}
{type === 0 && (
<div className="grid grid-cols-7">
{daysOfWeek.map((d) => (
<div
key={d}
className="flex items-center justify-center px-2 py-1 text-sm font-semibold"
>
{d}
</div>
))}
</div>
)}
{/* CALENDAR MAIN */}
<CalendarMain
date={date}
setDate={isRight ? setDateRight : setDateLeft}
setType={setType}
type={type}
year={year}
/>
</div>
);
})}
</div>
{/* BUTTON */}
<div className="grid grid-cols-2 gap-3 p-3 pt-0">
<Button onClick={onClose}>Hủy bỏ</Button>
<Button
onClick={handleSubmit}
disable={!datePicker.from || !datePicker.to}
>
Áp dụng
</Button>
</div>
</div>
</ContextCalendar.Provider>
);
}
export default memo(RangeDatePicker);
@@ -0,0 +1 @@
export { default } from "./RangeDatePicker";
@@ -0,0 +1,7 @@
export interface PropsRangeDatePicker {
onClose: () => void;
onSetValue: (any: any) => void;
onSubmit?: () => void;
value: any;
open?: boolean;
}
@@ -0,0 +1 @@
export { default } from "./FilterDateRange";
@@ -0,0 +1,17 @@
import { Dispatch, SetStateAction } from "react";
import { TYPE_DATE } from "@/constant/config/enum";
export interface PropsFilterDateRange {
styleRounded?: boolean;
showOptionAll?: boolean;
date: {
from: Date | null;
to: Date | null;
} | null;
setDate: Dispatch<
SetStateAction<{ from: Date | null; to: Date | null } | null>
>;
typeDate: TYPE_DATE;
setTypeDate: Dispatch<SetStateAction<TYPE_DATE>>;
title?: string;
}