feat(orders): improve order detail page UI and add payment settings pages

- Add product images display in order items
- Fix payment section overflow issues
- Merge customer and address sections into single card
- Add shipping method and delivery slot display
- Add payment gateway management page (IPG)
- Add wallet management page
- Add card-to-card payment settings page
- Remove coverage column from shipping methods table
- Improve overall layout and responsiveness
This commit is contained in:
hosseintaromi 2025-12-25 18:15:53 +03:30
parent 78b73808e5
commit 3d437aeb53
25 changed files with 1548 additions and 291 deletions

View File

@ -70,6 +70,15 @@ const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsL
const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage')); const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage'));
const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage')); const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage'));
// Payment IPG Page
const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'));
// Payment Card Page
const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormPage'));
// Wallet Page
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage'));
const ProtectedRoute = ({ children }: { children: any }) => { const ProtectedRoute = ({ children }: { children: any }) => {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
@ -158,6 +167,15 @@ const AppRoutes = () => {
<Route path="products/create" element={<ProductFormPage />} /> <Route path="products/create" element={<ProductFormPage />} />
<Route path="products/:id" element={<ProductDetailPage />} /> <Route path="products/:id" element={<ProductDetailPage />} />
<Route path="products/:id/edit" element={<ProductFormPage />} /> <Route path="products/:id/edit" element={<ProductFormPage />} />
{/* Payment IPG Route */}
<Route path="payment-ipg" element={<IPGListPage />} />
{/* Payment Card Route */}
<Route path="payment-card" element={<CardFormPage />} />
{/* Wallet Route */}
<Route path="wallet" element={<WalletListPage />} />
</Route> </Route>
</Routes> </Routes>
); );

View File

@ -17,7 +17,9 @@ import {
Users, Users,
Truck, Truck,
X, X,
MessageSquare MessageSquare,
CreditCard,
Wallet
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { PermissionWrapper } from '../common/PermissionWrapper'; import { PermissionWrapper } from '../common/PermissionWrapper';
@ -123,6 +125,21 @@ const menuItems: MenuItem[] = [
icon: Truck, icon: Truck,
path: '/shipping-methods', path: '/shipping-methods',
}, },
{
title: 'درگاه‌های پرداخت',
icon: CreditCard,
path: '/payment-ipg',
},
{
title: 'پرداخت کارت به کارت',
icon: CreditCard,
path: '/payment-card',
},
{
title: 'مدیریت کیف پول',
icon: Wallet,
path: '/wallet',
},
] ]
} }
]; ];

View File

@ -68,14 +68,14 @@ export const TagInput: React.FC<TagInputProps> = ({
{values.map((value, index) => ( {values.map((value, index) => (
<span <span
key={index} key={index}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-800 text-sm rounded-md" className="inline-flex items-center gap-1 px-2.5 py-1.5 bg-primary-100 dark:bg-primary-500/30 text-primary-900 dark:text-white text-sm rounded-md border border-primary-200 dark:border-primary-500/60"
> >
{value} {value}
{!disabled && ( {!disabled && (
<button <button
type="button" type="button"
onClick={() => removeValue(index)} onClick={() => removeValue(index)}
className="hover:bg-primary-200 rounded-full p-0.5" className="hover:bg-primary-200 dark:hover:bg-primary-500/50 rounded-full p-0.5"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>

View File

@ -133,4 +133,16 @@ export const API_ROUTES = {
CREATE_TICKET_SUBJECT: "tickets/config/subjects", CREATE_TICKET_SUBJECT: "tickets/config/subjects",
UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`, UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`, DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
// Payment IPG APIs
GET_IPG_STATUS: "payment/ipg/status",
UPDATE_IPG_STATUS: "payment/ipg/status",
// Payment Card APIs
GET_PAYMENT_CARD: "payment/card",
UPDATE_PAYMENT_CARD: "payment/card",
// Wallet APIs
GET_WALLET_STATUS: "wallet/status",
UPDATE_WALLET_STATUS: "wallet/status",
}; };

View File

@ -20,6 +20,7 @@ export interface OrderItem {
product_id: number; product_id: number;
product_name: string; product_name: string;
product_image?: string; product_image?: string;
image_url?: string;
variant_id?: number; variant_id?: number;
variant_name?: string; variant_name?: string;
product_variant_id?: number; product_variant_id?: number;
@ -128,6 +129,12 @@ export interface Order {
notes?: string; notes?: string;
tracking_number?: string; tracking_number?: string;
estimated_delivery?: string; estimated_delivery?: string;
shipping_method_id?: number;
selected_delivery_slot?: string | {
date?: string;
from_hour?: number;
to_hour?: number;
};
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useOrder, useUpdateOrderStatus } from '../core/_hooks'; import { useOrder, useUpdateOrderStatus } from '../core/_hooks';
import { OrderStatus } from '../core/_models'; import { OrderStatus } from '../core/_models';
import { useShippingMethods } from '@/pages/shipping-methods/core/_hooks';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
@ -20,6 +21,16 @@ import {
FileText FileText
} from 'lucide-react'; } from 'lucide-react';
import { englishToPersian } from '@/utils/numberUtils'; import { englishToPersian } from '@/utils/numberUtils';
import { API_GATE_WAY } from '@/constant/routes';
const resolveImageUrl = (imageUrl?: string): string => {
if (!imageUrl) return '';
const trimmedUrl = imageUrl.trim();
if (trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')) {
return trimmedUrl;
}
return `${API_GATE_WAY}${trimmedUrl.startsWith('/') ? '' : '/'}${trimmedUrl}`;
};
const getStatusColor = (status: OrderStatus) => { const getStatusColor = (status: OrderStatus) => {
const colors = { const colors = {
@ -81,7 +92,12 @@ const OrderDetailPage = () => {
const { data, isLoading, error } = useOrder(id || ''); const { data, isLoading, error } = useOrder(id || '');
const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus(); const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus();
const { data: shippingMethods = [] } = useShippingMethods();
const order = data?.order; const order = data?.order;
const shippingMethod = order?.shipping_method_id
? shippingMethods.find(method => method.id === order.shipping_method_id)
: null;
const handleStatusUpdate = () => { const handleStatusUpdate = () => {
if (id) { if (id) {
updateStatus( updateStatus(
@ -99,7 +115,6 @@ const OrderDetailPage = () => {
}; };
if (isLoading) return <LoadingSpinner />; if (isLoading) return <LoadingSpinner />;
console.log(order)
if (error || !order) { if (error || !order) {
return ( return (
<PageContainer> <PageContainer>
@ -143,9 +158,148 @@ const OrderDetailPage = () => {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="space-y-8">
{/* ستون اصلی */} {/* اطلاعات کاربر سفارش‌دهنده و آدرس */}
<div className="lg:col-span-2 space-y-8"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-gradient-to-r from-indigo-50 to-blue-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 dark:bg-indigo-900 rounded-lg">
<User className="h-5 w-5 text-indigo-600 dark:text-indigo-300" />
</div>
<SectionTitle>اطلاعات مشتری و آدرس</SectionTitle>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* اطلاعات کاربر */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<User className="h-5 w-5 text-indigo-600 dark:text-indigo-300" />
اطلاعات کاربر
</h3>
{order?.user ? (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="shrink-0">
{order.user.avatar ? (
<img
src={order.user.avatar}
alt={`${order.user.first_name} ${order.user.last_name}`}
className="h-12 w-12 rounded-full object-cover border-2 border-indigo-200 dark:border-indigo-700"
/>
) : (
<div className="h-12 w-12 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center border-2 border-indigo-200 dark:border-indigo-700">
<span className="text-lg font-semibold text-indigo-600 dark:text-indigo-300">
{(order.user.first_name?.charAt(0) || '') + (order.user.last_name?.charAt(0) || '') || 'U'}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-2 text-base">
{(order.user.first_name || 'نامشخص') + ' ' + (order.user.last_name || '')}
</h4>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${order.user.verified
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'}`}
>
{order.user.verified ? 'تأیید شده' : 'تأیید نشده'}
</span>
</div>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<Mail className="h-5 w-5 text-gray-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">ایمیل</p>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 break-words">{order.user.email || 'ایمیل نامشخص'}</p>
</div>
</div>
{order.user.phone_number && (
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<Phone className="h-5 w-5 text-gray-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">شماره تلفن</p>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 font-mono" dir="ltr" style={{ direction: 'ltr' }}>
{englishToPersian(order.user.phone_number)}
</p>
</div>
</div>
)}
</div>
</div>
) : (
<p className="text-gray-500 dark:text-gray-400">اطلاعات کاربر در دسترس نیست</p>
)}
</div>
{/* آدرس ارسال */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<MapPin className="h-5 w-5 text-orange-600 dark:text-orange-300" />
آدرس ارسال
</h3>
{order?.shipping_address ? (
<div className="space-y-3">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="space-y-2.5 text-sm">
{order.shipping_address.name && (
<div className="flex items-start gap-2">
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0 min-w-[80px]">نام:</span>
<span className="text-gray-900 dark:text-gray-100 break-words">{order.shipping_address.name}</span>
</div>
)}
{order.shipping_address.address && (
<div className="flex items-start gap-2">
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0 min-w-[80px]">آدرس:</span>
<span className="text-gray-900 dark:text-gray-100 break-words">{order.shipping_address.address}</span>
</div>
)}
{(order.shipping_address.city || order.shipping_address.state) && (
<div className="flex items-start gap-2">
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0 min-w-[80px]">شهر/استان:</span>
<span className="text-gray-900 dark:text-gray-100">
{order.shipping_address.city || 'نامشخص'}{order.shipping_address.state ? `، ${order.shipping_address.state}` : ''}
</span>
</div>
)}
{order.shipping_address.region && (
<div className="flex items-start gap-2">
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0 min-w-[80px]">منطقه:</span>
<span className="text-gray-900 dark:text-gray-100">{order.shipping_address.region}</span>
</div>
)}
{order.shipping_address.postal_code && (
<div className="flex items-start gap-2">
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0 min-w-[80px]">کد پستی:</span>
<span className="text-gray-900 dark:text-gray-100 font-mono">{order.shipping_address.postal_code}</span>
</div>
)}
{order.shipping_address.plaque && (
<div className="flex items-start gap-2">
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0 min-w-[80px]">پلاک/واحد:</span>
<span className="text-gray-900 dark:text-gray-100">
{order.shipping_address.plaque}{order.shipping_address.unit ? `، واحد ${order.shipping_address.unit}` : ''}
</span>
</div>
)}
{order.shipping_address.receiving_address && (
<div className="flex items-start gap-2">
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0 min-w-[80px]">آدرس تحویل:</span>
<span className="text-gray-900 dark:text-gray-100 break-words">{order.shipping_address.receiving_address}</span>
</div>
)}
</div>
</div>
</div>
) : (
<p className="text-gray-500 dark:text-gray-400">آدرس ارسال در دسترس نیست</p>
)}
</div>
</div>
</div>
</div>
{/* اطلاعات سفارش */} {/* اطلاعات سفارش */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@ -196,6 +350,30 @@ const OrderDetailPage = () => {
<p className="text-gray-600 dark:text-gray-400">{formatDate(order.estimated_delivery)}</p> <p className="text-gray-600 dark:text-gray-400">{formatDate(order.estimated_delivery)}</p>
</div> </div>
)} )}
{order?.shipping_method_id !== undefined && order?.shipping_method_id !== null && (
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">متد ارسال</h4>
<p className="text-gray-600 dark:text-gray-400">
{shippingMethod
? shippingMethod.name
: shippingMethods.length === 0
? `شناسه: ${order.shipping_method_id} (در حال بارگذاری...)`
: `شناسه: ${order.shipping_method_id}`
}
</p>
</div>
)}
{order?.selected_delivery_slot && (
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">زمان تحویل انتخاب شده</h4>
<p className="text-gray-600 dark:text-gray-400">
{typeof order.selected_delivery_slot === 'object'
? `${order.selected_delivery_slot.date || ''} ${order.selected_delivery_slot.from_hour || ''}:${order.selected_delivery_slot.to_hour || ''}`
: String(order.selected_delivery_slot)
}
</p>
</div>
)}
</div> </div>
{order?.notes && ( {order?.notes && (
<div className="mt-6"> <div className="mt-6">
@ -225,16 +403,32 @@ const OrderDetailPage = () => {
const baseWeight = (item.weight ?? 0) as number; const baseWeight = (item.weight ?? 0) as number;
const weightGr = Math.round(baseWeight * 1000); const weightGr = Math.round(baseWeight * 1000);
const formatFa = (n: number) => new Intl.NumberFormat('fa-IR').format(n); const formatFa = (n: number) => new Intl.NumberFormat('fa-IR').format(n);
const imageUrl = item.image_url || item.product_image;
return ( return (
<div key={item.id} className="px-4 md:px-6 py-3"> <div key={item.id} className="px-4 md:px-6 py-3">
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 md:p-5 shadow-sm"> <div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 md:p-5 shadow-sm">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0 flex-1">
{imageUrl && (
<div className="shrink-0">
<img
src={resolveImageUrl(imageUrl)}
alt={item.product_name || 'محصول'}
className="w-16 h-16 md:w-20 md:h-20 rounded-lg object-cover border border-gray-200 dark:border-gray-600"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
</div>
)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate"> <div className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{item.product_name || `محصول شناسه: ${item.product_id}`} {item.product_name || `محصول شناسه: ${item.product_id}`}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
{item.product_variant_name || `واریانت شناسه: ${item.product_variant_id}`} {item.product_variant_name || `واریانت شناسه: ${item.product_variant_id}`}
</div>
</div> </div>
</div> </div>
<div className="text-left shrink-0"> <div className="text-left shrink-0">
@ -270,87 +464,6 @@ const OrderDetailPage = () => {
)} )}
</div> </div>
</div> </div>
{/* آدرس‌ها - منتقل شده به زیر محصولات */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-gradient-to-r from-orange-50 to-red-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 dark:bg-orange-900 rounded-lg">
<MapPin className="h-5 w-5 text-orange-600 dark:text-orange-300" />
</div>
<SectionTitle>آدرسها</SectionTitle>
</div>
</div>
<div className="p-6 space-y-6">
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-3">آدرس ارسال</h4>
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-1 break-words">
<p className="break-words"><strong>نام:</strong> {order?.shipping_address?.name || 'نام نامشخص'}</p>
<p className="break-words"><strong>آدرس:</strong> {order?.shipping_address?.address || 'آدرس نامشخص'}</p>
<p className="break-words"><strong>شهر:</strong> {order?.shipping_address?.city || 'شهر نامشخص'}, <strong>استان:</strong> {order?.shipping_address?.state || 'استان نامشخص'}</p>
<p className="break-words"><strong>منطقه:</strong> {order?.shipping_address?.region || 'منطقه نامشخص'}</p>
<p className="break-words"><strong>کد پستی:</strong> {order?.shipping_address?.postal_code || 'نامشخص'}</p>
{order?.shipping_address?.plaque && (
<p className="break-words"><strong>پلاک:</strong> {order.shipping_address.plaque}, <strong>واحد:</strong> {order.shipping_address.unit || 'ندارد'}</p>
)}
{order?.shipping_address?.receiving_address && (
<p className="break-words"><strong>آدرس تحویل:</strong> {order.shipping_address.receiving_address}</p>
)}
</div>
</div>
</div>
</div>
</div>
{/* ستون جانبی */}
<div className="space-y-8">
{/* اطلاعات کاربر سفارش‌دهنده */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-gradient-to-r from-indigo-50 to-blue-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 dark:bg-indigo-900 rounded-lg">
<User className="h-5 w-5 text-indigo-600 dark:text-indigo-300" />
</div>
<SectionTitle>کاربر سفارشدهنده</SectionTitle>
</div>
</div>
<div className="p-6">
{order?.user ? (
<div className="space-y-4">
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">نام</h4>
<p className="text-gray-600 dark:text-gray-400">
{(order.user.first_name || 'نامشخص') + ' ' + (order.user.last_name || '')}
</p>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400">{order.user.email || 'ایمیل نامشخص'}</p>
</div>
{order.user.phone_number && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400 font-mono" dir="ltr" style={{ direction: 'ltr' }}>
{englishToPersian(order.user.phone_number)}
</p>
</div>
)}
<div className="flex items-center gap-2">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${order.user.verified
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'}`}
>
{order.user.verified ? 'تأیید شده' : 'تأیید نشده'}
</span>
</div>
</div>
) : (
<p className="text-gray-500 dark:text-gray-400">اطلاعات کاربر در دسترس نیست</p>
)}
</div>
</div>
{/* اطلاعات پرداخت */} {/* اطلاعات پرداخت */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
@ -362,42 +475,42 @@ const OrderDetailPage = () => {
<SectionTitle>پرداخت</SectionTitle> <SectionTitle>پرداخت</SectionTitle>
</div> </div>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4 overflow-hidden">
<div className="flex items-center justify-between text-base"> <div className="flex items-center justify-between text-base gap-2">
<span className="text-gray-800 dark:text-gray-200">جمع اقلام</span> <span className="text-gray-800 dark:text-gray-200 shrink-0">جمع اقلام</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(order?.net_total || 0)}</span> <span className="font-medium text-gray-900 dark:text-gray-100 break-all text-left">{formatCurrency(order?.net_total || 0)}</span>
</div> </div>
<div className="flex items-center justify-between text-base"> <div className="flex items-center justify-between text-base gap-2">
<span className="text-gray-800 dark:text-gray-200">مالیات</span> <span className="text-gray-800 dark:text-gray-200 shrink-0">مالیات</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(order?.vat_total || 0)}</span> <span className="font-medium text-gray-900 dark:text-gray-100 break-all text-left">{formatCurrency(order?.vat_total || 0)}</span>
</div> </div>
{order?.base_gold_price !== undefined && ( {order?.base_gold_price !== undefined && (
<div className="flex items-center justify-between text-base"> <div className="flex items-center justify-between text-base gap-2">
<span className="text-gray-800 dark:text-gray-200">قیمت پایه طلا</span> <span className="text-gray-800 dark:text-gray-200 shrink-0">قیمت پایه طلا</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(order.base_gold_price)}</span> <span className="font-medium text-gray-900 dark:text-gray-100 break-all text-left">{formatCurrency(order.base_gold_price)}</span>
</div> </div>
)} )}
<div className="flex items-center justify-between text-base"> <div className="flex items-center justify-between text-base gap-2">
<span className="text-gray-800 dark:text-gray-200">هزینه ارسال</span> <span className="text-gray-800 dark:text-gray-200 shrink-0">هزینه ارسال</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(order?.shipping_total || 0)}</span> <span className="font-medium text-gray-900 dark:text-gray-100 break-all text-left">{formatCurrency(order?.shipping_total || 0)}</span>
</div> </div>
{(order?.discount_total || 0) > 0 && ( {(order?.discount_total || 0) > 0 && (
<div className="flex justify-between text-green-600 dark:text-green-400"> <div className="flex justify-between text-green-600 dark:text-green-400 gap-2">
<span>تخفیف کل</span> <span className="shrink-0">تخفیف کل</span>
<span className="font-medium">-{formatCurrency(order?.discount_total || 0)}</span> <span className="font-medium break-all text-left">-{formatCurrency(order?.discount_total || 0)}</span>
</div> </div>
)} )}
<hr className="border-gray-200 dark:border-gray-700" /> <hr className="border-gray-200 dark:border-gray-700" />
<div className="flex items-center justify-between text-lg font-bold"> <div className="flex items-center justify-between text-lg font-bold gap-2">
<span className="text-gray-900 dark:text-gray-100">مجموع نهایی</span> <span className="text-gray-900 dark:text-gray-100 shrink-0">مجموع نهایی</span>
<span className="text-gray-900 dark:text-gray-100">{formatCurrency(order?.final_total || 0)}</span> <span className="text-gray-900 dark:text-gray-100 break-all text-left">{formatCurrency(order?.final_total || 0)}</span>
</div> </div>
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-base"> <div className="flex items-center justify-between text-base gap-2">
<span className="text-gray-700 dark:text-gray-200">وضعیت پرداخت</span> <span className="text-gray-700 dark:text-gray-200 shrink-0">وضعیت پرداخت</span>
<span className={`text-sm font-medium px-2 py-1 rounded-full ${order?.payment_status === 'paid' <span className={`text-sm font-medium px-2 py-1 rounded-full shrink-0 ${order?.payment_status === 'paid'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}> }`}>
@ -405,32 +518,31 @@ const OrderDetailPage = () => {
</span> </span>
</div> </div>
{Array.isArray((data as any)?.payments) && (data as any)?.payments.length > 0 && ( {Array.isArray((data as any)?.payments) && (data as any)?.payments.length > 0 && (
<div className="flex items-center justify-between text-base"> <div className="flex items-center justify-between text-base gap-2">
<span className="text-gray-700 dark:text-gray-200">روش پرداخت</span> <span className="text-gray-700 dark:text-gray-200 shrink-0">روش پرداخت</span>
<span className="text-gray-900 dark:text-gray-100">{formatPaymentType((data as any).payments[0].payment_type)}</span> <span className="text-gray-900 dark:text-gray-100 break-words text-left">{formatPaymentType((data as any).payments[0].payment_type)}</span>
</div> </div>
)} )}
{order?.invoice_id && ( {order?.invoice_id && (
<div className="flex items-center justify-between text-base"> <div className="flex items-start justify-between text-base gap-2">
<span className="text-gray-700 dark:text-gray-200">شماره فاکتور</span> <span className="text-gray-700 dark:text-gray-200 shrink-0">شماره فاکتور</span>
<span className="font-mono text-gray-900 dark:text-gray-100">{order.invoice_id}</span> <span className="font-mono text-gray-900 dark:text-gray-100 break-all text-left text-sm">{order.invoice_id}</span>
</div> </div>
)} )}
{Array.isArray((data as any)?.payments) && (data as any)?.payments[0]?.transaction_id && ( {Array.isArray((data as any)?.payments) && (data as any)?.payments[0]?.transaction_id && (
<div className="flex items-center justify-between text-base"> <div className="flex items-start justify-between text-base gap-2">
<span className="text-gray-700 dark:text-gray-200">شناسه تراکنش</span> <span className="text-gray-700 dark:text-gray-200 shrink-0">شناسه تراکنش</span>
<span className="font-mono text-gray-900 dark:text-gray-100">{(data as any).payments[0].transaction_id}</span> <span className="font-mono text-gray-900 dark:text-gray-100 break-all text-left text-sm">{(data as any).payments[0].transaction_id}</span>
</div> </div>
)} )}
{Array.isArray((data as any)?.payments) && (data as any)?.payments[0]?.image_urls?.length > 0 && ( {Array.isArray((data as any)?.payments) && (data as any)?.payments[0]?.image_urls?.length > 0 && (
<div className="flex items-center justify-between text-base"> <div className="flex items-center justify-between text-base gap-2">
<span className="text-gray-700 dark:text-gray-200">رسید پرداخت</span> <span className="text-gray-700 dark:text-gray-200 shrink-0">رسید پرداخت</span>
<a href={(data as any).payments[0].image_urls[0]} target="_blank" rel="noreferrer" className="shrink-0"> <a href={(data as any).payments[0].image_urls[0]} target="_blank" rel="noreferrer" className="shrink-0">
<img src={(data as any).payments[0].image_urls[0]} alt="رسید پرداخت" className="h-14 w-14 rounded-md object-cover border border-gray-200 dark:border-gray-600" /> <img src={(data as any).payments[0].image_urls[0]} alt="رسید پرداخت" className="h-14 w-14 rounded-md object-cover border border-gray-200 dark:border-gray-600" />
</a> </a>
</div> </div>
)} )}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,26 +1,28 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { englishToPersian } from '@/utils/numberUtils'; import { englishToPersian, persianToEnglish, formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useOrders, useOrderStats, useUpdateOrderStatus } from '../core/_hooks'; import { useOrders, useOrderStats, useUpdateOrderStatus } from '../core/_hooks';
import { Order, OrderFilters, OrderStatus } from '../core/_models'; import { OrderFilters, OrderStatus } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography"; import { PageContainer, PageTitle } from "@/components/ui/Typography";
import { Table } from "@/components/ui/Table"; import { Table } from "@/components/ui/Table";
import { TableColumn } from "@/types"; import { TableColumn } from "@/types";
import { StatsCard } from '@/components/dashboard/StatsCard';
import DatePicker from 'react-multi-date-picker';
import persian from 'react-date-object/calendars/persian';
import persian_fa from 'react-date-object/locales/persian_fa';
import DateObject from 'react-date-object';
import { import {
ShoppingCart, ShoppingCart,
Package,
DollarSign, DollarSign,
Clock, Clock,
Search, Search,
Filter, Filter,
Eye, Eye,
Edit3, Edit3,
TrendingUp, TrendingUp
Calendar
} from 'lucide-react'; } from 'lucide-react';
const getStatusColor = (status: OrderStatus) => { const getStatusColor = (status: OrderStatus) => {
@ -59,26 +61,145 @@ const ListSkeleton = () => (
<Table columns={[]} data={[]} loading={true} /> <Table columns={[]} data={[]} loading={true} />
); );
const getDefaultFilters = (): OrderFilters => ({
page: 1,
limit: 20,
status: 'pending',
payment_status: undefined,
search: '',
user_id: undefined,
invoice_id: undefined,
discount_code: undefined,
created_from: undefined,
created_to: undefined,
updated_from: undefined,
updated_to: undefined,
min_total: undefined,
max_total: undefined,
});
const toIsoDate = (date?: DateObject | null) => {
if (!date) return undefined;
try {
const g = date.convert(undefined);
const yyyy = g.year.toString().padStart(4, '0');
const mm = g.month.toString().padStart(2, '0');
const dd = g.day.toString().padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
} catch {
return undefined;
}
};
const fromIsoDate = (value?: string) => {
if (!value) return undefined;
try {
const d = new Date(value);
if (isNaN(d.getTime())) return undefined;
return new DateObject(d).convert(persian, persian_fa);
} catch {
return undefined;
}
};
const buildRangeValue = (from?: string, to?: string) => {
const start = fromIsoDate(from);
const end = fromIsoDate(to);
if (start && end) return [start, end];
if (start) return [start];
if (end) return [end];
return [];
};
const OrdersListPage = () => { const OrdersListPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [statusUpdateId, setStatusUpdateId] = useState<string | null>(null); const [statusUpdateId, setStatusUpdateId] = useState<string | null>(null);
const [newStatus, setNewStatus] = useState<OrderStatus>('processing'); const [newStatus, setNewStatus] = useState<OrderStatus>('processing');
const [filters, setFilters] = useState<OrderFilters>({ const [filters, setFilters] = useState<OrderFilters>(getDefaultFilters());
page: 1,
limit: 20,
order_number: '',
status: 'pending',
payment_status: undefined,
search: '',
});
const { data: ordersData, isLoading, error } = useOrders(filters); const { data: ordersData, isLoading, error } = useOrders(filters);
// Temporarily disabled stats API const { data: stats, isLoading: statsLoading, error: statsError } = useOrderStats(true);
// const { data: stats, isLoading: statsLoading } = useOrderStats(!isLoading);
const stats = null;
const statsLoading = false;
const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus(); const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus();
const handleIdFilterChange = (key: keyof OrderFilters, raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
setFilters(prev => ({
...prev,
[key]: numeric,
page: 1,
}));
};
const handleTextFilterChange = (key: keyof OrderFilters, value: string) => {
setFilters(prev => ({
...prev,
[key]: value || undefined,
page: 1,
}));
};
const formatNumberDisplay = (val?: number) => {
if (val === undefined || val === null || Number.isNaN(val)) return '';
return formatWithThousands(val);
};
const handleAmountFilterChange = (key: keyof OrderFilters, raw: string) => {
const converted = persianToEnglish(raw);
const numeric = parseFormattedNumber(converted);
setFilters(prev => ({
...prev,
[key]: numeric,
page: 1,
}));
};
const handleDateRangeChange = (startKey: keyof OrderFilters, endKey: keyof OrderFilters, range: (DateObject | null)[] | DateObject | null) => {
if (Array.isArray(range)) {
const [start, end] = range;
setFilters(prev => ({
...prev,
[startKey]: toIsoDate(start),
[endKey]: toIsoDate(end),
page: 1,
}));
return;
}
setFilters(prev => ({
...prev,
[startKey]: toIsoDate(range as DateObject | null),
[endKey]: undefined,
page: 1,
}));
};
const statsItems = useMemo(() => ([
{
title: 'کل سفارشات',
value: stats?.total_orders_count ?? 0,
icon: ShoppingCart,
color: 'yellow' as const,
},
{
title: 'مجموع فروش',
value: stats?.total_amount_of_sale ?? 0,
icon: DollarSign,
color: 'green' as const,
},
{
title: 'سفارش‌های در انتظار',
value: stats?.total_order_pending ?? 0,
icon: Clock,
color: 'blue' as const,
},
{
title: 'میانگین سفارش',
value: stats?.order_avg ?? 0,
icon: TrendingUp,
color: 'purple' as const,
},
]), [stats]);
const columns: TableColumn[] = useMemo(() => [ const columns: TableColumn[] = useMemo(() => [
{ key: 'order_number', label: 'شماره سفارش', sortable: true, align: 'right', render: (v: string) => `#${v}` }, { key: 'order_number', label: 'شماره سفارش', sortable: true, align: 'right', render: (v: string) => `#${v}` },
@ -171,64 +292,24 @@ const OrdersListPage = () => {
</div> </div>
</div> </div>
{/* آمار کلی */} <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 lg:gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> {statsLoading ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> <>
<div className="flex items-center"> {[...Array(4)].map((_, idx) => (
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg"> <div key={idx} className="card p-6 animate-pulse bg-gray-100 dark:bg-gray-800 h-24" />
<ShoppingCart className="h-6 w-6 text-blue-600 dark:text-blue-300" /> ))}
</div> </>
<div className="mr-4"> ) : (
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل سفارشات</p> statsItems.map((stat, index) => (
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100"> <StatsCard key={index} {...stat} />
-- ))
</p> )}
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center">
<div className="p-3 bg-green-100 dark:bg-green-900 rounded-lg">
<DollarSign className="h-6 w-6 text-green-600 dark:text-green-300" />
</div>
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل فروش</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
--
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center">
<div className="p-3 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<Clock className="h-6 w-6 text-yellow-600 dark:text-yellow-300" />
</div>
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">در انتظار</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
--
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center">
<div className="p-3 bg-purple-100 dark:bg-purple-900 rounded-lg">
<TrendingUp className="h-6 w-6 text-purple-600 dark:text-purple-300" />
</div>
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">میانگین سفارش</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
--
</p>
</div>
</div>
</div>
</div> </div>
{statsError && (
<div className="mt-2 text-sm text-red-600 dark:text-red-400">
خطا در دریافت آمار سفارشات
</div>
)}
{/* فیلترها */} {/* فیلترها */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
@ -237,7 +318,7 @@ const OrdersListPage = () => {
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-300" /> <Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-300" />
<input <input
type="text" type="text"
placeholder="جستجو عمومی (شماره سفارش، کد تراکنش، کد تخفیف)..." placeholder="جستجو عمومی (شماره سفارش، کد تراکنش، نام، تلفن، کالا، کد تخفیف)"
value={filters.search || ''} value={filters.search || ''}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value, page: 1 }))} onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value, page: 1 }))}
className="w-full pr-10 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-300" className="w-full pr-10 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-300"
@ -245,6 +326,42 @@ const OrdersListPage = () => {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">شناسه کاربر</label>
<input
type="text"
inputMode="numeric"
value={filters.user_id ? String(filters.user_id) : ''}
onChange={(e) => handleIdFilterChange('user_id', e.target.value)}
placeholder="مثلا 1024"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">شناسه فاکتور</label>
<input
type="text"
inputMode="numeric"
value={filters.invoice_id ? String(filters.invoice_id) : ''}
onChange={(e) => handleIdFilterChange('invoice_id', e.target.value)}
placeholder="invoice_id"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد تخفیف</label>
<input
type="text"
value={filters.discount_code ?? ''}
onChange={(e) => handleTextFilterChange('discount_code', e.target.value)}
placeholder="مثلا SPRING2025"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">وضعیت سفارش</label>
<select <select
value={filters.status || ''} value={filters.status || ''}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value as OrderStatus || undefined, page: 1 }))} onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value as OrderStatus || undefined, page: 1 }))}
@ -261,6 +378,7 @@ const OrdersListPage = () => {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">وضعیت پرداخت</label>
<select <select
value={filters.payment_status || ''} value={filters.payment_status || ''}
onChange={(e) => setFilters(prev => ({ ...prev, payment_status: e.target.value as any || undefined, page: 1 }))} onChange={(e) => setFilters(prev => ({ ...prev, payment_status: e.target.value as any || undefined, page: 1 }))}
@ -275,9 +393,67 @@ const OrdersListPage = () => {
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">حداقل مبلغ</label>
<input
type="text"
inputMode="numeric"
value={formatNumberDisplay(filters.min_total)}
onChange={(e) => handleAmountFilterChange('min_total', e.target.value)}
placeholder="مثلا 3000000"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">حداکثر مبلغ</label>
<input
type="text"
inputMode="numeric"
value={formatNumberDisplay(filters.max_total)}
onChange={(e) => handleAmountFilterChange('max_total', e.target.value)}
placeholder="مثلا 9000000"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div className="lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">بازه تاریخ ایجاد</label>
<DatePicker
value={buildRangeValue(filters.created_from, filters.created_to)}
onChange={(range) => handleDateRangeChange('created_from', 'created_to', range as any)}
format="YYYY/MM/DD"
range
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-center"
inputClass="w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500"
containerClassName="w-full"
editable={false}
placeholder="از تاریخ / تا تاریخ"
/>
</div>
<div className="lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">بازه تاریخ بروزرسانی</label>
<DatePicker
value={buildRangeValue(filters.updated_from, filters.updated_to)}
onChange={(range) => handleDateRangeChange('updated_from', 'updated_to', range as any)}
format="YYYY/MM/DD"
range
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-center"
inputClass="w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500"
containerClassName="w-full"
editable={false}
placeholder="از تاریخ / تا تاریخ"
/>
</div>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setFilters({ page: 1, limit: 20, order_number: '', status: undefined, payment_status: undefined, search: '' })} onClick={() => setFilters(getDefaultFilters())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />

View File

@ -0,0 +1,260 @@
import React, { useEffect, useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { CreditCard } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { usePaymentCard, useUpdatePaymentCard } from '../core/_hooks';
import { persianToEnglish } from '@/utils/numberUtils';
const cardSchema = yup.object({
bank_name: yup.string().required('نام بانک الزامی است'),
card_number: yup
.string()
.required('شماره کارت الزامی است')
.test('length', 'شماره کارت باید ۱۶ رقم باشد', (value) => {
const cleaned = value ? persianToEnglish(value).replace(/\s/g, '') : '';
return cleaned.length === 16;
})
.test('numeric', 'شماره کارت باید فقط عدد باشد', (value) => {
const cleaned = value ? persianToEnglish(value).replace(/\s/g, '') : '';
return /^\d+$/.test(cleaned);
}),
name: yup.string().required('نام صاحب کارت الزامی است'),
is_active: yup.boolean().default(true),
});
type CardFormData = yup.InferType<typeof cardSchema>;
const formatCardNumber = (value: string): string => {
const cleaned = persianToEnglish(value).replace(/\s/g, '');
const groups = cleaned.match(/.{1,4}/g);
return groups ? groups.join(' ') : cleaned;
};
const ToggleSwitch = ({
checked,
onChange,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) => {
return (
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`relative w-11 h-6 rounded-full transition-colors ${
checked
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
);
};
const CardFormPage = () => {
const { data, isLoading, error } = usePaymentCard();
const { mutate: updateCard, isPending } = useUpdatePaymentCard();
const [cardNumberDisplay, setCardNumberDisplay] = useState('');
const {
register,
handleSubmit,
formState: { errors },
setValue,
watch,
control,
} = useForm<CardFormData>({
resolver: yupResolver(cardSchema),
defaultValues: {
bank_name: '',
card_number: '',
name: '',
is_active: true,
},
});
const isActive = watch('is_active');
useEffect(() => {
if (data) {
setValue('bank_name', data.bank_name || '');
setValue('name', data.name || '');
setValue('is_active', data.is_active);
const formatted = formatCardNumber(data.card_number || '');
setCardNumberDisplay(formatted);
setValue('card_number', data.card_number || '');
}
}, [data, setValue]);
const handleCardNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const cleaned = persianToEnglish(value).replace(/\s/g, '');
if (cleaned.length <= 16 && /^\d*$/.test(cleaned)) {
const formatted = formatCardNumber(cleaned);
setCardNumberDisplay(formatted);
setValue('card_number', cleaned, { shouldValidate: true });
}
};
const onSubmit = (formData: CardFormData) => {
const cleanedCardNumber = persianToEnglish(formData.card_number).replace(/\s/g, '');
updateCard({
bank_name: formData.bank_name,
card_number: cleanedCardNumber,
name: formData.name,
is_active: formData.is_active,
});
};
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) {
return (
<PageContainer>
<div className="flex justify-center items-center h-64">
<LoadingSpinner />
</div>
</PageContainer>
);
}
if (error && !data) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">
خطا در بارگذاری اطلاعات کارت
</p>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<PageTitle className="flex items-center gap-2">
<CreditCard className="h-6 w-6" />
پرداخت کارت به کارت
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
مدیریت اطلاعات کارت و فعال/غیرفعال کردن روش پرداخت
</p>
</div>
</div>
{data?.updated_at && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
آخرین بهروزرسانی: {formatDate(data.updated_at)}
</p>
</div>
)}
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 space-y-6"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Input
label="نام بانک"
{...register('bank_name')}
error={errors.bank_name?.message}
placeholder="مثال: بانک ملی"
/>
</div>
<div>
<Controller
name="card_number"
control={control}
render={({ field }) => (
<Input
label="شماره کارت"
value={cardNumberDisplay}
onChange={handleCardNumberChange}
error={errors.card_number?.message}
placeholder="1234 5678 9012 3456"
maxLength={19}
/>
)}
/>
</div>
<div className="md:col-span-2">
<Input
label="نام صاحب کارت"
{...register('name')}
error={errors.name?.message}
placeholder="مثال: علی احمدی"
/>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
فعال بودن روش پرداخت
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
در صورت غیرفعال بودن، این روش پرداخت در صفحه پرداخت نمایش داده نمیشود
</p>
</div>
<ToggleSwitch
checked={isActive}
onChange={(checked) => setValue('is_active', checked)}
disabled={isPending}
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
type="submit"
variant="primary"
loading={isPending}
disabled={isPending}
>
ذخیره تغییرات
</Button>
</div>
</form>
</PageContainer>
);
};
export default CardFormPage;

View File

@ -0,0 +1,30 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import toast from "react-hot-toast";
import { getPaymentCard, updatePaymentCard } from "./_requests";
import { UpdatePaymentCardRequest } from "./_models";
export const usePaymentCard = () => {
return useQuery({
queryKey: [QUERY_KEYS.GET_PAYMENT_CARD],
queryFn: getPaymentCard,
});
};
export const useUpdatePaymentCard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdatePaymentCardRequest) => updatePaymentCard(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_PAYMENT_CARD],
});
toast.success("اطلاعات کارت با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در به‌روزرسانی اطلاعات کارت");
},
});
};

View File

@ -0,0 +1,19 @@
export interface PaymentCard {
bank_name: string;
card_number: string;
name: string;
is_active: boolean;
updated_at?: string;
}
export interface UpdatePaymentCardRequest {
bank_name: string;
card_number: string;
name: string;
is_active: boolean;
}
export interface UpdatePaymentCardResponse {
success?: boolean;
}

View File

@ -0,0 +1,21 @@
import { httpGetRequest, httpPutRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { PaymentCard, UpdatePaymentCardRequest, UpdatePaymentCardResponse } from "./_models";
export const getPaymentCard = async (): Promise<PaymentCard> => {
const response = await httpGetRequest<PaymentCard>(
APIUrlGenerator(API_ROUTES.GET_PAYMENT_CARD)
);
return response.data;
};
export const updatePaymentCard = async (
payload: UpdatePaymentCardRequest
): Promise<UpdatePaymentCardResponse> => {
const response = await httpPutRequest<UpdatePaymentCardResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_PAYMENT_CARD),
payload
);
return response.data;
};

View File

@ -0,0 +1,34 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import toast from "react-hot-toast";
import { getIPGStatus, updateIPGStatus } from "./_requests";
import { UpdateIPGStatusRequest } from "./_models";
export const useIPGStatus = () => {
return useQuery({
queryKey: [QUERY_KEYS.GET_IPG_STATUS],
queryFn: getIPGStatus,
});
};
export const useUpdateIPGStatus = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateIPGStatusRequest) => updateIPGStatus(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_IPG_STATUS],
});
toast.success("وضعیت درگاه پرداخت با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
const errorMessage =
error?.response?.data?.error === "validation failed"
? "مقدار وارد شده معتبر نیست"
: error?.message || "خطا در به‌روزرسانی وضعیت درگاه پرداخت";
toast.error(errorMessage);
},
});
};

View File

@ -0,0 +1,29 @@
export type IPGType = "cafe_bazaar" | "cep" | "fadax" | "sep" | "zarinpal";
export interface IPGStatus {
ipg_type: IPGType;
is_active: boolean;
updated_at: string;
}
export interface IPGStatusResponse {
statuses: IPGStatus[];
}
export interface UpdateIPGStatusRequest {
ipg_type: IPGType;
is_active: boolean;
}
export interface UpdateIPGStatusResponse {
success: boolean;
}
export const IPG_LABELS: Record<IPGType, string> = {
cafe_bazaar: "کافه‌بازار",
cep: "CEP",
fadax: "فدکس",
sep: "سامان",
zarinpal: "زرین‌پال",
};

View File

@ -0,0 +1,21 @@
import { httpGetRequest, httpPutRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { IPGStatusResponse, UpdateIPGStatusRequest, UpdateIPGStatusResponse } from "./_models";
export const getIPGStatus = async (): Promise<IPGStatusResponse> => {
const response = await httpGetRequest<IPGStatusResponse>(
APIUrlGenerator(API_ROUTES.GET_IPG_STATUS)
);
return response.data;
};
export const updateIPGStatus = async (
payload: UpdateIPGStatusRequest
): Promise<UpdateIPGStatusResponse> => {
const response = await httpPutRequest<UpdateIPGStatusResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_IPG_STATUS),
payload
);
return response.data;
};

View File

@ -0,0 +1,149 @@
import React from 'react';
import { CreditCard, Loader2 } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks';
import { IPGStatus, IPG_LABELS } from '../core/_models';
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const ToggleSwitch = ({
checked,
onChange,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) => {
return (
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`relative w-11 h-6 rounded-full transition-colors ${
checked
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
);
};
const IPGListPage = () => {
const { data, isLoading, error } = useIPGStatus();
const { mutate: updateStatus, isPending } = useUpdateIPGStatus();
const handleToggle = (ipg: IPGStatus, newStatus: boolean) => {
updateStatus({
ipg_type: ipg.ipg_type,
is_active: newStatus,
});
};
if (isLoading) {
return (
<PageContainer>
<div className="flex justify-center items-center h-64">
<LoadingSpinner />
</div>
</PageContainer>
);
}
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">
خطا در بارگذاری وضعیت درگاههای پرداخت
</p>
</div>
</PageContainer>
);
}
const statuses = data?.statuses || [];
return (
<PageContainer>
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<PageTitle className="flex items-center gap-2">
<CreditCard className="h-6 w-6" />
مدیریت درگاههای پرداخت
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
فعال یا غیرفعال کردن درگاههای پرداخت
</p>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="p-6">
<div className="space-y-4">
{statuses.map((ipg) => (
<div
key={ipg.ipg_type}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{IPG_LABELS[ipg.ipg_type]}
</h3>
<span
className={`px-2 py-1 rounded-md text-xs font-medium ${
ipg.is_active
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
}`}
>
{ipg.is_active ? 'فعال' : 'غیرفعال'}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
آخرین بهروزرسانی: {formatDate(ipg.updated_at)}
</p>
</div>
<div className="flex items-center gap-4">
{isPending ? (
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
) : (
<ToggleSwitch
checked={ipg.is_active}
onChange={(checked) => handleToggle(ipg, checked)}
/>
)}
</div>
</div>
))}
</div>
</div>
</div>
</PageContainer>
);
};
export default IPGListPage;

View File

@ -60,59 +60,6 @@ const toPublicUrl = (img: any): ProductImage => {
}; };
}; };
const IMAGE_MAX_SIZE = 2 * 1024 * 1024;
const VIDEO_MAX_SIZE = 25 * 1024 * 1024;
const isImageFile = (file: File) => file.type?.startsWith('image/');
const isVideoFile = (file: File) => file.type?.startsWith('video/');
const ensureSquareImage = (file: File) =>
new Promise<void>((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
if (img.width === img.height) {
resolve();
} else {
reject(new Error('ابعاد تصویر Explorer باید ۱ در ۱ باشد'));
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('امکان بررسی ابعاد تصویر وجود ندارد'));
};
img.src = url;
});
const validateMediaFile = async (file: File, options?: { requireSquare?: boolean }) => {
if (isImageFile(file)) {
if (file.size > IMAGE_MAX_SIZE) {
throw new Error('حجم تصویر نباید بیشتر از ۲ مگابایت باشد');
}
if (options?.requireSquare) {
await ensureSquareImage(file);
}
} else if (isVideoFile(file)) {
if (file.size > VIDEO_MAX_SIZE) {
throw new Error('حجم ویدیو نباید بیشتر از ۲۵ مگابایت باشد');
}
} else {
throw new Error('فقط تصاویر یا ویدیو مجاز است');
}
};
const validateExplorerFile = async (file: File) => {
if (isImageFile(file)) {
await ensureSquareImage(file);
return;
}
if (isVideoFile(file)) {
return;
}
throw new Error('فقط تصاویر یا ویدیو مجاز است');
};
const mapExplorerFiles = (entries: any[]): ProductImage[] => { const mapExplorerFiles = (entries: any[]): ProductImage[] => {
if (!entries || !Array.isArray(entries)) { if (!entries || !Array.isArray(entries)) {
return []; return [];
@ -162,6 +109,8 @@ const ProductFormPage = () => {
const [explorerFiles, setExplorerFiles] = useState<ProductImage[]>([]); const [explorerFiles, setExplorerFiles] = useState<ProductImage[]>([]);
const [isExplorerUploading, setIsExplorerUploading] = useState(false); const [isExplorerUploading, setIsExplorerUploading] = useState(false);
const [isDeleteExplorerFiles, setIsDeleteExplorerFiles] = useState(false); const [isDeleteExplorerFiles, setIsDeleteExplorerFiles] = useState(false);
const [initialExplorerIds, setInitialExplorerIds] = useState<string[]>([]);
const [initialCoverId, setInitialCoverId] = useState<number | null>(null);
const { data: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit); const { data: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit);
const { data: categories, isLoading: isLoadingCategories } = useCategories(); const { data: categories, isLoading: isLoadingCategories } = useCategories();
@ -197,11 +146,13 @@ const ProductFormPage = () => {
file_ids: [], file_ids: [],
variants: [], variants: [],
explorer_file_ids: [], explorer_file_ids: [],
is_delete_latest_explorer_files: false is_delete_latest_explorer_files: false,
product_cover_image_id: undefined
} }
}); });
const formValues = watch(); const formValues = watch();
const coverId = watch('product_cover_image_id');
useEffect(() => { useEffect(() => {
if (isEdit && product) { if (isEdit && product) {
@ -262,7 +213,8 @@ const ProductFormPage = () => {
category_ids: categoryIds, category_ids: categoryIds,
product_option_id: product.product_option_id || undefined, product_option_id: product.product_option_id || undefined,
file_ids: (product.file_ids && product.file_ids.length > 0 ? product.file_ids : (product as any).files || []), file_ids: (product.file_ids && product.file_ids.length > 0 ? product.file_ids : (product as any).files || []),
variants: formVariants variants: formVariants,
product_cover_image_id: (product as any).product_cover_image_id ? (product as any).product_cover_image_id.toString() : undefined
}); });
const initialImages = (product.file_ids && product.file_ids.length > 0 ? product.file_ids : (product as any).files || []); const initialImages = (product.file_ids && product.file_ids.length > 0 ? product.file_ids : (product as any).files || []);
const normalizedImages: ProductImage[] = (initialImages || []).map(toPublicUrl); const normalizedImages: ProductImage[] = (initialImages || []).map(toPublicUrl);
@ -276,12 +228,14 @@ const ProductFormPage = () => {
setExplorerFiles(normalizedExplorer); setExplorerFiles(normalizedExplorer);
setValue('explorer_file_ids', normalizedExplorer, { shouldValidate: true, shouldDirty: false }); setValue('explorer_file_ids', normalizedExplorer, { shouldValidate: true, shouldDirty: false });
setIsDeleteExplorerFiles(false); setIsDeleteExplorerFiles(false);
setInitialExplorerIds(normalizedExplorer.map(item => item.id?.toString?.() || ''));
const coverId = (product as any).product_cover_image_id;
setInitialCoverId(coverId ? Number(coverId) : null);
} }
}, [isEdit, product, reset]); }, [isEdit, product, reset]);
const handleFileUpload = async (file: File) => { const handleFileUpload = async (file: File) => {
try { try {
await validateMediaFile(file);
const result = await uploadFile(file); const result = await uploadFile(file);
setUploadedImages(prev => { setUploadedImages(prev => {
@ -308,12 +262,14 @@ const ProductFormPage = () => {
const updatedImages = uploadedImages.filter(img => img.id !== fileId); const updatedImages = uploadedImages.filter(img => img.id !== fileId);
setUploadedImages(updatedImages); setUploadedImages(updatedImages);
setValue('file_ids', updatedImages, { shouldValidate: true, shouldDirty: true }); setValue('file_ids', updatedImages, { shouldValidate: true, shouldDirty: true });
if (coverId === fileId) {
setValue('product_cover_image_id', undefined, { shouldValidate: true, shouldDirty: true });
}
deleteFile(fileId); deleteFile(fileId);
}; };
const handleExplorerUpload = async (file: File) => { const handleExplorerUpload = async (file: File) => {
try { try {
await validateExplorerFile(file);
const result = await uploadFile(file); const result = await uploadFile(file);
setExplorerFiles(prev => { setExplorerFiles(prev => {
const newImage: ProductImage = { const newImage: ProductImage = {
@ -354,12 +310,16 @@ const ProductFormPage = () => {
.filter(id => id !== null); .filter(id => id !== null);
const validExplorerIds = explorerFiles const validExplorerIds = explorerFiles
.filter(img => !initialExplorerIds.includes(img.id?.toString?.() || ''))
.map(img => { .map(img => {
const numericId = Number(img.id); const numericId = Number(img.id);
return isNaN(numericId) ? null : numericId; return isNaN(numericId) ? null : numericId;
}) })
.filter(id => id !== null); .filter(id => id !== null);
const selectedCoverId = convertedData.product_cover_image_id ? Number(convertedData.product_cover_image_id) : null;
const hasCover = selectedCoverId !== null && !isNaN(selectedCoverId);
const baseSubmitData = { const baseSubmitData = {
name: convertedData.name, name: convertedData.name,
description: convertedData.description || '', description: convertedData.description || '',
@ -399,12 +359,16 @@ const ProductFormPage = () => {
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {} meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
})) || []; })) || [];
updateProduct({ const updatePayload = {
id: parseInt(id), id: parseInt(id),
...submitBaseData, ...submitBaseData,
variants: updateVariants, variants: updateVariants,
is_delete_latest_explorer_files: isDeleteExplorerFiles is_delete_latest_explorer_files: isDeleteExplorerFiles
}, { };
if (hasCover && selectedCoverId !== initialCoverId) {
(updatePayload as any).product_cover_image_id = selectedCoverId;
}
updateProduct(updatePayload, {
onSuccess: () => { onSuccess: () => {
navigate('/products'); navigate('/products');
} }
@ -424,10 +388,15 @@ const ProductFormPage = () => {
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {} meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
})) || []; })) || [];
createProduct({ const createPayload: any = {
...submitBaseData, ...submitBaseData,
variants: createVariants variants: createVariants
}, { };
if (hasCover) {
createPayload.product_cover_image_id = selectedCoverId;
}
createProduct(createPayload, {
onSuccess: () => { onSuccess: () => {
navigate('/products'); navigate('/products');
} }
@ -439,6 +408,12 @@ const ProductFormPage = () => {
navigate('/products'); navigate('/products');
}; };
useEffect(() => {
if (!coverId && uploadedImages.length > 0 && !initialCoverId) {
setValue('product_cover_image_id', uploadedImages[0].id, { shouldValidate: true, shouldDirty: false });
}
}, [coverId, uploadedImages, setValue, initialCoverId]);
if (isEdit && isLoadingProduct) { if (isEdit && isLoadingProduct) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
@ -605,12 +580,9 @@ const ProductFormPage = () => {
<FileUploader <FileUploader
onUpload={handleFileUpload} onUpload={handleFileUpload}
onRemove={handleFileRemove} onRemove={handleFileRemove}
acceptedTypes={['image/*', 'video/*']}
maxFileSize={25 * 1024 * 1024}
maxFiles={10}
mode="multi" mode="multi"
label="" label=""
description="تصاویر یا ویدیوهای محصول را آپلود کنید (حداکثر ۲ مگ برای تصویر و ۲۵ مگ برای ویدیو)" description="فایل‌های محصول را آپلود کنید"
onUploadStart={() => setIsUploading(true)} onUploadStart={() => setIsUploading(true)}
onUploadComplete={() => setIsUploading(false)} onUploadComplete={() => setIsUploading(false)}
/> />
@ -644,11 +616,13 @@ const ProductFormPage = () => {
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
{index === 0 && ( <button
<div className="absolute bottom-1 left-1 bg-primary-500 text-white text-xs px-1 py-0.5 rounded"> type="button"
اصلی onClick={() => setValue('product_cover_image_id', image.id, { shouldValidate: true, shouldDirty: true })}
</div> className={`absolute -bottom-2 left-1 text-xs px-2 py-0.5 rounded ${coverId === image.id ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}
)} >
کاور
</button>
</div> </div>
))} ))}
</div> </div>
@ -660,18 +634,12 @@ const ProductFormPage = () => {
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4"> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
فایلهای Explorer فایلهای Explorer
</h3> </h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
تنها تصاویر مربعی پذیرفته میشوند و میتوانید ویدیو نیز اضافه کنید.
</p>
<FileUploader <FileUploader
onUpload={handleExplorerUpload} onUpload={handleExplorerUpload}
onRemove={handleExplorerRemove} onRemove={handleExplorerRemove}
acceptedTypes={['image/*', 'video/*']}
maxFileSize={0}
maxFiles={5}
mode="multi" mode="multi"
label="" label=""
description="فایل‌های Explorer را آپلود کنید (تصاویر باید مربعی باشند)" description="فایل‌های Explorer را آپلود کنید"
onUploadStart={() => setIsExplorerUploading(true)} onUploadStart={() => setIsExplorerUploading(true)}
onUploadComplete={() => setIsExplorerUploading(false)} onUploadComplete={() => setIsExplorerUploading(false)}
/> />

View File

@ -1,13 +1,20 @@
export interface ShippingOpenHour {
from_hour: number;
to_hour: number;
}
export interface ShippingMethod { export interface ShippingMethod {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
code: string; code: string;
enabled: boolean; enabled: boolean;
cost: number;
max_weight: number; max_weight: number;
min_weight: number; min_weight: number;
priority: number; priority: number;
time_note?: string;
open_hours: ShippingOpenHour[];
addresses: string[];
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }

View File

@ -17,7 +17,10 @@ export const getShippingMethods = async () => {
const response = await httpGetRequest<PaginatedShippingMethodsResponse>( const response = await httpGetRequest<PaginatedShippingMethodsResponse>(
APIUrlGenerator(API_ROUTES.GET_SHIPPING_METHODS) APIUrlGenerator(API_ROUTES.GET_SHIPPING_METHODS)
); );
return response.data.shipping_methods || []; if (Array.isArray(response.data)) {
return response.data;
}
return response.data?.shipping_methods || [];
}; };
export const getShippingMethod = async (id: string) => { export const getShippingMethod = async (id: string) => {

View File

@ -1,9 +1,11 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useCreateShippingMethod, useShippingMethod, useUpdateShippingMethod } from '../core/_hooks'; import { useCreateShippingMethod, useShippingMethod, useUpdateShippingMethod } from '../core/_hooks';
import { ShippingOpenHour } 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 { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { TagInput } from '@/components/ui/TagInput';
import { Truck } from 'lucide-react'; import { Truck } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils'; import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
@ -21,23 +23,43 @@ const ShippingMethodFormPage = () => {
description: '', description: '',
code: '', code: '',
enabled: true, enabled: true,
cost: '',
max_weight: '', max_weight: '',
min_weight: '', min_weight: '',
priority: '', priority: '',
time_note: '',
open_hours: [
{
from_hour: '',
to_hour: '',
},
],
addresses: [] as string[],
}); });
useEffect(() => { useEffect(() => {
if (isEdit && data) { if (isEdit && data) {
const normalizedOpenHours = (data.open_hours || []).map((item: ShippingOpenHour) => ({
from_hour: item?.from_hour?.toString() || '',
to_hour: item?.to_hour?.toString() || '',
}));
setForm({ setForm({
name: data.name || '', name: data.name || '',
description: data.description || '', description: data.description || '',
code: data.code || '', code: data.code || '',
enabled: data.enabled, enabled: data.enabled,
cost: formatWithThousands(data.cost ?? ''),
max_weight: formatWithThousands(data.max_weight ?? ''), max_weight: formatWithThousands(data.max_weight ?? ''),
min_weight: formatWithThousands(data.min_weight ?? ''), min_weight: formatWithThousands(data.min_weight ?? ''),
priority: formatWithThousands(data.priority ?? ''), priority: formatWithThousands(data.priority ?? ''),
time_note: data.time_note || '',
open_hours: normalizedOpenHours.length
? normalizedOpenHours
: [
{
from_hour: '',
to_hour: '',
},
],
addresses: data.addresses || [],
}); });
} }
}, [isEdit, data]); }, [isEdit, data]);
@ -57,10 +79,21 @@ const ShippingMethodFormPage = () => {
description: form.description, description: form.description,
code: form.code, code: form.code,
enabled: form.enabled, enabled: form.enabled,
cost: parseFormattedNumber(form.cost) ?? 0,
max_weight: parseFormattedNumber(form.max_weight) ?? 0, max_weight: parseFormattedNumber(form.max_weight) ?? 0,
min_weight: parseFormattedNumber(form.min_weight) ?? 0, min_weight: parseFormattedNumber(form.min_weight) ?? 0,
priority: parseFormattedNumber(form.priority) ?? 0, priority: parseFormattedNumber(form.priority) ?? 0,
time_note: form.time_note,
open_hours: form.open_hours
.map((item) => ({
from_hour: Number(item.from_hour),
to_hour: Number(item.to_hour),
}))
.filter(
(item) =>
!Number.isNaN(item.from_hour) &&
!Number.isNaN(item.to_hour)
),
addresses: form.addresses,
}; };
if (isEdit && id) { if (isEdit && id) {
update({ id: Number(id), ...payload }, { onSuccess: () => navigate('/shipping-methods') }); update({ id: Number(id), ...payload }, { onSuccess: () => navigate('/shipping-methods') });
@ -96,10 +129,6 @@ const ShippingMethodFormPage = () => {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد</label>
<Input name="code" value={form.code} onChange={handleChange} placeholder="مثلاً standard" /> <Input name="code" value={form.code} onChange={handleChange} placeholder="مثلاً standard" />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">هزینه</label>
<Input name="cost" value={form.cost} onChange={handleChange} thousandSeparator numeric />
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">اولویت</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">اولویت</label>
<Input name="priority" value={form.priority} onChange={handleChange} thousandSeparator numeric /> <Input name="priority" value={form.priority} onChange={handleChange} thousandSeparator numeric />
@ -112,6 +141,98 @@ const ShippingMethodFormPage = () => {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">بیشترین وزن</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">بیشترین وزن</label>
<Input name="max_weight" value={form.max_weight} onChange={handleChange} thousandSeparator numeric /> <Input name="max_weight" value={form.max_weight} onChange={handleChange} thousandSeparator numeric />
</div> </div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">یادداشت زمان تحویل</label>
<Input name="time_note" value={form.time_note} onChange={handleChange} placeholder="مثلاً تحویل طی ۲۴ ساعت" />
</div>
<div className="md:col-span-2 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">ساعات پاسخگویی</label>
<p className="text-xs text-gray-500 dark:text-gray-400">بازههای زمانی را به ساعت ۲۴ ساعته وارد کنید</p>
</div>
<Button size="sm" type="button" variant="secondary" onClick={() => setForm(prev => ({
...prev,
open_hours: [
...prev.open_hours,
{ from_hour: '', to_hour: '' },
],
}))}>
افزودن بازه
</Button>
</div>
<div className="space-y-3">
{form.open_hours.map((item, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-700/50 p-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">از ساعت</label>
<Input
name={`open_hours_from_${index}`}
value={item.from_hour}
onChange={(e) => {
const value = (e.target as HTMLInputElement).value;
setForm(prev => {
const open_hours = [...prev.open_hours];
open_hours[index] = { ...open_hours[index], from_hour: value };
return { ...prev, open_hours };
});
}}
numeric
placeholder="مثلاً 9"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">تا ساعت</label>
<Input
name={`open_hours_to_${index}`}
value={item.to_hour}
onChange={(e) => {
const value = (e.target as HTMLInputElement).value;
setForm(prev => {
const open_hours = [...prev.open_hours];
open_hours[index] = { ...open_hours[index], to_hour: value };
return { ...prev, open_hours };
});
}}
numeric
placeholder="مثلاً 18"
/>
</div>
<div className="flex md:justify-end">
<Button
type="button"
variant="danger"
size="sm"
onClick={() => setForm(prev => {
const open_hours = prev.open_hours.filter((_, i) => i !== index);
return {
...prev,
open_hours: open_hours.length ? open_hours : [
{
from_hour: '',
to_hour: '',
},
],
};
})}
>
حذف
</Button>
</div>
</div>
</div>
))}
</div>
</div>
<div className="md:col-span-2">
<TagInput
values={form.addresses}
onChange={(values) => setForm(prev => ({ ...prev, addresses: values }))}
label="محدوده‌های پوشش"
placeholder="آدرس را تایپ و Enter کنید"
/>
</div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">توضیحات</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">توضیحات</label>
<textarea name="description" value={form.description} onChange={handleChange} className="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" rows={3} /> <textarea name="description" value={form.description} onChange={handleChange} className="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" rows={3} />

View File

@ -2,7 +2,8 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Settings, Plus, Edit3, Trash2, Truck } from 'lucide-react'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Plus, Edit3, Trash2, Truck } from 'lucide-react';
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks'; import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
import { ShippingMethod } from '../core/_models'; import { ShippingMethod } from '../core/_models';
@ -12,6 +13,11 @@ const ShippingMethodsListPage = () => {
const { mutate: deleteMethod, isPending: isDeleting } = useDeleteShippingMethod(); const { mutate: deleteMethod, isPending: isDeleting } = useDeleteShippingMethod();
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const formatOpenHours = (openHours: ShippingMethod['open_hours']) => {
if (!openHours || !openHours.length) return '-';
return openHours.map((item) => `${item.from_hour}-${item.to_hour}`).join('، ');
};
const handleCreate = () => navigate('/shipping-methods/create'); const handleCreate = () => navigate('/shipping-methods/create');
const handleEdit = (id: number) => navigate(`/shipping-methods/${id}/edit`); const handleEdit = (id: number) => navigate(`/shipping-methods/${id}/edit`);
@ -20,6 +26,14 @@ const ShippingMethodsListPage = () => {
deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) }); deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) });
}; };
if (isLoading) {
return (
<div className="p-6 flex justify-center">
<LoadingSpinner />
</div>
);
}
if (error) { if (error) {
return ( return (
<div className="p-6"> <div className="p-6">
@ -57,8 +71,8 @@ const ShippingMethodsListPage = () => {
<tr> <tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">هزینه</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">محدوده وزن</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">محدوده وزن</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ساعات پاسخگویی</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">اولویت</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">اولویت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
@ -69,8 +83,8 @@ const ShippingMethodsListPage = () => {
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-700"> <tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.name}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.code}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.cost}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.min_weight} - {m.max_weight}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.min_weight} - {m.max_weight}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{formatOpenHours(m.open_hours)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.priority}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.priority}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm"> <td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-md text-xs ${m.enabled ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}>{m.enabled ? 'فعال' : 'غیرفعال'}</span> <span className={`px-2 py-1 rounded-md text-xs ${m.enabled ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}>{m.enabled ? 'فعال' : 'غیرفعال'}</span>
@ -99,10 +113,11 @@ const ShippingMethodsListPage = () => {
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<div className="flex-1"> <div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{m.name}</h3> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{m.name}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">کد: {m.code} هزینه: {m.cost}</p> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">کد: {m.code} وزن: {m.min_weight}-{m.max_weight}</p>
</div> </div>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">وزن: {m.min_weight}-{m.max_weight} اولویت: {m.priority}</div> <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ساعات پاسخگویی: {formatOpenHours(m.open_hours)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">اولویت: {m.priority}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"> <button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />

View File

@ -0,0 +1,30 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import toast from "react-hot-toast";
import { getWalletStatus, updateWalletStatus } from "./_requests";
import { UpdateWalletStatusRequest } from "./_models";
export const useWalletStatus = () => {
return useQuery({
queryKey: [QUERY_KEYS.GET_WALLET_STATUS],
queryFn: getWalletStatus,
});
};
export const useUpdateWalletStatus = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateWalletStatusRequest) => updateWalletStatus(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_WALLET_STATUS],
});
toast.success("وضعیت کیف پول با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در به‌روزرسانی وضعیت کیف پول");
},
});
};

View File

@ -0,0 +1,26 @@
export type WalletType = "gold_18k" | "rial";
export interface WalletStatus {
wallet_type: WalletType;
is_active: boolean;
updated_at: string;
}
export interface WalletStatusResponse {
statuses: WalletStatus[];
}
export interface UpdateWalletStatusRequest {
wallet_type: WalletType;
status: boolean;
}
export interface UpdateWalletStatusResponse {
success: boolean;
}
export const WALLET_LABELS: Record<WalletType, string> = {
gold_18k: "کیف طلا (۱۸ عیار)",
rial: "کیف پول (ریال)",
};

View File

@ -0,0 +1,21 @@
import { httpGetRequest, httpPutRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { WalletStatusResponse, UpdateWalletStatusRequest, UpdateWalletStatusResponse } from "./_models";
export const getWalletStatus = async (): Promise<WalletStatusResponse> => {
const response = await httpGetRequest<WalletStatusResponse>(
APIUrlGenerator(API_ROUTES.GET_WALLET_STATUS)
);
return response.data;
};
export const updateWalletStatus = async (
payload: UpdateWalletStatusRequest
): Promise<UpdateWalletStatusResponse> => {
const response = await httpPutRequest<UpdateWalletStatusResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_WALLET_STATUS),
payload
);
return response.data;
};

View File

@ -0,0 +1,149 @@
import React from 'react';
import { Wallet, Loader2 } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useWalletStatus, useUpdateWalletStatus } from '../core/_hooks';
import { WalletStatus, WALLET_LABELS } from '../core/_models';
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const ToggleSwitch = ({
checked,
onChange,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) => {
return (
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`relative w-11 h-6 rounded-full transition-colors ${
checked
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
);
};
const WalletListPage = () => {
const { data, isLoading, error } = useWalletStatus();
const { mutate: updateStatus, isPending } = useUpdateWalletStatus();
const handleToggle = (wallet: WalletStatus, newStatus: boolean) => {
updateStatus({
wallet_type: wallet.wallet_type,
status: newStatus,
});
};
if (isLoading) {
return (
<PageContainer>
<div className="flex justify-center items-center h-64">
<LoadingSpinner />
</div>
</PageContainer>
);
}
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">
خطا در بارگذاری وضعیت کیفهای پول
</p>
</div>
</PageContainer>
);
}
const statuses = data?.statuses || [];
return (
<PageContainer>
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<PageTitle className="flex items-center gap-2">
<Wallet className="h-6 w-6" />
مدیریت کیف پول
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
فعال یا غیرفعال کردن کیفهای پول
</p>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="p-6">
<div className="space-y-4">
{statuses.map((wallet) => (
<div
key={wallet.wallet_type}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{WALLET_LABELS[wallet.wallet_type]}
</h3>
<span
className={`px-2 py-1 rounded-md text-xs font-medium ${
wallet.is_active
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
}`}
>
{wallet.is_active ? 'فعال' : 'غیرفعال'}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
آخرین بهروزرسانی: {formatDate(wallet.updated_at)}
</p>
</div>
<div className="flex items-center gap-4">
{isPending ? (
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
) : (
<ToggleSwitch
checked={wallet.is_active}
onChange={(checked) => handleToggle(wallet, checked)}
/>
)}
</div>
</div>
))}
</div>
</div>
</div>
</PageContainer>
);
};
export default WalletListPage;

View File

@ -110,4 +110,16 @@ export const QUERY_KEYS = {
GET_TICKET_DEPARTMENTS: "get_ticket_departments", GET_TICKET_DEPARTMENTS: "get_ticket_departments",
GET_TICKET_STATUSES: "get_ticket_statuses", GET_TICKET_STATUSES: "get_ticket_statuses",
GET_TICKET_SUBJECTS: "get_ticket_subjects", GET_TICKET_SUBJECTS: "get_ticket_subjects",
// Payment IPG
GET_IPG_STATUS: "get_ipg_status",
UPDATE_IPG_STATUS: "update_ipg_status",
// Payment Card
GET_PAYMENT_CARD: "get_payment_card",
UPDATE_PAYMENT_CARD: "update_payment_card",
// Wallet
GET_WALLET_STATUS: "get_wallet_status",
UPDATE_WALLET_STATUS: "update_wallet_status",
}; };