table and filter reusable component

This commit is contained in:
hamid zarghami
2025-04-28 14:13:11 +03:30
parent 53212445c6
commit d11837fd3f
18 changed files with 706 additions and 55 deletions
+1
View File
@@ -1,5 +1,6 @@
import { FC } from 'react'
import './assets/fonts/irancell/style.css'
import 'react-loading-skeleton/dist/skeleton.css'
import { BrowserRouter } from 'react-router-dom'
import Main from './shared/Main'
import i18next from 'i18next'
+87
View File
@@ -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;
+30
View File
@@ -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="tr" key={rowIndex}>
{
Array.from({ length: tdCount }).map((_, colIndex) => (
<Td text={''} key={colIndex}>
<Skeleton />
</Td>
))
}
</tr>
))
}
</Fragment>
)
}
export default DefaultTableSkeleton
+140
View File
@@ -0,0 +1,140 @@
import { FC, useEffect, useState, ChangeEvent } from '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-10 flex justify-between items-center",
fieldClassName = "flex gap-4",
searchField = "search" // پیش‌فرض فیلد سرچ با نام search
}) => {
const [filters, setFilters] = useState<FilterValues>({});
// تنظیم مقادیر اولیه
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="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={fieldClassName}>
{otherFields.map(renderField)}
</div>
{searchFieldObj && (
<div>
{renderField(searchFieldObj)}
</div>
)}
</div>
);
};
export default Filters;
+6 -3
View File
@@ -33,9 +33,9 @@ const Input: FC<Props> = (props: Props) => {
const [search, setSearch] = useState<string>('')
const inputClass = clx(
'w-full h-10 text-black block px-4 text-xs rounded-xl border border-border',
'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' && 'bg-[#EEF0F7] border-0 ps-10',
props.variant === 'search' && 'ps-10',
props.className
);
@@ -84,7 +84,10 @@ const Input: FC<Props> = (props: Props) => {
{props.label}
</label>
<div className='w-full relative mt-1'>
<div className={clx(
'w-full relative',
props.label && 'mt-1'
)}>
<input {...props} onChange={(e) => {
setSearch(e.target.value)
handleInputChange(e)
+65
View File
@@ -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
+146
View File
@@ -0,0 +1,146 @@
import React, { Fragment, ReactNode } from 'react';
import DefaultTableSkeleton from './DefaultTableSkeleton';
import Td from './Td';
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 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';
}
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',
}: TableProps<T>): React.ReactElement => {
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 `p-3 ${alignClass} ${column.className || ''}`.trim();
};
const renderTableBody = () => {
if (isLoading) {
return (
<Fragment>
<DefaultTableSkeleton tdCount={columns.length} trCount={emptyRowsCount} />
</Fragment>
);
}
if (data.length === 0) {
return (
<tr>
<td colSpan={columns.length} className="p-8 text-center text-gray-500">
{noDataMessage}
</td>
</tr>
);
}
return data.map((item, rowIndex) => (
<tr
key={item.id}
className={`tr border-b hover:bg-gray-50 ${onRowClick ? 'cursor-pointer' : ''} ${getRowClassName(item, rowIndex)}`}
onClick={() => onRowClick && onRowClick(item.id, item)}
>
{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>
))}
</tr>
));
};
const renderHeader = () => {
if (!showHeader || actionsPosition === 'header-replace') return null;
return (
<thead className={`thead ${headerClassName}`}>
<tr>
{columns.map((col) => (
<th
key={col.key}
className={getCellClassName(col)}
style={{ width: col.width }}
>
<Td text={col.title} />
</th>
))}
</tr>
</thead>
);
};
const renderActions = () => {
if (!actions) return null;
return (
<div className={'action'}>
{actions}
</div>
);
};
return (
<div className={`relative overflow-x-auto rounded-3xl mt-9 w-full ${className}`}>
{(actionsPosition === 'top' || actionsPosition === 'above-header') && renderActions()}
<table className="w-full text-sm border-collapse">
{renderHeader()}
{actionsPosition === 'header-replace' && (
<thead>
<tr>
<th colSpan={columns.length} className="p-0">
{renderActions()}
</th>
</tr>
</thead>
)}
<tbody>
{renderTableBody()}
</tbody>
</table>
</div>
);
};
export default Table;
+22
View File
@@ -0,0 +1,22 @@
import { FC, ReactNode } from 'react'
interface Props {
text: string,
children?: ReactNode,
dir?: string,
}
const Td: FC<Props> = (props: Props) => {
return (
<td className='td' style={{ direction: props.dir === "ltr" ? "ltr" : "rtl" }}>
{
props.text ?
props.text
:
props.children
}
</td>
)
}
export default Td
+33
View File
@@ -44,5 +44,38 @@ textarea::placeholder {
@theme {
--color-primary: #000000;
--color-secondary: #eaecf4;
--color-border: #d0d0d0;
--color-description: #888888;
--radius-2.5: 10px;
}
td {
@apply px-6 py-4 whitespace-nowrap text-xs;
}
.thead {
@apply h-[69px] bg-white text-sm text-[#8C90A3] rounded-3xl overflow-hidden;
}
.tr {
@apply w-full h-[74px] bg-white border-t border-[#EAEDF5] hover:bg-[#F4F5F9];
}
.action {
@apply h-[74px] bg-white flex items-center px-6;
}
.rmdp-input {
min-height: 40px;
background-color: white;
border-radius: 12px !important;
border: 1px solid #d0d0d0 !important;
font-size: 12px !important;
width: 100% !important;
margin: 0px;
padding-right: 16px !important;
}
.readOny .rmdp-input {
background-color: #f5f5f5 !important;
border: none !important;
}
.rmdp-container {
width: 100%;
}
+7
View File
@@ -16,5 +16,12 @@
},
"header": {
"search": "جستجو"
},
"received": {
"title": "دریافتی ها",
"from_date": "از تاریخ",
"to_date": "تا تاریخ",
"all": "همه",
"search": "جستجو"
}
}
+95
View File
@@ -0,0 +1,95 @@
import Filters, { FilterValues } from '../../components/Filters';
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Table, { ColumnType } from '../../components/Table';
import { InfoCircle, More, Refresh2 } from 'iconsax-react';
interface Message extends Record<string, unknown> {
id: string;
title: string;
sender: string;
date: string;
read: boolean;
}
const List: FC = () => {
const { t } = useTranslation();
const [isLoading] = useState(false);
const messageData: Message[] = [
{ id: '1', title: 'اولین پیام', sender: 'علی محمدی', date: '1402/05/12', read: true },
{ id: '2', title: 'دومین پیام', sender: 'زهرا احمدی', date: '1402/05/13', read: true },
{ id: '3', title: 'جلسه هفتگی', sender: 'مدیریت', date: '1402/05/14', read: true },
];
const columns: ColumnType<Message>[] = [
{
key: 'title',
title: 'عنوان پیام',
render: (item) => (
<div className="flex items-center">
{!item.read && <div className="w-2 h-2 rounded-full bg-blue-500 mr-2" />}
<span>{item.title}</span>
</div>
)
},
{ key: 'sender', title: 'فرستنده' },
{ key: 'date', title: 'تاریخ', align: 'center' },
];
const tableActions = (
<div className='flex items-center gap-4'>
<Refresh2 size={20} color='black' />
<More size={20} color='black' className='rotate-90' />
</div>
);
const handleFilterChange = (newFilters: FilterValues) => {
console.log('Applied filters:', newFilters);
};
return (
<div className='mt-4'>
<h1>{t('received.title')}</h1>
<Filters
fields={[
{ type: 'date', name: 'from_date', placeholder: t('received.from_date') },
{ type: 'date', name: 'to_date', placeholder: t('received.to_date') },
{
type: 'select',
name: 'status',
placeholder: t('received.all'),
options: [
{ value: 'all', label: t('received.all') },
{ value: 'read', label: t('received.read') },
{ value: 'unread', label: t('received.unread') }
]
},
{ type: 'input', name: 'search', placeholder: t('received.search') }
]}
onChange={handleFilterChange}
className="mt-8 flex justify-between items-center"
searchField="search"
/>
<Table<Message>
columns={columns}
data={messageData}
isLoading={isLoading}
showHeader={false}
actions={tableActions}
onRowClick={(id) => console.log(`کلیک روی پیام با شناسه ${id}`)}
noDataMessage={
<div className="flex flex-col items-center py-6">
<InfoCircle size={40} className="text-gray-300 mb-2" />
<div>هیچ پیامی یافت نشد</div>
</div>
}
/>
</div>
)
}
export default List
+3
View File
@@ -1,11 +1,14 @@
import { FC } from 'react'
import { Paths } from '@/utils/Paths'
import { Routes, Route } from 'react-router-dom'
import Home from '@/pages/home/Home'
import ReceivedList from '@/pages/received/List'
const AppRouter: FC = () => {
return (
<Routes>
<Route path='/' element={<Home />} />
<Route path={Paths.received} element={<ReceivedList />} />
</Routes>
)
}
+1
View File
@@ -33,6 +33,7 @@ const Header: FC = () => {
<Input
variant='search'
placeholder={t('header.search')}
className='bg-secondary border-none'
/>
</div>
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
+8 -1
View File
@@ -1,5 +1,6 @@
import { FC } from 'react'
import SideBar from './SideBar'
import AppRouter from '@/router/AppRouter'
import Header from './Header'
const Main: FC = () => {
@@ -7,7 +8,13 @@ const Main: FC = () => {
<div className='p-4 overflow-hidden'>
<SideBar />
<Header />
<div className='flex-1 xl:ms-[269px] mt-[68px] xl:mt-[81px]'>
<div className={`overflow-auto w-[${window.innerWidth}] max-h-[calc(100vh-113px)]`}>
<div className='xl:pb-20 pb-32'>
<AppRouter />
</div>
</div>
</div>
</div>
)
}