feat update
This commit is contained in:
@@ -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 lý 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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user