admin/src/pages/orders/orders-list/OrdersListPage.tsx

528 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useMemo, useState } from 'react';
import { englishToPersian, persianToEnglish, formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
import { useNavigate } from 'react-router-dom';
import { useOrders, useOrderStats, useUpdateOrderStatus } from '../core/_hooks';
import { OrderFilters, OrderStatus } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { Pagination } from "@/components/ui/Pagination";
import { PageContainer, PageTitle } from "@/components/ui/Typography";
import { Table } from "@/components/ui/Table";
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 {
ShoppingCart,
DollarSign,
Clock,
Search,
Filter,
Eye,
Edit3,
TrendingUp
} from 'lucide-react';
const getStatusColor = (status: OrderStatus) => {
const colors = {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
shipped: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
delivered: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
cancelled: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
refunded: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
};
return colors[status] || colors.pending;
};
const getStatusText = (status: OrderStatus) => {
const text = {
pending: 'در انتظار',
processing: 'در حال پردازش',
shipped: 'ارسال شده',
delivered: 'تحویل شده',
cancelled: 'لغو شده',
refunded: 'مرجوع شده',
};
return text[status] || status;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('fa-IR').format(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR');
};
const ListSkeleton = () => (
<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 navigate = useNavigate();
const [statusUpdateId, setStatusUpdateId] = useState<string | null>(null);
const [newStatus, setNewStatus] = useState<OrderStatus>('processing');
const [filters, setFilters] = useState<OrderFilters>(getDefaultFilters());
const { data: ordersData, isLoading, error } = useOrders(filters);
const { data: stats, isLoading: statsLoading, error: statsError } = useOrderStats(true);
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(() => [
{ key: 'order_number', label: 'شماره سفارش', sortable: true, align: 'right', render: (v: string) => `#${v}` },
{
key: 'customer',
label: 'مشتری',
align: 'right',
render: (_val, row: any) => (
<div className="text-right">
<div className="font-medium">
{(row.user?.first_name || row.customer?.first_name || 'نامشخص')} {(row.user?.last_name || row.customer?.last_name || '')}
</div>
<div className="text-gray-500 dark:text-gray-400" dir="ltr" style={{ direction: 'ltr' }}>
{row.user?.phone_number ? englishToPersian(row.user.phone_number) : '-'}
</div>
</div>
)
},
{ key: 'final_total', label: 'مبلغ نهایی', sortable: true, align: 'right', render: (v: number, row: any) => formatCurrency(row.final_total || row.total_amount || 0) },
{ key: 'status', label: 'وضعیت', align: 'right', render: (v: OrderStatus) => (<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(v)}`}>{getStatusText(v)}</span>) },
{ key: 'created_at', label: 'تاریخ', sortable: true, align: 'right', render: (v: string) => formatDate(v) },
{
key: 'actions',
label: 'عملیات',
align: 'right',
render: (_val, row: any) => (
<div className="flex items-center gap-2 justify-end">
<button
onClick={() => handleViewOrder(row.id)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="مشاهده جزئیات"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => handleUpdateStatus(row.id, row.status)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
title="تغییر وضعیت"
>
<Edit3 className="h-4 w-4" />
</button>
</div>
)
},
], []);
const handleStatusUpdate = () => {
if (statusUpdateId) {
updateStatus(
{ id: statusUpdateId, payload: { status: newStatus } },
{ onSuccess: () => setStatusUpdateId(null) }
);
}
};
const handleViewOrder = (id: number) => {
navigate(`/orders/${id}`);
};
const handleUpdateStatus = (id: number, currentStatus: OrderStatus) => {
setStatusUpdateId(id.toString());
setNewStatus(currentStatus);
};
const handlePageChange = (page: number) => {
setFilters(prev => ({ ...prev, page }));
};
if (error) {
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">
<ShoppingCart className="h-6 w-6" />
مدیریت سفارشات
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{ordersData?.total || 0} سفارش یافت شد
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 lg:gap-6">
{statsLoading ? (
<>
{[...Array(4)].map((_, idx) => (
<div key={idx} className="card p-6 animate-pulse bg-gray-100 dark:bg-gray-800 h-24" />
))}
</>
) : (
statsItems.map((stat, index) => (
<StatsCard key={index} {...stat} />
))
)}
</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="space-y-4">
<div className="relative">
<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
type="text"
placeholder="جستجو عمومی (شماره سفارش، کد تراکنش، نام، تلفن، کالا، کد تخفیف)"
value={filters.search || ''}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value, page: 1 }))}
className="w-full pr-10 px-3 py-2.5 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"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">وضعیت سفارش</label>
<select
value={filters.status || ''}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value as OrderStatus || undefined, page: 1 }))}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه وضعیتها</option>
<option value="pending">در انتظار</option>
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل شده</option>
<option value="cancelled">لغو شده</option>
<option value="refunded">مرجوع شده</option>
</select>
</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="مثلاً ۱۰۲۴"
className="w-full px-3 py-2.5 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-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">وضعیت پرداخت</label>
<select
value={filters.payment_status || ''}
onChange={(e) => setFilters(prev => ({ ...prev, payment_status: e.target.value as any || undefined, page: 1 }))}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه وضعیتهای پرداخت</option>
<option value="pending">در انتظار پرداخت</option>
<option value="paid">پرداخت شده</option>
<option value="failed">پرداخت ناموفق</option>
<option value="refunded">بازپرداخت شده</option>
<option value="cancelled">لغو شده</option>
</select>
</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.5 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-400"
/>
</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="مثلاً ۱۲۳۴۵"
className="w-full px-3 py-2.5 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-400"
/>
</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="مثلاً ۳,۰۰۰,۰۰۰"
className="w-full px-3 py-2.5 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-400"
/>
</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="مثلاً ۹,۰۰۰,۰۰۰"
className="w-full px-3 py-2.5 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-400"
/>
</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-2.5 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-2.5 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="md:col-span-2 lg:col-span-4 flex justify-end">
<Button
variant="secondary"
onClick={() => setFilters(getDefaultFilters())}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
</div>
</div>
</div>
{/* جدول سفارشات */}
{isLoading ? (
<ListSkeleton />
) : !ordersData?.orders || ordersData.orders.length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="text-center py-12">
<ShoppingCart className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">هیچ سفارشی یافت نشد</h3>
<p className="text-gray-600 dark:text-gray-400">با تغییر فیلترها جستجو کنید</p>
</div>
</div>
) : (
<>
<Table columns={columns} data={ordersData.orders as any[]} />
<Pagination
currentPage={filters.page || 1}
totalPages={Math.ceil((ordersData.total || 0) / (filters.limit || 20))}
onPageChange={handlePageChange}
itemsPerPage={filters.limit || 20}
totalItems={ordersData.total || 0}
/>
</>
)}
{/* مودال تغییر وضعیت */}
<Modal isOpen={!!statusUpdateId} onClose={() => setStatusUpdateId(null)} title="تغییر وضعیت سفارش">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت جدید
</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value as OrderStatus)}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="pending">در انتظار</option>
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل شده</option>
<option value="cancelled">لغو شده</option>
<option value="refunded">مرجوع شده</option>
</select>
</div>
<div className="flex justify-end space-x-2 space-x-reverse">
<Button variant="secondary" onClick={() => setStatusUpdateId(null)} disabled={isUpdating}>
انصراف
</Button>
<Button variant="primary" onClick={handleStatusUpdate} loading={isUpdating}>
بهروزرسانی
</Button>
</div>
</div>
</Modal>
</PageContainer>
);
};
export default OrdersListPage;