diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 1fc88e7..b7f6a52 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -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) => { const [value, setValue] = useState(null); @@ -28,22 +56,36 @@ const DatePickerComponent: FC = (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 (
@@ -54,15 +96,20 @@ const DatePickerComponent: FC = (props) => {
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) => { color='#8c90a3' className='absolute top-0 bottom-0 my-auto left-2 pointer-events-none' /> + {props.clearable && value && ( + + )}
); }; -export default DatePickerComponent; \ No newline at end of file +export default DatePickerComponent; diff --git a/src/components/Filters.tsx b/src/components/Filters.tsx index f01f437..989cb7d 100644 --- a/src/components/Filters.tsx +++ b/src/components/Filters.tsx @@ -122,6 +122,14 @@ const Filters: FC = ({ const handleInputChange = (name: string, event: ChangeEvent) => { 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 = ({ 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 = ({ + {hasClearValue && ( + + )} { error_text && error_text !== '' ? diff --git a/src/index.css b/src/index.css index aedb0c0..1c50f30 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/pages/designer/DesignerSearch.tsx b/src/pages/designer/DesignerSearch.tsx index e854dd7..4dd9ee4 100644 --- a/src/pages/designer/DesignerSearch.tsx +++ b/src/pages/designer/DesignerSearch.tsx @@ -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 = ({ 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 (
{label && ( )} - { - 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" - /> +
+ { + 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 && ( + + )} +
{isOpen && debouncedSearch.length > 0 && (
{isFetching ? ( diff --git a/src/pages/order/List.tsx b/src/pages/order/List.tsx index dc48a49..782f017 100644 --- a/src/pages/order/List.tsx +++ b/src/pages/order/List.tsx @@ -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( - () => (searchParams.get('tab') as TabOrderListEnum) || TabOrderListEnum.IN_PROGRESS, - ) - const [designId, setDesignId] = useState( - () => searchParams.get('designerId') ?? searchParams.get('designId') ?? undefined, - ) - const [categoryId, setCategoryId] = useState( - () => searchParams.get('categoryId') ?? undefined, - ) - const [userId, setUserId] = useState( - () => searchParams.get('userId') ?? undefined, - ) - const [filters, setFilters] = useState({}) + 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 ( -
-
-

سفارش ها

-
- refetch()} isLoading={isFetching} /> - - - -
-
- -
- -
- -
- { - const nextUserId = id || undefined - setUserId(nextUserId) - updateSearchParam('userId', nextUserId) - }} - /> - { - const nextDesignId = id || undefined - setDesignId(nextDesignId) - updateSearchParam('designerId', nextDesignId) - }} - /> -
- { - const nextCategoryId = e.target.value || undefined - setCategoryId(nextCategoryId) - updateSearchParam('categoryId', nextCategoryId) - }} - /> -
-
- -
- -
- -
- - columns={[ - { - key: 'orderNumber', - title: 'شماره', - }, - { - key: 'title', - title: 'عنوان', - render: (item) => ( - - {item.title} - - ), - }, - { - key: 'customer', - title: 'مشتری', - render: (item) => { - return ( -
- {item.user?.firstName ? item.user?.firstName + ' ' + item.user?.lastName : item.user?.phone} -
- ) - } - }, - - - { - key: 'product', - title: 'نام محصول', - render: (item) => ( -
{item.product?.title ?? '—'}
- ), - }, - - - { - key: 'designer', - title: 'مجری / طراح', - render: (item) => { - const designer = item.designer - if (!designer || typeof designer === 'string') { - return
{typeof designer === 'string' ? designer : '—'}
- } - const name = `${designer.firstName ?? ''} ${designer.lastName ?? ''}`.trim() - return
{name || designer.phone || '—'}
- }, - }, - { - key: 'status', - title: 'وضعیت', - render: (item) => ( - - {getOrderStatusLabel(item.status)} - - ), - }, - { - key: 'createdAt', - title: 'تاریخ ایجاد ', - render: (item) => { - return ( -
{moment(item.createdAt).format('jYYYY-jMM-jDD')}
- ) - } - }, - { - key: 'actions', - title: '', - render: (item) => { - return ( -
- - - - - - - {/* */} -
- ) - } - }, - ]} - data={data?.data} - /> -
+ return ( +
+
+
+

سفارش‌ها

+

+ مدیریت و پیگیری سفارش‌های ثبت‌شده +

- ) -} -export default OrdersList \ No newline at end of file +
+ refetch()} isLoading={isFetching} /> + + + +
+
+ + setTab(tab as TabOrderListEnum)} + /> + +
+
+ + + {hasActiveFilters && ( + + )} +
+ + {isFiltersExpanded && ( +
+ + +
+ updateParams({ userId: id || undefined })} + /> + updateParams({ designerId: id || undefined })} + /> + + updateParams({ categoryId: e.target.value || undefined }) + } + /> +
+
+ )} +
+ + + columns={orderListColumns} + data={data?.data} + isLoading={isFetching} + noDataMessage="سفارشی یافت نشد" + /> +
+ ); +}; + +export default OrdersList; diff --git a/src/pages/order/components/OrderListColumns.tsx b/src/pages/order/components/OrderListColumns.tsx new file mode 100644 index 0000000..c47f11a --- /dev/null +++ b/src/pages/order/components/OrderListColumns.tsx @@ -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[] = [ + { + key: "orderNumber", + title: "شماره", + }, + { + key: "title", + title: "عنوان", + render: (item) => ( + + {item.title} + + ), + }, + { + key: "customer", + title: "مشتری", + render: (item) => {getCustomerName(item)}, + }, + { + key: "product", + title: "نام محصول", + render: (item) => {item.product?.title ?? "—"}, + }, + { + key: "designer", + title: "مجری / طراح", + render: (item) => {getDesignerName(item)}, + }, + { + key: "status", + title: "وضعیت", + render: (item) => ( + + {getOrderStatusLabel(item.status)} + + ), + }, + { + key: "createdAt", + title: "تاریخ ایجاد", + render: (item) => ( + + {moment(item.createdAt).format("jYYYY-jMM-jDD")} + + ), + }, + { + key: "actions", + title: "", + render: (item) => ( +
+ + + + + + +
+ ), + }, +]; diff --git a/src/pages/order/enum/OrderEnum.ts b/src/pages/order/enum/OrderEnum.ts index 567abbb..ed4bd37 100644 --- a/src/pages/order/enum/OrderEnum.ts +++ b/src/pages/order/enum/OrderEnum.ts @@ -1,4 +1,4 @@ -export const enum TabOrderListEnum { +export enum TabOrderListEnum { IN_PROGRESS = "in_progress", FINISHED = "finished", CANCELED = "canceled", diff --git a/src/pages/order/hooks/useOrderListParams.ts b/src/pages/order/hooks/useOrderListParams.ts new file mode 100644 index 0000000..81c61a5 --- /dev/null +++ b/src/pages/order/hooks/useOrderListParams.ts @@ -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>) => { + 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( + () => ({ + search: params.search ?? null, + from: params.from ?? null, + to: params.to ?? null, + }), + [params.search, params.from, params.to], + ); + + const queryParams = useMemo( + () => ({ + 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, + }; +}; diff --git a/src/pages/product/components/CategoriesSelect.tsx b/src/pages/product/components/CategoriesSelect.tsx index 088c364..5e13394 100644 --- a/src/pages/product/components/CategoriesSelect.tsx +++ b/src/pages/product/components/CategoriesSelect.tsx @@ -6,13 +6,14 @@ type Props = { error_text?: string, isDisableShowLable?: boolean, placeholder?: string, - + clearable?: boolean, } & SelectHTMLAttributes const CategoriesSelect: FC = ({ isDisableShowLable, placeholder, error_text, + clearable = true, ...selectProps }) => { @@ -23,6 +24,7 @@ const CategoriesSelect: FC = ({ label={isDisableShowLable ? undefined : 'دسته'} placeholder={placeholder || 'انتخاب'} error_text={error_text} + clearable={clearable} items={categories?.data?.map((item) => { return { label: item.title, diff --git a/src/pages/user/UserSearch.tsx b/src/pages/user/UserSearch.tsx index 994f73b..6e0ec78 100644 --- a/src/pages/user/UserSearch.tsx +++ b/src/pages/user/UserSearch.tsx @@ -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 = ({ 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 (
{label && ( )} - { - 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" - /> +
+ { + 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 && ( + + )} +
{isOpen && debouncedSearch.length > 0 && (
{isFetching ? (