first commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import LayoutAuth from "@/components/layouts/LayoutAuth";
|
||||
import MainForgotPassword from "@/components/page/auth/MainForgotPassword/MainForgotPassword";
|
||||
import React from "react";
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<LayoutAuth>
|
||||
<MainForgotPassword />
|
||||
</LayoutAuth>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import LayoutAuth from "@/components/layouts/LayoutAuth";
|
||||
import MainLogin from "@/components/page/auth/MainLogin";
|
||||
import React from "react";
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<LayoutAuth>
|
||||
<MainLogin />
|
||||
</LayoutAuth>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
import BaseLayout from "@/components/layouts/BaseLayout";
|
||||
import React from "react";
|
||||
|
||||
const page = () => {
|
||||
return <BaseLayout title="Phiên khám">Phiên khám</BaseLayout>;
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout";
|
||||
import MainPageHome from "@/components/page/Home/MainPageHome";
|
||||
import React from "react";
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<BaseLayout title="Dashboard">
|
||||
<MainPageHome />
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,198 @@
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== JODIT CUSTOM UI ===== */
|
||||
|
||||
.editor-wrapper .jodit-container {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-workplace {
|
||||
background: transparent !important;
|
||||
min-height: 120px !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-wysiwyg {
|
||||
padding: 18px 20px !important;
|
||||
color: #111827 !important;
|
||||
font-size: 15px !important;
|
||||
line-height: 1.7 !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-toolbar__box {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 14px 14px 10px 14px !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-toolbar-button {
|
||||
width: 34px !important;
|
||||
height: 34px !important;
|
||||
border-radius: 10px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-toolbar-button:hover {
|
||||
background: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-toolbar-button__button {
|
||||
min-width: 34px !important;
|
||||
min-height: 34px !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-status-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-add-new-line {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-ui-group {
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-icon {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-toolbar-button_active {
|
||||
background: #dbeafe !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-toolbar-button_active svg {
|
||||
fill: #2563eb !important;
|
||||
}
|
||||
|
||||
.editor-wrapper .jodit-wysiwyg:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import Providers from "@/contexts/AppProvider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
// <html
|
||||
// lang="en"
|
||||
// className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
// >
|
||||
// <body className="min-h-full flex flex-col">{children}</body>
|
||||
// </html>
|
||||
<html lang="vi" suppressHydrationWarning>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout";
|
||||
import MainPageHome from "@/components/page/Home/MainPageHome/MainPageHome";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<BaseLayout title="Dashboard">
|
||||
<MainPageHome />
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import BaseLayout from "@/components/layouts/BaseLayout";
|
||||
import React from "react";
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<BaseLayout title="Bệnh nhân">Bệnh nhân</BaseLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
import BaseLayout from "@/components/layouts/BaseLayout";
|
||||
import React from "react";
|
||||
|
||||
const page = () => {
|
||||
return <BaseLayout title="Đơn thuốc">Đơn thuốc</BaseLayout>;
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -0,0 +1,14 @@
|
||||
// Hàm chuyển đổi số coin thành chuỗi định dạng tiền tệ
|
||||
export const convertCoin = (coin: number | null) => {
|
||||
return coin
|
||||
? coin.toLocaleString("de-DE", { maximumFractionDigits: 2 })
|
||||
: "0";
|
||||
};
|
||||
|
||||
// Hàm chuyển đổi chuỗi hoặc số thành giá trị số
|
||||
export const price = (value: string | number): number => {
|
||||
const numericValue = Number(
|
||||
`${value}`.replace(/[^0-9,]/g, "").replace(",", "."),
|
||||
);
|
||||
return isNaN(numericValue) ? 0 : numericValue;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function delay(duration: number) {
|
||||
return await new Promise((resolve) => setTimeout(resolve, duration));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function getItemStorage(key: string) {
|
||||
const value = localStorage.getItem(key)!;
|
||||
if (value !== "undefined") {
|
||||
return JSON.parse(value);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setItemStorage(key: string, value: any) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function deleteItemStorage(...keys: Array<any>) {
|
||||
keys.forEach((key) => {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import md5 from "md5";
|
||||
import moment from "moment";
|
||||
|
||||
export function removeVietnameseTones(str: string): string {
|
||||
if (!str || typeof str !== "string") {
|
||||
return "";
|
||||
}
|
||||
return str
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/đ/g, "d")
|
||||
.replace(/Đ/g, "D");
|
||||
}
|
||||
|
||||
export function convertWeight(weight: number | null) {
|
||||
if (!weight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (weight / 1000).toLocaleString("vi-VN", {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
export function obfuscateEmail(email: string) {
|
||||
// Tách phần trước @ và phần tên miền
|
||||
const [username, domain] = email.split("@");
|
||||
|
||||
// Giữ lại ký tự đầu tiên và cuối cùng của tên người dùng
|
||||
const firstChar = username[0];
|
||||
const lastChar = username[username.length - 1];
|
||||
|
||||
// Tạo phần che giấu giữa
|
||||
const middleHidden = "...";
|
||||
|
||||
// Tạo tên người dùng mới với phần che giấu
|
||||
const newUsername = firstChar + middleHidden + lastChar;
|
||||
|
||||
// Kết hợp với tên miền để tạo email đã che giấu
|
||||
const obfuscatedEmail = newUsername + "@" + domain;
|
||||
|
||||
return obfuscatedEmail;
|
||||
}
|
||||
|
||||
export default function fancyTimeFormat(duration: number) {
|
||||
// Hours, minutes and seconds
|
||||
var hrs = ~~(duration / 3600);
|
||||
var mins = ~~((duration % 3600) / 60);
|
||||
var secs = ~~duration % 60;
|
||||
|
||||
// Output like "1:01" or "4:03:59" or "123:03:59"
|
||||
var ret = "";
|
||||
|
||||
if (hrs > 0) {
|
||||
ret += "" + hrs + ":" + (mins < 10 ? "0" : "");
|
||||
}
|
||||
|
||||
ret += "" + mins + ":" + (secs < 10 ? "0" : "");
|
||||
ret += "" + secs;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function getKeyCert(): {
|
||||
time: string;
|
||||
keyCert: string;
|
||||
} {
|
||||
const key: string = process.env.NEXT_PUBLIC_KEY_CERT!;
|
||||
const time = moment(new Date()).format("MM/DD/YYYY HH:mm:ss");
|
||||
return {
|
||||
time: time,
|
||||
keyCert: md5(`${key}${time}`),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTextAddress(detailAddress: any, address?: string): string {
|
||||
if (
|
||||
!detailAddress?.province &&
|
||||
!detailAddress?.district &&
|
||||
!detailAddress?.town &&
|
||||
!address
|
||||
) {
|
||||
return "---";
|
||||
}
|
||||
|
||||
const parts = [
|
||||
address,
|
||||
detailAddress?.town?.name,
|
||||
detailAddress?.district?.name,
|
||||
detailAddress?.province?.name,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length ? parts.join(", ") : "---";
|
||||
}
|
||||
|
||||
export function numberToWords(number: number) {
|
||||
if (typeof number !== "number" || isNaN(number)) {
|
||||
return "Không hợp lệ";
|
||||
}
|
||||
|
||||
if (number === 0) {
|
||||
return "Không đồng";
|
||||
}
|
||||
|
||||
const units = [
|
||||
"",
|
||||
"một",
|
||||
"hai",
|
||||
"ba",
|
||||
"bốn",
|
||||
"năm",
|
||||
"sáu",
|
||||
"bảy",
|
||||
"tám",
|
||||
"chín",
|
||||
];
|
||||
const levels = ["", "nghìn", "triệu", "tỷ", "nghìn tỷ", "triệu tỷ", "tỷ tỷ"];
|
||||
|
||||
function readThreeDigits(num: number) {
|
||||
let str = "";
|
||||
const hundred = Math.floor(num / 100);
|
||||
const ten = Math.floor((num % 100) / 10);
|
||||
const unit = num % 10;
|
||||
|
||||
if (hundred > 0) {
|
||||
str += units[hundred] + " trăm";
|
||||
if (ten === 0 && unit > 0) str += " linh";
|
||||
}
|
||||
|
||||
if (ten > 1) {
|
||||
str += " " + units[ten] + " mươi";
|
||||
if (unit === 1) str += " mốt";
|
||||
else if (unit === 5) str += " lăm";
|
||||
else if (unit > 0) str += " " + units[unit];
|
||||
} else if (ten === 1) {
|
||||
str += " mười";
|
||||
if (unit > 0) str += " " + units[unit];
|
||||
} else if (unit > 0) {
|
||||
str += " " + units[unit];
|
||||
}
|
||||
|
||||
return str.trim();
|
||||
}
|
||||
|
||||
function splitIntoChunks(num: number) {
|
||||
const chunks = [];
|
||||
while (num > 0) {
|
||||
chunks.push(num % 1000);
|
||||
num = Math.floor(num / 1000);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const chunks = splitIntoChunks(number);
|
||||
let result = "";
|
||||
|
||||
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||
if (chunks[i] > 0) {
|
||||
result += readThreeDigits(chunks[i]) + " " + levels[i] + " ";
|
||||
}
|
||||
}
|
||||
|
||||
result = result.trim().replace(/\s+/g, " ");
|
||||
result = result.charAt(0).toUpperCase() + result.slice(1) + " đồng";
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { TYPE_DATE } from "@/constant/config/enum";
|
||||
|
||||
export function getDateRange(range: number): {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
} {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
switch (range) {
|
||||
case TYPE_DATE.ALL:
|
||||
return { from: null, to: null };
|
||||
|
||||
// Tuần này
|
||||
case TYPE_DATE.TODAY:
|
||||
return { from: new Date(), to: new Date() };
|
||||
|
||||
// Hôm qua
|
||||
case TYPE_DATE.YESTERDAY:
|
||||
const firstYesterday = new Date();
|
||||
firstYesterday.setDate(today.getDate() - 1);
|
||||
|
||||
const lastYesterday = new Date();
|
||||
|
||||
lastYesterday.setDate(today.getDate() - 1);
|
||||
|
||||
return {
|
||||
from: firstYesterday,
|
||||
to: lastYesterday,
|
||||
};
|
||||
|
||||
// Tuần này
|
||||
case TYPE_DATE.THIS_WEEK:
|
||||
const firstDayOfWeek = new Date(today);
|
||||
firstDayOfWeek.setDate(today.getDate() - today.getDay());
|
||||
|
||||
const lastDayOfWeek = new Date(today);
|
||||
lastDayOfWeek.setDate(today.getDate() - today.getDay() + 6);
|
||||
|
||||
return { from: firstDayOfWeek, to: lastDayOfWeek };
|
||||
|
||||
// Tuần trước
|
||||
case TYPE_DATE.LAST_WEEK:
|
||||
const firstDayLastWeek = new Date(today);
|
||||
firstDayLastWeek.setDate(today.getDate() - today.getDay() - 7);
|
||||
|
||||
const lastDayLastWeek = new Date(today);
|
||||
lastDayLastWeek.setDate(today.getDate() - today.getDay() - 1);
|
||||
|
||||
return { from: firstDayLastWeek, to: lastDayLastWeek };
|
||||
|
||||
// Tháng này
|
||||
case TYPE_DATE.THIS_MONTH:
|
||||
const firstDayOfMonth = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
1,
|
||||
);
|
||||
const lastDayOfMonth = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth() + 1,
|
||||
0,
|
||||
);
|
||||
|
||||
return { from: firstDayOfMonth, to: lastDayOfMonth };
|
||||
|
||||
// Tháng trước
|
||||
case TYPE_DATE.LAST_MONTH:
|
||||
const firstDayLastMonth = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth() - 1,
|
||||
1,
|
||||
);
|
||||
const lastDayLastMonth = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
0,
|
||||
);
|
||||
|
||||
return { from: firstDayLastMonth, to: lastDayLastMonth };
|
||||
|
||||
// Năm nay
|
||||
case TYPE_DATE.THIS_YEAR:
|
||||
const firstDayOfYear = new Date(today.getFullYear(), 0, 1);
|
||||
const lastDayOfYear = new Date(today.getFullYear(), 11, 31);
|
||||
|
||||
return { from: firstDayOfYear, to: lastDayOfYear };
|
||||
|
||||
// 7 ngày trước
|
||||
case TYPE_DATE.LAST_7_DAYS:
|
||||
const firstDayLast7Days = new Date(today);
|
||||
firstDayLast7Days.setDate(today.getDate() - 7);
|
||||
|
||||
return { from: firstDayLast7Days, to: today };
|
||||
|
||||
default:
|
||||
return { from: null, to: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { toast } from "react-toastify";
|
||||
import IconToastifyCustom from "@/components/customs/custom-toast";
|
||||
|
||||
export const toastText = ({ msg }: { msg: string }) =>
|
||||
toast.info(msg, {
|
||||
position: "top-center",
|
||||
autoClose: 2000,
|
||||
hideProgressBar: true,
|
||||
closeButton: false,
|
||||
className: "toastify-custom",
|
||||
icon: IconToastifyCustom({ type: "info" }),
|
||||
});
|
||||
|
||||
export const toastSuccess = ({ msg }: { msg: string }) =>
|
||||
toast.success(msg, {
|
||||
autoClose: 2000,
|
||||
hideProgressBar: true,
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
className: "toastify-custom-success",
|
||||
icon: IconToastifyCustom({ type: "success" }),
|
||||
});
|
||||
|
||||
export const toastInfo = ({ msg }: { msg: string }) =>
|
||||
toast.info(msg, {
|
||||
autoClose: 2000,
|
||||
hideProgressBar: true,
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
className: "toastify-custom-info",
|
||||
icon: IconToastifyCustom({ type: "info" }),
|
||||
});
|
||||
|
||||
export const toastWarn = ({ msg }: { msg: string }) =>
|
||||
toast.warning(msg, {
|
||||
autoClose: 2000,
|
||||
hideProgressBar: true,
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
className: "toastify-custom-warn",
|
||||
icon: IconToastifyCustom({ type: "warn" }),
|
||||
});
|
||||
|
||||
export const toastError = ({ msg }: { msg: string }) =>
|
||||
toast.error(msg, {
|
||||
autoClose: 2000,
|
||||
hideProgressBar: true,
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
className: "toastify-custom-error",
|
||||
icon: IconToastifyCustom({ type: "error" }),
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export function validateEmail(value: string) {
|
||||
return !!value
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.match(
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
||||
);
|
||||
}
|
||||
|
||||
export function validatePhoneNumber(phoneNumber: string) {
|
||||
const vietnamPhoneRegex = /^(03|05|07|08|09)[0-9]{8}$/;
|
||||
|
||||
return vietnamPhoneRegex.test(phoneNumber);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const useStyleClass = (props: any, style: any) => {
|
||||
let styleProps: Array<any> = [];
|
||||
|
||||
for (let i in props) {
|
||||
styleProps.push(i);
|
||||
}
|
||||
|
||||
const convert: any = styleProps.map((item) => {
|
||||
if (style[item] !== undefined) {
|
||||
return { [style[item]]: !!props[item] };
|
||||
}
|
||||
});
|
||||
return convert;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
import icons from "@/constant/images/icons";
|
||||
|
||||
interface PropsDataWrapper {
|
||||
title?: string;
|
||||
note?: string;
|
||||
loading: boolean;
|
||||
data: unknown[];
|
||||
children: ReactNode;
|
||||
button?: ReactNode;
|
||||
}
|
||||
|
||||
function DataWrapper({
|
||||
title = "Dữ liệu trống",
|
||||
note = "Hiện tại dữ liệu đang trống!",
|
||||
data,
|
||||
loading,
|
||||
button,
|
||||
children,
|
||||
}: PropsDataWrapper) {
|
||||
// ================= LOADING =================
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-[55vh] flex flex-col items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
|
||||
<h3 className="text-2xl text-[#303b48] mt-6">Đang tải...</h3>
|
||||
<p className="text-sm text-[#515c69] mt-1">
|
||||
Vui lòng chờ, đang tải dữ liệu!
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ================= EMPTY =================
|
||||
if (!loading && (!data || data.length === 0)) {
|
||||
return (
|
||||
<div className="min-h-[55vh] flex flex-col items-center justify-center text-center">
|
||||
<Image
|
||||
src={icons.emptyTable}
|
||||
alt="empty-data"
|
||||
width={180}
|
||||
height={180}
|
||||
priority
|
||||
/>
|
||||
|
||||
<h3 className="text-2xl text-[#303b48] mt-6">{title}</h3>
|
||||
|
||||
<p className="text-sm text-[#515c69] mt-1">{note}</p>
|
||||
|
||||
{button && <div className="mt-5">{button}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ================= HAS DATA =================
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default DataWrapper;
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
interface StateItem {
|
||||
state: number | string;
|
||||
text: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
interface PropsStateActive {
|
||||
isBox?: boolean;
|
||||
stateActive: number | string;
|
||||
listState: StateItem[];
|
||||
}
|
||||
|
||||
function StateActive({
|
||||
isBox = true,
|
||||
stateActive,
|
||||
listState,
|
||||
}: PropsStateActive) {
|
||||
// memo trạng thái hiện tại
|
||||
const current = useMemo(() => {
|
||||
return listState.find((item) => item.state === stateActive);
|
||||
}, [stateActive, listState]);
|
||||
|
||||
if (isBox) {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
w-fit rounded-full text-sm font-normal
|
||||
"
|
||||
style={{
|
||||
color: current?.textColor ?? "#202939",
|
||||
backgroundColor: current?.backgroundColor,
|
||||
padding: current?.backgroundColor ? "6px 12px" : undefined,
|
||||
}}
|
||||
>
|
||||
{current?.text ?? "---"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 w-fit">
|
||||
{/* DOT */}
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: current?.backgroundColor }}
|
||||
/>
|
||||
|
||||
{/* TEXT */}
|
||||
<p
|
||||
className="text-sm font-medium"
|
||||
style={{ color: current?.textColor ?? "#23262f" }}
|
||||
>
|
||||
{current?.text ?? "---"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StateActive;
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
|
||||
export interface PropsButton {
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
children?: React.ReactNode;
|
||||
href?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
target?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
variant?:
|
||||
| "default"
|
||||
| "midnightBlue"
|
||||
| "green"
|
||||
| "red"
|
||||
| "grey"
|
||||
| "white"
|
||||
| "black"
|
||||
| "warning";
|
||||
|
||||
size?: "sm" | "md" | "lg";
|
||||
rounded?: "sm" | "md" | "lg" | "full";
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export default function CustomButton({
|
||||
children,
|
||||
onClick,
|
||||
href,
|
||||
icon,
|
||||
className,
|
||||
target,
|
||||
disabled = false,
|
||||
variant = "default",
|
||||
size = "md",
|
||||
rounded = "md",
|
||||
fullWidth = true,
|
||||
}: PropsButton) {
|
||||
// 🎨 Variant
|
||||
const variantClasses: Record<string, string> = {
|
||||
default: "bg-gray-100 text-gray-700 border border-gray-200 cursor-pointer",
|
||||
midnightBlue:
|
||||
"bg-blue-900 text-white border border-blue-900 cursor-pointer",
|
||||
green: "bg-green-500 text-white border border-green-500 cursor-pointer",
|
||||
red: "bg-red-500 text-white border border-red-500 cursor-pointer",
|
||||
grey: "bg-white text-gray-700 border border-gray-200 cursor-pointer",
|
||||
white:
|
||||
"bg-white text-gray-700 border border-gray-200 shadow-sm cursor-pointer",
|
||||
black: "bg-gray-800 text-white border border-gray-800 cursor-pointer",
|
||||
warning: "bg-orange-400 text-white border border-orange-400 cursor-pointer",
|
||||
};
|
||||
|
||||
// 📏 Size
|
||||
const sizeClasses = {
|
||||
sm: "px-3 py-1 text-sm",
|
||||
md: "px-5 py-2 text-sm",
|
||||
lg: "px-6 py-3 text-base",
|
||||
};
|
||||
|
||||
// 🔵 Rounded
|
||||
const roundedClasses = {
|
||||
sm: "rounded-md",
|
||||
md: "rounded-xl",
|
||||
lg: "rounded-2xl",
|
||||
full: "rounded-full",
|
||||
};
|
||||
|
||||
const baseClass = clsx(
|
||||
"inline-flex items-center justify-center gap-2",
|
||||
"transition-all duration-200 select-none",
|
||||
"active:scale-[0.98] hover:opacity-90",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
roundedClasses[rounded],
|
||||
fullWidth ? "w-full" : "w-fit",
|
||||
disabled && "opacity-40 pointer-events-none cursor-not-allowed",
|
||||
className,
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon && <span className="flex items-center">{icon}</span>}
|
||||
<span>{children}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
// 🔗 Link mode
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target={target}
|
||||
className={baseClass}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : undefined}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 🔘 Button mode
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={baseClass}
|
||||
disabled={disabled}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import IconRound, { IconRoundType } from "../utils/IconRound";
|
||||
import CustomPopup from "./custom-popup";
|
||||
import CustomButton from "./custom-button";
|
||||
|
||||
/* ================== TYPES ================== */
|
||||
export interface PropsDialog {
|
||||
open: boolean;
|
||||
title: React.ReactNode;
|
||||
note?: React.ReactNode;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
titleCancel?: string;
|
||||
titleSubmit?: string;
|
||||
type?: IconRoundType;
|
||||
}
|
||||
|
||||
/* ================== COMPONENT ================== */
|
||||
export default function CustomDialog({
|
||||
open,
|
||||
title,
|
||||
note,
|
||||
titleCancel = "Hủy bỏ",
|
||||
titleSubmit = "Xác nhận",
|
||||
onClose,
|
||||
onSubmit,
|
||||
type = "success",
|
||||
}: PropsDialog) {
|
||||
return (
|
||||
<CustomPopup open={open} onClose={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="
|
||||
relative w-[440px] max-w-[90vw]
|
||||
rounded-2xl
|
||||
bg-white shadow-lg
|
||||
px-6 py-7
|
||||
flex flex-col items-center text-center
|
||||
"
|
||||
>
|
||||
{/* ICON */}
|
||||
<IconRound type={type} />
|
||||
|
||||
{/* TITLE */}
|
||||
<h4 className="mt-4 text-xl font-semibold text-gray-800">{title}</h4>
|
||||
|
||||
{/* NOTE */}
|
||||
{note && <p className="mt-2 text-sm text-gray-500">{note}</p>}
|
||||
|
||||
{/* ACTIONS */}
|
||||
<div className="w-full flex items-center gap-3 mt-6">
|
||||
<CustomButton onClick={onClose} variant="grey" rounded="full">
|
||||
{titleCancel}
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
onClick={onSubmit}
|
||||
variant="midnightBlue"
|
||||
rounded="full"
|
||||
>
|
||||
{titleSubmit}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</CustomPopup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useMemo, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
lenght?: number;
|
||||
onSetValue?: (value: any) => void;
|
||||
name?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function InputSingle({
|
||||
className,
|
||||
lenght = 6,
|
||||
onSetValue,
|
||||
name = "otp_code",
|
||||
}: Props) {
|
||||
const inputsRef = useRef<HTMLInputElement[]>([]);
|
||||
|
||||
// update value
|
||||
const updateOTPValue = () => {
|
||||
const code = inputsRef.current.map((input) => input?.value || "");
|
||||
|
||||
onSetValue?.((prev: any) => ({
|
||||
...prev,
|
||||
[name]: code.join(""),
|
||||
}));
|
||||
};
|
||||
|
||||
// input change
|
||||
const handleChange = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const target = e.currentTarget;
|
||||
|
||||
const index = Number(target.dataset.index);
|
||||
|
||||
let value = target.value;
|
||||
|
||||
if (value.length > 1) {
|
||||
value = value.slice(0, 1);
|
||||
|
||||
target.value = value;
|
||||
}
|
||||
|
||||
updateOTPValue();
|
||||
|
||||
if (value) {
|
||||
inputsRef.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// backspace
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const index = Number(e.currentTarget.dataset.index);
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
if (!e.currentTarget.value) {
|
||||
inputsRef.current[index - 1]?.focus();
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updateOTPValue();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// set refs
|
||||
const setRef = (el: HTMLInputElement | null, index: number) => {
|
||||
if (el) {
|
||||
inputsRef.current[index] = el;
|
||||
}
|
||||
};
|
||||
|
||||
const renderInputs = useMemo(() => {
|
||||
const list: ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < lenght; i++) {
|
||||
list.push(
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => setRef(el, i)}
|
||||
data-index={i}
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
onInput={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={clsx(
|
||||
"h-[72px] w-[56px]",
|
||||
"rounded-xl border-2 border-[#dae1ea]",
|
||||
"text-center text-[42px] font-medium",
|
||||
"outline-none transition",
|
||||
"focus:border-[#066bb1]",
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [lenght]);
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-center gap-3", className)}>
|
||||
{renderInputs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import CustomPortal from "./custom-portal";
|
||||
|
||||
interface PropsLoading {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const SCALE = 2.5;
|
||||
|
||||
export default function CustomLoading({ loading }: PropsLoading) {
|
||||
if (!loading) return null;
|
||||
|
||||
return (
|
||||
<CustomPortal>
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black/60 z-[10002]">
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: 40 * SCALE,
|
||||
height: 40 * SCALE,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const rotate = i * 30;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
transformOrigin: `${20 * SCALE}px ${20 * SCALE}px`,
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
animation: "spinnerFade 1.2s linear infinite",
|
||||
animationDelay: `${-(1.1 - i * 0.1)}s`,
|
||||
}}
|
||||
className="absolute top-0 left-0 w-full h-full"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
top: 1.5 * SCALE,
|
||||
left: 18.5 * SCALE,
|
||||
width: 3 * SCALE,
|
||||
height: 9 * SCALE,
|
||||
}}
|
||||
className="absolute bg-white rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CustomPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
export interface PropsPagination {
|
||||
total: number;
|
||||
page: number;
|
||||
onSetPage: (page: number) => void;
|
||||
pageSize: number;
|
||||
onSetPageSize: (pageSize: number) => void;
|
||||
dependencies?: any[];
|
||||
}
|
||||
|
||||
// Wrapper component to forward ref for the dropdown trigger
|
||||
const TriggerButton = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
onClick: () => void;
|
||||
className: string;
|
||||
pageSize: number;
|
||||
openLimit: boolean;
|
||||
}
|
||||
>(({ onClick, className, pageSize, openLimit }, ref) => (
|
||||
<div ref={ref} onClick={onClick} className={className}>
|
||||
<span className="text-sm font-medium">{pageSize}</span>
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
"text-gray-500 transition-transform",
|
||||
openLimit && "rotate-180 text-[#0011ab]",
|
||||
)}
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
TriggerButton.displayName = "TriggerButton";
|
||||
|
||||
export default function Pagination({
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onSetPage,
|
||||
onSetPageSize,
|
||||
dependencies = [],
|
||||
}: PropsPagination) {
|
||||
const pageSizes = [50, 100, 200];
|
||||
|
||||
const [openLimit, setOpenLimit] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const maxPage = Math.ceil(total / Number(pageSize));
|
||||
|
||||
const items = useMemo(() => {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 1; i <= maxPage; i++) {
|
||||
const isCurrent = page === i;
|
||||
const isNear =
|
||||
i === page ||
|
||||
i === page - 1 ||
|
||||
i === page + 1 ||
|
||||
i === 1 ||
|
||||
i === maxPage;
|
||||
|
||||
if (isNear) {
|
||||
nodes.push(
|
||||
<li
|
||||
key={i}
|
||||
onClick={() => onSetPage(i)}
|
||||
className={clsx(
|
||||
"h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold border rounded cursor-pointer transition",
|
||||
isCurrent
|
||||
? "bg-[#0011ab] text-white border-[#0011ab] pointer-events-none"
|
||||
: "bg-white text-black border-[#efefef] hover:opacity-70",
|
||||
)}
|
||||
>
|
||||
{i}
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
|
||||
if ((i === page - 2 && page >= 4) || (i === page + 2 && i < maxPage)) {
|
||||
nodes.push(
|
||||
<li
|
||||
key={`dot-${i}`}
|
||||
className="h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold"
|
||||
>
|
||||
...
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}, [maxPage, page, onSetPage]);
|
||||
|
||||
const handlePrev = () => {
|
||||
if (page > 1) onSetPage(page - 1);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (page < maxPage) onSetPage(page + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (dependencies.length > 0) {
|
||||
onSetPage(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
openLimit &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpenLimit(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [openLimit]);
|
||||
|
||||
if (total <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap-reverse items-center justify-between gap-2">
|
||||
{/* LEFT */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<p>Hiển thị</p>
|
||||
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<TriggerButton
|
||||
onClick={() => setOpenLimit(!openLimit)}
|
||||
className={clsx(
|
||||
"w-[80px] flex items-center justify-between px-3 py-1 border rounded-full cursor-pointer bg-white",
|
||||
"border-gray-300",
|
||||
)}
|
||||
pageSize={pageSize}
|
||||
openLimit={openLimit}
|
||||
/>
|
||||
|
||||
{openLimit && (
|
||||
<div className="absolute right-0 z-10 w-[80px] border border-gray-300 bg-white rounded p-1 mt-1 shadow-lg">
|
||||
{pageSizes.map((v) => (
|
||||
<div
|
||||
key={v}
|
||||
onClick={() => {
|
||||
onSetPageSize(v);
|
||||
setOpenLimit(false);
|
||||
}}
|
||||
className={clsx(
|
||||
"px-2 py-1 text-[13px] cursor-pointer rounded",
|
||||
"hover:bg-[#0011ab] hover:text-white",
|
||||
pageSize === v && "bg-[#0011ab] text-white",
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
trong tổng <span className="font-semibold">{total}</span> kết quả
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* PAGINATION */}
|
||||
<div className="flex items-center justify-center">
|
||||
{page > 1 && (
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="h-[30px] w-[30px] flex items-center justify-center border rounded text-[#0011ab] hover:bg-[#0011ab] hover:text-white transition mr-2"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ul className="flex items-center gap-2">{items}</ul>
|
||||
|
||||
{page < maxPage && (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="h-[30px] w-[30px] flex items-center justify-center border rounded text-[#0011ab] hover:bg-[#0011ab] hover:text-white transition ml-2"
|
||||
>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
|
||||
/* ================== TYPES ================== */
|
||||
interface Props {
|
||||
open: boolean;
|
||||
isFull?: boolean;
|
||||
onClose: () => void;
|
||||
children?: ReactNode;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
/* ================== COMPONENT ================== */
|
||||
export default function CustomPopup({
|
||||
open,
|
||||
onClose,
|
||||
showOverlay = true,
|
||||
isFull,
|
||||
children,
|
||||
}: Props) {
|
||||
// lock body scroll
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const original = document.body.style.overflow;
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = original || "auto";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// SSR safe
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
// popup closed
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* OVERLAY */}
|
||||
{showOverlay && (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="
|
||||
fixed
|
||||
inset-0
|
||||
z-[10000]
|
||||
bg-[#141416]/50
|
||||
animate-[fadeIn_0.2s_ease-in]
|
||||
"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CONTENT */}
|
||||
<div
|
||||
className={clsx(
|
||||
`
|
||||
fixed
|
||||
z-[10001]
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
top-1/2
|
||||
left-1/2
|
||||
-translate-x-1/2
|
||||
-translate-y-1/2
|
||||
`,
|
||||
isFull &&
|
||||
`
|
||||
max-[768px]:top-0
|
||||
max-[768px]:left-0
|
||||
max-[768px]:right-0
|
||||
max-[768px]:bottom-0
|
||||
max-[768px]:translate-x-0
|
||||
max-[768px]:translate-y-0
|
||||
`,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useLayoutEffect, useMemo } from "react";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
export default function CustomPortal({
|
||||
children,
|
||||
className,
|
||||
containerId,
|
||||
}: Props) {
|
||||
// ✅ tạo element 1 lần duy nhất
|
||||
const element = useMemo(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const el = document.createElement("div");
|
||||
|
||||
if (className) {
|
||||
el.className = className;
|
||||
}
|
||||
|
||||
return el;
|
||||
}, [className]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const parent = containerId
|
||||
? document.getElementById(containerId)
|
||||
: document.body;
|
||||
|
||||
if (!parent) return;
|
||||
|
||||
parent.appendChild(element);
|
||||
|
||||
return () => {
|
||||
parent.removeChild(element);
|
||||
};
|
||||
}, [element, containerId]);
|
||||
|
||||
// ✅ không dùng ref.current
|
||||
// ✅ không dùng setState
|
||||
if (!element) return null;
|
||||
|
||||
return createPortal(children, element);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
|
||||
interface PropsSearch {
|
||||
placeholder?: string;
|
||||
keyword?: string;
|
||||
setKeyword: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function Search({
|
||||
keyword = "",
|
||||
setKeyword,
|
||||
placeholder = "Nhập từ khóa tìm kiếm",
|
||||
}: PropsSearch) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState(keyword);
|
||||
|
||||
// useEffect(() => {
|
||||
// setSearchTerm(keyword);
|
||||
// }, [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setKeyword(searchTerm.trim());
|
||||
}, 600);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
className={clsx(
|
||||
"flex h-12 cursor-text items-center gap-2 rounded-full border bg-white px-4 transition-colors",
|
||||
"border-[#e1e5ed] hover:border-[#3772ff] focus-within:border-[#3772ff]",
|
||||
)}
|
||||
>
|
||||
<SearchIcon size={20} className="text-[#908F99]" />
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="
|
||||
flex-1 border-none bg-transparent p-2
|
||||
text-[16px] font-medium outline-none
|
||||
placeholder:text-[#777e90]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ChevronUp,
|
||||
ArrowDownUp,
|
||||
ArrowDownWideNarrow,
|
||||
ArrowDownNarrowWide,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ================= TYPES ================= */
|
||||
interface ColumnType<T> {
|
||||
title: string | React.ReactNode;
|
||||
render: (row: T, index: number, path?: number[]) => React.ReactNode;
|
||||
className?: string;
|
||||
checkBox?: boolean;
|
||||
fixedLeft?: boolean;
|
||||
fixedRight?: boolean;
|
||||
maxWidth?: number | string;
|
||||
sortTable?: string;
|
||||
}
|
||||
|
||||
interface PropsTable<T> {
|
||||
data: T[];
|
||||
column: ColumnType<T>[];
|
||||
fixedHeader?: boolean;
|
||||
activeHeader?: boolean;
|
||||
handleCheckedAll?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
isCheckedAll?: boolean;
|
||||
handleCheckedRow?: (e: React.ChangeEvent<HTMLInputElement>, row: T) => void;
|
||||
handleIsCheckedRow?: (row: T) => boolean;
|
||||
rowKey: (row: T) => React.Key;
|
||||
getChildren?: (row: T) => T[] | undefined;
|
||||
useIndexPathAsKey?: boolean;
|
||||
}
|
||||
|
||||
/* ================= COMPONENT ================= */
|
||||
export default function Table<T>({
|
||||
data,
|
||||
column,
|
||||
fixedHeader,
|
||||
activeHeader,
|
||||
handleCheckedAll,
|
||||
isCheckedAll,
|
||||
handleCheckedRow,
|
||||
handleIsCheckedRow,
|
||||
rowKey,
|
||||
getChildren,
|
||||
useIndexPathAsKey,
|
||||
}: PropsTable<T>) {
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const thRefs = useRef<(HTMLTableCellElement | null)[]>([]);
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<React.Key[]>([]);
|
||||
const [sortConfig, setSortConfig] = useState<{
|
||||
key: string;
|
||||
direction: "asc" | "desc" | null;
|
||||
}>({ key: "", direction: null });
|
||||
|
||||
/* ================= SORT ================= */
|
||||
const handleSort = (key: string) => {
|
||||
setSortConfig((prev) => {
|
||||
if (prev.key === key) {
|
||||
const next =
|
||||
prev.direction === "asc"
|
||||
? "desc"
|
||||
: prev.direction === "desc"
|
||||
? null
|
||||
: "asc";
|
||||
return { key, direction: next };
|
||||
}
|
||||
return { key, direction: "asc" };
|
||||
});
|
||||
};
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig.key || !sortConfig.direction) return data;
|
||||
|
||||
const copy = [...data];
|
||||
|
||||
copy.sort((a: any, b: any) => {
|
||||
const aVal = a?.[sortConfig.key];
|
||||
const bVal = b?.[sortConfig.key];
|
||||
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
return sortConfig.direction === "asc"
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return copy;
|
||||
}, [data, sortConfig]);
|
||||
|
||||
/* ================= EXPAND ================= */
|
||||
const toggleExpand = (key: React.Key) => {
|
||||
setExpandedRows((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
/* ================= ROWS ================= */
|
||||
const renderRows = (rows: T[], path: number[] = []): React.ReactNode =>
|
||||
rows.map((row, i) => {
|
||||
const currentPath = [...path, i];
|
||||
const keyPath = useIndexPathAsKey ? currentPath.join(".") : rowKey(row);
|
||||
|
||||
const children = getChildren?.(row);
|
||||
const expanded = expandedRows.includes(keyPath);
|
||||
|
||||
return (
|
||||
<Fragment key={String(keyPath)}>
|
||||
<tr className="bg-white border-b">
|
||||
{getChildren && (
|
||||
<td className="w-[60px] text-center">
|
||||
{!!children?.length && (
|
||||
<button
|
||||
onClick={() => toggleExpand(keyPath)}
|
||||
className={clsx(
|
||||
"transition text-gray-400 hover:text-blue-500",
|
||||
expanded && "rotate-180 text-blue-500",
|
||||
)}
|
||||
>
|
||||
<ChevronUp size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{column.map((col, idx) => (
|
||||
<td
|
||||
key={idx}
|
||||
className={clsx(
|
||||
"px-4 py-3 text-sm font-medium whitespace-nowrap",
|
||||
col.fixedLeft && "sticky left-0 bg-white z-10",
|
||||
col.fixedRight && "sticky right-0 bg-white z-10",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth:
|
||||
typeof col.maxWidth === "number"
|
||||
? `${col.maxWidth}px`
|
||||
: col.maxWidth,
|
||||
}}
|
||||
className={clsx("flex items-center gap-2")}
|
||||
>
|
||||
{col.checkBox && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 accent-blue-500 cursor-pointer"
|
||||
onChange={(e) => handleCheckedRow?.(e, row)}
|
||||
checked={handleIsCheckedRow?.(row) ?? false}
|
||||
/>
|
||||
)}
|
||||
{col.render(row, i, currentPath)}
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{expanded && children && renderRows(children, currentPath)}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
/* ================= UI ================= */
|
||||
return (
|
||||
<div
|
||||
ref={tableRef}
|
||||
className={clsx(
|
||||
"overflow-auto pb-2",
|
||||
fixedHeader && "[&>table>thead]:sticky [&>table>thead]:top-0",
|
||||
activeHeader && "[&>table>thead]:bg-[#F4F7FA]",
|
||||
)}
|
||||
>
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="bg-white border-b">
|
||||
<tr>
|
||||
{getChildren && <th className="w-[60px]" />}
|
||||
|
||||
{column.map((col, i) => {
|
||||
const isActive = col.sortTable === sortConfig.key;
|
||||
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
thRefs.current[i] = el;
|
||||
}}
|
||||
onClick={() => col.sortTable && handleSort(col.sortTable)}
|
||||
className={clsx(
|
||||
"px-4 py-3 text-left text-[15px] font-semibold whitespace-nowrap",
|
||||
col.sortTable && "cursor-pointer",
|
||||
isActive && "text-blue-600",
|
||||
col.fixedLeft && "sticky left-0 bg-white z-10",
|
||||
col.fixedRight && "sticky right-0 bg-white z-10",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.checkBox && (
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={handleCheckedAll}
|
||||
checked={isCheckedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{col.title}
|
||||
|
||||
{col.sortTable &&
|
||||
(isActive ? (
|
||||
sortConfig.direction === "asc" ? (
|
||||
<ArrowDownNarrowWide size={14} />
|
||||
) : sortConfig.direction === "desc" ? (
|
||||
<ArrowDownWideNarrow size={14} />
|
||||
) : (
|
||||
<ArrowDownUp size={14} />
|
||||
)
|
||||
) : (
|
||||
<ArrowDownUp size={14} />
|
||||
))}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>{renderRows(sortedData)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { CircleCheck, CircleAlert, BadgeCheck } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export interface PropsIconToastifyCustom {
|
||||
type: "success" | "info" | "warn" | "error";
|
||||
}
|
||||
|
||||
const ICON_CONFIG = {
|
||||
success: {
|
||||
bg: "bg-[#06d7a0]",
|
||||
icon: CircleCheck,
|
||||
},
|
||||
info: {
|
||||
bg: "bg-[#4bc9f0]",
|
||||
icon: CircleCheck,
|
||||
},
|
||||
warn: {
|
||||
bg: "bg-[#ffd167]",
|
||||
icon: CircleAlert,
|
||||
},
|
||||
error: {
|
||||
bg: "bg-[#ee464c]",
|
||||
icon: BadgeCheck,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function IconToastifyCustom({ type }: PropsIconToastifyCustom) {
|
||||
const { bg, icon: Icon } = ICON_CONFIG[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"w-[50px] h-[50px] flex items-center justify-center rounded-full",
|
||||
bg,
|
||||
)}
|
||||
>
|
||||
<Icon size={20} className="text-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface CustomSliderProps {
|
||||
label?: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
|
||||
onChange?: (value: number) => void;
|
||||
|
||||
showValue?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
sliderClassName?: string;
|
||||
thumbClassName?: string;
|
||||
trackClassName?: string;
|
||||
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
|
||||
renderValue?: (value: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomSlider = ({
|
||||
label,
|
||||
value,
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.01,
|
||||
|
||||
onChange,
|
||||
|
||||
showValue = true,
|
||||
disabled = false,
|
||||
|
||||
className,
|
||||
labelClassName,
|
||||
sliderClassName,
|
||||
|
||||
color = "#2563eb",
|
||||
trackColor = "#e5e7eb",
|
||||
|
||||
renderValue,
|
||||
}: CustomSliderProps) => {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className={clsx("w-full space-y-3", className)}>
|
||||
{/* Header */}
|
||||
{(label || showValue) && (
|
||||
<div
|
||||
className={clsx("flex items-center justify-between", labelClassName)}
|
||||
>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
)}
|
||||
|
||||
{showValue && (
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{renderValue ? renderValue(value) : value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slider */}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(Number(e.target.value))}
|
||||
className={clsx(
|
||||
`
|
||||
w-full
|
||||
appearance-none
|
||||
bg-transparent
|
||||
cursor-pointer
|
||||
|
||||
disabled:opacity-50
|
||||
disabled:cursor-not-allowed
|
||||
|
||||
[&::-webkit-slider-runnable-track]:h-2
|
||||
[&::-webkit-slider-runnable-track]:rounded-full
|
||||
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:h-6
|
||||
[&::-webkit-slider-thumb]:w-6
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-white
|
||||
[&::-webkit-slider-thumb]:border-4
|
||||
[&::-webkit-slider-thumb]:shadow-md
|
||||
[&::-webkit-slider-thumb]:-mt-2
|
||||
|
||||
[&::-moz-range-track]:h-2
|
||||
[&::-moz-range-track]:rounded-full
|
||||
|
||||
[&::-moz-range-thumb]:h-6
|
||||
[&::-moz-range-thumb]:w-6
|
||||
[&::-moz-range-thumb]:rounded-full
|
||||
[&::-moz-range-thumb]:bg-white
|
||||
[&::-moz-range-thumb]:border-4
|
||||
`,
|
||||
sliderClassName,
|
||||
)}
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${color} ${percentage}%, ${trackColor} ${percentage}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSlider;
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { PropsBaseLayout, TContextBaseLayout } from "./interface";
|
||||
import clsx from "clsx";
|
||||
import Header from "./componets/Header";
|
||||
import Navbar from "./componets/Navbar";
|
||||
import RequireAuth from "@/components/protected/RequiredAuth";
|
||||
|
||||
export const ContextBaseLayout = React.createContext<TContextBaseLayout>({});
|
||||
|
||||
const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
|
||||
const [showFull, setShowFull] = React.useState(false);
|
||||
const [openMenuMobile, setOpenMenuMobile] = React.useState(false);
|
||||
return (
|
||||
// <RequireAuth>
|
||||
<ContextBaseLayout
|
||||
value={{ showFull, setShowFull, openMenuMobile, setOpenMenuMobile }}
|
||||
>
|
||||
<div className="min-h-screen bg-[#f8f8f8]">
|
||||
{/* OVERLAY cho Mobile */}
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-sm z-[20] transition-opacity duration-300 xl:hidden",
|
||||
openMenuMobile ? "opacity-100 visible" : "opacity-0 invisible",
|
||||
)}
|
||||
onClick={() => setOpenMenuMobile(false)}
|
||||
/>
|
||||
|
||||
{/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
|
||||
<nav
|
||||
className={clsx(
|
||||
"fixed top-0 left-0 h-full w-[240px] z-[21] bg-white transition-all duration-300 border-r border-[#f4f7fa]",
|
||||
// Desktop logic
|
||||
showFull ? "xl:translate-x-0" : "xl:-translate-x-full",
|
||||
// Mobile logic
|
||||
openMenuMobile ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<Navbar />
|
||||
</nav>
|
||||
|
||||
{/* HEADER */}
|
||||
<header
|
||||
className={clsx(
|
||||
"fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]",
|
||||
showFull
|
||||
? "xl:left-[240px] xl:w-[calc(100%-240px)]"
|
||||
: "left-0 w-full",
|
||||
"left-0 w-full", // Mặc định full width trên mobile
|
||||
)}
|
||||
>
|
||||
<Header title={title} breadcrumb={breadcrumb} />
|
||||
</header>
|
||||
|
||||
{/* MAIN CONTENT */}
|
||||
<main
|
||||
className={clsx(
|
||||
"pt-[92px] pb-6 px-6 transition-all duration-300",
|
||||
showFull ? "xl:pl-[264px]" : "xl:pl-6",
|
||||
"pl-6", // Mặc định padding trên mobile
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</ContextBaseLayout>
|
||||
// </RequireAuth>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseLayout;
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { PropsHeader } from "./interface";
|
||||
import { usePathname } from "next/dist/client/components/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
import { ContextBaseLayout } from "../../BaseLayout";
|
||||
import Image from "next/image";
|
||||
import icons from "@/constant/images/icons";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import MenuProfile from "../MenuProfile";
|
||||
|
||||
const Header = ({ title, breadcrumb }: PropsHeader) => {
|
||||
const pathname = usePathname();
|
||||
const { infoUser } = useSelector((state: RootState) => state.user);
|
||||
const context = useContext(ContextBaseLayout);
|
||||
|
||||
const [openProfile, setOpenProfile] = useState(false);
|
||||
|
||||
//Đóng menu mobile khi đổi route
|
||||
useEffect(() => {
|
||||
context.setOpenMenuMobile?.(false);
|
||||
}, [pathname, context]);
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full px-6 flex items-center justify-between bg-white relatives">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Nút bấm Mobile: Mở menu mobile */}
|
||||
<div
|
||||
className="xl:hidden cursor-pointer"
|
||||
onClick={() => context?.setOpenMenuMobile?.(true)}
|
||||
>
|
||||
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
|
||||
</div>
|
||||
{/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */}
|
||||
<div
|
||||
className="hidden xl:block cursor-pointer"
|
||||
onClick={() => context?.setShowFull?.(!context?.showFull)}
|
||||
>
|
||||
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
|
||||
</div>
|
||||
{breadcrumb ? (
|
||||
breadcrumb
|
||||
) : (
|
||||
<h4 className="text-[18px] font-bold text-[#141416]">{title}</h4>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-7">
|
||||
<div className="cursor-pointer" onClick={toggleFullScreen}>
|
||||
<Image src={icons.full_screen} alt="" width={24} height={24} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setOpenProfile(!openProfile)}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-blue-500 overflow-hidden">
|
||||
<Image
|
||||
src={
|
||||
infoUser?.avatar
|
||||
? `${process.env.NEXT_PUBLIC_IMAGE}/${infoUser.avatar}`
|
||||
: icons.avatar
|
||||
}
|
||||
alt="avatar"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
<p className="hidden md:block text-sm font-semibold text-[#171832]">
|
||||
{infoUser?.fullname || "User admin"}
|
||||
</p>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={clsx(
|
||||
"transition-transform",
|
||||
openProfile && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
|
||||
{openProfile && (
|
||||
<div className="absolute right-0 top-[120%] z-50">
|
||||
<MenuProfile onClose={() => setOpenProfile(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./Header";
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface PropsHeader {
|
||||
title: string;
|
||||
breadcrumb?: React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { use, useCallback, useState } from "react";
|
||||
import { PropsMenuProfile } from "./interface";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { httpRequest } from "@/services";
|
||||
import authServices from "@/services/authServices";
|
||||
import { store } from "@/redux/store";
|
||||
import { logout } from "@/redux/reducer/auth";
|
||||
import { setInfoUser } from "@/redux/reducer/user";
|
||||
import { PATH } from "@/constant/config";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { LogOut, ShieldCog, ShieldUser } from "lucide-react";
|
||||
import CustomDialog from "@/components/customs/custom-dialog";
|
||||
|
||||
const MenuProfile = ({ onClose }: PropsMenuProfile) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [openLogout, setOpenLogout] = useState(false);
|
||||
|
||||
/* ================== CHECK ACTIVE ================== */
|
||||
const checkActive = useCallback(
|
||||
(path: string) => {
|
||||
return pathname === path;
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
|
||||
/* ================== LOGOUT ================== */
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
httpRequest({
|
||||
showMessageFailed: true,
|
||||
showMessageSuccess: false,
|
||||
http: authServices.logout(),
|
||||
}),
|
||||
onSuccess(data) {
|
||||
if (data) {
|
||||
store.dispatch(logout());
|
||||
store.dispatch(setInfoUser(null));
|
||||
router.push(PATH.LOGIN);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[260px] bg-white rounded-xl shadow-md p-2">
|
||||
{/* PROFILE */}
|
||||
<Link
|
||||
href={PATH.PROFILE}
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
|
||||
checkActive(PATH.PROFILE) && "bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<ShieldUser size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Thông tin cá nhân</p>
|
||||
<p className="text-xs text-gray-500">Chi tiết tài khoản</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="h-px bg-gray-200 my-1" />
|
||||
|
||||
{/* CHANGE PASSWORD */}
|
||||
<Link
|
||||
href={`${PATH.PROFILE}?_action=change-password`}
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
|
||||
pathname === PATH.PROFILE &&
|
||||
searchParams.get("_action") === "change-password" &&
|
||||
"bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<ShieldCog size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Đổi mật khẩu</p>
|
||||
<p className="text-xs text-gray-500">Thay đổi mật khẩu</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="h-px bg-gray-200 my-1" />
|
||||
|
||||
{/* LOGOUT */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setOpenLogout(true);
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition"
|
||||
>
|
||||
<LogOut size={20} className="text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-500">Đăng xuất</p>
|
||||
<p className="text-xs text-gray-500">Đăng xuất khỏi hệ thống</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DIALOG */}
|
||||
<CustomDialog
|
||||
open={openLogout}
|
||||
onClose={() => setOpenLogout(false)}
|
||||
onSubmit={handleLogout}
|
||||
title="Đăng xuất"
|
||||
note="Bạn có muốn đăng xuất khỏi hệ thống không?"
|
||||
titleCancel="Không"
|
||||
titleSubmit="Đăng xuất"
|
||||
type="error"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuProfile;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MenuProfile";
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface PropsMenuProfile {
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Menus, PATH } from "@/constant/config";
|
||||
import Image from "next/image";
|
||||
import icons from "@/constant/images/icons";
|
||||
import clsx from "clsx";
|
||||
|
||||
const Navbar = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const checkActive = useCallback(
|
||||
(pathActive: string) => {
|
||||
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} />
|
||||
<h4 className="ml-2 text-sm font-semibold text-blue-900 select-none">
|
||||
Quản lý
|
||||
</h4>
|
||||
</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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./Navbar";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./BaseLayout";
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface PropsBaseLayout {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
breadcrumb?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TContextBaseLayout {
|
||||
showFull?: boolean;
|
||||
setShowFull?: (show: boolean) => void;
|
||||
openMenuMobile?: boolean;
|
||||
setOpenMenuMobile?: (show: boolean) => void;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { PropsChartWrapper } from "./interface";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import icons from "@/constant/images/icons";
|
||||
|
||||
const ChartWrapper = ({
|
||||
children,
|
||||
isEmpty,
|
||||
message = "Không có dữ liệu",
|
||||
}: PropsChartWrapper) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full min-h-[500px]",
|
||||
isEmpty && "flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Image
|
||||
alt="Ảnh dữ liệu trống"
|
||||
src={icons.emptyTable}
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
{/* nếu muốn hiện message thì bật dòng dưới */}
|
||||
{/* <p className="mt-3 text-sm text-gray-500">{message}</p> */}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartWrapper;
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface PropsChartWrapper {
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { GridColumnProps } from "./interface";
|
||||
import clsx from "clsx";
|
||||
|
||||
const GridColumn = ({
|
||||
children,
|
||||
col = 3,
|
||||
sm,
|
||||
tabletCol3,
|
||||
mobile2,
|
||||
scroll20,
|
||||
scrollMobile85,
|
||||
className,
|
||||
}: GridColumnProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"grid w-full",
|
||||
|
||||
/* ===== GAP ===== */
|
||||
sm ? "gap-2" : "gap-4",
|
||||
|
||||
/* ===== DEFAULT COL ===== */
|
||||
{
|
||||
"grid-cols-1": col === 1,
|
||||
"grid-cols-2": col === 2,
|
||||
"grid-cols-3": col === 3,
|
||||
"grid-cols-4": col === 4,
|
||||
"grid-cols-5": col === 5,
|
||||
"grid-cols-6": col === 6,
|
||||
"grid-cols-8": col === 8,
|
||||
"grid-cols-12": col === 12,
|
||||
},
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
"xl:grid-cols-3", // giống default SCSS fallback
|
||||
"lg:grid-cols-2",
|
||||
"md:grid-cols-2",
|
||||
"sm:grid-cols-1",
|
||||
|
||||
/* ===== CUSTOM RULES ===== */
|
||||
tabletCol3 && "lg:grid-cols-3",
|
||||
|
||||
mobile2 && "sm:grid-cols-2",
|
||||
|
||||
/* ===== COL 4 SPECIAL ===== */
|
||||
col === 4 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
|
||||
|
||||
/* ===== COL 5 ===== */
|
||||
col === 5 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
|
||||
|
||||
/* ===== SCROLL ===== */
|
||||
scroll20 &&
|
||||
"grid-flow-col auto-cols-[20%] overflow-x-auto snap-x snap-mandatory gap-2",
|
||||
|
||||
scrollMobile85 &&
|
||||
"md:grid-cols-none md:grid-flow-col md:auto-cols-[85%] md:overflow-x-auto md:snap-x md:snap-mandatory",
|
||||
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridColumn;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./GridColumn";
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface GridColumnProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
col?: 1 | 2 | 3 | 4 | 5 | 6 | 8 | 12;
|
||||
sm?: boolean;
|
||||
|
||||
tabletCol3?: boolean;
|
||||
mobile2?: boolean;
|
||||
|
||||
scroll20?: boolean;
|
||||
scrollMobile85?: boolean;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import RequiredLogout from "@/components/protected/RequiredLogout";
|
||||
import React from "react";
|
||||
|
||||
interface PropsLayoutAuth {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const LayoutAuth = ({ children }: PropsLayoutAuth) => {
|
||||
return (
|
||||
<RequiredLogout>
|
||||
<div className="h-screen grid grid-cols-2 max-[1200px]:grid-cols-1">
|
||||
{/* BACKGROUND */}
|
||||
<div
|
||||
className="
|
||||
bg-no-repeat
|
||||
bg-cover
|
||||
bg-center
|
||||
max-[1200px]:hidden bg-[url('/static/images/background_auth.jpg')]
|
||||
"
|
||||
/>
|
||||
|
||||
{/* MAIN */}
|
||||
<main
|
||||
className="
|
||||
flex items-center bg-white
|
||||
px-[200px]
|
||||
max-[1600px]:px-[100px]
|
||||
max-[768px]:px-[40px]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</RequiredLogout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutAuth;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./LayoutAuth";
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
import React, { useCallback } from "react";
|
||||
import { PropsLayoutPages } from "./interface";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
|
||||
const LayoutPages = ({ children, listPages }: PropsLayoutPages) => {
|
||||
const pathname = usePathname();
|
||||
const checkActive = useCallback(
|
||||
(url: string) => {
|
||||
return pathname === url;
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto whitespace-nowrap pb-1 mb-5 scrollbar-thin">
|
||||
{listPages.map((item, i) => {
|
||||
const isActive = checkActive(item.url);
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
href={item.url}
|
||||
onClick={(e) => {
|
||||
if (isActive) e.preventDefault();
|
||||
}}
|
||||
className={clsx(
|
||||
"inline-block min-w-[60px] text-center font-medium rounded-md px-6 py-3 border select-none cursor-pointer",
|
||||
"text-[16px] max-[1200px]:text-[14px] max-[768px]:text-[13px]",
|
||||
isActive
|
||||
? "bg-[#3772ff] text-white border-[#3772ff]"
|
||||
: "bg-white text-[#202939] border-[#eaedf2]",
|
||||
i !== 0 && "ml-2",
|
||||
)}
|
||||
>
|
||||
{" "}
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
background: isActive ? "#fff" : item.color,
|
||||
}}
|
||||
/>
|
||||
<p>{item.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* MAIN */}
|
||||
<div>{children}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutPages;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./LayoutPages";
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface PropsLayoutPages {
|
||||
children: React.ReactNode;
|
||||
listPages: {
|
||||
title: string;
|
||||
url: string;
|
||||
color: string;
|
||||
}[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
|
||||
const MainPageHome = () => {
|
||||
return <div>MainPageHome</div>;
|
||||
};
|
||||
|
||||
export default MainPageHome;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MainPageHome";
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { IFormForgotPassword, TYPE_FORGOT_PASWORD } from "./interface";
|
||||
|
||||
import { ContextForgotPassword } from "./context";
|
||||
|
||||
import FormEmail from "./components/FormEmail";
|
||||
import FormPassword from "./components/FormPassword/FormPassword";
|
||||
|
||||
export default function MainForgotPassword() {
|
||||
const [type, setType] = useState<TYPE_FORGOT_PASWORD>(
|
||||
TYPE_FORGOT_PASWORD.EMAIL,
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<IFormForgotPassword>({
|
||||
email: "",
|
||||
otp: "",
|
||||
password: "",
|
||||
rePassword: "",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* TITLE */}
|
||||
<h3
|
||||
className="
|
||||
text-[34px]
|
||||
font-bold
|
||||
text-[#1A1B2D]
|
||||
"
|
||||
>
|
||||
Quên mật khẩu
|
||||
</h3>
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
<p
|
||||
className="
|
||||
mt-1
|
||||
text-[14px]
|
||||
font-medium
|
||||
text-[#6F767E]
|
||||
"
|
||||
>
|
||||
Nhập địa chỉ email liên kết với tài khoản của bạn để lấy lại mật khẩu!
|
||||
</p>
|
||||
|
||||
{/* FORM */}
|
||||
<div className="mt-6">
|
||||
<ContextForgotPassword.Provider
|
||||
value={{
|
||||
form,
|
||||
setForm,
|
||||
type,
|
||||
setType,
|
||||
}}
|
||||
>
|
||||
{type === TYPE_FORGOT_PASWORD.EMAIL && <FormEmail />}
|
||||
|
||||
{type === TYPE_FORGOT_PASWORD.PASSWORD && <FormPassword />}
|
||||
</ContextForgotPassword.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Mail } from "lucide-react";
|
||||
|
||||
import FormCustom from "@/components/utils/FormCustom";
|
||||
import InputForm from "@/components/utils/FormCustom/components/InputForm";
|
||||
import CustomPopup from "@/components/customs/custom-popup";
|
||||
import CustomLoading from "@/components/customs/custom-loading";
|
||||
import CustomButton from "@/components/customs/custom-button";
|
||||
|
||||
import { ContextForgotPassword } from "../../context";
|
||||
|
||||
import FormOTP from "../FormOTP";
|
||||
|
||||
import { PATH } from "@/constant/config";
|
||||
|
||||
import { httpRequest } from "@/services";
|
||||
import accountServices from "@/services/accountServices";
|
||||
|
||||
function FormEmail() {
|
||||
const router = useRouter();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const open = searchParams.get("_open");
|
||||
|
||||
const { form, setForm } = useContext(ContextForgotPassword);
|
||||
|
||||
const sendOTPMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return httpRequest({
|
||||
showMessageFailed: true,
|
||||
showMessageSuccess: true,
|
||||
msgSuccess: "Mã OTP đã được gửi đến email của bạn",
|
||||
http: accountServices.sendOTP({
|
||||
email: form?.email,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
onSuccess(data) {
|
||||
if (!data) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
params.set("_open", "otp");
|
||||
|
||||
router.replace(`?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendOTP = () => {
|
||||
sendOTPMutation.mutate();
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
params.delete("_open");
|
||||
|
||||
router.replace(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormCustom form={form} setForm={setForm} onSubmit={handleSendOTP}>
|
||||
<CustomLoading loading={sendOTPMutation.isPending} />
|
||||
|
||||
{/* INPUT EMAIL */}
|
||||
<InputForm
|
||||
label={
|
||||
<span>
|
||||
Email
|
||||
<span className="text-red-500"> *</span>
|
||||
</span>
|
||||
}
|
||||
placeholder="Nhập email"
|
||||
type="text"
|
||||
name="email"
|
||||
isEmail
|
||||
onClean
|
||||
isRequired
|
||||
isBlur
|
||||
icon={<Mail size={22} />}
|
||||
/>
|
||||
|
||||
{/* BUTTON GROUP */}
|
||||
<div className="mt-6">
|
||||
{/* BUTTON SUBMIT */}
|
||||
<CustomButton
|
||||
// type="submit"
|
||||
disabled={sendOTPMutation.isPending}
|
||||
variant="midnightBlue"
|
||||
rounded="full"
|
||||
className="w-full"
|
||||
>
|
||||
Lấy lại mật khẩu
|
||||
</CustomButton>
|
||||
|
||||
{/* LINE */}
|
||||
<div className="my-5 h-[1px] w-full bg-[#E5E5E5]" />
|
||||
|
||||
{/* LOGIN BUTTON */}
|
||||
<CustomButton
|
||||
// type="button"
|
||||
variant="grey"
|
||||
rounded="full"
|
||||
className="w-full"
|
||||
onClick={() => router.push(PATH.LOGIN)}
|
||||
>
|
||||
Đăng nhập ngay
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{/* POPUP OTP */}
|
||||
<CustomPopup open={open === "otp"} onClose={handleClosePopup}>
|
||||
<FormOTP />
|
||||
</CustomPopup>
|
||||
</FormCustom>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormEmail;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./FormEmail";
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { ContextForgotPassword } from "../../context";
|
||||
|
||||
import { TYPE_FORGOT_PASWORD } from "../../interface";
|
||||
|
||||
import CustomButton from "@/components/customs/custom-button";
|
||||
import CustomLoading from "@/components/customs/custom-loading";
|
||||
|
||||
import { httpRequest } from "@/services";
|
||||
import accountServices from "@/services/accountServices";
|
||||
import InputSingle from "@/components/customs/custom-input";
|
||||
import fancyTimeFormat, { obfuscateEmail } from "@/common/funcs/optionConvert";
|
||||
|
||||
function FormOTP() {
|
||||
const TIME_OTP = 60;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [countDown, setCountDown] = useState<number>(TIME_OTP);
|
||||
|
||||
const { form, setForm, setType } = useContext(ContextForgotPassword);
|
||||
|
||||
/* ========================= COUNTDOWN ========================= */
|
||||
useEffect(() => {
|
||||
if (countDown <= 0) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setCountDown((prev) => prev - 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [countDown]);
|
||||
|
||||
/* ========================= CLOSE POPUP ========================= */
|
||||
const closeForm = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
params.delete("_open");
|
||||
|
||||
router.replace(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
/* ========================= SEND OTP ========================= */
|
||||
const sendOTPMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return httpRequest({
|
||||
showMessageFailed: true,
|
||||
showMessageSuccess: true,
|
||||
msgSuccess: "Mã OTP đã được gửi đến email của bạn",
|
||||
http: accountServices.sendOTP({
|
||||
email: form.email!,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
onSuccess(data) {
|
||||
if (data) {
|
||||
setCountDown(TIME_OTP);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/* ========================= VERIFY OTP ========================= */
|
||||
const submitOTPMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return httpRequest({
|
||||
showMessageFailed: true,
|
||||
showMessageSuccess: true,
|
||||
msgSuccess: "Xác thực thành công",
|
||||
http: accountServices.enterOTP({
|
||||
email: form.email!,
|
||||
otp: form.otp!,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
onSuccess(data) {
|
||||
if (data) {
|
||||
setType(TYPE_FORGOT_PASWORD.PASSWORD);
|
||||
|
||||
closeForm();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/* ========================= HANDLERS ========================= */
|
||||
const handleSendCode = () => {
|
||||
sendOTPMutation.mutate();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitOTPMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
relative
|
||||
w-[476px]
|
||||
rounded-2xl
|
||||
bg-white
|
||||
p-10
|
||||
shadow-[0px_8px_40px_rgba(63,83,115,0.16),0px_3px_24px_rgba(63,83,115,0.2),0px_3px_8px_rgba(63,83,115,0.08),0px_1px_0px_rgba(63,83,115,0.08)]
|
||||
"
|
||||
>
|
||||
{/* LOADING */}
|
||||
<CustomLoading
|
||||
loading={sendOTPMutation.isPending || submitOTPMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* TITLE */}
|
||||
<h3
|
||||
className="
|
||||
text-center
|
||||
text-[28px]
|
||||
font-bold
|
||||
text-[#1A1B2D]
|
||||
"
|
||||
>
|
||||
Xác thực mã OTP
|
||||
</h3>
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
<p
|
||||
className="
|
||||
mt-1
|
||||
text-center
|
||||
text-[14px]
|
||||
font-medium
|
||||
text-[#6F767E]
|
||||
"
|
||||
>
|
||||
Một mã xác thực đã được gửi cho bạn qua địa chỉ email:
|
||||
<span className="font-semibold"> {obfuscateEmail(form.email!)}</span>
|
||||
</p>
|
||||
|
||||
{/* FORM */}
|
||||
<div className="mt-6">
|
||||
{/* LABEL */}
|
||||
<p
|
||||
className="
|
||||
text-center
|
||||
text-[16px]
|
||||
font-medium
|
||||
text-[#23262F]
|
||||
"
|
||||
>
|
||||
Nhập mã OTP
|
||||
</p>
|
||||
|
||||
{/* INPUT OTP */}
|
||||
<div className="mt-4">
|
||||
<InputSingle onSetValue={setForm} name="otp" lenght={6} />
|
||||
</div>
|
||||
|
||||
{/* COUNTDOWN */}
|
||||
<p
|
||||
className="
|
||||
mt-5
|
||||
text-center
|
||||
text-[15px]
|
||||
font-medium
|
||||
text-[#23262F]
|
||||
"
|
||||
>
|
||||
Bạn chưa nhận được mã.
|
||||
{countDown > 0 ? (
|
||||
<span
|
||||
className="
|
||||
ml-1
|
||||
cursor-default
|
||||
text-[15px]
|
||||
font-medium
|
||||
text-[#0011AB]
|
||||
"
|
||||
>
|
||||
Gửi lại OTP ({fancyTimeFormat(countDown)})
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
onClick={handleSendCode}
|
||||
className="
|
||||
ml-1
|
||||
cursor-pointer
|
||||
text-[15px]
|
||||
font-medium
|
||||
text-[#0011AB]
|
||||
transition-all
|
||||
hover:underline
|
||||
"
|
||||
>
|
||||
Gửi lại OTP
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* BUTTON */}
|
||||
<div className="mt-6">
|
||||
<CustomButton
|
||||
variant="midnightBlue"
|
||||
rounded="full"
|
||||
className="w-full"
|
||||
disabled={form.otp.length! < 6}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Xác thực Email
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{/* CLOSE */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForm}
|
||||
className="
|
||||
absolute
|
||||
right-5
|
||||
top-5
|
||||
cursor-pointer
|
||||
select-none
|
||||
transition-all
|
||||
active:scale-90
|
||||
"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormOTP;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./FormOTP";
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import md5 from "md5";
|
||||
|
||||
import { RootState, store } from "@/redux/store";
|
||||
|
||||
import {
|
||||
setDataLoginStorage,
|
||||
setStateLogin,
|
||||
setToken,
|
||||
} from "@/redux/reducer/auth";
|
||||
|
||||
import { setInfoUser } from "@/redux/reducer/user";
|
||||
|
||||
import { PATH } from "@/constant/config";
|
||||
|
||||
import { httpRequest } from "@/services";
|
||||
import authServices from "@/services/authServices";
|
||||
|
||||
import { ContextForgotPassword, IContextForgotPassword } from "../../context";
|
||||
|
||||
import InputForm from "@/components/utils/FormCustom/components/InputForm";
|
||||
|
||||
import CustomButton from "@/components/customs/custom-button";
|
||||
import CustomLoading from "@/components/customs/custom-loading";
|
||||
import FormCustom from "@/components/utils/FormCustom";
|
||||
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
|
||||
|
||||
export default function FormPassword() {
|
||||
const router = useRouter();
|
||||
|
||||
const { dataLoginStorage } = useSelector((state: RootState) => state.auth);
|
||||
|
||||
const { isRememberPassword } = useSelector((state: RootState) => state.site);
|
||||
|
||||
const { form, setForm } = useContext<IContextForgotPassword>(
|
||||
ContextForgotPassword,
|
||||
);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return httpRequest({
|
||||
showMessageFailed: true,
|
||||
showMessageSuccess: true,
|
||||
msgSuccess: "Đăng nhập thành công!",
|
||||
|
||||
http: authServices.login({
|
||||
username: dataLoginStorage?.usernameStorage || "",
|
||||
|
||||
password: md5(
|
||||
`${form.password}_${process.env.NEXT_PUBLIC_KEY_PASSWORD}`,
|
||||
),
|
||||
|
||||
ip: "",
|
||||
address: "",
|
||||
type: 0,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
onSuccess(data: any) {
|
||||
if (!data) return;
|
||||
|
||||
store.dispatch(setStateLogin(true));
|
||||
|
||||
store.dispatch(setToken(data.accessToken));
|
||||
|
||||
store.dispatch(
|
||||
setInfoUser({
|
||||
accessToken: data?.accessToken || "",
|
||||
refreshToken: data?.refreshToken || "",
|
||||
accessExpiresAt: data?.accessExpiresAt || "",
|
||||
refreshExpiresAt: data?.refreshExpiresAt || "",
|
||||
avatar: data?.avatar || "",
|
||||
fullname: data?.fullname || "",
|
||||
}),
|
||||
);
|
||||
|
||||
if (isRememberPassword) {
|
||||
store.dispatch(
|
||||
setDataLoginStorage({
|
||||
usernameStorage: dataLoginStorage?.usernameStorage || "",
|
||||
|
||||
passwordStorage: form.password,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
store.dispatch(setDataLoginStorage(null));
|
||||
}
|
||||
|
||||
router.replace(PATH.HOME);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
loginMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
|
||||
<CustomLoading loading={loginMutation.isPending} />
|
||||
|
||||
{/* PASSWORD */}
|
||||
<InputForm
|
||||
label={
|
||||
<span>
|
||||
Mật khẩu mới
|
||||
<span className="text-red-500"> *</span>
|
||||
</span>
|
||||
}
|
||||
placeholder="Nhập mật khẩu mới"
|
||||
type="password"
|
||||
name="password"
|
||||
onClean
|
||||
isRequired
|
||||
isBlur
|
||||
showDone
|
||||
icon={<ShieldCheck size={22} />}
|
||||
/>
|
||||
|
||||
{/* RE PASSWORD */}
|
||||
<div className="mt-5">
|
||||
<InputForm
|
||||
label={
|
||||
<span>
|
||||
Xác nhận mật khẩu mới
|
||||
<span className="text-red-500"> *</span>
|
||||
</span>
|
||||
}
|
||||
placeholder="Xác nhận mật khẩu mới"
|
||||
type="password"
|
||||
name="rePassword"
|
||||
valueConfirm={form.password}
|
||||
onClean
|
||||
isRequired
|
||||
isBlur
|
||||
showDone
|
||||
icon={<ShieldCheck size={22} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BUTTON */}
|
||||
<div className="mt-6">
|
||||
<ContextFormCustom.Consumer>
|
||||
{({ isDone }) => (
|
||||
<CustomButton
|
||||
variant="midnightBlue"
|
||||
rounded="full"
|
||||
disabled={!isDone}
|
||||
className="py-[10px] font-bold"
|
||||
>
|
||||
Lấy lại mật khẩu
|
||||
</CustomButton>
|
||||
)}
|
||||
</ContextFormCustom.Consumer>
|
||||
</div>
|
||||
</FormCustom>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createContext, Dispatch, SetStateAction } from "react";
|
||||
import { IFormForgotPassword, TYPE_FORGOT_PASWORD } from "../interface";
|
||||
|
||||
export interface IContextForgotPassword {
|
||||
form: IFormForgotPassword;
|
||||
setForm: Dispatch<SetStateAction<IFormForgotPassword>>;
|
||||
type: number;
|
||||
setType: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export const ContextForgotPassword = createContext<IContextForgotPassword>({
|
||||
form: { email: "", otp: "", password: "", rePassword: "" },
|
||||
setForm: () => null,
|
||||
type: TYPE_FORGOT_PASWORD.EMAIL,
|
||||
setType: () => null,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface IFormForgotPassword {
|
||||
email: string;
|
||||
otp: string;
|
||||
password: string;
|
||||
rePassword: string;
|
||||
}
|
||||
|
||||
export enum TYPE_FORGOT_PASWORD {
|
||||
EMAIL = 1,
|
||||
PASSWORD,
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { LockKeyhole, ShieldPlus, User } from "lucide-react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { RootState, store } from "@/redux/store";
|
||||
|
||||
import {
|
||||
setDataLoginStorage,
|
||||
setStateLogin,
|
||||
setToken,
|
||||
} from "@/redux/reducer/auth";
|
||||
|
||||
import { setRememberPassword } from "@/redux/reducer/site";
|
||||
import { setInfoUser } from "@/redux/reducer/user";
|
||||
|
||||
import authServices from "@/services/authServices";
|
||||
import { httpRequest } from "@/services";
|
||||
|
||||
import { PATH } from "@/constant/config";
|
||||
|
||||
import FormCustom from "@/components/utils/FormCustom";
|
||||
import InputForm from "@/components/utils/FormCustom/components/InputForm";
|
||||
import CustomLoading from "@/components/customs/custom-loading";
|
||||
// import FormCustom, { InputForm } from "@/components/common/FormCustom";
|
||||
|
||||
export default function MainLogin() {
|
||||
const router = useRouter();
|
||||
|
||||
const { dataLoginStorage } = useSelector((state: RootState) => state.auth);
|
||||
|
||||
const { isRememberPassword } = useSelector((state: RootState) => state.site);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
username: isRememberPassword ? dataLoginStorage?.usernameStorage || "" : "",
|
||||
|
||||
password: isRememberPassword ? dataLoginStorage?.passwordStorage || "" : "",
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return httpRequest({
|
||||
showMessageFailed: true,
|
||||
showMessageSuccess: true,
|
||||
msgSuccess: "Đăng nhập thành công!",
|
||||
http: authServices.login({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
ip: "",
|
||||
address: "",
|
||||
type: 0,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
onSuccess(data: any) {
|
||||
if (!data) return;
|
||||
|
||||
store.dispatch(setStateLogin(true));
|
||||
|
||||
store.dispatch(setToken(data.accessToken));
|
||||
|
||||
store.dispatch(
|
||||
setInfoUser({
|
||||
accessToken: data?.accessToken || "",
|
||||
refreshToken: data?.refreshToken || "",
|
||||
accessExpiresAt: data?.accessExpiresAt || "",
|
||||
refreshExpiresAt: data?.refreshExpiresAt || "",
|
||||
avatar: data?.avatar || "",
|
||||
fullname: data?.fullname || "",
|
||||
}),
|
||||
);
|
||||
|
||||
if (isRememberPassword) {
|
||||
store.dispatch(
|
||||
setDataLoginStorage({
|
||||
usernameStorage: form.username,
|
||||
passwordStorage: form.password,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
store.dispatch(setDataLoginStorage(null));
|
||||
}
|
||||
|
||||
router.replace(PATH.HOME);
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogin = () => {
|
||||
loginMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<CustomLoading loading={loginMutation.isPending} />
|
||||
|
||||
<FormCustom form={form} setForm={setForm} onSubmit={handleLogin}>
|
||||
{/* TITLE */}
|
||||
<h3
|
||||
className="
|
||||
text-[34px]
|
||||
font-semibold
|
||||
text-[#1A1B2D]
|
||||
"
|
||||
>
|
||||
Đăng nhập
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className="
|
||||
mt-1
|
||||
text-[14px]
|
||||
font-medium
|
||||
text-[#6F767E]
|
||||
"
|
||||
>
|
||||
Chào mừng bạn đến với hệ thống quản lý
|
||||
</p>
|
||||
|
||||
{/* FORM */}
|
||||
<div className="mt-6">
|
||||
{/* USERNAME */}
|
||||
<InputForm
|
||||
label={
|
||||
<span>
|
||||
Tài khoản
|
||||
<span className="text-red-500"> *</span>
|
||||
</span>
|
||||
}
|
||||
placeholder="Tài khoản"
|
||||
type="text"
|
||||
name="username"
|
||||
onClean
|
||||
isRequired
|
||||
isBlur
|
||||
showDone
|
||||
icon={<User size={22} />}
|
||||
/>
|
||||
|
||||
{/* PASSWORD */}
|
||||
<div className="mt-5">
|
||||
<InputForm
|
||||
label={
|
||||
<span>
|
||||
Mật khẩu
|
||||
<span className="text-red-500"> *</span>
|
||||
</span>
|
||||
}
|
||||
placeholder="Mật khẩu"
|
||||
type="password"
|
||||
name="password"
|
||||
onClean
|
||||
isRequired
|
||||
isBlur
|
||||
showDone
|
||||
icon={<ShieldPlus size={22} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* REMEMBER PASSWORD */}
|
||||
<div
|
||||
className="
|
||||
mt-[14px]
|
||||
flex
|
||||
items-center
|
||||
gap-2
|
||||
cursor-pointer
|
||||
"
|
||||
>
|
||||
<input
|
||||
id="rememberPassword"
|
||||
type="checkbox"
|
||||
checked={isRememberPassword}
|
||||
onChange={() =>
|
||||
store.dispatch(setRememberPassword(!isRememberPassword))
|
||||
}
|
||||
className="
|
||||
h-5
|
||||
w-5
|
||||
cursor-pointer
|
||||
accent-[#0011AB]
|
||||
"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="rememberPassword"
|
||||
className="
|
||||
cursor-pointer
|
||||
select-none
|
||||
text-[14px]
|
||||
font-medium
|
||||
text-[#171717]
|
||||
"
|
||||
>
|
||||
Nhớ mật khẩu
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* BUTTONS */}
|
||||
<div className="mt-6">
|
||||
{/* LOGIN */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="
|
||||
flex
|
||||
h-[52px]
|
||||
w-full
|
||||
items-center
|
||||
justify-center
|
||||
rounded-full
|
||||
bg-[#0011AB]
|
||||
px-6
|
||||
text-white
|
||||
font-bold
|
||||
transition-all
|
||||
hover:opacity-90
|
||||
disabled:cursor-not-allowed
|
||||
disabled:opacity-50
|
||||
"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
|
||||
{/* LINE */}
|
||||
<div
|
||||
className="
|
||||
my-5
|
||||
h-[1px]
|
||||
w-full
|
||||
bg-[#E5E5E5]
|
||||
"
|
||||
/>
|
||||
|
||||
{/* FORGOT PASSWORD */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(PATH.FORGOT_PASSWORD)}
|
||||
className="
|
||||
flex
|
||||
h-[52px]
|
||||
w-full
|
||||
items-center
|
||||
justify-center
|
||||
gap-2
|
||||
rounded-full
|
||||
border
|
||||
border-gray-300
|
||||
bg-white
|
||||
px-6
|
||||
font-bold
|
||||
text-[#171717]
|
||||
transition-all
|
||||
hover:bg-gray-50
|
||||
"
|
||||
>
|
||||
<LockKeyhole size={22} />
|
||||
Quên mật khẩu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormCustom>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MainLogin";
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import NProgress from "nprogress";
|
||||
import "nprogress/nprogress.css";
|
||||
|
||||
function LoadingTopBar() {
|
||||
useEffect(() => {
|
||||
NProgress.configure({ showSpinner: false });
|
||||
|
||||
const handleClick = (e: any) => {
|
||||
const target = e.target.closest("a");
|
||||
|
||||
if (target?.href && target.href.startsWith(window.location.origin)) {
|
||||
NProgress.start();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
NProgress.done();
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleClick);
|
||||
window.addEventListener("load", handleStop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("click", handleClick);
|
||||
window.removeEventListener("load", handleStop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default LoadingTopBar;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./LoadingTopBar";
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { PATH } from "@/constant/config";
|
||||
import { RootState } from "@/redux/store";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
interface IRequireAuthProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function RequireAuth(props: IRequireAuthProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { loading } = useSelector((state: RootState) => state.site);
|
||||
const { isLogin } = useSelector((state: RootState) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLogin && !loading) {
|
||||
router.replace(PATH.LOGIN);
|
||||
}
|
||||
}, [isLogin, loading, router]);
|
||||
|
||||
if (isLogin && !loading) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
return <div className="loading-page" />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//**********************
|
||||
//* COMPONENT PROTECTED SCREEN THEN LOGIN
|
||||
//**********************
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { RootState } from "@/redux/store";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { PATH } from "@/constant/config";
|
||||
|
||||
interface props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function RequiredLogout({ children }: props) {
|
||||
const { replace } = useRouter();
|
||||
const { isLogin } = useSelector((state: RootState) => state.auth);
|
||||
const { loading } = useSelector((state: RootState) => state.site);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLogin && !loading) replace(PATH.HOME);
|
||||
}, [isLogin, loading, replace]);
|
||||
|
||||
if (!isLogin && !loading) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <div className="loading-page"></div>;
|
||||
}
|
||||
|
||||
export default RequiredLogout;
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RootState, store } from "@/redux/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import clsx from "clsx";
|
||||
import Lottie from "lottie-react";
|
||||
|
||||
import { getItemStorage, setItemStorage } from "@/common/funcs/localStorage";
|
||||
import { KEY_STORE } from "@/constant/config";
|
||||
|
||||
import { setLoading, setRememberPassword } from "@/redux/reducer/site";
|
||||
import {
|
||||
setDataLoginStorage,
|
||||
setStateLogin,
|
||||
setToken,
|
||||
} from "@/redux/reducer/auth";
|
||||
import { setInfoUser } from "@/redux/reducer/user";
|
||||
|
||||
import loadingAnim from "@/../public/static/anim/loading_screen.json";
|
||||
|
||||
function SplashScreen() {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const { loading, isRememberPassword } = useSelector(
|
||||
(state: RootState) => state.site,
|
||||
);
|
||||
const { infoUser } = useSelector((state: RootState) => state.user);
|
||||
const { token, isLogin, dataLoginStorage } = useSelector(
|
||||
(state: RootState) => state.auth,
|
||||
);
|
||||
|
||||
// 🔥 Load state từ localStorage
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const encryptedState = await getItemStorage(KEY_STORE);
|
||||
const SECRET_KEY = process.env.NEXT_PUBLIC_SECRET_KEY || "default_key";
|
||||
|
||||
const decode = (data: string): any => {
|
||||
try {
|
||||
const decoded = decodeURIComponent(atob(data));
|
||||
const clean = decoded.startsWith(SECRET_KEY)
|
||||
? decoded.slice(SECRET_KEY.length)
|
||||
: null;
|
||||
return clean ? JSON.parse(clean) : null;
|
||||
} catch (err) {
|
||||
console.error("Decode error:", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const state =
|
||||
typeof encryptedState === "string" ? decode(encryptedState) : null;
|
||||
|
||||
if (state) {
|
||||
store.dispatch(setToken(state.token));
|
||||
store.dispatch(setStateLogin(state.isLogin));
|
||||
store.dispatch(setInfoUser(state.infoUser));
|
||||
store.dispatch(setRememberPassword(state.isRememberPassword));
|
||||
store.dispatch(setDataLoginStorage(state.dataLoginStorage));
|
||||
}
|
||||
|
||||
store.dispatch(setLoading(false));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 🔥 Save lại state
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
const SECRET_KEY = process.env.NEXT_PUBLIC_SECRET_KEY || "default_key";
|
||||
|
||||
const encode = (data: string): string => {
|
||||
const textToEncode = SECRET_KEY + data;
|
||||
return btoa(encodeURIComponent(textToEncode));
|
||||
};
|
||||
|
||||
const hashedData = encode(
|
||||
JSON.stringify({
|
||||
isLogin,
|
||||
token,
|
||||
infoUser,
|
||||
isRememberPassword,
|
||||
dataLoginStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
setItemStorage(KEY_STORE, hashedData);
|
||||
|
||||
// 👉 trigger animation fade out
|
||||
setTimeout(() => setIsClosing(true), 0);
|
||||
}
|
||||
}, [loading, isLogin, token, infoUser, isRememberPassword, dataLoginStorage]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed inset-0 flex items-center justify-center z-[1000000] bg-white transition-opacity duration-300",
|
||||
{
|
||||
"opacity-0 invisible": !loading && isClosing,
|
||||
"opacity-100 visible": loading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="w-[40vw] max-w-[320px] max-h-[320px]">
|
||||
<Lottie animationData={loadingAnim} loop autoplay />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SplashScreen;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./SplashScreen";
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { PropsFormCustom } from "./interface";
|
||||
import { ContextFormCustom } from "./contexts";
|
||||
|
||||
function FormCustom<T extends Record<string, any>>({
|
||||
form,
|
||||
setForm,
|
||||
onSubmit,
|
||||
children,
|
||||
}: PropsFormCustom<T>) {
|
||||
// 👉 tạo object error ban đầu
|
||||
const initialError = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
Object.keys(form).map((key) => [key, null]),
|
||||
) as Record<keyof T, string | null>;
|
||||
}, [form]);
|
||||
|
||||
const [countValidate, setCountValidate] = useState(0);
|
||||
const [errorText, setErrorText] =
|
||||
useState<Record<keyof T, string | null>>(initialError);
|
||||
|
||||
const [validate, setValidate] = useState<Record<keyof T, boolean> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 👉 check form hợp lệ
|
||||
const isDone = useMemo(() => {
|
||||
if (!validate) return false;
|
||||
|
||||
return Object.values(validate).every(Boolean);
|
||||
}, [validate]);
|
||||
|
||||
// 👉 submit form
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isDone) {
|
||||
onSubmit?.();
|
||||
} else {
|
||||
setCountValidate((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
[isDone, onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextFormCustom.Provider
|
||||
value={{
|
||||
form,
|
||||
setForm,
|
||||
errorText,
|
||||
setErrorText,
|
||||
setValidate,
|
||||
countValidate,
|
||||
setCountValidate,
|
||||
isDone,
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
{children}
|
||||
</form>
|
||||
</ContextFormCustom.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormCustom;
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
|
||||
import clsx from "clsx";
|
||||
|
||||
import { Check, CircleX, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { validateEmail, validatePhoneNumber } from "@/common/funcs/validate";
|
||||
|
||||
import { convertCoin, price } from "@/common/funcs/convertCoin";
|
||||
|
||||
import { ContextFormCustom, IContextFormCustom } from "../../contexts";
|
||||
|
||||
import { PropsInputForm } from "./interface";
|
||||
|
||||
function InputForm({
|
||||
type,
|
||||
name,
|
||||
placeholder,
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
note,
|
||||
textRequired,
|
||||
textConfirm,
|
||||
valueConfirm,
|
||||
isBlur = true,
|
||||
onClean,
|
||||
showDone,
|
||||
readOnly,
|
||||
max,
|
||||
min,
|
||||
isRequired,
|
||||
isNumber,
|
||||
isUppercase,
|
||||
isPhone,
|
||||
isEmail,
|
||||
isMoney,
|
||||
onBlur,
|
||||
onChangeValue,
|
||||
}: PropsInputForm) {
|
||||
const isPassword = type === "password";
|
||||
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
|
||||
const {
|
||||
form,
|
||||
countValidate,
|
||||
errorText,
|
||||
isDone,
|
||||
setForm,
|
||||
setValidate,
|
||||
setErrorText,
|
||||
} = useContext<IContextFormCustom<any>>(ContextFormCustom);
|
||||
|
||||
// =========================
|
||||
// VALUE
|
||||
// =========================
|
||||
|
||||
const currentValue = useMemo(() => {
|
||||
return value ?? form?.[name] ?? "";
|
||||
}, [value, form, name]);
|
||||
|
||||
// =========================
|
||||
// VALIDATE
|
||||
// =========================
|
||||
|
||||
const handleValidate = (inputValue?: string | number) => {
|
||||
const finalValue = String(inputValue ?? currentValue ?? "");
|
||||
|
||||
if (isRequired && finalValue.trim() === "") {
|
||||
return {
|
||||
valid: false,
|
||||
message: textRequired || "Vui lòng nhập trường này",
|
||||
};
|
||||
}
|
||||
|
||||
if (finalValue) {
|
||||
if (isNumber && isNaN(Number(finalValue))) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "Vui lòng chỉ nhập số",
|
||||
};
|
||||
}
|
||||
|
||||
if (isPhone && !validatePhoneNumber(finalValue)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "Định dạng số điện thoại không đúng",
|
||||
};
|
||||
}
|
||||
|
||||
if (isEmail && !validateEmail(finalValue)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "Định dạng email không chính xác",
|
||||
};
|
||||
}
|
||||
|
||||
if (valueConfirm && finalValue !== valueConfirm) {
|
||||
return {
|
||||
valid: false,
|
||||
message: textConfirm || "Mật khẩu không trùng khớp",
|
||||
};
|
||||
}
|
||||
|
||||
if (max && finalValue.length > max) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Nhập tối đa ${max} kí tự`,
|
||||
};
|
||||
}
|
||||
|
||||
if (min && finalValue.length < min) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Nhập tối thiểu ${min} kí tự`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
message: null,
|
||||
};
|
||||
};
|
||||
|
||||
// =========================
|
||||
// ERROR
|
||||
// =========================
|
||||
|
||||
const handleSetMessage = (inputValue?: string | number) => {
|
||||
const result = handleValidate(inputValue);
|
||||
|
||||
setErrorText((prev) => ({
|
||||
...prev,
|
||||
[name]: result.message,
|
||||
}));
|
||||
|
||||
setValidate((prev) => ({
|
||||
...prev,
|
||||
[name]: result.valid,
|
||||
}));
|
||||
};
|
||||
|
||||
// =========================
|
||||
// HANDLERS
|
||||
// =========================
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocus(true);
|
||||
|
||||
setErrorText((prev) => ({
|
||||
...prev,
|
||||
[name]: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocus(false);
|
||||
|
||||
onBlur?.();
|
||||
|
||||
if (isBlur) {
|
||||
handleSetMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let inputValue = e.target.value;
|
||||
|
||||
if (isUppercase) {
|
||||
inputValue = inputValue.toUpperCase();
|
||||
}
|
||||
|
||||
// MONEY
|
||||
if (isMoney) {
|
||||
const numericValue = Number(price(inputValue));
|
||||
|
||||
inputValue = numericValue ? convertCoin(numericValue) : "0";
|
||||
}
|
||||
|
||||
// CUSTOM CHANGE
|
||||
if (onChangeValue) {
|
||||
onChangeValue(inputValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// UPDATE FORM
|
||||
setForm((prev: any) => ({
|
||||
...prev,
|
||||
[name]: inputValue,
|
||||
}));
|
||||
|
||||
// VALIDATE LIVE
|
||||
if (countValidate > 0) {
|
||||
handleSetMessage(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClean = () => {
|
||||
setForm((prev: any) => ({
|
||||
...prev,
|
||||
[name]: "",
|
||||
}));
|
||||
|
||||
setErrorText((prev) => ({
|
||||
...prev,
|
||||
[name]: null,
|
||||
}));
|
||||
|
||||
setValidate((prev) => ({
|
||||
...prev,
|
||||
[name]: !isRequired,
|
||||
}));
|
||||
};
|
||||
|
||||
// =========================
|
||||
// RENDER
|
||||
// =========================
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* LABEL */}
|
||||
{label && (
|
||||
<label
|
||||
className="
|
||||
mb-2
|
||||
block
|
||||
text-[16px]
|
||||
font-medium
|
||||
text-[#171717]
|
||||
"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* INPUT WRAPPER */}
|
||||
<div
|
||||
className={clsx(
|
||||
`
|
||||
flex
|
||||
h-12
|
||||
items-center
|
||||
gap-2
|
||||
rounded-full
|
||||
border
|
||||
bg-white
|
||||
px-4
|
||||
transition-all
|
||||
`,
|
||||
"border-[#CDD5DF]",
|
||||
"hover:border-[#0011AB]",
|
||||
|
||||
isFocus && "border-[#0011AB]",
|
||||
|
||||
errorText?.[name] && "border-red-500",
|
||||
|
||||
showDone && isDone && !errorText?.[name] && "border-green-600",
|
||||
|
||||
readOnly &&
|
||||
`
|
||||
cursor-not-allowed
|
||||
border-gray-200
|
||||
bg-gray-100
|
||||
`,
|
||||
)}
|
||||
>
|
||||
{/* ICON */}
|
||||
{icon && (
|
||||
<div
|
||||
className={clsx(
|
||||
"transition-all",
|
||||
|
||||
isFocus && "text-[#0011AB]",
|
||||
|
||||
errorText?.[name] && "text-red-500",
|
||||
|
||||
showDone && isDone && !errorText?.[name] && "text-green-600",
|
||||
|
||||
!isFocus && !errorText?.[name] && "text-gray-400",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INPUT */}
|
||||
<input
|
||||
name={name}
|
||||
type={isPassword && showPass ? "text" : type}
|
||||
value={String(currentValue)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
disabled={readOnly}
|
||||
readOnly={readOnly}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
className="
|
||||
flex-1
|
||||
bg-transparent
|
||||
text-[16px]
|
||||
font-medium
|
||||
outline-none
|
||||
placeholder:text-gray-400
|
||||
"
|
||||
/>
|
||||
|
||||
{/* UNIT */}
|
||||
{unit ? (
|
||||
<div
|
||||
className="
|
||||
border-l
|
||||
border-gray-300
|
||||
pl-3
|
||||
text-[16px]
|
||||
font-medium
|
||||
"
|
||||
>
|
||||
{unit}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* CLEAN */}
|
||||
{onClean && !!currentValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClean}
|
||||
className="
|
||||
cursor-pointer
|
||||
transition-all
|
||||
hover:opacity-70
|
||||
"
|
||||
>
|
||||
<CircleX size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* DONE */}
|
||||
{!isPassword &&
|
||||
showDone &&
|
||||
isDone &&
|
||||
!errorText?.[name] &&
|
||||
!!currentValue && <Check size={18} className="text-green-600" />}
|
||||
|
||||
{/* PASSWORD */}
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
className="
|
||||
cursor-pointer
|
||||
transition-all
|
||||
hover:opacity-70
|
||||
"
|
||||
>
|
||||
{showPass ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ERROR */}
|
||||
{errorText?.[name] && (
|
||||
<p
|
||||
className="
|
||||
mt-1
|
||||
text-xs
|
||||
font-medium
|
||||
text-red-500
|
||||
"
|
||||
>
|
||||
{errorText[name]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* NOTE */}
|
||||
{note && (
|
||||
<p
|
||||
className="
|
||||
mt-1
|
||||
text-xs
|
||||
font-medium
|
||||
text-gray-500
|
||||
"
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InputForm;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./InputForm";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export interface PropsInputForm {
|
||||
type: string;
|
||||
name: string;
|
||||
placeholder: string;
|
||||
|
||||
icon?: React.ReactNode;
|
||||
label?: string | React.ReactNode;
|
||||
|
||||
value?: string | number;
|
||||
unit?: string;
|
||||
note?: string;
|
||||
textRequired?: string;
|
||||
valueConfirm?: string;
|
||||
textConfirm?: string;
|
||||
|
||||
isBlur?: boolean;
|
||||
onClean?: boolean;
|
||||
showDone?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
max?: number;
|
||||
min?: number;
|
||||
|
||||
isRequired?: boolean;
|
||||
isNumber?: boolean;
|
||||
isPhone?: boolean;
|
||||
isEmail?: boolean;
|
||||
isMoney?: boolean;
|
||||
isUppercase?: boolean;
|
||||
|
||||
onBlur?: () => void;
|
||||
onChangeValue?: (val: string | number) => void;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, Search, CircleX } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
|
||||
import icons from "@/constant/images/icons";
|
||||
import { PropsSelectForm } from "./interface";
|
||||
|
||||
function SelectForm<OptionType>({
|
||||
label,
|
||||
placeholder = "Chọn...",
|
||||
isSearch = true,
|
||||
readOnly,
|
||||
value,
|
||||
options = [], // ✅ FIX NULL
|
||||
onClean,
|
||||
onSelect,
|
||||
getOptionLabel,
|
||||
getOptionValue,
|
||||
}: PropsSelectForm<OptionType>) {
|
||||
const refWrapper = useRef<HTMLDivElement>(null);
|
||||
const refInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
/* ================= CLICK OUTSIDE ================= */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!refWrapper.current?.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setKeyword("");
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
/* ================= FOCUS SEARCH ================= */
|
||||
useEffect(() => {
|
||||
if (open && isSearch) {
|
||||
setTimeout(() => {
|
||||
refInput.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [open, isSearch]);
|
||||
|
||||
/* ================= DATA ================= */
|
||||
const safeOptions = options ?? [];
|
||||
|
||||
const selectedOption = safeOptions.find(
|
||||
(opt) => getOptionValue(opt) === value,
|
||||
);
|
||||
|
||||
const filteredOptions = safeOptions.filter((opt) => {
|
||||
const label = removeVietnameseTones(getOptionLabel(opt) || "");
|
||||
const key = removeVietnameseTones(keyword || "");
|
||||
return label.includes(key);
|
||||
});
|
||||
|
||||
/* ================= HANDLER ================= */
|
||||
const handleSelect = (opt: OptionType) => {
|
||||
onSelect?.(opt);
|
||||
setOpen(false);
|
||||
setKeyword("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={refWrapper}>
|
||||
{/* LABEL */}
|
||||
{label && (
|
||||
<label className="block mb-2 text-[16px] font-medium">{label}</label>
|
||||
)}
|
||||
|
||||
{/* SELECT BOX */}
|
||||
<div
|
||||
onClick={() => !readOnly && setOpen((prev) => !prev)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between px-4 h-12 border rounded-full cursor-pointer transition",
|
||||
"border-gray-300 bg-white hover:border-blue-600",
|
||||
open && "border-blue-600",
|
||||
readOnly &&
|
||||
"bg-gray-100 border-gray-200 cursor-not-allowed hover:border-gray-200",
|
||||
)}
|
||||
>
|
||||
{/* VALUE */}
|
||||
<p
|
||||
className={clsx(
|
||||
"flex-1 text-[16px] font-medium",
|
||||
!selectedOption && "text-gray-400",
|
||||
)}
|
||||
>
|
||||
{selectedOption ? getOptionLabel(selectedOption) : placeholder}
|
||||
</p>
|
||||
|
||||
{/* CLEAR */}
|
||||
{onClean && !!value && !readOnly && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClean();
|
||||
}}
|
||||
className="mr-2 hover:opacity-70"
|
||||
>
|
||||
<CircleX size={18} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ARROW */}
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={clsx(
|
||||
"transition",
|
||||
open && "rotate-180 text-blue-600",
|
||||
readOnly && "text-gray-400",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DROPDOWN */}
|
||||
{open && (
|
||||
<div className="mt-2 w-full bg-white border border-blue-600 rounded-2xl p-3 shadow-lg z-50 absolute">
|
||||
{/* SEARCH */}
|
||||
{isSearch && (
|
||||
<div className="flex items-center gap-2 px-3 h-[42px] border border-gray-300 rounded-full mb-3">
|
||||
<Search size={18} className="text-blue-600" />
|
||||
<input
|
||||
ref={refInput}
|
||||
value={keyword}
|
||||
placeholder="Tìm kiếm..."
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="flex-1 bg-transparent outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LIST */}
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-1">
|
||||
{filteredOptions.map((opt) => {
|
||||
const active = getOptionValue(opt) === value;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(getOptionValue(opt))}
|
||||
onClick={() => handleSelect(opt)}
|
||||
className={clsx(
|
||||
"px-4 py-2 text-sm font-medium rounded-2xl cursor-pointer transition",
|
||||
active
|
||||
? "bg-blue-700 text-white"
|
||||
: "hover:bg-blue-700 hover:text-white",
|
||||
)}
|
||||
>
|
||||
{getOptionLabel(opt)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[200px] flex flex-col items-center justify-center">
|
||||
<Image src={icons.emptyFile} alt="empty" />
|
||||
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectForm;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./SelectForm";
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface PropsSelectForm<OptionType> {
|
||||
placeholder: string;
|
||||
label?: string | React.ReactNode;
|
||||
|
||||
isSearch?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
value: string | number;
|
||||
options: OptionType[];
|
||||
|
||||
onClean?: () => void;
|
||||
onSelect: (option: OptionType) => void;
|
||||
getOptionLabel: (option: OptionType) => string;
|
||||
getOptionValue: (option: OptionType) => string | number;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import React, { Fragment, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { CirclePlus, Trash, Search, X } from "lucide-react";
|
||||
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
|
||||
import Image from "next/image";
|
||||
import icons from "@/constant/images/icons";
|
||||
import Popup from "@/components/customs/custom-popup";
|
||||
import CustomButton from "@/components/customs/custom-button";
|
||||
|
||||
import { PropsSelectMany } from "./interface";
|
||||
|
||||
function SelectMany<OptionType>({
|
||||
text,
|
||||
label,
|
||||
placeholder,
|
||||
isSearch = true,
|
||||
showSelectedItems = false,
|
||||
readOnly,
|
||||
disabledItems = [],
|
||||
selectedItems = [],
|
||||
options = [],
|
||||
title,
|
||||
onClickSelect,
|
||||
setSelectedItems,
|
||||
getOptionLabel,
|
||||
getOptionValue,
|
||||
onRemove,
|
||||
action,
|
||||
renderOption,
|
||||
selectedItemFields,
|
||||
selectedItemFieldLabels,
|
||||
}: PropsSelectMany<OptionType>) {
|
||||
const refInputSearch = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
const [listDataTerm, setListDataTerm] =
|
||||
useState<Array<string | number>>(selectedItems);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocus) {
|
||||
setTimeout(() => refInputSearch.current?.focus(), 0);
|
||||
}
|
||||
}, [isFocus]);
|
||||
|
||||
const filteredOptions = options.filter((opt) =>
|
||||
removeVietnameseTones(String(getOptionLabel(opt) || "")).includes(
|
||||
removeVietnameseTones(keyword),
|
||||
),
|
||||
);
|
||||
|
||||
const handleOptionClick = (option: OptionType) => {
|
||||
const value = getOptionValue(option);
|
||||
if (disabledItems.includes(value)) return;
|
||||
|
||||
setListDataTerm((prev) =>
|
||||
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
|
||||
);
|
||||
|
||||
setKeyword("");
|
||||
};
|
||||
|
||||
const handleRemoveSelected = (value: string | number) => {
|
||||
const updated = listDataTerm.filter((v) => v !== value);
|
||||
setListDataTerm(updated);
|
||||
|
||||
onRemove ? onRemove(value) : setSelectedItems?.(updated);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setKeyword("");
|
||||
setIsFocus(false);
|
||||
|
||||
// reset lại data cũ
|
||||
setTimeout(() => {
|
||||
setListDataTerm(selectedItems);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleSaveData = () => {
|
||||
setIsFocus(false);
|
||||
setSelectedItems?.(listDataTerm);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block mb-2 text-base font-medium">{label}</label>
|
||||
)}
|
||||
|
||||
{/* Select box */}
|
||||
<div
|
||||
onClick={() => !readOnly && setIsFocus(true)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between h-12 px-4 rounded-full border cursor-pointer transition",
|
||||
"border-gray-300 bg-white hover:border-blue-700",
|
||||
isFocus && "border-blue-700",
|
||||
readOnly && "bg-gray-100 border-gray-200 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={clsx(
|
||||
"flex-1 font-semibold",
|
||||
selectedItems.length === 0 && "text-gray-400 font-medium",
|
||||
)}
|
||||
>
|
||||
{selectedItems.length > 0
|
||||
? `Đã chọn ${selectedItems.length} ${text}`
|
||||
: placeholder}
|
||||
</p>
|
||||
|
||||
<CirclePlus className="text-blue-700" size={20} />
|
||||
</div>
|
||||
|
||||
{/* Selected chips */}
|
||||
{showSelectedItems === "default" && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{selectedItems
|
||||
.map((v) => options.find((o) => getOptionValue(o) === v))
|
||||
.filter(Boolean)
|
||||
.map((item) => {
|
||||
const value = getOptionValue(item!);
|
||||
const disabled = disabledItems.includes(value);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-1 rounded-full border text-sm",
|
||||
disabled
|
||||
? "bg-gray-100 border-gray-200 text-gray-400"
|
||||
: "bg-blue-50 border-blue-200 text-blue-700",
|
||||
)}
|
||||
>
|
||||
<span>{getOptionLabel(item!)}</span>
|
||||
<X
|
||||
size={16}
|
||||
className={clsx(
|
||||
"cursor-pointer",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
onClick={() => handleRemoveSelected(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Popup */}
|
||||
<Popup open={isFocus} onClose={handleClose}>
|
||||
<div className="relative w-[540px] max-h-[640px] bg-white rounded-2xl p-6 shadow-xl flex flex-col">
|
||||
{/* Title */}
|
||||
<h4 className="text-2xl font-semibold">{title || "Danh sách"}</h4>
|
||||
|
||||
{/* Search */}
|
||||
{isSearch && (
|
||||
<div className="flex items-center gap-2 px-3 h-[42px] border border-gray-300 rounded-full mt-3">
|
||||
<Search className="text-blue-700" size={18} />
|
||||
<input
|
||||
ref={refInputSearch}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="flex-1 outline-none bg-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Select all */}
|
||||
{filteredOptions.length > 0 && (
|
||||
<div className="flex items-center justify-between mt-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={listDataTerm.length === options.length}
|
||||
onChange={(e) =>
|
||||
setListDataTerm(
|
||||
e.target.checked ? options.map(getOptionValue) : [],
|
||||
)
|
||||
}
|
||||
className="w-5 h-5 accent-blue-500"
|
||||
/>
|
||||
<span className="text-gray-500 font-medium">Chọn tất cả</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm">
|
||||
Đã chọn:{" "}
|
||||
<span className="font-semibold">{listDataTerm.length}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="max-h-[320px] overflow-y-auto border-t">
|
||||
{filteredOptions.map((opt) => {
|
||||
const value = getOptionValue(opt);
|
||||
const checked = listDataTerm.includes(value);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(value)}
|
||||
className="flex items-center gap-3 px-3 py-2 border-b"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleOptionClick(opt)}
|
||||
disabled={disabledItems.includes(value)}
|
||||
className="w-5 h-5 accent-blue-500"
|
||||
/>
|
||||
<label className="flex-1 cursor-pointer text-base font-medium">
|
||||
{getOptionLabel(opt)}
|
||||
{renderOption?.(opt)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[280px] flex flex-col items-center justify-center">
|
||||
<Image src={icons.emptyFile} alt="empty" />
|
||||
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<CustomButton variant="grey" rounded="full" onClick={handleClose}>
|
||||
Hủy bỏ
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
disabled={listDataTerm.length === 0}
|
||||
variant="midnightBlue"
|
||||
rounded="full"
|
||||
onClick={handleSaveData}
|
||||
>
|
||||
Xác nhận
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<div
|
||||
onClick={handleClose}
|
||||
className="absolute top-6 right-6 cursor-pointer active:scale-90"
|
||||
>
|
||||
<X size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectMany;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./SelectMany";
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export interface PropsSelectMany<OptionType> {
|
||||
text: string;
|
||||
placeholder: string;
|
||||
label?: string | React.ReactNode;
|
||||
|
||||
isSearch?: boolean;
|
||||
readOnly?: boolean;
|
||||
showSelectedItems?: false | "default" | "input";
|
||||
|
||||
disabledItems?: Array<string | number>;
|
||||
selectedItems: Array<string | number>;
|
||||
options: OptionType[];
|
||||
title?: string;
|
||||
|
||||
onClickSelect?: () => void;
|
||||
setSelectedItems?: Dispatch<SetStateAction<Array<string | number>>>;
|
||||
getOptionLabel: (option: OptionType) => string | React.ReactNode;
|
||||
getOptionValue: (option: OptionType) => string | number;
|
||||
onRemove?: (item: string | number) => void;
|
||||
|
||||
action?: React.ReactNode;
|
||||
renderOption?: (option: OptionType) => React.ReactNode;
|
||||
selectedItemFields?: (keyof OptionType)[];
|
||||
selectedItemFieldLabels?: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { X, Search } from "lucide-react";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
|
||||
import icons from "@/constant/images/icons";
|
||||
import Popup from "@/components/customs/custom-popup";
|
||||
import CustomButton from "@/components/customs/custom-button";
|
||||
import { PropsSelectSingle } from "./interface";
|
||||
|
||||
function SelectSingle<OptionType>({
|
||||
text = "",
|
||||
label,
|
||||
placeholder = "Chọn 1 giá trị",
|
||||
isSearch = true,
|
||||
readOnly,
|
||||
disabledItems = [],
|
||||
selectedItem,
|
||||
options,
|
||||
title,
|
||||
action,
|
||||
getOptionLabel,
|
||||
getOptionValue,
|
||||
setSelectedItem,
|
||||
onClickSelect,
|
||||
renderOption,
|
||||
}: PropsSelectSingle<OptionType>) {
|
||||
const refInputSearch = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
const [selectedTemp, setSelectedTemp] = useState<string | number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocus && refInputSearch.current) {
|
||||
setTimeout(() => refInputSearch.current?.focus(), 0);
|
||||
}
|
||||
}, [isFocus]);
|
||||
|
||||
const handlerFocused = () => {
|
||||
if (onClickSelect) {
|
||||
onClickSelect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (readOnly) return;
|
||||
|
||||
// sync dữ liệu tại đây
|
||||
setSelectedTemp(selectedItem ?? null);
|
||||
|
||||
setIsFocus(true);
|
||||
};
|
||||
|
||||
const handleSelect = (value: string | number) => {
|
||||
if (!disabledItems.includes(value)) {
|
||||
setSelectedTemp(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setSelectedItem?.(selectedTemp);
|
||||
setIsFocus(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsFocus(false);
|
||||
setKeyword("");
|
||||
setSelectedTemp(selectedItem ?? null);
|
||||
};
|
||||
|
||||
const selectedLabel = (() => {
|
||||
const selectedOption = options.find(
|
||||
(opt) => getOptionValue(opt) === selectedItem,
|
||||
);
|
||||
return selectedOption
|
||||
? getOptionLabel(selectedOption)
|
||||
: `Đã chọn 1 ${text}`;
|
||||
})();
|
||||
|
||||
const filteredOptions = options.filter((opt) =>
|
||||
removeVietnameseTones(getOptionLabel(opt)).includes(
|
||||
removeVietnameseTones(keyword),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block mb-2 text-[16px] font-medium">{label}</label>
|
||||
)}
|
||||
|
||||
{/* Select box */}
|
||||
<div
|
||||
onClick={handlerFocused}
|
||||
className={clsx(
|
||||
"h-12 px-4 flex items-center rounded-full border transition cursor-pointer",
|
||||
isFocus ? "border-[#0011AB]" : "border-[#CDD5DF]",
|
||||
readOnly &&
|
||||
"bg-gray-100 border-gray-200 cursor-default pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={clsx(
|
||||
"flex-1 text-[16px] font-semibold",
|
||||
!selectedItem && "text-gray-400 font-medium",
|
||||
)}
|
||||
>
|
||||
{selectedItem ? selectedLabel : placeholder}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Popup */}
|
||||
<Popup open={isFocus} onClose={handleClose}>
|
||||
<div className="relative w-[640px] max-w-[90vw] max-h-[740px] bg-white rounded-2xl shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-4 border-b shadow-sm">
|
||||
<h4 className="text-2xl font-semibold">
|
||||
{title || "Chọn 1 giá trị"}
|
||||
</h4>
|
||||
|
||||
{isSearch && (
|
||||
<div className="flex items-center gap-4 mt-3 mb-3 flex-wrap">
|
||||
<div className="flex items-center w-full h-[42px] px-3 border rounded-full border-gray-300">
|
||||
<Search color="#0011AB" size={20} />
|
||||
<input
|
||||
ref={refInputSearch}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="flex-1 ml-2 outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="px-6 max-h-[420px] min-h-[280px] overflow-auto">
|
||||
{filteredOptions.map((opt) => {
|
||||
const value = getOptionValue(opt);
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
className="flex items-center gap-3 py-3 border-b"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedTemp === value}
|
||||
onChange={() => handleSelect(value)}
|
||||
disabled={disabledItems.includes(value)}
|
||||
className="w-5 h-5 accent-[#0011AB]"
|
||||
/>
|
||||
<label className="w-full text-[16px] font-medium cursor-pointer">
|
||||
{getOptionLabel(opt)}
|
||||
{renderOption && renderOption(opt)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[280px] flex flex-col items-center justify-center">
|
||||
<Image src={icons.emptyFile} alt="empty" />
|
||||
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-3 shadow-inner">
|
||||
<CustomButton variant="grey" rounded="full" onClick={handleClose}>
|
||||
Hủy bỏ
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
variant="midnightBlue"
|
||||
rounded="full"
|
||||
disabled={selectedTemp === null}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Xác nhận
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<div
|
||||
onClick={handleClose}
|
||||
className="absolute top-6 right-6 cursor-pointer active:scale-90"
|
||||
>
|
||||
<X size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectSingle;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./SelectSingle";
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface PropsSelectSingle<OptionType> {
|
||||
text?: string;
|
||||
label?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
isSearch?: boolean;
|
||||
readOnly?: boolean;
|
||||
disabledItems?: (string | number)[];
|
||||
selectedItem?: string | number | null;
|
||||
options: OptionType[];
|
||||
title?: string;
|
||||
action?: React.ReactNode;
|
||||
getOptionLabel: (opt: OptionType) => string;
|
||||
getOptionValue: (opt: OptionType) => string | number;
|
||||
setSelectedItem?: (value: string | number | null) => void;
|
||||
renderOption?: (option: OptionType) => React.ReactNode;
|
||||
onClickSelect?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { PropsTextArea } from "./interface";
|
||||
import { ContextFormCustom, IContextFormCustom } from "../../contexts";
|
||||
|
||||
function TextArea({
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
label,
|
||||
isBlur = true,
|
||||
showDone,
|
||||
readOnly,
|
||||
max,
|
||||
min,
|
||||
isRequired,
|
||||
textRequired,
|
||||
}: PropsTextArea) {
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
|
||||
const {
|
||||
form,
|
||||
countValidate,
|
||||
errorText,
|
||||
isDone,
|
||||
setForm,
|
||||
setValidate,
|
||||
setErrorText,
|
||||
} = useContext<IContextFormCustom<any>>(ContextFormCustom);
|
||||
|
||||
// =========================
|
||||
// VALIDATE
|
||||
// =========================
|
||||
|
||||
const handleValidate = (): boolean => {
|
||||
const currentValue = `${form[name] || ""}`.trim();
|
||||
|
||||
if (isRequired && currentValue === "") return false;
|
||||
|
||||
if (min && currentValue.length < min) return false;
|
||||
|
||||
if (max && currentValue.length > max) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// =========================
|
||||
// ERROR MESSAGE
|
||||
// =========================
|
||||
|
||||
const handleSetMessage = () => {
|
||||
const currentValue = `${form[name] || ""}`.trim();
|
||||
|
||||
setErrorText((prev) => ({
|
||||
...prev,
|
||||
[name]: null,
|
||||
}));
|
||||
|
||||
if (isRequired && currentValue === "") {
|
||||
return setErrorText((prev) => ({
|
||||
...prev,
|
||||
[name]: textRequired || "Vui lòng nhập trường này",
|
||||
}));
|
||||
}
|
||||
|
||||
if (max && currentValue.length > max) {
|
||||
return setErrorText((prev) => ({
|
||||
...prev,
|
||||
[name]: `Nhập tối đa ${max} kí tự`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (min && currentValue.length < min) {
|
||||
return setErrorText((prev) => ({
|
||||
...prev,
|
||||
[name]: `Nhập tối thiểu ${min} kí tự`,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// =========================
|
||||
// EFFECTS
|
||||
// =========================
|
||||
|
||||
useEffect(() => {
|
||||
if (countValidate > 0) {
|
||||
handleSetMessage();
|
||||
}
|
||||
}, [countValidate]);
|
||||
|
||||
useEffect(() => {
|
||||
setValidate((prev) => ({
|
||||
...prev,
|
||||
[name]: handleValidate(),
|
||||
}));
|
||||
}, [form[name]]);
|
||||
|
||||
// =========================
|
||||
// EVENTS
|
||||
// =========================
|
||||
|
||||
const handlerFocused = () => {
|
||||
setIsFocus(true);
|
||||
|
||||
setErrorText((prev) => ({
|
||||
...prev,
|
||||
[name]: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const handlerBlur = () => {
|
||||
setIsFocus(false);
|
||||
|
||||
if (isBlur) {
|
||||
handleSetMessage();
|
||||
|
||||
setValidate((prev) => ({
|
||||
...prev,
|
||||
[name]: handleValidate(),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const { value } = e.target;
|
||||
|
||||
setForm((prev: any) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-1">
|
||||
{/* LABEL */}
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={`textarea_${name}`}
|
||||
className="block text-[16px] font-medium"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* TEXTAREA */}
|
||||
<textarea
|
||||
id={`textarea_${name}`}
|
||||
name={name}
|
||||
value={value ?? form[name] ?? ""}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
readOnly={readOnly}
|
||||
disabled={readOnly}
|
||||
onChange={handleChange}
|
||||
onFocus={handlerFocused}
|
||||
onBlur={handlerBlur}
|
||||
className={clsx(
|
||||
"w-full min-h-[140px] resize-none rounded-[20px] border px-4 py-3 text-[16px] font-medium outline-none transition",
|
||||
|
||||
// default
|
||||
"border-[#CDD5DF] bg-white",
|
||||
|
||||
// hover
|
||||
!readOnly && "hover:border-[#0019F6]",
|
||||
|
||||
// focus
|
||||
isFocus && "border-[#0019F6]",
|
||||
|
||||
// error
|
||||
errorText[name] && "border-[#E5493F]",
|
||||
|
||||
// done
|
||||
showDone && isDone && "border-[#1CAD4C]",
|
||||
|
||||
// readonly
|
||||
readOnly &&
|
||||
"cursor-not-allowed border-gray-200 bg-gray-100 hover:border-gray-200",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ERROR */}
|
||||
{errorText[name] && (
|
||||
<p className="text-[12px] text-red-500">{errorText[name]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextArea;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./TextArea";
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface PropsTextArea {
|
||||
name: string;
|
||||
value?: string;
|
||||
placeholder: string;
|
||||
|
||||
label?: string | React.ReactNode;
|
||||
|
||||
isBlur?: boolean;
|
||||
showDone?: boolean;
|
||||
readOnly?: boolean;
|
||||
textRequired?: string;
|
||||
|
||||
max?: number;
|
||||
min?: number;
|
||||
|
||||
isRequired?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, Dispatch, SetStateAction } from "react";
|
||||
|
||||
export interface IContextFormCustom<T> {
|
||||
form: T;
|
||||
setForm: Dispatch<SetStateAction<T>>;
|
||||
errorText: Record<keyof T, string | null>;
|
||||
setErrorText: Dispatch<SetStateAction<Record<keyof T, string | null>>>;
|
||||
setValidate: Dispatch<SetStateAction<Record<keyof T, boolean> | null>>;
|
||||
countValidate: number;
|
||||
setCountValidate: Dispatch<SetStateAction<number>>;
|
||||
isDone: boolean;
|
||||
}
|
||||
|
||||
export const ContextFormCustom = createContext<IContextFormCustom<any>>({
|
||||
form: {} as any,
|
||||
setForm: () => {},
|
||||
errorText: {},
|
||||
setErrorText: () => {},
|
||||
setValidate: () => {},
|
||||
countValidate: 0,
|
||||
setCountValidate: () => {},
|
||||
isDone: false,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./FormCustom";
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export interface PropsFormCustom<T extends Record<string, any>> {
|
||||
children: React.ReactNode;
|
||||
form: T;
|
||||
setForm: Dispatch<SetStateAction<T>>;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
import icons from "@/constant/images/icons";
|
||||
|
||||
// 👉 Types
|
||||
export const ICON_ROUND_TYPES = [
|
||||
"successPlay",
|
||||
"success",
|
||||
"error",
|
||||
"errorEnd",
|
||||
"errorGoods",
|
||||
] as const;
|
||||
|
||||
export type IconRoundType = (typeof ICON_ROUND_TYPES)[number];
|
||||
|
||||
export interface PropsIconRound {
|
||||
type?: IconRoundType;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// 👉 Icon map
|
||||
const iconMap = {
|
||||
success: icons.tickCircle,
|
||||
successPlay: icons.successPlay,
|
||||
error: icons.errorWarning,
|
||||
errorEnd: icons.errorEnd,
|
||||
errorGoods: icons.errorGoods,
|
||||
};
|
||||
|
||||
// 🎨 Background mapping
|
||||
const outerBgMap: Record<IconRoundType, string> = {
|
||||
success: "bg-green-100",
|
||||
successPlay: "bg-green-100",
|
||||
error: "bg-red-100",
|
||||
errorEnd: "bg-red-100",
|
||||
errorGoods: "bg-red-100",
|
||||
};
|
||||
|
||||
const innerBgMap: Record<IconRoundType, string> = {
|
||||
success: "bg-green-200",
|
||||
successPlay: "bg-green-200",
|
||||
error: "bg-red-200",
|
||||
errorEnd: "bg-red-200",
|
||||
errorGoods: "bg-red-200",
|
||||
};
|
||||
|
||||
function IconRound({
|
||||
type = "success",
|
||||
icon,
|
||||
className,
|
||||
size = 24,
|
||||
}: PropsIconRound) {
|
||||
const renderDefaultIcon = () => {
|
||||
const imgSrc = iconMap[type] ?? icons.tickCircle;
|
||||
return (
|
||||
<Image alt={`icon ${type}`} src={imgSrc} width={size} height={size} />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"p-2 rounded-full flex items-center justify-center w-fit h-fit",
|
||||
outerBgMap[type],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center justify-center rounded-full p-2",
|
||||
innerBgMap[type],
|
||||
)}
|
||||
style={{ width: 40, height: 40 }}
|
||||
>
|
||||
{icon ?? renderDefaultIcon()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(IconRound);
|
||||
@@ -0,0 +1,116 @@
|
||||
"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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./ClickOpen";
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface PropsClickOpen {
|
||||
children: any;
|
||||
onClick: (bol: boolean) => void;
|
||||
duration?: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface PropsPositionContainer {
|
||||
children: any;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
disableOverlay?: boolean;
|
||||
idParent?: string;
|
||||
classStyle?: {
|
||||
main: string;
|
||||
open: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Upload, Trash } from "lucide-react";
|
||||
|
||||
import { toastError, toastWarn } from "@/common/funcs/toast";
|
||||
import { PropsUploadAvatar } from "./interface";
|
||||
|
||||
const MAXIMUM_FILE = 10;
|
||||
|
||||
export default function UploadAvatar({
|
||||
path,
|
||||
name,
|
||||
onSetFile,
|
||||
resetPath,
|
||||
}: PropsUploadAvatar) {
|
||||
const [imageUrl, setImageUrl] = useState<string>("");
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
|
||||
const handleSelectImg = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const { size, type, name } = file;
|
||||
|
||||
if (size / 1024 / 1024 > MAXIMUM_FILE) {
|
||||
toastError({ msg: `Kích thước tối đa ${MAXIMUM_FILE}MB` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["image/jpeg", "image/jpg", "image/png"].includes(type)) {
|
||||
toastWarn({
|
||||
msg: "Chỉ hỗ trợ định dạng .jpg, .jpeg, .png",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
setImageUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
|
||||
setFileName(name);
|
||||
onSetFile?.(file);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageUrl) URL.revokeObjectURL(imageUrl);
|
||||
};
|
||||
}, [imageUrl]);
|
||||
|
||||
const handleRemoveImg = () => {
|
||||
if (imageUrl) URL.revokeObjectURL(imageUrl);
|
||||
|
||||
setImageUrl("");
|
||||
setFileName("");
|
||||
|
||||
onSetFile?.(null);
|
||||
resetPath?.();
|
||||
};
|
||||
|
||||
const displayImage = imageUrl || path;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
{/* AVATAR */}
|
||||
<Image
|
||||
src={displayImage}
|
||||
alt="avatar"
|
||||
width={104}
|
||||
height={104}
|
||||
className="rounded-md border border-blue-500 object-cover"
|
||||
/>
|
||||
|
||||
{/* RIGHT CONTENT */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* CONTROL */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-3">
|
||||
{/* UPLOAD INPUT */}
|
||||
<label className="flex cursor-pointer select-none h-12 w-full md:w-auto">
|
||||
{/* BUTTON */}
|
||||
<div className="flex items-center justify-center gap-2 px-5 bg-gray-100 rounded-l-md shadow-inner">
|
||||
<Upload size={18} />
|
||||
<span className="text-sm font-semibold">Chọn file</span>
|
||||
</div>
|
||||
|
||||
{/* FILE NAME */}
|
||||
<div className="flex items-center px-5 w-full md:w-[328px] bg-blue-50 rounded-r-md shadow-inner overflow-hidden">
|
||||
<p className="text-sm font-semibold text-gray-600 truncate w-full">
|
||||
{fileName || "Tên file"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
name={name}
|
||||
accept="image/png, image/jpeg, image/jpg"
|
||||
onChange={handleSelectImg}
|
||||
onClick={(e) => {
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* DELETE BUTTON */}
|
||||
<button
|
||||
onClick={handleRemoveImg}
|
||||
className="flex items-center gap-2 px-6 h-12 bg-gray-100 rounded-md transition active:scale-95 hover:opacity-70"
|
||||
>
|
||||
<Trash size={18} color="#AF0000" />
|
||||
<span className="text-sm font-semibold text-gray-700">
|
||||
Gỡ ảnh đại diện
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
<p className="text-sm text-gray-400">
|
||||
Hình ảnh dùng làm avatar, tối thiểu 300x300px
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Định dạng hỗ trợ: JPG, JPEG, PNG
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user