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 {
|
||||
ChartNoAxesCombined,
|
||||
CircleGauge,
|
||||
HeartPulse,
|
||||
Pill,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
// import {
|
||||
// ChartNoAxesCombined,
|
||||
// CircleGauge,
|
||||
// HeartPulse,
|
||||
// Pill,
|
||||
// Users,
|
||||
// } from "lucide-react";
|
||||
import { TYPE_DATE } from "./enum";
|
||||
|
||||
export enum PATH {
|
||||
HOME = "/",
|
||||
DASHBOARD = "/dashboard",
|
||||
PATIENT = "/patient",
|
||||
CONSULTATION = "/consultation",
|
||||
PRESCRIPTION = "/prescription",
|
||||
CREATE_PRESCRIPTION = "/prescription/create",
|
||||
|
||||
STATISTICAL = "/statistical",
|
||||
|
||||
LOGIN = "/auth/login",
|
||||
REGISTER = "/auth/register",
|
||||
FORGOT_PASSWORD = "/auth/forgot-password",
|
||||
@@ -25,51 +17,51 @@ export enum PATH {
|
||||
UPDATEPROFILE = "/profile/update",
|
||||
}
|
||||
|
||||
export const Menus: {
|
||||
title?: string;
|
||||
group: {
|
||||
path: string;
|
||||
pathActive: string;
|
||||
// isSpecial?: TYPE_SPECIAL;
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
}[];
|
||||
}[] = [
|
||||
{
|
||||
group: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: CircleGauge,
|
||||
path: PATH.DASHBOARD || PATH.HOME,
|
||||
pathActive: PATH.DASHBOARD,
|
||||
},
|
||||
{
|
||||
title: "Bệnh nhân",
|
||||
icon: Users,
|
||||
path: PATH.PATIENT,
|
||||
pathActive: PATH.PATIENT,
|
||||
},
|
||||
{
|
||||
title: "Phiên khám",
|
||||
icon: HeartPulse,
|
||||
path: PATH.CONSULTATION,
|
||||
pathActive: PATH.CONSULTATION,
|
||||
},
|
||||
{
|
||||
title: "Đơn thuốc",
|
||||
icon: Pill,
|
||||
path: PATH.PRESCRIPTION,
|
||||
pathActive: PATH.PRESCRIPTION,
|
||||
},
|
||||
{
|
||||
title: "Thống kê chuyên khoa",
|
||||
icon: ChartNoAxesCombined,
|
||||
path: PATH.STATISTICAL,
|
||||
pathActive: PATH.STATISTICAL,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// export const Menus: {
|
||||
// title?: string;
|
||||
// group: {
|
||||
// path: string;
|
||||
// pathActive: string;
|
||||
// // isSpecial?: TYPE_SPECIAL;
|
||||
// title: string;
|
||||
// icon: React.ElementType;
|
||||
// }[];
|
||||
// }[] = [
|
||||
// {
|
||||
// group: [
|
||||
// {
|
||||
// title: "Dashboard",
|
||||
// icon: CircleGauge,
|
||||
// path: PATH.DASHBOARD || PATH.HOME,
|
||||
// pathActive: PATH.DASHBOARD,
|
||||
// },
|
||||
// {
|
||||
// title: "Bệnh nhân",
|
||||
// icon: Users,
|
||||
// path: PATH.PATIENT,
|
||||
// pathActive: PATH.PATIENT,
|
||||
// },
|
||||
// {
|
||||
// title: "Phiên khám",
|
||||
// icon: HeartPulse,
|
||||
// path: PATH.CONSULTATION,
|
||||
// pathActive: PATH.CONSULTATION,
|
||||
// },
|
||||
// {
|
||||
// title: "Đơn thuốc",
|
||||
// icon: Pill,
|
||||
// path: PATH.PRESCRIPTION,
|
||||
// pathActive: PATH.PRESCRIPTION,
|
||||
// },
|
||||
// {
|
||||
// title: "Thống kê chuyên khoa",
|
||||
// icon: ChartNoAxesCombined,
|
||||
// path: PATH.STATISTICAL,
|
||||
// pathActive: PATH.STATISTICAL,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ];
|
||||
|
||||
export const ListOptionFilterDate: {
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user