feat(discount-reports): enhance user selection functionality in discount usage reports

- Integrated user search with debounced input for improved performance and user experience.
- Added dropdown for user selection based on search input, displaying user details.
- Updated user ID input to allow searching by phone number or selecting from a list.
- Refactored filter handling to improve state management and clarity.

These changes significantly enhance the usability and functionality of the discount usage reporting pages.
This commit is contained in:
hosseintaromi 2026-02-09 13:17:13 +03:30
parent 85fdf1f7a2
commit 8d71350f62
2 changed files with 131 additions and 28 deletions

View File

@ -1,19 +1,19 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { useCustomerDiscountUsageReport } from '../core/_hooks'; import { useCustomerDiscountUsageReport } from '../core/_hooks';
import { CustomerDiscountUsageFilters } from '../core/_models'; import { CustomerDiscountUsageFilters } from '../core/_models';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Table } from '@/components/ui/Table'; import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types'; import { TableColumn } from '@/types';
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker'; import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
import { PageContainer, PageTitle } from '@/components/ui/Typography'; import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination'; import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, Users, DollarSign, Hash, X } from 'lucide-react'; import { Filter, TrendingUp, Users, DollarSign, Hash, X } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils'; import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils';
import { formatCurrency, formatDateTime } from '@/utils/formatters'; import { formatCurrency, formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton'; import { ReportSkeleton } from '@/components/common/ReportSkeleton';
import { useSearchUsers, useUsers } from '@/pages/users-admin/core/_hooks';
import { UserFilters } from '@/pages/users-admin/core/_models';
const CustomerDiscountUsagePage = () => { const CustomerDiscountUsagePage = () => {
const [filters, setFilters] = useState<CustomerDiscountUsageFilters>({ const [filters, setFilters] = useState<CustomerDiscountUsageFilters>({
@ -29,6 +29,20 @@ const CustomerDiscountUsagePage = () => {
}); });
const [refetchKey, setRefetchKey] = useState(0); const [refetchKey, setRefetchKey] = useState(0);
const [userSearchText, setUserSearchText] = useState('');
const [userSearchDebounced, setUserSearchDebounced] = useState('');
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
useEffect(() => {
const t = setTimeout(() => setUserSearchDebounced(userSearchText), 300);
return () => clearTimeout(t);
}, [userSearchText]);
const { data: usersList } = useUsers({ limit: 20, offset: 0 });
const userSearchFilters = useMemo<UserFilters>(() =>
userSearchDebounced ? { search_text: userSearchDebounced, limit: 20, offset: 0 } : {}, [userSearchDebounced]);
const { data: userSearchData } = useSearchUsers(userSearchFilters);
const userOptions = userSearchDebounced ? (userSearchData?.users || []) : (usersList || []);
const filtersWithKey = useMemo(() => ({ const filtersWithKey = useMemo(() => ({
...filters, ...filters,
@ -54,14 +68,16 @@ const CustomerDiscountUsagePage = () => {
})); }));
}; };
const handleNumericFilterChange = (key: 'discount_id' | 'user_id', raw: string) => { const handleNumericFilterChange = (key: 'discount_id', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, ''); const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined; const numeric = converted ? Number(converted) : undefined;
if (key === 'user_id') {
handleTempFilterChange('user_id', numeric || 0);
} else {
handleTempFilterChange(key, numeric); handleTempFilterChange(key, numeric);
} };
const handleSelectUser = (user: { id: number; phone_number?: string; first_name?: string; last_name?: string }) => {
handleTempFilterChange('user_id', user.id);
setUserSearchText(user.phone_number || `${user.first_name || ''} ${user.last_name || ''}`.trim() || String(user.id));
setUserDropdownOpen(false);
}; };
const handleApplyFilters = () => { const handleApplyFilters = () => {
@ -88,6 +104,7 @@ const CustomerDiscountUsagePage = () => {
}; };
setTempFilters(clearedFilters); setTempFilters(clearedFilters);
setFilters(clearedFilters); setFilters(clearedFilters);
setUserSearchText('');
}; };
const columns: TableColumn[] = [ const columns: TableColumn[] = [
@ -163,7 +180,7 @@ const CustomerDiscountUsagePage = () => {
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"> <div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200"> <p className="text-sm text-blue-800 dark:text-blue-200">
<span className="font-semibold">توجه:</span> برای مشاهده گزارش، لطفاً شناسه کاربر را وارد کنید. این فیلد الزامی است. <span className="font-semibold">توجه:</span> برای مشاهده گزارش، کاربر را از لیست انتخاب کنید یا با شماره موبایل جستجو کنید. این فیلد الزامی است.
</p> </p>
</div> </div>
@ -172,13 +189,44 @@ const CustomerDiscountUsagePage = () => {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر <span className="text-red-500">*</span> شناسه کاربر <span className="text-red-500">*</span>
</label> </label>
<div className="relative">
<Input <Input
value={tempFilters.user_id?.toString() || ''} value={userSearchText}
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)} onChange={(e) => {
placeholder="مثلاً 456" setUserSearchText(persianToEnglish(e.target.value));
numeric setUserDropdownOpen(true);
required if (tempFilters.user_id) handleTempFilterChange('user_id', 0);
}}
onFocus={() => setUserDropdownOpen(true)}
onBlur={() => setTimeout(() => setUserDropdownOpen(false), 150)}
placeholder="جستجو با شماره موبایل یا انتخاب از لیست"
/> />
{userDropdownOpen && (
<div className="absolute z-20 mt-2 w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg max-h-60 overflow-y-auto">
{(userSearchDebounced ? (userSearchData?.users ?? []) : (usersList ?? [])).length === 0 && (
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
کاربری یافت نشد
</div>
)}
{(userSearchDebounced ? (userSearchData?.users ?? []) : (usersList ?? [])).map((user: { id: number; phone_number?: string; first_name?: string; last_name?: string }) => (
<button
key={user.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelectUser(user)}
className="w-full text-right px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="text-sm text-gray-900 dark:text-gray-100">
{user.first_name} {user.last_name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user.phone_number}
</div>
</button>
))}
</div>
)}
</div>
</div> </div>
<div> <div>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { useDiscountUsageReport } from '../core/_hooks'; import { useDiscountUsageReport } from '../core/_hooks';
import { DiscountUsageFilters } from '../core/_models'; import { DiscountUsageFilters } from '../core/_models';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@ -9,10 +9,11 @@ import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
import { PageContainer, PageTitle } from '@/components/ui/Typography'; import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination'; import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, Users, DollarSign, Hash, X } from 'lucide-react'; import { Filter, TrendingUp, Users, DollarSign, Hash, X } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils'; import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils';
import { formatCurrency, formatDateTime } from '@/utils/formatters'; import { formatCurrency, formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton'; import { ReportSkeleton } from '@/components/common/ReportSkeleton';
import { useSearchUsers, useUsers } from '@/pages/users-admin/core/_hooks';
import { UserFilters } from '@/pages/users-admin/core/_models';
const DiscountUsageReportPage = () => { const DiscountUsageReportPage = () => {
const [filters, setFilters] = useState<DiscountUsageFilters>({ const [filters, setFilters] = useState<DiscountUsageFilters>({
@ -21,6 +22,21 @@ const DiscountUsageReportPage = () => {
group_by_code: false, group_by_code: false,
}); });
const [userSearchText, setUserSearchText] = useState('');
const [userSearchDebounced, setUserSearchDebounced] = useState('');
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
useEffect(() => {
const t = setTimeout(() => setUserSearchDebounced(userSearchText), 300);
return () => clearTimeout(t);
}, [userSearchText]);
const { data: usersList } = useUsers({ limit: 20, offset: 0 });
const userSearchFilters = useMemo<UserFilters>(() =>
userSearchDebounced ? { search_text: userSearchDebounced, limit: 20, offset: 0 } : {}, [userSearchDebounced]);
const { data: userSearchData } = useSearchUsers(userSearchFilters);
const userOptions = userSearchDebounced ? (userSearchData?.users || []) : (usersList || []);
const { data, isLoading, error } = useDiscountUsageReport(filters); const { data, isLoading, error } = useDiscountUsageReport(filters);
const handleFilterChange = (key: keyof DiscountUsageFilters, value: any) => { const handleFilterChange = (key: keyof DiscountUsageFilters, value: any) => {
@ -42,12 +58,18 @@ const DiscountUsageReportPage = () => {
})); }));
}; };
const handleNumericFilterChange = (key: 'discount_id' | 'user_id', raw: string) => { const handleNumericFilterChange = (key: 'discount_id', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, ''); const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined; const numeric = converted ? Number(converted) : undefined;
handleFilterChange(key, numeric); handleFilterChange(key, numeric);
}; };
const handleSelectUser = (user: { id: number; phone_number?: string; first_name?: string; last_name?: string }) => {
handleFilterChange('user_id', user.id);
setUserSearchText(user.phone_number || `${user.first_name || ''} ${user.last_name || ''}`.trim() || String(user.id));
setUserDropdownOpen(false);
};
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setFilters(prev => ({ setFilters(prev => ({
...prev, ...prev,
@ -61,6 +83,7 @@ const DiscountUsageReportPage = () => {
offset: 0, offset: 0,
group_by_code: false, group_by_code: false,
}); });
setUserSearchText('');
}; };
const columns: TableColumn[] = [ const columns: TableColumn[] = [
@ -169,12 +192,44 @@ const DiscountUsageReportPage = () => {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر شناسه کاربر
</label> </label>
<div className="relative">
<Input <Input
value={filters.user_id?.toString() || ''} value={userSearchText}
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)} onChange={(e) => {
placeholder="مثلاً 456" setUserSearchText(persianToEnglish(e.target.value));
numeric setUserDropdownOpen(true);
if (filters.user_id) handleFilterChange('user_id', undefined);
}}
onFocus={() => setUserDropdownOpen(true)}
onBlur={() => setTimeout(() => setUserDropdownOpen(false), 150)}
placeholder="جستجو با شماره موبایل یا انتخاب از لیست"
/> />
{userDropdownOpen && (
<div className="absolute z-20 mt-2 w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg max-h-60 overflow-y-auto">
{userOptions.length === 0 && (
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
کاربری یافت نشد
</div>
)}
{userOptions.map((user: { id: number; phone_number?: string; first_name?: string; last_name?: string }) => (
<button
key={user.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelectUser(user)}
className="w-full text-right px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="text-sm text-gray-900 dark:text-gray-100">
{user.first_name} {user.last_name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user.phone_number}
</div>
</button>
))}
</div>
)}
</div>
</div> </div>
<div> <div>