This commit is contained in:
hosseintaromi 2026-02-14 10:22:03 +03:30
parent 89c2abd5cf
commit 481e7e748a
13 changed files with 163 additions and 95 deletions

View File

@ -19,13 +19,14 @@ export const FormActions: React.FC<FormActionsProps> = ({
className = '', className = '',
}) => { }) => {
return ( return (
<div className={`flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600 ${className}`}> <div className={`flex flex-col-reverse sm:flex-row justify-end gap-3 sm:space-x-4 sm:space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600 ${className}`}>
{onCancel && ( {onCancel && (
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={onCancel} onClick={onCancel}
disabled={isLoading} disabled={isLoading}
className="w-full sm:w-auto"
> >
{cancelLabel} {cancelLabel}
</Button> </Button>
@ -34,6 +35,7 @@ export const FormActions: React.FC<FormActionsProps> = ({
type="submit" type="submit"
loading={isLoading} loading={isLoading}
disabled={isDisabled || isLoading} disabled={isDisabled || isLoading}
className="w-full sm:w-auto"
> >
{submitLabel} {submitLabel}
</Button> </Button>

View File

@ -223,7 +223,7 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
{showUploadArea && ( {showUploadArea && (
<div <div
className={` className={`
relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer relative border-2 border-dashed rounded-lg p-4 sm:p-6 min-w-0 transition-colors cursor-pointer
${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'} ${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'} ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'}
${error ? 'border-red-300 bg-red-50 dark:bg-red-900/20' : ''} ${error ? 'border-red-300 bg-red-50 dark:bg-red-900/20' : ''}

View File

@ -19,12 +19,16 @@ const toIsoLike = (date?: DateObject | null): string | undefined => {
if (!date) return undefined; if (!date) return undefined;
try { try {
const g = date.convert(undefined); const g = date.convert(undefined);
const yyyy = g.year.toString().padStart(4, '0'); const localDate = new Date(
const mm = g.month.toString().padStart(2, '0'); g.year,
const dd = g.day.toString().padStart(2, '0'); g.month.number - 1,
const hh = g.hour.toString().padStart(2, '0'); g.day,
const mi = g.minute.toString().padStart(2, '0'); g.hour ?? 0,
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:00Z`; g.minute ?? 0,
0,
0
);
return localDate.toISOString();
} catch { } catch {
return undefined; return undefined;
} }
@ -60,7 +64,7 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
containerClassName="w-full" containerClassName="w-full"
placeholder={placeholder || 'تاریخ و ساعت'} placeholder={placeholder || 'تاریخ و ساعت'}
editable={false} editable={false}
plugins={[<TimePicker key="time" position="bottom" />]} plugins={[<TimePicker key="time" position="bottom" hStep={1} mStep={1} />]}
disableMonthPicker={false} disableMonthPicker={false}
disableYearPicker={false} disableYearPicker={false}
showOtherDays showOtherDays

View File

@ -221,7 +221,7 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
}; };
return ( return (
<div className="space-y-6 bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border"> <div className="space-y-6 bg-gray-50 dark:bg-gray-700 p-4 sm:p-6 rounded-lg border overflow-hidden">
<div> <div>
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100"> <h4 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'} {isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'}
@ -499,26 +499,28 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
Meta Data Meta Data
</h5> </h5>
<div className="flex gap-3 mb-3"> <div className="flex flex-col sm:flex-row gap-3 mb-3">
<div className="flex flex-col sm:flex-row gap-3 flex-1 min-w-0">
<input <input
type="text" type="text"
value={newMetaKey} value={newMetaKey}
onChange={(e) => setNewMetaKey(e.target.value)} onChange={(e) => setNewMetaKey(e.target.value)}
placeholder="کلید Meta" placeholder="کلید Meta"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" className="flex-1 min-w-0 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/> />
<input <input
type="text" type="text"
value={newMetaValue} value={newMetaValue}
onChange={(e) => setNewMetaValue(e.target.value)} onChange={(e) => setNewMetaValue(e.target.value)}
placeholder="مقدار Meta" placeholder="مقدار Meta"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" className="flex-1 min-w-0 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/> />
</div>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={handleAddMeta} onClick={handleAddMeta}
className="flex items-center gap-2" className="flex items-center justify-center gap-2 w-full sm:w-auto shrink-0"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
افزودن افزودن
@ -528,14 +530,14 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
{Object.keys(meta).length > 0 && ( {Object.keys(meta).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(meta).map(([key, value]) => ( {Object.entries(meta).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-white dark:bg-gray-600 px-3 py-2 rounded-md border"> <div key={key} className="flex items-center justify-between gap-2 bg-white dark:bg-gray-600 px-3 py-2 rounded-md border min-w-0">
<span className="text-sm"> <span className="text-sm truncate min-w-0">
<strong>{key}:</strong> {String(value)} <strong>{key}:</strong> {String(value)}
</span> </span>
<button <button
type="button" type="button"
onClick={() => handleRemoveMeta(key)} onClick={() => handleRemoveMeta(key)}
className="text-red-500 hover:text-red-700" className="text-red-500 hover:text-red-700 shrink-0"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</button> </button>
@ -559,11 +561,11 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-600"> <div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-600">
<Button variant="secondary" onClick={onCancel}> <Button variant="secondary" onClick={onCancel} className="w-full sm:w-auto">
انصراف انصراف
</Button> </Button>
<Button onClick={handleSave}> <Button onClick={handleSave} className="w-full sm:w-auto">
{isEdit ? 'به‌روزرسانی' : 'افزودن'} {isEdit ? 'به‌روزرسانی' : 'افزودن'}
</Button> </Button>
</div> </div>
@ -611,12 +613,12 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Variants محصول ({variants.length}) Variants محصول ({variants.length})
</h3> </h3>
{!disabled && !showForm && ( {!disabled && !showForm && (
<Button onClick={handleAddVariant} className="flex items-center gap-2"> <Button onClick={handleAddVariant} className="flex items-center justify-center gap-2 w-full sm:w-auto shrink-0">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
افزودن Variant افزودن Variant
</Button> </Button>

View File

@ -213,7 +213,7 @@ const ProductDetailPage = () => {
{product.sku && ( {product.sku && (
<div> <div>
<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">
کد محصول (SKU) کد محصول
</label> </label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"> <div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-mono"> <p className="text-gray-900 dark:text-gray-100 font-mono">

View File

@ -530,7 +530,7 @@ const ProductFormPage = () => {
/> />
<Input <Input
label="SKU" label="کد محصول"
{...register('sku')} {...register('sku')}
error={errors.sku?.message} error={errors.sku?.message}
placeholder="مثال: RING-001" placeholder="مثال: RING-001"

View File

@ -258,7 +258,7 @@ const ProductsListPage = () => {
</div> </div>
{product.sku && ( {product.sku && (
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 dark:text-gray-400">
SKU: {product.sku} کد محصول: {product.sku}
</div> </div>
)} )}
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { usePaymentMethodsReport } from '../core/_hooks'; import { usePaymentMethodsReport } from '../core/_hooks';
import { PaymentMethodsFilters } from '../core/_models'; import { PaymentMethodsFilters } from '../core/_models';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@ -13,6 +13,8 @@ import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils';
import { PieChart } from '@/components/charts/PieChart'; import { PieChart } from '@/components/charts/PieChart';
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 formatPercentage = (value: number) => { const formatPercentage = (value: number) => {
return formatWithThousands(value.toFixed(2)) + '%'; return formatWithThousands(value.toFixed(2)) + '%';
@ -42,8 +44,31 @@ const PaymentMethodsReportPage = () => {
group_by_user: false, group_by_user: 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 } = usePaymentMethodsReport(filters); const { data, isLoading, error } = usePaymentMethodsReport(filters);
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 handleTempFilterChange = (key: keyof PaymentMethodsFilters, value: any) => { const handleTempFilterChange = (key: keyof PaymentMethodsFilters, value: any) => {
setTempFilters(prev => ({ setTempFilters(prev => ({
...prev, ...prev,
@ -61,12 +86,6 @@ const PaymentMethodsReportPage = () => {
})); }));
}; };
const handleNumericFilterChange = (key: 'user_id', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
handleTempFilterChange(key, numeric);
};
const handleApplyFilters = () => { const handleApplyFilters = () => {
setFilters({ setFilters({
...tempFilters, ...tempFilters,
@ -82,13 +101,14 @@ const PaymentMethodsReportPage = () => {
}; };
const handleClearFilters = () => { const handleClearFilters = () => {
const clearedFilters = { const clearedFilters: PaymentMethodsFilters = {
limit: 50, limit: 50,
offset: 0, offset: 0,
group_by_user: false, group_by_user: false,
}; };
setTempFilters(clearedFilters); setTempFilters(clearedFilters);
setFilters(clearedFilters); setFilters(clearedFilters);
setUserSearchText('');
}; };
const columns: TableColumn[] = [ const columns: TableColumn[] = [
@ -191,17 +211,55 @@ const PaymentMethodsReportPage = () => {
</div> </div>
</div> </div>
<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">
<span className="font-semibold">توجه:</span> برای فیلتر بر اساس کاربر، کاربر را از لیست انتخاب کنید یا با شماره موبایل جستجو کنید.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div> <div>
<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={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);
if (tempFilters.user_id) handleTempFilterChange('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>

View File

@ -65,7 +65,7 @@ const ProfitLossReportPage = () => {
<div> <div>
<div className="font-medium">{row.product_name}</div> <div className="font-medium">{row.product_name}</div>
{row.product_sku && ( {row.product_sku && (
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div> <div className="text-xs text-gray-500 dark:text-gray-400">کد محصول: {row.product_sku}</div>
)} )}
</div> </div>
), ),
@ -226,7 +226,7 @@ const ProfitLossReportPage = () => {
<div> <div>
<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">
SKU محصول کد محصول
</label> </label>
<Input <Input
value={tempFilters.product_sku || ''} value={tempFilters.product_sku || ''}

View File

@ -123,7 +123,7 @@ const SalesSummaryReportPage = () => {
<div> <div>
<div className="font-medium">{row.product_name}</div> <div className="font-medium">{row.product_name}</div>
{row.product_sku && ( {row.product_sku && (
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div> <div className="text-xs text-gray-500 dark:text-gray-400">کد محصول: {row.product_sku}</div>
)} )}
</div> </div>
</div> </div>
@ -260,7 +260,7 @@ const SalesSummaryReportPage = () => {
<div> <div>
<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">
SKU محصول کد محصول
</label> </label>
<Input <Input
value={tempFilters.product_sku || ''} value={tempFilters.product_sku || ''}

View File

@ -81,7 +81,7 @@ const VariantComparisonReportPage = () => {
<div> <div>
<div className="font-medium">{row.product_name}</div> <div className="font-medium">{row.product_name}</div>
{row.product_sku && ( {row.product_sku && (
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div> <div className="text-xs text-gray-500 dark:text-gray-400">کد محصول: {row.product_sku}</div>
)} )}
{(row.variant_size || row.variant_color) && ( {(row.variant_size || row.variant_color) && (
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 dark:text-gray-400">
@ -198,7 +198,7 @@ const VariantComparisonReportPage = () => {
<div> <div>
<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">
SKU محصول کد محصول
</label> </label>
<Input <Input
value={tempFilters.product_sku || ''} value={tempFilters.product_sku || ''}

View File

@ -191,6 +191,7 @@ export const useVerifyUser = () => {
mutationFn: (id: string) => verifyUser(id), mutationFn: (id: string) => verifyUser(id),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.SEARCH_USERS] });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [QUERY_KEY.GET_USER, variables], queryKey: [QUERY_KEY.GET_USER, variables],
}); });
@ -212,6 +213,7 @@ export const useUnverifyUser = () => {
mutationFn: (id: string) => unverifyUser(id), mutationFn: (id: string) => unverifyUser(id),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.SEARCH_USERS] });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [QUERY_KEY.GET_USER, variables], queryKey: [QUERY_KEY.GET_USER, variables],
}); });

View File

@ -193,14 +193,14 @@ const UsersAdminListPage: React.FC = () => {
</button> </button>
<button <button
onClick={() => handleVerifyToggle(row)} onClick={() => handleVerifyToggle(row)}
className={`${row.verified className={`${!row.verified
? 'text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300' ? 'text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300'
: 'text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300' : 'text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300'
}`} }`}
title={row.verified ? 'لغو تأیید' : 'تأیید کاربر'} title={row.verified ? 'لغو تأیید' : 'تأیید کاربر'}
data-testid={`verify-user-${row.id}`} data-testid={`verify-user-${row.id}`}
> >
{row.verified ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />} {!row.verified ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />}
</button> </button>
<button <button
onClick={() => handleDeleteClick(row)} onClick={() => handleDeleteClick(row)}