feat update

This commit is contained in:
TuanVT
2026-08-13 22:58:39 +07:00
parent 04011a9568
commit 719851effa
30 changed files with 52 additions and 1578 deletions
@@ -1,76 +0,0 @@
"use client";
import React, { useCallback } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { Menus, PATH } from "@/constant/config";
import clsx from "clsx";
import { Hospital } from "lucide-react";
const Navbar = () => {
const pathname = usePathname();
const checkActive = useCallback(
(pathActive: string) => {
// Handle root path "/" as dashboard
if (pathname === "/" && pathActive === "/dashboard") {
return true;
}
const currentRoute = pathname.split("/")[1];
return pathActive === `/${currentRoute}`;
},
[pathname],
);
return (
<div className="h-full w-full flex flex-col items-center p-3 bg-white">
<Link href={PATH.HOME} className="flex items-center justify-center py-2">
{/* <Image alt="Logo" src={icons.avatar} width={40} height={40} /> */}
<Hospital className="text-blue-900" />
<h2 className="ml-2 text-sm font-semibold text-blue-900 select-none">
Quản khám bệnh
</h2>
</Link>
<div className="flex-1 w-full overflow-auto pt-3">
{Menus.map((menu, i) => (
<div key={i} className="mb-4">
{/* TITLE */}
<h5 className="text-xs font-semibold text-gray-400 uppercase mb-2">
{menu.title}
</h5>
<div className="space-y-1">
{menu.group.map((tab, y) => {
const isActive = checkActive(tab.pathActive);
return (
<Link
key={y}
href={tab.path}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded-lg transition",
isActive
? "bg-indigo-100 text-blue-700"
: "text-gray-800 hover:bg-indigo-100 hover:text-blue-700",
)}
>
<tab.icon
size={22}
className={clsx(
isActive ? "text-blue-700" : "text-gray-800",
)}
/>
<p className="text-base font-semibold">{tab.title}</p>
</Link>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
export default Navbar;
@@ -1 +0,0 @@
export { default } from "./Navbar";
@@ -1,85 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import * as Popover from "@radix-ui/react-popover";
import { PropsFilterDateRange } from "./interface";
import Moment from "react-moment";
import { TYPE_DATE } from "@/constant/config/enum";
import { getDateRange } from "@/common/funcs/selectData";
import { ListOptionFilterDate } from "@/constant/config";
import { ChevronDown } from "lucide-react";
import DateOption from "./components/DateOption";
function FilterDateRange({
styleRounded = false,
showOptionAll = false,
date,
setDate,
typeDate,
setTypeDate,
title = "Thời gian",
}: PropsFilterDateRange) {
const [openDate, setOpenDate] = useState(false);
useEffect(() => {
if (typeDate !== null && typeDate !== TYPE_DATE.LUA_CHON) {
setDate(getDateRange(typeDate));
}
}, [typeDate, setDate]);
return (
<Popover.Root open={openDate} onOpenChange={setOpenDate}>
<Popover.Trigger asChild>
<button
type="button"
className={clsx(
"flex h-12 min-w-[240px] items-center justify-between gap-1 rounded-full border border-[#E1E5ED] bg-white px-4 transition",
"hover:border-[#0011ab]",
openDate && "border-[#0011ab]",
styleRounded && "h-10 min-w-[200px] bg-[#f0f2fa]",
)}
>
<div className="flex items-center">
<p className="text-sm font-medium text-[#777E90]">{title}:</p>
<p className="ml-2 text-sm font-medium">
{typeDate === TYPE_DATE.LUA_CHON && date?.from && date?.to ? (
<>
<Moment date={date.from} format="DD/MM/YYYY" />
{" - "}
<Moment date={date.to} format="DD/MM/YYYY" />
</>
) : (
ListOptionFilterDate.find((v) => v.value === typeDate)?.name
)}
</p>
</div>
<ChevronDown
size={16}
className={clsx(
"transition duration-300",
openDate && "rotate-180",
)}
/>
</button>
</Popover.Trigger>
<Popover.Content align="start" sideOffset={8} className="z-50">
<DateOption
showOptionAll={showOptionAll}
date={date}
setDate={setDate}
typeDate={typeDate}
setTypeDate={setTypeDate}
show={openDate}
setShow={setOpenDate}
/>
</Popover.Content>
</Popover.Root>
);
}
export default FilterDateRange;
@@ -1,230 +0,0 @@
"use client";
import { memo, useCallback, useContext, useMemo } from "react";
import clsx from "clsx";
import { ContextCalendar } from "../RangeDatePicker/RangeDatePicker";
import { PropsCalendarMain } from "./interface";
const months = Array.from({ length: 12 }, (_, i) => i + 1);
function CalendarMain({
date,
setDate,
setType,
type,
year,
}: PropsCalendarMain) {
// ✅ destructure context (fix React Compiler)
const { datePicker, dateHover, setDatePicker, setDateHover } =
useContext<any>(ContextCalendar);
// ===================== HOVER RANGE =====================
const hoverRange = useMemo(() => {
if (datePicker?.from && dateHover) {
return dateHover > datePicker.from
? { start: datePicker.from, end: dateHover }
: { start: dateHover, end: datePicker.from };
}
return null;
}, [datePicker?.from, dateHover]);
// ===================== HANDLE PICK =====================
const handleDatePick = useCallback(
(datePick: Date) => {
if (!datePicker?.from || datePicker?.to) {
return setDatePicker({
from: datePick,
to: null,
});
}
setDateHover(null);
if (datePick < datePicker.from) {
return setDatePicker({
from: datePick,
to: datePicker.from,
});
}
return setDatePicker({
...datePicker,
to: datePick,
});
},
[datePicker, setDatePicker, setDateHover],
);
// ===================== HANDLE HOVER =====================
const handleHover = useCallback(
(d: Date) => {
if (datePicker?.from && !datePicker?.to) {
setDateHover(d);
}
},
[datePicker?.from, datePicker?.to, setDateHover],
);
// ===================== BUILD CALENDAR =====================
const rows = useMemo(() => {
const currentDate = new Date();
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0);
const startDate = new Date(monthStart);
const result: React.ReactNode[] = [];
// lùi về CN
while (startDate.getDay() !== 0) {
startDate.setDate(startDate.getDate() - 1);
}
while (startDate <= monthEnd) {
const cells: React.ReactNode[] = [];
for (let i = 0; i < 7; i++) {
const currDate = new Date(startDate);
const isStart = currDate.getTime() === datePicker?.from?.getTime();
const isEnd = currDate.getTime() === datePicker?.to?.getTime();
const isBetween =
datePicker?.from &&
datePicker?.to &&
currDate > datePicker.from &&
currDate < datePicker.to;
const isHover =
hoverRange &&
currDate > hoverRange.start &&
currDate < hoverRange.end;
const isStartHover =
hoverRange && currDate.getTime() === hoverRange.start.getTime();
const isEndHover =
hoverRange && currDate.getTime() === hoverRange.end.getTime();
const isDisabled = startDate.getMonth() !== date.getMonth();
const isToday =
startDate.toDateString() === currentDate.toDateString() &&
startDate.getMonth() === date.getMonth();
cells.push(
<div
key={startDate.getTime()}
onClick={() => handleDatePick(currDate)}
onMouseOver={() => handleHover(currDate)}
className={clsx(
"flex items-center justify-center px-2 py-1 text-sm font-medium rounded border border-transparent transition",
"cursor-pointer select-none hover:opacity-60",
isDisabled && "pointer-events-none opacity-30",
isToday && "border-blue-700",
isStart && "bg-blue-700 text-white rounded-l-md",
isEnd && "bg-blue-700 text-white rounded-r-md",
isBetween && "bg-gray-100",
isHover && "border-y border-blue-700",
isStartHover && "border border-blue-700 rounded-l-md",
isEndHover && "border border-blue-700 rounded-r-md",
)}
>
{startDate.getDate()}
</div>,
);
startDate.setDate(startDate.getDate() + 1);
}
result.push(
<div key={startDate.getTime()} className="grid grid-cols-7 gap-[2px]">
{cells}
</div>,
);
}
return result;
}, [
date,
datePicker?.from,
datePicker?.to,
handleDatePick,
handleHover,
hoverRange,
]);
// ===================== YEAR LIST =====================
const listYear = useMemo(() => {
if (!year) return [];
return Array.from(
{ length: year.last - year.first + 1 },
(_, i) => year.first + i,
);
}, [year]);
// ===================== UI =====================
return (
<div onMouseLeave={() => setDateHover(null)}>
{/* DATE */}
{type === 0 && rows}
{/* MONTH */}
{type === 1 && (
<div className="grid grid-cols-3 gap-1.5 min-w-[280px]">
{months.map((m) => (
<div
key={m}
onClick={() => {
const newDate = new Date(date);
newDate.setMonth(m - 1);
setDate(newDate);
setType(0);
}}
className={clsx(
"cursor-pointer select-none rounded px-2 py-1 text-center text-sm font-semibold transition",
"hover:bg-gray-100 hover:opacity-70",
m === date.getMonth() + 1 &&
"bg-blue-500 text-white hover:bg-blue-500 hover:opacity-100",
)}
>
Tháng {m}
</div>
))}
</div>
)}
{/* YEAR */}
{type === 2 && (
<div className="grid grid-cols-3 gap-1.5 min-w-[280px]">
{listYear.map((y) => (
<div
key={y}
onClick={() => {
const newDate = new Date(date);
newDate.setFullYear(y);
setDate(newDate);
setType(1);
}}
className={clsx(
"cursor-pointer select-none rounded px-2 py-1 text-center text-sm font-semibold transition",
"hover:bg-gray-100 hover:opacity-70",
y === date.getFullYear() &&
"bg-blue-500 text-white hover:bg-blue-500 hover:opacity-100",
)}
>
{y}
</div>
))}
</div>
)}
</div>
);
}
export default memo(CalendarMain);
@@ -1 +0,0 @@
export { default } from "./CalendarMain";
@@ -1,7 +0,0 @@
export interface PropsCalendarMain {
date: Date;
setDate: (date: Date) => void;
setType: (num: number) => void;
type: number;
year: any;
}
@@ -1,77 +0,0 @@
"use client";
import clsx from "clsx";
import { Check } from "lucide-react";
import { PropsDateOption } from "./interface";
import { TYPE_DATE } from "@/constant/config/enum";
import { getDateRange } from "@/common/funcs/selectData";
import { ListOptionFilterDate } from "@/constant/config";
import RangeDatePicker from "../RangeDatePicker";
export default function DateOption({
showOptionAll,
date,
setDate,
typeDate,
setTypeDate,
show,
setShow,
}: PropsDateOption) {
const isCustom = Number(typeDate) === TYPE_DATE.LUA_CHON;
return (
<div className="flex gap-2">
{/* MENU */}
<div
className="
w-[180px]
rounded-2xl
bg-white
p-2
shadow-xl
"
>
{ListOptionFilterDate.map((item) => {
const active = item.value === typeDate;
return (
<div
key={item.value}
onClick={() => {
setTypeDate(item.value);
if (item.value !== TYPE_DATE.LUA_CHON) {
setDate(getDateRange(item.value));
setShow(false);
}
}}
className={clsx(
"flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm",
active ? "bg-blue-50 text-blue-600" : "hover:bg-gray-50",
)}
>
<span>{item.name}</span>
{active && item.value !== TYPE_DATE.LUA_CHON && (
<Check size={16} />
)}
</div>
);
})}
</div>
{/* DATE PICKER */}
{isCustom && (
<RangeDatePicker
value={date}
onSetValue={setDate}
open={show}
onClose={() => setShow(false)}
/>
)}
</div>
);
}
@@ -1 +0,0 @@
export { default } from "./DateOption";
@@ -1,12 +0,0 @@
export interface PropsDateOption {
showOptionAll?: boolean;
date: {
from: Date | null;
to: Date | null;
} | null;
setDate: (any: any) => void;
typeDate: number | null;
setTypeDate: (any: any) => void;
show: boolean;
setShow: (any: any) => void;
}
@@ -1,220 +0,0 @@
"use client";
import { createContext, memo, useCallback, useEffect, useState } from "react";
import { PropsRangeDatePicker } from "./interface";
import clsx from "clsx";
import { ChevronLeft, MoveRight, ChevronRight } from "lucide-react";
import CalendarMain from "../CalendarMain";
import Button from "@/components/customs/custom-button";
import Moment from "react-moment";
import CustomButton from "@/components/customs/custom-button";
const daysOfWeek = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
export const ContextCalendar = createContext<any>(null);
function RangeDatePicker({
onClose,
onSetValue,
value,
open,
onSubmit,
}: PropsRangeDatePicker) {
const [typeCalendarLeft, setTypeCalendarLeft] = useState(0);
const [typeCalendarRight, setTypeCalendarRight] = useState(0);
const [dateLeft, setDateLeft] = useState(new Date());
const [dateRight, setDateRight] = useState(
new Date(new Date().getFullYear(), new Date().getMonth() + 1),
);
const [dateHover, setDateHover] = useState<Date | null>(null);
const [yearTableLeft, setYearTableLeft] = useState<any>();
const [yearTableRight, setYearTableRight] = useState<any>();
const [datePicker, setDatePicker] = useState({
from: null as Date | null,
to: null as Date | null,
});
// ===== HANDLE =====
const handleNext = useCallback(
(isRight?: boolean) => {
if (isRight) {
if (typeCalendarRight === 0) {
setDateRight((prev) => {
const newDate = new Date(prev.getFullYear(), prev.getMonth() + 1);
return newDate;
});
}
} else {
if (typeCalendarLeft === 0) {
setDateLeft((prev) => {
const newDate = new Date(prev.getFullYear(), prev.getMonth() + 1);
return newDate < dateRight ? newDate : prev;
});
}
}
},
[dateRight, typeCalendarLeft, typeCalendarRight],
);
const handlePrev = useCallback(
(isRight?: boolean) => {
if (isRight) {
if (typeCalendarRight === 0) {
setDateRight((prev) => {
const newDate = new Date(prev.getFullYear(), prev.getMonth() - 1);
return dateLeft < newDate ? newDate : prev;
});
}
} else {
if (typeCalendarLeft === 0) {
setDateLeft((prev) => {
return new Date(prev.getFullYear(), prev.getMonth() - 1);
});
}
}
},
[dateLeft, typeCalendarLeft, typeCalendarRight],
);
const handleSubmit = useCallback(() => {
if (datePicker.from && datePicker.to) {
onSetValue(datePicker);
onClose();
onSubmit && onSubmit();
}
}, [datePicker]);
// ===== EFFECT =====
useEffect(() => {
if (!open) {
setDatePicker({ from: value?.from, to: value?.to });
}
}, [open, value]);
useEffect(() => {
setYearTableLeft({
first: dateLeft.getFullYear() - 5,
last: dateLeft.getFullYear() + 6,
});
setYearTableRight({
first: dateRight.getFullYear() - 5,
last: dateRight.getFullYear() + 6,
});
}, [dateLeft, dateRight]);
return (
<ContextCalendar.Provider
value={{ datePicker, setDateHover, dateHover, setDatePicker }}
>
<div className="rounded bg-white shadow-[0px_137px_123px_rgba(175,175,175,0.11),0px_59px_64px_rgba(175,175,175,0.08),0px_29px_43px_rgba(175,175,175,0.07)]">
{/* HEADER DATE */}
<div className="flex items-center gap-3 border-b p-3">
<span
className={clsx(
"text-sm font-medium text-gray-400",
datePicker.from && "font-semibold text-[#23262f]",
)}
>
{datePicker.from ? (
<Moment format="DD/MM/YYYY">{datePicker.from}</Moment>
) : (
"Ngày bắt đầu"
)}
</span>
<MoveRight size={20} />
<span
className={clsx(
"text-sm font-medium text-gray-400",
datePicker.to && "font-semibold text-[#23262f]",
)}
>
{datePicker.to ? (
<Moment format="DD/MM/YYYY">{datePicker.to}</Moment>
) : (
"Ngày kết thúc"
)}
</span>
</div>
{/* CALENDAR */}
<div className="flex max-md:flex-col">
{[false, true].map((isRight, idx) => {
const date = isRight ? dateRight : dateLeft;
const type = isRight ? typeCalendarRight : typeCalendarLeft;
const setType = isRight
? setTypeCalendarRight
: setTypeCalendarLeft;
const year = isRight ? yearTableRight : yearTableLeft;
return (
<div key={idx} className="p-3">
{/* TITLE */}
<div className="mb-3 flex items-center justify-between border-b pb-3">
<button
onClick={() => handlePrev(isRight)}
className="cursor-pointer disabled:opacity-10"
>
<ChevronLeft size={20} />
</button>
<p
className="cursor-pointer text-base font-semibold"
onClick={() => setType(type === 0 ? 1 : type === 1 ? 2 : 0)}
>
Tháng {date.getMonth() + 1}, {date.getFullYear()}
</p>
<button
onClick={() => handleNext(isRight)}
className="cursor-pointer"
>
<ChevronRight size={20} />
</button>
</div>
{/* DAYS */}
{type === 0 && (
<div className="grid grid-cols-7">
{daysOfWeek.map((d) => (
<div
key={d}
className="flex items-center justify-center px-2 py-1 text-sm font-semibold"
>
{d}
</div>
))}
</div>
)}
{/* CALENDAR MAIN */}
<CalendarMain
date={date}
setDate={isRight ? setDateRight : setDateLeft}
setType={setType}
type={type}
year={year}
/>
</div>
);
})}
</div>
{/* BUTTON */}
<div className="grid grid-cols-2 gap-3 p-3 pt-0">
<CustomButton onClick={onClose}>Hủy bỏ</CustomButton>
<CustomButton
onClick={handleSubmit}
disabled={!datePicker.from || !datePicker.to}
>
Áp dụng
</CustomButton>
</div>
</div>
</ContextCalendar.Provider>
);
}
export default memo(RangeDatePicker);
@@ -1 +0,0 @@
export { default } from "./RangeDatePicker";
@@ -1,7 +0,0 @@
export interface PropsRangeDatePicker {
onClose: () => void;
onSetValue: (any: any) => void;
onSubmit?: () => void;
value: any;
open?: boolean;
}
@@ -1 +0,0 @@
export { default } from "./FilterDateRange";
@@ -1,17 +0,0 @@
import { Dispatch, SetStateAction } from "react";
import { TYPE_DATE } from "@/constant/config/enum";
export interface PropsFilterDateRange {
styleRounded?: boolean;
showOptionAll?: boolean;
date: {
from: Date | null;
to: Date | null;
} | null;
setDate: Dispatch<
SetStateAction<{ from: Date | null; to: Date | null } | null>
>;
typeDate: TYPE_DATE;
setTypeDate: Dispatch<SetStateAction<TYPE_DATE>>;
title?: string;
}
@@ -1,116 +0,0 @@
"use client";
import { ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { PropsPositionContainer } from "./interface";
export default function PositionContainer({
children,
open,
onClose,
disableOverlay,
idParent,
classStyle,
}: PropsPositionContainer) {
const containerRef = useRef<HTMLDivElement | null>(null);
// ✅ create portal element ONCE
const [portalElement] = useState(() => {
if (typeof window === "undefined") {
return null;
}
return document.createElement("div");
});
// ================= APPEND PORTAL =================
useEffect(() => {
if (!portalElement) return;
const parent = idParent ? document.getElementById(idParent) : document.body;
if (!parent) return;
parent.appendChild(portalElement);
return () => {
parent.removeChild(portalElement);
};
}, [idParent, portalElement]);
// ================= CLICK OUTSIDE =================
useEffect(() => {
if (!disableOverlay || !open) return;
const handleMouseUp = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const insideClick = target.closest(".click");
if (
containerRef.current &&
!containerRef.current.contains(target) &&
!insideClick
) {
onClose();
}
};
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
};
}, [disableOverlay, open, onClose]);
// ================= SSR SAFE =================
if (!portalElement) return null;
return createPortal(
<div
className={clsx("fixed inset-0 z-[100]", !open && "pointer-events-none")}
>
{/* overlay */}
{!disableOverlay && (
<div
onClick={onClose}
className={clsx(
`
fixed inset-0
bg-black/50
transition-opacity
duration-300
`,
open ? "opacity-100" : "opacity-0",
)}
/>
)}
{/* panel */}
<div
ref={containerRef}
className={clsx(
`
fixed
top-0
right-0
h-full
z-[101]
transition-all
duration-300
ease-in-out
`,
open ? "translate-x-0 opacity-100" : "translate-x-full opacity-0",
classStyle?.main,
classStyle?.open,
)}
>
{children}
</div>
</div>,
portalElement,
);
}
@@ -1,36 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { PropsClickOpen } from "./interface";
export default function ClickOpen({
children,
onClick,
duration = 100,
}: PropsClickOpen) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => {
// clear timeout cũ nếu spam click
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
onClick(false);
timeoutRef.current = setTimeout(() => {
onClick(true);
}, duration);
};
useEffect(() => {
return () => {
// cleanup khi unmount
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return <div onClick={handleClick}>{children}</div>;
}
@@ -1 +0,0 @@
export { default } from "./ClickOpen";
@@ -1,5 +0,0 @@
export interface PropsClickOpen {
children: any;
onClick: (bol: boolean) => void;
duration?: number;
}
@@ -1,11 +0,0 @@
export interface PropsPositionContainer {
children: any;
open: boolean;
onClose: () => void;
disableOverlay?: boolean;
idParent?: string;
classStyle?: {
main: string;
open: string;
};
}
@@ -1,116 +0,0 @@
import React, { useEffect, useState } from "react";
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;
function UploadAvatar({ path, name, onSetFile, resetPath }: PropsUploadAvatar) {
const [imageBase64, setImageBase64] = useState<string>("");
const [fileName, setFileName] = useState<string>("");
const handleSelectImg = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const { size, type, name } = file;
const maxSize = MAXIMUM_FILE; // MB
if (size / 1000000 > maxSize) {
return toastError({
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`,
});
}
const imageUrl = URL.createObjectURL(file);
setImageBase64((prev) => {
URL.revokeObjectURL(prev);
return imageUrl;
});
setFileName(name);
onSetFile && onSetFile(file);
}
};
useEffect(() => {
return () => {
if (imageBase64) {
URL.revokeObjectURL(imageBase64);
}
};
}, [imageBase64]);
const handleRemoveImg = () => {
setImageBase64("");
setFileName("");
onSetFile && onSetFile(null);
resetPath && resetPath();
};
return (
<div className="flex gap-3">
<Image
alt="update avatar"
src={!!imageBase64 ? imageBase64 : path}
width={80}
height={80}
className="rounded-md border border-[#6e99fd]"
/>
<div>
<div className="mb-3 flex items-center gap-3">
<label className="flex h-12 cursor-pointer select-none">
<input
hidden
type="file"
accept="image/png, image/jpeg, image/jpg"
name={name}
onChange={handleSelectImg}
onClick={(e) => {
(e.target as HTMLInputElement).value = "";
}}
/>
{/* Button chọn file */}
<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)]">
<p className="text-sm font-bold text-[#445463]">Chọn file</p>
</div>
{/* Tên file */}
<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)]">
<p className="truncate text-sm font-bold text-[#445463]">
{fileName || "Tên file"}
</p>
</div>
</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>
</div>
</div>
);
}
export default UploadAvatar;
@@ -1 +0,0 @@
export { default } from "./UploadAvatar";
@@ -1,8 +0,0 @@
import { Dispatch, SetStateAction } from "react";
export interface PropsUploadAvatar {
path: any;
name: string;
onSetFile: Dispatch<SetStateAction<any>>;
resetPath?: () => void;
}
@@ -1,243 +0,0 @@
"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;
@@ -1 +0,0 @@
export { default } from "./UploadImage";
@@ -1,17 +0,0 @@
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;
}
@@ -1,211 +0,0 @@
"use client";
import React from "react";
import Image from "next/image";
import {
X,
CirclePlus,
FileText,
FileSpreadsheet,
FileType,
FileImage,
} from "lucide-react";
import clsx from "clsx";
import { PropsUploadMultipleFile, UploadFileItem } from "./interface";
export default function UploadMultipleFile({
images = [],
setImages,
isDisableDelete = false,
}: PropsUploadMultipleFile) {
/* =========================
CHECK IMAGE FILE
========================= */
const isImageFile = (item: UploadFileItem) => {
const type = item?.fileType || "";
return (
type.startsWith("image/") ||
/\.(jpg|jpeg|png|gif|webp)$/i.test(item?.path || "")
);
};
/* =========================
GET FILE ICON
========================= */
const renderFileIcon = (item: UploadFileItem) => {
const fileName = item?.fileName || item?.path?.split("/").pop() || "";
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 flex-wrap items-center gap-2">
{/* FILE LIST */}
{images?.length > 0 && (
<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 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={fileUrl}
alt="file"
fill
className="object-cover"
/>
</a>
) : (
<a
href={`${process.env.NEXT_PUBLIC_IMAGE}/${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)}
<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 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" />
</button>
)}
</div>
);
})}
</div>
)}
{/* UPLOAD BUTTON */}
<div className="flex items-center gap-2">
<label
className={clsx(
"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
multiple
type="file"
accept="
image/*,
.pdf,
.doc,
.docx,
.xls,
.xlsx,
.ppt,
.pptx,
.txt
"
onClick={(e) => {
(e.target as HTMLInputElement).value = "";
}}
onChange={handleFileChange}
/>
</label>
<div className="flex flex-col">
<p className="text-[14px] font-medium text-[#2f3643]">Upload file</p>
<p className="text-[12px] font-medium text-[#99a2b3]">
JPG, PNG, PDF, Word, Excel, PPT ( 50MB)
</p>
</div>
</div>
</div>
);
}
@@ -1 +0,0 @@
export { default } from "./UploadMultipleFile";
@@ -1,15 +0,0 @@
export interface UploadFileItem {
file?: File | null;
url?: string;
path?: string;
img?: string;
fileName?: string;
fileType?: string;
}
export interface PropsUploadMultipleFile {
images: UploadFileItem[];
setImages: React.Dispatch<React.SetStateAction<UploadFileItem[]>>;
isDisableDelete?: boolean;
}
+52 -60
View File
@@ -1,22 +1,14 @@
import { // import {
ChartNoAxesCombined, // ChartNoAxesCombined,
CircleGauge, // CircleGauge,
HeartPulse, // HeartPulse,
Pill, // Pill,
Users, // Users,
} from "lucide-react"; // } from "lucide-react";
import { TYPE_DATE } from "./enum"; import { TYPE_DATE } from "./enum";
export enum PATH { export enum PATH {
HOME = "/", HOME = "/",
DASHBOARD = "/dashboard",
PATIENT = "/patient",
CONSULTATION = "/consultation",
PRESCRIPTION = "/prescription",
CREATE_PRESCRIPTION = "/prescription/create",
STATISTICAL = "/statistical",
LOGIN = "/auth/login", LOGIN = "/auth/login",
REGISTER = "/auth/register", REGISTER = "/auth/register",
FORGOT_PASSWORD = "/auth/forgot-password", FORGOT_PASSWORD = "/auth/forgot-password",
@@ -25,51 +17,51 @@ export enum PATH {
UPDATEPROFILE = "/profile/update", UPDATEPROFILE = "/profile/update",
} }
export const Menus: { // export const Menus: {
title?: string; // title?: string;
group: { // group: {
path: string; // path: string;
pathActive: string; // pathActive: string;
// isSpecial?: TYPE_SPECIAL; // // isSpecial?: TYPE_SPECIAL;
title: string; // title: string;
icon: React.ElementType; // icon: React.ElementType;
}[]; // }[];
}[] = [ // }[] = [
{ // {
group: [ // group: [
{ // {
title: "Dashboard", // title: "Dashboard",
icon: CircleGauge, // icon: CircleGauge,
path: PATH.DASHBOARD || PATH.HOME, // path: PATH.DASHBOARD || PATH.HOME,
pathActive: PATH.DASHBOARD, // pathActive: PATH.DASHBOARD,
}, // },
{ // {
title: "Bệnh nhân", // title: "Bệnh nhân",
icon: Users, // icon: Users,
path: PATH.PATIENT, // path: PATH.PATIENT,
pathActive: PATH.PATIENT, // pathActive: PATH.PATIENT,
}, // },
{ // {
title: "Phiên khám", // title: "Phiên khám",
icon: HeartPulse, // icon: HeartPulse,
path: PATH.CONSULTATION, // path: PATH.CONSULTATION,
pathActive: PATH.CONSULTATION, // pathActive: PATH.CONSULTATION,
}, // },
{ // {
title: "Đơn thuốc", // title: "Đơn thuốc",
icon: Pill, // icon: Pill,
path: PATH.PRESCRIPTION, // path: PATH.PRESCRIPTION,
pathActive: PATH.PRESCRIPTION, // pathActive: PATH.PRESCRIPTION,
}, // },
{ // {
title: "Thống kê chuyên khoa", // title: "Thống kê chuyên khoa",
icon: ChartNoAxesCombined, // icon: ChartNoAxesCombined,
path: PATH.STATISTICAL, // path: PATH.STATISTICAL,
pathActive: PATH.STATISTICAL, // pathActive: PATH.STATISTICAL,
}, // },
], // ],
}, // },
]; // ];
export const ListOptionFilterDate: { export const ListOptionFilterDate: {
name: string; name: string;