feate:update

This commit is contained in:
TuanVT 2026-05-25 17:42:19 +07:00
parent b0baa509d9
commit 2978cf2db0
9 changed files with 416 additions and 49 deletions

View File

@ -16,8 +16,6 @@ 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";
@ -31,6 +29,7 @@ import { QUERY_KEY } from "@/constant/config/enum";
import { toastWarn } from "@/common/funcs/toast";
import { PATH } from "@/constant/config";
import { useRouter } from "next/navigation";
import UploadImage from "@/components/utils/UploadImage";
interface PrescriptionItem {
medicineName: string;
@ -43,7 +42,7 @@ interface PrescriptionItem {
interface PrescriptionForm {
examinationSessionId: string;
notes: string;
prescriptionImg: string;
PrescriptionFile: string;
items: PrescriptionItem[];
}
@ -62,14 +61,30 @@ const defaultMedicine: PrescriptionItem = {
instructions: "",
};
const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = (error) => {
reject(error);
};
});
};
const CreatePrescription = () => {
const router = useRouter();
const [images, setImages] = useState<UploadImageItem[]>([]);
const [file, setFile] = useState<any>(null);
const [form, setForm] = useState<PrescriptionForm>({
examinationSessionId: "",
notes: "",
prescriptionImg: "",
PrescriptionFile: "",
items: [defaultMedicine],
});
@ -106,7 +121,7 @@ const CreatePrescription = () => {
const examinationSessionOptions = examinationSessionQuery.data?.items || [];
const createPrescriptionMutation = useMutation({
mutationFn: async () => {
mutationFn: async (body: { PrescriptionFile: string }) => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
@ -114,9 +129,20 @@ const CreatePrescription = () => {
http: prescriptionServices.createPrescription({
examinationSessionId: form.examinationSessionId,
notes: form.notes,
prescriptionImg: form.prescriptionImg,
items: form.items,
PrescriptionFile: body.PrescriptionFile,
items: form.items
.filter((x) => x.medicineName.trim() !== "")
.map((x) => ({
medicineName: x.medicineName,
dosage: x.dosage,
frequency: x.frequency,
duration: x.duration,
instructions: x.instructions,
})),
}),
});
},
@ -193,11 +219,61 @@ const CreatePrescription = () => {
}));
};
const handleSubmit = async () => {
try {
/**
* SUBMIT
* VALIDATE
*/
const handleSubmit = () => {
createPrescriptionMutation.mutate();
if (!form.examinationSessionId) {
return toastWarn({
msg: "Vui lòng chọn phiên khám",
});
}
if (!file) {
return toastWarn({
msg: "Vui lòng chọn ảnh đơn thuốc",
});
}
/**
* UPLOAD IMAGE
*/
let uploadedImage = "";
if (file) {
const formData = new FormData();
formData.append("file", file);
/**
* API UPLOAD
* SỬA uploadServices theo project của bạn
*/
const uploadRes: any = await httpRequest({
showMessageFailed: true,
http: prescriptionServices.uploadImage(formData),
});
/**
* BACKEND RESPONSE
*/
uploadedImage =
uploadRes?.url || uploadRes?.path || uploadRes?.data?.url || "";
}
/**
* CREATE PRESCRIPTION
*/
createPrescriptionMutation.mutate({
PrescriptionFile: uploadedImage,
});
} catch (error: any) {
toastWarn({
msg: error?.message || "Upload ảnh thất bại",
});
}
};
return (
@ -207,7 +283,7 @@ const CreatePrescription = () => {
<div className="flex items-center justify-between">
<div>
<h2 className="text-[28px] font-semibold text-[#111827]">
Thêm đơn thuốc
Tạo đơn thuốc
</h2>
<p className="mt-1 text-sm text-[#697586]">
@ -221,8 +297,8 @@ const CreatePrescription = () => {
disabled={createPrescriptionMutation.isPending}
>
{createPrescriptionMutation.isPending
? "Đang lưu..."
: "Lưu đơn thuốc"}
? "Đang tạo..."
: "Tạo đơn thuốc"}
</CustomButton>
</div>
</div>
@ -265,22 +341,16 @@ const CreatePrescription = () => {
{/* IMAGE */}
<div className="flex flex-col gap-2">
<UploadMultipleFile
images={images}
setImages={(files: UploadImageItem[]) => {
setImages(files);
const imagePath =
files?.[0]?.path ||
files?.[0]?.url ||
files?.[0]?.img ||
"";
setForm((prev) => ({
...prev,
prescriptionImg: imagePath,
}));
}}
<UploadImage
label={
<span>
Hình nh đơn thuốc <span style={{ color: "red" }}>*</span>
</span>
}
name="PrescriptionFile"
file={file}
setFile={setFile}
path={""}
/>
</div>
@ -292,6 +362,12 @@ const CreatePrescription = () => {
value={form.notes}
isBlur
max={5000}
onChangeValue={(value) =>
setForm((prev) => ({
...prev,
notes: String(value),
}))
}
/>
</div>
</div>

View File

@ -142,6 +142,8 @@ const MainPrescription = () => {
<Image
src={item.prescriptionImg}
alt="prescription"
width={40}
height={40}
className="h-10 w-10 rounded object-cover"
/>
) : (

View File

@ -33,6 +33,7 @@ import type { PrescriptionDetail } from "@/services/prescriptionServices";
========================= */
interface PrescriptionItem {
id: string;
medicineName: string;
dosage: string;
frequency: string;
@ -58,6 +59,7 @@ interface UploadImageItem {
========================= */
const defaultMedicine: PrescriptionItem = {
id: "",
medicineName: "",
dosage: "",
frequency: "",
@ -171,6 +173,7 @@ const UpdatePrescription = () => {
items:
data.items && data.items.length > 0
? data.items.map((i) => ({
id: i.id,
medicineName: i.medicineName ?? "",
dosage: i.dosage ?? "",
frequency: i.frequency ?? "",
@ -224,19 +227,32 @@ const UpdatePrescription = () => {
throw new Error("Không tìm thấy ID đơn thuốc!");
}
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Cập nhật đơn thuốc thành công!",
http: prescriptionServices.putPrescription(id, {
// examinationSessionId: form.examinationSessionId,
const payload = {
examinationSessionId: form.examinationSessionId,
notes: form.notes,
prescriptionImg: form.prescriptionImg,
items: form.items,
}),
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),
});
},
@ -245,8 +261,10 @@ const UpdatePrescription = () => {
},
onError: (error: any) => {
console.log("UPDATE ERROR:", error);
const message =
error?.message || error?.error?.message || "Có lỗi xảy ra";
error?.response?.data?.message || error?.message || "Có lỗi xảy ra";
toastWarn({
msg: message,
@ -283,6 +301,7 @@ const UpdatePrescription = () => {
items: [
...prev.items,
{
id: "",
medicineName: "",
dosage: "",
frequency: "",

View File

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

View File

@ -0,0 +1,243 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import Image from "next/image";
import clsx from "clsx";
import { UploadCloud, X } from "lucide-react";
import { toastError, toastWarn } from "@/common/funcs/toast";
import { UploadImageProps } from "./interface";
const MAXIMUM_FILE = 10; // MB
const ACCEPT_TYPES = ["image/jpeg", "image/jpg", "image/png"];
/* =========================
COMPONENT
========================= */
const UploadImage = ({
label,
name,
path,
file,
setFile,
resetPath,
isWidthFull = true,
disabled = false,
}: UploadImageProps) => {
const [dragging, setDragging] = useState(false);
/* =========================
PREVIEW IMAGE
========================= */
const imagePreview = useMemo(() => {
if (!file) return "";
return URL.createObjectURL(file);
}, [file]);
/* =========================
CLEANUP OBJECT URL
========================= */
useEffect(() => {
return () => {
if (imagePreview) {
URL.revokeObjectURL(imagePreview);
}
};
}, [imagePreview]);
/* =========================
VALIDATE FILE
========================= */
const validateFile = (selectedFile: File | null | undefined) => {
if (!selectedFile) return;
const { size, type } = selectedFile;
/**
* CHECK SIZE
*/
if (size / 1000000 > MAXIMUM_FILE) {
return toastError({
msg: `Kích thước tối đa của ảnh là ${MAXIMUM_FILE} MB`,
});
}
/**
* CHECK TYPE
*/
if (!ACCEPT_TYPES.includes(type)) {
return toastWarn({
msg: "Định dạng không hợp lệ. Chỉ chấp nhận JPG, JPEG, PNG",
});
}
/**
* SET FILE
*/
setFile(selectedFile);
};
/* =========================
DRAG EVENTS
========================= */
const handleDragEnter = (e: React.DragEvent<HTMLLabelElement>): void => {
e.preventDefault();
if (disabled) return;
setDragging(true);
};
const handleDragLeave = (): void => {
setDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>): void => {
e.preventDefault();
if (disabled) return;
setDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
validateFile(droppedFile);
};
/* =========================
SELECT FILE
========================= */
const handleSelectImage = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (disabled) return;
const selectedFile = e.target.files?.[0];
validateFile(selectedFile);
};
/* =========================
REMOVE IMAGE
========================= */
const handleRemoveImage = (): void => {
setFile(null);
resetPath?.();
};
/* =========================
IMAGE SOURCE
========================= */
const imageSrc = imagePreview || path || "";
/* =========================
UI
========================= */
return (
<div className="w-full">
{/* LABEL */}
{label && (
<div className="mb-2">
{typeof label === "string" ? (
<p className="text-sm font-medium text-gray-700">{label}</p>
) : (
label
)}
</div>
)}
{/* PREVIEW */}
{imageSrc ? (
<div
className={clsx(
"relative overflow-hidden rounded-2xl border border-dashed border-blue-300 bg-blue-50/20",
"h-[240px]",
isWidthFull ? "w-full" : "w-[600px]",
)}
>
<Image
src={imageSrc}
alt="Preview image"
fill
className="object-cover"
sizes="100vw"
/>
{/* REMOVE BUTTON */}
{!disabled && (
<button
type="button"
onClick={handleRemoveImage}
className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow-md transition hover:bg-gray-100 active:scale-95"
>
<X size={18} className="text-gray-700" />
</button>
)}
</div>
) : (
<label
htmlFor={`upload-image-${name}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
className={clsx(
"flex h-[240px] flex-col items-center justify-center gap-4 rounded-2xl border border-dashed transition",
isWidthFull ? "w-full" : "w-[600px]",
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
dragging
? "border-blue-500 bg-white"
: "border-blue-300 bg-blue-50/20",
)}
>
{/* ICON */}
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
<UploadCloud className="h-8 w-8 text-blue-500" />
</div>
{/* TEXT */}
<div className="space-y-1 text-center">
<p className="text-sm font-medium text-gray-800">
Kéo & thả nh vào đây
</p>
<p className="text-xs text-gray-500">
Hỗ trợ JPG, JPEG, PNG (Tối đa {MAXIMUM_FILE}MB)
</p>
</div>
{/* INPUT */}
<input
hidden
id={`upload-image-${name}`}
type="file"
accept="image/png,image/jpeg,image/jpg"
disabled={disabled}
onChange={handleSelectImage}
onClick={(e) => {
(e.target as HTMLInputElement).value = "";
}}
/>
</label>
)}
</div>
);
};
export default UploadImage;

View File

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

View File

@ -0,0 +1,17 @@
export interface UploadImageProps {
isWidthFull?: boolean;
label?: string | React.ReactNode;
name: string;
file: File | null;
setFile: (file: File | null) => void;
path?: string;
resetPath?: () => void;
disabled?: boolean;
}

View File

@ -45,12 +45,12 @@ export const Menus: {
path: PATH.CONSULTATION,
pathActive: PATH.CONSULTATION,
},
{
title: "Đơn thuốc",
icon: Pill,
path: PATH.PRESCRIPTION,
pathActive: PATH.PRESCRIPTION,
},
// {
// title: "Đơn thuốc",
// icon: Pill,
// path: PATH.PRESCRIPTION,
// pathActive: PATH.PRESCRIPTION,
// },
],
},
];

View File

@ -57,7 +57,7 @@ const prescriptionServices = {
data: {
examinationSessionId: string;
notes: string;
prescriptionImg: string;
PrescriptionFile: string;
items: {
medicineName: string;
dosage: string;
@ -72,6 +72,7 @@ const prescriptionServices = {
cancelToken: tokenAxios,
});
},
getPrescription: (params?: GetPrescriptionParams, tokenAxios?: any) => {
return axiosClient.get<PrescriptionResponse>(`/Prescription`, {
params,
@ -104,6 +105,14 @@ const prescriptionServices = {
cancelToken: tokenAxios,
});
},
uploadImage: (body: FormData) => {
return axiosClient.post("/upload", body, {
headers: {
"Content-Type": "multipart/form-data",
},
});
},
};
export default prescriptionServices;