admin panel
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
import { clx } from '../helpers/utils'
|
||||
import MoonLoader from "react-spinners/ClipLoader"
|
||||
|
||||
|
||||
interface ButtonProps {
|
||||
label?: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
variant?: 'primary' | 'secondary'
|
||||
}
|
||||
|
||||
const Button: FC<ButtonProps> = (props) => {
|
||||
|
||||
const buttonClass = clx(
|
||||
'w-full bg-primary text-white cursor-pointer rounded-xl font-normal text-xs md:text-sm h-10 px-3 md:px-4 transition-all duration-200 hover:bg-opacity-90 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
props.variant === 'secondary' && 'bg-[#ECEEF5] text-black',
|
||||
props.className
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={props.onClick}
|
||||
type={props.type || 'button'}
|
||||
disabled={props.disabled || props.loading}
|
||||
className={buttonClass}
|
||||
>
|
||||
{props.loading ? (
|
||||
<MoonLoader className='mt-1.5' size={15} color={props.variant === 'secondary' ? '#000' : '#fff'} />
|
||||
|
||||
) : (
|
||||
props.children || props.label
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FC, useState } from 'react'
|
||||
import ColorImage from '@/assets/images/color.png'
|
||||
import { HexColorPicker } from 'react-colorful'
|
||||
|
||||
type Props = {
|
||||
defaultColor?: string
|
||||
changeColor: (color: string) => void,
|
||||
label: string
|
||||
}
|
||||
|
||||
const ColorPicker: FC<Props> = ({ defaultColor, changeColor, label }) => {
|
||||
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [color, setColor] = useState<string>(defaultColor || '#000')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{label}
|
||||
</div>
|
||||
<div onClick={() => setShowPicker(!showPicker)}
|
||||
className='mt-2 relative cursor-pointer h-10 border border-[#D0D0D0] rounded-xl px-4 flex justify-between items-center'>
|
||||
<div className='flex gap-1.5 items-center'>
|
||||
<div style={{ background: color }} className='size-4 rounded-full'></div>
|
||||
<div className='text-description text-sm dltr text-right'>{color}</div>
|
||||
</div>
|
||||
<img src={ColorImage} alt="color" className='size-6' />
|
||||
{showPicker && (
|
||||
<div style={{ position: "absolute", top: "50px", zIndex: 10 }}>
|
||||
<HexColorPicker color={color} onChange={(newColor) => {
|
||||
setColor(newColor)
|
||||
changeColor(newColor)
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColorPicker
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect, FC } from 'react';
|
||||
import DatePicker from 'react-multi-date-picker';
|
||||
import persian from 'react-date-object/calendars/persian';
|
||||
import persian_fa from 'react-date-object/locales/persian_fa';
|
||||
import DateObject from 'react-date-object';
|
||||
import { Calendar } from 'iconsax-react';
|
||||
import { clx } from '../helpers/utils';
|
||||
|
||||
type Props = {
|
||||
onChange: (date: string) => void;
|
||||
defaultValue?: string;
|
||||
error_text?: string;
|
||||
placeholder: string;
|
||||
reset?: boolean;
|
||||
isDateTime?: boolean;
|
||||
className?: string;
|
||||
label?: string,
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const DatePickerComponent: FC<Props> = (props: Props) => {
|
||||
const [value, setValue] = useState<DateObject | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.reset) {
|
||||
setValue(null);
|
||||
}
|
||||
}, [props.reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const formattedDate = `${value.year}/${value.month.number}/${value.day}`;
|
||||
props.onChange(formattedDate);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.defaultValue && !value) {
|
||||
const defaultDate = new DateObject({
|
||||
date: props.defaultValue,
|
||||
calendar: persian,
|
||||
locale: persian_fa,
|
||||
});
|
||||
setValue(defaultDate);
|
||||
}
|
||||
}, [props.defaultValue, value]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{
|
||||
props.label &&
|
||||
<div className='text-sm'>
|
||||
{props.label}
|
||||
</div>
|
||||
}
|
||||
<div className={clx(
|
||||
'relative',
|
||||
props.readOnly && 'readOny',
|
||||
props.label && 'mt-1.5'
|
||||
)}>
|
||||
<DatePicker
|
||||
placeholder={props.placeholder}
|
||||
value={value}
|
||||
onChange={(date) => setValue(date as DateObject)}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
calendarPosition="bottom-right"
|
||||
className={props.className}
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
{props.error_text && props.error_text !== '' && (
|
||||
<div className="text-xs text-right text-red-600 mt-2 mr-2 font-medium">
|
||||
{props.error_text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Calendar
|
||||
size={20}
|
||||
color='black'
|
||||
className='absolute top-0 bottom-0 my-auto left-2 pointer-events-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatePickerComponent;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FC, Fragment } from 'react'
|
||||
import Td from './Td'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
|
||||
type Props = {
|
||||
tdCount: number,
|
||||
trCount?: number,
|
||||
}
|
||||
|
||||
const DefaultTableSkeleton: FC<Props> = ({ tdCount, trCount = 5 }) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{
|
||||
Array.from({ length: trCount }).map((_, rowIndex) => (
|
||||
<tr className="w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5]" key={rowIndex}>
|
||||
{
|
||||
Array.from({ length: tdCount }).map((_, colIndex) => (
|
||||
<Td text={''} key={colIndex}>
|
||||
<Skeleton />
|
||||
</Td>
|
||||
))
|
||||
}
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default DefaultTableSkeleton
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
errorText: string
|
||||
}
|
||||
|
||||
const Error: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className='mt-1.5 font-normal text-red-500 text-[11px]'>
|
||||
{props.errorText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Error
|
||||
@@ -0,0 +1,191 @@
|
||||
import { FC, useEffect, useState, ChangeEvent } from 'react';
|
||||
import { Filter, CloseCircle } from 'iconsax-react';
|
||||
import DatePicker from './DatePicker';
|
||||
import Input from './Input';
|
||||
import Select, { ItemsSelectType } from './Select';
|
||||
|
||||
// تعریف نوع داده برای فیلدهای مختلف
|
||||
export type DateFieldType = {
|
||||
type: 'date';
|
||||
name: string;
|
||||
placeholder: string;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type SelectFieldType = {
|
||||
type: 'select';
|
||||
name: string;
|
||||
placeholder: string;
|
||||
options: ItemsSelectType[];
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type InputFieldType = {
|
||||
type: 'input';
|
||||
name: string;
|
||||
placeholder: string;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type FieldType = DateFieldType | SelectFieldType | InputFieldType;
|
||||
|
||||
// تعریف نوع داده برای مقادیر فیلترها
|
||||
export type FilterValues = Record<string, string | null>;
|
||||
|
||||
interface FiltersProps {
|
||||
fields: FieldType[];
|
||||
onChange: (filters: FilterValues) => void;
|
||||
initialValues?: FilterValues;
|
||||
className?: string;
|
||||
fieldClassName?: string;
|
||||
searchField?: string; // نام فیلد جستجو که باید در انتها نمایش داده شود
|
||||
}
|
||||
|
||||
const Filters: FC<FiltersProps> = ({
|
||||
fields,
|
||||
onChange,
|
||||
initialValues = {},
|
||||
className = "mt-6 md:mt-8 xl:mt-10 flex flex-col md:flex-row justify-between items-start md:items-center gap-4",
|
||||
fieldClassName = "flex flex-col sm:flex-row gap-3 md:gap-4 w-full md:w-auto",
|
||||
searchField = "search" // پیشفرض فیلد سرچ با نام search
|
||||
}) => {
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||
|
||||
// تنظیم مقادیر اولیه
|
||||
useEffect(() => {
|
||||
if (Object.keys(initialValues).length > 0) {
|
||||
setFilters(initialValues);
|
||||
} else {
|
||||
// تنظیم مقادیر پیشفرض از فیلدها
|
||||
const defaultValues: FilterValues = {};
|
||||
fields.forEach(field => {
|
||||
if ('defaultValue' in field && field.defaultValue !== undefined) {
|
||||
defaultValues[field.name] = field.defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(defaultValues).length > 0) {
|
||||
setFilters(defaultValues);
|
||||
onChange(defaultValues);
|
||||
}
|
||||
}
|
||||
}, [fields, initialValues, onChange]);
|
||||
|
||||
const handleChange = (name: string, value: string | null) => {
|
||||
const newFilters = { ...filters, [name]: value };
|
||||
setFilters(newFilters);
|
||||
onChange(newFilters);
|
||||
};
|
||||
|
||||
const handleInputChange = (name: string, event: ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange(name, event.target.value);
|
||||
};
|
||||
|
||||
const renderField = (field: FieldType) => {
|
||||
const currentValue = filters[field.name];
|
||||
|
||||
switch (field.type) {
|
||||
case 'date':
|
||||
return (
|
||||
<DatePicker
|
||||
key={field.name}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(value) => handleChange(field.name, value)}
|
||||
defaultValue={currentValue || field.defaultValue || ''}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
defaultValue={currentValue || field.defaultValue || ''}
|
||||
items={field.options}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
key={field.name}
|
||||
placeholder={field.placeholder}
|
||||
variant="search"
|
||||
className="w-full md:max-w-[200px]"
|
||||
value={currentValue || field.defaultValue || ''}
|
||||
onChange={(e) => handleInputChange(field.name, e)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// جداسازی فیلد جستجو از سایر فیلدها
|
||||
const searchFieldObj = fields.find(field => field.name === searchField);
|
||||
const otherFields = fields.filter(field => field.name !== searchField);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* آیکون فیلتر برای موبایل */}
|
||||
<div className="flex md:hidden items-center justify-between w-full mb-2">
|
||||
<button
|
||||
onClick={() => setIsFiltersOpen(!isFiltersOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Filter size={18} color="#000" />
|
||||
<span className="text-sm">فیلترها</span>
|
||||
</button>
|
||||
{searchFieldObj && (
|
||||
<div className="flex-1 mr-4">
|
||||
{renderField(searchFieldObj)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فیلترها برای دسکتاپ (همیشه نمایش داده میشوند) */}
|
||||
<div className="hidden md:flex md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className={fieldClassName}>
|
||||
{otherFields.map(renderField)}
|
||||
</div>
|
||||
{searchFieldObj && (
|
||||
<div className="w-full md:w-auto">
|
||||
{renderField(searchFieldObj)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فیلترها برای موبایل (قابل باز و بسته شدن) */}
|
||||
{isFiltersOpen && (
|
||||
<div className="md:hidden w-full animate-fadeInDown">
|
||||
<div className="bg-gradient-to-br from-white to-gray-50 border border-gray-200 rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} color="#6B7280" />
|
||||
<span className="text-sm font-medium text-gray-700">فیلتر کردن نتایج</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFiltersOpen(false)}
|
||||
className="p-1 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<CloseCircle size={18} color="#6B7280" />
|
||||
</button>
|
||||
</div>
|
||||
{otherFields.map(field => (
|
||||
<div key={field.name} className="w-full">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
{field.placeholder}
|
||||
</label>
|
||||
<div className="transform hover:scale-[1.02] transition-transform duration-200">
|
||||
{renderField(field)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { FC, InputHTMLAttributes, useEffect, useState } from 'react'
|
||||
import { clx } from '../helpers/utils';
|
||||
import { Eye, SearchNormal } from 'iconsax-react';
|
||||
import Error from './Error';
|
||||
|
||||
type Variant = "floating_outlined" | "primary" | "search";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
className?: string;
|
||||
variant?: Variant;
|
||||
error_text?: string;
|
||||
onEnter?: () => void;
|
||||
unit?: string;
|
||||
seprator?: boolean;
|
||||
isNotRequired?: boolean;
|
||||
onChangeSearchFinal?: (value: string) => void;
|
||||
endIcon?: React.ReactNode;
|
||||
} & InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const formatNumber = (value: string | number): string => {
|
||||
if (!value) return '';
|
||||
const inputValue = String(value).replace(/,/g, '');
|
||||
return inputValue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
||||
|
||||
const Input: FC<Props> = (props: Props) => {
|
||||
|
||||
const [formattedValue, setFormattedValue] = useState<string>(
|
||||
props.value ? formatNumber(props.value as string) : ''
|
||||
);
|
||||
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
const [search, setSearch] = useState<string>('')
|
||||
|
||||
const inputClass = clx(
|
||||
'w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border',
|
||||
props.readOnly && 'bg-gray-100 border-0 text-description',
|
||||
props.variant === 'search' && 'ps-10',
|
||||
props.className
|
||||
);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (props.seprator) {
|
||||
const inputValue = event.target.value.replace(/,/g, ''); // حذف کاماها
|
||||
const formatted = formatNumber(inputValue);
|
||||
|
||||
// بهروزرسانی مقدار قالببندیشده
|
||||
setFormattedValue(formatted);
|
||||
|
||||
// ارسال مقدار خام به `onChange` والد
|
||||
props.onChange?.({
|
||||
...event,
|
||||
target: {
|
||||
...event.target,
|
||||
value: inputValue,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
props.onChange?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.value) {
|
||||
setFormattedValue(formatNumber(props.value as string));
|
||||
} else {
|
||||
setFormattedValue('');
|
||||
}
|
||||
}, [props.value])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.variant === 'search' && props.onChangeSearchFinal) {
|
||||
const timeout = setTimeout(() => {
|
||||
props.onChangeSearchFinal?.(search)
|
||||
}, 1000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [search, props])
|
||||
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<label className='text-sm'>
|
||||
{props.label}
|
||||
</label>
|
||||
|
||||
<div className={clx(
|
||||
'w-full relative',
|
||||
props.label && 'mt-1'
|
||||
)}>
|
||||
<input {...props} onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
handleInputChange(e)
|
||||
}} value={props.seprator ? formattedValue : props.value} type={props.type === 'password' && showPassword ? 'text' : props.type === 'password' ? 'password' : undefined} className={inputClass} />
|
||||
|
||||
{
|
||||
props.type === 'password' &&
|
||||
<Eye onClick={() => setShowPassword((oldValue) => !oldValue)} size={20} color='#8C90A3' className='absolute top-0 bottom-0 cursor-pointer my-auto left-3' />
|
||||
}
|
||||
|
||||
{
|
||||
props.variant === 'search' &&
|
||||
<SearchNormal size={20} color='#8C90A3' className='absolute pointer-events-none top-0 w-5 bottom-0 my-auto right-3' />
|
||||
}
|
||||
|
||||
{
|
||||
props.endIcon &&
|
||||
<div className='absolute flex items-center pointer-events-none top-0 bottom-0 cursor-pointer my-auto left-3'>
|
||||
{props.endIcon}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
props.error_text &&
|
||||
<Error
|
||||
errorText={props.error_text}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Input
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}) => {
|
||||
// اگر فقط یک صفحه داریم، pagination نمایش داده نمیشود
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pageNumbers: (number | string)[] = [];
|
||||
const maxVisiblePages = window.innerWidth < 768 ? 3 : 5; // تعداد کمتر برای موبایل
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
} else {
|
||||
// نمایش صفحه اول
|
||||
pageNumbers.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pageNumbers.push("...");
|
||||
}
|
||||
|
||||
// صفحات میانی
|
||||
const start = Math.max(2, Math.min(currentPage - 1, totalPages - 3));
|
||||
const end = Math.min(totalPages - 1, Math.max(currentPage + 1, 4));
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (!pageNumbers.includes(i)) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
pageNumbers.push("...");
|
||||
}
|
||||
|
||||
// نمایش صفحه آخر
|
||||
if (!pageNumbers.includes(totalPages)) {
|
||||
pageNumbers.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pageNumbers;
|
||||
};
|
||||
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-1 md:gap-2 py-4 md:py-6 mt-3 md:mt-4 border-t border-gray-100">
|
||||
{/* دکمه صفحه قبل */}
|
||||
<button
|
||||
className={`px-2 md:px-3 py-2 text-xs md:text-sm rounded-lg transition-colors ${currentPage === 1
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
|
||||
}`}
|
||||
onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
|
||||
{/* شماره صفحات */}
|
||||
<div className="flex gap-1">
|
||||
{pageNumbers.map((page, index) =>
|
||||
typeof page === "number" ? (
|
||||
<button
|
||||
key={index}
|
||||
className={`w-8 h-8 md:w-10 md:h-10 text-xs md:text-sm rounded-lg transition-colors ${currentPage === page
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
|
||||
}`}
|
||||
onClick={() => onPageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<span key={index} className="flex items-center px-1 md:px-2 text-gray-400 text-xs md:text-sm">
|
||||
{page}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دکمه صفحه بعد */}
|
||||
<button
|
||||
className={`px-2 md:px-3 py-2 text-xs md:text-sm rounded-lg transition-colors ${currentPage === totalPages
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
|
||||
}`}
|
||||
onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
بعدی
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
isActive: boolean,
|
||||
value: string,
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const Radio: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div onClick={() => props.onChange(props.value)} className='size-4 cursor-pointer rounded-full bg-[#EAEDF5] flex justify-center items-center'>
|
||||
{
|
||||
props.isActive &&
|
||||
<div className='size-2 bg-black rounded-full'></div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Radio
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FC } from 'react'
|
||||
import Radio from './Radio'
|
||||
|
||||
|
||||
type Props = {
|
||||
items: {
|
||||
label: string
|
||||
value: string
|
||||
}[]
|
||||
selected: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const RadioGroup: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className='flex justify-between xl:justify-start gap-5 items-center text-xs'>
|
||||
{
|
||||
props.items.map((item, index) => (
|
||||
<div key={index} className='flex gap-2 items-center'>
|
||||
<Radio value={item.value} onChange={props.onChange} isActive={item.value === props.selected} />
|
||||
<div className='mt-0.5'>
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RadioGroup
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { More } from 'iconsax-react';
|
||||
|
||||
export interface RowActionItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface RowActionsDropdownProps {
|
||||
actions: RowActionItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RowActionsDropdown: React.FC<RowActionsDropdownProps> = ({ actions, className = '' }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isOpen && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const dropdownWidth = isMobile ? 140 : 150;
|
||||
const dropdownHeight = actions.length * (isMobile ? 36 : 40); // تخمین ارتفاع منو
|
||||
|
||||
let left = rect.left + window.scrollX - dropdownWidth + rect.width;
|
||||
let top = rect.bottom + window.scrollY;
|
||||
|
||||
// بررسی اینکه منو از سمت راست خارج نشود
|
||||
if (left + dropdownWidth > window.innerWidth) {
|
||||
left = rect.left + window.scrollX - dropdownWidth;
|
||||
}
|
||||
|
||||
// بررسی اینکه منو از سمت چپ خارج نشود
|
||||
if (left < 0) {
|
||||
left = isMobile ? 20 : 40;
|
||||
}
|
||||
|
||||
// بررسی اینکه منو از پایین خارج نشود
|
||||
if (top + dropdownHeight > window.innerHeight + window.scrollY) {
|
||||
top = rect.top + window.scrollY - dropdownHeight;
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleActionClick = (action: RowActionItem) => {
|
||||
action.onClick();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const renderDropdown = () => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed py-1 md:py-2 bg-white rounded-xl md:rounded-2xl shadow-lg z-[9999] min-w-[140px] md:min-w-[150px]"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
}}
|
||||
>
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleActionClick(action)}
|
||||
className={`w-full text-right px-2 md:px-3 py-1 md:py-1.5 text-xs md:text-[13px] flex items-center gap-2 hover:bg-gray-50 ${index === 0 ? 'rounded-t-lg' : ''
|
||||
} ${action.className || ''}`}
|
||||
>
|
||||
{action.icon && <span className="ml-2">{action.icon}</span>}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={handleToggle}
|
||||
className="p-1 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<More size={14} color="black" className="rotate-90" />
|
||||
</button>
|
||||
|
||||
{renderDropdown()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RowActionsDropdown;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FC, SelectHTMLAttributes } from 'react'
|
||||
import { clx } from '../helpers/utils'
|
||||
import { ArrowDown2 } from 'iconsax-react'
|
||||
|
||||
export type ItemsSelectType = {
|
||||
value: string,
|
||||
label: string,
|
||||
}
|
||||
type Props = {
|
||||
className?: string,
|
||||
items: ItemsSelectType[],
|
||||
error_text?: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
readOnly?: boolean,
|
||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const Select: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{
|
||||
props.label &&
|
||||
<label className='text-sm'>
|
||||
{props.label}
|
||||
</label>
|
||||
}
|
||||
<div className='relative'>
|
||||
<select {...props} className={clx(
|
||||
'w-full text-black relative block border appearance-none border-border px-2.5 h-10 text-sm rounded-2.5 bg-white',
|
||||
props.readOnly && 'bg-gray-100 border-0 text-description',
|
||||
props.className,
|
||||
props.label && 'mt-1'
|
||||
)}>
|
||||
{
|
||||
props.placeholder &&
|
||||
<option value="" disabled selected>{props.placeholder}</option>
|
||||
}
|
||||
{
|
||||
props.items?.map((item) => {
|
||||
return (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
</select>
|
||||
<ArrowDown2 size={16} color='black' className={clx(
|
||||
'absolute z-0 top-3 left-2 pointer-events-none',
|
||||
)} />
|
||||
</div>
|
||||
{
|
||||
props.error_text && props.error_text !== '' ?
|
||||
<div className='text-xs text-right text-red-600 mt-2 mr-2 font-medium'>
|
||||
{props.error_text}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Select
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import DefaultTableSkeleton from './DefaultTableSkeleton';
|
||||
import Td from './Td';
|
||||
import { TableProps, RowDataType, ColumnType } from './types/TableTypes';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import RowActionsDropdown from './RowActionsDropdown';
|
||||
import Pagination from './Pagination';
|
||||
|
||||
const Table = <T extends RowDataType>({
|
||||
columns,
|
||||
data,
|
||||
isLoading = false,
|
||||
onRowClick,
|
||||
className = '',
|
||||
rowClassName = '',
|
||||
noDataMessage = 'هیچ دادهای یافت نشد',
|
||||
headerClassName = 'bg-gray-50',
|
||||
emptyRowsCount = 5,
|
||||
showHeader = true,
|
||||
actions = null,
|
||||
actionsPosition = 'top',
|
||||
selectable = false,
|
||||
selectedActions = null,
|
||||
onSelectionChange,
|
||||
rowActions,
|
||||
pagination,
|
||||
}: TableProps<T>): React.ReactElement => {
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<T[]>([]);
|
||||
|
||||
const getRowClassName = (item: T, index: number): string => {
|
||||
if (typeof rowClassName === 'function') {
|
||||
return rowClassName(item, index);
|
||||
}
|
||||
return rowClassName;
|
||||
};
|
||||
|
||||
const getCellClassName = (column: ColumnType<T>): string => {
|
||||
const alignClass = column.align ? `text-${column.align}` : '';
|
||||
return `px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${alignClass} ${column.className || ''}`.trim();
|
||||
};
|
||||
|
||||
const handleRowClick = (item: T) => {
|
||||
if (onRowClick) {
|
||||
onRowClick(item.id, item);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const dataArray = data || [];
|
||||
const newSelectedRows = selectedRows.length === dataArray.length ? [] : [...dataArray];
|
||||
setSelectedRows(newSelectedRows);
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(newSelectedRows);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleRowSelect = (item: T, checked: boolean) => {
|
||||
const newSelectedRows = checked
|
||||
? [...selectedRows, item]
|
||||
: selectedRows.filter((row) => row.id !== item.id);
|
||||
setSelectedRows(newSelectedRows);
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(newSelectedRows);
|
||||
}
|
||||
};
|
||||
|
||||
const isRowSelected = (item: T): boolean => {
|
||||
return selectedRows.some((row) => row.id === item.id);
|
||||
};
|
||||
|
||||
// Gmail-style layout برای وقتی header نمایش داده نمیشود
|
||||
const renderGmailStyleRows = () => {
|
||||
const hasActions = (actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions));
|
||||
const roundedClass = hasActions ? 'rounded-b-2xl' : 'rounded-2xl';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-white ${roundedClass} overflow-hidden`}>
|
||||
{Array.from({ length: emptyRowsCount }).map((_, index) => (
|
||||
<div key={index} className={`h-[48px] bg-gray-100 animate-pulse ${index !== 0 ? 'border-t border-[#EAEDF5]' : ''}`}></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className={`bg-white ${roundedClass} py-6 md:py-8 text-center text-gray-500`}>
|
||||
{noDataMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white ${roundedClass} overflow-hidden`}>
|
||||
{data.map((item, rowIndex) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`w-full min-h-[48px] hover:bg-[#F4F5F9] ${onRowClick ? 'cursor-pointer' : ''} ${getRowClassName(item, rowIndex)} px-4 py-3 flex items-center gap-3 ${rowIndex !== 0 ? 'border-t border-[#EAEDF5]' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
{selectable && (
|
||||
<div
|
||||
className="flex-shrink-0 relative z-[5]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isRowSelected(item)}
|
||||
onCheckedChange={(checked) => handleSingleRowSelect(item, !!checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* محتوای اصلی - ستون اول */}
|
||||
<div className="flex items-center mb-0.5 text-sm font-medium">
|
||||
{columns[0]?.render ? columns[0].render(item) : (item[columns[0]?.key] as React.ReactNode)}
|
||||
</div>
|
||||
|
||||
{/* اطلاعات اضافی - سه ستون آخر در یک خط */}
|
||||
{columns.length > 1 && (
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
{columns.slice(1).map((col) => (
|
||||
<div key={col.key} className="truncate">
|
||||
{col.render ? col.render(item) : (item[col.key] as React.ReactNode)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rowActions && (
|
||||
<div
|
||||
className="flex-shrink-0 relative z-[5]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RowActionsDropdown actions={rowActions(item)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTableBody = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Fragment>
|
||||
<DefaultTableSkeleton tdCount={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} trCount={emptyRowsCount} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} className="px-3 md:px-6 py-6 md:py-8 text-center text-gray-500">
|
||||
{noDataMessage}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (data || []).map((item, rowIndex) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5] hover:bg-[#F4F5F9] ${onRowClick ? 'cursor-pointer' : ''} ${getRowClassName(item, rowIndex)}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
{selectable && (
|
||||
<td
|
||||
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isRowSelected(item)}
|
||||
onCheckedChange={(checked) => handleSingleRowSelect(item, !!checked)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={`${item.id}-${col.key}`}
|
||||
className={getCellClassName(col)}
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.render ? col.render(item) : item[col.key] as React.ReactNode}
|
||||
</td>
|
||||
))}
|
||||
{rowActions && (
|
||||
<td
|
||||
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RowActionsDropdown actions={rowActions(item)} />
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
));
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
if (!showHeader || actionsPosition === 'header-replace') return null;
|
||||
|
||||
return (
|
||||
<thead className={`h-[60px] md:h-[69px] bg-white text-sm text-[#8C90A3] ${headerClassName}`}>
|
||||
<tr>
|
||||
{selectable && (
|
||||
<th
|
||||
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5] first:rounded-tr-2xl md:first:rounded-tr-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={(data?.length || 0) > 0 && selectedRows.length === (data?.length || 0)}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<Td key={col.key} text={col.title} />
|
||||
))}
|
||||
{rowActions && (
|
||||
<th className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 rounded-tl-2xl md:rounded-tl-3xl"></th>
|
||||
)}
|
||||
</tr>
|
||||
</thead >
|
||||
);
|
||||
};
|
||||
|
||||
const renderActions = () => {
|
||||
if (!actions && (!selectable || selectedRows.length === 0 || !selectedActions)) return null;
|
||||
|
||||
return (
|
||||
<div className="h-[64px] rounded-t-2xl md:rounded-t-3xl md:h-[74px] bg-white flex items-center px-3 md:px-6">
|
||||
<div className='flex items-center gap-2 md:gap-4'>
|
||||
{actions}
|
||||
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// اگر header نشان داده نمیشود، از Gmail-style layout فقط در موبایل استفاده کن
|
||||
if (!showHeader) {
|
||||
return (
|
||||
<div className={`relative mt-6 md:mt-9 w-full ${className}`}>
|
||||
{/* نسخه موبایل - Gmail style */}
|
||||
<div className={`md:hidden`}>
|
||||
{(actionsPosition === 'top' || actionsPosition === 'above-header') && (
|
||||
<div className="h-[64px] bg-white flex items-center px-4 rounded-t-2xl border-b border-[#EAEDF5]">
|
||||
<div className='flex items-center gap-2'>
|
||||
{actions}
|
||||
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderGmailStyleRows()}
|
||||
</div>
|
||||
|
||||
{/* نسخه دسکتاپ - Table معمولی */}
|
||||
<div className={`hidden md:block`}>
|
||||
{(actionsPosition === 'top' || actionsPosition === 'above-header') && (
|
||||
<div className="h-[74px] bg-white flex items-center px-6 rounded-t-3xl">
|
||||
<div className='flex items-center gap-4'>
|
||||
{actions}
|
||||
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`overflow-x-auto overflow-hidden ${(actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions)) ? 'rounded-b-3xl' : 'rounded-3xl'} bg-white`}>
|
||||
<table className="w-full text-sm border-collapse min-w-[600px]">
|
||||
<tbody>
|
||||
{renderTableBody()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={pagination.onPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative mt-6 md:mt-9 w-full ${className}`}>
|
||||
{(actionsPosition === 'top' || actionsPosition === 'above-header') && renderActions()}
|
||||
|
||||
<div className={`overflow-x-auto overflow-hidden ${showHeader && actionsPosition !== 'header-replace' ? 'rounded-2xl md:rounded-3xl' : 'rounded-b-2xl md:rounded-b-3xl'} bg-white`}>
|
||||
<table className="w-full text-sm border-collapse min-w-[600px]">
|
||||
{renderHeader()}
|
||||
{actionsPosition === 'header-replace' && (
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={columns.length} className="p-0">
|
||||
{renderActions()}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{renderTableBody()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={pagination.onPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
|
||||
/*
|
||||
مثال استفاده با pagination:
|
||||
|
||||
import Table from '@/components/Table';
|
||||
import { useState } from 'react';
|
||||
|
||||
const MyComponent = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const totalPages = 10;
|
||||
|
||||
const columns = [
|
||||
{ title: 'نام', key: 'name' },
|
||||
{ title: 'ایمیل', key: 'email' },
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ id: 1, name: 'علی', email: 'ali@example.com' },
|
||||
{ id: 2, name: 'فاطمه', email: 'fateme@example.com' },
|
||||
];
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
// اینجا میتوانید API call جدید برای دریافت دادههای صفحه جدید بزنید
|
||||
};
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={data}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange: handlePageChange
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
*/
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
import { clx } from '../helpers/utils'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
|
||||
type Item = {
|
||||
icon: ReactNode,
|
||||
label: string,
|
||||
value: string,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
items: Item[],
|
||||
onChange: (value: string) => void,
|
||||
active: string,
|
||||
}
|
||||
|
||||
const Tabs: FC<Props> = (props: Props) => {
|
||||
|
||||
const SWIPER_SLIDE_STYLE = {
|
||||
overflow: 'visible !important',
|
||||
display: 'flex',
|
||||
}
|
||||
|
||||
return (
|
||||
<Swiper
|
||||
slidesPerView='auto'
|
||||
spaceBetween={20}
|
||||
className='px-4 md:px-8 xl:px-10 max-w-full w-fit items-center text-description mx-auto backdrop-blur-md border-2 border-white gap-4 md:gap-6 xl:gap-10 flex h-[60px] md:h-[70px] xl:h-[80px] rounded-[20px] md:rounded-[28px] xl:rounded-[32px] bg-white bg-opacity-45'>
|
||||
{
|
||||
props.items.map((item: Item, index: number) => {
|
||||
return (
|
||||
<SwiperSlide style={SWIPER_SLIDE_STYLE} onClick={() => props.onChange(item.value)} key={item.value} className={clx(
|
||||
'flex flex-col max-w-fit mt-[10px] md:mt-[12px] xl:mt-[15px] items-center gap-1 md:gap-2 cursor-pointer',
|
||||
index === 0 && 'pr-[15px] md:pr-[20px] xl:pr-[30px]',
|
||||
index === props.items.length - 1 && props.items.length > 3 && 'pl-[5px]',
|
||||
props.active === item.value && 'text-black'
|
||||
)}>
|
||||
{item.icon}
|
||||
<div className='text-[10px] md:text-xs'>
|
||||
{item.label}
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Swiper>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tabs
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
text: string | number | ReactNode,
|
||||
children?: ReactNode,
|
||||
dir?: string,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
const Td: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<td className={`px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${props.className}`} style={{ direction: props.dir === "ltr" ? "ltr" : "rtl" }}>
|
||||
{
|
||||
props.text ?
|
||||
props.text
|
||||
:
|
||||
props.children
|
||||
}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
export default Td
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FC, InputHTMLAttributes } from 'react'
|
||||
import Error from './Error'
|
||||
import { clx } from '../helpers/utils'
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
error_text?: string
|
||||
} & InputHTMLAttributes<HTMLTextAreaElement>
|
||||
|
||||
const Textarea: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className='w-full relative'>
|
||||
<label className='text-sm'>
|
||||
{props.label}
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
className={clx(
|
||||
'border border-border leading-7 w-full rounded-xl px-4 py-3 min-h-[100px] mt-1 text-xs',
|
||||
props.readOnly && 'bg-gray-100 border-0 text-description'
|
||||
)}
|
||||
|
||||
{...props}
|
||||
>
|
||||
|
||||
</textarea>
|
||||
{
|
||||
props.error_text &&
|
||||
<Error errorText={props.error_text} />
|
||||
}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Textarea
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CloseCircle, InfoCircle, TickCircle } from 'iconsax-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
isExiting?: boolean;
|
||||
}
|
||||
|
||||
let addToast: (toast: Toast) => void;
|
||||
|
||||
const ToastContainer: React.FC = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
// ثبت toast جدید
|
||||
const showToast = (toast: Toast) => {
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
// حذف خودکار بعد از ۳ ثانیه
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.map(t =>
|
||||
t.id === toast.id ? { ...t, isExiting: true } : t
|
||||
));
|
||||
|
||||
// حذف از DOM بعد از اتمام انیمیشن
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
|
||||
}, 300); // مدت زمان انیمیشن خروج
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// تخصیص تابع نمایش toast به متغیر سراسری
|
||||
useEffect(() => {
|
||||
addToast = showToast;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`px-4 flex items-center gap-2 backdrop-blur-2xl h-16 min-w-[300px] rounded-2xl shadow-md
|
||||
${toast.isExiting ? 'animate-slide-out-right' : 'animate-slide-in-right'} ${toast.type === 'success'
|
||||
? 'bg-white/70 text-black'
|
||||
: toast.type === 'error'
|
||||
? 'bg-white/70 text-black'
|
||||
: 'bg-white/70 text-black'
|
||||
}`}
|
||||
style={{
|
||||
animationFillMode: 'forwards',
|
||||
animationDuration: '0.3s',
|
||||
animationTimingFunction: 'ease-out'
|
||||
}}
|
||||
>
|
||||
{
|
||||
toast.type === 'success' ? <TickCircle className='size-5' color='green' /> :
|
||||
toast.type === 'error' ? <CloseCircle className='size-5' color='red' /> :
|
||||
<InfoCircle className='size-5' color='blue' />
|
||||
}
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const toast = (message?: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||
addToast({ id: Date.now().toString(), message: message || '', type });
|
||||
};
|
||||
|
||||
export default ToastContainer;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { CloseCircle } from 'iconsax-react'
|
||||
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
isMultiple?: boolean,
|
||||
onChange?: (file: File[]) => void;
|
||||
isReset?: boolean;
|
||||
}
|
||||
|
||||
const UploadBox: FC<Props> = (props: Props) => {
|
||||
|
||||
const { t } = useTranslation('global')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (props.isMultiple) {
|
||||
const array = [...files]
|
||||
array.push(acceptedFiles[0])
|
||||
setFiles(array)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
props.onChange && props.onChange(array)
|
||||
} else {
|
||||
setFiles([acceptedFiles[0]])
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
props.onChange && props.onChange([acceptedFiles[0]])
|
||||
}
|
||||
}, [files])
|
||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const array = [...files]
|
||||
array.splice(index, 1)
|
||||
setFiles(array)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
props.onChange && props.onChange(array)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (props.isReset) {
|
||||
setFiles([])
|
||||
}
|
||||
|
||||
}, [props.isReset])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='text-sm'>{props.label}</div>
|
||||
<div className='w-full h-10 mt-1 border border-border rounded-xl items-center px-2 flex gap-2.5'>
|
||||
<button {...getRootProps()} className=' w-fit h-7 rounded-lg text-xs px-6 bg-secondary'>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{t('select_file')}
|
||||
</button>
|
||||
|
||||
<div className='lg:flex gap-2 hidden'>
|
||||
{
|
||||
files?.map((item, index) => (
|
||||
<div key={index} className='flex bg-gray-200 py-1.5 px-2 rounded-lg items-center gap-1'>
|
||||
<CloseCircle onClick={() => handleRemove(index)} size={16} color='red' />
|
||||
<div className='text-xs'>{item.name}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='lg:hidden gap-2 flex flex-wrap mt-4'>
|
||||
{
|
||||
files?.map((item, index) => (
|
||||
<div key={index} className='flex bg-gray-200 py-1.5 px-2 rounded-lg items-center gap-1'>
|
||||
<CloseCircle onClick={() => handleRemove(index)} size={16} color='red' />
|
||||
<div className='text-xs'>{item.name}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadBox
|
||||
@@ -0,0 +1,161 @@
|
||||
import { CloseCircle, DocumentUpload, Gallery } from 'iconsax-react'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { clx } from '../helpers/utils';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
onChange: (file: File[]) => void;
|
||||
isMultiple?: boolean;
|
||||
isFile?: boolean,
|
||||
preview?: string[],
|
||||
onChangePreview?: (preview: string[]) => void,
|
||||
getCover?: (url: string) => void,
|
||||
coverUrl?: string,
|
||||
isReset?: boolean
|
||||
}
|
||||
|
||||
const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||
|
||||
const { t } = useTranslation('global')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [perviews, setPerviews] = useState<string[]>(props.preview ? props.preview : [])
|
||||
const [isFill, setIsFill] = useState<boolean>(false)
|
||||
const [cover, setCover] = useState<string>(props.coverUrl ? props.coverUrl : '')
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (props.isMultiple) {
|
||||
const array = [...files]
|
||||
array.push(acceptedFiles[0])
|
||||
setFiles(array)
|
||||
props.onChange(array)
|
||||
} else {
|
||||
setFiles([acceptedFiles[0]])
|
||||
props.onChange([acceptedFiles[0]])
|
||||
}
|
||||
}, [files])
|
||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const array = [...files]
|
||||
array.splice(index, 1)
|
||||
setFiles(array)
|
||||
props.onChange(array)
|
||||
}
|
||||
|
||||
const handleDeletePreview = (index: number) => {
|
||||
const array = [...perviews];
|
||||
array.splice(index, 1);
|
||||
setPerviews(array);
|
||||
if (props.onChangePreview) {
|
||||
props.onChangePreview(array);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (props.preview && props.preview.length > 0 && !isFill) {
|
||||
setPerviews(props.preview)
|
||||
console.log('preview', props.preview);
|
||||
|
||||
setIsFill(true)
|
||||
}
|
||||
|
||||
}, [props.preview, isFill])
|
||||
|
||||
useEffect(() => {
|
||||
setCover(props.coverUrl ? props.coverUrl : '')
|
||||
}, [props.coverUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.isReset) {
|
||||
setFiles([])
|
||||
setPerviews([])
|
||||
}
|
||||
}, [props.isReset])
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div {...getRootProps()} className='w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl'>
|
||||
<input {...getInputProps()} />
|
||||
<Gallery
|
||||
className='size-8'
|
||||
color='#8C90A3'
|
||||
/>
|
||||
<div className='text-description text-xs'>
|
||||
{props.label}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<DocumentUpload
|
||||
className='size-4'
|
||||
color='black'
|
||||
/>
|
||||
<div className='text-xs'>{t('upload')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
files.length > 0 || perviews ? (
|
||||
<div className='mt-4 flex gap-4 items-center'>
|
||||
{
|
||||
perviews && perviews.map((item, index) => {
|
||||
return (
|
||||
<div key={index} className='flex items-center gap-2'>
|
||||
<div key={index} className='flex relative items-center gap-2'>
|
||||
<img onClick={() => {
|
||||
if (props.getCover) {
|
||||
setCover(item)
|
||||
props.getCover(item)
|
||||
}
|
||||
}} src={item}
|
||||
className={clx(
|
||||
'size-10 rounded-full object-cover',
|
||||
cover === item ? 'border-2 border-red-400' : ''
|
||||
)} />
|
||||
<div onClick={() => handleDeletePreview(index)} className='absolute -left-2 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center'>
|
||||
<CloseCircle className='size-4 ' color='red' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
files.map((file, index) => {
|
||||
if (props.isFile) {
|
||||
return (
|
||||
<div key={index} className='flex border p-2 rounded-lg items-center gap-2'>
|
||||
<div className='flex relative items-center gap-2'>
|
||||
<div className='text-xs'>{file.name}</div>
|
||||
<div className='absolute -left-4 -top-4 shadow-md bg-white size-5 rounded-full flex justify-center items-center'>
|
||||
<CloseCircle onClick={() => handleDelete(index)} className='size-4 ' color='red' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else
|
||||
return (
|
||||
<div key={index} className='flex items-center gap-2'>
|
||||
<div key={index} className='flex relative items-center gap-2'>
|
||||
<img src={URL.createObjectURL(file)} alt={file.name} className='size-10 rounded-full object-cover' />
|
||||
<div className='absolute -left-2 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center'>
|
||||
<CloseCircle onClick={() => handleDelete(index)} className='size-4 ' color='red' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadBoxDraggble
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CloseCircle, Paperclip2 } from 'iconsax-react'
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
|
||||
|
||||
type Props = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
const UploadButton: FC<Props> = (props) => {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { title } = props
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
setFiles((prev) => [...prev, ...acceptedFiles])
|
||||
}, [])
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const array = [...files]
|
||||
array.splice(index, 1)
|
||||
setFiles(array)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4 sm:flex-row'>
|
||||
<div {...getRootProps()} className='h-10 bg-[#ECEEF5] rounded-full flex gap-2 items-center px-3 cursor-pointer'>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<Paperclip2 size={20} color='#000' />
|
||||
<div className='text-xs'>{title ? title : t('attachment_select')}</div>
|
||||
</div>
|
||||
|
||||
{files.map((file, index) => (
|
||||
<div key={file.name} className='bg-[#EBEDF5] text-xs flex gap-2 h-10 rounded-full items-center px-3'>
|
||||
<span>{file.name}</span>
|
||||
<CloseCircle size={19} color='#D52903' className='cursor-pointer' onClick={() => handleRemove(index)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadButton
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ReactNode } from "react";
|
||||
import { RowActionItem } from "../RowActionsDropdown";
|
||||
|
||||
export type RowDataType = Record<string, unknown> & { id: string | number };
|
||||
|
||||
export interface ColumnType<T extends RowDataType = RowDataType> {
|
||||
title: string;
|
||||
key: string;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
width?: string | number;
|
||||
align?: "left" | "center" | "right";
|
||||
sortable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export interface TableProps<T extends RowDataType = RowDataType> {
|
||||
columns: ColumnType<T>[];
|
||||
data?: T[];
|
||||
isLoading?: boolean;
|
||||
onRowClick?: (id: T["id"], item: T) => void;
|
||||
className?: string;
|
||||
rowClassName?: string | ((item: T, index: number) => string);
|
||||
noDataMessage?: React.ReactNode;
|
||||
headerClassName?: string;
|
||||
emptyRowsCount?: number;
|
||||
showHeader?: boolean;
|
||||
actions?: ReactNode;
|
||||
actionsClassName?: string;
|
||||
actionsPosition?: "top" | "header-replace" | "above-header";
|
||||
selectable?: boolean;
|
||||
selectedActions?: ReactNode;
|
||||
onSelectionChange?: (selectedRows: T[]) => void;
|
||||
rowActions?: (item: T) => RowActionItem[];
|
||||
pagination?: PaginationConfig;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
Reference in New Issue
Block a user