update-full

This commit is contained in:
TuanVT 2026-06-06 01:02:07 +07:00
parent 7791e1d6a8
commit d4194d6f12
30 changed files with 1019 additions and 392 deletions

View File

@ -5,12 +5,19 @@ const nextConfig: NextConfig = {
root: process.cwd(), root: process.cwd(),
}, },
images: { images: {
unoptimized: true,
remotePatterns: [ remotePatterns: [
{ {
protocol: "http", protocol: "http",
hostname: "127.0.0.1", hostname: "127.0.0.1",
port: "5264", port: "5264",
pathname: "/uploads/**", pathname: "/**",
},
{
protocol: "http",
hostname: "localhost",
port: "5264",
pathname: "/**",
}, },
], ],
}, },

14
src/app/profile/page.tsx Normal file
View File

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

View File

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

View File

@ -1,16 +1,21 @@
"use client"; "use client";
import React from "react";
import Link from "next/link"; import Link from "next/link";
import clsx from "clsx"; import clsx from "clsx";
export interface PropsButton { export interface PropsButton {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children?: React.ReactNode; children?: React.ReactNode;
href?: string;
icon?: React.ReactNode; icon?: React.ReactNode;
className?: string;
href?: string;
target?: string; target?: string;
onClick?: (
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => void;
className?: string;
disabled?: boolean; disabled?: boolean;
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
@ -26,17 +31,22 @@ export interface PropsButton {
| "warning"; | "warning";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
rounded?: "sm" | "md" | "lg" | "full";
rounded?: "sm" | "md" | "lg" | "xl" | "full";
fullWidth?: boolean; fullWidth?: boolean;
maxContent?: boolean;
maxHeight?: boolean;
} }
export default function CustomButton({ export default function CustomButton({
children, children,
onClick,
href,
icon, icon,
className, href,
target, target,
onClick,
className,
disabled = false, disabled = false,
type = "button", type = "button",
@ -44,10 +54,12 @@ export default function CustomButton({
variant = "default", variant = "default",
size = "md", size = "md",
rounded = "md", rounded = "md",
fullWidth = true, fullWidth = true,
maxContent = false,
maxHeight = false,
}: PropsButton) { }: PropsButton) {
// 🎨 Variant const variantClasses = {
const variantClasses: Record<string, string> = {
default: "bg-gray-100 text-gray-700 border border-gray-200 cursor-pointer", default: "bg-gray-100 text-gray-700 border border-gray-200 cursor-pointer",
midnightBlue: midnightBlue:
"bg-blue-900 text-white border border-blue-900 cursor-pointer", "bg-blue-900 text-white border border-blue-900 cursor-pointer",
@ -60,67 +72,85 @@ export default function CustomButton({
warning: "bg-orange-400 text-white border border-orange-400 cursor-pointer", warning: "bg-orange-400 text-white border border-orange-400 cursor-pointer",
}; };
// 📏 Size
const sizeClasses = { const sizeClasses = {
sm: "px-3 py-1 text-sm", sm: "px-6 py-2 text-[13px]",
md: "px-5 py-2 text-sm", md: "px-6 py-3 text-sm",
lg: "px-6 py-3 text-base", lg: "px-8 py-3 text-base",
}; };
// 🔵 Rounded
const roundedClasses = { const roundedClasses = {
sm: "rounded-md", sm: "rounded-sm",
md: "rounded-xl", md: "rounded-md",
lg: "rounded-2xl", lg: "rounded-lg",
xl: "rounded-xl",
full: "rounded-full", full: "rounded-full",
}; };
const baseClass = clsx( const baseClass = clsx(
"inline-flex items-center justify-center gap-2", "inline-flex items-center justify-center gap-2",
"transition-all duration-200 select-none", "font-medium whitespace-nowrap",
"active:scale-[0.98] hover:opacity-90", "select-none",
"transition-all duration-200",
"hover:opacity-80",
"active:scale-95",
variantClasses[variant], variantClasses[variant],
sizeClasses[size], sizeClasses[size],
roundedClasses[rounded], roundedClasses[rounded],
fullWidth ? "w-full" : "w-fit",
disabled && "opacity-40 pointer-events-none cursor-not-allowed", fullWidth && "w-full",
maxContent && "w-max",
maxHeight && "h-full",
disabled &&
"opacity-30 cursor-not-allowed pointer-events-none active:scale-100",
className, className,
); );
const content = ( const content = (
<> <>
{icon && <span className="flex items-center">{icon}</span>} {icon && (
<span>{children}</span> <span className="flex items-center justify-center shrink-0">
{icon}
</span>
)}
<span className="flex items-center justify-center">{children}</span>
</> </>
); );
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { const handleClick = (
if (disabled) return; e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
if (disabled) {
e.preventDefault();
return;
}
onClick?.(e); onClick?.(e);
}; };
// 🔗 Link mode
if (href) { if (href) {
return ( return (
<Link <Link
href={href} href={href}
target={target} target={target}
className={baseClass} className={baseClass}
onClick={handleClick}
aria-disabled={disabled} aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
> >
{content} {content}
</Link> </Link>
); );
} }
// 🔘 Button mode
return ( return (
<button <button
type={type} type={type}
disabled={disabled}
onClick={handleClick} onClick={handleClick}
className={baseClass} className={baseClass}
disabled={disabled}
> >
{content} {content}
</button> </button>

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import React, { useContext, useEffect, useState } from "react";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { PropsHeader } from "./interface"; import { PropsHeader } from "./interface";
import { usePathname } from "next/dist/client/components/navigation"; import { usePathname } from "next/dist/client/components/navigation";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@ -10,6 +11,10 @@ import icons from "@/constant/images/icons";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import MenuProfile from "../MenuProfile"; import MenuProfile from "../MenuProfile";
import { useQuery } from "@tanstack/react-query";
import profileServices from "@/services/profileServices";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
const Header = ({ title, breadcrumb }: PropsHeader) => { const Header = ({ title, breadcrumb }: PropsHeader) => {
const pathname = usePathname(); const pathname = usePathname();
@ -33,8 +38,44 @@ 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
});
// Trả về đúng object chứa items theo cấu trúc của API
return ( return (
<div className="h-full w-full px-6 flex items-center justify-between bg-white relatives"> 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="h-full w-full px-6 flex items-center justify-between bg-white relative">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Nút bấm Mobile: Mở menu mobile */} {/* Nút bấm Mobile: Mở menu mobile */}
<div <div
@ -62,24 +103,26 @@ const Header = ({ title, breadcrumb }: PropsHeader) => {
<Image src={icons.full_screen} alt="" width={24} height={24} /> <Image src={icons.full_screen} alt="" width={24} height={24} />
</div> </div>
{/* PROFILE CONTROL AREA */}
<div <div
className="relative flex items-center gap-2 cursor-pointer" className="relative flex items-center gap-2 cursor-pointer"
onClick={() => setOpenProfile(!openProfile)} onClick={() => setOpenProfile(!openProfile)}
> >
<div className="w-10 h-10 rounded-full border-2 border-blue-500 overflow-hidden"> <div className="w-10 h-10 rounded-full border-2 border-blue-500 overflow-hidden relative">
<Image <Image
src={ src={
infoUser?.avatar currentUserProfile?.avatarUrl
? `${process.env.NEXT_PUBLIC_IMAGE}/${infoUser.avatar}` ? `${process.env.NEXT_PUBLIC_IMAGE}${currentUserProfile.avatarUrl}`
: icons.avatar : icons.avatar
} }
alt="avatar" alt="avatar"
width={40} width={40}
height={40} height={40}
className="object-cover h-full w-full"
/> />
</div> </div>
<p className="hidden md:block text-sm font-semibold text-[#171832]"> <p className="hidden md:block text-sm font-semibold text-[#171832]">
{infoUser?.fullname || "User admin"} {currentUserProfile?.fullName || "User admin"}
</p> </p>
<ChevronDown <ChevronDown
size={16} size={16}

View File

@ -67,25 +67,6 @@ const MenuProfile = ({ onClose }: PropsMenuProfile) => {
</Link> </Link>
<div className="h-px bg-gray-200 my-1" /> <div className="h-px bg-gray-200 my-1" />
{/* CHANGE PASSWORD */}
{/* <Link
href={`${PATH.PROFILE}?_action=change-password`}
onClick={onClose}
className={clsx(
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
pathname === PATH.PROFILE &&
searchParams.get("_action") === "change-password" &&
"bg-gray-100",
)}
>
<ShieldCog size={20} />
<div>
<p className="text-sm font-medium">Đi mật khẩu</p>
<p className="text-xs text-gray-500">Thay đi mật khẩu</p>
</div>
</Link>
<div className="h-px bg-gray-200 my-1" /> */}
{/* LOGOUT */} {/* LOGOUT */}
<div <div
onClick={() => { onClick={() => {

View File

@ -1,97 +1,94 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { LockKeyhole, ShieldPlus, User } from "lucide-react"; import { ShieldPlus, User } from "lucide-react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { RootState, store } from "@/redux/store"; import { RootState, store } from "@/redux/store";
import { import {
setDataLoginStorage, setDataLoginStorage,
setStateLogin, setStateLogin,
setToken, setToken,
} from "@/redux/reducer/auth"; } from "@/redux/reducer/auth";
import { setRememberPassword } from "@/redux/reducer/site"; import { setRememberPassword } from "@/redux/reducer/site";
import { setInfoUser } from "@/redux/reducer/user"; import { setInfoUser } from "@/redux/reducer/user";
import authServices, { LoginResponse } from "@/services/authServices";
import authServices from "@/services/authServices";
import { httpRequest } from "@/services"; import { httpRequest } from "@/services";
import { PATH } from "@/constant/config"; import { PATH } from "@/constant/config";
import FormCustom from "@/components/utils/FormCustom"; import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm"; import InputForm from "@/components/utils/FormCustom/components/InputForm";
import CustomLoading from "@/components/customs/custom-loading"; import CustomLoading from "@/components/customs/custom-loading";
import CustomButton from "@/components/customs/custom-button"; import CustomButton from "@/components/customs/custom-button";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
export default function MainLogin() { export default function MainLogin() {
const router = useRouter(); const router = useRouter();
const { dataLoginStorage } = useSelector((state: RootState) => state.auth); const { dataLoginStorage } = useSelector((state: RootState) => state.auth);
const { isRememberPassword } = useSelector((state: RootState) => state.site); const { isRememberPassword } = useSelector((state: RootState) => state.site);
const [form, setForm] = useState({ // Khởi tạo state form từ data lưu trữ
const [form, setForm] = useState(() => ({
username: isRememberPassword ? dataLoginStorage?.usernameStorage || "" : "", username: isRememberPassword ? dataLoginStorage?.usernameStorage || "" : "",
password: isRememberPassword ? dataLoginStorage?.passwordStorage || "" : "", password: isRememberPassword ? dataLoginStorage?.passwordStorage || "" : "",
}));
deviceId: isRememberPassword ? dataLoginStorage?.deviceIdStorage || "" : "", const loginMutation = useMutation<LoginResponse>({
});
const loginMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
return httpRequest({ return (await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Đăng nhập thành công!",
http: authServices.login({ http: authServices.login({
username: form.username, username: form.username,
password: form.password, password: form.password,
deviceId: form.username, deviceId: "Browser-Chrome-Windowns",
}), }),
}); })) as LoginResponse;
}, },
onSuccess(data) {
onSuccess(data: any) { if (data) {
if (!data) return; store.dispatch(setToken(data?.token));
store.dispatch(setStateLogin(true));
store.dispatch(setToken(data.token));
store.dispatch( store.dispatch(
setInfoUser({ setInfoUser({
accessToken: data?.token || "", accessToken: data?.token || "",
refreshToken: data?.refreshToken || "", refreshToken: data?.refreshToken || "",
accessExpiresAt: data?.accessExpiresAt || "", userId: data?.userId || "",
refreshExpiresAt: data?.refreshExpiresAt || "",
avatar: data?.avatar || "",
fullname: data?.fullname || "",
}), }),
); );
store.dispatch(setStateLogin(true));
if (isRememberPassword) { if (isRememberPassword) {
store.dispatch( store.dispatch(
setDataLoginStorage({ setDataLoginStorage({
usernameStorage: form.username, usernameStorage: form.username,
passwordStorage: form.password, passwordStorage: form.password,
deviceIdStorage: form.deviceId,
}), }),
); );
} else { } else {
store.dispatch(setDataLoginStorage(null)); store.dispatch(setDataLoginStorage(null));
} }
router.replace(PATH.HOME); router.replace(PATH.HOME, undefined);
}
}, },
}); });
const handleLogin = () => { const handleLogin = () => {
// Kiểm tra nhanh điều kiện dữ liệu trước khi trigger API
if (!form.username || !form.password) return;
if (isRememberPassword) {
store.dispatch(
setDataLoginStorage({
usernameStorage: form.username,
passwordStorage: form.password,
}),
);
} else {
store.dispatch(setDataLoginStorage(null));
}
loginMutation.mutate(); loginMutation.mutate();
}; };
@ -101,24 +98,8 @@ export default function MainLogin() {
<FormCustom form={form} setForm={setForm} onSubmit={handleLogin}> <FormCustom form={form} setForm={setForm} onSubmit={handleLogin}>
{/* TITLE */} {/* TITLE */}
<h3 <h3 className="text-[34px] font-semibold text-[#1A1B2D]">Đăng nhập</h3>
className=" <p className="mt-1 text-[14px] font-medium text-[#6F767E]">
text-[34px]
font-semibold
text-[#1A1B2D]
"
>
Đăng nhập
</h3>
<p
className="
mt-1
text-[14px]
font-medium
text-[#6F767E]
"
>
Chào mừng bạn đến với hệ thống quản Chào mừng bạn đến với hệ thống quản
</p> </p>
@ -128,8 +109,7 @@ export default function MainLogin() {
<InputForm <InputForm
label={ label={
<span> <span>
Tài khoản Tài khoản<span className="text-red-500"> *</span>
<span className="text-red-500"> *</span>
</span> </span>
} }
placeholder="Tài khoản" placeholder="Tài khoản"
@ -142,32 +122,12 @@ export default function MainLogin() {
icon={<User size={22} />} icon={<User size={22} />}
/> />
{/* <div className="mt-5">
<InputForm
label={
<span>
deviceId
<span className="text-red-500"> *</span>
</span>
}
placeholder="deviceId"
type="text"
name="deviceId"
onClean
isRequired
isBlur
showDone
icon={<ShieldPlus size={22} />}
/>
</div> */}
{/* PASSWORD */} {/* PASSWORD */}
<div className="mt-5"> <div className="mt-5">
<InputForm <InputForm
label={ label={
<span> <span>
Mật khẩu Mật khẩu<span className="text-red-500"> *</span>
<span className="text-red-500"> *</span>
</span> </span>
} }
placeholder="Mật khẩu" placeholder="Mật khẩu"
@ -182,15 +142,7 @@ export default function MainLogin() {
</div> </div>
{/* REMEMBER PASSWORD */} {/* REMEMBER PASSWORD */}
<div <div className="mt-[14px] flex items-center gap-2 cursor-pointer">
className="
mt-[14px]
flex
items-center
gap-2
cursor-pointer
"
>
<input <input
id="rememberPassword" id="rememberPassword"
type="checkbox" type="checkbox"
@ -198,23 +150,11 @@ export default function MainLogin() {
onChange={() => onChange={() =>
store.dispatch(setRememberPassword(!isRememberPassword)) store.dispatch(setRememberPassword(!isRememberPassword))
} }
className=" className="h-5 w-5 cursor-pointer accent-[#0011AB]"
h-5
w-5
cursor-pointer
accent-[#0011AB]
"
/> />
<label <label
htmlFor="rememberPassword" htmlFor="rememberPassword"
className=" className="cursor-pointer select-none text-[14px] font-medium text-[#171717]"
cursor-pointer
select-none
text-[14px]
font-medium
text-[#171717]
"
> >
Nhớ mật khẩu Nhớ mật khẩu
</label> </label>
@ -223,64 +163,40 @@ export default function MainLogin() {
{/* BUTTONS */} {/* BUTTONS */}
<div className="mt-6"> <div className="mt-6">
<div className="flex gap-2"> <div className="flex gap-2">
{/* LOGIN */} {/* LOGIN BUTTON */}
<ContextFormCustom.Consumer>
{({ isDone }) => {
// Tự kiểm tra xem form hiện tại đã có sẵn dữ liệu hợp lệ chưa
const hasRememberedData = !!(form.username && form.password);
// Sáng nút nếu FormCustom báo Done HOẶC form đã có sẵn dữ liệu từ Redux
const canSubmit = isDone || hasRememberedData;
return (
<CustomButton <CustomButton
variant="midnightBlue" variant="midnightBlue"
disabled={loginMutation.isPending} disabled={!canSubmit}
rounded="full" rounded="full"
type="submit" type="button" // Chuyển từ "submit" sang "button" để tránh bị FormCustom chặn
className="font-bold text-2xl disabled:cursor-not-allowed onClick={handleLogin} // Gọi trực tiếp hàm submit khi click
disabled:opacity-50 h-[52px]" className="font-bold text-2xl disabled:cursor-not-allowed disabled:opacity-50 h-[52px]"
> >
Đăng nhập Đăng nhập
</CustomButton> </CustomButton>
{/* REGISTER */} );
}}
</ContextFormCustom.Consumer>
{/* REGISTER BUTTON */}
<CustomButton <CustomButton
variant="default" variant="default"
rounded="full" rounded="full"
className="font-bold text-2xl disabled:cursor-not-allowed className="font-bold text-2xl disabled:cursor-not-allowed disabled:opacity-50 h-[52px]"
disabled:opacity-50 h-[52px]"
onClick={() => router.push(PATH.REGISTER)} onClick={() => router.push(PATH.REGISTER)}
> >
Đăng Đăng
</CustomButton> </CustomButton>
</div> </div>
{/* LINE */}
{/* <div
className="
my-5
h-[1px]
w-full
bg-[#E5E5E5]
"
/> */}
{/* FORGOT PASSWORD */}
{/* <button
type="button"
onClick={() => router.push(PATH.FORGOT_PASSWORD)}
className="
flex
h-[52px]
w-full
items-center
justify-center
gap-2
rounded-full
border
border-gray-300
bg-white
px-6
font-bold
text-[#171717]
transition-all
hover:bg-gray-50
"
>
<LockKeyhole size={22} />
Quên mật khẩu
</button> */}
</div> </div>
</div> </div>
</FormCustom> </FormCustom>

View File

@ -120,15 +120,29 @@ const DetailConsultation = () => {
type PrescriptionItem = type PrescriptionItem =
ConsultationDetail["aggregatedPrescriptionItems"][number]; 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) => { const getFileInfo = (file: AttachmentFile | string, index: number) => {
if (typeof file === "string") { if (typeof file === "string") {
return { return {
fileName: file.split("/").pop() || `File ${index + 1}`, fileName: file.split("/").pop() || `File ${index + 1}`,
path: file, path: `${process.env.NEXT_PUBLIC_IMAGE}${file}`,
}; };
} }
return file; return {
...file,
path: `${process.env.NEXT_PUBLIC_IMAGE}${file.path}`,
};
}; };
return ( return (
@ -282,7 +296,7 @@ const DetailConsultation = () => {
</p> </p>
<p className="text-[15px] font-medium text-[#202939]"> <p className="text-[15px] font-medium text-[#202939]">
{consultation.createdBy || "---"} {consultation.createdByName || "---"}
</p> </p>
</div> </div>

View File

@ -17,6 +17,7 @@ import consultationServices, {
} from "@/services/consultationServices"; } from "@/services/consultationServices";
import uploadServices from "@/services/uploadServices"; import uploadServices from "@/services/uploadServices";
import UploadMultipleFile from "@/components/utils/UploadMultipleFile"; import UploadMultipleFile from "@/components/utils/UploadMultipleFile";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
/* ========================= /* =========================
TYPES TYPES
@ -29,6 +30,7 @@ interface IUpdateConsul {
treatmentPlan: string; treatmentPlan: string;
doctorNotes: string; doctorNotes: string;
isGlassesDelivered: boolean; isGlassesDelivered: boolean;
glassType: string;
generalAttachmentUrls: string[]; generalAttachmentUrls: string[];
} }
@ -43,6 +45,7 @@ const defaultForm: IUpdateConsul = {
treatmentPlan: "", treatmentPlan: "",
doctorNotes: "", doctorNotes: "",
isGlassesDelivered: false, isGlassesDelivered: false,
glassType: "",
generalAttachmentUrls: [], generalAttachmentUrls: [],
}; };
@ -101,9 +104,14 @@ const UpdateConsultation = () => {
/* ========================= /* =========================
MAP & SET FORM MAP & SET FORM
========================= */ ========================= */
useEffect(() => {
const consul = consulDetailQuery.data; const consul = consulDetailQuery.data;
useEffect(() => {
if (!consul) return; if (!consul) return;
const eyeClinic = consul?.specialtyClinics?.find(
(item) =>
item.specialtyName?.toLowerCase().includes("mắt") &&
item.glassRequired === true,
);
setForm({ setForm({
chiefComplaint: consul.chiefComplaint || "", chiefComplaint: consul.chiefComplaint || "",
@ -112,7 +120,8 @@ const UpdateConsultation = () => {
diagnosis: consul.diagnosis || "", diagnosis: consul.diagnosis || "",
treatmentPlan: consul.treatmentPlan || "", treatmentPlan: consul.treatmentPlan || "",
doctorNotes: consul.doctorNotes || "", doctorNotes: consul.doctorNotes || "",
isGlassesDelivered: consul.isGlassesDelivered, isGlassesDelivered: !!eyeClinic,
glassType: eyeClinic?.glassType || "",
generalAttachmentUrls: consul.generalAttachmentUrls || [], generalAttachmentUrls: consul.generalAttachmentUrls || [],
}); });
@ -212,6 +221,11 @@ const UpdateConsultation = () => {
</div> </div>
); );
} }
const eyeClinic = consul?.specialtyClinics?.find(
(item) =>
item.specialtyName?.toLowerCase().includes("mắt") &&
item.glassRequired === true,
);
/* ========================= /* =========================
MAIN UI MAIN UI
@ -324,6 +338,56 @@ const UpdateConsultation = () => {
} }
/> />
</GridColumn> </GridColumn>
{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="Thông số / Loại kính chỉ định"
value={form.glassType}
isRequired
readOnly
isBlur
max={5000}
onChangeValue={(v) =>
setForm((prev) => ({
...prev,
glassType: String(v),
}))
}
/>
</div>
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
<label className="text-sm font-medium">Tài liệu đính kèm</label> <label className="text-sm font-medium">Tài liệu đính kèm</label>

View File

@ -14,6 +14,7 @@ import consultationServices, {
} from "@/services/consultationServices"; } from "@/services/consultationServices";
import uploadServices from "@/services/uploadServices"; import uploadServices from "@/services/uploadServices";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import moment from "moment";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
@ -62,6 +63,7 @@ const UpdateSpecialtyClinicId = () => {
res || { res || {
id: "", id: "",
patientId: "", patientId: "",
patientName: "",
sessionCode: "", sessionCode: "",
visitDate: "", visitDate: "",
status: 1, status: 1,
@ -71,50 +73,34 @@ const UpdateSpecialtyClinicId = () => {
diagnosis: null, diagnosis: null,
treatmentPlan: null, treatmentPlan: null,
doctorNotes: null, doctorNotes: null,
isGlassesDelivered: false,
createdBy: "", createdBy: "",
createdByName: "",
isDeleted: false, isDeleted: false,
specialtyClinics: [ generalAttachmentUrls: [],
{ specialtyClinics: [],
id: "",
specialtyId: "",
specialtyName: "",
specialtyNotes: null,
diagnosis: null,
procedureNotes: null,
glassRequired: null,
glassType: null,
examinerName: "",
specialtyStatus: 1,
attachmentUrls: [],
sharedItems: [],
},
],
aggregatedPrescriptionItems: [], 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ề // Tìm chuyên khoa hiện tại từ data trả về
const selectedSpecialtyClinic = const selectedSpecialtyClinic = generalData?.specialtyClinics?.find(
consulDetailQuery.data?.specialtyClinics?.find(
(item) => item.id === specialtyClinicId, (item) => item.id === specialtyClinicId,
); );
/**
* ĐIỀU KIỆN KIỂM TRA KHOA MẮT
* Bạn hãy thay đi điều kiện này tùy thuộc vào database của bạn.
* dụ: selectedSpecialtyClinic?.specialtyId === "MẮT" hoặc kiểm tra theo tên.
*/
const isEyeClinic = const isEyeClinic =
selectedSpecialtyClinic?.specialtyName?.toLowerCase().includes("mắt") || selectedSpecialtyClinic?.specialtyName?.toLowerCase().includes("mắt") ||
selectedSpecialtyClinic?.specialtyId?.toLowerCase().includes("mat"); selectedSpecialtyClinic?.specialtyId?.toLowerCase().includes("mat");
useEffect(() => { useEffect(() => {
const consul = consulDetailQuery.data; if (!generalData) return;
if (!consul) return;
const specialtyClinic = consul.specialtyClinics?.find( const specialtyClinic = generalData.specialtyClinics?.find(
(item) => item.id === specialtyClinicId, (item) => item.id === specialtyClinicId,
); );
@ -130,18 +116,17 @@ const UpdateSpecialtyClinicId = () => {
fileUrls: specialtyClinic.attachmentUrls || [], fileUrls: specialtyClinic.attachmentUrls || [],
}); });
setImages( setImages(
(consul.generalAttachmentUrls || []).map((url) => ({ (specialtyClinic.attachmentUrls || []).map((url) => ({
file: null, file: null,
img: url, img: url,
path: url, path: url,
})), })),
); );
}, [consulDetailQuery.data, specialtyClinicId]); }, [generalData, specialtyClinicId]);
/* ========================= /* =========================
UPDATE MUTATION UPDATE MUTATION
========================= */ ========================= */
const specialtyResultsMutation = useMutation({ const specialtyResultsMutation = useMutation({
mutationFn: async (body: { paths: string[] }) => { mutationFn: async (body: { paths: string[] }) => {
if (!consultationId) { if (!consultationId) {
@ -151,14 +136,13 @@ const UpdateSpecialtyClinicId = () => {
return httpRequest({ return httpRequest({
showMessageFailed: true, showMessageFailed: true,
showMessageSuccess: true, showMessageSuccess: true,
msgSuccess: "Cập nhật phiên khám thành công!", msgSuccess: "Cập nhật kết quả khám chuyên khoa thành công!",
http: consultationServices.specialtyResults( http: consultationServices.specialtyResults(
form.specialtyAssignmentId, form.specialtyAssignmentId,
{ {
specialtyNotes: form.specialtyNotes, specialtyNotes: form.specialtyNotes,
diagnosis: form.diagnosis, diagnosis: form.diagnosis,
procedureNotes: form.procedureNotes, procedureNotes: form.procedureNotes,
// Nếu không phải khoa mắt, gán mặc định false và chuỗi rỗng khi gửi API
glassRequired: isEyeClinic ? form.glassRequired : false, glassRequired: isEyeClinic ? form.glassRequired : false,
glassType: isEyeClinic ? form.glassType : "", glassType: isEyeClinic ? form.glassType : "",
fileUrls: body.paths, fileUrls: body.paths,
@ -177,7 +161,6 @@ const UpdateSpecialtyClinicId = () => {
}); });
router.back(); router.back();
router.push(PATH.CONSULTATION || "/consultation"); router.push(PATH.CONSULTATION || "/consultation");
}, },
}); });
@ -195,12 +178,9 @@ const UpdateSpecialtyClinicId = () => {
if (files.length > 0) { if (files.length > 0) {
const uploadResult: any = await uploadServices.uploadImage({ const uploadResult: any = await uploadServices.uploadImage({
Files: files, Files: files,
SessionCode: consulDetailQuery.data?.sessionCode, SessionCode: generalData?.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 = const uploadedUrls =
uploadResult?.fileUrls || uploadResult?.data?.fileUrls || []; uploadResult?.fileUrls || uploadResult?.data?.fileUrls || [];
@ -243,9 +223,51 @@ const UpdateSpecialtyClinicId = () => {
</div> </div>
</div> </div>
{/* CHUYÊN KHOA INFO */} {/* 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">
<h3 className="mb-4 text-xs font-bold uppercase tracking-wider text-gray-500">
Kết quả khám chuyên khoa đm nhận
</h3>
<div className="mb-5">
<InputForm <InputForm
label="Chuyên khoa đảm nhận" label="Chuyên khoa tiếp nhận"
name="specialtyAssignmentId" name="specialtyAssignmentId"
type="text" type="text"
value={ value={
@ -257,12 +279,8 @@ const UpdateSpecialtyClinicId = () => {
isRequired isRequired
readOnly readOnly
/> />
</div>
{/* KHỐI THÔNG TIN KHÁM CHUNG (Luôn cố định 3 cột đẹp mắt) */}
<div className="mt-2">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
Thông tin khám chung
</h3>
<GridColumn col={3}> <GridColumn col={3}>
<InputForm <InputForm
label="Ghi chú chuyên khoa" label="Ghi chú chuyên khoa"
@ -277,9 +295,9 @@ const UpdateSpecialtyClinicId = () => {
/> />
<InputForm <InputForm
type="text" type="text"
label="Chuẩn đoán chuyên khoa" label="Chẩn đoán chuyên khoa"
name="diagnosis" name="diagnosis"
placeholder="Nhập Chuẩn đoán" placeholder="Nhập chẩn đoán chuyên khoa"
isRequired isRequired
value={form.diagnosis} value={form.diagnosis}
onChangeValue={(v) => onChangeValue={(v) =>
@ -308,7 +326,6 @@ const UpdateSpecialtyClinicId = () => {
</h3> </h3>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* Radio Chọn Có/Không */}
<div> <div>
<label className="text-sm font-medium text-gray-700"> <label className="text-sm font-medium text-gray-700">
Bệnh nhân chỉ đnh đeo kính không? Bệnh nhân chỉ đnh đeo kính không?
@ -342,7 +359,6 @@ const UpdateSpecialtyClinicId = () => {
</div> </div>
</div> </div>
{/* TextArea Loại Kính (Trải rộng ra 100% dòng tạo cảm giác thoáng đãng) */}
{form.glassRequired && ( {form.glassRequired && (
<div className="mt-2 animate-fadeIn "> <div className="mt-2 animate-fadeIn ">
<TextArea <TextArea
@ -359,11 +375,12 @@ const UpdateSpecialtyClinicId = () => {
</div> </div>
</div> </div>
)} )}
<div className="space-y-4"> <div className="space-y-4">
<label className="text-sm font-medium">Tài liệu đính kèm</label> <label className="text-sm font-medium">
Tài liệu đính kèm chuyên khoa
</label>
<UploadMultipleFile images={images} setImages={setImages} /> <UploadMultipleFile images={images} setImages={setImages} />
{uploading && ( {uploading && (
<p className="text-sm text-blue-500">Đang upload file...</p> <p className="text-sm text-blue-500">Đang upload file...</p>
)} )}

View File

@ -159,7 +159,8 @@ const PopupCreatePatient = ({ onClose }: PropsPopupCreatePatient) => {
} }
placeholder="Mã định danh" placeholder="Mã định danh"
name="identificationNumber" name="identificationNumber"
type="text" type="number"
isNumber
value={form.identificationNumber} value={form.identificationNumber}
isRequired isRequired
/> />

View File

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

View File

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

View File

@ -0,0 +1,175 @@
"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);

View File

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

View File

@ -0,0 +1,300 @@
"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 { 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("YYYY-MM-DD")
: "",
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: 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="Họ và tên"
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, fullName: String(v) }))
}
/>
<InputForm
type="date"
placeholder="Nhập ngày sinh"
name="birthDate"
label="Ngày sinh"
value={form.birthDate}
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, birthDate: String(v) }))
}
/>
</div>
{/* Cột phải */}
<div className="space-y-4">
<InputForm
placeholder="Nhập email"
name="email"
type="email"
value={form.email}
label="Email"
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, email: String(v) }))
}
/>
<InputForm
placeholder="Nhập số điện thoại"
name="phone"
type="number"
isNumber
value={form.phone}
label="Số điện thoại"
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, phone: String(v) }))
}
/>
</div>
</div>
</div>
</div>
</div>
</FormCustom>
</Fragment>
);
}
export default memo(MainUpdateProfile);

View File

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

View File

@ -136,7 +136,7 @@ const MainPageStatistical = () => {
column={[ column={[
{ {
title: "MÃ CHUYÊN KHOA", title: "MÃ CHUYÊN KHOA",
render: (item) => <span>{item.specialtyId}</span>, render: (item) => <span>{item.specialtyCode}</span>,
}, },
{ {
title: "TÊN CHUYÊN KHOA", title: "TÊN CHUYÊN KHOA",

View File

@ -1,132 +1,116 @@
"use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Image from "next/image";
import { Upload, Trash } from "lucide-react";
import { toastError, toastWarn } from "@/common/funcs/toast";
import { PropsUploadAvatar } from "./interface"; import { PropsUploadAvatar } from "./interface";
// import { toastError, toastWarn } from "~/common/funcs/toast";
import Image from "next/image";
import { toastError, toastWarn } from "@/common/funcs/toast";
import { Trash } from "lucide-react";
// import icons from "~/constants/images/icons";
const MAXIMUM_FILE = 10; const MAXIMUM_FILE = 10;
export default function UploadAvatar({ function UploadAvatar({ path, name, onSetFile, resetPath }: PropsUploadAvatar) {
path, const [imageBase64, setImageBase64] = useState<string>("");
name,
onSetFile,
resetPath,
}: PropsUploadAvatar) {
const [imageUrl, setImageUrl] = useState<string>("");
const [fileName, setFileName] = useState<string>(""); const [fileName, setFileName] = useState<string>("");
const handleSelectImg = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSelectImg = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return;
if (file) {
const { size, type, name } = file; const { size, type, name } = file;
const maxSize = MAXIMUM_FILE; // MB
if (size / 1024 / 1024 > MAXIMUM_FILE) { if (size / 1000000 > maxSize) {
toastError({ msg: `Kích thước tối đa ${MAXIMUM_FILE}MB` }); return toastError({
return; msg: `Kích thước tối đa của ảnh là ${maxSize} MB`,
});
} else if (!["image/jpeg", "image/jpg", "image/png"].includes(type)) {
return toastWarn({
msg: `Định dạng tệp không chính xác, chỉ chấp nhận .jpg, .jpeg, .png`,
});
} }
if (!["image/jpeg", "image/jpg", "image/png"].includes(type)) { const imageUrl = URL.createObjectURL(file);
toastWarn({ setImageBase64((prev) => {
msg: "Chỉ hỗ trợ định dạng .jpg, .jpeg, .png", URL.revokeObjectURL(prev);
return imageUrl;
}); });
return;
}
const url = URL.createObjectURL(file);
setImageUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
setFileName(name); setFileName(name);
onSetFile?.(file); onSetFile && onSetFile(file);
}
}; };
useEffect(() => { useEffect(() => {
return () => { return () => {
if (imageUrl) URL.revokeObjectURL(imageUrl); if (imageBase64) {
URL.revokeObjectURL(imageBase64);
}
}; };
}, [imageUrl]); }, [imageBase64]);
const handleRemoveImg = () => { const handleRemoveImg = () => {
if (imageUrl) URL.revokeObjectURL(imageUrl); setImageBase64("");
setImageUrl("");
setFileName(""); setFileName("");
onSetFile && onSetFile(null);
onSetFile?.(null); resetPath && resetPath();
resetPath?.();
}; };
const displayImage = imageUrl || path;
return ( return (
<div className="flex flex-col md:flex-row gap-3"> <div className="flex gap-3">
{/* AVATAR */}
<Image <Image
src={displayImage} alt="update avatar"
alt="avatar" src={!!imageBase64 ? imageBase64 : path}
width={104} width={80}
height={104} height={80}
className="rounded-md border border-blue-500 object-cover" className="rounded-md border border-[#6e99fd]"
/> />
{/* RIGHT CONTENT */} <div>
<div className="flex flex-col gap-3"> <div className="mb-3 flex items-center gap-3">
{/* CONTROL */} <label className="flex h-12 cursor-pointer select-none">
<div className="flex flex-col md:flex-row items-start md:items-center gap-3">
{/* UPLOAD INPUT */}
<label className="flex cursor-pointer select-none h-12 w-full md:w-auto">
{/* BUTTON */}
<div className="flex items-center justify-center gap-2 px-5 bg-gray-100 rounded-l-md shadow-inner">
<Upload size={18} />
<span className="text-sm font-semibold">Chọn file</span>
</div>
{/* FILE NAME */}
<div className="flex items-center px-5 w-full md:w-[328px] bg-blue-50 rounded-r-md shadow-inner overflow-hidden">
<p className="text-sm font-semibold text-gray-600 truncate w-full">
{fileName || "Tên file"}
</p>
</div>
<input <input
hidden hidden
type="file" type="file"
name={name}
accept="image/png, image/jpeg, image/jpg" accept="image/png, image/jpeg, image/jpg"
name={name}
onChange={handleSelectImg} onChange={handleSelectImg}
onClick={(e) => { onClick={(e) => {
(e.target as HTMLInputElement).value = ""; (e.target as HTMLInputElement).value = "";
}} }}
/> />
</label>
{/* DELETE BUTTON */} {/* Button chọn file */}
<button <div className="flex h-full items-center justify-center rounded-l-lg bg-[#fafafb] px-5 shadow-[inset_0px_0px_3px_0px_rgba(175,190,211,0.2)]">
onClick={handleRemoveImg} <p className="text-sm font-bold text-[#445463]">Chọn file</p>
className="flex items-center gap-2 px-6 h-12 bg-gray-100 rounded-md transition active:scale-95 hover:opacity-70"
>
<Trash size={18} color="#AF0000" />
<span className="text-sm font-semibold text-gray-700">
Gỡ nh đi diện
</span>
</button>
</div> </div>
{/* DESCRIPTION */} {/* Tên file */}
<p className="text-sm text-gray-400"> <div className="flex h-full w-[328px] items-center justify-start rounded-r-lg bg-[#f1f8ff] px-5 shadow-[inset_0px_3px_3px_0px_rgba(175,190,211,0.2)]">
Hình nh dùng làm avatar, tối thiểu 300x300px <p className="truncate text-sm font-bold text-[#445463]">
{fileName || "Tên file"}
</p> </p>
<p className="text-sm text-gray-400"> </div>
Đnh dạng hỗ trợ: JPG, JPEG, PNG </label>
{/* Xóa avatar */}
<div
onClick={handleRemoveImg}
className="flex h-12 cursor-pointer select-none items-center gap-2.5 rounded-lg bg-[rgba(154,169,182,0.1)] px-6 transition duration-300 hover:opacity-70 active:scale-95"
>
<Trash width={20} height={20} />
<p className="text-base font-bold text-[#445463]">
Xóa nh đi diện
</p>
</div>
</div>
<p className="text-sm font-normal text-[#9aa9b6]">
Hình nh đưc dùng làm nh đi diện, kích thước tối thiểu 300px X
300px đ đm bảo đ sắc nét.
</p> </p>
</div> </div>
</div> </div>
); );
} }
export default UploadAvatar;

View File

@ -112,7 +112,7 @@ export default function UploadMultipleFile({
const fileUrl = const fileUrl =
item?.url || item?.url ||
(item?.path (item?.path
? `${process.env.NEXT_PUBLIC_IMAGE}/${item.path}` ? `${process.env.NEXT_PUBLIC_IMAGE}${item.path}`
: ""); : "");
return ( return (
@ -137,7 +137,7 @@ export default function UploadMultipleFile({
</a> </a>
) : ( ) : (
<a <a
href={fileUrl} href={`${process.env.NEXT_PUBLIC_IMAGE}/${fileUrl}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-gray-50 p-1" className="flex h-full w-full flex-col items-center justify-center gap-1 bg-gray-50 p-1"

View File

@ -4,6 +4,7 @@ export enum QUERY_KEY {
table_list_patientrescription, table_list_patientrescription,
table_list_specialty, table_list_specialty,
table_list_report, table_list_report,
table_list_profile,
list_specialty_lookup, list_specialty_lookup,
examination_specialty_list, examination_specialty_list,
@ -11,6 +12,7 @@ export enum QUERY_KEY {
chi_tiet_benh_nhan, chi_tiet_benh_nhan,
chi_tiet_don_thuoc, chi_tiet_don_thuoc,
chi_tiet_phien_kham, chi_tiet_phien_kham,
chi_tiet_thong_tin,
user_me, user_me,
} }

View File

@ -22,6 +22,7 @@ export enum PATH {
FORGOT_PASSWORD = "/auth/forgot-password", FORGOT_PASSWORD = "/auth/forgot-password",
PROFILE = "/profile", PROFILE = "/profile",
UPDATEPROFILE = "/profile/update",
} }
export const Menus: { export const Menus: {

View File

@ -3,7 +3,7 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit";
export interface IDataLoginStorage { export interface IDataLoginStorage {
usernameStorage: string; usernameStorage: string;
passwordStorage: string; passwordStorage: string;
deviceIdStorage: string; // deviceIdStorage: string;
} }
export interface AuthState { export interface AuthState {

View File

@ -3,10 +3,7 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit";
interface IUser { interface IUser {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
accessExpiresAt: string; userId: string;
refreshExpiresAt: string;
avatar: string;
fullname: string;
} }
export interface UserState { export interface UserState {

View File

@ -1,5 +1,11 @@
import axiosClient from "."; import axiosClient from ".";
export interface LoginResponse {
token: string;
refreshToken: string;
userId: string;
}
const authServices = { const authServices = {
login: ( login: (
data: { data: {

View File

@ -65,6 +65,7 @@ export interface ConsultationDetail {
id: string; id: string;
patientId: string; patientId: string;
patientName: string; patientName: string;
patientDateOfBirth: string | null;
sessionCode: string; sessionCode: string;
visitDate: string; visitDate: string;
status: string | number; status: string | number;
@ -76,6 +77,7 @@ export interface ConsultationDetail {
doctorNotes: string | null; doctorNotes: string | null;
isGlassesDelivered: boolean; isGlassesDelivered: boolean;
createdBy: string; createdBy: string;
createdByName: string;
isDeleted: boolean; isDeleted: boolean;
generalAttachmentUrls: string[]; generalAttachmentUrls: string[];
specialtyClinics: { specialtyClinics: {
@ -126,6 +128,7 @@ export interface ReportResponse {
totalSpecialtySubSessions: number; totalSpecialtySubSessions: number;
specialtyDetails: { specialtyDetails: {
specialtyId: string; specialtyId: string;
specialtyCode: string;
specialtyName: string; specialtyName: string;
totalCases: number; totalCases: number;
}[]; }[];

View File

@ -151,13 +151,7 @@ const refreshAccessToken = async (): Promise<string | null> => {
refreshToken: responseData?.refreshToken || prev?.refreshToken || "", refreshToken: responseData?.refreshToken || prev?.refreshToken || "",
accessExpiresAt: prev?.accessExpiresAt || "", userId: prev?.userId || "",
refreshExpiresAt: prev?.refreshExpiresAt || "",
avatar: prev?.avatar || "",
fullname: prev?.fullname || "",
}), }),
); );

View File

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

View File

@ -2,6 +2,7 @@ import axiosClient from ".";
export interface SpecialtyItem { export interface SpecialtyItem {
id: string; id: string;
code: string;
name: string; name: string;
} }

View File

@ -21,6 +21,17 @@ export interface UserMe {
phone: string | number | 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 = { const userServices = {
getUser: (params?: GetUserParams, tokenAxios?: any) => { getUser: (params?: GetUserParams, tokenAxios?: any) => {
return axiosClient.get(`/api/v1/User/list`, { return axiosClient.get(`/api/v1/User/list`, {