feat update

This commit is contained in:
TuanVT
2026-08-13 14:20:20 +07:00
parent 5c37d8ad00
commit a449c1799a
78 changed files with 1641 additions and 7652 deletions
+8 -2
View File
@@ -1,5 +1,5 @@
{
"name": "kham-benh-mo-pho",
"name": "TEDI-DDP",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -11,6 +11,8 @@
"dependencies": {
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.8",
"@react-three/fiber": "^9.7.0",
"@reduxjs/toolkit": "^2.12.0",
"@tanstack/react-query": "^5.100.11",
"axios": "^1.16.1",
@@ -25,7 +27,10 @@
"react-dom": "19.2.4",
"react-moment": "^2.0.2",
"react-redux": "^9.3.0",
"react-toastify": "^11.1.0"
"react-toastify": "^11.1.0",
"three": "^0.185.1",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -34,6 +39,7 @@
"@types/nprogress": "^0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 KiB

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

-16
View File
@@ -1,16 +0,0 @@
"use client";
import React from "react";
import BaseLayout from "@/components/layouts/BaseLayout";
import DetailConsultation from "@/components/page/consultation/DetailConsultation";
const Page = () => {
return (
<BaseLayout title="Chi tiết phiên khám">
<DetailConsultation />
</BaseLayout>
);
};
export default Page;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import CreateConsultation from "@/components/page/consultation/CreateConsultation";
import React from "react";
const page = () => {
return (
<BaseLayout title="Tạo phiên khám">
<CreateConsultation />
</BaseLayout>
);
};
export default page;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import TableHistoryPatient from "@/components/page/consultation/TableHistoryPatient";
import React from "react";
const page = () => {
return (
<BaseLayout title="Lịch sử các phiên khám">
<TableHistoryPatient />
</BaseLayout>
);
};
export default page;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPageConsultation from "@/components/page/consultation/MainPageConsultation";
import React from "react";
const page = () => {
return (
<BaseLayout title="Phiên khám">
<MainPageConsultation />
</BaseLayout>
);
};
export default page;
@@ -1,14 +0,0 @@
"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;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import UpdateConsultation from "@/components/page/consultation/UpdateConsultation";
import React from "react";
const page = () => {
return (
<BaseLayout title="Cập nhật phiên khám">
<UpdateConsultation />
</BaseLayout>
);
};
export default page;
-15
View File
@@ -1,15 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout";
import MainPageHome from "@/components/page/Home/MainPageHome";
import React from "react";
const page = () => {
return (
<BaseLayout title="Dashboard">
<MainPageHome />
</BaseLayout>
);
};
export default page;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+1 -1
View File
@@ -15,7 +15,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Khám Bệnh Mơ Phố",
title: "Core BIM tedi",
description: "Generated by create next app",
};
+2 -1
View File
@@ -6,9 +6,10 @@ import React from "react";
const page = () => {
return (
<BaseLayout title="Dashboard">
<BaseLayout title="">
<MainPageHome />
</BaseLayout>
// <h1>Home</h1>
);
};
-16
View File
@@ -1,16 +0,0 @@
"use client";
import React from "react";
import DetailPatient from "@/components/page/patient/DetailPatient";
import BaseLayout from "@/components/layouts/BaseLayout";
const Page = () => {
return (
<BaseLayout title="Chi tiết bệnh nhân">
<DetailPatient />
</BaseLayout>
);
};
export default Page;
-17
View File
@@ -1,17 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPagePatient from "@/components/page/patient/MainPagePatient/MainPagePatient";
import React from "react";
const page = () => {
return (
<div>
<BaseLayout title="Bệnh nhân">
<MainPagePatient />
</BaseLayout>
</div>
);
};
export default page;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import DetailPrescription from "@/components/page/prescription/DetailPrescription";
import React from "react";
const page = () => {
return (
<BaseLayout title="Chi tiết đơn thuốc">
<DetailPrescription />
</BaseLayout>
);
};
export default page;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import CreatePrescription from "@/components/page/prescription/CreatePrescription";
import React from "react";
const page = () => {
return (
<BaseLayout title="Tạo đơn thuốc">
<CreatePrescription />
</BaseLayout>
);
};
export default page;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPrescription from "@/components/page/prescription/MainPrescription";
import React from "react";
const page = () => {
return (
<BaseLayout title="Đơn thuốc">
<MainPrescription />
</BaseLayout>
);
};
export default page;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPageProfile from "@/components/page/profile/MainPageProfile";
import React from "react";
const page = () => {
return (
<BaseLayout title="Thông tin cá nhân">
<MainPageProfile />
</BaseLayout>
);
};
export default page;
-14
View File
@@ -1,14 +0,0 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainUpdateProfile from "@/components/page/profile/MainUpdateProfile";
import React from "react";
const page = () => {
return (
<BaseLayout title="Cập nhật thông tin cá nhân">
<MainUpdateProfile />
</BaseLayout>
);
};
export default page;
-14
View File
@@ -1,14 +0,0 @@
"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;
@@ -0,0 +1,34 @@
import { z } from "zod";
export const SuperTBeamSchema = z.object({
id: z.string().uuid(),
code: z.string().min(1, "Mã dầm không được để trống"),
// Thông số hình học dầm (mm)
length: z
.number()
.min(12000, "Chiều dài dầm tối thiểu 12,000mm")
.max(40000, "Chiều dài dầm tối đa 40,000mm"),
height: z
.number()
.min(1000, "Chiều cao tối thiểu 1,000mm")
.max(2500, "Chiều cao tối đa 2,500mm"),
topFlangeWidth: z.number().min(1500).max(2500),
topFlangeThickness: z.number().min(150).max(400),
webThickness: z.number().min(100).max(300),
bottomFlangeWidth: z.number().min(500).max(1200),
bottomFlangeThickness: z.number().min(200).max(500),
// Cấu kiện kèm theo
diaphragmPositions: z.array(z.number()), // Các vị trí dầm ngang tính từ đầu dầm (mm)
strandCount: z.number().int().min(2).max(60), // Số lượng tao cáp DƯL
// Vị trí lắp đặt kết cấu nhịp
placement: z.object({
station: z.number(), // Lý trình (m)
skewAngle: z.number().min(-45).max(45), // Góc chéo (độ)
offsetY: z.number(),
offsetZ: z.number(),
}),
});
export type SuperTBeamData = z.infer<typeof SuperTBeamSchema>;
@@ -0,0 +1,51 @@
import { SuperTBeamData } from "../schema/superT.schema";
export interface ValidationIssue {
type: "error" | "warning";
field: string;
message: string;
}
export function validateSuperTEngineering(
data: SuperTBeamData,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// Rule 1: Tỷ lệ chiều cao / chiều dài dầm (H/L) nằm trong khoảng 1/15 đến 1/25
const ratio = data.height / data.length;
if (ratio < 1 / 25) {
issues.push({
type: "warning",
field: "height",
message: `Tỷ lệ H/L = (1/${(1 / ratio).toFixed(1)}) nhỏ hơn 1/25. Dầm có nguy cơ bị võng quá mức cho phép!`,
});
} else if (ratio > 1 / 15) {
issues.push({
type: "warning",
field: "height",
message: `Tỷ lệ H/L = (1/${(1 / ratio).toFixed(1)}) lớn hơn 1/15. Dầm quá cứng, lãng phí vật liệu!`,
});
}
// Rule 2: Kiểm tra bề dày sườn dầm với bề rộng cánh dưới
if (data.webThickness * 2 >= data.bottomFlangeWidth) {
issues.push({
type: "error",
field: "webThickness",
message: "Bề dày sườn dầm quá lớn so với cánh dưới!",
});
}
// Rule 3: Kiểm tra vị trí dầm ngang không vượt quá chiều dài dầm
data.diaphragmPositions.forEach((pos, idx) => {
if (pos < 0 || pos > data.length) {
issues.push({
type: "error",
field: `diaphragmPositions[${idx}]`,
message: `Dầm ngang thứ ${idx + 1} ở vị trí ${pos}mm nằm ngoài phạm vi chiều dài dầm!`,
});
}
});
return issues;
}
@@ -1,9 +1,9 @@
"use client";
import React from "react";
import { PropsBaseLayout, TContextBaseLayout } from "./interface";
import clsx from "clsx";
import Header from "./componets/Header";
import Navbar from "./componets/Navbar";
import RequireAuth from "@/components/protected/RequiredAuth";
export const ContextBaseLayout = React.createContext<TContextBaseLayout>({});
@@ -15,49 +15,22 @@ const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
return (
<RequireAuth>
<ContextBaseLayout
value={{ showFull, setShowFull, openMenuMobile, setOpenMenuMobile }}
value={{
showFull,
setShowFull,
openMenuMobile,
setOpenMenuMobile,
}}
>
<div className="min-h-screen bg-[#f8f8f8]">
{/* OVERLAY cho Mobile */}
<div
className={clsx(
"fixed inset-0 bg-black/50 backdrop-blur-sm z-[20] transition-opacity duration-300 xl:hidden",
openMenuMobile ? "opacity-100 visible" : "opacity-0 invisible",
)}
onClick={() => setOpenMenuMobile(false)}
/>
{/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
<nav
className={clsx(
"fixed top-0 left-0 h-screen bg-white border-r border-[#f4f7fa] z-[30] transition-all duration-300 ease-in-out",
/* DESKTOP */
showFull
? "xl:w-[240px] xl:translate-x-0"
: "xl:w-[240px] xl:-translate-x-full",
/* MOBILE + TABLET */
openMenuMobile
? "translate-x-0 w-[240px]"
: "-translate-x-full w-[240px]",
)}
>
<Navbar />
</nav>
<div className="min-h-screen w-full overflow-x-hidden bg-[#f8f8f8]">
{/* HEADER */}
<header
className={clsx(
"fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]",
/* DESKTOP */
showFull
? "xl:left-[240px] xl:w-[calc(100%-240px)]"
: "xl:left-0 xl:w-full",
/* MOBILE */
"left-0 w-full",
"top-0 right-0 z-[11] h-[68px]",
"w-full",
"border-b border-[#f4f7fa]",
"bg-white",
"transition-all duration-300",
)}
>
<Header title={title} breadcrumb={breadcrumb} />
@@ -66,12 +39,10 @@ const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
{/* MAIN CONTENT */}
<main
className={clsx(
"pt-[92px] pb-6 px-6 transition-all duration-300 ease-in-out",
/* DESKTOP */
showFull ? "xl:ml-[240px]" : "xl:ml-0",
/* MOBILE */
"w-full",
"min-w-0",
"overflow-x-hidden",
"transition-all duration-300 ease-in-out",
"ml-0",
)}
>
@@ -12,7 +12,7 @@ import { ChevronDown } from "lucide-react";
import clsx from "clsx";
import MenuProfile from "../MenuProfile";
import { useQuery } from "@tanstack/react-query";
import profileServices from "@/services/profileServices";
// import profileServices from "@/services/profileServices";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
@@ -41,38 +41,38 @@ const Header = ({ title, breadcrumb }: PropsHeader) => {
/* =========================================================
CALL API GET LIST PROFILE & KHỚP NỐI THEO CHUẨN DATA MỚI
========================================================= */
const profileQuery = useQuery<any>({
queryKey: [QUERY_KEY.table_list_profile],
queryFn: async () => {
const res = await httpRequest<any>({
showMessageFailed: true,
http: profileServices.getProfile(), // Đảm bảo endpoint trỏ về /api/v1/UserProfile/list
});
// const profileQuery = useQuery<any>({
// queryKey: [QUERY_KEY.table_list_profile],
// queryFn: async () => {
// const res = await httpRequest<any>({
// showMessageFailed: true,
// http: profileServices.getProfile(), // Đảm bảo endpoint trỏ về /api/v1/UserProfile/list
// });
// Trả về đúng object chứa items theo cấu trúc của API
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
// // Trả về đúng object chứa items theo cấu trúc của API
// return (
// res || {
// items: [],
// page: 1,
// pageSize: 10,
// total: 0,
// totalPages: 0,
// }
// );
// },
// });
// Tìm kiếm thông tin profile của user hiện tại đang đăng nhập bằng useMemo
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const currentUserProfile = useMemo(() => {
const listItems = profileQuery.data?.items || [];
if (!infoUser?.userId) return null;
// const currentUserProfile = useMemo(() => {
// const listItems = profileQuery.data?.items || [];
// if (!infoUser?.userId) return null;
// So khớp accountId của API trả về với userId trong Redux (infoUser)
return (
listItems.find((item: any) => item.accountId === infoUser.userId) || null
);
}, [profileQuery.data, infoUser?.userId]);
// // So khớp accountId của API trả về với userId trong Redux (infoUser)
// return (
// listItems.find((item: any) => item.accountId === infoUser.userId) || null
// );
// }, [profileQuery.data, infoUser?.userId]);
return (
<div className="h-full w-full px-6 flex items-center justify-between bg-white relative">
@@ -80,21 +80,21 @@ const Header = ({ title, breadcrumb }: PropsHeader) => {
{/* Nút bấm Mobile: Mở menu mobile */}
<div
className="xl:hidden cursor-pointer"
onClick={() => context?.setOpenMenuMobile?.(!context?.openMenuMobile)}
// onClick={() => context?.setOpenMenuMobile?.(!context?.openMenuMobile)}
>
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
<Image src={icons.LogoTedi} alt="menu" width={20} height={20} />
</div>
{/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */}
<div
className="hidden xl:block cursor-pointer"
onClick={() => context?.setShowFull?.(!context?.showFull)}
// onClick={() => context?.setShowFull?.(!context?.showFull)}
>
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
<Image src={icons.LogoTedi} alt="menu" width={20} height={20} />
</div>
{breadcrumb ? (
breadcrumb
) : (
<h4 className="text-[18px] font-bold text-[#141416]">{title}</h4>
<h4 className="text-[18px] font-bold text-[#141416]">Công ty Tedi</h4>
)}
</div>
@@ -110,11 +110,7 @@ const Header = ({ title, breadcrumb }: PropsHeader) => {
>
<div className="w-10 h-10 rounded-full border-2 border-blue-500 overflow-hidden relative">
<Image
src={
currentUserProfile?.avatarUrl
? `${process.env.NEXT_PUBLIC_IMAGE}${currentUserProfile.avatarUrl}`
: icons.avatar
}
src={icons.avatar}
alt="avatar"
width={40}
height={40}
@@ -122,7 +118,7 @@ const Header = ({ title, breadcrumb }: PropsHeader) => {
/>
</div>
<p className="hidden md:block text-sm font-semibold text-[#171832]">
{currentUserProfile?.fullName || "User admin"}
{"User admin"}
</p>
<ChevronDown
size={16}
@@ -51,21 +51,6 @@ const MenuProfile = ({ onClose }: PropsMenuProfile) => {
return (
<div className="w-[260px] bg-white rounded-xl shadow-md p-2">
{/* PROFILE */}
<Link
href={PATH.PROFILE}
onClick={onClose}
className={clsx(
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
checkActive(PATH.PROFILE) && "bg-gray-100",
)}
>
<ShieldUser size={20} />
<div>
<p className="text-sm font-medium">Thông tin nhân</p>
<p className="text-xs text-gray-500">Chi tiết tài khoản</p>
</div>
</Link>
<div className="h-px bg-gray-200 my-1" />
{/* LOGOUT */}
<div
@@ -1,340 +1,27 @@
"use client";
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";
import dynamic from "next/dynamic";
import BeamForm from "./components/BeamForm";
import { BridgeTreeSidebar } from "@/components/utils/BridgeTreeSidebar";
const MainPageHome = () => {
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,
}),
});
const Canvas3D = dynamic(
() => import("@/components/viewer3d/Canvas3D").then((m) => m.Canvas3D),
{ ssr: false },
);
export default function Home() {
return (
res || {
items: [],
page: 1,
pageSize: 100,
total: 0,
totalPages: 0,
}
<main className="w-screen h-screen flex overflow-hidden bg-slate-950">
{/* 1. Cột Menu cây thư mục (Trái) */}
<BridgeTreeSidebar />
{/* 2. Cột Form nhập liệu theo Tab (Giữa) */}
<BeamForm />
{/* 3. Cột Viewport 3D Focus theo Cấu kiện (Phải) */}
<div className="flex-1 h-full relative">
<Canvas3D />
</div>
</main>
);
},
});
const reportQuery = useQuery<ReportResponse>({
queryKey: [QUERY_KEY.table_list_report, today],
queryFn: async () => {
const res = await httpRequest<ReportResponse>({
showMessageFailed: true,
http: consultationServices.getConsultationsReport({
FromDate: 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;
}
@@ -0,0 +1,297 @@
"use client";
import React from "react";
import { Download, AlertTriangle, CheckCircle2, Layers } from "lucide-react";
import { useSuperTStore } from "@/hooks/useSuperTStore";
export function BeamForm() {
const {
activeTab,
beamData,
setBeamData,
bridgeData,
setBridgeData,
isAssemblyMode,
setIsAssemblyMode,
validationIssues,
exportJSONContract,
} = useSuperTStore();
const handleExportContract = () => {
const jsonString = exportJSONContract();
const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `TEDI_DDP_${activeTab}_${Date.now()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className="w-[360px] h-full bg-slate-900 border-r border-slate-800 text-slate-100 p-4 overflow-y-auto flex flex-col gap-5 shrink-0 select-none">
{/* Nút bật/tắt Chế độ Ghép Cầu */}
<button
onClick={() => setIsAssemblyMode(!isAssemblyMode)}
className={`w-full py-2 px-3 rounded text-xs font-semibold flex items-center justify-center gap-2 transition-all border ${
isAssemblyMode
? "bg-emerald-600 hover:bg-emerald-500 border-emerald-400 text-white shadow-lg shadow-emerald-600/20"
: "bg-slate-800 hover:bg-slate-700 border-slate-700 text-slate-300"
}`}
>
<Layers className="w-4 h-4 shrink-0" />
<span>
{isAssemblyMode
? "Chế độ: Ghép toàn bộ cầu"
: "Xem riêng lẻ cấu kiện"}
</span>
</button>
{/* Dynamic Form Content */}
<div className="flex-1 space-y-4">
{/* 1.1 DẦM SUPER-T */}
{activeTab === "superTBeam" && (
<div className="space-y-3">
<h2 className="text-xs font-bold text-sky-400 uppercase tracking-wider">
1.1 Thông số Dầm Super-T
</h2>
<div className="space-y-2">
<div>
<label className="text-xs text-slate-400 block mb-1">
Chiều dài L (mm):
</label>
<input
type="number"
value={beamData.length}
onChange={(e) =>
setBeamData({ length: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none transition-colors"
/>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">
Chiều cao H (mm):
</label>
<input
type="number"
value={beamData.height}
onChange={(e) =>
setBeamData({ height: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none transition-colors"
/>
</div>
</div>
{/* Validation Panel */}
<div className="pt-2">
{validationIssues && validationIssues.length > 0 ? (
<div className="p-2.5 bg-rose-500/10 border border-rose-500/30 rounded text-rose-400 text-xs space-y-1">
<div className="flex items-center gap-1.5 font-semibold">
<AlertTriangle className="w-3.5 h-3.5" />
<span>Cảnh báo kỹ thuật:</span>
</div>
<ul className="list-disc list-inside text-[11px] space-y-0.5 text-rose-300">
{validationIssues.map((issue, idx) => (
<li key={idx}>{issue.message}</li>
))}
</ul>
</div>
) : (
<div className="p-2 bg-emerald-500/10 border border-emerald-500/30 rounded text-emerald-400 text-xs flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5" />
<span>Kích thước dầm hợp lệ (AASHTO / TCVN)</span>
</div>
)}
</div>
</div>
)}
{/* 1.2 BẢN MẶT CẦU */}
{activeTab === "deck" && (
<div className="space-y-3">
<h2 className="text-xs font-bold text-sky-400 uppercase tracking-wider">
1.2 Bản Mặt Cầu
</h2>
<div>
<label className="text-xs text-slate-400 block mb-1">
Bề rộng bản mặt cầu (mm):
</label>
<input
type="number"
value={bridgeData.deck.width}
onChange={(e) =>
setBridgeData("deck", { width: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none"
/>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">
Chiều dày bản (mm):
</label>
<input
type="number"
value={bridgeData.deck.thickness}
onChange={(e) =>
setBridgeData("deck", { thickness: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none"
/>
</div>
</div>
)}
{/* 1.3 DẦM NGANG */}
{activeTab === "diaphragm" && (
<div className="space-y-3">
<h2 className="text-xs font-bold text-sky-400 uppercase tracking-wider">
1.3 Dầm Ngang / Diaphragms
</h2>
<div>
<label className="text-xs text-slate-400 block mb-1">
Chiều dày dầm ngang (mm):
</label>
<input
type="number"
value={bridgeData.diaphragm.thickness}
onChange={(e) =>
setBridgeData("diaphragm", {
thickness: Number(e.target.value),
})
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none"
/>
</div>
</div>
)}
{/* 1.4 LAN CAN */}
{activeTab === "railing" && (
<div className="space-y-3">
<h2 className="text-xs font-bold text-sky-400 uppercase tracking-wider">
1.4 Lan Can & Gờ Chắn
</h2>
<div>
<label className="text-xs text-slate-400 block mb-1">
Chiều cao lan can (mm):
</label>
<input
type="number"
value={bridgeData.railing.height}
onChange={(e) =>
setBridgeData("railing", { height: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none"
/>
</div>
</div>
)}
{/* 2.1 GỐI CẦU */}
{activeTab === "bearing" && (
<div className="space-y-3">
<h2 className="text-xs font-bold text-sky-400 uppercase tracking-wider">
2.1 Gối Cầu
</h2>
<div>
<label className="text-xs text-slate-400 block mb-1">
Chiều cao gối (mm):
</label>
<input
type="number"
value={bridgeData.bearing.height}
onChange={(e) =>
setBridgeData("bearing", { height: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none"
/>
</div>
</div>
)}
{/* 3.1 MỐ CẦU */}
{activeTab === "abutment" && (
<div className="space-y-3">
<h2 className="text-xs font-bold text-sky-400 uppercase tracking-wider">
3.1 Mố Cầu
</h2>
<div>
<label className="text-xs text-slate-400 block mb-1">
Chiều cao mố H (mm):
</label>
<input
type="number"
value={bridgeData.abutment.height}
onChange={(e) =>
setBridgeData("abutment", { height: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none"
/>
</div>
</div>
)}
{/* 3.2 TRỤ CẦU */}
{activeTab === "pier" && (
<div className="space-y-3">
<h2 className="text-xs font-bold text-sky-400 uppercase tracking-wider">
3.2 Trụ Cầu
</h2>
<div>
<label className="text-xs text-slate-400 block mb-1">
Chiều cao thân trụ (mm):
</label>
<input
type="number"
value={bridgeData.pier.height}
onChange={(e) =>
setBridgeData("pier", { height: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none"
/>
</div>
</div>
)}
{/* 3.3 CỌC / MÓNG CỌC */}
{activeTab === "pile" && (
<div className="space-y-3">
<h2 className="text-xs font-bold text-sky-400 uppercase tracking-wider">
3.3 Cọc / Móng Cọc
</h2>
<div>
<label className="text-xs text-slate-400 block mb-1">
Đưng kính cọc D (mm):
</label>
<input
type="number"
value={bridgeData.pile.diameter}
onChange={(e) =>
setBridgeData("pile", { diameter: Number(e.target.value) })
}
className="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-xs text-slate-200 focus:border-sky-500 outline-none"
/>
</div>
</div>
)}
</div>
{/* Button Xuất dữ liệu Contract */}
<button
onClick={handleExportContract}
className="mt-auto w-full bg-sky-600 hover:bg-sky-500 active:bg-sky-700 text-white font-medium py-2.5 px-3 rounded text-xs flex items-center justify-center gap-2 transition-all duration-150 shadow-lg shadow-sky-600/20"
>
<Download className="w-4 h-4 shrink-0" />
<span className="truncate">Xuất Data Contract ({activeTab})</span>
</button>
</div>
);
}
export default BeamForm;
@@ -0,0 +1 @@
export { default } from "./BeamForm";
@@ -1,320 +0,0 @@
"use client";
import React, { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import FormCustom from "@/components/utils/FormCustom/FormCustom";
import SelectForm from "@/components/utils/FormCustom/components/SelectForm";
import { httpRequest } from "@/services";
import consultationServices from "@/services/consultationServices";
import patientServices, {
PatientItem,
PatientResponse,
} from "@/services/patientServices";
import { QUERY_KEY } from "@/constant/config/enum";
import CustomButton from "@/components/customs/custom-button";
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<{
patientId: string;
chiefComplaint: string;
symptomsText: string;
vitalSigns: string;
specialtyIds: string[];
}>({
patientId: patientId || "",
chiefComplaint: "",
symptomsText: "",
vitalSigns: "",
specialtyIds: [],
});
/* =========================
GET LIST PATIENT
========================= */
const patientQuery = useQuery<PatientResponse>({
queryKey: [QUERY_KEY.table_list_patient],
queryFn: async () => {
const res = await httpRequest<PatientResponse>({
showMessageFailed: true,
http: patientServices.getPatient({
Page: 1,
PageSize: 1000,
Search: "",
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
/* =========================
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
========================= */
const patientOptions = useMemo(() => {
return patientQuery.data?.items || [];
}, [patientQuery.data]);
/* =========================
CREATE CONSULTATION
========================= */
const createConsultationMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Tạo phiên khám thành công!",
http: consultationServices.createConsultations({
patientId: form.patientId,
chiefComplaint: form.chiefComplaint,
symptomsText: form.symptomsText,
vitalSigns: form.vitalSigns,
specialtyIds: form.specialtyIds,
}),
});
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
router.back();
},
});
const handleSubmit = () => {
if (!form?.patientId) {
return toastWarn({ msg: "Vui lòng chọn bệnh nhân" });
}
if (!form?.specialtyIds) {
return toastWarn({ msg: "Vui lòng chọn chuyên khoa" });
}
createConsultationMutation.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div
className="
max-w-[95vw]
border
rounded-lg
bg-white
shadow-xl
flex
flex-col
"
>
<div className="flex items-center justify-between">
<div
className="
shrink-0
border-b
px-7
py-5
bg-white
"
>
<h2 className="text-[30px] font-semibold text-[#111827]">
Tạo phiên khám
</h2>
</div>
</div>
{/* BODY */}
<div className="flex-1 px-7 py-5">
<div className="space-y-5 mb-2">
<SelectForm
label={
<span>
Bệnh nhân
<span className="text-red-500">*</span>
</span>
}
readOnly={!!patientId}
placeholder="Chọn bệnh nhân"
value={form.patientId}
options={patientOptions}
// loading={patientQuery.isLoading}
onSelect={(item: PatientItem) =>
setForm((prev) => ({
...prev,
patientId: item.id,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
patientId: "",
}))
}
getOptionLabel={(item: PatientItem) =>
`${item.fullName} - ${item.identificationNumber}`
}
getOptionValue={(item: PatientItem) => item.id}
/>
</div>
<div className="space-y-5 mb-2">
<GridColumn col={3}>
<InputForm
label={
<span>
do khám <span className="text-red-500">*</span>
</span>
}
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={
<span>
Triệu chứng <span className="text-red-500">*</span>
</span>
}
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={
<span>
Chỉ số huyết áp, chỉ số mạch{" "}
<span className="text-red-500">*</span>
</span>
}
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={
<span>
Chuyên khoa
<span className="text-red-500">*</span>
</span>
}
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 */}
<div
className="
shrink-0
border-t
bg-white
px-7
py-5
flex
items-center
justify-end
gap-3
"
>
<CustomButton
variant="grey"
rounded="md"
onClick={() => router.back()}
>
Hủy bỏ
</CustomButton>
<CustomButton
disabled={
!form.patientId ||
!form.chiefComplaint ||
!form.symptomsText ||
!form.vitalSigns ||
!form.specialtyIds ||
createConsultationMutation.isPending
}
variant="midnightBlue"
rounded="md"
onClick={handleSubmit}
>
{createConsultationMutation.isPending
? "Đang tạo..."
: "Tạo phiên khám"}
</CustomButton>
</div>
</div>
</FormCustom>
);
};
export default CreateConsultation;
@@ -1 +0,0 @@
export { default } from "./CreateConsultation";
@@ -1,728 +0,0 @@
"use client";
import React, { useEffect, useMemo, useState } 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, Pencil } from "lucide-react";
import consultationServices, {
AttachmentFile,
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";
import Pagination from "@/components/customs/custom-pagination";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
const DetailConsultation = () => {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const specialtyId = searchParams.get("_type");
const consultationId = params?.id as string;
const [prescriptionPage, setPrescriptionPage] = useState<number>(1);
const [prescriptionPageSize, setPrescriptionPageSize] = useState<number>(10);
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: [],
}
);
},
});
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]);
// Tính toán cắt mảng dữ liệu thuốc để hiển thị phân trang trên Client
const paginatedPrescriptionItems = useMemo(() => {
const items = consultation?.aggregatedPrescriptionItems || [];
const startIndex = (prescriptionPage - 1) * prescriptionPageSize;
const endIndex = startIndex + prescriptionPageSize;
return items.slice(startIndex, endIndex);
}, [
consultation?.aggregatedPrescriptionItems,
prescriptionPage,
prescriptionPageSize,
]);
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>;
}
if (!consultation) {
return <div>Không tìm thấy dữ liệu phiên khám</div>;
}
type PrescriptionItem =
ConsultationDetail["aggregatedPrescriptionItems"][number];
// const getFileInfo = (file: AttachmentFile | string, index: number) => {
// if (typeof file === "string") {
// return {
// fileName: file.split("/").pop() || `File ${index + 1}`,
// path: file,
// };
// }
// return file;
// };
const getFileInfo = (file: AttachmentFile | string, index: number) => {
if (typeof file === "string") {
return {
fileName: file.split("/").pop() || `File ${index + 1}`,
path: `${process.env.NEXT_PUBLIC_IMAGE}${file}`,
};
}
return {
...file,
path: `${process.env.NEXT_PUBLIC_IMAGE}${file.path}`,
};
};
return (
<div className="flex flex-col gap-4">
<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>
</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.createdByName || "---"}
</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]">
FILE UPLOAD
</p>
{consultation.generalAttachmentUrls?.length > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm font-medium text-blue-600 transition hover:bg-blue-100">
<span>📎</span>
<span>Chi tiết các file</span>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="w-[350px] rounded-xl border bg-white p-4 shadow-xl"
>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-800">
Danh sách file đính kèm
</h4>
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{consultation.generalAttachmentUrls.map(
(item, index) => {
const file = getFileInfo(item, index);
const extension =
file.fileName
.split(".")
.pop()
?.toLowerCase() || "";
const isImage = [
"jpg",
"jpeg",
"png",
"gif",
"webp",
"bmp",
"svg",
].includes(extension);
return (
<a
key={index}
href={file.path}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50"
>
<div className="flex min-w-0 items-center gap-3">
<span className="text-xl">
{isImage ? "🖼️" : "📄"}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-800">
{file.fileName}
</p>
<p className="text-xs text-gray-500 uppercase">
{extension || "FILE"}
</p>
</div>
</div>
<span className="text-xs text-blue-500">
Mở
</span>
</a>
);
},
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div>Không file nào đưc tải lên</div>
)}
</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",
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>
</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>
<DataWrapper
data={consultation.aggregatedPrescriptionItems}
loading={consulDetailQuery.isLoading}
title="Không có dữ liệu"
note="Vui lòng thử lại sau"
>
<Table
// Nếu item không có thuộc tính id, bạn có thể đổi thành (item, index) => String(index)
rowKey={(item: PrescriptionItem) => {
// Tìm vị trí của item hiện tại trong mảng paginatedPrescriptionItems
const idx = paginatedPrescriptionItems.indexOf(item);
return item.id || String(idx);
}}
data={paginatedPrescriptionItems} // Hiển thị dữ liệu trang hiện tại
column={[
{
title: "Tên thuốc",
render: (item: PrescriptionItem) => (
<span className="font-medium text-gray-900">
{item.medicineName}
</span>
),
},
{
title: "Liều dùng",
render: (item: PrescriptionItem) => (
<span>{item.dosage}</span>
),
},
{
title: "Tần suất",
render: (item: PrescriptionItem) => (
<span>{item.frequency}</span>
),
},
{
title: "Thời gian",
render: (item: PrescriptionItem) => (
<span>{item.duration}</span>
),
},
{
title: "Hướng dẫn",
render: (item: PrescriptionItem) => (
<span className="text-sm text-gray-500">
{item.instructions || "---"}
</span>
),
},
]}
/>
</DataWrapper>
{/* Chỉ hiển thị phân trang khi tổng số lượng thuốc lớn hơn 10 phần tử */}
{(consultation.aggregatedPrescriptionItems?.length || 0) > 10 && (
<Pagination
total={consultation.aggregatedPrescriptionItems?.length || 0}
page={prescriptionPage}
pageSize={prescriptionPageSize}
onSetPage={(value) => setPrescriptionPage(value)}
onSetPageSize={(value) => {
setPrescriptionPageSize(value);
setPrescriptionPage(1); // Reset về trang 1 khi thay đổi kích thước hiển thị hàng
}}
dependencies={[prescriptionPage, prescriptionPageSize]}
/>
)}
</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>
{/* Thêm class để bọc text */}
<p className="break-words overflow-hidden">
{selectedSpecialty.specialtyName || "---"}
</p>
</div>
<div>
<p className="text-sm text-[#697586]">CHẨN ĐOÁN</p>
{/* Thêm class để bọc text */}
<p className="break-words overflow-hidden">
{selectedSpecialty.diagnosis || "---"}
</p>
</div>
<div>
<p className="text-sm text-[#697586]">GHI CHÚ KHÁM</p>
{/* Thêm class để bọc text */}
<p className="break-words overflow-hidden">
{selectedSpecialty.specialtyNotes || "---"}
</p>
</div>
<div>
<p className="text-sm text-[#697586]">THỦ THUẬT</p>
{/* Thêm class để bọc text */}
<p className="break-words overflow-hidden">
{selectedSpecialty.procedureNotes || "---"}
</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium uppercase tracking-wide text-[#697586]">
FILE UPLOAD
</p>
{selectedSpecialty.attachmentUrls?.length > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm font-medium text-blue-600 transition hover:bg-blue-100">
<span>📎</span>
<span>Chi tiết các file</span>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="w-[350px] rounded-xl border bg-white p-4 shadow-xl"
>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-800">
Danh sách file đính kèm
</h4>
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{selectedSpecialty.attachmentUrls.map(
(item, index) => {
const file = getFileInfo(item, index);
const extension =
file.fileName.split(".").pop()?.toLowerCase() ||
"";
const isImage = [
"jpg",
"jpeg",
"png",
"gif",
"webp",
"bmp",
"svg",
].includes(extension);
return (
<a
key={index}
href={file.path}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50"
>
<div className="flex min-w-0 items-center gap-3">
<span className="text-xl">
{isImage ? "🖼️" : "📄"}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-800">
{file.fileName}
</p>
<p className="text-xs text-gray-500 uppercase">
{extension || "FILE"}
</p>
</div>
</div>
<span className="text-xs text-blue-500">
Mở
</span>
</a>
);
},
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div>Không file nào đưc tải lên</div>
)}
</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>
<div>
<p className="text-sm text-[#697586]">ĐÃ PHÁT KÍNH CHƯA</p>
{selectedSpecialty.isGlassesDelivered ? (
<p className="bg-[#DCFCE7] text-[#166534] p-2 border rounded-full w-fit">
Đã phát kính
</p>
) : (
<p className="bg-[#fee2e2] text-[#991B1B] p-2 border rounded-full w-fit">
Chưa phát kính
</p>
)}
</div>
</>
)}
</GridColumn>
</div>
)}
</div>
);
};
export default DetailConsultation;
@@ -1 +0,0 @@
export { default } from "./DetailConsultation";
@@ -1,725 +0,0 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import CustomDialog from "@/components/customs/custom-dialog";
import DataWrapper from "@/components/customs/DataWrapper";
import Search from "@/components/customs/custom-search";
import StateActive from "@/components/customs/StateActive";
import Table from "@/components/customs/custom-table";
import { QUERY_KEY, TYPE_STATUS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import consultationServices, {
ConsultationItem,
ConsultationResponse,
} from "@/services/consultationServices";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Pencil, ClipboardPlus, Ellipsis } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
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");
const _type = searchParams.get("_type");
const isAllTab = !_type || _type === "all";
const [selectedConsultationId, setSelectedConsultationId] =
useState<string>("");
const [openCancelDialog, setOpenCancelDialog] = useState(false);
const [openCompleteDialog, setOpenCompleteDialog] = useState(false);
const consultationQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation, _page, _pageSize, _keyword],
queryFn: async () => {
const res = await httpRequest<ConsultationResponse>({
showMessageFailed: true,
http: consultationServices.getConsultations({
Page: Number(_page || 1),
PageSize: Number(_pageSize || 10),
Search: _keyword || "",
SortBy: _specialtyId || undefined,
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
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(() => {
let data = consultationQuery.data?.items || [];
// =========================
// 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(
(specialty) => specialty.specialtyId === _specialtyId,
),
);
}
// =========================
// FILTER TRẠNG THÁI
// =========================
if (_status) {
data = data.filter((item) => item.status === Number(_status));
}
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());
if (!value || value === "") {
params.delete(key);
} else {
params.set(key, String(value));
}
if (
key === "_pageSize" ||
key === "_keyword" ||
key === "_status" ||
key === "_specialtyId"
) {
params.delete("_page");
}
const queryString = params.toString();
router.push(queryString ? `?${queryString}` : pathname);
};
const consultation = consultationQuery.data;
const specialtyTabs = useMemo(() => {
const specialtyMap = new Map();
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,
});
}
});
});
return [
{
pathname: "/consultation",
query: "all",
title: "Tất cả",
},
...Array.from(specialtyMap.values()),
];
}, [consultation?.items]);
const handleOpenCancel = (item: ConsultationItem) => {
setSelectedConsultationId(item.id);
setOpenCancelDialog(true);
};
const handleOpenComplete = (item: ConsultationItem) => {
setSelectedConsultationId(item.id);
setOpenCompleteDialog(true);
};
const handleCloseDialog = () => {
setOpenCancelDialog(false);
setOpenCompleteDialog(false);
};
const cancelConsultationMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Hủy phiên khám thành công!",
http: consultationServices.cancelConsultations(selectedConsultationId),
});
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
handleCloseDialog();
},
});
const completeConsultationMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Hoàn thành phiên khám thành công!",
http: consultationServices.completeConsultations(
selectedConsultationId,
),
});
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
handleCloseDialog();
},
});
const handleConfirmCancel = () => {
cancelConsultationMutation.mutate();
};
const handleConfirmComplete = () => {
completeConsultationMutation.mutate();
};
return (
<div>
<div className="rounded-lg bg-white shadow mb-6 p-6">
<TabNavLink query="_type" listHref={specialtyTabs} />
</div>
<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>
{/* 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",
},
{
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",
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 (
/*
Chỉnh sửa tại đây:
- flex-col: xếp dọc trên màn mobile
- sm:flex-row: quay lại xếp ngang trên tablet / desktop (>= 640px)
- w-full sm:w-auto: mở rộng full chiều rộng ô khi ở màn mobile cho cân đối
*/
<div className="flex flex-col sm:flex-row items-center justify-center gap-2 w-full sm:w-auto">
<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 shrink-0",
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)}
className="w-full sm:w-auto"
>
Hủy
</CustomButton>
<CustomButton
size="sm"
variant="green"
disabled={isDisabled}
onClick={() => handleOpenComplete(item)}
className="w-full sm:w-auto"
>
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>
{/* CANCEL DIALOG */}
<CustomDialog
open={openCancelDialog}
title="Hủy phiên khám"
note="Bạn có chắc chắn muốn hủy phiên khám này không?"
type="error"
onClose={handleCloseDialog}
onSubmit={handleConfirmCancel}
titleCancel="Đóng"
titleSubmit="Xác nhận"
/>
{/* COMPLETE DIALOG */}
<CustomDialog
open={openCompleteDialog}
title="Hoàn thành phiên khám"
note="Bạn có chắc chắn muốn hoàn thành phiên khám này không?"
type="success"
onClose={handleCloseDialog}
onSubmit={handleConfirmComplete}
titleCancel="Đóng"
titleSubmit="Xác nhận"
/>
</div>
);
};
export default MainPageConsultation;
@@ -1 +0,0 @@
export { default } from "./MainPageConsultation";
@@ -1,220 +0,0 @@
"use client";
import React from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import consultationServices, {
ConsultationItem,
ConsultationResponse,
} from "@/services/consultationServices";
import { httpRequest } from "@/services";
import { QUERY_KEY, TYPE_STATUS } from "@/constant/config/enum";
import DataWrapper from "@/components/customs/DataWrapper";
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();
const router = useRouter();
const pathname = usePathname();
const patientId = searchParams.get("_id");
const consultationQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation, patientId, "full-history"],
enabled: !!patientId,
queryFn: async () => {
const res = await httpRequest<ConsultationResponse>({
showMessageFailed: true,
http: consultationServices.getConsultations({
Page: 1,
PageSize: 1000,
PatientId: patientId || "",
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
const _page = searchParams.get("_page");
const _pageSize = searchParams.get("_pageSize");
const _keyword = searchParams.get("_keyword");
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 || value === "" || (key === "_page" && value === 1)) {
params.delete(key);
} else {
params.set(key, String(value));
}
if (key === "_pageSize") {
params.delete("_page");
}
const queryString = params.toString();
router.push(queryString ? `?${queryString}` : pathname);
};
const data = consultationQuery.data?.items || [];
// lấy tên bệnh nhân từ record đầu tiên (nếu backend có trả)
const patientName = data?.[0]?.patientName || "Bệnh nhân";
return (
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">
Lịch sử phiên khám của {patientName}
</h2>
<button
onClick={() => router.back()}
className="text-blue-500 hover:underline"
>
Quay lại
</button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="w-full md:min-w-[400px]">
<Search
keyword={_keyword ?? ""}
setKeyword={(value) => updateQuery("_keyword", value)}
placeholder="Tìm kiếm phiên khám..."
/>
</div>
</div>
<DataWrapper
data={data}
loading={consultationQuery.isLoading}
title="Không có dữ liệu"
note="Bệnh nhân chưa có lịch sử khám"
>
<Table
rowKey={(item: ConsultationItem) => item.id}
data={data}
column={[
{
title: "MÃ PHIÊN KHÁM",
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: "CHẨN ĐOÁN",
render: (item: ConsultationItem) => (
<span>{item.diagnosis || "---"}</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",
},
]}
/>
),
},
]}
/>
</DataWrapper>
<Pagination
total={data.length}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery("_page", value)}
onSetPageSize={(value) => updateQuery("_pageSize", value)}
dependencies={[_pageSize, _keyword]}
/>
</div>
);
};
export default TableHistoryPatient;
@@ -1 +0,0 @@
export { default } from "./TableHistoryPatient";
@@ -1,469 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import GridColumn from "@/components/layouts/GridColumn";
import { QUERY_KEY } from "@/constant/config/enum";
import { PATH } from "@/constant/config";
import { httpRequest } from "@/services";
import consultationServices, {
ConsultationDetail,
} from "@/services/consultationServices";
import uploadServices from "@/services/uploadServices";
import UploadMultipleFile from "@/components/utils/UploadMultipleFile";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import { toastWarn } from "@/common/funcs/toast";
/* =========================
TYPES
========================= */
interface IUpdateConsul {
chiefComplaint: string;
symptomsText: string;
vitalSigns: string;
diagnosis: string;
treatmentPlan: string;
doctorNotes: string;
isGlassesDelivered: boolean;
glassType: string;
generalAttachmentUrls: string[];
}
/* =========================
DEFAULT
========================= */
const defaultForm: IUpdateConsul = {
chiefComplaint: "",
symptomsText: "",
vitalSigns: "",
diagnosis: "",
treatmentPlan: "",
doctorNotes: "",
isGlassesDelivered: false,
glassType: "",
generalAttachmentUrls: [],
};
/* =========================
COMPONENT
========================= */
const UpdateConsultation = () => {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
// Hỗ trợ lấy ID từ cả /consultation/update/[id] hoặc /consultation/update?_id=...
const idFromParams = params?.id as string | undefined;
const idFromSearch = searchParams?.get("_id") ?? undefined;
const id = idFromParams || idFromSearch || "";
const [images, setImages] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const [form, setForm] = useState<IUpdateConsul>(defaultForm);
/* =========================
GET DETAIL API
========================= */
const consulDetailQuery = useQuery<ConsultationDetail>({
queryKey: [QUERY_KEY.chi_tiet_phien_kham, id],
enabled: !!id,
queryFn: async () => {
const res = await httpRequest<ConsultationDetail>({
showMessageFailed: true,
http: consultationServices.detailConsultations(id),
});
return (
res || {
id: "",
patientId: "",
sessionCode: "",
visitDate: "",
status: 1,
chiefComplaint: null,
symptomsText: null,
vitalSigns: null,
diagnosis: null,
treatmentPlan: null,
doctorNotes: null,
createdBy: "",
isDeleted: false,
generalAttachmentUrls: [],
}
);
},
});
/* =========================
MAP & SET FORM
========================= */
const consul = consulDetailQuery.data;
useEffect(() => {
if (!consul) return;
const eyeClinic = consul?.specialtyClinics?.find(
(item) =>
item.specialtyName?.toLowerCase().includes("mắt") &&
item.glassRequired === true,
);
setForm({
chiefComplaint: consul.chiefComplaint || "",
symptomsText: consul.symptomsText || "",
vitalSigns: consul.vitalSigns || "",
diagnosis: consul.diagnosis || "",
treatmentPlan: consul.treatmentPlan || "",
doctorNotes: consul.doctorNotes || "",
isGlassesDelivered: !!eyeClinic,
glassType: eyeClinic?.glassType || "",
generalAttachmentUrls: consul.generalAttachmentUrls || [],
});
setImages(
(consul.generalAttachmentUrls || []).map((url) => ({
file: null,
img: url,
path: url,
fileName: url.split("/").pop(),
})),
);
}, [consulDetailQuery.data]);
/* =========================
UPDATE MUTATION
========================= */
const updateConsultationMutation = useMutation({
mutationFn: async (body: { paths: string[] }) => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Cập nhật phiên khám thành công!",
http: consultationServices.putConsultations(id, {
chiefComplaint: form.chiefComplaint,
symptomsText: form.symptomsText,
vitalSigns: form.vitalSigns,
diagnosis: form.diagnosis,
treatmentPlan: form.treatmentPlan,
doctorNotes: form.doctorNotes,
isGlassesDelivered: form.isGlassesDelivered,
fileUrls: body.paths,
}),
});
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.chi_tiet_phien_kham, id],
});
router.back();
router.push(PATH.CONSULTATION);
},
});
const handleSubmit = async () => {
try {
const currentImage = images
.filter((item) => !!item.img)
.map((item) => item.img);
const files = images
.filter((item) => !!item.file)
.map((item) => item.file);
if (files.length > 0) {
const uploadResult: any = await uploadServices.uploadImage({
Files: files,
SessionCode: consulDetailQuery.data?.sessionCode,
});
console.log("Cấu trúc log kiểm tra uploadResult:", uploadResult);
// CẬP NHẬT Ở ĐÂY: Lấy trực tiếp từ uploadResult hoặc bóc tách đúng tầng data
const uploadedUrls =
uploadResult?.fileUrls || uploadResult?.data?.fileUrls || [];
return updateConsultationMutation.mutate({
paths: [...currentImage, ...uploadedUrls],
});
}
return updateConsultationMutation.mutate({
paths: currentImage,
});
} catch (error) {
console.error(error);
}
};
const isLoading =
consulDetailQuery.isLoading || updateConsultationMutation.isPending;
/* =========================
LOADING UI
========================= */
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>
);
}
const eyeClinic = consul?.specialtyClinics?.find(
(item) =>
item.specialtyName?.toLowerCase().includes("mắt") &&
item.glassRequired === true,
);
/* =========================
MAIN UI
========================= */
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-[28px] font-semibold text-[#111827]">
Phiên khám
</h2>
<p className="mt-1 text-sm text-[#697586]">
Cập nhật thông tin chi tiết quá trình khám bệnh
</p>
</div>
</div>
{/* GENERAL INFO (PATIENT) */}
<InputForm
label={
<span>
Bệnh nhân <span className="text-red-500">*</span>
</span>
}
name="patientInfo"
type="text"
// Hiển thị Tên bệnh nhân - SĐT hoặc CCCD dựa trên API detail trả về
value={
consulDetailQuery.data?.patientName
? `${consulDetailQuery.data.patientName} - ${
consulDetailQuery.data.sessionCode ||
// consulDetailQuery.data.identityCard ||
""
}`
: "Chưa có thông tin bệnh nhân"
}
placeholder="Thông tin bệnh nhân"
isRequired
readOnly
/>
{/* </div> */}
<GridColumn col={3}>
<InputForm
label={
<span>
do khám <span className="text-red-500">*</span>
</span>
}
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={
<span>
Triệu chứng <span className="text-red-500">*</span>
</span>
}
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={
<span>
Chỉ số huyết áp, chỉ số mạch{" "}
<span className="text-red-500">*</span>
</span>
}
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>
<GridColumn col={2}>
<InputForm
type="text"
label={
<span>
Chẩn đoán <span className="text-red-500">*</span>
</span>
}
name="diagnosis"
placeholder="Nhập chẩn đoán"
value={form.diagnosis}
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, diagnosis: String(v) }))
}
/>
<InputForm
type="text"
label={<span>Ghi chú bác </span>}
name="doctorNotes"
placeholder="Nhập ghi chú"
value={form.doctorNotes}
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, doctorNotes: String(v) }))
}
/>
</GridColumn>
<TextArea
name="treatmentPlan"
placeholder="Nhập thông số chi tiết loại kính được chỉ định"
label={
<span>
Kế hoạch điều trị
<span className="text-red-500">*</span>
</span>
}
value={form.treatmentPlan}
isRequired
// readOnly
isBlur
max={5000}
onChangeValue={(v) =>
setForm((prev) => ({
...prev,
treatmentPlan: String(v),
}))
}
/>
{eyeClinic && (
<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">
<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.isGlassesDelivered === true}
onChange={() =>
setForm((prev) => ({
...prev,
isGlassesDelivered: true,
}))
}
/>
chỉ đnh
</label>
</div>
</div>
<TextArea
name="glassType"
placeholder="Nhập thông số chi tiết loại kính được chỉ định"
label={
<span>
Thông số / loại kính chỉ đnh
<span className="text-red-500">*</span>
</span>
}
value={form.glassType}
isRequired
readOnly
isBlur
max={5000}
onChangeValue={(v) =>
setForm((prev) => ({
...prev,
glassType: String(v),
}))
}
/>
</div>
</div>
)}
<div className="space-y-4">
<label className="text-sm font-medium">Tài liệu đính kèm</label>
<UploadMultipleFile images={images} setImages={setImages} />
{uploading && (
<p className="text-sm text-blue-500">Đang upload file...</p>
)}
</div>
<div className="flex items-center gap-3">
<CustomButton
type="button"
variant="grey"
rounded="md"
onClick={() => router.back()}
className="min-w-[120px]"
>
Hủy bỏ
</CustomButton>
<CustomButton
onClick={handleSubmit}
variant="midnightBlue"
rounded="md"
disabled={
!form.chiefComplaint ||
!form.symptomsText ||
!form.vitalSigns ||
!form.diagnosis ||
!form.treatmentPlan ||
updateConsultationMutation.isPending
}
className="min-w-[140px]"
>
{updateConsultationMutation.isPending
? "Đang cập nhật..."
: "Cập nhật"}
</CustomButton>
</div>
</div>
</FormCustom>
);
};
export default UpdateConsultation;
@@ -1 +0,0 @@
export { default } from "./UpdateConsultation";
@@ -1,482 +0,0 @@
"use client";
import { toastWarn } from "@/common/funcs/toast";
import CustomButton from "@/components/customs/custom-button";
import GridColumn from "@/components/layouts/GridColumn";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import UploadMultipleFile from "@/components/utils/UploadMultipleFile";
import { PATH } from "@/constant/config";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import consultationServices, {
ConsultationDetail,
} from "@/services/consultationServices";
import uploadServices from "@/services/uploadServices";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import moment from "moment";
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 [images, setImages] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const [form, setForm] = useState<{
specialtyAssignmentId: string;
specialtyNotes: string;
diagnosis: string;
procedureNotes: string;
glassRequired: boolean;
glassType: string;
isGlassesDelivered: boolean;
fileUrls: string[];
}>({
specialtyAssignmentId: "",
specialtyNotes: "",
diagnosis: "",
procedureNotes: "",
glassRequired: false,
glassType: "",
isGlassesDelivered: false,
fileUrls: [],
});
/* =========================
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: "",
patientName: "",
sessionCode: "",
visitDate: "",
status: 1,
chiefComplaint: null,
symptomsText: null,
vitalSigns: null,
diagnosis: null,
treatmentPlan: null,
doctorNotes: null,
isGlassesDelivered: false,
createdBy: "",
createdByName: "",
isDeleted: false,
generalAttachmentUrls: [],
specialtyClinics: [],
aggregatedPrescriptionItems: [],
}
);
},
});
// Khai báo dữ liệu lấy từ Query phục vụ hiển thị
const generalData = consulDetailQuery.data;
// Tìm chuyên khoa hiện tại từ data trả về
const selectedSpecialtyClinic = generalData?.specialtyClinics?.find(
(item) => item.id === specialtyClinicId,
);
const isEyeClinic =
selectedSpecialtyClinic?.specialtyName?.toLowerCase().includes("mắt") ||
selectedSpecialtyClinic?.specialtyId?.toLowerCase().includes("mat");
useEffect(() => {
if (!generalData) return;
const specialtyClinic = generalData.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 || "",
isGlassesDelivered: specialtyClinic.isGlassesDelivered || false,
fileUrls: specialtyClinic.attachmentUrls || [],
});
setImages(
(specialtyClinic.attachmentUrls || []).map((url) => ({
file: null,
img: url,
path: url,
})),
);
}, [generalData, specialtyClinicId]);
/* =========================
UPDATE MUTATION
========================= */
const specialtyResultsMutation = useMutation({
mutationFn: async (body: { paths: string[] }) => {
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 kết quả khám chuyên khoa thành công!",
http: consultationServices.specialtyResults(
form.specialtyAssignmentId,
{
specialtyNotes: form.specialtyNotes,
diagnosis: form.diagnosis,
procedureNotes: form.procedureNotes,
glassRequired: isEyeClinic ? form.glassRequired : false,
glassType: isEyeClinic ? form.glassType : "",
isGlassesDelivered: isEyeClinic ? form.isGlassesDelivered : false,
fileUrls: body.paths,
},
),
});
},
onSuccess(data) {
if (!data) return;
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.chi_tiet_phien_kham, consultationId],
});
router.back();
router.push(PATH.CONSULTATION || "/consultation");
},
});
const handleSubmit = async () => {
if (!form.specialtyNotes || !form.diagnosis || !form.procedureNotes) {
return toastWarn({ msg: "Vui lòng nhập đầy đủ các trường!" });
}
if (isEyeClinic && form.glassRequired === true && !form.glassType) {
return toastWarn({
msg: "Vui lòng nhập thông số / Loại kính chỉ định!",
});
}
try {
const currentImage = images
.filter((item) => !!item.img)
.map((item) => item.img);
const files = images
.filter((item) => !!item.file)
.map((item) => item.file);
if (files.length > 0) {
const uploadResult: any = await uploadServices.uploadImage({
Files: files,
SessionCode: generalData?.sessionCode,
});
const uploadedUrls =
uploadResult?.fileUrls || uploadResult?.data?.fileUrls || [];
return specialtyResultsMutation.mutate({
paths: [...currentImage, ...uploadedUrls],
});
}
return specialtyResultsMutation.mutate({
paths: currentImage,
});
} catch (error) {
console.error(error);
}
};
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>
{/* THÔNG TIN HÀNH CHÍNH & PHIÊN KHÁM TỔNG QUÁT */}
<div className="rounded-xl border border-gray-200 bg-[#f8fafc] p-5">
<h3 className="mb-4 text-xs font-bold uppercase tracking-wider text-gray-500">
Thông tin tổng quan phiên khám
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<span className="text-xs font-medium text-gray-400 block uppercase">
Tên bệnh nhân
</span>
<span className="text-base font-bold text-gray-700 mt-0.5 block">
{generalData?.patientName || "Chưa có thông tin"}
</span>
</div>
<div>
<span className="text-xs font-medium text-gray-400 block uppercase">
Ngày sinh
</span>
<span className="text-base font-medium text-gray-700 mt-0.5 block">
{generalData?.patientDateOfBirth
? moment(generalData?.patientDateOfBirth).format("DD/MM/YYYY")
: "---"}
</span>
</div>
<div>
<span className="text-xs font-medium text-gray-400 block uppercase">
phiên khám tổng quát
</span>
<span className="text-base font-semibold text-blue-600 mt-0.5 block">
#{generalData?.sessionCode || "Chưa có mã"}
</span>
</div>
</div>
</div>
{/* KHỐI THÔNG TIN KHÁM CHUYÊN KHOA */}
<div className="mt-2">
<div className="mb-4 flex items-center gap-3">
<span className="text-xs font-semibold uppercase text-slate-500">
Chuyên khoa tiếp nhận:
</span>
<div className="inline-flex items-center rounded-full bg-blue-50 px-4 py-2 text-sm font-semibold text-blue-700 border border-blue-200">
{selectedSpecialtyClinic?.specialtyName || "Chưa có thông tin"}
</div>
</div>
<GridColumn col={isEyeClinic ? 4 : 3}>
<InputForm
label={
<span>
Ghi chú chuyên khoa
<span className="text-red-500">*</span>
</span>
}
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={
<span>
Chẩn đoán chuyên khoa
<span className="text-red-500">*</span>
</span>
}
name="diagnosis"
placeholder="Nhập chẩn đoán chuyên khoa"
isRequired
value={form.diagnosis}
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, diagnosis: String(v) }))
}
/>
<InputForm
type="text"
label={
<span>
Ghi chú thủ thuật / can thiệp
<span className="text-red-500">*</span>
</span>
}
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-blue-50 p-4 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">
<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>
{form.glassRequired && (
<div>
<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={
<span>
Thông số / Loại kính chỉ đnh
<span className="text-red-500">*</span>
</span>
}
value={form.glassType}
isRequired
isBlur
max={5000}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">
Đã phát kính chưa
</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="isGlassesDelivered"
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
checked={form.isGlassesDelivered === true}
onChange={() =>
setForm((prev) => ({
...prev,
isGlassesDelivered: true,
}))
}
/>
Đã phát kính
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-gray-900">
<input
type="radio"
name="isGlassesDelivered"
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
checked={form.isGlassesDelivered === false}
onChange={() =>
setForm((prev) => ({
...prev,
isGlassesDelivered: false,
}))
}
/>
Chưa phát kính
</label>
</div>
</div>
</div>
)}
</div>
</div>
)}
<div className="space-y-4">
<label className="text-sm font-medium">
Tài liệu đính kèm chuyên khoa
</label>
<UploadMultipleFile images={images} setImages={setImages} />
{uploading && (
<p className="text-sm text-blue-500">Đang upload file...</p>
)}
</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
onClick={handleSubmit}
variant="midnightBlue"
rounded="md"
disabled={specialtyResultsMutation.isPending}
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;
@@ -1 +0,0 @@
export { default } from "./UpdateSpecialtyClinicId";
@@ -1,433 +0,0 @@
"use client";
import React, { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { ArrowLeft, ClipboardPlus } from "lucide-react";
import patientServices, { PatientDetail } from "@/services/patientServices";
import consultationServices, {
ConsultationItem,
ConsultationResponse,
} from "@/services/consultationServices";
import { httpRequest } from "@/services";
import { QUERY_KEY, TYPE_STATUS } from "@/constant/config/enum";
import { PATH } from "@/constant/config";
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 moment from "moment";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
// import CustomDialog from "@/components/customs/custom-dialog";
const DetailPatient = () => {
const params = useParams();
const router = useRouter();
// const queryClient = useQueryClient();
const patientId = params?.id as string;
/* =========================
GET DETAIL PATIENT
========================= */
const patientDetailQuery = useQuery<PatientDetail>({
queryKey: [QUERY_KEY.chi_tiet_benh_nhan, patientId],
enabled: !!patientId,
queryFn: async () => {
const res = await httpRequest<PatientDetail>({
showMessageFailed: true,
http: patientServices.detailPatient(patientId),
});
return (
res || {
id: "",
patientCode: "",
identificationNumber: "",
fullName: "",
dateOfBirth: "",
gender: "",
phone: "",
email: "",
address: "",
emergencyContactName: null,
emergencyContactPhone: null,
allergyNotes: null,
chronicDiseaseNotes: null,
insuranceNumber: null,
notes: null,
isActive: false,
}
);
},
});
/* =========================
GET CONSULTATION HISTORY
========================= */
const consultationQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation, patientId],
enabled: !!patientId,
queryFn: async () => {
const res = await httpRequest<ConsultationResponse>({
showMessageFailed: true,
http: consultationServices.getConsultations({
Page: 1,
PageSize: 5,
PatientId: patientId,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
const patient = patientDetailQuery.data;
const consultationData = consultationQuery.data?.items || [];
if (patientDetailQuery.isLoading) {
return (
<div className="flex items-center justify-center p-10 font-medium text-gray-500">
Đang tải dữ liệu...
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2
className="text-[28px] flex items-center gap-2 font-semibold text-[#111827] cursor-pointer"
onClick={() => router.back()}
>
<ArrowLeft /> {patient?.fullName}
</h2>
<div>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<ClipboardPlus size={18} />}
onClick={() =>
router.push(
`${PATH.CONSULTATION}/create?patientId=${patient?.id}`,
)
}
>
Tạo phiên khám
</CustomButton>
</div>
</div>
{/* Thông tin chi tiết bệnh nhân */}
<div className="grid grid-cols-2 gap-5">
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
bệnh nhân
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.patientCode}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">Họ tên</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.fullName}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
đnh danh
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.identificationNumber}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Ngày sinh
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.dateOfBirth
? moment(patient?.dateOfBirth).format("DD/MM/YYYY")
: "---"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Giới tính
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.gender}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Số điện thoại
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.phone}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">Email</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.email}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Đa chỉ
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.address || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Người liên hệ khẩn cấp
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.emergencyContactName || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
SĐT khẩn cấp
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.emergencyContactPhone || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">Dị ng</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.allergyNotes || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Bệnh nền
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.chronicDiseaseNotes || "-"}
</p>
</div>
</div>
</div>
</div>
</div>
{/* Lịch sử phiên khám */}
<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 của {patient?.fullName}
</h2>
<Link
href={`/consultation/history?_id=${patientId}`}
className="text-blue-500 hover:underline"
>
Tất cả
</Link>
</div>
<DataWrapper
data={consultationData}
loading={consultationQuery.isLoading}
title="Không có dữ liệu"
note="Bệnh nhân chưa có lịch sử khám"
>
<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) => (
<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>
),
},
{
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",
fixedRight: true,
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",
},
]}
/>
),
},
]}
/>
</DataWrapper>
</div>
</div>
);
};
export default DetailPatient;
@@ -1 +0,0 @@
export { default } from "./DetailPatient";
@@ -1,362 +0,0 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import CustomDialog from "@/components/customs/custom-dialog";
import CustomPopup from "@/components/customs/custom-popup";
import DataWrapper from "@/components/customs/DataWrapper";
import Search from "@/components/customs/custom-search";
import Table from "@/components/customs/custom-table";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import patientServices, {
PatientItem,
PatientResponse,
} from "@/services/patientServices";
import { useQuery } from "@tanstack/react-query";
import { Pencil, Trash2, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import PopupCreatePatient from "../PopupCreatePatient";
import PopupUpdatePatient from "../PopupUpdatePatient";
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();
const pathname = usePathname();
const searchParams = useSearchParams();
const [loadingDelete, setLoadingDelete] = useState(false);
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
const [deleteItemId, setDeleteItemId] = useState<string>("");
const _page = searchParams.get("_page");
const _pageSize = searchParams.get("_pageSize");
const _keyword = searchParams.get("_keyword");
/**
* PARAMS
*/
const createParam = searchParams.get("_create");
const updateParam = searchParams.get("_update");
/**
* OPEN POPUP
*/
const openCreatePopup = createParam !== null;
const openUpdatePopup = !!updateParam;
/**
* QUERY LIST PATIENT
*/
const patientQuery = useQuery<PatientResponse>({
queryKey: [QUERY_KEY.table_list_patient, _page, _pageSize, _keyword],
queryFn: async () => {
const res = await httpRequest<PatientResponse>({
showMessageFailed: true,
http: patientServices.getPatient({
Page: Number(_page || 1),
PageSize: Number(_pageSize || 10),
Search: _keyword || "",
// IdentificationNumber: _keyword || "",
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
/**
* UPDATE QUERY PARAMS
*/
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 || value === "") {
params.delete(key);
} else {
params.set(key, String(value));
}
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
};
/**
* OPEN CREATE
*/
const handleOpenCreate = () => {
const params = new URLSearchParams(searchParams.toString());
params.set("_create", "");
router.push(`${pathname}?${params.toString()}`);
};
/**
* OPEN UPDATE
*/
const handleOpenUpdate = (id: string) => {
const params = new URLSearchParams(searchParams.toString());
params.delete("_create");
params.set("_update", id);
router.push(`${pathname}?${params.toString()}`);
};
/**
* CLOSE ALL POPUP
*/
const handleClosePopup = () => {
const params = new URLSearchParams(searchParams.toString());
params.delete("_create");
params.delete("_update");
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
setOpenDeleteDialog(false);
setDeleteItemId("");
};
/**
* DELETE
*/
const handleOpenDelete = (id: string) => {
setDeleteItemId(id);
setOpenDeleteDialog(true);
};
const handleDeletePatient = async () => {
if (!deleteItemId) return;
try {
setLoadingDelete(true);
await patientServices.deletePatient(deleteItemId);
// đóng dialog + reset state
handleClosePopup();
// refetch list
patientQuery.refetch();
} catch (error) {
console.error("Delete patient error:", error);
} finally {
setLoadingDelete(false);
}
};
/**
* DATA
*/
const patientData = useMemo(() => {
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">
{/* HEADER */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-black">Bệnh nhân</h2>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Users />}
onClick={handleOpenCreate}
>
Tạo mới
</CustomButton>
</div>
{/* SEARCH */}
<div className="flex items-center justify-between gap-4">
<div className="w-full md:min-w-[400px]">
<Search
keyword={_keyword ?? ""}
setKeyword={(value) => updateQuery("_keyword", value)}
placeholder="Tìm kiếm theo tên bệnh nhân, ID"
/>
</div>
</div>
{/* TABLE */}
<DataWrapper
data={patientData}
loading={patientQuery.isLoading}
title="No New Requests"
note="Please check back later or view your schedule"
>
<Table
rowKey={(item) => item.id}
data={patientData}
column={[
{
fixedLeft: true,
title: "TÊN BỆNH NHÂN",
render: (item: PatientItem) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/patient/${item.id}`}
className="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>
),
},
{
title: "MÃ ĐỊNH DANH",
render: (item: PatientItem) => (
<span>{item.identificationNumber || "---"}</span>
),
},
{
title: "SỐ ĐIỆN THOẠI",
render: (item: PatientItem) => <span>{item.phone || "---"}</span>,
},
{
title: "EMAIL",
render: (item: PatientItem) => <span>{item.email || "---"}</span>,
},
{
title: "GIỚI TÍNH",
render: (item: PatientItem) => (
<span>{item.gender || "---"}</span>
),
},
{
fixedRight: true,
title: "ACTION",
render: (item: PatientItem) => (
/*
- flex-col: Xếp dọc các icon trên mobile
- sm:flex-row: Xếp ngang lại trên màn lớn (>= 640px)
- items-center justify-center: Căn giữa các nút khi xếp dọc
*/
<div className="flex flex-col sm:flex-row items-center justify-center gap-2">
{/* UPDATE */}
<CustomButton
size="sm"
icon={<Pencil className="text-blue-400" />}
onClick={() => handleOpenUpdate(item.id)}
/>
{/* DELETE */}
<CustomButton
size="sm"
icon={<Trash2 className="text-red-400" />}
onClick={() => handleOpenDelete(item.id)}
/>
</div>
),
},
]}
/>
</DataWrapper>
<Pagination
total={patientData.length}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery("_page", value)}
onSetPageSize={(value) => updateQuery("_pageSize", value)}
dependencies={[_pageSize, _keyword]}
/>
{/* DELETE DIALOG */}
<CustomDialog
open={openDeleteDialog}
title="Xóa bệnh nhân"
note="Bạn có chắc chắn muốn xóa bệnh nhân này?"
type="error"
onClose={handleClosePopup}
onSubmit={handleDeletePatient}
titleCancel="Hủy"
titleSubmit="Xác nhận"
/>
{/* CREATE POPUP */}
<CustomPopup open={openCreatePopup} onClose={handleClosePopup}>
<PopupCreatePatient onClose={handleClosePopup} />
</CustomPopup>
{/* UPDATE POPUP */}
<CustomPopup open={openUpdatePopup} onClose={handleClosePopup}>
<PopupUpdatePatient onClose={handleClosePopup} id={updateParam || ""} />
</CustomPopup>
</div>
);
};
export default MainPagePatient;
@@ -1 +0,0 @@
export { default } from "./MainPagePatient";
@@ -1,356 +0,0 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
import { QUERY_KEY, TYPE_GENDER } from "@/constant/config/enum";
import { X } from "lucide-react";
import React, { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { httpRequest } from "@/services";
import patientServices from "@/services/patientServices";
import { toastWarn } from "@/common/funcs/toast";
import {
formatDateOfBirth,
parseDateOfBirth,
formatToISODate,
} from "@/common/funcs/optionConvert";
export interface ICreatePatient {
identificationNumber: string;
fullName: string;
dateOfBirth: string;
gender: string;
phone: string;
email: string;
address: string;
}
export interface PropsPopupCreatePatient {
onClose: () => void;
}
const PopupCreatePatient = ({ onClose }: PropsPopupCreatePatient) => {
const queryClient = useQueryClient();
const [form, setForm] = useState<ICreatePatient>({
identificationNumber: "",
fullName: "",
dateOfBirth: "",
gender: "",
phone: "",
email: "",
address: "",
});
const createPatientMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Tạo bệnh nhân thành công!",
http: patientServices.createPatient({
identificationNumber: form.identificationNumber,
fullName: form.fullName,
dateOfBirth:
formatToISODate(parseDateOfBirth(form.dateOfBirth)) || "",
gender: form.gender,
phone: form.phone,
email: form.email,
address: form.address,
}),
});
},
onError(error: any) {
// Lấy message lỗi từ backend trả về
const message = error?.response?.data?.error?.message || error?.message;
// Bật thông báo Toast dạng cảnh báo
toastWarn({ msg: message });
},
onSuccess() {
/* REFRESH LIST */
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_patient],
});
onClose();
},
});
const handleSubmit = () => {
if (!form.dateOfBirth) {
return toastWarn({ msg: "Vui lòng nhập ngày sinh!" });
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const birthDay = parseDateOfBirth(form.dateOfBirth);
if (!birthDay) {
return toastWarn({
msg: "Ngày sinh phải theo định dạng ngày/tháng/năm!",
});
}
birthDay.setHours(0, 0, 0, 0);
if (birthDay.getTime() > today.getTime()) {
return toastWarn({ msg: "Ngày sinh không hợp lệ!" });
}
if (!form.gender) {
return toastWarn({ msg: "Vui lòng chọn giới tính!" });
}
createPatientMutation.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div
className="
relative
h-[680px]
w-[640px]
max-w-[95vw]
rounded-3xl
bg-white
shadow-xl
flex
flex-col
overflow-hidden
"
>
{/* CLOSE */}
<button
type="button"
onClick={onClose}
className="
absolute
right-5
top-5
z-30
text-gray-400
transition
hover:text-gray-600
"
>
<X size={22} />
</button>
{/* HEADER */}
<div
className="
shrink-0
border-b
px-7
py-5
bg-white
"
>
<h2 className="text-[30px] font-semibold text-[#111827]">
Thêm bệnh nhân
</h2>
</div>
{/* BODY */}
<div
className="
flex-1
overflow-y-auto
px-7
py-5
"
>
<div className="space-y-5">
<InputForm
label={
<span>
đnh danh
<span className="text-red-500"> *</span>
</span>
}
placeholder="Mã định danh"
name="identificationNumber"
type="number"
isNumber
min={12}
max={12}
value={form.identificationNumber}
isRequired
/>
<InputForm
label={
<span>
Tên bệnh nhân
<span className="text-red-500"> *</span>
</span>
}
placeholder="Tên bệnh nhân"
name="fullName"
type="text"
value={form.fullName}
isRequired
/>
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Giới tính <span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-6">
{/* NAM */}
<label className="flex cursor-pointer select-none items-center gap-2.5">
<input
id="male"
type="radio"
name="gender"
value={form.gender}
checked={form.gender === TYPE_GENDER.MALE}
onChange={() =>
setForm((prev: any) => ({
...prev,
gender: TYPE_GENDER.MALE,
}))
}
className="h-5 w-5 cursor-pointer"
/>
<span className="cursor-pointer text-sm font-medium text-gray-700">
Nam
</span>
</label>
{/* NỮ */}
<label className="flex cursor-pointer select-none items-center gap-2.5">
<input
id="female"
type="radio"
name="gender"
value={form.gender}
checked={form.gender === TYPE_GENDER.FEMALE}
onChange={() =>
setForm((prev: any) => ({
...prev,
gender: TYPE_GENDER.FEMALE,
}))
}
className="h-5 w-5 cursor-pointer"
/>
<span className="cursor-pointer text-sm font-medium text-gray-700">
Nữ
</span>
</label>
</div>
</div>
<InputForm
label={
<span>
Ngày sinh
<span className="text-red-500"> *</span>
</span>
}
placeholder="DD/MM/YYYY"
name="dateOfBirth"
type="text"
value={form.dateOfBirth}
onChangeValue={(value) =>
setForm((prev) => ({
...prev,
dateOfBirth: formatDateOfBirth(String(value)),
}))
}
/>
<InputForm
label={
<span>
Số điện thoại
<span className="text-red-500"> *</span>
</span>
}
placeholder="Số điện thoại"
name="phone"
type="number"
isNumber
min={8}
max={11}
value={form.phone}
isRequired
/>
<InputForm
label={<span>Email</span>}
placeholder="Email"
name="email"
type="email"
isEmail
value={form.email}
/>
<TextArea
name="address"
placeholder="Địa chỉ"
label={
<span>
Đa chỉ
<span className="text-red-500"> *</span>
</span>
}
isRequired
isBlur
max={5000}
/>
</div>
</div>
{/* FOOTER */}
<div
className="
shrink-0
border-t
bg-white
px-7
py-5
flex
items-center
justify-end
gap-3
"
>
<CustomButton variant="grey" rounded="md" onClick={onClose}>
Hủy bỏ
</CustomButton>
<ContextFormCustom.Consumer>
{({ isDone }) => (
<CustomButton
disabled={!isDone || createPatientMutation.isPending}
variant="midnightBlue"
rounded="md"
type="submit"
>
{createPatientMutation.isPending ? "Đang tạo..." : "Tạo"}
</CustomButton>
)}
</ContextFormCustom.Consumer>
</div>
</div>
</FormCustom>
);
};
export default PopupCreatePatient;
@@ -1 +0,0 @@
export { default } from "./PopupCreatePatient";
@@ -1,449 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
import CustomButton from "@/components/customs/custom-button";
import CustomLoading from "@/components/customs/custom-loading";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
import { QUERY_KEY, TYPE_GENDER } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import patientServices, { PatientDetail } from "@/services/patientServices";
import { toastWarn } from "@/common/funcs/toast";
import { timeSubmit } from "@/common/funcs/optionConvert";
interface PopupUpdatePatientProps {
onClose: () => void;
id: string;
}
interface IUpdatePatient {
fullName: string;
gender: string;
phone: string;
email: string;
address: string;
emergencyContactName: string;
emergencyContactPhone: string;
allergyNotes: string;
chronicDiseaseNotes: string;
insuranceNumber: string;
notes: string;
}
const defaultForm: IUpdatePatient = {
fullName: "",
gender: "",
phone: "",
email: "",
address: "",
emergencyContactName: "",
emergencyContactPhone: "",
allergyNotes: "",
chronicDiseaseNotes: "",
insuranceNumber: "",
notes: "",
};
const PopupUpdatePatient = ({ onClose, id }: PopupUpdatePatientProps) => {
const queryClient = useQueryClient();
const [form, setForm] = useState<IUpdatePatient>(defaultForm);
const patientDetailQuery = useQuery<PatientDetail>({
queryKey: [QUERY_KEY.chi_tiet_benh_nhan, id],
enabled: !!id,
queryFn: async () => {
const res = await httpRequest<PatientDetail>({
showMessageFailed: true,
http: patientServices.detailPatient(id),
});
return (
res || {
id: "",
patientCode: "",
identificationNumber: "",
fullName: "",
dateOfBirth: "",
gender: "",
phone: "",
email: "",
address: "",
emergencyContactName: null,
emergencyContactPhone: null,
allergyNotes: null,
chronicDiseaseNotes: null,
insuranceNumber: null,
notes: null,
isActive: false,
}
);
},
});
useEffect(() => {
const patient = patientDetailQuery.data;
if (!patient) return;
setForm((prev) => {
/**
* tránh render loop
*/
if (
prev.fullName === patient.fullName &&
prev.phone === patient.phone &&
prev.email === patient.email
) {
return prev;
}
return {
fullName: patient.fullName || "",
gender: patient.gender || TYPE_GENDER.MALE,
phone: patient.phone || "",
email: patient.email || "",
address: patient.address || "",
emergencyContactName: patient.emergencyContactName || "",
emergencyContactPhone: patient.emergencyContactPhone || "",
allergyNotes: patient.allergyNotes || "",
chronicDiseaseNotes: patient.chronicDiseaseNotes || "",
insuranceNumber: patient.insuranceNumber || "",
notes: patient.notes || "",
};
});
}, [patientDetailQuery.data]);
const updatePatientMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Cập nhật bệnh nhân thành công!",
http: patientServices.putPatient(id, {
fullName: form.fullName,
gender: form.gender,
phone: form.phone,
email: form.email,
address: form.address,
emergencyContactName: form.emergencyContactName,
emergencyContactPhone: form.emergencyContactPhone,
allergyNotes: form.allergyNotes,
chronicDiseaseNotes: form.chronicDiseaseNotes,
insuranceNumber: form.insuranceNumber,
notes: form.notes,
}),
});
},
onSuccess(data) {
if (!data) return;
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_patient],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.chi_tiet_benh_nhan, id],
});
onClose();
},
});
const handleSubmit = () => {
if (!form.gender) {
return toastWarn({ msg: "Vui lòng chọn giới tính!" });
}
updatePatientMutation.mutate();
};
if (patientDetailQuery.isLoading) {
return (
<div className="flex h-[400px] items-center justify-center">
<CustomLoading />
</div>
);
}
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div
className="
relative
h-[680px]
w-[640px]
max-w-[95vw]
rounded-3xl
bg-white
shadow-xl
flex
flex-col
overflow-hidden
"
>
{/* CLOSE */}
<button
type="button"
onClick={onClose}
className="
absolute
right-5
top-5
z-10
text-gray-400
transition
hover:text-gray-600
"
>
<X size={22} />
</button>
{/* HEADER */}
<div
className="
shrink-0
border-b
px-7
py-5
bg-white
"
>
<h2 className="text-[30px] font-semibold text-[#111827]">
Cập nhật thông tin bệnh nhân
</h2>
</div>
{/* BODY */}
<div
className="
flex-1
overflow-y-auto
px-7
py-5
"
>
<div className="space-y-5">
<InputForm
label={
<span>
Tên bệnh nhân
<span className="text-red-500"> *</span>
</span>
}
placeholder="Tên bệnh nhân"
name="fullName"
type="text"
value={form.fullName}
isRequired
/>
{/* GENDER */}
<div>
<label className="text-base font-medium">
Giới tính <span className="text-red-500">*</span>
</label>
<div className="mt-2 flex items-center gap-6">
{/* NAM */}
<div className="flex items-center gap-2">
<input
type="radio"
id="male"
checked={form.gender === TYPE_GENDER.MALE}
onChange={() =>
setForm((prev) => ({
...prev,
gender: TYPE_GENDER.MALE,
}))
}
/>
<label htmlFor="male">Nam</label>
</div>
{/* NỮ */}
<div className="flex items-center gap-2">
<input
type="radio"
id="female"
checked={form.gender === TYPE_GENDER.FEMALE}
onChange={() =>
setForm((prev) => ({
...prev,
gender: TYPE_GENDER.FEMALE,
}))
}
/>
<label htmlFor="female">Nữ</label>
</div>
</div>
</div>
<InputForm
label={
<span>
Số điện thoại
<span className="text-red-500"> *</span>
</span>
}
placeholder="Số điện thoại"
name="phone"
type="phone"
isPhone
value={form.phone}
isRequired
/>
<InputForm
label={
<span>
Email<span className="text-red-500"> *</span>
</span>
}
placeholder="Email"
name="email"
type="email"
isEmail
value={form.email}
/>
<TextArea
name="address"
placeholder="Địa chỉ"
label={
<span>
Đa chỉ
<span className="text-red-500"> *</span>
</span>
}
value={form.address}
isRequired
isBlur
max={5000}
/>
<InputForm
isRequired
label={
<span>
Người liên hệ khẩn cấp
<span className="text-red-500"> *</span>
</span>
}
placeholder="Người liên hệ khẩn cấp"
name="emergencyContactName"
type="text"
value={form.emergencyContactName}
/>
<InputForm
label={
<span>
SĐT khẩn cấp
<span className="text-red-500"> *</span>
</span>
}
placeholder="SĐT khẩn cấp"
name="emergencyContactPhone"
type="number"
min={8}
max={11}
isRequired
// isPhone
value={form.emergencyContactPhone}
/>
<TextArea
name="allergyNotes"
label={
<span>
Dị ng
<span className="text-red-500"> *</span>
</span>
}
isRequired
placeholder="Dị ứng"
value={form.allergyNotes}
isBlur
max={5000}
/>
<TextArea
name="chronicDiseaseNotes"
placeholder="Bệnh nền"
label={
<span>
Bệnh nền
<span className="text-red-500"> *</span>
</span>
}
isRequired
value={form.chronicDiseaseNotes}
isBlur
max={5000}
/>
<InputForm
label={
<span>
Số bảo hiểm
<span className="text-red-500"> *</span>
</span>
}
placeholder="Số bảo hiểm"
name="insuranceNumber"
type="text"
isRequired
value={form.insuranceNumber}
/>
<TextArea
name="notes"
label={<span>Ghi chú</span>}
placeholder="Ghi chú"
value={form.notes}
isBlur
max={5000}
/>
</div>
</div>
{/* FOOTER */}
<div
className="
shrink-0
border-t
bg-white
px-7
py-5
flex
items-center
justify-end
gap-3
"
>
<CustomButton variant="grey" rounded="md" onClick={onClose}>
Hủy bỏ
</CustomButton>
<ContextFormCustom.Consumer>
{({ isDone }) => (
<CustomButton
disabled={!isDone || updatePatientMutation.isPending}
variant="midnightBlue"
rounded="md"
type="submit"
>
{updatePatientMutation.isPending
? "Đang cập nhật..."
: "Cập nhật"}
</CustomButton>
)}
</ContextFormCustom.Consumer>
</div>
</div>
</FormCustom>
);
};
export default PopupUpdatePatient;
@@ -1 +0,0 @@
export { default } from "./PopupUpdatePatient";
@@ -1,554 +0,0 @@
"use client";
import React, { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
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, {
ConsultationResponse,
} from "@/services/consultationServices";
import { QUERY_KEY } from "@/constant/config/enum";
import { toastWarn } from "@/common/funcs/toast";
import uploadServices from "@/services/uploadServices";
/* =========================
TYPES
========================= */
interface PrescriptionItem {
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}
interface PrescriptionForm {
examinationSessionSpecialtyId: string;
notes: string;
prescriptionImg: string;
items: PrescriptionItem[];
}
/* =========================
DEFAULT
========================= */
const defaultMedicine: PrescriptionItem = {
medicineName: "",
dosage: "",
frequency: "",
duration: "",
instructions: "",
};
/* =========================
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>({
examinationSessionSpecialtyId: "",
notes: "",
prescriptionImg: "",
items: [defaultMedicine],
});
/* =========================
GET EXAMINATION SESSION
========================= */
const examinationSessionQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation],
queryFn: async () => {
const res = await httpRequest<ConsultationResponse>({
showMessageFailed: true,
http: consultationServices.getConsultations({
Page: 1,
PageSize: 100,
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
const examinationSessionOptions =
examinationSessionQuery.data?.items
?.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
========================= */
const funcCreatePrescription = useMutation({
mutationFn: (payload: any) => {
console.log("=== GỬI PAYLOAD LÊN API TẠO ĐƠN THUỐC ===", payload);
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Tạo đơn thuốc thành công!",
http: prescriptionServices.createPrescription(payload),
});
},
onSuccess(data) {
console.log("TẠO ĐƠN THUỐC THÀNH CÔNG:", data);
queryKeys.forEach((key) => {
queryClient.invalidateQueries({
queryKey: [key],
});
});
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
========================= */
const handleChangeMedicine = (
index: number,
key: keyof PrescriptionItem,
value: string,
) => {
const cloneItems = [...form.items];
cloneItems[index] = {
...cloneItems[index],
[key]: value,
};
setForm((prev) => ({
...prev,
items: cloneItems,
}));
};
/* =========================
ADD MEDICINE
========================= */
const handleAddMedicine = () => {
setForm((prev) => ({
...prev,
items: [{ ...defaultMedicine }, ...prev.items],
}));
};
/* =========================
REMOVE MEDICINE
========================= */
const handleRemoveMedicine = (index: number) => {
const newItems = form.items.filter((_, i) => i !== index);
setForm((prev) => ({
...prev,
items: newItems,
}));
};
/* =========================
SUBMIT VALIDATION
========================= */
/* =========================
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",
});
}
if (!file) {
return toastWarn({
msg: "Vui lòng chọn ảnh đơn thuốc",
});
}
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ệ",
});
}
// Lấy thông tin phiên khám đang được chọn để lấy SessionCode (Mã phiên khám)
const selectedSession = examinationSessionOptions.find(
(item) => item.id === form.examinationSessionSpecialtyId,
);
// 1. Thực hiện gọi API upload ảnh với cấu trúc chính xác
setLoading(true);
// SỬA TẠI ĐÂY: Truyền đúng định dạng Object chứa mảng Files giống như trang cập nhật
const uploadResponse: any = await uploadServices.uploadImage({
Files: [file], // Bọc file đơn lẻ vào mảng vì API yêu cầu danh sách file
SessionCode: selectedSession?.sessionCode || "", // Truyền mã phiên khám tương ứng
});
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;
}
// Hỗ trợ trường hợp API trả về mảng fileUrls giống như kết quả POST /api/Media/upload bạn cung cấp
else if (uploadResponse?.fileUrls && uploadResponse.fileUrls.length > 0) {
imageUrl = uploadResponse.fileUrls[0];
}
// 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?.fileUrls?.[0] || // Đọc mảng fileUrls bên trong data
innerData?.fileUrl ||
innerData?.url ||
innerData?.data?.fileUrls?.[0] ||
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);
}
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="flex flex-col gap-6 rounded-2xl bg-white p-6 shadow">
{/* HEADER */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-[28px] font-semibold text-[#111827]">
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>
</div>
<div>
<CustomButton
type="submit"
variant="midnightBlue"
disabled={funcCreatePrescription.isPending || loading}
className="min-w-[180px]"
>
{funcCreatePrescription.isPending || loading
? "Đang tạo đơn..."
: "Tạo đơn thuốc"}
</CustomButton>
</div>
</div>
{/* GENERAL INFO */}
<div className="rounded-2xl border border-gray-200 p-5">
<h3 className="mb-5 text-lg font-semibold text-[#111827]">
Thông tin chung
</h3>
<div className="grid grid-cols-1 gap-5">
{/* EXAMINATION SESSION */}
<SelectForm
label={
<span className="text-[14px] font-medium text-[#374151]">
Phiên khám chuyên khoa
<span className="text-red-500">*</span>
</span>
}
placeholder="Chọn phiên khám chuyên khoa"
value={form.examinationSessionSpecialtyId}
options={examinationSessionOptions}
onSelect={(item: any) =>
setForm((prev) => ({
...prev,
examinationSessionSpecialtyId: item.id,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
examinationSessionSpecialtyId: "",
}))
}
getOptionLabel={(item: any) =>
`${item.sessionCode} - ${item.specialtyName} (${item.patientName})`
}
getOptionValue={(item: any) => item.id}
/>
{/* IMAGE UPLOAD */}
<div className="flex flex-col gap-2">
<UploadImage
label={
<span>
Hình nh đơn thuốc <span style={{ color: "red" }}>*</span>
</span>
}
name="prescriptionImg"
file={file}
setFile={setFile}
path={""}
/>
</div>
{/* NOTES */}
<TextArea
label={
<span>
Ghi chú<span style={{ color: "red" }}>*</span>
</span>
}
name="notes"
placeholder="Nhập ghi chú"
value={form.notes}
isBlur
isRequired
max={5000}
onChangeValue={(value) =>
setForm((prev) => ({
...prev,
notes: String(value),
}))
}
/>
</div>
</div>
{/* MEDICINE LIST */}
<div className="rounded-2xl border border-gray-200 p-5">
<div className="mb-5 flex items-center justify-between">
<h3 className="text-lg font-semibold text-[#111827]">
Danh sách thuốc
</h3>
<div>
<CustomButton
type="button"
variant="midnightBlue"
icon={<Plus size={18} />}
onClick={handleAddMedicine}
>
Thêm thuốc
</CustomButton>
</div>
</div>
<div className="flex flex-col gap-6">
{form.items.map((medicine, index) => (
// Lưu ý: Nếu có trường id duy nhất thì nên dùng thay cho index ở đây
<div
key={`${index}-${form.items.length}`}
className="rounded-2xl border border-gray-200 p-5"
>
{/* TITLE */}
<div className="mb-4 flex items-center justify-between">
<h4 className="font-semibold text-[#111827]">
Thuốc #{index + 1}
</h4>
{form.items.length > 1 && (
<button
type="button"
onClick={() => handleRemoveMedicine(index)}
className="text-red-500 transition hover:text-red-600"
>
<Trash2 size={18} />
</button>
)}
</div>
{/* INPUTS */}
<div className="grid grid-cols-2 gap-5">
<InputForm
label={
<span>
Tên thuốc<span style={{ color: "red" }}>*</span>
</span>
}
name={`items.${index}.medicineName`}
placeholder="Nhập tên thuốc"
value={medicine.medicineName}
isRequired
type="text"
onChangeValue={(value) =>
handleChangeMedicine(index, "medicineName", String(value))
}
/>
<InputForm
label={
<span>
Liều lượng<span style={{ color: "red" }}>*</span>
</span>
}
name={`items.${index}.dosage`}
placeholder="Ví dụ: 500mg"
value={medicine.dosage}
type="text"
isRequired
onChangeValue={(value) =>
handleChangeMedicine(index, "dosage", String(value))
}
/>
<InputForm
label={
<span>
Tần xuất<span style={{ color: "red" }}>*</span>
</span>
}
isRequired
name={`items.${index}.frequency`}
placeholder="Ví dụ: 2 lần/ngày"
value={medicine.frequency}
type="text"
onChangeValue={(value) =>
handleChangeMedicine(index, "frequency", String(value))
}
/>
<InputForm
label={
<span>
Thời gian dùng<span style={{ color: "red" }}>*</span>
</span>
}
isRequired
name={`items.${index}.duration`}
placeholder="Ví dụ: 7 ngày"
value={medicine.duration}
type="text"
onChangeValue={(value) =>
handleChangeMedicine(index, "duration", String(value))
}
/>
</div>
{/* INSTRUCTION */}
<div className="mt-5">
<TextArea
label={
<span>
Hướng dẫn sử dụng<span style={{ color: "red" }}>*</span>
</span>
}
isRequired
name={`items.${index}.instructions`}
placeholder="Nhập hướng dẫn sử dụng thuốc"
value={medicine.instructions}
isBlur
max={5000}
onChangeValue={(value) =>
handleChangeMedicine(index, "instructions", String(value))
}
/>
</div>
</div>
))}
</div>
</div>
</div>
</FormCustom>
);
};
export default CreatePrescription;
@@ -1 +0,0 @@
export { default } from "./CreatePrescription";
@@ -1,192 +0,0 @@
"use client";
import { QUERY_KEY } from "@/constant/config/enum";
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, 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);
const prescriptionDetailQuery = useQuery<PrescriptionDetail>({
queryKey: [QUERY_KEY.chi_tiet_don_thuoc, PrescriptionId],
enabled: !!PrescriptionId,
queryFn: async () => {
const res = await httpRequest<PrescriptionDetail>({
showMessageFailed: true,
http: prescriptionServices.detailPrescription(PrescriptionId),
});
return (
res || {
id: "",
examinationSessionSpecialtyId: "",
prescriptionCode: "",
prescriptionImg: null,
notes: "",
createdBy: "",
items: [],
}
);
},
});
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">
<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>}
{/* ERROR */}
{prescriptionDetailQuery.isError && (
<p className="text-red-500">Lỗi tải dữ liệu</p>
)}
{/* CONTENT */}
{data && (
<div className="space-y-4">
<div>
<strong> đơn thuốc:</strong> {data.prescriptionCode}
</div>
<div>
<strong>Chuyên khoa:</strong>{" "}
{specialtyName || data.examinationSessionSpecialtyId || "---"}
</div>
<div>
<strong>Ghi chú:</strong> {data.notes || "---"}
</div>
{/* IMAGE */}
<div>
<strong>nh đơn thuốc:</strong>
<div className="mt-2">
{data.prescriptionImg ? (
<>
<Image
src={`${process.env.NEXT_PUBLIC_IMAGE}/${data.prescriptionImg}`}
alt="prescription"
width={140}
height={140}
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setOpenImagePreview(true)}
unoptimized
/>
{/* IMAGE PREVIEW POPUP */}
<CustomPopup
open={openImagePreview}
onClose={() => setOpenImagePreview(false)}
>
<div className="relative rounded-lg p-4 max-w-[90vw] max-h-[90vh] flex items-center justify-center">
<Image
src={`${process.env.NEXT_PUBLIC_IMAGE}${data.prescriptionImg}`}
alt="prescription preview"
width={600}
height={600}
className="max-w-full max-h-full object-contain"
unoptimized
/>
</div>
</CustomPopup>
</>
) : (
<span className="text-gray-400">Không nh</span>
)}
</div>
</div>
{/* ITEMS */}
<div>
<strong>Danh sách thuốc:</strong>
<div className="mt-2 space-y-2">
{data.items?.length ? (
data.items.map((item, index) => (
<div key={item.id || index} className="p-3 border rounded">
<p>
<b>Tên thuốc:</b> {item.medicineName}
</p>
<p>
<b>Liều lượng:</b> {item.dosage}
</p>
<p>
<b>Tần suất:</b> {item.frequency}
</p>
<p>
<b>Thời gian:</b> {item.duration}
</p>
<p>
<b>Hướng dẫn:</b> {item.instructions}
</p>
</div>
))
) : (
<p className="text-gray-400">Không thuốc</p>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default DetailPrescription;
@@ -1 +0,0 @@
export { default } from "./DetailPrescription";
@@ -1,212 +0,0 @@
import CustomButton from "@/components/customs/custom-button";
import Pagination from "@/components/customs/custom-pagination";
import Search from "@/components/customs/custom-search";
import Table from "@/components/customs/custom-table";
import DataWrapper from "@/components/customs/DataWrapper";
import { PATH } from "@/constant/config";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import prescriptionServices, {
PrescriptionItem,
PrescriptionResponse,
} from "@/services/prescriptionServices";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query";
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";
import React, { useMemo } from "react";
const MainPrescription = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const _page = searchParams.get("_page");
const _pageSize = searchParams.get("_pageSize");
const _keyword = searchParams.get("_keyword");
const prescriptionQuery = useQuery<PrescriptionResponse>({
queryKey: [
QUERY_KEY.table_list_patientrescription,
_page,
_pageSize,
_keyword,
],
queryFn: async () => {
const res = await httpRequest<PrescriptionResponse>({
showMessageFailed: true,
http: prescriptionServices.getPrescription({
Page: Number(_page || 1),
PageSize: Number(_pageSize || 10),
Search: _keyword || "",
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
const prescriptionData = useMemo(() => {
return prescriptionQuery.data?.items || [];
}, [prescriptionQuery.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">Đơn thuốc</h2>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Pill />}
href={PATH.CREATE_PRESCRIPTION}
>
Tạo mới
</CustomButton>
</div>
{/* SEARCH */}
<div className="flex items-center justify-between gap-4">
<div className="w-full md:min-w-[400px]">
<Search
keyword={_keyword ?? ""}
setKeyword={(value) => updateQuery("_keyword", value)}
placeholder="Tìm theo mã đơn thuốc"
/>
</div>
</div>
<DataWrapper
data={prescriptionData || []}
loading={prescriptionQuery.isLoading}
title="No Data"
note="Không có đơn thuốc nào"
>
<Table
rowKey={(item: any) => item.id}
data={prescriptionData || []}
column={[
// ================= ID / CODE =================
{
title: "MÃ ĐƠN THUỐC",
render: (item: any) => (
<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>
),
},
// ================= NOTES =================
{
title: "GHI CHÚ",
render: (item: any) => <span>{item.notes || "-"}</span>,
},
// ================= IMAGE =================
{
title: "ẢNH ĐƠN THUỐC",
render: (item: any) =>
item.prescriptionImg ? (
<Image
src={`${process.env.NEXT_PUBLIC_IMAGE}${item.prescriptionImg}`}
// src={item.prescriptionImg}
alt="prescription"
width={40}
height={40}
className="h-10 w-10 rounded object-cover"
unoptimized
/>
) : (
<span className="text-gray-400">Không nh</span>
),
},
// ================= ITEMS COUNT =================
{
title: "SỐ THUỐC",
render: (item: any) => <span>{item.items?.length || 0}</span>,
},
// ================= ACTION =================
{
fixedRight: true,
title: "ACTION",
render: (item: any) => (
<div className="flex flex-col sm:flex-row items-center justify-center gap-2">
<CustomButton
size="sm"
icon={<Eye className="text-blue-400" />}
href={`${PATH.PRESCRIPTION}/${item.id}`}
/>
</div>
),
},
]}
/>
</DataWrapper>
<Pagination
total={prescriptionData?.length}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery("_page", value)}
onSetPageSize={(value) => updateQuery("_pageSize", value)}
dependencies={[_pageSize, _keyword]}
/>
</div>
);
};
export default MainPrescription;
@@ -1 +0,0 @@
export { default } from "./MainPrescription";
@@ -1,175 +0,0 @@
"use client";
import { memo, useMemo } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import icons from "@/constant/images/icons";
import { PATH } from "@/constant/config";
import { QUERY_KEY } from "@/constant/config/enum";
import { useQuery } from "@tanstack/react-query";
import { httpRequest } from "@/services";
import Moment from "react-moment";
import {
Cake,
Phone,
MapPin,
Mail,
SquareUser,
ShieldUser,
} from "lucide-react";
import userServices from "@/services/userServices";
import CustomButton from "@/components/customs/custom-button";
import profileServices, { ProfileResponse } from "@/services/profileServices";
import { useSelector } from "react-redux";
import { RootState } from "@/redux/store";
function MainPageProfile() {
const router = useRouter();
const { infoUser } = useSelector((state: RootState) => state.user);
const profileQuery = useQuery<any>({
queryKey: [QUERY_KEY.table_list_profile],
queryFn: async () => {
const res = await httpRequest<any>({
showMessageFailed: true,
http: profileServices.getProfile(), // Đảm bảo endpoint trỏ về /api/v1/UserProfile/list
});
// Trả về đúng object chứa items theo cấu trúc của API
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
// Tìm kiếm thông tin profile của user hiện tại đang đăng nhập bằng useMemo
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const currentUserProfile = useMemo(() => {
const listItems = profileQuery.data?.items || [];
if (!infoUser?.userId) return null;
// So khớp accountId của API trả về với userId trong Redux (infoUser)
return (
listItems.find((item: any) => item.accountId === infoUser.userId) || null
);
}, [profileQuery.data, infoUser?.userId]);
return (
<div className="flex flex-col p-6 bg-white/80 rounded-2xl">
{/* HEADER */}
<div className="flex items-center justify-between w-full">
<h2 className="text-xl font-semibold">Thông tin tài khoản</h2>
<div className="flex gap-2.5">
<CustomButton
variant="midnightBlue"
size="md"
rounded="full"
fullWidth={false}
href={PATH.UPDATEPROFILE}
>
Chỉnh sửa
</CustomButton>
</div>
</div>
{/* AVATAR */}
<div className="flex gap-4 mt-6 mb-4">
<Image
src={
currentUserProfile?.avatarUrl
? `${process.env.NEXT_PUBLIC_IMAGE}${currentUserProfile.avatarUrl}`
: icons.avatar
}
alt="avatar"
width={120}
height={120}
className="object-cover rounded-lg "
/>
</div>
{/* MAIN */}
<div className="flex gap-4 mt-6 flex-col md:flex-row">
{/* BASIC INFO */}
<div className="flex flex-col border border-gray-200 bg-gray-50 rounded-xl w-full md:w-1/2 p-5 text-gray-600">
<div className="text-xl font-semibold mb-2 text-gray-800">
Thông tin bản
</div>
<div className="flex flex-col gap-3">
{/* NAME */}
<div className="grid grid-cols-[180px_1fr] items-center font-semibold">
<p className="flex gap-2">
<SquareUser size={24} />
Họ tên
</p>
<span className="text-gray-900">
{currentUserProfile?.fullName || "---"}
</span>
</div>
{/* DOB */}
<div className="grid grid-cols-[180px_1fr] items-center font-semibold">
<p className="flex gap-2">
<Cake size={24} />
Ngày sinh
</p>
<span className="text-gray-900">
{currentUserProfile?.birthDate ? (
<Moment format="DD/MM/YYYY">
{currentUserProfile.birthDate}
</Moment>
) : (
"---"
)}
</span>
</div>
</div>
</div>
{/* CONTACT */}
<div className="flex flex-col border border-gray-200 bg-gray-50 rounded-xl w-full md:w-1/2 p-5 text-gray-600">
<div className="text-xl font-semibold mb-2 text-gray-800">
Liên hệ
</div>
<div className="flex flex-col gap-3">
{/* EMAIL */}
<div className="grid grid-cols-[180px_1fr] items-center font-semibold">
<p className="flex gap-2">
<Mail size={24} />
Email
</p>
<span className="text-gray-900">
{currentUserProfile?.email || "---"}
</span>
</div>
{/* PHONE */}
<div className="grid grid-cols-[180px_1fr] items-center font-semibold">
<p className="flex gap-2">
<Phone size={24} />
Số điện thoại
</p>
<span className="text-gray-900">
{currentUserProfile?.phone || "---"}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
export default memo(MainPageProfile);
@@ -1 +0,0 @@
export { default } from "./MainPageProfile";
@@ -1,332 +0,0 @@
"use client";
import { Fragment, memo, useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Button from "@/components/customs/custom-button";
import UploadAvatar from "@/components/utils/UploadAvatar";
import GridColumn from "@/components/layouts/GridColumn";
import Loading from "@/components/customs/custom-loading";
import { PATH } from "@/constant/config";
import { QUERY_KEY } from "@/constant/config/enum";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { httpRequest } from "@/services";
import uploadServices from "@/services/uploadServices";
import profileServices from "@/services/profileServices";
import { toastWarn } from "@/common/funcs/toast";
import { useSelector } from "react-redux";
import { RootState } from "@/redux/store";
import icons from "@/constant/images/icons";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import FormCustom from "@/components/utils/FormCustom/FormCustom";
import moment from "moment";
import {
formatDateOfBirth,
formatToISODate,
parseDateOfBirth,
timeSubmit,
} from "@/common/funcs/optionConvert";
import CustomButton from "@/components/customs/custom-button";
function MainUpdateProfile() {
const router = useRouter();
const queryClient = useQueryClient();
const { infoUser } = useSelector((state: RootState) => state.user);
const [fileAvatar, setFileAvatar] = useState<File | null>(null);
// Khởi tạo form dựa hoàn toàn trên các trường Backend trả về
const [form, setForm] = useState<{
fullName: string;
email: string;
phone: string;
birthDate: string;
avatarUrl: string;
}>({
fullName: "",
email: "",
phone: "",
birthDate: "",
avatarUrl: "",
});
/* =========================================================
1. GỌI API LIST PROFILE & LỌC USER ĐANG ĐĂNG NHẬP
========================================================= */
const profileQuery = useQuery<any>({
queryKey: [QUERY_KEY.table_list_profile],
queryFn: async () => {
const res = await httpRequest<any>({
showMessageFailed: true,
http: profileServices.getProfile(),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const currentUserProfile = useMemo(() => {
const listItems = profileQuery.data?.items || [];
if (!infoUser?.userId) return null;
return (
listItems.find((item: any) => item.accountId === infoUser.userId) || null
);
}, [profileQuery.data, infoUser?.userId]);
/* =========================================================
2. Đ DỮ LIỆU TỪ PROFILE VÀO FORM KHI TẢI TRANG
========================================================= */
useEffect(() => {
if (currentUserProfile) {
setForm({
fullName: currentUserProfile.fullName || "",
email: currentUserProfile.email || "",
phone: currentUserProfile.phone || "",
// Định dạng input date HTML yêu cầu format YYYY-MM-DD
birthDate: currentUserProfile.birthDate
? moment(currentUserProfile.birthDate).format("DD/MM/YYYY")
: "",
avatarUrl: currentUserProfile.avatarUrl || "",
});
}
}, [currentUserProfile]);
/* =========================================================
3. API MUTATION UPDATE PROFILE (Thay thế API userServices )
========================================================= */
const funcUpdateProfile = useMutation({
mutationFn: (body: { path: string }) =>
httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Chỉnh sửa thông tin thành công!",
// Bạn có thể đổi sang endpoint PATCH/PUT tùy thuộc vào route Update của bạn
http: profileServices.putProfile(currentUserProfile?.id, {
fullName: form.fullName,
email: form.email,
phone: form.phone,
birthDate: formatToISODate(parseDateOfBirth(form.birthDate)),
avatarUrl: body.path || form.avatarUrl,
}), // Lưu ý: Hãy cập nhật đúng service update của user profile tại đây
}),
onSuccess() {
// Làm mới dữ liệu list profile trên toàn hệ thống (bao gồm cả Header và Profile Page)
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_profile],
});
router.push(PATH.PROFILE || "/profile");
},
onError() {
toastWarn({ msg: "Cập nhật thông tin thất bại!" });
},
});
/* =========================================================
4. XỬ SUBMIT (UPLOAD AVATAR TRƯỚC - UPDATE PROFILE SAU)
========================================================= */
const handleSubmit = async () => {
const today = new Date(timeSubmit(new Date())!);
const birthDay = new Date(form.birthDate);
if (today < birthDay) {
return toastWarn({ msg: "Ngày sinh không hợp lệ!" });
}
try {
// Nếu người dùng chọn file avatar mới
if (fileAvatar) {
const uploadResult: any = await uploadServices.uploadImage({
Files: [fileAvatar],
});
const uploadedUrl =
uploadResult?.fileUrls?.[0] ||
uploadResult?.data?.fileUrls?.[0] ||
"";
return funcUpdateProfile.mutate({ path: uploadedUrl });
}
// Nếu không thay đổi ảnh, dùng lại link avatar cũ
return funcUpdateProfile.mutate({ path: form.avatarUrl });
} catch (error) {
console.error("Lỗi cập nhật ảnh đại diện:", error);
}
};
return (
<Fragment>
<Loading
loading={funcUpdateProfile.isPending || profileQuery.isLoading}
/>
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="flex flex-col p-6 bg-white/80 rounded-2xl gap-6 shadow-sm">
{/* HEADER */}
<div className="flex items-center justify-between w-full border-b pb-4">
<h2 className="text-xl font-semibold text-gray-800">
Chỉnh sửa thông tin tài khoản
</h2>
<div className="flex items-center gap-2.5">
<CustomButton
variant="grey"
rounded="full"
size="md"
fullWidth={false}
type="button"
onClick={() => router.back()}
>
Hủy bỏ
</CustomButton>
<ContextFormCustom.Consumer>
{({ isDone }) => {
const hasRememberedData = !!(
form.fullName &&
form.email &&
form.birthDate &&
form.phone
);
const canSubmit = isDone || hasRememberedData;
return (
<CustomButton
variant="midnightBlue"
rounded="full"
size="md"
fullWidth={false}
type="button"
disabled={!canSubmit}
onClick={handleSubmit}
>
{funcUpdateProfile.isPending ? "Đang lưu..." : "Cập nhật"}
</CustomButton>
);
}}
</ContextFormCustom.Consumer>
</div>
</div>
{/* MAIN FORM */}
<div className="space-y-6">
{/* KHỐI AVATAR */}
<div className="flex justify-start">
<UploadAvatar
path={
form?.avatarUrl
? `${process.env.NEXT_PUBLIC_IMAGE}${form?.avatarUrl}`
: icons.avatar
}
name="avatar"
onSetFile={(file) => setFileAvatar(file)}
resetPath={() => {
setFileAvatar(null);
setForm((prev) => ({
...prev,
avatarUrl: "",
}));
}}
/>
</div>
{/* KHỐI THÔNG TIN CÁ NHÂN CHUẨN BACKEND */}
<div className="mt-4">
<div className="grid grid-cols-2 gap-6">
{/* Cột trái */}
<div className="space-y-4">
<InputForm
type="text"
placeholder="Nhập họ và tên"
name="fullName"
value={form.fullName}
label={
<span>
Họ Tên
<span className="text-red-500">*</span>
</span>
}
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, fullName: String(v) }))
}
/>
<InputForm
type="text"
placeholder="Nhập ngày sinh"
name="birthDate"
isRequired
label={
<span>
Ngày sinh
<span className="text-red-500">*</span>
</span>
}
value={form.birthDate}
onChangeValue={(v) =>
setForm((prev) => ({
...prev,
birthDate: formatDateOfBirth(String(v)),
}))
}
/>
</div>
{/* Cột phải */}
<div className="space-y-4">
<InputForm
placeholder="Nhập email"
name="email"
type="email"
isEmail
value={form.email}
label={
<span>
Email
<span className="text-red-500">*</span>
</span>
}
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, email: String(v) }))
}
/>
<InputForm
placeholder="Nhập số điện thoại"
name="phone"
type="number"
value={form.phone}
isRequired
min={8}
max={11}
label={
<span>
Số điện thoại
<span className="text-red-500">*</span>
</span>
}
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, phone: String(v) }))
}
/>
</div>
</div>
</div>
</div>
</div>
</FormCustom>
</Fragment>
);
}
export default memo(MainUpdateProfile);
@@ -1 +0,0 @@
export { default } from "./MainUpdateProfile";
@@ -1,202 +0,0 @@
"use client";
import React, { useMemo, useState } from "react";
import moment from "moment";
import { useQuery } from "@tanstack/react-query";
import { Users, Stethoscope, Pill, Glasses } from "lucide-react";
import Table from "@/components/customs/custom-table";
import DataWrapper from "@/components/customs/DataWrapper";
import { QUERY_KEY, TYPE_DATE } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import consultationServices, {
ReportResponse,
} from "@/services/consultationServices";
import FilterDateRange from "@/components/utils/FilterDateRage";
const MainPageStatistical = () => {
const [date, setDate] = useState<{
from: Date | null;
to: Date | null;
} | null>(null);
const [typeDate, setTypeDate] = useState<TYPE_DATE>(TYPE_DATE.TODAY);
const isInvalidDate =
!!date?.from && moment(date.from).isAfter(moment().endOf("day"));
const reportQuery = useQuery<ReportResponse>({
queryKey: [
QUERY_KEY.table_list_report,
date?.from?.toISOString(),
date?.to?.toISOString(),
],
enabled: !!date?.from && !!date?.to && !isInvalidDate,
queryFn: async () => {
const fromDate = moment(date?.from).format("YYYY-MM-DD");
const toDate = moment(date?.to).format("YYYY-MM-DD");
console.log({
FromDate: fromDate,
ToDate: toDate,
});
const res = await httpRequest<ReportResponse>({
showMessageFailed: true,
http: consultationServices.getConsultationsReport({
FromDate: fromDate,
ToDate: toDate,
}),
});
return res as ReportResponse;
},
});
const report = reportQuery.data;
const reportData = useMemo(() => {
return report?.specialtyDetails || [];
}, [report]);
return (
<div className="flex flex-col gap-6">
{/* HEADER */}
<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>
{/* 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 className="rounded-xl bg-white p-5 shadow">
<div className="mb-3 flex items-center justify-between">
<Glasses size={22} />
</div>
<p className="text-sm text-gray-500">Số kính đã phát</p>
<h3 className="mt-2 text-3xl font-bold">
{report?.totalGlassesDelivered || 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}
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.specialtyCode}</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>
Tổng số kính đã phát:
<strong> {report?.totalGlassesDelivered || 0}</strong> cái.
</p>
</div>
</div>
</div>
);
};
export default MainPageStatistical;
@@ -1 +0,0 @@
export { default } from "./MainPageStatistical";
+125
View File
@@ -0,0 +1,125 @@
"use client";
import React, { useState } from "react";
import { useSuperTStore, BridgeComponentType } from "@/hooks/useSuperTStore";
import {
ChevronDown,
ChevronRight,
Layers,
Link2,
Building2,
MapPin,
} from "lucide-react";
interface MenuNode {
id: string;
label: string;
tabKey?: BridgeComponentType;
children?: MenuNode[];
}
const bridgeTreeData: MenuNode[] = [
{
id: "superstructure",
label: "1. Kết cấu phần trên",
children: [
{ id: "beam", label: "Dầm Super-T", tabKey: "superTBeam" },
{ id: "deck", label: "Bản mặt cầu", tabKey: "deck" },
{ id: "diaphragm", label: "Dầm ngang / Diaphragms", tabKey: "diaphragm" },
{ id: "railing", label: "Lan can", tabKey: "railing" },
],
},
{
id: "connections",
label: "2. Liên kết",
children: [
{ id: "bearing", label: "Gối cầu", tabKey: "bearing" },
{
id: "beamDeckJoint",
label: "Liên kết dầm - bản",
tabKey: "beamDeckJoint",
},
],
},
{
id: "substructure",
label: "3. Kết cấu phần dưới",
children: [
{ id: "abutment", label: "Mố cầu", tabKey: "abutment" },
{ id: "pier", label: "Trụ cầu", tabKey: "pier" },
{ id: "pile", label: "Cọc / Móng cọc", tabKey: "pile" },
],
},
{
id: "geometry",
label: "4. Dữ liệu hình học",
children: [
{ id: "alignment", label: "Tim cầu & Lý trình", tabKey: "alignment" },
],
},
];
export const BridgeTreeSidebar: React.FC = () => {
const { activeTab, setActiveTab } = useSuperTStore();
const [openNodes, setOpenNodes] = useState<Record<string, boolean>>({
superstructure: true,
substructure: true,
});
const toggleNode = (nodeId: string) => {
setOpenNodes((prev) => ({ ...prev, [nodeId]: !prev[nodeId] }));
};
return (
<div className="w-64 h-full bg-slate-900 border-r border-slate-700 p-3 text-white overflow-y-auto shrink-0 select-none">
<div className="mb-4 px-2">
<h1 className="text-base font-bold text-sky-400">TEDI DDP - Super-T</h1>
<p className="text-[10px] text-slate-400"> hình cấu kiện Cầu KTS</p>
</div>
<div className="space-y-1">
{bridgeTreeData.map((group) => (
<div key={group.id} className="text-xs">
{/* Header nhóm chính */}
<button
onClick={() => toggleNode(group.id)}
className="w-full flex items-center justify-between p-2 hover:bg-slate-800 rounded font-semibold text-slate-200 transition-colors"
>
<span>{group.label}</span>
{openNodes[group.id] ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{/* Danh sách nút cấu kiện con */}
{openNodes[group.id] && group.children && (
<div className="ml-3 pl-2 border-l border-slate-700 space-y-1 mt-1">
{group.children.map((child) => {
const isActive = activeTab === child.tabKey;
return (
<button
key={child.id}
onClick={() => child.tabKey && setActiveTab(child.tabKey)}
className={`w-full text-left p-1.5 rounded transition-all flex items-center gap-2 ${
isActive
? "bg-sky-600/30 text-sky-400 font-medium border border-sky-500/50"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${isActive ? "bg-sky-400" : "bg-slate-600"}`}
/>
{child.label}
</button>
);
})}
</div>
)}
</div>
))}
</div>
</div>
);
};
+180
View File
@@ -0,0 +1,180 @@
"use client";
import React from "react";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Grid, Bounds, useBounds } from "@react-three/drei";
import { SuperTBeamMesh } from "./SuperTBeamMesh";
import { useSuperTStore } from "@/hooks/useSuperTStore";
const SelectFocus: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const api = useBounds();
const activeTab = useSuperTStore((state) => state.activeTab);
const isAssemblyMode = useSuperTStore((state) => state.isAssemblyMode);
React.useEffect(() => {
api.refresh().fit();
}, [activeTab, isAssemblyMode, api]);
return <group>{children}</group>;
};
export const Canvas3D: React.FC = () => {
const { activeTab, beamData, bridgeData, isAssemblyMode } = useSuperTStore();
// Khai báo các mesh bộ phận
const renderSuperTBeam = () => <SuperTBeamMesh data={beamData} />;
const renderDeck = () => (
<mesh position={[0, beamData.height + bridgeData.deck.thickness / 2, 0]}>
<boxGeometry
args={[
bridgeData.deck.width,
bridgeData.deck.thickness,
beamData.length,
]}
/>
<meshStandardMaterial color="#64748b" />
</mesh>
);
const renderPile = () => (
<mesh position={[0, (-bridgeData.pile.length * 1000) / 2, 0]}>
<cylinderGeometry
args={[
bridgeData.pile.diameter / 2,
bridgeData.pile.diameter / 2,
bridgeData.pile.length * 1000,
32,
]}
/>
<meshStandardMaterial color="#38bdf8" />
</mesh>
);
const renderPier = () => (
<mesh position={[0, -bridgeData.pier.height / 2, 0]}>
<cylinderGeometry
args={[
bridgeData.pier.diameter / 2,
bridgeData.pier.diameter / 2,
bridgeData.pier.height,
32,
]}
/>
<meshStandardMaterial color="#94a3b8" />
</mesh>
);
const renderAbutment = () => (
<mesh position={[0, -bridgeData.abutment.height / 2, beamData.length / 2]}>
<boxGeometry
args={[
bridgeData.deck.width,
bridgeData.abutment.height,
bridgeData.abutment.topWidth,
]}
/>
<meshStandardMaterial color="#475569" />
</mesh>
);
const renderBearing = () => (
<mesh
position={[0, -bridgeData.bearing.height / 2, beamData.length / 2 - 500]}
>
<boxGeometry args={[600, bridgeData.bearing.height, 600]} />
<meshStandardMaterial color="#0f172a" />
</mesh>
);
const renderRailing = () => (
<group>
<mesh
position={[
-bridgeData.deck.width / 2 + 200,
beamData.height +
bridgeData.deck.thickness +
bridgeData.railing.height / 2,
0,
]}
>
<boxGeometry args={[100, bridgeData.railing.height, beamData.length]} />
<meshStandardMaterial color="#cbd5e1" />
</mesh>
<mesh
position={[
bridgeData.deck.width / 2 - 200,
beamData.height +
bridgeData.deck.thickness +
bridgeData.railing.height / 2,
0,
]}
>
<boxGeometry args={[100, bridgeData.railing.height, beamData.length]} />
<meshStandardMaterial color="#cbd5e1" />
</mesh>
</group>
);
const renderDiaphragm = () => (
<mesh position={[0, beamData.height / 2, 0]}>
<boxGeometry
args={[
bridgeData.deck.width - 1000,
beamData.height * 0.8,
bridgeData.diaphragm.thickness,
]}
/>
<meshStandardMaterial color="#e2e8f0" />
</mesh>
);
return (
<div className="w-full h-full">
<Canvas
camera={{ position: [15000, 10000, 25000], fov: 45, far: 500000 }}
>
<ambientLight intensity={1.2} />
<directionalLight position={[20000, 30000, 20000]} intensity={1.5} />
<Bounds fit clip observe margin={1.2}>
<SelectFocus>
{isAssemblyMode ? (
/* Ghép toàn bộ các cấu kiện lại thành cầu */
<group>
{renderSuperTBeam()}
{renderDeck()}
{renderPile()}
{renderPier()}
{renderAbutment()}
{renderBearing()}
{renderRailing()}
{renderDiaphragm()}
</group>
) : (
/* Hiển thị đơn lẻ theo Tab đang chọn */
<>
{activeTab === "superTBeam" && renderSuperTBeam()}
{activeTab === "deck" && renderDeck()}
{activeTab === "pile" && renderPile()}
{activeTab === "pier" && renderPier()}
{activeTab === "abutment" && renderAbutment()}
{activeTab === "bearing" && renderBearing()}
{activeTab === "railing" && renderRailing()}
{activeTab === "diaphragm" && renderDiaphragm()}
</>
)}
</SelectFocus>
</Bounds>
<Grid
args={[200000, 200000]}
sectionColor="#38bdf8"
cellColor="#334155"
infiniteGrid
/>
<OrbitControls makeDefault />
</Canvas>
</div>
);
};
@@ -0,0 +1,22 @@
"use client";
import React from "react";
import { SuperTBeamData } from "../core/schema/superT.schema";
// Component Dầm Ngang
export const DiaphragmsMesh: React.FC<{ data: SuperTBeamData }> = ({
data,
}) => {
return (
<group position={[0, 0, -data.length / 2]}>
{data.diaphragmPositions.map((pos, index) => (
<mesh key={index} position={[0, data.height / 2, pos]} castShadow>
<boxGeometry
args={[data.topFlangeWidth - 200, data.height - 300, 300]}
/>
<meshStandardMaterial color="#4A5568" />
</mesh>
))}
</group>
);
};
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { SuperTBeamData } from "@/constant/types/bridge/superT";
import React from "react";
// Component Tao Cáp Dự Ứng Lực
export const StrandsMesh: React.FC<{ data: SuperTBeamData }> = ({ data }) => {
const strandRows = Math.ceil(data.strandCount / 4);
const strands = [];
for (let i = 0; i < strandRows; i++) {
for (let j = 0; j < 4; j++) {
if (strands.length < data.strandCount) {
strands.push({
x: (j - 1.5) * 60,
y: 80 + i * 50,
});
}
}
}
return (
<group position={[0, 0, -data.length / 2]}>
{strands.map((s, idx) => (
<mesh key={idx} position={[s.x, s.y, data.length / 2]}>
<cylinderGeometry args={[7, 7, data.length, 8]} />
<meshStandardMaterial
color="#E53E3E"
metalness={0.8}
roughness={0.2}
/>
</mesh>
))}
</group>
);
};
@@ -0,0 +1,37 @@
"use client";
import React, { useMemo } from "react";
import * as THREE from "three";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const SuperTBeamMesh: React.FC<{ data: any }> = ({ data }) => {
const beamShape = useMemo(() => {
const shape = new THREE.Shape();
const halfTop = (data.topFlangeWidth || 2100) / 2;
const h = data.height || 1752;
// Vẽ hình chữ nhật đơn giản để test trước
shape.moveTo(-halfTop, 0);
shape.lineTo(halfTop, 0);
shape.lineTo(halfTop, h);
shape.lineTo(-halfTop, h);
shape.closePath();
return shape;
}, [data]);
const extrudeSettings = useMemo(
() => ({
depth: data.length || 33000,
bevelEnabled: false,
}),
[data.length],
);
return (
<mesh position={[0, 0, -(data.length || 33000) / 2]}>
<extrudeGeometry args={[beamShape, extrudeSettings]} />
<meshStandardMaterial color="#00ffff" roughness={0.3} />
</mesh>
);
};
+43
View File
@@ -113,4 +113,47 @@ export const ListOptionFilterDate: {
},
];
export const SPECIALTY_ORDER = [
"Khám Nội",
"Khám Răng",
"Khám Tai Mũi Họng",
"Khám Mắt",
"Khám Sản phụ khoa",
"Xét nghiệm máu",
"Xét nghiệm nước tiểu",
"X-quang",
"Điện tim",
"Siêu Âm",
];
export const SPECIALTY_DISPLAY_NAME: Record<string, string> = {
Nội: "Khám Nội",
"Khám Nội": "Khám Nội",
Răng: "Khám Răng",
"Khám Răng": "Khám Răng",
"Tai Mũi Họng": "Khám Tai Mũi Họng",
"Khám Tai Mũi Họng": "Khám Tai Mũi Họng",
Mắt: "Khám Mắt",
"Khám Mắt": "Khám Mắt",
"Sản Phụ Khoa": "Khám Sản phụ khoa",
"Sản phụ khoa": "Khám Sản phụ khoa",
"Khám Sản phụ khoa": "Khám Sản phụ khoa",
"Xét nghiệm máu": "Xét nghiệm máu",
"Xét nghiệm nước tiểu": "Xét nghiệm nước tiểu",
"X-Quang": "X-quang",
"X-quang": "X-quang",
"Điện Tim": "Điện tim",
"Điện tim": "Điện tim",
"Siêu Âm": "Siêu Âm",
};
export const KEY_STORE = "management-kham-benh";
+2 -2
View File
@@ -1,4 +1,4 @@
import hamburger from "../../../public/static/images/icon-hamburger.svg";
import LogoTedi from "../../../public/static/images/logo-tedi.png";
import full_screen from "../../../public/static/images/full-screen.svg";
import avatar from "../../../public/static/images/avatar-default.png";
import tickCircle from "../../../public/static/images/tick-circle.svg";
@@ -10,7 +10,7 @@ import emptyTable from "../../../public/static/images/empty_table.png";
import emptyFile from "../../../public/static/images/emptyFile.svg";
export default {
hamburger,
LogoTedi,
full_screen,
avatar,
tickCircle,
+139
View File
@@ -0,0 +1,139 @@
import { create } from "zustand";
import { SuperTBeamData } from "@/components/core/schema/superT.schema";
import {
validateSuperTEngineering,
ValidationIssue,
} from "@/components/core/validators/superTValidator";
export type BridgeComponentType =
| "superTBeam"
| "deck"
| "diaphragm"
| "asphalt"
| "railing"
| "expansionJoint"
| "subComponents"
| "bearing"
| "beamDeckJoint"
| "diaphragmJoint"
| "substructureJoint"
| "abutment"
| "pier"
| "footing"
| "pile"
| "alignment";
interface BridgeFullData {
deck: { width: number; thickness: number; slope: number };
diaphragm: { thickness: number; count: number };
railing: { height: number; curbWidth: number };
bearing: { type: string; height: number };
beamDeckJoint: { rebarDiameter: number; spacing: number };
abutment: { height: number; topWidth: number };
pier: { height: number; diameter: number };
pile: { diameter: number; length: number; count: number };
}
interface SuperTStore {
activeTab: BridgeComponentType;
setActiveTab: (tab: BridgeComponentType) => void;
isAssemblyMode: boolean;
setIsAssemblyMode: (val: boolean) => void;
beamData: SuperTBeamData;
validationIssues: ValidationIssue[];
setBeamData: (data: Partial<SuperTBeamData>) => void;
bridgeData: BridgeFullData;
setBridgeData: <K extends keyof BridgeFullData>(
component: K,
data: Partial<BridgeFullData[K]>,
) => void;
exportJSONContract: () => string;
}
const initialData: SuperTBeamData = {
id: "550e8400-e29b-41d4-a716-446655440000",
code: "SUPER-T-33M-01",
length: 33000,
height: 1750,
topFlangeWidth: 2100,
topFlangeThickness: 250,
webThickness: 150,
bottomFlangeWidth: 750,
bottomFlangeThickness: 300,
diaphragmPositions: [0, 16500, 33000],
strandCount: 24,
placement: {
station: 1025.5,
skewAngle: 0,
offsetY: 0,
offsetZ: 0,
},
};
const initialBridgeData: BridgeFullData = {
deck: { width: 12000, thickness: 200, slope: 2 },
diaphragm: { thickness: 300, count: 3 },
railing: { height: 1100, curbWidth: 500 },
bearing: { type: "elastomeric", height: 80 },
beamDeckJoint: { rebarDiameter: 16, spacing: 200 },
abutment: { height: 7500, topWidth: 1800 },
pier: { height: 12000, diameter: 1800 },
pile: { diameter: 1200, length: 30, count: 6 },
};
export const useSuperTStore = create<SuperTStore>((set, get) => ({
activeTab: "superTBeam",
setActiveTab: (tab) => set({ activeTab: tab }),
isAssemblyMode: false,
setIsAssemblyMode: (val) => set({ isAssemblyMode: val }),
beamData: initialData,
bridgeData: initialBridgeData,
validationIssues: validateSuperTEngineering(initialData),
setBeamData: (updated) => {
set((state) => {
const newBeamData = {
...state.beamData,
...updated,
placement: {
...state.beamData.placement,
...(updated.placement || {}),
},
};
const issues = validateSuperTEngineering(newBeamData);
return { beamData: newBeamData, validationIssues: issues };
});
},
setBridgeData: (component, updated) => {
set((state) => ({
bridgeData: {
...state.bridgeData,
[component]: {
...state.bridgeData[component],
...updated,
},
},
}));
},
exportJSONContract: () => {
const { beamData, bridgeData } = get();
return JSON.stringify(
{
header: {
schemaVersion: "1.0",
generator: "TEDI DDP Web Module",
timestamp: new Date().toISOString(),
},
payload: { beamData, bridgeData },
},
null,
2,
);
},
}));
-50
View File
@@ -1,50 +0,0 @@
import axiosClient from ".";
const accountServices = {
sendOTP: (
data: {
email: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Account/send-otp-email`, data, {
cancelToken: tokenAxios,
});
},
enterOTP: (
data: {
email: string;
otp: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Account/enter-otp`, data, {
cancelToken: tokenAxios,
});
},
changePassForget: (
data: {
email: string;
otp: string;
newPass: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Account/reset-password`, data, {
cancelToken: tokenAxios,
});
},
changePassword: (
data: {
oldPass: string;
newPass: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Account/change-password`, data, {
cancelToken: tokenAxios,
});
},
};
export default accountServices;
-240
View File
@@ -1,240 +0,0 @@
import axiosClient from ".";
export interface GetConsultationParams {
Page?: number;
PageSize?: number;
Search?: string;
PatientId?: string;
SortBy?: string;
Desc?: boolean;
}
export interface ConsultationItem {
id: string;
patientId: string;
patientName: string;
sessionCode: string;
visitDate: string;
status: number;
chiefComplaint: string | null;
symptomsText: string | null;
vitalSigns: string | null;
diagnosis: string | null;
treatmentPlan: string | null;
doctorNotes: string | null;
createdBy: string;
isDeleted: boolean;
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: string | number;
sharedItems: {
id: string;
prescriptionId: string;
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
}[];
aggregatedPrescriptionItems: {
id: string;
prescriptionId: string;
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
}
export interface AttachmentFile {
fileName: string;
path: string;
}
export interface ConsultationDetail {
id: string;
patientId: string;
patientName: string;
patientDateOfBirth: string | null;
sessionCode: string;
visitDate: string;
status: string | number;
chiefComplaint: string | null;
symptomsText: string | null;
vitalSigns: string | null;
diagnosis: string | null;
treatmentPlan: string | null;
doctorNotes: string | null;
isGlassesDelivered: boolean;
createdBy: string;
createdByName: string;
isDeleted: boolean;
generalAttachmentUrls: string[];
specialtyClinics: {
id: string;
specialtyId: string;
specialtyName: string;
specialtyNotes: string | null;
diagnosis: string | null;
procedureNotes: string | null;
glassRequired: boolean | null;
glassType: string | null;
isGlassesDelivered: boolean;
examinerName: string;
specialtyStatus: string | number | undefined;
attachmentUrls: string[];
sharedItems: {
id: string;
prescriptionId: string;
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
}[];
aggregatedPrescriptionItems: {
id: string;
prescriptionId: string;
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
}
export interface ConsultationResponse {
items: ConsultationItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface ReportResponse {
fromDate: string;
toDate: string;
totalSessions: number;
totalSpecialtySubSessions: number;
specialtyDetails: {
specialtyId: string;
specialtyCode: string;
specialtyName: string;
totalCases: number;
}[];
totalPrescriptionsIssued: number;
totalGlassesDelivered: number;
}
const consultationServices = {
getConsultations: (params?: GetConsultationParams, tokenAxios?: any) => {
return axiosClient.get<ConsultationResponse>(
`/api/v1/ExaminationSession/list`,
{
params,
cancelToken: tokenAxios,
},
);
},
getConsultationsReport: (
params: { FromDate: 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>(
`/api/v1/ExaminationSession/${id}`,
{
cancelToken: tokenAxios,
},
);
},
putConsultations: (
id: string,
data: {
chiefComplaint: string | null;
symptomsText: string | null;
vitalSigns: string | null;
diagnosis: string | null;
treatmentPlan: string | null;
doctorNotes: string | null;
isGlassesDelivered: boolean;
fileUrls: string[];
},
tokenAxios?: any,
) => {
return axiosClient.put(`/api/v1/ExaminationSession/${id}`, data, {
cancelToken: tokenAxios,
});
},
cancelConsultations: (id: string, tokenAxios?: any) => {
return axiosClient.post(`/api/v1/ExaminationSession/${id}/cancel`, {
cancelToken: tokenAxios,
});
},
completeConsultations: (id: string, tokenAxios?: any) => {
return axiosClient.post(`/api/v1/ExaminationSession/${id}/complete`, {
cancelToken: tokenAxios,
});
},
specialtyResults: (
specialtyAssignmentId: string,
data: {
specialtyNotes: string;
diagnosis: string;
procedureNotes: string;
glassRequired: boolean;
glassType: string;
isGlassesDelivered: boolean;
fileUrls: string[];
},
tokenAxios?: any,
) => {
return axiosClient.put(
`/api/v1/ExaminationSession/specialty-results/${specialtyAssignmentId}`,
data,
{
cancelToken: tokenAxios,
},
);
},
};
export default consultationServices;
-118
View File
@@ -1,118 +0,0 @@
import axiosClient from ".";
export interface PatientItem {
id: string;
patientCode: string;
identificationNumber: string;
fullName: string;
dateOfBirth: string;
gender: string;
phone: string;
email: string;
address: string;
emergencyContactName?: string;
emergencyContactPhone?: string;
allergyNotes?: string;
chronicDiseaseNotes?: string;
insuranceNumber?: string;
notes?: string;
isActive: boolean;
}
export interface PatientDetail {
id: string;
patientCode: string;
identificationNumber: string;
fullName: string;
dateOfBirth: string;
gender: string;
phone: string;
email: string;
address: string;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
allergyNotes: string | null;
chronicDiseaseNotes: string | null;
insuranceNumber: string | null;
notes: string | null;
isActive: boolean;
}
export interface GetPatientParams {
Page?: number;
PageSize?: number;
Search?: string;
IdentificationNumber?: string;
SortBy?: string;
Desc?: boolean;
}
export interface PatientResponse {
items: PatientItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
const patientServices = {
getPatient: (params?: GetPatientParams, tokenAxios?: any) => {
return axiosClient.get<PatientResponse>(`/api/v1/Patient/list`, {
params,
cancelToken: tokenAxios,
});
},
createPatient: (
data: {
identificationNumber: string;
fullName: string;
dateOfBirth: string;
gender: string;
phone: string;
email: string;
address: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/api/v1/Patient`, data, {
cancelToken: tokenAxios,
});
},
putPatient: (
id: string,
data: {
fullName: string;
gender: string;
phone: string;
email: string;
address: string;
emergencyContactName: string;
emergencyContactPhone: string;
allergyNotes: string;
chronicDiseaseNotes: string;
insuranceNumber: string;
notes: string;
},
tokenAxios?: any,
) => {
return axiosClient.put(`/api/v1/Patient/${id}`, data, {
cancelToken: tokenAxios,
});
},
detailPatient: (id: string, tokenAxios?: any) => {
return axiosClient.get<PatientDetail>(`/api/v1/Patient/${id}`, {
cancelToken: tokenAxios,
});
},
deletePatient: (id: string, tokenAxios?: any) => {
return axiosClient.delete(`/api/v1/Patient/${id}`, {
cancelToken: tokenAxios,
});
},
};
export default patientServices;
-117
View File
@@ -1,117 +0,0 @@
import axiosClient from ".";
export interface GetPrescriptionParams {
Page?: number;
PageSize?: number;
Search?: string;
SortBy?: string;
Desc?: boolean;
}
export interface PrescriptionItem {
id: string;
examinationSessionId: string;
prescriptionCode: string;
prescriptionImg: string | null;
notes: string;
createdBy: string;
items: {
id: string;
prescriptionId: string;
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
}
export interface PrescriptionDetail {
id: string;
examinationSessionSpecialtyId: string;
prescriptionCode: string;
prescriptionImg: string;
notes: string;
createdBy: string;
items: {
id: string;
prescriptionId: string;
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
}
export interface PrescriptionResponse {
items: PrescriptionItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
const prescriptionServices = {
/**
* CREATE
*/
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,
});
},
/**
* GET LIST
*/
getPrescription: (params?: GetPrescriptionParams, tokenAxios?: any) => {
return axiosClient.get<PrescriptionResponse>(`/api/v1/Prescription`, {
params,
cancelToken: tokenAxios,
});
},
/**
* UPDATE
*/
putPrescription: (id: string, data: FormData, tokenAxios?: any) => {
return axiosClient.put(`/api/v1/Prescription/${id}`, data, {
headers: {
"Content-Type": "multipart/form-data",
},
cancelToken: tokenAxios,
});
},
/**
* DETAIL
*/
detailPrescription: (id: string, tokenAxios?: any) => {
return axiosClient.get<PrescriptionDetail>(`/api/v1/Prescription/${id}`, {
cancelToken: tokenAxios,
});
},
/**
* UPLOAD IMAGE
*/
};
export default prescriptionServices;
-50
View File
@@ -1,50 +0,0 @@
import axiosClient from ".";
export interface GetProfileParams {
Page?: number;
PageSize?: number;
Search?: string;
SortBy?: string;
Desc?: boolean;
}
export interface ProfileResponse {
items: ProfileItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface ProfileItem {
id: string;
accountId: string;
fullName: string;
email: string;
phone: string | null;
dateOfBirth: string | null;
avatarUrl: string | null;
}
const profileServices = {
getProfile: (params?: GetProfileParams, tokenAxios?: any) => {
return axiosClient.get<ProfileResponse>(`/api/v1/UserProfile/list`, {
params,
cancelToken: tokenAxios,
});
},
putProfile: (
id: string,
data: {
fullName: string;
email: string;
phone: string;
birthDate: string;
avatarUrl: string;
},
) => {
return axiosClient.put<ProfileItem>(`/api/v1/UserProfile/${id}`, data);
},
};
export default profileServices;
-17
View File
@@ -1,17 +0,0 @@
import axiosClient from ".";
export interface SpecialtyItem {
id: string;
code: string;
name: string;
}
const specialtyServices = {
getSpecialty: (tokenAxios?: any) => {
return axiosClient.get<SpecialtyItem[]>("/api/v1/Specialty/lookup", {
cancelToken: tokenAxios,
});
},
};
export default specialtyServices;
-50
View File
@@ -1,50 +0,0 @@
import axiosClient from ".";
type UploadPayload = {
Files: File[];
SessionCode?: string;
};
const uploadServices = {
// Accept either: (filesArray, sessionCode?) OR ({ Files, SessionCode })
uploadImage: (
filesOrPayload: File[] | File | UploadPayload,
sessionCode?: string,
) => {
let files: File[] = [];
let session: string | undefined = sessionCode;
if (!filesOrPayload) return Promise.reject(new Error("No files provided"));
if (Array.isArray(filesOrPayload)) {
files = filesOrPayload as File[];
} else if ((filesOrPayload as UploadPayload).Files) {
const p = filesOrPayload as UploadPayload;
files = p.Files || [];
session = p.SessionCode || session;
} else {
files = [filesOrPayload as File];
}
const formData = new FormData();
files.forEach((file) => {
formData.append("Files", file);
});
if (session) {
formData.append("SessionCode", session);
}
// Override content-type for this request so multipart boundary is set correctly
return axiosClient.post("/api/Media/upload", formData, {
headers: {
// Let browser/axios set the correct boundary; setting to multipart/form-data
// overrides the default 'application/json' header defined on axiosClient.
"Content-Type": "multipart/form-data",
},
});
},
};
export default uploadServices;
-90
View File
@@ -1,90 +0,0 @@
import axiosClient from ".";
interface GetUserParams {
Page?: number;
PageSize?: number;
Search?: string;
Role?: number;
IsActive?: boolean;
SortBy?: string;
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;
}
export interface UserDetail {
id: string;
username: string;
role?: number;
roleType?: number | string | null;
isActive?: boolean;
fullName: string | null;
email: string;
phone: string;
}
const userServices = {
getUser: (params?: GetUserParams, tokenAxios?: any) => {
return axiosClient.get(`/api/v1/User/list`, {
params,
cancelToken: tokenAxios,
});
},
postUser: (
data: {
username: string;
password: string;
role: number;
roleType: number;
fullName: string;
email: string;
phone: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/api/v1/Users/`, data, {
cancelToken: tokenAxios,
});
},
putUser: (
id: string,
data: {
role: number;
roleType: number;
fullName: string;
email: string;
phone: string;
},
tokenAxios?: any,
) => {
return axiosClient.put(`/api/v1/Users/${id}`, data, {
cancelToken: tokenAxios,
});
},
deleteUser: (id: string, tokenAxios?: any) => {
return axiosClient.delete(`/api/v1/Users/${id}`, {
cancelToken: tokenAxios,
});
},
detailUser: (id: string, tokenAxios?: any) => {
return axiosClient.get(`/api/v1/Users/${id}`, {
cancelToken: tokenAxios,
});
},
meUser: (tokenAxios?: any) => {
return axiosClient.get<UserMe>(`/api/v1/Users/me`, {
cancelToken: tokenAxios,
});
},
};
export default userServices;
+593 -38
View File
@@ -151,6 +151,13 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.26.0":
version: 7.29.7
resolution: "@babel/runtime@npm:7.29.7"
checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e
languageName: node
linkType: hard
"@babel/template@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/template@npm:7.28.6"
@@ -187,6 +194,13 @@ __metadata:
languageName: node
linkType: hard
"@dimforge/rapier3d-compat@npm:~0.12.0":
version: 0.12.0
resolution: "@dimforge/rapier3d-compat@npm:0.12.0"
checksum: 10c0/c66c24f90649c0fc870679c12e7fec1a111080d44450169b57561f957d7b6b284ad8a3ceeba95533e213176ea171351acebd3dd43885fafb33f18bfbd9d507db
languageName: node
linkType: hard
"@emnapi/core@npm:1.10.0, @emnapi/core@npm:^1.10.0":
version: 1.10.0
resolution: "@emnapi/core@npm:1.10.0"
@@ -653,6 +667,24 @@ __metadata:
languageName: node
linkType: hard
"@mediapipe/tasks-vision@npm:0.10.17":
version: 0.10.17
resolution: "@mediapipe/tasks-vision@npm:0.10.17"
checksum: 10c0/f2f5dd9ca39d562b902a6d964f8b786f17a143f42776d0d26b8c79632b10cf210e28d98ec92de757b7bb2e6595ed4f9bbf5e49f1709b09dc08709bd276ced442
languageName: node
linkType: hard
"@monogrid/gainmap-js@npm:^3.0.6":
version: 3.4.0
resolution: "@monogrid/gainmap-js@npm:3.4.0"
dependencies:
promise-worker-transferable: "npm:^1.0.4"
peerDependencies:
three: ">= 0.159.0"
checksum: 10c0/06104767a0f542b36e1e203fe569f78996babcdd52661f42db4f079f372b272e0849b86f84f7690b2c165a8ba931f77ad324fe82a09c9b23e26d20e76cabd3e7
languageName: node
linkType: hard
"@napi-rs/wasm-runtime@npm:^1.1.4":
version: 1.1.4
resolution: "@napi-rs/wasm-runtime@npm:1.1.4"
@@ -1188,6 +1220,83 @@ __metadata:
languageName: node
linkType: hard
"@react-three/drei@npm:^10.7.8":
version: 10.7.8
resolution: "@react-three/drei@npm:10.7.8"
dependencies:
"@babel/runtime": "npm:^7.26.0"
"@mediapipe/tasks-vision": "npm:0.10.17"
"@monogrid/gainmap-js": "npm:^3.0.6"
"@use-gesture/react": "npm:^10.3.1"
camera-controls: "npm:^3.1.0"
cross-env: "npm:^7.0.3"
detect-gpu: "npm:^5.0.56"
glsl-noise: "npm:^0.0.0"
hls.js: "npm:^1.5.17"
maath: "npm:^0.10.8"
meshline: "npm:^3.3.1"
stats-gl: "npm:^2.2.8"
stats.js: "npm:^0.17.0"
suspend-react: "npm:^0.1.3"
three-mesh-bvh: "npm:^0.8.3"
three-stdlib: "npm:^2.35.6"
troika-three-text: "npm:^0.52.4"
tunnel-rat: "npm:^0.1.2"
use-sync-external-store: "npm:^1.4.0"
utility-types: "npm:^3.11.0"
zustand: "npm:^5.0.1"
peerDependencies:
"@react-three/fiber": ^9.0.0
react: ^19
react-dom: ^19
three: ">=0.159"
peerDependenciesMeta:
react-dom:
optional: true
checksum: 10c0/b4103a79f2cebfd6ff00a869de38e602ad670df14bd08c2fa0d44b71ebebe81b000b523077df01df8c358fe406a4e1ff75c44eb727bca216d3407c1d7e91192e
languageName: node
linkType: hard
"@react-three/fiber@npm:^9.7.0":
version: 9.7.0
resolution: "@react-three/fiber@npm:9.7.0"
dependencies:
"@babel/runtime": "npm:^7.17.8"
"@types/webxr": "npm:*"
base64-js: "npm:^1.5.1"
buffer: "npm:^6.0.3"
its-fine: "npm:^2.0.0"
react-use-measure: "npm:^2.1.7"
scheduler: "npm:^0.27.0"
suspend-react: "npm:^0.1.3"
use-sync-external-store: "npm:^1.4.0"
zustand: "npm:^5.0.3"
peerDependencies:
expo: ">=43.0"
expo-asset: ">=8.4"
expo-file-system: ">=11.0"
expo-gl: ">=11.0"
react: ">=19 <19.3"
react-dom: ">=19 <19.3"
react-native: ">=0.78"
three: ">=0.156"
peerDependenciesMeta:
expo:
optional: true
expo-asset:
optional: true
expo-file-system:
optional: true
expo-gl:
optional: true
react-dom:
optional: true
react-native:
optional: true
checksum: 10c0/7ad36da135b5105e379c4984455dab1bd7cfabfad7548e4ff87ee161f8265be5d9e9af7f00a761c7f3a4252dc2db7c7ea8b9bfd9db7f601dd9759ee1179dc533
languageName: node
linkType: hard
"@reduxjs/toolkit@npm:^2.12.0":
version: 2.12.0
resolution: "@reduxjs/toolkit@npm:2.12.0"
@@ -1422,6 +1531,13 @@ __metadata:
languageName: node
linkType: hard
"@tweenjs/tween.js@npm:~23.1.3":
version: 23.1.3
resolution: "@tweenjs/tween.js@npm:23.1.3"
checksum: 10c0/811b30f5f0e7409fb41833401c501c2d6f600eb5f43039dd9067a7f70aff6dad5f5ce1233848e13f0b33a269a160d9c133f344d986cbff4f1f6b72ddecd06c89
languageName: node
linkType: hard
"@tybys/wasm-util@npm:^0.10.1":
version: 0.10.2
resolution: "@tybys/wasm-util@npm:0.10.2"
@@ -1431,6 +1547,13 @@ __metadata:
languageName: node
linkType: hard
"@types/draco3d@npm:^1.4.0":
version: 1.4.10
resolution: "@types/draco3d@npm:1.4.10"
checksum: 10c0/431e333b2fd67e2b081e8697e71dfb82a125fc04b2cbfa0205e4d521719d3749c964b2bf82bb7944ba0a12e5bc3d9afe387a58e68b195d838fcdbc65c8572a35
languageName: node
linkType: hard
"@types/estree@npm:^1.0.6":
version: 1.0.9
resolution: "@types/estree@npm:1.0.9"
@@ -1475,6 +1598,13 @@ __metadata:
languageName: node
linkType: hard
"@types/offscreencanvas@npm:^2019.6.4":
version: 2019.7.3
resolution: "@types/offscreencanvas@npm:2019.7.3"
checksum: 10c0/6d1dfae721d321cd2b5435f347a0e53b09f33b2f9e9333396480f592823bc323847b8169f7d251d2285cb93dbc1ba2e30741ac5cf4b1c003d660fd4c24526963
languageName: node
linkType: hard
"@types/react-dom@npm:^19":
version: 19.2.3
resolution: "@types/react-dom@npm:19.2.3"
@@ -1484,6 +1614,15 @@ __metadata:
languageName: node
linkType: hard
"@types/react-reconciler@npm:^0.28.9":
version: 0.28.9
resolution: "@types/react-reconciler@npm:0.28.9"
peerDependencies:
"@types/react": "*"
checksum: 10c0/9fe71fa856aab2cd4742fe0416bdd4f5c82ecc05ef6451ee7fcb65a5efdf5fa588f5820fbe2a665b15371b0da3bfc4097f28bb6d450b9a834af2d0fc00f403bd
languageName: node
linkType: hard
"@types/react@npm:^19":
version: 19.2.15
resolution: "@types/react@npm:19.2.15"
@@ -1493,6 +1632,27 @@ __metadata:
languageName: node
linkType: hard
"@types/stats.js@npm:*":
version: 0.17.4
resolution: "@types/stats.js@npm:0.17.4"
checksum: 10c0/4fe0429998519f0476f03a25b4900b4d4a1474606478657271e40a884f7936ba902ea564b1c95cfd33a8e84af46cef6e1e98bb23e86fd3b6676cd5b974987151
languageName: node
linkType: hard
"@types/three@npm:*, @types/three@npm:^0":
version: 0.185.4
resolution: "@types/three@npm:0.185.4"
dependencies:
"@dimforge/rapier3d-compat": "npm:~0.12.0"
"@tweenjs/tween.js": "npm:~23.1.3"
"@types/stats.js": "npm:*"
"@types/webxr": "npm:>=0.5.17"
fflate: "npm:~0.8.2"
meshoptimizer: "npm:~1.1.1"
checksum: 10c0/f9cc28d3f343067e56741017bb0ac0257d8d2994707c2ee69512b668a57c8cd8892798978e54b2224920e9495eebe0878fcd8ea18b9a5ab555a7d445c689f804
languageName: node
linkType: hard
"@types/use-sync-external-store@npm:^0.0.6":
version: 0.0.6
resolution: "@types/use-sync-external-store@npm:0.0.6"
@@ -1500,6 +1660,13 @@ __metadata:
languageName: node
linkType: hard
"@types/webxr@npm:*, @types/webxr@npm:>=0.5.17, @types/webxr@npm:^0.5.2":
version: 0.5.24
resolution: "@types/webxr@npm:0.5.24"
checksum: 10c0/ff59ffd390d06ca3f89ab2531d583ac10bc5e2ab82e5a01ecc40fbc365224a3375e7caa5b14649cf6141db21fb024940da7ad2bd8faa3cd497a6665257cb53b5
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.59.4":
version: 8.59.4
resolution: "@typescript-eslint/eslint-plugin@npm:8.59.4"
@@ -1793,6 +1960,65 @@ __metadata:
languageName: node
linkType: hard
"@use-gesture/core@npm:10.3.1":
version: 10.3.1
resolution: "@use-gesture/core@npm:10.3.1"
checksum: 10c0/2e3b5c0f7fe26cdb47be3a9c2a58a6a9edafc5b2895b07d2898eda9ab5a2b29fb0098b15597baa0856907b593075cd44cc69bba4785c9cfb7b6fabaa3b52cd3e
languageName: node
linkType: hard
"@use-gesture/react@npm:^10.3.1":
version: 10.3.1
resolution: "@use-gesture/react@npm:10.3.1"
dependencies:
"@use-gesture/core": "npm:10.3.1"
peerDependencies:
react: ">= 16.8.0"
checksum: 10c0/978da66e4e7c424866ad52eba8fdf0ce93a4c8fc44f8837c7043e68c6a6107cd67e817fffb27f7db2ae871ef2f6addb0c8ddf1586f24c67b7e6aef1646c668cf
languageName: node
linkType: hard
"TEDI-DDP@workspace:.":
version: 0.0.0-use.local
resolution: "TEDI-DDP@workspace:."
dependencies:
"@radix-ui/react-popover": "npm:^1.1.15"
"@radix-ui/react-tooltip": "npm:^1.2.8"
"@react-three/drei": "npm:^10.7.8"
"@react-three/fiber": "npm:^9.7.0"
"@reduxjs/toolkit": "npm:^2.12.0"
"@tailwindcss/postcss": "npm:^4"
"@tanstack/react-query": "npm:^5.100.11"
"@types/md5": "npm:^2"
"@types/node": "npm:^20"
"@types/nprogress": "npm:^0"
"@types/react": "npm:^19"
"@types/react-dom": "npm:^19"
"@types/three": "npm:^0"
axios: "npm:^1.16.1"
babel-plugin-react-compiler: "npm:1.0.0"
clsx: "npm:^2.1.1"
eslint: "npm:^9"
eslint-config-next: "npm:16.2.6"
lottie-react: "npm:^2.4.1"
lucide-react: "npm:^1.16.0"
md5: "npm:^2.3.0"
moment: "npm:^2.30.1"
next: "npm:16.2.6"
nprogress: "npm:^0.2.0"
react: "npm:19.2.4"
react-dom: "npm:19.2.4"
react-moment: "npm:^2.0.2"
react-redux: "npm:^9.3.0"
react-toastify: "npm:^11.1.0"
tailwindcss: "npm:^4"
three: "npm:^0.185.1"
typescript: "npm:^5"
zod: "npm:^4.4.3"
zustand: "npm:^5.0.14"
languageName: unknown
linkType: soft
"acorn-jsx@npm:^5.3.2":
version: 5.3.2
resolution: "acorn-jsx@npm:5.3.2"
@@ -2057,6 +2283,13 @@ __metadata:
languageName: node
linkType: hard
"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf
languageName: node
linkType: hard
"baseline-browser-mapping@npm:^2.10.12, baseline-browser-mapping@npm:^2.9.19":
version: 2.10.31
resolution: "baseline-browser-mapping@npm:2.10.31"
@@ -2066,6 +2299,15 @@ __metadata:
languageName: node
linkType: hard
"bidi-js@npm:^1.0.2":
version: 1.0.3
resolution: "bidi-js@npm:1.0.3"
dependencies:
require-from-string: "npm:^2.0.2"
checksum: 10c0/fdddea4aa4120a34285486f2267526cd9298b6e8b773ad25e765d4f104b6d7437ab4ba542e6939e3ac834a7570bcf121ee2cf6d3ae7cd7082c4b5bedc8f271e1
languageName: node
linkType: hard
"brace-expansion@npm:^1.1.7":
version: 1.1.14
resolution: "brace-expansion@npm:1.1.14"
@@ -2109,6 +2351,16 @@ __metadata:
languageName: node
linkType: hard
"buffer@npm:^6.0.3":
version: 6.0.3
resolution: "buffer@npm:6.0.3"
dependencies:
base64-js: "npm:^1.3.1"
ieee754: "npm:^1.2.1"
checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
@@ -2148,6 +2400,15 @@ __metadata:
languageName: node
linkType: hard
"camera-controls@npm:^3.1.0":
version: 3.1.2
resolution: "camera-controls@npm:3.1.2"
peerDependencies:
three: ">=0.126.1"
checksum: 10c0/2d42162efd6789e713332a72212819fb04b261f7babc45b5794cd902997e4a4826c9401b672ffab2e09f9808fc2b615d9154d67c57f38a7c90487d4cf5429655
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001782":
version: 1.0.30001793
resolution: "caniuse-lite@npm:1.0.30001793"
@@ -2225,7 +2486,19 @@ __metadata:
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.6":
"cross-env@npm:^7.0.3":
version: 7.0.3
resolution: "cross-env@npm:7.0.3"
dependencies:
cross-spawn: "npm:^7.0.1"
bin:
cross-env: src/bin/cross-env.js
cross-env-shell: src/bin/cross-env-shell.js
checksum: 10c0/f3765c25746c69fcca369655c442c6c886e54ccf3ab8c16847d5ad0e91e2f337d36eedc6599c1227904bf2a228d721e690324446876115bc8e7b32a866735ecf
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.6":
version: 7.0.6
resolution: "cross-spawn@npm:7.0.6"
dependencies:
@@ -2347,6 +2620,15 @@ __metadata:
languageName: node
linkType: hard
"detect-gpu@npm:^5.0.56":
version: 5.0.70
resolution: "detect-gpu@npm:5.0.70"
dependencies:
webgl-constants: "npm:^1.1.1"
checksum: 10c0/5a2053d4779edb3490821599b316b5362c8b55c30b82806c64bcfd21f40c67e2debfd677937899080aea2b27765471552bafeeae9c9f1cf63bdd07461a7e2163
languageName: node
linkType: hard
"detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
@@ -2370,6 +2652,13 @@ __metadata:
languageName: node
linkType: hard
"draco3d@npm:^1.4.1":
version: 1.5.7
resolution: "draco3d@npm:1.5.7"
checksum: 10c0/4419509bb93c31560a22a1a54e1c394a5b5017cab39941120c75151d941c11dec05925abf31f597ac2694c570b78c04f82aa3d69e5311f8f8e71fc8b9d92d12f
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -2905,6 +3194,20 @@ __metadata:
languageName: node
linkType: hard
"fflate@npm:^0.6.9":
version: 0.6.11
resolution: "fflate@npm:0.6.11"
checksum: 10c0/5bbf4a7557db6f65b25c7bb12621008c400a116ec1cc65bea78abd75c0c049d8a22fb92f0a59fbf9a7ad1a24a47d2112ce10fd817d08db0e1bf7dc82e780faa1
languageName: node
linkType: hard
"fflate@npm:~0.8.2":
version: 0.8.3
resolution: "fflate@npm:0.8.3"
checksum: 10c0/eab181ca37f5348ae76d4b6f840e0026e30220e33153289ac942222d8b9638237d486507dbcc09878d724095bd354993a2ee48bbee99c8f2c6440d4448719aa7
languageName: node
linkType: hard
"file-entry-cache@npm:^8.0.0":
version: 8.0.0
resolution: "file-entry-cache@npm:8.0.0"
@@ -3124,6 +3427,13 @@ __metadata:
languageName: node
linkType: hard
"glsl-noise@npm:^0.0.0":
version: 0.0.0
resolution: "glsl-noise@npm:0.0.0"
checksum: 10c0/442630e86ed109079f4ed92f3b59d24d0b9da60c03cb8ac9313a056996bf4b5d696b4c6121ee29952e8a19c5cf174a999cfe672464959db36fc75334c3134677
languageName: node
linkType: hard
"gopd@npm:^1.0.1, gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
@@ -3211,6 +3521,13 @@ __metadata:
languageName: node
linkType: hard
"hls.js@npm:^1.5.17":
version: 1.6.18
resolution: "hls.js@npm:1.6.18"
checksum: 10c0/f1601ed4da27e09341f2dd2ac29951d30fb0c78581c405217a8286ec3962e50a181eb82e362dccf828acd364deae1fe0236fc8e6aeed9b2e990ac61e2bd82a77
languageName: node
linkType: hard
"https-proxy-agent@npm:^5.0.1":
version: 5.0.1
resolution: "https-proxy-agent@npm:5.0.1"
@@ -3221,6 +3538,13 @@ __metadata:
languageName: node
linkType: hard
"ieee754@npm:^1.2.1":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb
languageName: node
linkType: hard
"ignore@npm:^5.2.0":
version: 5.3.2
resolution: "ignore@npm:5.3.2"
@@ -3235,6 +3559,13 @@ __metadata:
languageName: node
linkType: hard
"immediate@npm:~3.0.5":
version: 3.0.6
resolution: "immediate@npm:3.0.6"
checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039
languageName: node
linkType: hard
"immer@npm:^11.0.0":
version: 11.1.8
resolution: "immer@npm:11.1.8"
@@ -3435,6 +3766,13 @@ __metadata:
languageName: node
linkType: hard
"is-promise@npm:^2.1.0":
version: 2.2.2
resolution: "is-promise@npm:2.2.2"
checksum: 10c0/2dba959812380e45b3df0fb12e7cb4d4528c989c7abb03ececb1d1fd6ab1cbfee956ca9daa587b9db1d8ac3c1e5738cf217bdb3dfd99df8c691be4c00ae09069
languageName: node
linkType: hard
"is-regex@npm:^1.2.1":
version: 1.2.1
resolution: "is-regex@npm:1.2.1"
@@ -3547,6 +3885,17 @@ __metadata:
languageName: node
linkType: hard
"its-fine@npm:^2.0.0":
version: 2.0.0
resolution: "its-fine@npm:2.0.0"
dependencies:
"@types/react-reconciler": "npm:^0.28.9"
peerDependencies:
react: ^19.0.0
checksum: 10c0/1ff1ff3257c0c7eb115c9c26cf0506eb84162edc1a63d3136780d146f7c7833298b240d0fcb46888909773f1a7d16539e0c03f2482cff1a5a502d6436686fe21
languageName: node
linkType: hard
"jiti@npm:^2.6.1":
version: 2.7.0
resolution: "jiti@npm:2.7.0"
@@ -3645,41 +3994,6 @@ __metadata:
languageName: node
linkType: hard
"kham-benh-mo-pho@workspace:.":
version: 0.0.0-use.local
resolution: "kham-benh-mo-pho@workspace:."
dependencies:
"@radix-ui/react-popover": "npm:^1.1.15"
"@radix-ui/react-tooltip": "npm:^1.2.8"
"@reduxjs/toolkit": "npm:^2.12.0"
"@tailwindcss/postcss": "npm:^4"
"@tanstack/react-query": "npm:^5.100.11"
"@types/md5": "npm:^2"
"@types/node": "npm:^20"
"@types/nprogress": "npm:^0"
"@types/react": "npm:^19"
"@types/react-dom": "npm:^19"
axios: "npm:^1.16.1"
babel-plugin-react-compiler: "npm:1.0.0"
clsx: "npm:^2.1.1"
eslint: "npm:^9"
eslint-config-next: "npm:16.2.6"
lottie-react: "npm:^2.4.1"
lucide-react: "npm:^1.16.0"
md5: "npm:^2.3.0"
moment: "npm:^2.30.1"
next: "npm:16.2.6"
nprogress: "npm:^0.2.0"
react: "npm:19.2.4"
react-dom: "npm:19.2.4"
react-moment: "npm:^2.0.2"
react-redux: "npm:^9.3.0"
react-toastify: "npm:^11.1.0"
tailwindcss: "npm:^4"
typescript: "npm:^5"
languageName: unknown
linkType: soft
"language-subtag-registry@npm:^0.3.20":
version: 0.3.23
resolution: "language-subtag-registry@npm:0.3.23"
@@ -3706,6 +4020,15 @@ __metadata:
languageName: node
linkType: hard
"lie@npm:^3.0.2":
version: 3.3.0
resolution: "lie@npm:3.3.0"
dependencies:
immediate: "npm:~3.0.5"
checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808
languageName: node
linkType: hard
"lightningcss-android-arm64@npm:1.32.0":
version: 1.32.0
resolution: "lightningcss-android-arm64@npm:1.32.0"
@@ -3890,6 +4213,16 @@ __metadata:
languageName: node
linkType: hard
"maath@npm:^0.10.8":
version: 0.10.8
resolution: "maath@npm:0.10.8"
peerDependencies:
"@types/three": ">=0.134.0"
three: ">=0.134.0"
checksum: 10c0/fb61faac89567595f7e25f261f8a9750d0ced674fd16c77c6e486a0c4a00c572fa37969d613d017380f8ea4e8ff357d481dcd94240a104a7969bc7e95b9fe99e
languageName: node
linkType: hard
"magic-string@npm:^0.30.21":
version: 0.30.21
resolution: "magic-string@npm:0.30.21"
@@ -3924,6 +4257,22 @@ __metadata:
languageName: node
linkType: hard
"meshline@npm:^3.3.1":
version: 3.3.1
resolution: "meshline@npm:3.3.1"
peerDependencies:
three: ">=0.137"
checksum: 10c0/95d5c7c90aca350530f37f9ea5c5e333a66ea0c50416fa6372c24a23321837a4c70d92d6a9a5942ee124484855a2d3fc8ca0656305feb8d8f86aa10513e324e4
languageName: node
linkType: hard
"meshoptimizer@npm:~1.1.1":
version: 1.1.1
resolution: "meshoptimizer@npm:1.1.1"
checksum: 10c0/aacd816cbe206e49f16c88ed8b13deafec40cdb8b844080b6d614b4d945dfc7242d5dccfbc1269375a5d4dce2f354261c107d3ce6e564961bd9a220e4616dafb
languageName: node
linkType: hard
"micromatch@npm:^4.0.4":
version: 4.0.8
resolution: "micromatch@npm:4.0.8"
@@ -4305,6 +4654,13 @@ __metadata:
languageName: node
linkType: hard
"potpack@npm:^1.0.1":
version: 1.0.2
resolution: "potpack@npm:1.0.2"
checksum: 10c0/670c23898a4257130858b960c2e654d3327c0f6a7e7091ff5846f213e65af8f9476320b995b8ad561a47a4d1c359c7ef347de57d22e7b02597051abb52bc85c4
languageName: node
linkType: hard
"prelude-ls@npm:^1.2.1":
version: 1.2.1
resolution: "prelude-ls@npm:1.2.1"
@@ -4312,6 +4668,16 @@ __metadata:
languageName: node
linkType: hard
"promise-worker-transferable@npm:^1.0.4":
version: 1.0.4
resolution: "promise-worker-transferable@npm:1.0.4"
dependencies:
is-promise: "npm:^2.1.0"
lie: "npm:^3.0.2"
checksum: 10c0/110d273cbba2631c318762f02c159531f1e05e4af91080368b9d4c02b880824ae4c49a8ec7094c6434989ea2ee8de913b21c809279babeaf447fc90a682dcd7c
languageName: node
linkType: hard
"prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
@@ -4458,6 +4824,19 @@ __metadata:
languageName: node
linkType: hard
"react-use-measure@npm:^2.1.7":
version: 2.1.7
resolution: "react-use-measure@npm:2.1.7"
peerDependencies:
react: ">=16.13"
react-dom: ">=16.13"
peerDependenciesMeta:
react-dom:
optional: true
checksum: 10c0/ff24130e6f95e853feb6892fb74af08dbc5aae3574b701169e3bc3adb392c3162f51a58ddfe39bb7337db13ae609bbec0bb51a9de8b5fae5420f9d17e1f8b542
languageName: node
linkType: hard
"react@npm:19.2.4":
version: 19.2.4
resolution: "react@npm:19.2.4"
@@ -4511,6 +4890,13 @@ __metadata:
languageName: node
linkType: hard
"require-from-string@npm:^2.0.2":
version: 2.0.2
resolution: "require-from-string@npm:2.0.2"
checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2
languageName: node
linkType: hard
"reselect@npm:^5.1.0":
version: 5.2.0
resolution: "reselect@npm:5.2.0"
@@ -4838,6 +5224,26 @@ __metadata:
languageName: node
linkType: hard
"stats-gl@npm:^2.2.8":
version: 2.4.2
resolution: "stats-gl@npm:2.4.2"
dependencies:
"@types/three": "npm:*"
three: "npm:^0.170.0"
peerDependencies:
"@types/three": "*"
three: "*"
checksum: 10c0/cff079103d4004cdfd72d65c977b7237a929b02f57b275081188e9313d74620b70ea0a57e0561fb6a43b1597830068b4adb2625dbc08b99799fd12a71ec54729
languageName: node
linkType: hard
"stats.js@npm:^0.17.0":
version: 0.17.0
resolution: "stats.js@npm:0.17.0"
checksum: 10c0/03392b4b9eb15cb285b8cf46c5dd68e4e7b556d8e4c72a3a48e6bd2ab6f2e72a02b145d465f594450df081d8f94a45e8ac1112a0d507a200a5768504082ca080
languageName: node
linkType: hard
"stop-iteration-iterator@npm:^1.1.0":
version: 1.1.0
resolution: "stop-iteration-iterator@npm:1.1.0"
@@ -4974,6 +5380,15 @@ __metadata:
languageName: node
linkType: hard
"suspend-react@npm:^0.1.3":
version: 0.1.3
resolution: "suspend-react@npm:0.1.3"
peerDependencies:
react: ">=17.0"
checksum: 10c0/c35436f492a5cab85a2aac8f5ee5af8f67edbab3a49e12d15c7f770aae4cf09fe2925b3b8c122929210c1f28aa0958f91684a687d6608f116f7da8406d8a2f1c
languageName: node
linkType: hard
"tailwindcss@npm:4.3.0, tailwindcss@npm:^4":
version: 4.3.0
resolution: "tailwindcss@npm:4.3.0"
@@ -4988,6 +5403,45 @@ __metadata:
languageName: node
linkType: hard
"three-mesh-bvh@npm:^0.8.3":
version: 0.8.3
resolution: "three-mesh-bvh@npm:0.8.3"
peerDependencies:
three: ">= 0.159.0"
checksum: 10c0/d1f052d4606498acf57e08e28d01c84157eb388d46be8e03bc839630dc4eb919d5bb5e2ed4e9113d39cbc3cda29f09212e3450e1bb21de6615ec330f2575fc28
languageName: node
linkType: hard
"three-stdlib@npm:^2.35.6":
version: 2.36.1
resolution: "three-stdlib@npm:2.36.1"
dependencies:
"@types/draco3d": "npm:^1.4.0"
"@types/offscreencanvas": "npm:^2019.6.4"
"@types/webxr": "npm:^0.5.2"
draco3d: "npm:^1.4.1"
fflate: "npm:^0.6.9"
potpack: "npm:^1.0.1"
peerDependencies:
three: ">=0.128.0"
checksum: 10c0/6d04a3e6e1cf5a23e1d8807085609e5cfa999d342a8f4a3168432c4152e99ab555f7fd7ab22903d9639f33d8d19d2183c62931be34ad6c3ac8bd3ed879d973c2
languageName: node
linkType: hard
"three@npm:^0.170.0":
version: 0.170.0
resolution: "three@npm:0.170.0"
checksum: 10c0/eeaa1eccb31467654a044fb90634b3b6bb8d0768a4f029c082fb6aa22aec0d6362d0e3af74938caa1db512b7823a031b54a76e88fba6cfd535054165ea9667c9
languageName: node
linkType: hard
"three@npm:^0.185.1":
version: 0.185.1
resolution: "three@npm:0.185.1"
checksum: 10c0/d15d3fa934f53c9fd6706965ff9946f55653bc728cf582e68acb03421c0179d7662f7d270e14bcf72923e140c36779e7fba6b0f6e542e07deade7342f63c8bdf
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.15":
version: 0.2.16
resolution: "tinyglobby@npm:0.2.16"
@@ -5007,6 +5461,36 @@ __metadata:
languageName: node
linkType: hard
"troika-three-text@npm:^0.52.4":
version: 0.52.5
resolution: "troika-three-text@npm:0.52.5"
dependencies:
bidi-js: "npm:^1.0.2"
troika-three-utils: "npm:^0.52.5"
troika-worker-utils: "npm:^0.52.0"
webgl-sdf-generator: "npm:1.1.1"
peerDependencies:
three: ">=0.125.0"
checksum: 10c0/0c3dbe6bc0addf43dbbec2a5d2deafe18775d3971df745655b86ed08341940ab6fed6a2e2082779656709081fd5113cc6deb1f4808aa10dc6e913827d2f1ea4a
languageName: node
linkType: hard
"troika-three-utils@npm:^0.52.5":
version: 0.52.5
resolution: "troika-three-utils@npm:0.52.5"
peerDependencies:
three: ">=0.125.0"
checksum: 10c0/97e5bdf78f8633c42f0665b39320c890fa898ff6e9f521a06e3dd1f0b97c235b201e0ab0c3813c9e0a49e2be82f3d14509a02ef1d830b2190d244d2fdc48f0bb
languageName: node
linkType: hard
"troika-worker-utils@npm:^0.52.0":
version: 0.52.0
resolution: "troika-worker-utils@npm:0.52.0"
checksum: 10c0/bcd776324ce3e941c1a913531cee6022c867b71bc2d6bb67f1eadf6df96fd4f865c51b90ac58fc5c0ae3346a4bd8360b0a52398864800a7a9c60473151d452d5
languageName: node
linkType: hard
"ts-api-utils@npm:^2.5.0":
version: 2.5.0
resolution: "ts-api-utils@npm:2.5.0"
@@ -5035,6 +5519,15 @@ __metadata:
languageName: node
linkType: hard
"tunnel-rat@npm:^0.1.2":
version: 0.1.2
resolution: "tunnel-rat@npm:0.1.2"
dependencies:
zustand: "npm:^4.3.2"
checksum: 10c0/93cd50c7c9141e2662707602a21401145092e5a3c815b57a752937419ab6187a2ff36fa7e0f65e0c587022149bf2d323ace07dff61106511b7d4845e53390cc9
languageName: node
linkType: hard
"type-check@npm:^0.4.0, type-check@npm:~0.4.0":
version: 0.4.0
resolution: "type-check@npm:0.4.0"
@@ -5281,7 +5774,7 @@ __metadata:
languageName: node
linkType: hard
"use-sync-external-store@npm:^1.4.0":
"use-sync-external-store@npm:^1.2.2, use-sync-external-store@npm:^1.4.0":
version: 1.6.0
resolution: "use-sync-external-store@npm:1.6.0"
peerDependencies:
@@ -5290,6 +5783,27 @@ __metadata:
languageName: node
linkType: hard
"utility-types@npm:^3.11.0":
version: 3.11.0
resolution: "utility-types@npm:3.11.0"
checksum: 10c0/2f1580137b0c3e6cf5405f37aaa8f5249961a76d26f1ca8efc0ff49a2fc0e0b2db56de8e521a174d075758e0c7eb3e590edec0832eb44478b958f09914920f19
languageName: node
linkType: hard
"webgl-constants@npm:^1.1.1":
version: 1.1.1
resolution: "webgl-constants@npm:1.1.1"
checksum: 10c0/525a9838b0cbcbab902d1bc5457882424a9c146f851042c901ae457123193f5c5e47846b47c1c999e4f3abc24322cac494cea0cc52fec28bd8eb3a05cd79d573
languageName: node
linkType: hard
"webgl-sdf-generator@npm:1.1.1":
version: 1.1.1
resolution: "webgl-sdf-generator@npm:1.1.1"
checksum: 10c0/769bfab22a9485a59bda9448d1d8684228adc9ebdac2cc69012a0854282385d1bef319f703758829e2c647e7532a9844e62977110e825bd75a3e0b8a0ee276ce
languageName: node
linkType: hard
"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1":
version: 1.1.1
resolution: "which-boxed-primitive@npm:1.1.1"
@@ -5392,9 +5906,50 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.25.0 || ^4.0.0":
"zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.4.3":
version: 4.4.3
resolution: "zod@npm:4.4.3"
checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3
languageName: node
linkType: hard
"zustand@npm:^4.3.2":
version: 4.5.7
resolution: "zustand@npm:4.5.7"
dependencies:
use-sync-external-store: "npm:^1.2.2"
peerDependencies:
"@types/react": ">=16.8"
immer: ">=9.0.6"
react: ">=16.8"
peerDependenciesMeta:
"@types/react":
optional: true
immer:
optional: true
react:
optional: true
checksum: 10c0/55559e37a82f0c06cadc61cb08f08314c0fe05d6a93815e41e3376130c13db22a5017cbb0cd1f018c82f2dad0051afe3592561d40f980bd4082e32005e8a950c
languageName: node
linkType: hard
"zustand@npm:^5.0.1, zustand@npm:^5.0.14, zustand@npm:^5.0.3":
version: 5.0.14
resolution: "zustand@npm:5.0.14"
peerDependencies:
"@types/react": ">=18.0.0"
immer: ">=9.0.6"
react: ">=18.0.0"
use-sync-external-store: ">=1.2.0"
peerDependenciesMeta:
"@types/react":
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
checksum: 10c0/89de0c8119f1e0861b2f896a0b4df0d67994a373ecee02628d4e7b97cee1885207f646917d3ede395c3a5083316d48846855cfbc84aced7cd23a6db3238930a8
languageName: node
linkType: hard