This commit is contained in:
@@ -3,7 +3,7 @@ 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 { Calendar, CloseCircle } from 'iconsax-react';
|
||||
import { clx } from '@/helpers/utils';
|
||||
|
||||
type Props = {
|
||||
@@ -16,8 +16,36 @@ type Props = {
|
||||
className?: string;
|
||||
label?: string,
|
||||
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 [value, setValue] = useState<DateObject | null>(null);
|
||||
|
||||
@@ -28,22 +56,36 @@ const DatePickerComponent: FC<Props> = (props) => {
|
||||
}, [props.reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const formattedDate = `${value.year}/${value.month.number}/${value.day}`;
|
||||
props.onChange(formattedDate);
|
||||
}
|
||||
}, [value]);
|
||||
if (props.reset) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.defaultValue && !value) {
|
||||
const defaultDate = new DateObject({
|
||||
date: props.defaultValue,
|
||||
calendar: persian,
|
||||
locale: persian_fa,
|
||||
});
|
||||
setValue(defaultDate);
|
||||
if (props.defaultValue) {
|
||||
setValue(parseDefaultValue(props.defaultValue));
|
||||
} else {
|
||||
setValue(null);
|
||||
}
|
||||
}, [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 (
|
||||
<div className="w-full">
|
||||
@@ -54,15 +96,20 @@ const DatePickerComponent: FC<Props> = (props) => {
|
||||
<div className={clx(
|
||||
'relative',
|
||||
props.readOnly && 'readOny',
|
||||
props.label && 'mt-1'
|
||||
props.label && 'mt-1',
|
||||
props.clearable && Boolean(value) && 'date-picker--clearable',
|
||||
)}>
|
||||
<DatePicker
|
||||
placeholder={props.placeholder}
|
||||
value={value}
|
||||
onChange={(date) => setValue(date as DateObject)}
|
||||
onChange={handleChange}
|
||||
onOpenPickNewDate={props.onOpenPickNewDate ?? false}
|
||||
editable={false}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
calendarPosition="bottom-right"
|
||||
portal
|
||||
zIndex={50}
|
||||
className={props.className}
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
@@ -77,9 +124,19 @@ const DatePickerComponent: FC<Props> = (props) => {
|
||||
color='#8c90a3'
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatePickerComponent;
|
||||
export default DatePickerComponent;
|
||||
|
||||
@@ -122,6 +122,14 @@ const Filters: FC<FiltersProps> = ({
|
||||
const handleInputChange = (name: string, event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.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) => {
|
||||
@@ -133,7 +141,18 @@ const Filters: FC<FiltersProps> = ({
|
||||
<DatePicker
|
||||
key={field.name}
|
||||
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 || ''}
|
||||
/>
|
||||
);
|
||||
@@ -143,8 +162,9 @@ const Filters: FC<FiltersProps> = ({
|
||||
<Select
|
||||
key={field.name}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
defaultValue={currentValue || field.defaultValue || ''}
|
||||
onChange={(e) => handleChange(field.name, e.target.value || null)}
|
||||
value={currentValue || ''}
|
||||
clearable={hasFieldValue(field)}
|
||||
items={field.options}
|
||||
/>
|
||||
);
|
||||
@@ -157,6 +177,7 @@ const Filters: FC<FiltersProps> = ({
|
||||
variant="search"
|
||||
className="w-full md:min-w-[230px] md:max-w-[230px]"
|
||||
value={currentValue || field.defaultValue || ''}
|
||||
clearable={hasFieldValue(field)}
|
||||
onChange={(e) => handleInputChange(field.name, e)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type FC, type InputHTMLAttributes, useEffect, useState } from 'react'
|
||||
import { clx } from '../helpers/utils';
|
||||
import { Eye, EyeSlash, SearchNormal } from 'iconsax-react';
|
||||
import { CloseCircle, Eye, EyeSlash, SearchNormal } from 'iconsax-react';
|
||||
import Error from './Error';
|
||||
|
||||
type Variant = "floating_outlined" | "primary" | "search";
|
||||
@@ -15,6 +15,7 @@ type Props = {
|
||||
seprator?: boolean;
|
||||
isNotRequired?: boolean;
|
||||
onChangeSearchFinal?: (value: string) => void;
|
||||
clearable?: boolean;
|
||||
} & InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const formatNumber = (value: string | number): string => {
|
||||
@@ -32,10 +33,13 @@ const Input: FC<Props> = (props: Props) => {
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
const [search, setSearch] = useState<string>('')
|
||||
|
||||
const hasClearValue = Boolean(props.clearable && props.value);
|
||||
|
||||
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',
|
||||
hasClearValue && 'pl-10',
|
||||
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' />
|
||||
}
|
||||
|
||||
{
|
||||
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 &&
|
||||
<Error
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC, SelectHTMLAttributes } from 'react'
|
||||
import type { ChangeEvent, FC, SelectHTMLAttributes } from 'react'
|
||||
import { clx } from '../helpers/utils'
|
||||
import { ArrowDown2 } from 'iconsax-react'
|
||||
import { ArrowDown2, CloseCircle } from 'iconsax-react'
|
||||
|
||||
export type ItemsSelectType = {
|
||||
value: string | number,
|
||||
@@ -13,6 +13,7 @@ type Props = {
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
readOnly?: boolean,
|
||||
clearable?: boolean,
|
||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const Select: FC<Props> = ({
|
||||
@@ -22,9 +23,18 @@ const Select: FC<Props> = ({
|
||||
placeholder,
|
||||
label,
|
||||
readOnly,
|
||||
clearable,
|
||||
...selectProps
|
||||
}) => {
|
||||
const isControlled = selectProps.value !== undefined
|
||||
const hasClearValue = Boolean(clearable && selectProps.value)
|
||||
|
||||
const handleClear = () => {
|
||||
selectProps.onChange?.({
|
||||
target: { value: '' },
|
||||
currentTarget: { value: '' },
|
||||
} as ChangeEvent<HTMLSelectElement>);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
@@ -41,6 +51,7 @@ const Select: FC<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',
|
||||
readOnly && 'bg-muted border-0 text-muted-foreground',
|
||||
hasClearValue && 'pl-10',
|
||||
className,
|
||||
label && 'mt-1'
|
||||
)}>
|
||||
@@ -61,6 +72,16 @@ const Select: FC<Props> = ({
|
||||
</select>
|
||||
<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>
|
||||
{
|
||||
error_text && error_text !== '' ?
|
||||
|
||||
@@ -61,10 +61,15 @@ tbody tr:nth-child(odd) {
|
||||
width: 100% !important;
|
||||
margin: 0px;
|
||||
padding-right: 16px !important;
|
||||
padding-left: 16px !important;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.date-picker--clearable .rmdp-input {
|
||||
padding-left: 52px !important;
|
||||
}
|
||||
.readOny .rmdp-input {
|
||||
background-color: #f5f5f5 !important;
|
||||
border: none !important;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { CloseCircle } from "iconsax-react";
|
||||
import { clx } from "@/helpers/utils";
|
||||
import { useDesignerSearch } from "./hooks/useDesignerSearch";
|
||||
import type { AdminItemType } from "../admin/types/Types";
|
||||
@@ -51,22 +52,51 @@ const DesignerSearch: FC<DesignerSearchProps> = ({
|
||||
const displayLabel = (designer: AdminItemType) =>
|
||||
`${designer.firstName} ${designer.lastName}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSearch("");
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleClear = () => {
|
||||
setSearch("");
|
||||
setIsOpen(false);
|
||||
onChange?.("");
|
||||
};
|
||||
|
||||
const hasValue = Boolean(value || search);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={clx("w-full relative", className)}>
|
||||
{label && (
|
||||
<label className="text-sm text-primary-content block mb-1">{label}</label>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
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 && (
|
||||
<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 ? (
|
||||
|
||||
+163
-250
@@ -1,257 +1,170 @@
|
||||
import Filters, { type FilterValues } from '@/components/Filters'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import Table from '@/components/Table'
|
||||
import Tabs from '@/components/Tabs'
|
||||
import { AddSquare, Edit2, Printer } from 'iconsax-react'
|
||||
import { useGetOrders } from './hooks/useOrderData'
|
||||
import moment from 'moment-jalaali'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import type { OrderListItemType } from './types/Types'
|
||||
import Button from '@/components/Button'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import UserSearch from '../user/UserSearch'
|
||||
import DesignerSearch from '../designer/DesignerSearch'
|
||||
import CategoriesSelect from '../product/components/CategoriesSelect'
|
||||
import { getOrderStatusBadgeClass, getOrderStatusLabel, orderListTabStatuses, orderListTabs } from './constants/orderStatus'
|
||||
import { TabOrderListEnum } from './enum/OrderEnum'
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AddSquare, ArrowDown2, Filter } from "iconsax-react";
|
||||
import Filters from "@/components/Filters";
|
||||
import Table from "@/components/Table";
|
||||
import Tabs from "@/components/Tabs";
|
||||
import Button from "@/components/Button";
|
||||
import RefreshButton from "@/components/RefreshButton";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import { clx } from "@/helpers/utils";
|
||||
import UserSearch from "../user/UserSearch";
|
||||
import DesignerSearch from "../designer/DesignerSearch";
|
||||
import CategoriesSelect from "../product/components/CategoriesSelect";
|
||||
import { orderListTabs } from "./constants/orderStatus";
|
||||
import { orderListColumns } from "./components/OrderListColumns";
|
||||
import { useGetOrders } from "./hooks/useOrderData";
|
||||
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 [searchParams, setSearchParams] = useSearchParams()
|
||||
const [isFiltersExpanded, setIsFiltersExpanded] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabOrderListEnum>(
|
||||
() => (searchParams.get('tab') as TabOrderListEnum) || TabOrderListEnum.IN_PROGRESS,
|
||||
)
|
||||
const [designId, setDesignId] = useState<string | undefined>(
|
||||
() => searchParams.get('designerId') ?? searchParams.get('designId') ?? undefined,
|
||||
)
|
||||
const [categoryId, setCategoryId] = useState<string | undefined>(
|
||||
() => searchParams.get('categoryId') ?? undefined,
|
||||
)
|
||||
const [userId, setUserId] = useState<string | undefined>(
|
||||
() => searchParams.get('userId') ?? undefined,
|
||||
)
|
||||
const [filters, setFilters] = useState<FilterValues>({})
|
||||
const {
|
||||
params,
|
||||
queryParams,
|
||||
filterInitialValues,
|
||||
setTab,
|
||||
setFilters,
|
||||
updateParams,
|
||||
} = useOrderListParams();
|
||||
|
||||
useEffect(() => {
|
||||
const nextCategoryId = searchParams.get('categoryId') ?? undefined
|
||||
const nextUserId = searchParams.get('userId') ?? undefined
|
||||
const nextDesignId =
|
||||
searchParams.get('designerId') ?? searchParams.get('designId') ?? undefined
|
||||
const nextTab = (searchParams.get('tab') as TabOrderListEnum) || TabOrderListEnum.IN_PROGRESS
|
||||
const { data, refetch, isFetching } = useGetOrders(queryParams);
|
||||
const activeFilterCount = useMemo(() => countActiveFilters(params), [params]);
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
|
||||
setCategoryId(nextCategoryId)
|
||||
setUserId(nextUserId)
|
||||
setDesignId(nextDesignId)
|
||||
setActiveTab(nextTab)
|
||||
}, [searchParams])
|
||||
|
||||
const updateSearchParam = (key: string, value?: string) => {
|
||||
setSearchParams((current) => {
|
||||
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>
|
||||
return (
|
||||
<div className="mt-5 space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-light">سفارشها</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
مدیریت و پیگیری سفارشهای ثبتشده
|
||||
</p>
|
||||
</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,4 +1,4 @@
|
||||
export const enum TabOrderListEnum {
|
||||
export enum TabOrderListEnum {
|
||||
IN_PROGRESS = "in_progress",
|
||||
FINISHED = "finished",
|
||||
CANCELED = "canceled",
|
||||
|
||||
@@ -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,
|
||||
isDisableShowLable?: boolean,
|
||||
placeholder?: string,
|
||||
|
||||
clearable?: boolean,
|
||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const CategoriesSelect: FC<Props> = ({
|
||||
isDisableShowLable,
|
||||
placeholder,
|
||||
error_text,
|
||||
clearable = true,
|
||||
...selectProps
|
||||
}) => {
|
||||
|
||||
@@ -23,6 +24,7 @@ const CategoriesSelect: FC<Props> = ({
|
||||
label={isDisableShowLable ? undefined : 'دسته'}
|
||||
placeholder={placeholder || 'انتخاب'}
|
||||
error_text={error_text}
|
||||
clearable={clearable}
|
||||
items={categories?.data?.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { CloseCircle } from "iconsax-react";
|
||||
import { clx } from "@/helpers/utils";
|
||||
import { useUserSearch } from "./hooks/useUserSearch";
|
||||
import type { UserType } from "./types/Types";
|
||||
@@ -51,22 +52,51 @@ const UserSearch: FC<UserSearchProps> = ({
|
||||
const displayLabel = (user: UserType) =>
|
||||
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 (
|
||||
<div ref={wrapperRef} className={clx("w-full relative", className)}>
|
||||
{label && (
|
||||
<label className="text-sm text-primary-content block mb-1">{label}</label>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-white h-10 text-black block px-4 text-xs rounded-xl border border-border"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
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 && (
|
||||
<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 ? (
|
||||
|
||||
Reference in New Issue
Block a user