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

491 lines
24 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 } 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,
Edit3,
TrendingUp
} from 'lucide-react';
import { PageHeader } from '@/components/layout/PageHeader';
import { FiltersSection } from '@/components/common/FiltersSection';
import { EmptyState } from '@/components/common/EmptyState';
import { ActionButtons } from '@/components/common/ActionButtons';
import { StatusBadge } from '@/components/ui/StatusBadge';
import { PAYMENT_STATUS_OPTIONS } from '@/constant/enums';
import { formatCurrency, formatDate } from '@/utils/formatters';
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 flex items-center gap-2">
{(row.user?.first_name || row.customer?.first_name || 'نامشخص')} {(row.user?.last_name || row.customer?.last_name || '')}
{row.user?.is_deleted && (
<span className="px-2 py-0.5 text-xs bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded">
حذف شده
</span>
)}
</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) => <StatusBadge status={v} type="order" /> },
{ 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">
<ActionButtons
onView={() => handleViewOrder(row.id)}
/>
<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>
<PageHeader
title="مدیریت سفارشات"
subtitle={`${ordersData?.total || 0} سفارش یافت شد`}
icon={ShoppingCart}
/>
<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"
>
{PAYMENT_STATUS_OPTIONS.map(o => (
<option key={o.value || 'all'} value={o.value}>{o.value === '' ? 'همه وضعیت‌های پرداخت' : o.label}</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 ? (
<Table columns={columns} data={[]} loading={true} />
) : !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">
<EmptyState
icon={ShoppingCart}
title="هیچ سفارشی یافت نشد"
description="با تغییر فیلترها جستجو کنید"
/>
</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;