feat:update

This commit is contained in:
TuanVT 2026-06-04 15:59:39 +07:00
parent 77dd05932e
commit 7791e1d6a8
19 changed files with 572 additions and 678 deletions

View File

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

View File

@ -8,9 +8,6 @@ export function getDateRange(range: number): {
today.setHours(0, 0, 0, 0);
switch (range) {
case TYPE_DATE.ALL:
return { from: null, to: null };
// Tuần này
case TYPE_DATE.TODAY:
return { from: new Date(), to: new Date() };

View File

@ -11,6 +11,7 @@ import clsx from "clsx";
import { Check, ChevronDown } from "lucide-react";
import Button from "./custom-button";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
import CustomButton from "./custom-button";
interface OptionItem<T> {
uuid: T;
@ -177,15 +178,15 @@ function FilterMany<T extends string | number>({
{/* ACTION */}
<div className="flex gap-2">
<Button
<CustomButton
variant="grey"
rounded="full"
onClick={() => setOpen(false)}
>
Hủy bỏ
</Button>
</CustomButton>
<Button
<CustomButton
variant="midnightBlue"
rounded="full"
onClick={() => {
@ -194,7 +195,7 @@ function FilterMany<T extends string | number>({
}}
>
Xác nhận
</Button>
</CustomButton>
</div>
</div>
)}

View File

@ -55,7 +55,7 @@ const MainPageHome = () => {
const res = await httpRequest<ReportResponse>({
showMessageFailed: true,
http: consultationServices.getConsultationsReport({
FormDate: today,
FromDate: today,
ToDate: today,
}),
});

View File

@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
@ -10,6 +10,7 @@ import { QUERY_KEY, TYPE_STATUS } from "@/constant/config/enum";
import { ArrowLeft, Pencil } from "lucide-react";
import consultationServices, {
AttachmentFile,
ConsultationDetail,
} from "@/services/consultationServices";
@ -20,6 +21,7 @@ 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";
const DetailConsultation = () => {
const params = useParams();
@ -29,6 +31,9 @@ const DetailConsultation = () => {
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],
@ -77,6 +82,17 @@ const DetailConsultation = () => {
})) || []
);
}, [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(() => {
@ -104,6 +120,17 @@ const DetailConsultation = () => {
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;
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row gap-4">
@ -122,7 +149,8 @@ const DetailConsultation = () => {
</h2>
</div>
{consultation.status === TYPE_STATUS.PendingPrescription && canUpdateConsultation && (
{consultation.status === TYPE_STATUS.PendingPrescription &&
canUpdateConsultation && (
<CustomButton
variant="midnightBlue"
fullWidth={false}
@ -283,6 +311,61 @@ const DetailConsultation = () => {
.join(", ") || "---"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
FILE UPLOAD
</p>
{consultation.generalAttachmentUrls?.length > 0 ? (
<ul className="space-y-2">
{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 (
<li key={index}>
{isImage ? (
<a
href={file.path}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
🖼 {file.fileName}
</a>
) : (
<a
href={file.path}
download={file.fileName}
className="text-green-600 hover:underline"
>
📄 {file.fileName}
</a>
)}
</li>
);
})}
</ul>
) : (
<p className="text-gray-500">
Không file nào đưc tải lên.
</p>
)}
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
TRẠNG THÁI
@ -341,6 +424,7 @@ const DetailConsultation = () => {
Danh sách đơn thuốc
</h2>
</div>
<DataWrapper
data={consultation.aggregatedPrescriptionItems}
loading={consulDetailQuery.isLoading}
@ -348,13 +432,20 @@ const DetailConsultation = () => {
note="Vui lòng thử lại sau"
>
<Table
rowKey={(item) => item.id}
data={consultation.aggregatedPrescriptionItems}
// 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>{item.medicineName}</span>
<span className="font-medium text-gray-900">
{item.medicineName}
</span>
),
},
{
@ -378,12 +469,29 @@ const DetailConsultation = () => {
{
title: "Hướng dẫn",
render: (item: PrescriptionItem) => (
<span>{item.instructions || "---"}</span>
<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>
@ -437,6 +545,58 @@ const DetailConsultation = () => {
<p>{selectedSpecialty.procedureNotes || "---"}</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
FILE UPLOAD
</p>
{selectedSpecialty.attachmentUrls?.length > 0 ? (
<ul className="space-y-2">
{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 (
<li key={index}>
{isImage ? (
<a
href={file.path}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
🖼 {file.fileName}
</a>
) : (
<a
href={file.path}
download={file.fileName}
className="text-green-600 hover:underline"
>
📄 {file.fileName}
</a>
)}
</li>
);
})}
</ul>
) : (
<p className="text-gray-500">Không file nào đưc tải lên.</p>
)}
</div>
{selectedSpecialty.glassRequired && (
<>
<div>

View File

@ -7,7 +7,6 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import GridColumn from "@/components/layouts/GridColumn";
import { QUERY_KEY } from "@/constant/config/enum";
@ -16,6 +15,8 @@ import { httpRequest } from "@/services";
import consultationServices, {
ConsultationDetail,
} from "@/services/consultationServices";
import uploadServices from "@/services/uploadServices";
import UploadMultipleFile from "@/components/utils/UploadMultipleFile";
/* =========================
TYPES
@ -28,6 +29,7 @@ interface IUpdateConsul {
treatmentPlan: string;
doctorNotes: string;
isGlassesDelivered: boolean;
generalAttachmentUrls: string[];
}
/* =========================
@ -41,6 +43,7 @@ const defaultForm: IUpdateConsul = {
treatmentPlan: "",
doctorNotes: "",
isGlassesDelivered: false,
generalAttachmentUrls: [],
};
/* =========================
@ -57,6 +60,9 @@ const UpdateConsultation = () => {
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);
/* =========================
@ -86,6 +92,7 @@ const UpdateConsultation = () => {
doctorNotes: null,
createdBy: "",
isDeleted: false,
generalAttachmentUrls: [],
}
);
},
@ -106,18 +113,25 @@ const UpdateConsultation = () => {
treatmentPlan: consul.treatmentPlan || "",
doctorNotes: consul.doctorNotes || "",
isGlassesDelivered: consul.isGlassesDelivered,
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 () => {
if (!id || id === "undefined") {
throw new Error("Không tìm thấy ID phiên khám!");
}
const updateConsultationMutation = useMutation({
mutationFn: async (body: { paths: string[] }) => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
@ -129,31 +143,60 @@ const UpdateConsultation = () => {
diagnosis: form.diagnosis,
treatmentPlan: form.treatmentPlan,
doctorNotes: form.doctorNotes,
isGlassesDelivered: form?.isGlassesDelivered,
isGlassesDelivered: form.isGlassesDelivered,
fileUrls: body.paths,
}),
});
},
onSuccess(data) {
if (!data) return;
// Làm mới danh sách và chi tiết phiên khám trong cache
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.chi_tiet_phien_kham, id],
});
router.back();
// Quay lại trang quản lý danh sách phiên khám
router.push(PATH.CONSULTATION || "/consultation");
router.push(PATH.CONSULTATION);
},
});
const handleSubmit = () => {
updateConsultationMutation.mutate();
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 =
@ -281,6 +324,15 @@ const UpdateConsultation = () => {
}
/>
</GridColumn>
<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"

View File

@ -5,12 +5,14 @@ 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 { useParams, useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
@ -23,6 +25,8 @@ const UpdateSpecialtyClinicId = () => {
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;
@ -31,6 +35,7 @@ const UpdateSpecialtyClinicId = () => {
procedureNotes: string;
glassRequired: boolean;
glassType: string;
fileUrls: string[];
}>({
specialtyAssignmentId: "",
specialtyNotes: "",
@ -38,6 +43,7 @@ const UpdateSpecialtyClinicId = () => {
procedureNotes: "",
glassRequired: false,
glassType: "",
fileUrls: [],
});
/* =========================
@ -79,6 +85,7 @@ const UpdateSpecialtyClinicId = () => {
glassType: null,
examinerName: "",
specialtyStatus: 1,
attachmentUrls: [],
sharedItems: [],
},
],
@ -120,14 +127,23 @@ const UpdateSpecialtyClinicId = () => {
procedureNotes: specialtyClinic.procedureNotes || "",
glassRequired: specialtyClinic.glassRequired || false,
glassType: specialtyClinic.glassType || "",
fileUrls: specialtyClinic.attachmentUrls || [],
});
setImages(
(consul.generalAttachmentUrls || []).map((url) => ({
file: null,
img: url,
path: url,
})),
);
}, [consulDetailQuery.data, specialtyClinicId]);
/* =========================
UPDATE MUTATION
========================= */
const specialtyResultsMutation = useMutation({
mutationFn: async () => {
mutationFn: async (body: { paths: string[] }) => {
if (!consultationId) {
throw new Error("Không tìm thấy ID phiên khám!");
}
@ -145,6 +161,7 @@ const UpdateSpecialtyClinicId = () => {
// Nếu không phải khoa mắt, gán mặc định false và chuỗi rỗng khi gửi API
glassRequired: isEyeClinic ? form.glassRequired : false,
glassType: isEyeClinic ? form.glassType : "",
fileUrls: body.paths,
},
),
});
@ -165,8 +182,39 @@ const UpdateSpecialtyClinicId = () => {
},
});
const handleSubmit = () => {
specialtyResultsMutation.mutate();
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 specialtyResultsMutation.mutate({
paths: [...currentImage, ...uploadedUrls],
});
}
return specialtyResultsMutation.mutate({
paths: currentImage,
});
} catch (error) {
console.error(error);
}
};
const isLoading =
@ -311,6 +359,15 @@ const UpdateSpecialtyClinicId = () => {
</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>
{/* BUTTON ACTION */}
<div className="mt-4 flex items-center justify-end gap-3 border-t pt-5">

View File

@ -17,6 +17,7 @@ import consultationServices, {
} from "@/services/consultationServices";
import { QUERY_KEY } from "@/constant/config/enum";
import { toastWarn } from "@/common/funcs/toast";
import uploadServices from "@/services/uploadServices";
/* =========================
TYPES
@ -197,6 +198,9 @@ const CreatePrescription = () => {
}));
};
/* =========================
SUBMIT VALIDATION
========================= */
/* =========================
SUBMIT VALIDATION
========================= */
@ -225,13 +229,20 @@ const CreatePrescription = () => {
});
}
// 1. Thực hiện gọi API upload ảnh
setLoading(true);
const uploadResponse: any = await prescriptionServices.uploadImage(
file,
folderName,
// 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
@ -245,6 +256,10 @@ const CreatePrescription = () => {
} 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;
@ -252,8 +267,10 @@ const CreatePrescription = () => {
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 ||
"";

View File

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

View File

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

View File

@ -41,14 +41,14 @@ const MainPageStatistical = () => {
const toDate = moment(date?.to).format("YYYY-MM-DD");
console.log({
FormDate: fromDate,
FromDate: fromDate,
ToDate: toDate,
});
const res = await httpRequest<ReportResponse>({
showMessageFailed: true,
http: consultationServices.getConsultationsReport({
FormDate: fromDate,
FromDate: fromDate,
ToDate: toDate,
}),
});

View File

@ -36,7 +36,9 @@ export default function DateOption({
>
{(showOptionAll
? ListOptionFilterDate
: ListOptionFilterDate.filter((item) => item.value !== TYPE_DATE.ALL)
: ListOptionFilterDate.filter(
(item) => item.value !== TYPE_DATE.TODAY,
)
).map((item) => {
const active = item.value === typeDate;

View File

@ -7,6 +7,7 @@ import { ChevronLeft, MoveRight, ChevronRight } from "lucide-react";
import CalendarMain from "../CalendarMain";
import Button from "@/components/customs/custom-button";
import Moment from "react-moment";
import CustomButton from "@/components/customs/custom-button";
const daysOfWeek = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
@ -203,13 +204,13 @@ function RangeDatePicker({
{/* BUTTON */}
<div className="grid grid-cols-2 gap-3 p-3 pt-0">
<Button onClick={onClose}>Hủy bỏ</Button>
<Button
<CustomButton onClick={onClose}>Hủy bỏ</CustomButton>
<CustomButton
onClick={handleSubmit}
disable={!datePicker.from || !datePicker.to}
disabled={!datePicker.from || !datePicker.to}
>
Áp dụng
</Button>
</CustomButton>
</div>
</div>
</ContextCalendar.Provider>

View File

@ -2,69 +2,167 @@
import React from "react";
import Image from "next/image";
import { X, CirclePlus } from "lucide-react";
import {
X,
CirclePlus,
FileText,
FileSpreadsheet,
FileType,
FileImage,
} from "lucide-react";
import clsx from "clsx";
import { PropsUploadMultipleFile } from "./interface";
import { PropsUploadMultipleFile, UploadFileItem } from "./interface";
export default function UploadMultipleFile({
images = [],
setImages,
isDisableDelete = false,
}: PropsUploadMultipleFile) {
// 👉 upload files
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files) return;
/* =========================
CHECK IMAGE FILE
========================= */
const isImageFile = (item: UploadFileItem) => {
const type = item?.fileType || "";
const newImages = Array.from(files).map((file) => ({
url: URL.createObjectURL(file),
file,
}));
setImages((prev: any) => [...prev, ...newImages]);
return (
type.startsWith("image/") ||
/\.(jpg|jpeg|png|gif|webp)$/i.test(item?.path || "")
);
};
// 👉 delete file
const handleDelete = (index: number) => {
setImages((prev: any) => {
const target = prev[index];
if (target?.url) URL.revokeObjectURL(target.url);
/* =========================
GET FILE ICON
========================= */
const renderFileIcon = (item: UploadFileItem) => {
const fileName = item?.fileName || item?.path?.split("/").pop() || "";
return prev.filter((_: any, i: number) => i !== index);
const ext = fileName.split(".").pop()?.toLowerCase();
switch (ext) {
case "jpg":
case "jpeg":
case "png":
case "gif":
case "webp":
return <FileImage size={28} />;
case "pdf":
return <FileText size={28} />;
case "xls":
case "xlsx":
return <FileSpreadsheet size={28} />;
case "doc":
case "docx":
return <FileType size={28} />;
case "ppt":
case "pptx":
return <FileType size={28} />;
case "txt":
return <FileText size={28} />;
default:
return <FileText size={28} />;
}
};
/* =========================
UPLOAD FILES
========================= */
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files) return;
const newFiles: UploadFileItem[] = Array.from(files).map((file) => ({
file,
url: URL.createObjectURL(file),
fileName: file.name,
fileType: file.type,
}));
setImages((prev) => [...prev, ...newFiles]);
};
/* =========================
DELETE FILE
========================= */
const handleDelete = (index: number) => {
setImages((prev) => {
const target = prev[index];
if (target?.url) {
URL.revokeObjectURL(target.url);
}
return prev.filter((_, i) => i !== index);
});
};
return (
<div className="flex items-center flex-wrap gap-2">
{/* LIST IMAGE */}
<div className="flex flex-wrap items-center gap-2">
{/* FILE LIST */}
{images?.length > 0 && (
<div className="flex items-center flex-wrap gap-2">
{images.map((image, index) => (
<div className="flex flex-wrap items-center gap-2">
{images.map((item, index) => {
const fileUrl =
item?.url ||
(item?.path
? `${process.env.NEXT_PUBLIC_IMAGE}/${item.path}`
: "");
return (
<div
key={index}
className="relative w-[72px] h-[72px] rounded-md border border-[#99a2b34d] overflow-hidden select-none"
className="relative h-[72px] w-[72px] overflow-hidden rounded-md border border-[#99a2b34d]"
>
{/* IMAGE */}
{isImageFile(item) ? (
<a
href={fileUrl}
target="_blank"
rel="noreferrer"
className="block h-full w-full"
>
<Image
src={
image?.url ||
`${process.env.NEXT_PUBLIC_IMAGE}/${image?.path}`
}
alt="image"
src={fileUrl}
alt="file"
fill
className="object-cover"
/>
</a>
) : (
<a
href={fileUrl}
target="_blank"
rel="noreferrer"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-gray-50 p-1"
>
{renderFileIcon(item)}
{/* DELETE BUTTON */}
{isDisableDelete && !image?.file && image?.img ? null : (
<div
<span className="line-clamp-2 text-center text-[9px]">
{item?.fileName || item?.path?.split("/").pop()}
</span>
</a>
)}
{/* DELETE */}
{isDisableDelete && !item?.file && item?.img ? null : (
<button
type="button"
onClick={() => handleDelete(index)}
className="absolute top-[2px] right-[2px] w-[18px] h-[18px] bg-white rounded-sm flex items-center justify-center cursor-pointer transition active:scale-90"
className="absolute right-[2px] top-[2px] flex h-[18px] w-[18px] items-center justify-center rounded-sm bg-white transition active:scale-90"
>
<X size={14} color="#8496AC" />
</div>
</button>
)}
</div>
))}
);
})}
</div>
)}
@ -72,18 +170,27 @@ export default function UploadMultipleFile({
<div className="flex items-center gap-2">
<label
className={clsx(
"flex items-center justify-center",
"w-[72px] h-[72px] rounded-md border cursor-pointer bg-white",
"border-[#99a2b34d] hover:border-gray-400 transition",
"flex h-[72px] w-[72px] cursor-pointer items-center justify-center rounded-md border bg-white",
"border-[#99a2b34d] transition hover:border-gray-400",
)}
>
<CirclePlus color="rgba(198, 201, 206, 1)" />
<input
hidden
type="file"
multiple
accept="image/png, image/jpeg, image/gif"
type="file"
accept="
image/*,
.pdf,
.doc,
.docx,
.xls,
.xlsx,
.ppt,
.pptx,
.txt
"
onClick={(e) => {
(e.target as HTMLInputElement).value = "";
}}
@ -91,11 +198,11 @@ export default function UploadMultipleFile({
/>
</label>
{/* NOTE */}
<div className="flex flex-col">
<p className="text-[14px] font-medium text-[#2f3643]">Upload file</p>
<p className="text-[12px] font-medium text-[#99a2b3]">
File không vượt quá 50MB
JPG, PNG, PDF, Word, Excel, PPT ( 50MB)
</p>
</div>
</div>

View File

@ -1,5 +1,15 @@
export interface UploadFileItem {
file?: File | null;
url?: string;
path?: string;
img?: string;
fileName?: string;
fileType?: string;
}
export interface PropsUploadMultipleFile {
images: any[];
setImages: (any: any) => void;
images: UploadFileItem[];
setImages: React.Dispatch<React.SetStateAction<UploadFileItem[]>>;
isDisableDelete?: boolean;
}

View File

@ -74,10 +74,6 @@ export const ListOptionFilterDate: {
name: string;
value: number;
}[] = [
{
name: "Tất cả",
value: TYPE_DATE.ALL,
},
{
name: "Hôm nay",
value: TYPE_DATE.TODAY,

View File

@ -56,6 +56,11 @@ export interface ConsultationItem {
}[];
}
export interface AttachmentFile {
fileName: string;
path: string;
}
export interface ConsultationDetail {
id: string;
patientId: string;
@ -72,6 +77,7 @@ export interface ConsultationDetail {
isGlassesDelivered: boolean;
createdBy: string;
isDeleted: boolean;
generalAttachmentUrls: string[];
specialtyClinics: {
id: string;
specialtyId: string;
@ -83,6 +89,7 @@ export interface ConsultationDetail {
glassType: string | null;
examinerName: string;
specialtyStatus: string | number | undefined;
attachmentUrls: string[];
sharedItems: {
id: string;
prescriptionId: string;
@ -136,7 +143,7 @@ const consultationServices = {
);
},
getConsultationsReport: (
params: { FormDate: string; ToDate: string },
params: { FromDate: string; ToDate: string },
tokenAxios?: any,
) => {
return axiosClient.get<ReportResponse>(
@ -181,6 +188,7 @@ const consultationServices = {
treatmentPlan: string | null;
doctorNotes: string | null;
isGlassesDelivered: boolean;
fileUrls: string[];
},
tokenAxios?: any,
) => {
@ -209,6 +217,7 @@ const consultationServices = {
procedureNotes: string;
glassRequired: boolean;
glassType: string;
fileUrls: string[];
},
tokenAxios?: any,
) => {

View File

@ -112,23 +112,6 @@ const prescriptionServices = {
/**
* UPLOAD IMAGE
*/
uploadImage: (file: File, folderName?: string) => {
const formData = new FormData();
// Key phải viết hoa chữ cái đầu đúng như Swagger yêu cầu (File và FolderName)
formData.append("File", file);
if (folderName) {
formData.append("FolderName", folderName);
}
return axiosClient.post("/api/Media/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
},
};
export default prescriptionServices;

View File

@ -0,0 +1,50 @@
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;