clearble inputs
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-07 19:42:24 +03:30
parent d3943feaf1
commit 0889a61514
12 changed files with 628 additions and 298 deletions
+75 -18
View File
@@ -3,7 +3,7 @@ import DatePicker from 'react-multi-date-picker';
import persian from 'react-date-object/calendars/persian'; import persian from 'react-date-object/calendars/persian';
import persian_fa from 'react-date-object/locales/persian_fa'; import persian_fa from 'react-date-object/locales/persian_fa';
import DateObject from 'react-date-object'; import DateObject from 'react-date-object';
import { Calendar } from 'iconsax-react'; import { Calendar, CloseCircle } from 'iconsax-react';
import { clx } from '@/helpers/utils'; import { clx } from '@/helpers/utils';
type Props = { type Props = {
@@ -16,8 +16,36 @@ type Props = {
className?: string; className?: string;
label?: string, label?: string,
readOnly?: boolean; readOnly?: boolean;
/** When false, opening the calendar does not auto-select today's date. */
onOpenPickNewDate?: boolean;
clearable?: boolean;
}; };
const parseDefaultValue = (defaultValue: string): DateObject | null => {
const gregorian = new DateObject({
date: defaultValue,
format: 'YYYY-MM-DD',
calendar: persian,
locale: persian_fa,
});
if (gregorian.isValid) {
return gregorian;
}
const jalali = new DateObject({
date: defaultValue,
format: 'jYYYY/jMM/jDD',
calendar: persian,
locale: persian_fa,
});
return jalali.isValid ? jalali : null;
};
const formatJalaliDate = (date: DateObject) =>
`${date.year}/${date.month.number}/${date.day}`;
const DatePickerComponent: FC<Props> = (props) => { const DatePickerComponent: FC<Props> = (props) => {
const [value, setValue] = useState<DateObject | null>(null); const [value, setValue] = useState<DateObject | null>(null);
@@ -28,22 +56,36 @@ const DatePickerComponent: FC<Props> = (props) => {
}, [props.reset]); }, [props.reset]);
useEffect(() => { useEffect(() => {
if (value) { if (props.reset) return;
const formattedDate = `${value.year}/${value.month.number}/${value.day}`;
props.onChange(formattedDate);
}
}, [value]);
useEffect(() => { if (props.defaultValue) {
if (props.defaultValue && !value) { setValue(parseDefaultValue(props.defaultValue));
const defaultDate = new DateObject({ } else {
date: props.defaultValue, setValue(null);
calendar: persian,
locale: persian_fa,
});
setValue(defaultDate);
} }
}, [props.defaultValue, value]); }, [props.defaultValue, props.reset]);
const handleChange = (
date: DateObject | DateObject[] | null,
{ isTyping }: { isTyping: boolean },
) => {
if (isTyping) return;
if (!date || Array.isArray(date)) {
setValue(null);
props.onChange('');
return;
}
const selected = date as DateObject;
setValue(selected);
props.onChange(formatJalaliDate(selected));
};
const handleClear = () => {
setValue(null);
props.onChange('');
};
return ( return (
<div className="w-full"> <div className="w-full">
@@ -54,15 +96,20 @@ const DatePickerComponent: FC<Props> = (props) => {
<div className={clx( <div className={clx(
'relative', 'relative',
props.readOnly && 'readOny', props.readOnly && 'readOny',
props.label && 'mt-1' props.label && 'mt-1',
props.clearable && Boolean(value) && 'date-picker--clearable',
)}> )}>
<DatePicker <DatePicker
placeholder={props.placeholder} placeholder={props.placeholder}
value={value} value={value}
onChange={(date) => setValue(date as DateObject)} onChange={handleChange}
onOpenPickNewDate={props.onOpenPickNewDate ?? false}
editable={false}
calendar={persian} calendar={persian}
locale={persian_fa} locale={persian_fa}
calendarPosition="bottom-right" calendarPosition="bottom-right"
portal
zIndex={50}
className={props.className} className={props.className}
readOnly={props.readOnly} readOnly={props.readOnly}
/> />
@@ -77,9 +124,19 @@ const DatePickerComponent: FC<Props> = (props) => {
color='#8c90a3' color='#8c90a3'
className='absolute top-0 bottom-0 my-auto left-2 pointer-events-none' className='absolute top-0 bottom-0 my-auto left-2 pointer-events-none'
/> />
{props.clearable && value && (
<button
type="button"
onClick={handleClear}
className="absolute top-0 bottom-0 my-auto left-9 z-10 flex items-center"
aria-label="پاک کردن"
>
<CloseCircle size={16} color="#8C90A3" />
</button>
)}
</div> </div>
</div> </div>
); );
}; };
export default DatePickerComponent; export default DatePickerComponent;
+24 -3
View File
@@ -122,6 +122,14 @@ const Filters: FC<FiltersProps> = ({
const handleInputChange = (name: string, event: ChangeEvent<HTMLInputElement>) => { const handleInputChange = (name: string, event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value; const value = event.target.value;
setInputValues(prev => ({ ...prev, [name]: value })); setInputValues(prev => ({ ...prev, [name]: value }));
if (!value) {
handleChange(name, null);
}
};
const hasFieldValue = (field: FieldType) => {
const currentValue = field.type === 'input' ? inputValues[field.name] : filters[field.name];
return Boolean(currentValue);
}; };
const renderField = (field: FieldType) => { const renderField = (field: FieldType) => {
@@ -133,7 +141,18 @@ const Filters: FC<FiltersProps> = ({
<DatePicker <DatePicker
key={field.name} key={field.name}
placeholder={field.placeholder} placeholder={field.placeholder}
onChange={(value) => handleChange(field.name, moment(value, 'jYYYY/jMM/jDD').format('YYYY-MM-DD'))} clearable={hasFieldValue(field)}
onChange={(value) => {
if (!value?.trim()) {
handleChange(field.name, null);
return;
}
const parsed = moment(value, 'jYYYY/jMM/jDD', true);
if (!parsed.isValid()) return;
handleChange(field.name, parsed.format('YYYY-MM-DD'));
}}
defaultValue={currentValue || field.defaultValue || ''} defaultValue={currentValue || field.defaultValue || ''}
/> />
); );
@@ -143,8 +162,9 @@ const Filters: FC<FiltersProps> = ({
<Select <Select
key={field.name} key={field.name}
placeholder={field.placeholder} placeholder={field.placeholder}
onChange={(e) => handleChange(field.name, e.target.value)} onChange={(e) => handleChange(field.name, e.target.value || null)}
defaultValue={currentValue || field.defaultValue || ''} value={currentValue || ''}
clearable={hasFieldValue(field)}
items={field.options} items={field.options}
/> />
); );
@@ -157,6 +177,7 @@ const Filters: FC<FiltersProps> = ({
variant="search" variant="search"
className="w-full md:min-w-[230px] md:max-w-[230px]" className="w-full md:min-w-[230px] md:max-w-[230px]"
value={currentValue || field.defaultValue || ''} value={currentValue || field.defaultValue || ''}
clearable={hasFieldValue(field)}
onChange={(e) => handleInputChange(field.name, e)} onChange={(e) => handleInputChange(field.name, e)}
/> />
); );
+22 -1
View File
@@ -1,6 +1,6 @@
import { type FC, type InputHTMLAttributes, useEffect, useState } from 'react' import { type FC, type InputHTMLAttributes, useEffect, useState } from 'react'
import { clx } from '../helpers/utils'; import { clx } from '../helpers/utils';
import { Eye, EyeSlash, SearchNormal } from 'iconsax-react'; import { CloseCircle, Eye, EyeSlash, SearchNormal } from 'iconsax-react';
import Error from './Error'; import Error from './Error';
type Variant = "floating_outlined" | "primary" | "search"; type Variant = "floating_outlined" | "primary" | "search";
@@ -15,6 +15,7 @@ type Props = {
seprator?: boolean; seprator?: boolean;
isNotRequired?: boolean; isNotRequired?: boolean;
onChangeSearchFinal?: (value: string) => void; onChangeSearchFinal?: (value: string) => void;
clearable?: boolean;
} & InputHTMLAttributes<HTMLInputElement> } & InputHTMLAttributes<HTMLInputElement>
const formatNumber = (value: string | number): string => { const formatNumber = (value: string | number): string => {
@@ -32,10 +33,13 @@ const Input: FC<Props> = (props: Props) => {
const [showPassword, setShowPassword] = useState<boolean>(false) const [showPassword, setShowPassword] = useState<boolean>(false)
const [search, setSearch] = useState<string>('') const [search, setSearch] = useState<string>('')
const hasClearValue = Boolean(props.clearable && props.value);
const inputClass = clx( const inputClass = clx(
'w-full bg-white 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.readOnly && 'bg-gray-100 border-0 text-description',
props.variant === 'search' && 'border-0 ps-10 mt-0', props.variant === 'search' && 'border-0 ps-10 mt-0',
hasClearValue && 'pl-10',
props.className props.className
); );
@@ -103,6 +107,23 @@ const Input: FC<Props> = (props: Props) => {
<SearchNormal size={20} color='#8C90A3' className='absolute top-0 w-5 bottom-0 my-auto right-3' /> <SearchNormal size={20} color='#8C90A3' className='absolute top-0 w-5 bottom-0 my-auto right-3' />
} }
{
hasClearValue &&
<button
type="button"
onClick={() => {
setSearch('');
props.onChange?.({
target: { value: '' },
} as React.ChangeEvent<HTMLInputElement>);
}}
className="absolute top-0 bottom-0 my-auto left-3 z-10 flex items-center"
aria-label="پاک کردن"
>
<CloseCircle size={16} color="#8C90A3" />
</button>
}
{ {
props.error_text && props.error_text &&
<Error <Error
+23 -2
View File
@@ -1,6 +1,6 @@
import type { FC, SelectHTMLAttributes } from 'react' import type { ChangeEvent, FC, SelectHTMLAttributes } from 'react'
import { clx } from '../helpers/utils' import { clx } from '../helpers/utils'
import { ArrowDown2 } from 'iconsax-react' import { ArrowDown2, CloseCircle } from 'iconsax-react'
export type ItemsSelectType = { export type ItemsSelectType = {
value: string | number, value: string | number,
@@ -13,6 +13,7 @@ type Props = {
placeholder?: string, placeholder?: string,
label?: string, label?: string,
readOnly?: boolean, readOnly?: boolean,
clearable?: boolean,
} & SelectHTMLAttributes<HTMLSelectElement> } & SelectHTMLAttributes<HTMLSelectElement>
const Select: FC<Props> = ({ const Select: FC<Props> = ({
@@ -22,9 +23,18 @@ const Select: FC<Props> = ({
placeholder, placeholder,
label, label,
readOnly, readOnly,
clearable,
...selectProps ...selectProps
}) => { }) => {
const isControlled = selectProps.value !== undefined const isControlled = selectProps.value !== undefined
const hasClearValue = Boolean(clearable && selectProps.value)
const handleClear = () => {
selectProps.onChange?.({
target: { value: '' },
currentTarget: { value: '' },
} as ChangeEvent<HTMLSelectElement>);
};
return ( return (
<div className='w-full'> <div className='w-full'>
@@ -41,6 +51,7 @@ const Select: FC<Props> = ({
className={clx( 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', 'w-full bg-white border border-border input-surface relative block appearance-none px-2.5 h-10 text-sm rounded-[10px] transition-colors',
readOnly && 'bg-muted border-0 text-muted-foreground', readOnly && 'bg-muted border-0 text-muted-foreground',
hasClearValue && 'pl-10',
className, className,
label && 'mt-1' label && 'mt-1'
)}> )}>
@@ -61,6 +72,16 @@ const Select: FC<Props> = ({
</select> </select>
<ArrowDown2 size={16} color='#8c90a3' className='absolute z-0 top-3 left-2 pointer-events-none' <ArrowDown2 size={16} color='#8c90a3' className='absolute z-0 top-3 left-2 pointer-events-none'
/> />
{hasClearValue && (
<button
type="button"
onClick={handleClear}
className="absolute top-0 bottom-0 my-auto left-8 z-10 flex items-center"
aria-label="پاک کردن"
>
<CloseCircle size={16} color="#8C90A3" />
</button>
)}
</div> </div>
{ {
error_text && error_text !== '' ? error_text && error_text !== '' ?
+5
View File
@@ -61,10 +61,15 @@ tbody tr:nth-child(odd) {
width: 100% !important; width: 100% !important;
margin: 0px; margin: 0px;
padding-right: 16px !important; padding-right: 16px !important;
padding-left: 16px !important;
transition: transition:
background-color 0.3s ease, background-color 0.3s ease,
border-color 0.3s ease; border-color 0.3s ease;
} }
.date-picker--clearable .rmdp-input {
padding-left: 52px !important;
}
.readOny .rmdp-input { .readOny .rmdp-input {
background-color: #f5f5f5 !important; background-color: #f5f5f5 !important;
border: none !important; border: none !important;
+41 -11
View File
@@ -1,4 +1,5 @@
import { type FC, useEffect, useRef, useState } from "react"; import { type FC, useEffect, useRef, useState } from "react";
import { CloseCircle } from "iconsax-react";
import { clx } from "@/helpers/utils"; import { clx } from "@/helpers/utils";
import { useDesignerSearch } from "./hooks/useDesignerSearch"; import { useDesignerSearch } from "./hooks/useDesignerSearch";
import type { AdminItemType } from "../admin/types/Types"; import type { AdminItemType } from "../admin/types/Types";
@@ -51,22 +52,51 @@ const DesignerSearch: FC<DesignerSearchProps> = ({
const displayLabel = (designer: AdminItemType) => const displayLabel = (designer: AdminItemType) =>
`${designer.firstName} ${designer.lastName}`; `${designer.firstName} ${designer.lastName}`;
useEffect(() => {
if (!value) {
setSearch("");
}
}, [value]);
const handleClear = () => {
setSearch("");
setIsOpen(false);
onChange?.("");
};
const hasValue = Boolean(value || search);
return ( return (
<div ref={wrapperRef} className={clx("w-full relative", className)}> <div ref={wrapperRef} className={clx("w-full relative", className)}>
{label && ( {label && (
<label className="text-sm text-primary-content block mb-1">{label}</label> <label className="text-sm text-primary-content block mb-1">{label}</label>
)} )}
<input <div className="relative">
type="text" <input
value={search} type="text"
onChange={(e) => { value={search}
setSearch(e.target.value); onChange={(e) => {
setIsOpen(true); setSearch(e.target.value);
}} setIsOpen(true);
onFocus={() => setIsOpen(true)} }}
placeholder={placeholder} onFocus={() => setIsOpen(true)}
className="w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border" placeholder={placeholder}
/> className={clx(
"w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border",
hasValue && "pl-10",
)}
/>
{hasValue && (
<button
type="button"
onClick={handleClear}
className="absolute top-0 bottom-0 my-auto left-3 z-10 flex items-center"
aria-label="پاک کردن"
>
<CloseCircle size={16} color="#8C90A3" />
</button>
)}
</div>
{isOpen && debouncedSearch.length > 0 && ( {isOpen && debouncedSearch.length > 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border border-border rounded-xl shadow-lg max-h-60 overflow-auto"> <div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border border-border rounded-xl shadow-lg max-h-60 overflow-auto">
{isFetching ? ( {isFetching ? (
+163 -250
View File
@@ -1,257 +1,170 @@
import Filters, { type FilterValues } from '@/components/Filters' import { type FC, useMemo, useState } from "react";
import { type FC, useEffect, useMemo, useState } from 'react' import { Link } from "react-router-dom";
import Table from '@/components/Table' import { AddSquare, ArrowDown2, Filter } from "iconsax-react";
import Tabs from '@/components/Tabs' import Filters from "@/components/Filters";
import { AddSquare, Edit2, Printer } from 'iconsax-react' import Table from "@/components/Table";
import { useGetOrders } from './hooks/useOrderData' import Tabs from "@/components/Tabs";
import moment from 'moment-jalaali' import Button from "@/components/Button";
import { Link, useSearchParams } from 'react-router-dom' import RefreshButton from "@/components/RefreshButton";
import { Paths } from '@/config/Paths' import { Paths } from "@/config/Paths";
import type { OrderListItemType } from './types/Types' import { clx } from "@/helpers/utils";
import Button from '@/components/Button' import UserSearch from "../user/UserSearch";
import RefreshButton from '@/components/RefreshButton' import DesignerSearch from "../designer/DesignerSearch";
import UserSearch from '../user/UserSearch' import CategoriesSelect from "../product/components/CategoriesSelect";
import DesignerSearch from '../designer/DesignerSearch' import { orderListTabs } from "./constants/orderStatus";
import CategoriesSelect from '../product/components/CategoriesSelect' import { orderListColumns } from "./components/OrderListColumns";
import { getOrderStatusBadgeClass, getOrderStatusLabel, orderListTabStatuses, orderListTabs } from './constants/orderStatus' import { useGetOrders } from "./hooks/useOrderData";
import { TabOrderListEnum } from './enum/OrderEnum' import {
ORDER_LIST_FILTER_FIELDS,
useOrderListParams,
} from "./hooks/useOrderListParams";
import type { OrderListItemType } from "./types/Types";
import { TabOrderListEnum } from "./enum/OrderEnum";
const countActiveFilters = (params: {
search?: string;
from?: string;
to?: string;
userId?: string;
designerId?: string;
categoryId?: string;
}) =>
[params.search, params.from, params.to, params.userId, params.designerId, params.categoryId]
.filter(Boolean).length;
const OrdersList: FC = () => { const OrdersList: FC = () => {
const [searchParams, setSearchParams] = useSearchParams() const [isFiltersExpanded, setIsFiltersExpanded] = useState(false);
const [activeTab, setActiveTab] = useState<TabOrderListEnum>( const {
() => (searchParams.get('tab') as TabOrderListEnum) || TabOrderListEnum.IN_PROGRESS, params,
) queryParams,
const [designId, setDesignId] = useState<string | undefined>( filterInitialValues,
() => searchParams.get('designerId') ?? searchParams.get('designId') ?? undefined, setTab,
) setFilters,
const [categoryId, setCategoryId] = useState<string | undefined>( updateParams,
() => searchParams.get('categoryId') ?? undefined, } = useOrderListParams();
)
const [userId, setUserId] = useState<string | undefined>(
() => searchParams.get('userId') ?? undefined,
)
const [filters, setFilters] = useState<FilterValues>({})
useEffect(() => { const { data, refetch, isFetching } = useGetOrders(queryParams);
const nextCategoryId = searchParams.get('categoryId') ?? undefined const activeFilterCount = useMemo(() => countActiveFilters(params), [params]);
const nextUserId = searchParams.get('userId') ?? undefined const hasActiveFilters = activeFilterCount > 0;
const nextDesignId =
searchParams.get('designerId') ?? searchParams.get('designId') ?? undefined
const nextTab = (searchParams.get('tab') as TabOrderListEnum) || TabOrderListEnum.IN_PROGRESS
setCategoryId(nextCategoryId) return (
setUserId(nextUserId) <div className="mt-5 space-y-6">
setDesignId(nextDesignId) <div className="flex flex-wrap items-center justify-between gap-4">
setActiveTab(nextTab) <div>
}, [searchParams]) <h1 className="text-lg font-light">سفارشها</h1>
<p className="mt-1 text-sm text-gray-500">
const updateSearchParam = (key: string, value?: string) => { مدیریت و پیگیری سفارشهای ثبتشده
setSearchParams((current) => { </p>
const next = new URLSearchParams(current)
if (value) {
next.set(key, value)
} else {
next.delete(key)
}
return next
}, { replace: true })
}
const statuses = useMemo(
() => orderListTabStatuses[activeTab],
[activeTab],
)
const { data, refetch, isFetching } = useGetOrders({ designId, categoryId, userId, statuses, ...filters })
const handleTabChange = (tab: string) => {
const nextTab = tab as TabOrderListEnum
setActiveTab(nextTab)
updateSearchParam('tab', nextTab)
}
return (
<div className='mt-5'>
<div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>سفارش ها</h1>
<div className='flex items-center gap-3'>
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
<Link to={Paths.convertToOrder}>
<Button className='w-fit px-6'>
<div className='flex gap-2 items-center'>
<AddSquare size={18} color='black' />
سفارش جدید
</div>
</Button>
</Link>
</div>
</div>
<div className='mt-8 flex flex-wrap items-end gap-4'>
<Filters
fields={[
{
name: 'search',
type: 'input',
placeholder: 'جستجو',
},
{
name: 'from',
type: 'date',
placeholder: 'از تاریخ',
},
{
name: 'to',
type: 'date',
placeholder: 'تا تریخ',
},
]}
onChange={setFilters} />
</div>
<div className='flex mt-6 gap-4'>
<UserSearch
placeholder='جستجوی کاربر'
className='w-[220px]'
value={userId ?? ''}
onChange={(id) => {
const nextUserId = id || undefined
setUserId(nextUserId)
updateSearchParam('userId', nextUserId)
}}
/>
<DesignerSearch
className='w-[220px]'
placeholder='جستجوی طراح'
value={designId ?? ''}
onChange={(id) => {
const nextDesignId = id || undefined
setDesignId(nextDesignId)
updateSearchParam('designerId', nextDesignId)
}}
/>
<div className='w-[220px]'>
<CategoriesSelect
isDisableShowLable
placeholder='جستجوی نوع'
value={categoryId ?? ''}
onChange={(e) => {
const nextCategoryId = e.target.value || undefined
setCategoryId(nextCategoryId)
updateSearchParam('categoryId', nextCategoryId)
}}
/>
</div>
</div>
<div className='mt-8'>
<Tabs
activeTab={activeTab}
items={[...orderListTabs]}
onTabChange={handleTabChange}
/>
</div>
<div className='mt-8'>
<Table<OrderListItemType>
columns={[
{
key: 'orderNumber',
title: 'شماره',
},
{
key: 'title',
title: 'عنوان',
render: (item) => (
<Link
to={Paths.order.details + item.id}
className="text-[#0037FF] hover:underline"
>
{item.title}
</Link>
),
},
{
key: 'customer',
title: 'مشتری',
render: (item) => {
return (
<div>
{item.user?.firstName ? item.user?.firstName + ' ' + item.user?.lastName : item.user?.phone}
</div>
)
}
},
{
key: 'product',
title: 'نام محصول',
render: (item) => (
<div>{item.product?.title ?? '—'}</div>
),
},
{
key: 'designer',
title: 'مجری / طراح',
render: (item) => {
const designer = item.designer
if (!designer || typeof designer === 'string') {
return <div>{typeof designer === 'string' ? designer : '—'}</div>
}
const name = `${designer.firstName ?? ''} ${designer.lastName ?? ''}`.trim()
return <div>{name || designer.phone || '—'}</div>
},
},
{
key: 'status',
title: 'وضعیت',
render: (item) => (
<span
className={`inline-flex h-6 items-center rounded-full px-2.5 text-xs ${getOrderStatusBadgeClass(item.status)}`}
>
{getOrderStatusLabel(item.status)}
</span>
),
},
{
key: 'createdAt',
title: 'تاریخ ایجاد ',
render: (item) => {
return (
<div>{moment(item.createdAt).format('jYYYY-jMM-jDD')}</div>
)
}
},
{
key: 'actions',
title: '',
render: (item) => {
return (
<div className='flex gap-2 items-center'>
<Link title='فرم چاپ' to={Paths.orderPrint + item.id}>
<Printer
size={20}
color='black'
/>
</Link>
<Link to={Paths.order.edit + item.id}>
<Edit2 size={20} color='#0037FF' />
</Link>
{/* <Receipt21 size={20} color='#FF8000' /> */}
</div>
)
}
},
]}
data={data?.data}
/>
</div>
</div> </div>
)
}
export default OrdersList <div className="flex items-center gap-3">
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
<Link to={Paths.convertToOrder}>
<Button className="w-fit px-6">
<div className="flex items-center gap-2">
<AddSquare size={18} color="black" />
سفارش جدید
</div>
</Button>
</Link>
</div>
</div>
<Tabs
activeTab={params.tab}
items={[...orderListTabs]}
onTabChange={(tab) => setTab(tab as TabOrderListEnum)}
/>
<div className="overflow-hidden rounded-3xl bg-white">
<div className="flex items-center justify-between gap-3 p-5 md:px-6">
<button
type="button"
onClick={() => setIsFiltersExpanded((prev) => !prev)}
className="flex flex-1 items-center gap-2 text-right"
>
<Filter size={18} color="#8C90A3" />
<span className="text-sm font-medium text-gray-700">فیلترها</span>
{hasActiveFilters && (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-black">
{activeFilterCount.toLocaleString("fa-IR")}
</span>
)}
<ArrowDown2
size={18}
color="#8C90A3"
className={clx(
"mr-auto transition-transform duration-200",
isFiltersExpanded && "rotate-180",
)}
/>
</button>
{hasActiveFilters && (
<button
type="button"
onClick={() =>
updateParams({
search: undefined,
from: undefined,
to: undefined,
userId: undefined,
designerId: undefined,
categoryId: undefined,
})
}
className="shrink-0 text-xs text-[#0037FF] hover:underline"
>
پاک کردن فیلترها
</button>
)}
</div>
{isFiltersExpanded && (
<div className="space-y-5 border-t border-gray-100 px-5 pb-5 pt-4 md:px-6 md:pb-6">
<Filters
fields={ORDER_LIST_FILTER_FIELDS}
initialValues={filterInitialValues}
onChange={setFilters}
className="w-full"
fieldClassName="grid w-full grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<UserSearch
placeholder="جستجوی کاربر"
className="w-full"
value={params.userId ?? ""}
onChange={(id) => updateParams({ userId: id || undefined })}
/>
<DesignerSearch
className="w-full"
placeholder="جستجوی طراح"
value={params.designerId ?? ""}
onChange={(id) => updateParams({ designerId: id || undefined })}
/>
<CategoriesSelect
isDisableShowLable
placeholder="جستجوی نوع"
value={params.categoryId ?? ""}
onChange={(e) =>
updateParams({ categoryId: e.target.value || undefined })
}
/>
</div>
</div>
)}
</div>
<Table<OrderListItemType>
columns={orderListColumns}
data={data?.data}
isLoading={isFetching}
noDataMessage="سفارشی یافت نشد"
/>
</div>
);
};
export default OrdersList;
@@ -0,0 +1,96 @@
import { Link } from "react-router-dom";
import moment from "moment-jalaali";
import { Edit2, Printer } from "iconsax-react";
import type { ColumnType } from "@/components/types/TableTypes";
import { Paths } from "@/config/Paths";
import {
getOrderStatusBadgeClass,
getOrderStatusLabel,
} from "../constants/orderStatus";
import type { OrderListItemType } from "../types/Types";
const getCustomerName = (item: OrderListItemType) => {
const user = item.user;
if (!user) return "—";
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ");
return fullName || user.phone || "—";
};
const getDesignerName = (item: OrderListItemType) => {
const designer = item.designer;
if (!designer || typeof designer === "string") {
return typeof designer === "string" ? designer : "—";
}
const name = [designer.firstName, designer.lastName].filter(Boolean).join(" ");
return name || designer.phone || "—";
};
export const orderListColumns: ColumnType<OrderListItemType>[] = [
{
key: "orderNumber",
title: "شماره",
},
{
key: "title",
title: "عنوان",
render: (item) => (
<Link
to={Paths.order.details + item.id}
className="font-medium text-[#0037FF] hover:underline"
>
{item.title}
</Link>
),
},
{
key: "customer",
title: "مشتری",
render: (item) => <span>{getCustomerName(item)}</span>,
},
{
key: "product",
title: "نام محصول",
render: (item) => <span>{item.product?.title ?? "—"}</span>,
},
{
key: "designer",
title: "مجری / طراح",
render: (item) => <span>{getDesignerName(item)}</span>,
},
{
key: "status",
title: "وضعیت",
render: (item) => (
<span
className={`inline-flex h-6 items-center rounded-full px-2.5 text-xs ${getOrderStatusBadgeClass(item.status)}`}
>
{getOrderStatusLabel(item.status)}
</span>
),
},
{
key: "createdAt",
title: "تاریخ ایجاد",
render: (item) => (
<span className="dltr text-right">
{moment(item.createdAt).format("jYYYY-jMM-jDD")}
</span>
),
},
{
key: "actions",
title: "",
render: (item) => (
<div className="flex items-center gap-2">
<Link title="فرم چاپ" to={Paths.orderPrint + item.id}>
<Printer size={20} color="black" />
</Link>
<Link title="ویرایش" to={Paths.order.edit + item.id}>
<Edit2 size={20} color="#0037FF" />
</Link>
</div>
),
},
];
+1 -1
View File
@@ -1,4 +1,4 @@
export const enum TabOrderListEnum { export enum TabOrderListEnum {
IN_PROGRESS = "in_progress", IN_PROGRESS = "in_progress",
FINISHED = "finished", FINISHED = "finished",
CANCELED = "canceled", CANCELED = "canceled",
+134
View File
@@ -0,0 +1,134 @@
import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type { FilterValues } from "@/components/Filters";
import { orderListTabStatuses } from "../constants/orderStatus";
import { TabOrderListEnum } from "../enum/OrderEnum";
import type { GetOrdersParams } from "../service/OrderService";
const DEFAULT_TAB = TabOrderListEnum.IN_PROGRESS;
const isValidTab = (value: string | null): value is TabOrderListEnum =>
Object.values(TabOrderListEnum).includes(value as TabOrderListEnum);
export type OrderListQueryParams = {
tab: TabOrderListEnum;
search?: string;
from?: string;
to?: string;
userId?: string;
designerId?: string;
categoryId?: string;
};
export const ORDER_LIST_FILTER_FIELDS = [
{
name: "search",
type: "input" as const,
placeholder: "جستجو (شماره سفارش / مشتری)",
},
{
name: "from",
type: "date" as const,
placeholder: "از تاریخ",
},
{
name: "to",
type: "date" as const,
placeholder: "تا تاریخ",
},
];
const readQueryParams = (searchParams: URLSearchParams): OrderListQueryParams => {
const tabParam = searchParams.get("tab");
const tab = isValidTab(tabParam) ? tabParam : DEFAULT_TAB;
const read = (key: string) => searchParams.get(key) ?? undefined;
return {
tab,
search: read("search"),
from: read("from"),
to: read("to"),
userId: read("userId"),
designerId: read("designerId") ?? read("designId"),
categoryId: read("categoryId"),
};
};
export const useOrderListParams = () => {
const [searchParams, setSearchParams] = useSearchParams();
const params = useMemo(() => readQueryParams(searchParams), [searchParams]);
const updateParams = useCallback(
(updates: Partial<Record<keyof OrderListQueryParams, string | undefined>>) => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(updates)) {
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
}
if (updates.designerId === undefined && "designerId" in updates) {
next.delete("designId");
}
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const setTab = useCallback(
(tab: TabOrderListEnum) => updateParams({ tab }),
[updateParams],
);
const setFilters = useCallback(
(filters: FilterValues) => {
updateParams({
search: filters.search?.trim() || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
});
},
[updateParams],
);
const filterInitialValues = useMemo<FilterValues>(
() => ({
search: params.search ?? null,
from: params.from ?? null,
to: params.to ?? null,
}),
[params.search, params.from, params.to],
);
const queryParams = useMemo<GetOrdersParams>(
() => ({
designId: params.designerId,
categoryId: params.categoryId,
userId: params.userId,
search: params.search ?? null,
from: params.from ?? null,
to: params.to ?? null,
statuses: orderListTabStatuses[params.tab],
}),
[params],
);
return {
params,
queryParams,
filterInitialValues,
setTab,
setFilters,
updateParams,
};
};
@@ -6,13 +6,14 @@ type Props = {
error_text?: string, error_text?: string,
isDisableShowLable?: boolean, isDisableShowLable?: boolean,
placeholder?: string, placeholder?: string,
clearable?: boolean,
} & SelectHTMLAttributes<HTMLSelectElement> } & SelectHTMLAttributes<HTMLSelectElement>
const CategoriesSelect: FC<Props> = ({ const CategoriesSelect: FC<Props> = ({
isDisableShowLable, isDisableShowLable,
placeholder, placeholder,
error_text, error_text,
clearable = true,
...selectProps ...selectProps
}) => { }) => {
@@ -23,6 +24,7 @@ const CategoriesSelect: FC<Props> = ({
label={isDisableShowLable ? undefined : 'دسته'} label={isDisableShowLable ? undefined : 'دسته'}
placeholder={placeholder || 'انتخاب'} placeholder={placeholder || 'انتخاب'}
error_text={error_text} error_text={error_text}
clearable={clearable}
items={categories?.data?.map((item) => { items={categories?.data?.map((item) => {
return { return {
label: item.title, label: item.title,
+41 -11
View File
@@ -1,4 +1,5 @@
import { type FC, useEffect, useRef, useState } from "react"; import { type FC, useEffect, useRef, useState } from "react";
import { CloseCircle } from "iconsax-react";
import { clx } from "@/helpers/utils"; import { clx } from "@/helpers/utils";
import { useUserSearch } from "./hooks/useUserSearch"; import { useUserSearch } from "./hooks/useUserSearch";
import type { UserType } from "./types/Types"; import type { UserType } from "./types/Types";
@@ -51,22 +52,51 @@ const UserSearch: FC<UserSearchProps> = ({
const displayLabel = (user: UserType) => const displayLabel = (user: UserType) =>
user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.phone; user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.phone;
useEffect(() => {
if (!value) {
setSearch("");
}
}, [value]);
const handleClear = () => {
setSearch("");
setIsOpen(false);
onChange?.("");
};
const hasValue = Boolean(value || search);
return ( return (
<div ref={wrapperRef} className={clx("w-full relative", className)}> <div ref={wrapperRef} className={clx("w-full relative", className)}>
{label && ( {label && (
<label className="text-sm text-primary-content block mb-1">{label}</label> <label className="text-sm text-primary-content block mb-1">{label}</label>
)} )}
<input <div className="relative">
type="text" <input
value={search} type="text"
onChange={(e) => { value={search}
setSearch(e.target.value); onChange={(e) => {
setIsOpen(true); setSearch(e.target.value);
}} setIsOpen(true);
onFocus={() => setIsOpen(true)} }}
placeholder={placeholder} onFocus={() => setIsOpen(true)}
className="w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border" placeholder={placeholder}
/> className={clx(
"w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border",
hasValue && "pl-10",
)}
/>
{hasValue && (
<button
type="button"
onClick={handleClear}
className="absolute top-0 bottom-0 my-auto left-3 z-10 flex items-center"
aria-label="پاک کردن"
>
<CloseCircle size={16} color="#8C90A3" />
</button>
)}
</div>
{isOpen && debouncedSearch.length > 0 && ( {isOpen && debouncedSearch.length > 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border border-border rounded-xl shadow-lg max-h-60 overflow-auto"> <div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border border-border rounded-xl shadow-lg max-h-60 overflow-auto">
{isFetching ? ( {isFetching ? (