diff --git a/src/pages/invoice/ProformaInvoice.tsx b/src/pages/invoice/ProformaInvoice.tsx index ff1e125..68bf72c 100644 --- a/src/pages/invoice/ProformaInvoice.tsx +++ b/src/pages/invoice/ProformaInvoice.tsx @@ -1,265 +1,379 @@ -import Tabs from '@/components/Tabs' -import { useCallback, useMemo, useState, type FC } from 'react' +import Tabs from "@/components/Tabs"; +import { useCallback, useMemo, useState, type FC } from "react"; import { - invoiceListTabs, - invoiceListTabStatuses, - ProformaInvoiceStatusEnum, -} from './enum/InvoiceEnum' -import Filters from '@/components/Filters' -import RefreshButton from '@/components/RefreshButton' -import Table from '@/components/Table' -import { Eye, Add, Edit2 } from 'iconsax-react' -import { Link, useSearchParams } from 'react-router-dom' -import { Paths } from '@/config/Paths' -import moment from 'moment-jalaali' -import type { FilterValues } from '@/components/Filters' -import { useArchiveInvoice, useGetInvoice } from './hooks/useInvoiceData' -import type { InvoiceType } from './types/Types' -import ArchiveWithConfirm from '@/components/ArchiveWithConfirm' -import { toast } from 'react-toastify' -import { extractErrorMessage } from '@/config/func' + invoiceListTabs, + invoiceListTabStatuses, + ProformaInvoiceStatusEnum, +} from "./enum/InvoiceEnum"; +import Filters from "@/components/Filters"; +import type { FilterValues } from "@/components/Filters"; +import RefreshButton from "@/components/RefreshButton"; +import Table from "@/components/Table"; +import { Eye, Add, Edit2, ArrowDown2, Filter } from "iconsax-react"; +import { Link, useSearchParams } from "react-router-dom"; +import { Paths } from "@/config/Paths"; +import moment from "moment-jalaali"; +import { useArchiveInvoice, useGetInvoice, useGetInvoiceTabCounts } from "./hooks/useInvoiceData"; +import type { InvoiceType } from "./types/Types"; +import ArchiveWithConfirm from "@/components/ArchiveWithConfirm"; +import { toast } from "react-toastify"; +import { extractErrorMessage } from "@/config/func"; +import { clx } from "@/helpers/utils"; -const INVOICE_TABS = invoiceListTabs.map((tab) => tab.value) +const INVOICE_TABS = invoiceListTabs.map((tab) => tab.value); -const isValidInvoiceTab = (value: string | null): value is ProformaInvoiceStatusEnum => - value !== null && INVOICE_TABS.includes(value as ProformaInvoiceStatusEnum) +const INVOICE_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 isValidInvoiceTab = ( + value: string | null, +): value is ProformaInvoiceStatusEnum => + value !== null && INVOICE_TABS.includes(value as ProformaInvoiceStatusEnum); + +const countActiveFilters = (filters: FilterValues) => + [filters.search, filters.from, filters.to].filter(Boolean).length; const ProformaInvoice: FC = () => { - const [searchParams, setSearchParams] = useSearchParams() - const tabParam = searchParams.get('tab') - const activeTab = isValidInvoiceTab(tabParam) - ? tabParam - : ProformaInvoiceStatusEnum.PENDING - const [page, setPage] = useState(1) - const [filters, setFilters] = useState({}) + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get("tab"); + const activeTab = isValidInvoiceTab(tabParam) + ? tabParam + : ProformaInvoiceStatusEnum.PENDING; + const [page, setPage] = useState(1); + const [filters, setFilters] = useState({}); + const [isFiltersExpanded, setIsFiltersExpanded] = useState(false); + const [filtersKey, setFiltersKey] = useState(0); - const statuses = invoiceListTabStatuses[activeTab] + const statuses = invoiceListTabStatuses[activeTab]; + const activeFilterCount = useMemo( + () => countActiveFilters(filters), + [filters], + ); + const hasActiveFilters = activeFilterCount > 0; - const setActiveTab = (tab: ProformaInvoiceStatusEnum) => { - setPage(1) - setSearchParams( - (current) => { - const next = new URLSearchParams(current) - next.set('tab', tab) - return next - }, - { replace: true }, - ) - } + const setActiveTab = (tab: ProformaInvoiceStatusEnum) => { + setPage(1); + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + next.set("tab", tab); + return next; + }, + { replace: true }, + ); + }; - const { data, isLoading, refetch, isFetching } = useGetInvoice({ page, statuses }) - const { mutate: archiveInvoice, isPending: isArchiving } = useArchiveInvoice() + const handleFiltersChange = useCallback((next: FilterValues) => { + setPage(1); + setFilters(next); + }, []); - const meta = data?.meta + const clearFilters = useCallback(() => { + setPage(1); + setFilters({ search: null, from: null, to: null }); + setFiltersKey((key) => key + 1); + }, []); - const handleArchive = useCallback( - (id: string) => { - archiveInvoice(id, { - onSuccess: () => { - refetch() - toast.success('پیش‌فاکتور با موفقیت آرشیو شد') - }, - onError: (error) => { - toast.error(extractErrorMessage(error)) - }, - }) + const { data, isLoading, refetch, isFetching } = useGetInvoice({ + page, + statuses, + search: filters.search?.trim() || null, + from: filters.from || null, + to: filters.to || null, + }); + const { data: tabCounts } = useGetInvoiceTabCounts({ + search: filters.search?.trim() || null, + from: filters.from || null, + to: filters.to || null, + }); + const { mutate: archiveInvoice, isPending: isArchiving } = + useArchiveInvoice(); + + const tabCountByTab: Record = useMemo( + () => ({ + [ProformaInvoiceStatusEnum.PENDING]: tabCounts?.pending ?? 0, + [ProformaInvoiceStatusEnum.CONFIRMED]: tabCounts?.confirmed ?? 0, + [ProformaInvoiceStatusEnum.ARCHIVED]: tabCounts?.archived ?? 0, + }), + [tabCounts], + ); + const tabsWithCount = useMemo( + () => + invoiceListTabs.map((tab) => { + const total = tabCountByTab[tab.value] ?? 0; + return { + ...tab, + label: total > 0 ? `${tab.label} (${total.toLocaleString("fa-IR")})` : tab.label, + }; + }), + [tabCountByTab], + ); + + const meta = data?.meta; + + const handleArchive = useCallback( + (id: string) => { + archiveInvoice(id, { + onSuccess: () => { + refetch(); + toast.success("پیش‌فاکتور با موفقیت آرشیو شد"); }, - [archiveInvoice, refetch], - ) + onError: (error) => { + toast.error(extractErrorMessage(error)); + }, + }); + }, + [archiveInvoice, refetch], + ); - const filteredData = useMemo(() => { - let result = data?.data ?? [] + const getServiceLabel = (item: InvoiceType) => { + if (!item.items?.length) return "-"; + if (item.items.length === 1) return item.items[0].product?.title ?? "-"; + return ( +
+ {item.items.map((line) => ( + + {line.product?.title ?? "-"} ×{" "} + {line.quantity.toLocaleString("fa-IR")} + + ))} +
+ ); + }; - const search = (filters.search ?? '').trim() - if (search) { - const lower = search.toLowerCase() - result = result.filter( - (o) => - String(o.invoiceNumber).includes(search) || - o.user?.firstName?.toLowerCase().includes(lower) || - o.user?.lastName?.toLowerCase().includes(lower) || - o.user?.phone?.includes(search) - ) - } + const getCustomerLabel = (item: InvoiceType) => { + const fullName = + item.user?.firstName || item.user?.lastName + ? [item.user.firstName, item.user.lastName].filter(Boolean).join(" ") + : null; + const phone = item.user?.phone; - const dateFilter = filters.date - if (dateFilter) { - const filterDate = moment(dateFilter, 'jYYYY/jMM/jDD').format('YYYY-MM-DD') - result = result.filter((o) => { - const d = o.createdAt - return d && moment(d).format('YYYY-MM-DD') === filterDate - }) - } - - return result - }, [data?.data, filters.search, filters.date]) - - const getPaymentStatus = (item: InvoiceType) => { - const total = Number(item.total) || 0 - const paid = Number(item.paidAmount) || 0 - if (total <= 0) return '-' - if (paid >= total) return 'پرداخت شده' - if (paid > 0) return 'پرداخت جزئی' - return 'پرداخت نشده' - } - - const getServiceLabel = (item: InvoiceType) => { - if (!item.items?.length) return '-' - if (item.items.length === 1) return item.items[0].product?.title ?? '-' - return ( -
- {item.items.map((line) => ( - - {line.product?.title ?? '-'} ({line.quantity.toLocaleString('fa-IR')}) - - ))} -
- ) - } + if (!fullName && !phone) return "-"; return ( -
-
-

پیش فاکتورها

-
- refetch()} isLoading={isFetching} /> - - - -
-
+
+ {fullName && {fullName}} + {phone && {phone}} +
+ ); + }; -
- setActiveTab(tab as ProformaInvoiceStatusEnum)} - /> -
+ const formatAmount = (value: number | null | undefined) => + value != null ? Number(value).toLocaleString("fa-IR") + " ریال" : "-"; -
- -
- -
- item.invoiceNumber, - }, - { - key: 'service', - title: 'خدمت', - className: '!whitespace-normal', - render: (item) => getServiceLabel(item), - }, - { - key: 'issueDate', - title: 'تاریخ صدور', - render: (item) => - item.createdAt - ? moment(item.createdAt).format('jYYYY/jMM/jDD') - : '-', - }, - { - key: 'lastConfirmDate', - title: 'آخرین مهلت تایید', - render: (item) => - item.approvalDeadline - ? moment(item.approvalDeadline).format('jYYYY/jMM/jDD') - : '-', - }, - { - key: 'total', - title: 'مجموع', - render: (item) => - item.total - ? Number(item.total).toLocaleString('fa-IR') + ' ریال' - : '-', - }, - { - key: 'status', - title: 'وضعیت تایید', - render: (item) => { - const statusMap = { - confirmed: { className: 'bg-green-100 text-green-800', text: 'تایید شده' }, - partially_confirmed: { className: 'bg-blue-100 text-blue-800', text: 'تایید جزئی' }, - pending: { className: 'bg-amber-100 text-amber-800', text: 'در انتظار تایید' }, - archived: { className: 'bg-gray-100 text-gray-700', text: 'آرشیو شده' }, - } - const status = statusMap[item.status] ?? statusMap.pending - return ( - - {status.text} - - ) - }, - }, - { - key: 'paymentStatus', - title: 'وضعیت پرداخت', - render: (item) => getPaymentStatus(item), - }, - { - key: 'actions', - title: '', - render: (item) => ( -
- - - - {item.status !== 'archived' && ( - <> - - - - handleArchive(item.id)} - isloading={isArchiving} - /> - - )} -
- ), - }, - ]} - data={filteredData} - isLoading={isLoading} - noDataMessage='پیش فاکتوری یافت نشد.' - pagination={ - meta && meta.totalPages > 1 - ? { - currentPage: meta.page, - totalPages: meta.totalPages, - onPageChange: setPage, - } - : undefined - } - /> - + return ( +
+
+
+

پیش فاکتورها

- ) -} +
+ refetch()} isLoading={isFetching} /> + + + +
+
-export default ProformaInvoice + setActiveTab(tab as ProformaInvoiceStatusEnum)} + /> + +
+
+ + + {hasActiveFilters && ( + + )} +
+ + {isFiltersExpanded && ( +
+ +
+ )} +
+ +
item.invoiceNumber, + }, + { + key: "customer", + title: "مشتری", + className: "!whitespace-normal", + render: (item) => getCustomerLabel(item), + }, + { + key: "service", + title: "آیتم ها", + className: "!whitespace-normal", + render: (item) => getServiceLabel(item), + }, + { + key: "issueDate", + title: "تاریخ صدور", + render: (item) => + item.createdAt + ? moment(item.createdAt).format("jYYYY/jMM/jDD") + : "-", + }, + { + key: "lastConfirmDate", + title: "آخرین مهلت تایید", + render: (item) => + item.approvalDeadline + ? moment(item.approvalDeadline).format("jYYYY/jMM/jDD") + : "-", + }, + { + key: "total", + title: "مالی", + className: "!whitespace-normal", + render: (item) => ( +
+ + + + + + + + + + + + +
مجموع : {formatAmount(item.total)}
پرداخت : {formatAmount(item.paidAmount)}
مانده: {formatAmount(item.balance)}
+ ), + }, + { + key: "status", + title: "وضعیت", + render: (item) => { + const statusMap = { + confirmed: { + className: "bg-green-100 text-green-800", + text: "تایید شده", + }, + partially_confirmed: { + className: "bg-blue-100 text-blue-800", + text: "تایید جزئی", + }, + pending: { + className: "bg-amber-100 text-amber-800", + text: "در انتظار تایید", + }, + archived: { + className: "bg-gray-100 text-gray-700", + text: "آرشیو شده", + }, + }; + const status = statusMap[item.status] ?? statusMap.pending; + return ( + + {status.text} + + ); + }, + }, + { + key: "actions", + title: "", + render: (item) => ( +
+ + + + {item.status !== "archived" && ( + <> + + + + handleArchive(item.id)} + isloading={isArchiving} + /> + + )} +
+ ), + }, + ]} + data={data?.data} + isLoading={isLoading} + noDataMessage="پیش فاکتوری یافت نشد." + pagination={ + meta && meta.totalPages > 1 + ? { + currentPage: meta.page, + totalPages: meta.totalPages, + onPageChange: setPage, + } + : undefined + } + /> +
+ ); +}; + +export default ProformaInvoice; diff --git a/src/pages/invoice/hooks/useInvoiceData.ts b/src/pages/invoice/hooks/useInvoiceData.ts index c7cae6e..485d406 100644 --- a/src/pages/invoice/hooks/useInvoiceData.ts +++ b/src/pages/invoice/hooks/useInvoiceData.ts @@ -9,17 +9,35 @@ export const useCreateInvoice = () => { mutationFn: api.createInvoice, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["invoice"] }); + queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] }); }, }); }; export const useGetInvoice = (params: api.GetInvoicesParams) => { return useQuery({ - queryKey: ["invoice", params.page, params.statuses?.join(",") ?? ""], + queryKey: [ + "invoice", + params.page, + params.statuses?.join(",") ?? "", + params.search ?? "", + params.from ?? "", + params.to ?? "", + ], queryFn: () => api.getInvoice(params), }); }; +export const useGetInvoiceTabCounts = ( + params?: api.GetInvoiceTabCountsParams, +) => { + return useQuery({ + queryKey: ["invoice-tab-counts", params], + queryFn: () => api.getInvoiceTabCounts(params), + refetchInterval: 20_000, + }); +}; + export const useGetInvoiceDetail = (id: string | undefined) => { return useQuery({ queryKey: ["invoice", id], @@ -49,6 +67,7 @@ export const useUpdateInvoice = () => { }) => api.updateInvoice(id, params), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["invoice"] }); + queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] }); }, }); }; @@ -60,6 +79,7 @@ export const useArchiveInvoice = () => { mutationFn: (id: string) => api.archiveInvoice(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["invoice"] }); + queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] }); }, }); }; diff --git a/src/pages/invoice/service/InvoiceService.ts b/src/pages/invoice/service/InvoiceService.ts index f10d2c2..310cc77 100644 --- a/src/pages/invoice/service/InvoiceService.ts +++ b/src/pages/invoice/service/InvoiceService.ts @@ -7,10 +7,22 @@ import type { UpdatePreInvoiceType, } from "../types/Types"; import type { InvoiceConfirmStatus } from "../enum/InvoiceEnum"; +import type { BaseResponse } from "@/shared/types/Types"; export type GetInvoicesParams = { page?: number; statuses?: InvoiceConfirmStatus[]; + search?: string | null; + from?: string | null; + to?: string | null; +}; + +export type GetInvoiceTabCountsParams = Omit; + +export type InvoiceTabCountsResponseType = { + pending: number; + confirmed: number; + archived: number; }; export const createInvoice = async (params: CreatePreInvoiceType) => { @@ -21,16 +33,36 @@ export const createInvoice = async (params: CreatePreInvoiceType) => { export const getInvoice = async ( params: GetInvoicesParams = {}, ): Promise => { - const { page = 1, statuses } = params; + const { page = 1, statuses, search, from, to } = params; const { data } = await axios.get("/admin/invoice", { params: { page, ...(statuses?.length ? { statuses: statuses.join(",") } : {}), + ...(search ? { search } : {}), + ...(from ? { from } : {}), + ...(to ? { to } : {}), }, }); return data; }; +export const getInvoiceTabCounts = async ( + params: GetInvoiceTabCountsParams = {}, +): Promise => { + const { search, from, to } = params; + const { data } = await axios.get>( + "/admin/invoice/tab-counts", + { + params: { + ...(search ? { search } : {}), + ...(from ? { from } : {}), + ...(to ? { to } : {}), + }, + }, + ); + return data.data; +}; + export const getInvoiceById = async (id: string) => { const { data } = await axios.get<{ data: InvoiceType }>(`/admin/invoice/${id}`); return data;