Compare commits
No commits in common. "85fdf1f7a2aac3ea8571032646a989e9bfe38f36" and "56a891e6685336979a8c0c61f84ec193681472c0" have entirely different histories.
85fdf1f7a2
...
56a891e668
|
|
@ -355,55 +355,55 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
w-64 transform transition-transform duration-300 ease-in-out
|
||||
lg:translate-x-0 lg:block
|
||||
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
|
||||
h-screen bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-lg lg:shadow-none
|
||||
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
|
||||
`}>
|
||||
<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">
|
||||
<SectionTitle>
|
||||
پنل مدیریت
|
||||
</SectionTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Mobile close button */}
|
||||
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<SectionTitle>
|
||||
پنل مدیریت
|
||||
</SectionTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700">
|
||||
<SectionTitle>
|
||||
پنل مدیریت
|
||||
</SectionTitle>
|
||||
</div>
|
||||
{/* Logo - desktop only */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto overflow-x-hidden sidebar-nav">
|
||||
<nav className="space-y-1 px-4 py-6">
|
||||
{menuItems.map(item => renderMenuItem(item))}
|
||||
</nav>
|
||||
</div>
|
||||
{/* Navigation - scrollable */}
|
||||
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto overflow-x-hidden sidebar-nav min-h-0">
|
||||
{menuItems.map(item => renderMenuItem(item))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-800">
|
||||
<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">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SmallText>
|
||||
{user?.first_name} {user?.last_name}
|
||||
</SmallText>
|
||||
<SmallText>
|
||||
{user?.username}
|
||||
</SmallText>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
{/* User Info - fixed at bottom */}
|
||||
<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="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SmallText>
|
||||
{user?.first_name} {user?.last_name}
|
||||
</SmallText>
|
||||
<SmallText>
|
||||
{user?.username}
|
||||
</SmallText>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export interface DateRange {
|
||||
from?: string;
|
||||
to?: string;
|
||||
from?: string; // ISO 8601
|
||||
to?: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface DiscountUsageFilters {
|
||||
|
|
@ -8,13 +8,6 @@ export interface DiscountUsageFilters {
|
|||
discount_code?: string;
|
||||
discount_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;
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
|
@ -25,15 +18,15 @@ export interface DiscountUsage {
|
|||
discount_code: string;
|
||||
discount_name: string;
|
||||
usage_count: number;
|
||||
total_amount: number;
|
||||
total_amount: number; // ریال
|
||||
unique_users: number;
|
||||
first_used_at: string;
|
||||
last_used_at: string;
|
||||
first_used_at: string; // ISO 8601
|
||||
last_used_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface DiscountUsageSummary {
|
||||
total_usages: number;
|
||||
total_discount_given: number;
|
||||
total_discount_given: number; // ریال
|
||||
unique_users: number;
|
||||
unique_codes: number;
|
||||
most_used_code: string;
|
||||
|
|
@ -50,14 +43,10 @@ export interface DiscountUsageResponse {
|
|||
}
|
||||
|
||||
export interface CustomerDiscountUsageFilters {
|
||||
user_id?: number;
|
||||
phone_number?: string;
|
||||
user_id: number; // Required
|
||||
date_range?: DateRange;
|
||||
discount_code?: string;
|
||||
discount_id?: number;
|
||||
status?: "active" | "inactive" | "expired";
|
||||
sort_by?: "date" | "amount" | "code";
|
||||
sort_order?: "asc" | "desc";
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
|
@ -71,15 +60,15 @@ export interface CustomerDiscountUsage {
|
|||
discount_name: string;
|
||||
order_id: number;
|
||||
order_number: string;
|
||||
amount: number;
|
||||
used_at: string;
|
||||
amount: number; // ریال
|
||||
used_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface CustomerDiscountUsageSummary {
|
||||
total_usages: number;
|
||||
total_discount_amount: number;
|
||||
total_discount_amount: number; // ریال
|
||||
unique_codes: number;
|
||||
average_discount_per_order: number;
|
||||
average_discount_per_order: number; // ریال
|
||||
}
|
||||
|
||||
export interface CustomerDiscountUsageResponse {
|
||||
|
|
|
|||
|
|
@ -12,25 +12,17 @@ export const getDiscountUsageReport = async (
|
|||
): Promise<DiscountUsageResponse> => {
|
||||
const queryParams: Record<string, string | number | boolean> = {};
|
||||
|
||||
queryParams.view_mode = "simple";
|
||||
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.date_range?.from) queryParams.from = filters.date_range.from;
|
||||
if (filters.date_range?.to) queryParams.to = filters.date_range.to;
|
||||
if (filters.discount_code) queryParams.discount_code = filters.discount_code;
|
||||
if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_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.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.offset !== undefined) queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<DiscountUsageResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DISCOUNT_REPORTS, queryParams)
|
||||
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -40,15 +32,11 @@ export const getCustomerDiscountUsageReport = async (
|
|||
): Promise<CustomerDiscountUsageResponse> => {
|
||||
const queryParams: Record<string, string | number> = {};
|
||||
|
||||
if (filters.user_id !== undefined) queryParams.user_id = filters.user_id;
|
||||
if (filters.phone_number) queryParams.phone_number = filters.phone_number;
|
||||
if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
|
||||
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
|
||||
queryParams.user_id = filters.user_id;
|
||||
if (filters.date_range?.from) queryParams.from = filters.date_range.from;
|
||||
if (filters.date_range?.to) queryParams.to = filters.date_range.to;
|
||||
if (filters.discount_code) queryParams.discount_code = filters.discount_code;
|
||||
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.offset !== undefined) queryParams.offset = filters.offset;
|
||||
|
||||
|
|
|
|||
|
|
@ -111,13 +111,8 @@ const DiscountUsageReportPage = () => {
|
|||
last_used_at: formatDateTime(usage.last_used_at),
|
||||
}));
|
||||
|
||||
const limit = filters.limit;
|
||||
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));
|
||||
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
|
||||
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
|
|
@ -304,19 +299,19 @@ const DiscountUsageReportPage = () => {
|
|||
<Table columns={columns} data={tableData} loading={isLoading} />
|
||||
</div>
|
||||
|
||||
{data && totalItems > limit && (
|
||||
{data && data.total > 0 && (data.total > filters.limit || data.has_more) && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
itemsPerPage={limit}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={filters.limit}
|
||||
totalItems={data.total}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && totalItems === 0 && (
|
||||
{data && data.total === 0 && (
|
||||
<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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Wallet, Plus, Loader2 } from 'lucide-react';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useWalletCredit } from '../core/_hooks';
|
||||
import { WalletCreditRequest, WalletType } from '../core/_credit-models';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils';
|
||||
import { useSearchUsers, useUsers } from '@/pages/users-admin/core/_hooks';
|
||||
import { UserFilters } from '@/pages/users-admin/core/_models';
|
||||
import { persianToEnglish } from '@/utils/numberUtils';
|
||||
|
||||
const schema = yup.object({
|
||||
user_id: yup.number().required('شناسه کاربر الزامی است').positive('شناسه کاربر باید عدد مثبت باشد'),
|
||||
|
|
@ -26,14 +24,9 @@ type FormData = yup.InferType<typeof schema>;
|
|||
const WalletCreditPage = () => {
|
||||
const { mutate: creditWallet, isPending } = useWalletCredit();
|
||||
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 {
|
||||
register,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
|
|
@ -44,44 +37,6 @@ 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 payload: WalletCreditRequest = {
|
||||
user_id: data.user_id,
|
||||
|
|
@ -98,7 +53,6 @@ const WalletCreditPage = () => {
|
|||
message: response.message,
|
||||
});
|
||||
reset();
|
||||
setAmountDisplay('');
|
||||
setTimeout(() => setSuccessData(null), 5000);
|
||||
},
|
||||
});
|
||||
|
|
@ -126,55 +80,14 @@ const WalletCreditPage = () => {
|
|||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
شناسه کاربر <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
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}
|
||||
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>
|
||||
<Input
|
||||
type="number"
|
||||
{...register('user_id', {
|
||||
setValueAs: (value) => (value === '' ? undefined : Number(persianToEnglish(value))),
|
||||
})}
|
||||
error={errors.user_id?.message}
|
||||
placeholder="مثلاً 52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -201,15 +114,11 @@ const WalletCreditPage = () => {
|
|||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={amountDisplay}
|
||||
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 });
|
||||
}}
|
||||
{...register('amount', {
|
||||
setValueAs: (value) => (value === '' ? undefined : Number(persianToEnglish(value))),
|
||||
})}
|
||||
error={errors.amount?.message}
|
||||
placeholder="مثلاً 1,000,000"
|
||||
placeholder="مثلاً 1000000"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue