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