Compare commits

...

2 Commits

Author SHA1 Message Date
hosseintaromi 85fdf1f7a2 feat(wallet-credit): enhance user search functionality and input handling
- 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 amount input to format currency with thousands separators.
- Refactored form handling to improve state management and validation.

These changes significantly enhance the Wallet Credit page's usability and functionality.
2026-02-08 15:05:43 +03:30
hosseintaromi 785f97b26d feat(discount-reports): enhance discount usage reporting with additional filters and improved query parameters
- Added new filter options for status, type, application level, and sorting in discount usage reports.
- Updated API request parameters to align with new filter capabilities.
- Refactored pagination logic to improve clarity and accuracy in displaying total items and pages.
- Enhanced the sidebar layout for better organization and usability.

These changes significantly improve the reporting functionality and user experience in the discount statistics section.
2026-02-08 12:55:28 +03:30
5 changed files with 202 additions and 83 deletions

View File

@ -355,10 +355,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
w-64 transform transition-transform duration-300 ease-in-out w-64 transform transition-transform duration-300 ease-in-out
lg:translate-x-0 lg:block lg:translate-x-0 lg:block
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'} ${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
flex flex-col h-screen bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-lg lg:shadow-none h-screen bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-lg lg:shadow-none
`}> `}>
{/* Mobile close button */} <div className="grid h-full grid-rows-[auto_auto_1fr_auto] overflow-hidden">
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0"> <div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700">
<SectionTitle> <SectionTitle>
پنل مدیریت پنل مدیریت
</SectionTitle> </SectionTitle>
@ -370,20 +370,19 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</button> </button>
</div> </div>
{/* Logo - desktop only */} <div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700">
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<SectionTitle> <SectionTitle>
پنل مدیریت پنل مدیریت
</SectionTitle> </SectionTitle>
</div> </div>
{/* Navigation - scrollable */} <div className="min-h-0 overflow-y-auto overflow-x-hidden sidebar-nav">
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto overflow-x-hidden sidebar-nav min-h-0"> <nav className="space-y-1 px-4 py-6">
{menuItems.map(item => renderMenuItem(item))} {menuItems.map(item => renderMenuItem(item))}
</nav> </nav>
</div>
{/* User Info - fixed at bottom */} <div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-800">
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0 bg-white dark:bg-gray-800">
<div className="flex items-center space-x-3 space-x-reverse"> <div className="flex items-center space-x-3 space-x-reverse">
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center"> <div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
<span className="text-sm font-medium text-white"> <span className="text-sm font-medium text-white">
@ -407,6 +406,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</div> </div>
</div> </div>
</div> </div>
</div>
</> </>
); );
}; };

View File

@ -1,6 +1,6 @@
export interface DateRange { export interface DateRange {
from?: string; // ISO 8601 from?: string;
to?: string; // ISO 8601 to?: string;
} }
export interface DiscountUsageFilters { export interface DiscountUsageFilters {
@ -8,6 +8,13 @@ export interface DiscountUsageFilters {
discount_code?: string; discount_code?: string;
discount_id?: number; discount_id?: number;
user_id?: number; user_id?: number;
status?: "active" | "inactive" | "expired";
type?: "percentage" | "fixed" | "fee_percentage";
application_level?: "invoice" | "category" | "product" | "shipping" | "product_fee";
min_usage_count?: number;
include_unused?: boolean;
sort_by?: "usage_count" | "amount" | "date" | "code" | "created_at";
sort_order?: "asc" | "desc";
group_by_code?: boolean; group_by_code?: boolean;
limit: number; limit: number;
offset: number; offset: number;
@ -18,15 +25,15 @@ export interface DiscountUsage {
discount_code: string; discount_code: string;
discount_name: string; discount_name: string;
usage_count: number; usage_count: number;
total_amount: number; // ریال total_amount: number;
unique_users: number; unique_users: number;
first_used_at: string; // ISO 8601 first_used_at: string;
last_used_at: string; // ISO 8601 last_used_at: string;
} }
export interface DiscountUsageSummary { export interface DiscountUsageSummary {
total_usages: number; total_usages: number;
total_discount_given: number; // ریال total_discount_given: number;
unique_users: number; unique_users: number;
unique_codes: number; unique_codes: number;
most_used_code: string; most_used_code: string;
@ -43,10 +50,14 @@ export interface DiscountUsageResponse {
} }
export interface CustomerDiscountUsageFilters { export interface CustomerDiscountUsageFilters {
user_id: number; // Required user_id?: number;
phone_number?: string;
date_range?: DateRange; date_range?: DateRange;
discount_code?: string; discount_code?: string;
discount_id?: number; discount_id?: number;
status?: "active" | "inactive" | "expired";
sort_by?: "date" | "amount" | "code";
sort_order?: "asc" | "desc";
limit: number; limit: number;
offset: number; offset: number;
} }
@ -60,15 +71,15 @@ export interface CustomerDiscountUsage {
discount_name: string; discount_name: string;
order_id: number; order_id: number;
order_number: string; order_number: string;
amount: number; // ریال amount: number;
used_at: string; // ISO 8601 used_at: string;
} }
export interface CustomerDiscountUsageSummary { export interface CustomerDiscountUsageSummary {
total_usages: number; total_usages: number;
total_discount_amount: number; // ریال total_discount_amount: number;
unique_codes: number; unique_codes: number;
average_discount_per_order: number; // ریال average_discount_per_order: number;
} }
export interface CustomerDiscountUsageResponse { export interface CustomerDiscountUsageResponse {

View File

@ -12,17 +12,25 @@ export const getDiscountUsageReport = async (
): Promise<DiscountUsageResponse> => { ): Promise<DiscountUsageResponse> => {
const queryParams: Record<string, string | number | boolean> = {}; const queryParams: Record<string, string | number | boolean> = {};
if (filters.date_range?.from) queryParams.from = filters.date_range.from; queryParams.view_mode = "simple";
if (filters.date_range?.to) queryParams.to = filters.date_range.to; if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
if (filters.discount_code) queryParams.discount_code = filters.discount_code; if (filters.discount_code) queryParams.discount_code = filters.discount_code;
if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_id; if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_id;
if (filters.user_id !== undefined) queryParams.user_id = filters.user_id; if (filters.user_id !== undefined) queryParams.user_id = filters.user_id;
if (filters.group_by_code !== undefined) queryParams.group_by_code = filters.group_by_code; if (filters.group_by_code !== undefined) queryParams.group_by_code = filters.group_by_code;
if (filters.status) queryParams.status = filters.status;
if (filters.type) queryParams.type = filters.type;
if (filters.application_level) queryParams.application_level = filters.application_level;
if (filters.min_usage_count !== undefined) queryParams.min_usage_count = filters.min_usage_count;
if (filters.include_unused !== undefined) queryParams.include_unused = filters.include_unused;
if (filters.sort_by) queryParams.sort_by = filters.sort_by;
if (filters.sort_order) queryParams.sort_order = filters.sort_order;
if (filters.limit !== undefined) queryParams.limit = filters.limit; if (filters.limit !== undefined) queryParams.limit = filters.limit;
if (filters.offset !== undefined) queryParams.offset = filters.offset; if (filters.offset !== undefined) queryParams.offset = filters.offset;
const response = await httpGetRequest<DiscountUsageResponse>( const response = await httpGetRequest<DiscountUsageResponse>(
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT, queryParams) APIUrlGenerator(API_ROUTES.DISCOUNT_REPORTS, queryParams)
); );
return response.data; return response.data;
}; };
@ -32,11 +40,15 @@ export const getCustomerDiscountUsageReport = async (
): Promise<CustomerDiscountUsageResponse> => { ): Promise<CustomerDiscountUsageResponse> => {
const queryParams: Record<string, string | number> = {}; const queryParams: Record<string, string | number> = {};
queryParams.user_id = filters.user_id; if (filters.user_id !== undefined) queryParams.user_id = filters.user_id;
if (filters.date_range?.from) queryParams.from = filters.date_range.from; if (filters.phone_number) queryParams.phone_number = filters.phone_number;
if (filters.date_range?.to) queryParams.to = filters.date_range.to; if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
if (filters.discount_code) queryParams.discount_code = filters.discount_code; if (filters.discount_code) queryParams.discount_code = filters.discount_code;
if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_id; if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_id;
if (filters.status) queryParams.status = filters.status;
if (filters.sort_by) queryParams.sort_by = filters.sort_by;
if (filters.sort_order) queryParams.sort_order = filters.sort_order;
if (filters.limit !== undefined) queryParams.limit = filters.limit; if (filters.limit !== undefined) queryParams.limit = filters.limit;
if (filters.offset !== undefined) queryParams.offset = filters.offset; if (filters.offset !== undefined) queryParams.offset = filters.offset;

View File

@ -111,8 +111,13 @@ const DiscountUsageReportPage = () => {
last_used_at: formatDateTime(usage.last_used_at), last_used_at: formatDateTime(usage.last_used_at),
})); }));
const currentPage = Math.floor(filters.offset / filters.limit) + 1; const limit = filters.limit;
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1; const offset = filters.offset;
const currentPage = Math.floor(offset / limit) + 1;
const totalItems = typeof data?.total === 'number'
? data.total
: offset + (data?.usages?.length || 0) + (data?.has_more ? 1 : 0);
const totalPages = Math.max(1, Math.ceil(totalItems / limit));
return ( return (
<PageContainer> <PageContainer>
@ -299,19 +304,19 @@ const DiscountUsageReportPage = () => {
<Table columns={columns} data={tableData} loading={isLoading} /> <Table columns={columns} data={tableData} loading={isLoading} />
</div> </div>
{data && data.total > 0 && (data.total > filters.limit || data.has_more) && ( {data && totalItems > limit && (
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
onPageChange={handlePageChange} onPageChange={handlePageChange}
itemsPerPage={filters.limit} itemsPerPage={limit}
totalItems={data.total} totalItems={totalItems}
/> />
</div> </div>
)} )}
{data && data.total === 0 && ( {data && totalItems === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center"> <div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p> <p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div> </div>

View File

@ -1,15 +1,17 @@
import React, { useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup'; import * as yup from 'yup';
import { Wallet, Plus, Loader2 } from 'lucide-react'; import { Plus } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography'; import { PageContainer, PageTitle } from '@/components/ui/Typography';
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 { useWalletCredit } from '../core/_hooks'; import { useWalletCredit } from '../core/_hooks';
import { WalletCreditRequest, WalletType } from '../core/_credit-models'; import { WalletCreditRequest, WalletType } from '../core/_credit-models';
import { formatCurrency } from '@/utils/formatters'; import { formatCurrency } from '@/utils/formatters';
import { persianToEnglish } from '@/utils/numberUtils'; import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils';
import { useSearchUsers, useUsers } from '@/pages/users-admin/core/_hooks';
import { UserFilters } from '@/pages/users-admin/core/_models';
const schema = yup.object({ const schema = yup.object({
user_id: yup.number().required('شناسه کاربر الزامی است').positive('شناسه کاربر باید عدد مثبت باشد'), user_id: yup.number().required('شناسه کاربر الزامی است').positive('شناسه کاربر باید عدد مثبت باشد'),
@ -24,9 +26,14 @@ type FormData = yup.InferType<typeof schema>;
const WalletCreditPage = () => { const WalletCreditPage = () => {
const { mutate: creditWallet, isPending } = useWalletCredit(); const { mutate: creditWallet, isPending } = useWalletCredit();
const [successData, setSuccessData] = useState<{ new_balance: number; message: string } | null>(null); const [successData, setSuccessData] = useState<{ new_balance: number; message: string } | null>(null);
const [searchText, setSearchText] = useState('');
const [debouncedText, setDebouncedText] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [amountDisplay, setAmountDisplay] = useState('');
const { const {
register, register,
setValue,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
reset, reset,
@ -37,6 +44,44 @@ const WalletCreditPage = () => {
}, },
}); });
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedText(searchText);
}, 300);
return () => clearTimeout(handler);
}, [searchText]);
const { data: usersData, isLoading: isUsersLoading } = useUsers({
limit: 20,
offset: 0,
});
const searchFilters = useMemo<UserFilters>(() => {
if (!debouncedText) return {};
return {
search_text: debouncedText,
limit: 20,
offset: 0,
};
}, [debouncedText]);
const { data: searchData, isLoading: isSearchLoading } = useSearchUsers(searchFilters);
const userOptions = useMemo(() => {
if (debouncedText) {
return searchData?.users || [];
}
return usersData || [];
}, [debouncedText, searchData, usersData]);
const isLoadingUsers = debouncedText ? isSearchLoading : isUsersLoading;
const handleSelectUser = (user: any) => {
setValue('user_id', user.id, { shouldValidate: true, shouldDirty: true });
setSearchText(user.phone_number);
setIsDropdownOpen(false);
};
const onSubmit = (data: FormData) => { const onSubmit = (data: FormData) => {
const payload: WalletCreditRequest = { const payload: WalletCreditRequest = {
user_id: data.user_id, user_id: data.user_id,
@ -53,6 +98,7 @@ const WalletCreditPage = () => {
message: response.message, message: response.message,
}); });
reset(); reset();
setAmountDisplay('');
setTimeout(() => setSuccessData(null), 5000); setTimeout(() => setSuccessData(null), 5000);
}, },
}); });
@ -80,14 +126,55 @@ const WalletCreditPage = () => {
<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
type="number" value={searchText}
{...register('user_id', { onChange={(e) => {
setValueAs: (value) => (value === '' ? undefined : Number(persianToEnglish(value))), const normalized = persianToEnglish(e.target.value).replace(/[^\d]/g, '');
})} setSearchText(normalized);
setIsDropdownOpen(true);
setValue('user_id', undefined as any, { shouldValidate: true, shouldDirty: true });
}}
onFocus={() => setIsDropdownOpen(true)}
onBlur={() => setTimeout(() => setIsDropdownOpen(false), 150)}
error={errors.user_id?.message} error={errors.user_id?.message}
placeholder="مثلاً 52" placeholder="جستجو با شماره موبایل"
/> />
<input
type="hidden"
{...register('user_id')}
/>
{isDropdownOpen && (
<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">
{isLoadingUsers && (
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
در حال بارگذاری...
</div>
)}
{!isLoadingUsers && userOptions.length === 0 && (
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
کاربری یافت نشد
</div>
)}
{!isLoadingUsers && userOptions.map((user: any) => (
<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>
@ -114,11 +201,15 @@ const WalletCreditPage = () => {
</label> </label>
<Input <Input
type="text" type="text"
{...register('amount', { value={amountDisplay}
setValueAs: (value) => (value === '' ? undefined : Number(persianToEnglish(value))), onChange={(e) => {
})} const normalized = persianToEnglish(e.target.value).replace(/[^\d]/g, '');
const numericValue = normalized ? Number(normalized) : undefined;
setAmountDisplay(normalized ? formatWithThousands(Number(normalized)) : '');
setValue('amount', numericValue as any, { shouldValidate: true, shouldDirty: true });
}}
error={errors.amount?.message} error={errors.amount?.message}
placeholder="مثلاً 1000000" placeholder="مثلاً 1,000,000"
numeric numeric
/> />
</div> </div>