request list + structure
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import type { ButtonHTMLAttributes, FC, ReactNode } from 'react'
|
||||
import { memo } from 'react'
|
||||
import MoonLoader from "react-spinners/MoonLoader"
|
||||
import { type XOR } from '@/helpers/types'
|
||||
import { clx } from '@/helpers/utils'
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
isLoading?: boolean,
|
||||
percentage?: number,
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
XOR<{ children: ReactNode }, { label: string }>;
|
||||
|
||||
const Button: FC<Props> = memo((props: Props) => {
|
||||
|
||||
const buttonClass = clx(
|
||||
'flex rounded-xl items-center justify-center text-center h-10 text-sm bg-primary w-full',
|
||||
props.disabled && 'cursor-not-allowed opacity-60',
|
||||
props.className
|
||||
);
|
||||
|
||||
return (
|
||||
<button {...props} className={`${buttonClass} ${props.className}`} disabled={props.disabled || props.isLoading}>
|
||||
{
|
||||
props.isLoading ?
|
||||
<div className='flex gap-2 items-center'>
|
||||
{
|
||||
!props.percentage ?
|
||||
<MoonLoader size={20} color='#fff' />
|
||||
:
|
||||
<span>{props.percentage}%</span>
|
||||
}
|
||||
</div>
|
||||
:
|
||||
props.label || props.children
|
||||
}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export default Button
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect, type 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) => {
|
||||
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 text-foreground font-medium mb-2'>{props.label}</div>
|
||||
}
|
||||
<div className={clx(
|
||||
'relative',
|
||||
props.readOnly && 'readOny',
|
||||
props.label && 'mt-1'
|
||||
)}>
|
||||
<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='#8c90a3'
|
||||
className='absolute top-0 bottom-0 my-auto left-2 pointer-events-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatePickerComponent;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type FC, Fragment, type ReactNode, useEffect } from 'react'
|
||||
import HeaderModal from './HeaderModal'
|
||||
|
||||
interface Props {
|
||||
open: boolean,
|
||||
close: () => void,
|
||||
children: ReactNode,
|
||||
isHeader?: boolean,
|
||||
title_header?: string,
|
||||
width?: number
|
||||
}
|
||||
|
||||
const DefaulModal: FC<Props> = (props: Props) => {
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = 'auto'
|
||||
}
|
||||
}, [props.open])
|
||||
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{
|
||||
props.open && (
|
||||
<Fragment>
|
||||
<div style={{ maxWidth: props.width }} className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-y-auto fixed inset-0 z-[9999999] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto'>
|
||||
<div className='relative xl:h-full max-h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2'>
|
||||
<div className='h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full outline-none focus:outline-none bg-card border border-border'>
|
||||
|
||||
{
|
||||
props.isHeader && props.title_header &&
|
||||
<div onClick={props.close} className='pb-6 border-b border-border border-opacity-20'>
|
||||
<div className={`h-[5px] w-[200px] mx-auto rounded-full mb-4 xl:hidden bg-[#D1D3D7]'
|
||||
}`}></div>
|
||||
<HeaderModal close={props.close} label={props.title_header} />
|
||||
</div>
|
||||
}
|
||||
|
||||
{props.children}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onClick={props.close} className='fixed size-full bg-black/65 top-0 bottom-0 right-0 inset-0 z-50 '></div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default DefaulModal
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type 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 { type 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,224 @@
|
||||
import { type FC, useEffect, useState, type ChangeEvent, useRef } from 'react';
|
||||
import { Filter } from 'iconsax-react';
|
||||
import DatePicker from './DatePicker';
|
||||
import Input from './Input';
|
||||
import Select, { type ItemsSelectType } from './Select';
|
||||
import DefaulModal from './DefaulModal';
|
||||
import moment from 'moment-jalaali'
|
||||
import Button from './Button';
|
||||
|
||||
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 = "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"
|
||||
}) => {
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
||||
const onChangeRef = useRef(onChange);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(initialValues).length > 0) {
|
||||
setFilters(initialValues);
|
||||
const inputFields: Record<string, string> = {};
|
||||
fields.forEach(field => {
|
||||
if (field.type === 'input' && initialValues[field.name]) {
|
||||
inputFields[field.name] = initialValues[field.name] as string;
|
||||
}
|
||||
});
|
||||
setInputValues(inputFields);
|
||||
} else {
|
||||
const defaultValues: FilterValues = {};
|
||||
const defaultInputs: Record<string, string> = {};
|
||||
fields.forEach(field => {
|
||||
if ('defaultValue' in field && field.defaultValue !== undefined) {
|
||||
defaultValues[field.name] = field.defaultValue;
|
||||
if (field.type === 'input') {
|
||||
defaultInputs[field.name] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(defaultValues).length > 0) {
|
||||
setFilters(defaultValues);
|
||||
setInputValues(defaultInputs);
|
||||
onChange(defaultValues);
|
||||
}
|
||||
}
|
||||
}, [fields, initialValues, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts: Record<string, number> = {};
|
||||
|
||||
Object.keys(inputValues).forEach(fieldName => {
|
||||
timeouts[fieldName] = setTimeout(() => {
|
||||
setFilters(currentFilters => {
|
||||
const currentFilter = currentFilters[fieldName];
|
||||
const newValue = inputValues[fieldName];
|
||||
|
||||
if (currentFilter !== newValue) {
|
||||
const newFilters = { ...currentFilters, [fieldName]: newValue || null };
|
||||
onChangeRef.current(newFilters);
|
||||
return newFilters;
|
||||
}
|
||||
return currentFilters;
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.values(timeouts).forEach(timeout => clearTimeout(timeout));
|
||||
};
|
||||
}, [inputValues]);
|
||||
|
||||
const handleChange = (name: string, value: string | null) => {
|
||||
const newFilters = { ...filters, [name]: value };
|
||||
setFilters(newFilters);
|
||||
onChange(newFilters);
|
||||
};
|
||||
|
||||
const handleInputChange = (name: string, event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setInputValues(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const renderField = (field: FieldType) => {
|
||||
const currentValue = field.type === 'input' ? inputValues[field.name] : filters[field.name];
|
||||
|
||||
switch (field.type) {
|
||||
case 'date':
|
||||
return (
|
||||
<DatePicker
|
||||
key={field.name}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(value) => handleChange(field.name, moment(value, 'jYYYY/jMM/jDD').format('YYYY-MM-DD'))}
|
||||
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:min-w-[230px] md:max-w-[230px]"
|
||||
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 rounded-lg transition-colors bg-card text-card-foreground border border-border hover:bg-secondary hover:bg-opacity-80'
|
||||
>
|
||||
<Filter
|
||||
size={18}
|
||||
color='#000000'
|
||||
/>
|
||||
<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}>
|
||||
{fields.map(renderField)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DefaulModal
|
||||
open={isFiltersOpen}
|
||||
close={() => setIsFiltersOpen(false)}
|
||||
isHeader={true}
|
||||
title_header="فیلتر کردن نتایج"
|
||||
>
|
||||
<div className="pb-5 gap-4 flex flex-col-reverse mt-3">
|
||||
{otherFields.map(field => (
|
||||
<div key={field.name} className="w-full">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{field.placeholder}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
{renderField(field)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<Button
|
||||
label='بستن'
|
||||
onClick={() => setIsFiltersOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from "react";
|
||||
import { clx } from "@/helpers/utils";
|
||||
|
||||
type GridWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
desktop: number; // تعداد ستونها در دسکتاپ
|
||||
mobile: number; // تعداد ستونها در موبایل
|
||||
gapDesktop?: number; // فاصله بین آیتمها در دسکتاپ. اگر مقدار > 12 باشد به عنوان px تفسیر میشود (مثلاً 24 => 24px)
|
||||
gapMobile?: number; // فاصله بین آیتمها در موبایل. اگر مقدار > 12 باشد به عنوان px تفسیر میشود
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const GRID_COLS_CLASS: Record<number, string> = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-2",
|
||||
3: "grid-cols-3",
|
||||
4: "grid-cols-4",
|
||||
5: "grid-cols-5",
|
||||
6: "grid-cols-6",
|
||||
7: "grid-cols-7",
|
||||
8: "grid-cols-8",
|
||||
9: "grid-cols-9",
|
||||
10: "grid-cols-10",
|
||||
11: "grid-cols-11",
|
||||
12: "grid-cols-12",
|
||||
};
|
||||
|
||||
const MD_GRID_COLS_CLASS: Record<number, string> = {
|
||||
1: "md:grid-cols-1",
|
||||
2: "md:grid-cols-2",
|
||||
3: "md:grid-cols-3",
|
||||
4: "md:grid-cols-4",
|
||||
5: "md:grid-cols-5",
|
||||
6: "md:grid-cols-6",
|
||||
7: "md:grid-cols-7",
|
||||
8: "md:grid-cols-8",
|
||||
9: "md:grid-cols-9",
|
||||
10: "md:grid-cols-10",
|
||||
11: "md:grid-cols-11",
|
||||
12: "md:grid-cols-12",
|
||||
};
|
||||
|
||||
// نسخهی دسکتاپ با پیشوند lg: به صورت literal تا با purge مشکلی نداشته باشد
|
||||
const LG_GRID_COLS_CLASS: Record<number, string> = {
|
||||
1: "lg:grid-cols-1",
|
||||
2: "lg:grid-cols-2",
|
||||
3: "lg:grid-cols-3",
|
||||
4: "lg:grid-cols-4",
|
||||
5: "lg:grid-cols-5",
|
||||
6: "lg:grid-cols-6",
|
||||
7: "lg:grid-cols-7",
|
||||
8: "lg:grid-cols-8",
|
||||
9: "lg:grid-cols-9",
|
||||
10: "lg:grid-cols-10",
|
||||
11: "lg:grid-cols-11",
|
||||
12: "lg:grid-cols-12",
|
||||
};
|
||||
|
||||
// نگاشت Gap بر اساس scale رایج Tailwind: gap-N که هر N معادل 4px است (N * 4px)
|
||||
const GAP_CLASS: Record<number, string> = {
|
||||
0: "gap-0", 1: "gap-1", 2: "gap-2", 3: "gap-3", 4: "gap-4", 5: "gap-5",
|
||||
6: "gap-6", 7: "gap-7", 8: "gap-8", 9: "gap-9", 10: "gap-10", 11: "gap-11",
|
||||
12: "gap-12", 13: "gap-13", 14: "gap-14", 15: "gap-15", 16: "gap-16", 17: "gap-17",
|
||||
18: "gap-18", 19: "gap-19", 20: "gap-20", 21: "gap-21", 22: "gap-22", 23: "gap-23", 24: "gap-24",
|
||||
};
|
||||
|
||||
const LG_GAP_CLASS: Record<number, string> = {
|
||||
0: "lg:gap-0", 1: "lg:gap-1", 2: "lg:gap-2", 3: "lg:gap-3", 4: "lg:gap-4", 5: "lg:gap-5",
|
||||
6: "lg:gap-6", 7: "lg:gap-7", 8: "lg:gap-8", 9: "lg:gap-9", 10: "lg:gap-10", 11: "lg:gap-11",
|
||||
12: "lg:gap-12", 13: "lg:gap-13", 14: "lg:gap-14", 15: "lg:gap-15", 16: "lg:gap-16", 17: "lg:gap-17",
|
||||
18: "lg:gap-18", 19: "lg:gap-19", 20: "lg:gap-20", 21: "lg:gap-21", 22: "lg:gap-22", 23: "lg:gap-23", 24: "lg:gap-24",
|
||||
};
|
||||
|
||||
function clampToRange(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getGridColsClass(count: number): string {
|
||||
const safe = clampToRange(count, 1, 12);
|
||||
return GRID_COLS_CLASS[safe];
|
||||
}
|
||||
|
||||
function getLgGridColsClass(count: number): string {
|
||||
const safe = clampToRange(count, 1, 12);
|
||||
return LG_GRID_COLS_CLASS[safe];
|
||||
}
|
||||
|
||||
function getMdGridColsClass(count: number): string {
|
||||
const safe = clampToRange(count, 1, 12);
|
||||
return MD_GRID_COLS_CLASS[safe];
|
||||
}
|
||||
|
||||
function getGapClass(value?: number): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
// اگر کاربر مقدار را به صورت px فرستاده باشد (بزرگتر از 12)، آن را به scale تبدیل میکنیم: N = round(px/4)
|
||||
const interpretedAsScale = value > 12 ? Math.round(value / 4) : value;
|
||||
const safe = clampToRange(interpretedAsScale, 0, 24);
|
||||
return GAP_CLASS[safe];
|
||||
}
|
||||
|
||||
function getLgGapClass(value?: number): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const interpretedAsScale = value > 12 ? Math.round(value / 4) : value;
|
||||
const safe = clampToRange(interpretedAsScale, 0, 24);
|
||||
return LG_GAP_CLASS[safe];
|
||||
}
|
||||
|
||||
export default function GridWrapper(props: GridWrapperProps) {
|
||||
const { children, desktop, mobile, className, gapDesktop = 4, gapMobile = 4 } = props;
|
||||
|
||||
const mobileCols = getGridColsClass(mobile);
|
||||
const desktopColsMd = getMdGridColsClass(desktop);
|
||||
const desktopColsLg = getLgGridColsClass(desktop);
|
||||
|
||||
const mobileGap = getGapClass(gapMobile);
|
||||
const desktopGap = getLgGapClass(gapDesktop);
|
||||
|
||||
return (
|
||||
<div className={clx("grid", mobileCols, desktopColsMd, desktopColsLg, mobileGap, desktopGap, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type FC } from 'react'
|
||||
import { CloseCircle } from 'iconsax-react'
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
close: () => void,
|
||||
}
|
||||
|
||||
const HeaderModal: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='text-sm text-foreground font-medium'>{props.label}</div>
|
||||
<div className='size-7 rounded-full flex justify-center items-center transition-colors bg-white bg-opacity-35 hover:bg-opacity-50'>
|
||||
<CloseCircle
|
||||
size={20}
|
||||
color='#000000'
|
||||
onClick={props.close}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderModal
|
||||
@@ -0,0 +1,117 @@
|
||||
import { type FC, type InputHTMLAttributes, useEffect, useState } from 'react'
|
||||
import { clx } from '../helpers/utils';
|
||||
import { Eye, EyeSlash, 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;
|
||||
} & 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' && 'border-0 ps-10 mt-0',
|
||||
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' &&
|
||||
(showPassword ?
|
||||
<Eye onClick={() => setShowPassword((oldValue) => !oldValue)} className='w-5 absolute top-0 bottom-0 cursor-pointer my-auto left-3' /> :
|
||||
<EyeSlash onClick={() => setShowPassword((oldValue) => !oldValue)} className='w-5 absolute top-0 bottom-0 cursor-pointer my-auto left-3' />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
props.variant === 'search' &&
|
||||
<SearchNormal size={20} color='#8C90A3' className='absolute top-0 w-5 bottom-0 my-auto right-3' />
|
||||
}
|
||||
|
||||
{
|
||||
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,145 @@
|
||||
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;
|
||||
trigger?: React.ReactNode;
|
||||
topOffset?: number;
|
||||
leftOffset?: number;
|
||||
topOffsetMobile?: number;
|
||||
leftOffsetMobile?: number;
|
||||
}
|
||||
|
||||
const RowActionsDropdown: React.FC<RowActionsDropdownProps> = ({
|
||||
actions,
|
||||
className = '',
|
||||
trigger,
|
||||
topOffset = 0,
|
||||
leftOffset = 0,
|
||||
topOffsetMobile = 0,
|
||||
leftOffsetMobile = 0
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [dropdownPosition, setDropdownPosition] = 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 calculatePosition = (rect: DOMRect) => {
|
||||
const isMobile = window.innerWidth < 768
|
||||
const dropdownWidth = isMobile ? 140 : 150
|
||||
const dropdownHeight = actions.length * (isMobile ? 36 : 40)
|
||||
|
||||
// انتخاب offset بر اساس سایز صفحه
|
||||
const currentTopOffset = isMobile ? topOffsetMobile : topOffset
|
||||
const currentLeftOffset = isMobile ? leftOffsetMobile : leftOffset
|
||||
|
||||
let left = rect.left + window.scrollX - dropdownWidth + rect.width + currentLeftOffset
|
||||
let top = rect.bottom + window.scrollY + currentTopOffset
|
||||
|
||||
if (left + dropdownWidth > window.innerWidth) {
|
||||
left = rect.left + window.scrollX - dropdownWidth + currentLeftOffset
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = isMobile ? 20 : 40
|
||||
}
|
||||
|
||||
if (top + dropdownHeight > window.innerHeight + window.scrollY) {
|
||||
top = rect.top + window.scrollY - dropdownHeight + currentTopOffset
|
||||
}
|
||||
|
||||
// اطمینان از اینکه dropdown از صفحه خارج نشود
|
||||
if (left < 0) left = 20
|
||||
if (left + dropdownWidth > window.innerWidth) left = window.innerWidth - dropdownWidth - 20
|
||||
if (top < 0) top = 20
|
||||
if (top + dropdownHeight > window.innerHeight) top = window.innerHeight - dropdownHeight - 20
|
||||
|
||||
return { top, left }
|
||||
}
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
if (!isOpen && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
const calculatedPosition = calculatePosition(rect)
|
||||
setDropdownPosition(calculatedPosition)
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
const handleActionClick = (action: RowActionItem) => {
|
||||
action.onClick()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const renderDropdown = () => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed py-3 md:py-3 bg-white dark:bg-[#252526] rounded-xl md:rounded-2xl shadow-lg z-[5] min-w-[140px] md:min-w-[150px] border border-[#d0d0d0]"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
}}
|
||||
>
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleActionClick(action)}
|
||||
className={`w-full mt-1 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 dark:hover:bg-[#2d2d30] ${index === 0 ? 'rounded-t-lg' : ''} ${action.className || ''}`}
|
||||
>
|
||||
{action.icon && <span className="ml-2">{action.icon}</span>}
|
||||
<span className={`${action.label === 'حذف' ? 'text-red-500' : ''} dark:text-gray-200`}>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={handleToggle}
|
||||
className="mt-2 hover:bg-gray-100 dark:hover:bg-[#2d2d30] rounded-full transition-colors"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{trigger ? trigger : (
|
||||
<More size={16} color={'black'} className="rotate-90" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{renderDropdown()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RowActionsDropdown
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { 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 text-primary-content'>
|
||||
{props.label}
|
||||
</label>
|
||||
}
|
||||
<div className='relative'>
|
||||
<select {...props} className={clx(
|
||||
'w-full bg-white border border-border input-surface relative block appearance-none px-2.5 h-10 text-sm rounded-[10px] transition-colors',
|
||||
props.readOnly && 'bg-muted border-0 text-muted-foreground',
|
||||
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='#8c90a3' className='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,15 @@
|
||||
import { type FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
color: string
|
||||
}
|
||||
|
||||
const StatusCircle: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div style={{ background: props.color }} className='size-1.5 rounded-full'>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusCircle
|
||||
@@ -0,0 +1,15 @@
|
||||
import { type FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
color: string
|
||||
}
|
||||
|
||||
const StatusCircle: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div style={{ background: props.color }} className='size-1.5 rounded-full'>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusCircle
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { Fragment, useState, memo } from 'react';
|
||||
import DefaultTableSkeleton from '@/components/DefaultTableSkeleton';
|
||||
import Td from '@/components/Td';
|
||||
import type { TableProps, RowDataType, ColumnType } from '@/components/types/TableTypes';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import RowActionsDropdown from '@/components/RowActionsDropdown';
|
||||
import Pagination from '@/components/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, rowIndex) : (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, rowIndex) : (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] ${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, rowIndex) : 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 memo(Table) as <T extends RowDataType>(props: TableProps<T>) => React.ReactElement;
|
||||
|
||||
/*
|
||||
مثال استفاده با 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,41 @@
|
||||
import { clx } from '@/helpers/utils'
|
||||
import { type FC } from 'react'
|
||||
|
||||
type TabItem = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
items: TabItem[]
|
||||
activeTab: string
|
||||
onTabChange: (tab: string) => void
|
||||
}
|
||||
|
||||
const Tabs: FC<Props> = (props) => {
|
||||
|
||||
const { items, activeTab, onTabChange } = props
|
||||
|
||||
|
||||
return (
|
||||
<div className='flex justify-center text-sm'>
|
||||
<div className='flex gap-4 h-[52px] rounded-full bg-white px-4 items-center'>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<div onClick={() => onTabChange(item.value)} key={item.value} className={clx(
|
||||
'h-[32px] flex items-center justify-center w-[122px] cursor-pointer rounded-full',
|
||||
activeTab === item.value && 'bg-primary'
|
||||
)}>
|
||||
{item.label}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tabs
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { 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,67 @@
|
||||
import { CloseCircle, InfoCircle, TickCircle } from 'iconsax-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { setAddToast } from '../shared/toast';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
isExiting?: boolean;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
setAddToast(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 default ToastContainer;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { type FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { CloseCircle } from 'iconsax-react'
|
||||
import { t } from '@/locale';
|
||||
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
isMultiple?: boolean,
|
||||
onChange?: (file: File[]) => void;
|
||||
isReset?: boolean;
|
||||
}
|
||||
|
||||
const UploadBox: FC<Props> = (props: Props) => {
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
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]])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [files])
|
||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const array = [...files]
|
||||
array.splice(index, 1)
|
||||
setFiles(array)
|
||||
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('uploadBox.selectFile')}
|
||||
</button>
|
||||
|
||||
<div className='lg:flex gap-2 hidden'>
|
||||
{
|
||||
files.length === 0 ? (
|
||||
<div className='text-xs text-description'>هیچ فایلی انتخاب نشده است</div>
|
||||
) : (
|
||||
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>
|
||||
{files.length > 0 && (
|
||||
<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,189 @@
|
||||
import { type FC, useState, useEffect, useRef } from 'react'
|
||||
import { Microphone, Play, Pause } from 'iconsax-react'
|
||||
|
||||
type Props = {
|
||||
label?: string
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
onRecordingComplete?: (audioBlob: Blob) => void
|
||||
}
|
||||
|
||||
const VoiceRecorder: FC<Props> = ({
|
||||
label = 'پیام شما',
|
||||
placeholder = 'پیام شما',
|
||||
value,
|
||||
onChange,
|
||||
onRecordingComplete
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
chunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' })
|
||||
const url = URL.createObjectURL(audioBlob)
|
||||
setAudioUrl(url)
|
||||
onRecordingComplete?.(audioBlob)
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
} catch (error) {
|
||||
console.error('خطا در دسترسی به میکروفون:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (!audioRef.current) return
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause()
|
||||
} else {
|
||||
audioRef.current.play()
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
const updateProgress = () => {
|
||||
if (audioRef.current) {
|
||||
setCurrentTime(audioRef.current.currentTime)
|
||||
if (isPlaying) {
|
||||
animationFrameRef.current = requestAnimationFrame(updateProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (audioUrl && audioRef.current) {
|
||||
audioRef.current.addEventListener('loadedmetadata', () => {
|
||||
if (audioRef.current) {
|
||||
setDuration(audioRef.current.duration)
|
||||
}
|
||||
})
|
||||
|
||||
audioRef.current.addEventListener('ended', () => {
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
})
|
||||
}
|
||||
}, [audioUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
updateProgress()
|
||||
}
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPlaying])
|
||||
|
||||
const generateWaveBars = () => {
|
||||
const bars = []
|
||||
const barCount = 100
|
||||
const progress = duration > 0 ? currentTime / duration : 0
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const position = i / barCount
|
||||
const isPassed = position <= progress
|
||||
const baseHeight = 3
|
||||
const randomHeight = Math.random() * 20 + baseHeight
|
||||
|
||||
bars.push(
|
||||
<div
|
||||
key={i}
|
||||
className="w-[2px] rounded-full transition-all duration-100"
|
||||
style={{
|
||||
height: `${randomHeight}px`,
|
||||
backgroundColor: isPassed ? '#3B82F6' : '#E5E7EB'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return bars
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <div className="text-sm mb-1">{label}</div>}
|
||||
|
||||
<div className="w-full">
|
||||
<div className="w-full h-10 bg-white border border-border rounded-xl flex items-center px-4 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="flex-1 bg-transparent outline-none text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Microphone
|
||||
size={20}
|
||||
color={isRecording ? '#EF4444' : '#000'}
|
||||
variant={isRecording ? 'Bold' : 'Outline'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{audioUrl && (
|
||||
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="w-8 h-8 rounded-full bg-black flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} color="#fff" variant="Bold" />
|
||||
) : (
|
||||
<Play size={16} color="#fff" variant="Bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex items-center gap-[2px] h-8">
|
||||
{generateWaveBars()}
|
||||
</div>
|
||||
|
||||
<audio ref={audioRef} src={audioUrl} className="hidden" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceRecorder
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface RowDataType {
|
||||
id: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ColumnType<T extends RowDataType> {
|
||||
title: string;
|
||||
key: string;
|
||||
align?: "left" | "center" | "right";
|
||||
className?: string;
|
||||
width?: string | number;
|
||||
render?: (item: T, index?: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface PaginationType {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export interface ActionType {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface TableProps<T extends RowDataType> {
|
||||
columns: ColumnType<T>[];
|
||||
data?: T[];
|
||||
isLoading?: boolean;
|
||||
onRowClick?: (id: string | number, item: T) => void;
|
||||
className?: string;
|
||||
rowClassName?: string | ((item: T, index: number) => string);
|
||||
noDataMessage?: string;
|
||||
headerClassName?: string;
|
||||
emptyRowsCount?: number;
|
||||
showHeader?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
actionsPosition?: "top" | "above-header" | "bottom" | "header-replace";
|
||||
selectable?: boolean;
|
||||
selectedActions?: React.ReactNode;
|
||||
onSelectionChange?: (selectedRows: T[]) => void;
|
||||
rowActions?: (item: T) => ActionType[];
|
||||
pagination?: PaginationType;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
|
||||
import { clx } from "@/helpers/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={clx("flex items-center justify-center text-current")}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
Reference in New Issue
Block a user