table and filter reusable component
This commit is contained in:
@@ -1,54 +1,14 @@
|
||||
# React + TypeScript + Vite
|
||||
# Mailbox App
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
A modern, right-to-left (RTL) mailbox application built with React, TypeScript, and Tailwind CSS.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Features
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
- **RTL Support:** Designed for Persian (Farsi) users with full RTL layout.
|
||||
- **Sidebar Navigation:** Access to Received, Sent, Drafts, Archive, Trash, Favorites, Spam, and Settings.
|
||||
- **Responsive Design:** Works seamlessly on desktop and mobile.
|
||||
- **Localization:** Easily translatable, with Farsi as the default language.
|
||||
- **Custom Theming:** Easily adjust primary and secondary colors via CSS variables.
|
||||
- **Reusable Components:** Includes customizable Button and Sidebar components.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
## Project Structure
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@ export default tseslint.config(
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
"i18next": "^25.0.1",
|
||||
"iconsax-react": "^0.0.8",
|
||||
"react": "^19.0.0",
|
||||
"react-date-object": "^2.1.9",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^7.5.2",
|
||||
"react-spinners": "^0.17.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
|
||||
Generated
+47
@@ -20,12 +20,21 @@ importers:
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.1.0
|
||||
react-date-object:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
react-i18next:
|
||||
specifier: ^15.5.1
|
||||
version: 15.5.1(i18next@25.0.1(typescript@5.7.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.3)
|
||||
react-loading-skeleton:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0(react@19.1.0)
|
||||
react-multi-date-picker:
|
||||
specifier: ^4.5.2
|
||||
version: 4.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-router-dom:
|
||||
specifier: ^7.5.2
|
||||
version: 7.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -1166,11 +1175,20 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
react-date-object@2.1.9:
|
||||
resolution: {integrity: sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==}
|
||||
|
||||
react-dom@19.1.0:
|
||||
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
|
||||
peerDependencies:
|
||||
react: ^19.1.0
|
||||
|
||||
react-element-popper@2.1.7:
|
||||
resolution: {integrity: sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-i18next@15.5.1:
|
||||
resolution: {integrity: sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA==}
|
||||
peerDependencies:
|
||||
@@ -1190,6 +1208,17 @@ packages:
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-loading-skeleton@3.5.0:
|
||||
resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
react-multi-date-picker@4.5.2:
|
||||
resolution: {integrity: sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-refresh@0.17.0:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2408,11 +2437,18 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
react-date-object@2.1.9: {}
|
||||
|
||||
react-dom@19.1.0(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
scheduler: 0.26.0
|
||||
|
||||
react-element-popper@2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
react-i18next@15.5.1(i18next@25.0.1(typescript@5.7.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.7.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.0
|
||||
@@ -2425,6 +2461,17 @@ snapshots:
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-loading-skeleton@3.5.0(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
||||
react-multi-date-picker@4.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-date-object: 2.1.9
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-element-popper: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-router-dom@7.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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="tr" key={rowIndex}>
|
||||
{
|
||||
Array.from({ length: tdCount }).map((_, colIndex) => (
|
||||
<Td text={''} key={colIndex}>
|
||||
<Skeleton />
|
||||
</Td>
|
||||
))
|
||||
}
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default DefaultTableSkeleton
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -16,5 +16,12 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "جستجو"
|
||||
},
|
||||
"received": {
|
||||
"title": "دریافتی ها",
|
||||
"from_date": "از تاریخ",
|
||||
"to_date": "تا تاریخ",
|
||||
"all": "همه",
|
||||
"search": "جستجو"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user