feat(orders): implement orders pages, update routes, and add order-related API constants
This commit is contained in:
parent
d8b6f2a54f
commit
d216a886d0
10
src/App.tsx
10
src/App.tsx
|
|
@ -15,7 +15,6 @@ import { Layout } from './components/layout/Layout';
|
|||
const Login = lazy(() => import('./pages/Login').then(module => ({ default: module.Login })));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })));
|
||||
const Users = lazy(() => import('./pages/Users').then(module => ({ default: module.Users })));
|
||||
const Orders = lazy(() => import('./pages/Orders').then(module => ({ default: module.Orders })));
|
||||
const Reports = lazy(() => import('./pages/Reports').then(module => ({ default: module.Reports })));
|
||||
const Notifications = lazy(() => import('./pages/Notifications').then(module => ({ default: module.Notifications })));
|
||||
|
||||
|
|
@ -47,6 +46,10 @@ const CategoryFormPage = lazy(() => import('./pages/categories/category-form/Cat
|
|||
const DiscountCodesListPage = lazy(() => import('./pages/discount-codes/discount-codes-list/DiscountCodesListPage'));
|
||||
const DiscountCodeFormPage = lazy(() => import('./pages/discount-codes/discount-code-form/DiscountCodeFormPage'));
|
||||
|
||||
// Orders Pages
|
||||
const OrdersListPage = lazy(() => import('./pages/orders/orders-list/OrdersListPage'));
|
||||
const OrderDetailPage = lazy(() => import('./pages/orders/order-detail/OrderDetailPage'));
|
||||
|
||||
// Products Pages
|
||||
const ProductsListPage = lazy(() => import('./pages/products/products-list/ProductsListPage'));
|
||||
const ProductFormPage = lazy(() => import('./pages/products/product-form/ProductFormPage'));
|
||||
|
|
@ -81,7 +84,6 @@ const AppRoutes = () => {
|
|||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="products" element={<ProductsListPage />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="reports" element={<Reports />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
|
||||
|
|
@ -118,6 +120,10 @@ const AppRoutes = () => {
|
|||
<Route path="discount-codes/create" element={<DiscountCodeFormPage />} />
|
||||
<Route path="discount-codes/:id/edit" element={<DiscountCodeFormPage />} />
|
||||
|
||||
{/* Orders Routes */}
|
||||
<Route path="orders" element={<OrdersListPage />} />
|
||||
<Route path="orders/:id" element={<OrderDetailPage />} />
|
||||
|
||||
{/* Landing Hero Route */}
|
||||
<Route path="landing-hero" element={<HeroSliderPage />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
FolderOpen,
|
||||
Sliders,
|
||||
BadgePercent,
|
||||
ShoppingCart,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
|
@ -33,6 +34,11 @@ const menuItems: MenuItem[] = [
|
|||
icon: Home,
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
title: 'سفارشات',
|
||||
icon: ShoppingCart,
|
||||
path: '/orders',
|
||||
},
|
||||
{
|
||||
title: 'مدیریت محصولات',
|
||||
icon: Package,
|
||||
|
|
|
|||
|
|
@ -87,4 +87,9 @@ export const API_ROUTES = {
|
|||
CREATE_DISCOUNT_CODE: "api/v1/admin/discount/",
|
||||
UPDATE_DISCOUNT_CODE: (id: string) => `api/v1/admin/discount/${id}/`,
|
||||
DELETE_DISCOUNT_CODE: (id: string) => `api/v1/admin/discount/${id}/`,
|
||||
|
||||
// Orders APIs
|
||||
GET_ORDERS: "checkout/orders",
|
||||
GET_ORDER: (id: string) => `checkout/orders/${id}`,
|
||||
UPDATE_ORDER_STATUS: (id: string) => `checkout/orders/${id}/status`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,192 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus, Search, Filter, Package, ShoppingCart, DollarSign, Clock } from 'lucide-react';
|
||||
import { Table } from '../components/ui/Table';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Pagination } from '../components/ui/Pagination';
|
||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||
import { TableColumn } from '../types';
|
||||
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
|
||||
|
||||
const allOrders = [
|
||||
{ id: 1001, customer: 'علی احمدی', products: '۳ محصول', amount: '۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۵' },
|
||||
{ id: 1002, customer: 'فاطمه حسینی', products: '۱ محصول', amount: '۲۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۱۴' },
|
||||
{ id: 1003, customer: 'محمد رضایی', products: '۲ محصول', amount: '۳۲,۰۰۰,۰۰۰', status: 'ارسال شده', date: '۱۴۰۲/۰۸/۱۳' },
|
||||
{ id: 1004, customer: 'زهرا کریمی', products: '۵ محصول', amount: '۱۲۰,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۲' },
|
||||
{ id: 1005, customer: 'حسن نوری', products: '۱ محصول', amount: '۱۸,۰۰۰,۰۰۰', status: 'لغو شده', date: '۱۴۰۲/۰۸/۱۱' },
|
||||
{ id: 1006, customer: 'مریم صادقی', products: '۴ محصول', amount: '۸۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۱۰' },
|
||||
{ id: 1007, customer: 'احمد قاسمی', products: '۲ محصول', amount: '۳۸,۰۰۰,۰۰۰', status: 'ارسال شده', date: '۱۴۰۲/۰۸/۰۹' },
|
||||
{ id: 1008, customer: 'سارا محمدی', products: '۳ محصول', amount: '۶۲,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۰۸' },
|
||||
{ id: 1009, customer: 'رضا کریمی', products: '۱ محصول', amount: '۱۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۰۷' },
|
||||
{ id: 1010, customer: 'نرگس احمدی', products: '۶ محصول', amount: '۱۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۰۶' },
|
||||
];
|
||||
|
||||
export const Orders = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 6;
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{ key: 'id', label: 'شماره سفارش', sortable: true },
|
||||
{ key: 'customer', label: 'مشتری', sortable: true },
|
||||
{ key: 'products', label: 'محصولات' },
|
||||
{
|
||||
key: 'amount',
|
||||
label: 'مبلغ',
|
||||
render: (value) => (
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{value} تومان
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'وضعیت',
|
||||
render: (value) => (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'تحویل شده'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: value === 'ارسال شده'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: value === 'در حال پردازش'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ key: 'date', label: 'تاریخ سفارش', sortable: true },
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'عملیات',
|
||||
render: (_, row) => (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleViewOrder(row)}
|
||||
>
|
||||
مشاهده
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => handleEditOrder(row)}
|
||||
>
|
||||
ویرایش
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const filteredOrders = allOrders.filter((order: any) =>
|
||||
order.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.id.toString().includes(searchTerm)
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const paginatedOrders = filteredOrders.slice(startIndex, startIndex + itemsPerPage);
|
||||
|
||||
const handleViewOrder = (order: any) => {
|
||||
console.log('Viewing order:', order);
|
||||
};
|
||||
|
||||
const handleEditOrder = (order: any) => {
|
||||
console.log('Editing order:', order);
|
||||
};
|
||||
|
||||
const totalRevenue = allOrders.reduce((sum, order) => {
|
||||
const amount = parseInt(order.amount.replace(/[,]/g, ''));
|
||||
return sum + amount;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>مدیریت سفارشات</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{filteredOrders.length} سفارش یافت شد
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<ShoppingCart className="h-8 w-8 text-blue-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل سفارشات</p>
|
||||
<StatValue>{allOrders.length}</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-8 w-8 text-green-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">تحویل شده</p>
|
||||
<StatValue>
|
||||
{allOrders.filter(o => o.status === 'تحویل شده').length}
|
||||
</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<ShoppingCart className="h-8 w-8 text-yellow-600" />
|
||||
<div className="mr-3">
|
||||
<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">
|
||||
{allOrders.filter(o => o.status === 'در حال پردازش').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-8 w-8 text-purple-600" />
|
||||
<div className="mr-3">
|
||||
<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">
|
||||
{totalRevenue.toLocaleString()} تومان
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در سفارشات..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input pr-10 max-w-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={paginatedOrders}
|
||||
loading={false}
|
||||
/>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={filteredOrders.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
export type DiscountCodeType = "percentage" | "fixed";
|
||||
export type DiscountCodeType = "percentage" | "fixed" | "fee_percentage";
|
||||
|
||||
export type DiscountApplicationLevel =
|
||||
| "invoice"
|
||||
| "category"
|
||||
| "product"
|
||||
| "shipping";
|
||||
| "shipping"
|
||||
| "product_fee";
|
||||
|
||||
export type DiscountStatus = "active" | "inactive";
|
||||
|
||||
export type UserGroup = "new" | "loyal" | "all";
|
||||
|
||||
export interface DiscountUserRestrictions {
|
||||
user_ids?: number[];
|
||||
user_group?: string;
|
||||
user_group?: UserGroup;
|
||||
min_purchase_count?: number;
|
||||
max_purchase_count?: number;
|
||||
referrer_user_id?: number;
|
||||
new_users_only?: boolean;
|
||||
loyal_users_only?: boolean;
|
||||
}
|
||||
|
|
@ -19,6 +25,15 @@ export interface DiscountMeta {
|
|||
[key: string]: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface SteppedDiscountStep {
|
||||
min_amount: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SteppedDiscount {
|
||||
steps: SteppedDiscountStep[];
|
||||
}
|
||||
|
||||
export interface DiscountCode {
|
||||
id: number;
|
||||
code: string;
|
||||
|
|
@ -32,10 +47,11 @@ export interface DiscountCode {
|
|||
max_discount_amount?: number;
|
||||
usage_limit?: number;
|
||||
user_usage_limit?: number;
|
||||
single_use?: boolean;
|
||||
single_use: boolean;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
user_restrictions?: DiscountUserRestrictions;
|
||||
stepped_discount?: SteppedDiscount;
|
||||
meta?: DiscountMeta;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
|
@ -63,10 +79,11 @@ export interface CreateDiscountCodeRequest {
|
|||
max_discount_amount?: number;
|
||||
usage_limit?: number;
|
||||
user_usage_limit?: number;
|
||||
single_use?: boolean;
|
||||
single_use: boolean;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
user_restrictions?: DiscountUserRestrictions;
|
||||
stepped_discount?: SteppedDiscount;
|
||||
meta?: DiscountMeta;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,18 +12,18 @@ import { FormHeader, PageContainer, Label, SectionTitle } from '../../../compone
|
|||
import { ArrowRight, BadgePercent, Calendar, Settings, Users, Tag, Info } from 'lucide-react';
|
||||
|
||||
const schema = yup.object({
|
||||
code: yup.string().required('کد الزامی است'),
|
||||
name: yup.string().required('نام الزامی است'),
|
||||
description: yup.string().nullable(),
|
||||
type: yup.mixed<'percentage' | 'fixed'>().oneOf(['percentage', 'fixed']).required('نوع الزامی است'),
|
||||
value: yup.number().typeError('مقدار نامعتبر است').required('مقدار الزامی است').min(0),
|
||||
code: yup.string().min(3, 'کد باید حداقل ۳ کاراکتر باشد').max(50, 'کد نباید بیشتر از ۵۰ کاراکتر باشد').required('کد الزامی است'),
|
||||
name: yup.string().min(1, 'نام الزامی است').max(100, 'نام نباید بیشتر از ۱۰۰ کاراکتر باشد').required('نام الزامی است'),
|
||||
description: yup.string().max(500, 'توضیحات نباید بیشتر از ۵۰۰ کاراکتر باشد').nullable(),
|
||||
type: yup.mixed<'percentage' | 'fixed' | 'fee_percentage'>().oneOf(['percentage', 'fixed', 'fee_percentage']).required('نوع الزامی است'),
|
||||
value: yup.number().typeError('مقدار نامعتبر است').required('مقدار الزامی است').min(0.01, 'مقدار باید بیشتر از صفر باشد'),
|
||||
status: yup.mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']).required('وضعیت الزامی است'),
|
||||
application_level: yup.mixed<'invoice' | 'category' | 'product' | 'shipping'>().oneOf(['invoice', 'category', 'product', 'shipping']).required('سطح اعمال الزامی است'),
|
||||
min_purchase_amount: yup.number().transform((v, o) => o === '' ? undefined : v).nullable(),
|
||||
max_discount_amount: yup.number().transform((v, o) => o === '' ? undefined : v).nullable(),
|
||||
usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).nullable(),
|
||||
user_usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).nullable(),
|
||||
single_use: yup.boolean().default(false),
|
||||
application_level: yup.mixed<'invoice' | 'category' | 'product' | 'shipping' | 'product_fee'>().oneOf(['invoice', 'category', 'product', 'shipping', 'product_fee']).required('سطح اعمال الزامی است'),
|
||||
min_purchase_amount: yup.number().transform((v, o) => o === '' ? undefined : v).min(0.01, 'مبلغ باید بیشتر از صفر باشد').nullable(),
|
||||
max_discount_amount: yup.number().transform((v, o) => o === '' ? undefined : v).min(0.01, 'مبلغ باید بیشتر از صفر باشد').nullable(),
|
||||
usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).min(1, 'حداقل ۱ بار استفاده').nullable(),
|
||||
user_usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).min(1, 'حداقل ۱ بار استفاده').nullable(),
|
||||
single_use: yup.boolean().required('این فیلد الزامی است'),
|
||||
valid_from: yup.string().nullable(),
|
||||
valid_to: yup.string().nullable(),
|
||||
});
|
||||
|
|
@ -166,6 +166,7 @@ const DiscountCodeFormPage = () => {
|
|||
<select className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 transition-colors" {...register('type')}>
|
||||
<option value="percentage">درصدی</option>
|
||||
<option value="fixed">مبلغ ثابت</option>
|
||||
<option value="fee_percentage">درصد کارمزد</option>
|
||||
</select>
|
||||
{errors.type && <p className="text-sm text-red-600 dark:text-red-400">{errors.type.message as string}</p>}
|
||||
</div>
|
||||
|
|
@ -192,6 +193,7 @@ const DiscountCodeFormPage = () => {
|
|||
<option value="category">دستهبندی خاص</option>
|
||||
<option value="product">محصول خاص</option>
|
||||
<option value="shipping">هزینه ارسال</option>
|
||||
<option value="product_fee">کارمزد محصول</option>
|
||||
</select>
|
||||
{errors.application_level && <p className="text-sm text-red-600 dark:text-red-400">{errors.application_level.message as string}</p>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
getOrders,
|
||||
getOrder,
|
||||
updateOrderStatus,
|
||||
getOrderStats,
|
||||
} from "./_requests";
|
||||
import { OrderFilters, UpdateOrderStatusRequest } from "./_models";
|
||||
|
||||
export const useOrders = (filters?: OrderFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_ORDERS, filters],
|
||||
queryFn: () => getOrders(filters),
|
||||
});
|
||||
};
|
||||
|
||||
export const useOrder = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_ORDER, id],
|
||||
queryFn: () => getOrder(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useOrderStats = () => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_ORDERS, "stats"],
|
||||
queryFn: getOrderStats,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateOrderStatus = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: string;
|
||||
payload: UpdateOrderStatusRequest;
|
||||
}) => updateOrderStatus(id, payload),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEYS.GET_ORDERS],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEYS.GET_ORDER, variables.id],
|
||||
});
|
||||
toast.success("وضعیت سفارش با موفقیت بهروزرسانی شد");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || "خطا در بهروزرسانی وضعیت سفارش");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
export type OrderStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "shipped"
|
||||
| "delivered"
|
||||
| "cancelled"
|
||||
| "refunded";
|
||||
|
||||
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded";
|
||||
|
||||
export type PaymentMethod =
|
||||
| "credit_card"
|
||||
| "debit_card"
|
||||
| "bank_transfer"
|
||||
| "cash_on_delivery"
|
||||
| "wallet";
|
||||
|
||||
export interface OrderItem {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_image?: string;
|
||||
variant_id?: number;
|
||||
variant_name?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
discount_amount?: number;
|
||||
}
|
||||
|
||||
export interface OrderAddress {
|
||||
id: number;
|
||||
type: "billing" | "shipping";
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
company?: string;
|
||||
address_line_1: string;
|
||||
address_line_2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface OrderPayment {
|
||||
id: number;
|
||||
payment_method: PaymentMethod;
|
||||
payment_status: PaymentStatus;
|
||||
amount: number;
|
||||
transaction_id?: string;
|
||||
gateway_response?: Record<string, any>;
|
||||
paid_at?: string;
|
||||
}
|
||||
|
||||
export interface OrderCustomer {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: number;
|
||||
order_number: string;
|
||||
customer: OrderCustomer;
|
||||
status: OrderStatus;
|
||||
items: OrderItem[];
|
||||
billing_address: OrderAddress;
|
||||
shipping_address: OrderAddress;
|
||||
payment: OrderPayment;
|
||||
subtotal: number;
|
||||
tax_amount: number;
|
||||
shipping_amount: number;
|
||||
discount_amount: number;
|
||||
total_amount: number;
|
||||
currency: string;
|
||||
notes?: string;
|
||||
tracking_number?: string;
|
||||
estimated_delivery?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OrderFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: OrderStatus;
|
||||
payment_status?: PaymentStatus;
|
||||
customer_id?: number;
|
||||
order_number?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
min_amount?: number;
|
||||
max_amount?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedOrdersResponse {
|
||||
orders: Order[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface UpdateOrderStatusRequest {
|
||||
status: OrderStatus;
|
||||
notes?: string;
|
||||
tracking_number?: string;
|
||||
estimated_delivery?: string;
|
||||
}
|
||||
|
||||
export interface OrderStats {
|
||||
total_orders: number;
|
||||
total_revenue: number;
|
||||
orders_by_status: Record<OrderStatus, number>;
|
||||
avg_order_value: number;
|
||||
}
|
||||
|
||||
export type Response<T> = {
|
||||
data: T;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
APIUrlGenerator,
|
||||
httpGetRequest,
|
||||
httpPutRequest,
|
||||
} from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import {
|
||||
Order,
|
||||
OrderFilters,
|
||||
PaginatedOrdersResponse,
|
||||
UpdateOrderStatusRequest,
|
||||
OrderStats,
|
||||
} from "./_models";
|
||||
|
||||
export const getOrders = async (filters?: OrderFilters) => {
|
||||
const queryParams: Record<string, string | number | boolean | null> = {};
|
||||
|
||||
if (filters?.page) queryParams.page = filters.page;
|
||||
if (filters?.limit) queryParams.limit = filters.limit;
|
||||
if (filters?.offset) queryParams.offset = filters.offset;
|
||||
if (filters?.status) queryParams.status = filters.status;
|
||||
if (filters?.payment_status)
|
||||
queryParams.payment_status = filters.payment_status;
|
||||
if (filters?.customer_id) queryParams.customer_id = filters.customer_id;
|
||||
if (filters?.order_number) queryParams.order_number = filters.order_number;
|
||||
if (filters?.date_from) queryParams.date_from = filters.date_from;
|
||||
if (filters?.date_to) queryParams.date_to = filters.date_to;
|
||||
if (filters?.min_amount) queryParams.min_amount = filters.min_amount;
|
||||
if (filters?.max_amount) queryParams.max_amount = filters.max_amount;
|
||||
|
||||
const response = await httpGetRequest<PaginatedOrdersResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_ORDERS, queryParams)
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getOrder = async (id: string) => {
|
||||
const response = await httpGetRequest<Order>(
|
||||
APIUrlGenerator(API_ROUTES.GET_ORDER(id))
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateOrderStatus = async (
|
||||
id: string,
|
||||
payload: UpdateOrderStatusRequest
|
||||
) => {
|
||||
const response = await httpPutRequest<Order>(
|
||||
APIUrlGenerator(API_ROUTES.UPDATE_ORDER_STATUS(id)),
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getOrderStats = async (): Promise<OrderStats> => {
|
||||
try {
|
||||
const ordersResponse = await getOrders({ limit: 1000 });
|
||||
|
||||
const stats: OrderStats = {
|
||||
total_orders: ordersResponse.total,
|
||||
total_revenue: ordersResponse.orders.reduce(
|
||||
(sum, order) => sum + order.total_amount,
|
||||
0
|
||||
),
|
||||
orders_by_status: ordersResponse.orders.reduce((acc, order) => {
|
||||
acc[order.status] = (acc[order.status] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<OrderStatus, number>),
|
||||
avg_order_value:
|
||||
ordersResponse.orders.length > 0
|
||||
? ordersResponse.orders.reduce(
|
||||
(sum, order) => sum + order.total_amount,
|
||||
0
|
||||
) / ordersResponse.orders.length
|
||||
: 0,
|
||||
};
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error("Error fetching order stats:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useOrder, useUpdateOrderStatus } from '../core/_hooks';
|
||||
import { OrderStatus } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
|
||||
import {
|
||||
ArrowRight,
|
||||
Package,
|
||||
User,
|
||||
CreditCard,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Truck,
|
||||
Edit3,
|
||||
Phone,
|
||||
Mail,
|
||||
FileText
|
||||
} 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', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const OrderDetailPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||
const [newStatus, setNewStatus] = useState<OrderStatus>('processing');
|
||||
|
||||
const { data: order, isLoading, error } = useOrder(id || '');
|
||||
const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus();
|
||||
|
||||
const handleStatusUpdate = () => {
|
||||
if (id) {
|
||||
updateStatus(
|
||||
{ id, payload: { status: newStatus } },
|
||||
{ onSuccess: () => setStatusUpdateOpen(false) }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatusClick = () => {
|
||||
if (order) {
|
||||
setNewStatus(order.status);
|
||||
setStatusUpdateOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
if (error || !order) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری اطلاعات سفارش</p>
|
||||
<Button variant="secondary" onClick={() => navigate('/orders')} className="mt-4">
|
||||
بازگشت به لیست سفارشات
|
||||
</Button>
|
||||
</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>سفارش #{order.order_number}</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
تاریخ ثبت: {formatDate(order.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/orders')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUpdateStatusClick}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
تغییر وضعیت
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-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-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="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<Package className="h-5 w-5 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<SectionTitle>اطلاعات سفارش</SectionTitle>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(order.status)}`}>
|
||||
{getStatusText(order.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">شماره سفارش</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">#{order.order_number}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ ثبت</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
{order.tracking_number && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">کد رهگیری</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400 font-mono">{order.tracking_number}</p>
|
||||
</div>
|
||||
)}
|
||||
{order.estimated_delivery && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ تحویل تخمینی</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{formatDate(order.estimated_delivery)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{order.notes && (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">یادداشت</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-700 p-3 rounded-lg">
|
||||
{order.notes}
|
||||
</p>
|
||||
</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-green-50 to-emerald-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-green-100 dark:bg-green-900 rounded-lg">
|
||||
<Package className="h-5 w-5 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<SectionTitle>محصولات سفارش</SectionTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
{item.product_image && (
|
||||
<img
|
||||
src={item.product_image}
|
||||
alt={item.product_name}
|
||||
className="w-16 h-16 object-cover rounded-lg"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100">{item.product_name}</h4>
|
||||
{item.variant_name && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">نوع: {item.variant_name}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
تعداد: {item.quantity}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
قیمت واحد: {formatCurrency(item.unit_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(item.total_price)}
|
||||
</p>
|
||||
</div>
|
||||
</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-purple-50 to-pink-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-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<User className="h-5 w-5 text-purple-600 dark:text-purple-300" />
|
||||
</div>
|
||||
<SectionTitle>اطلاعات مشتری</SectionTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<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.customer.first_name} {order.customer.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.customer.email}</p>
|
||||
</div>
|
||||
{order.customer.phone && (
|
||||
<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" dir="ltr">{order.customer.phone}</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-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">
|
||||
<p>{order.shipping_address.first_name} {order.shipping_address.last_name}</p>
|
||||
<p>{order.shipping_address.address_line_1}</p>
|
||||
{order.shipping_address.address_line_2 && <p>{order.shipping_address.address_line_2}</p>}
|
||||
<p>{order.shipping_address.city}, {order.shipping_address.state}</p>
|
||||
<p>کد پستی: {order.shipping_address.postal_code}</p>
|
||||
{order.shipping_address.phone && <p>تلفن: {order.shipping_address.phone}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-gray-200 dark:border-gray-700" />
|
||||
<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">
|
||||
<p>{order.billing_address.first_name} {order.billing_address.last_name}</p>
|
||||
<p>{order.billing_address.address_line_1}</p>
|
||||
{order.billing_address.address_line_2 && <p>{order.billing_address.address_line_2}</p>}
|
||||
<p>{order.billing_address.city}, {order.billing_address.state}</p>
|
||||
<p>کد پستی: {order.billing_address.postal_code}</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-gradient-to-r from-teal-50 to-cyan-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-teal-100 dark:bg-teal-900 rounded-lg">
|
||||
<CreditCard className="h-5 w-5 text-teal-600 dark:text-teal-300" />
|
||||
</div>
|
||||
<SectionTitle>پرداخت</SectionTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">جمع فرعی</span>
|
||||
<span className="font-medium">{formatCurrency(order.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">مالیات</span>
|
||||
<span className="font-medium">{formatCurrency(order.tax_amount)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">هزینه ارسال</span>
|
||||
<span className="font-medium">{formatCurrency(order.shipping_amount)}</span>
|
||||
</div>
|
||||
{order.discount_amount > 0 && (
|
||||
<div className="flex justify-between text-green-600 dark:text-green-400">
|
||||
<span>تخفیف</span>
|
||||
<span className="font-medium">-{formatCurrency(order.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<hr className="border-gray-200 dark:border-gray-700" />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>مجموع</span>
|
||||
<span>{formatCurrency(order.total_amount)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">روش پرداخت</span>
|
||||
<span className="text-sm font-medium">{order.payment.payment_method}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">وضعیت پرداخت</span>
|
||||
<span className={`text-sm font-medium ${order.payment.payment_status === 'paid'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
}`}>
|
||||
{order.payment.payment_status === 'paid' ? 'پرداخت شده' : 'در انتظار پرداخت'}
|
||||
</span>
|
||||
</div>
|
||||
{order.payment.transaction_id && (
|
||||
<div className="flex justify-between mt-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">شماره تراکنش</span>
|
||||
<span className="text-sm font-mono">{order.payment.transaction_id}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* مودال تغییر وضعیت */}
|
||||
<Modal isOpen={statusUpdateOpen} onClose={() => setStatusUpdateOpen(false)} 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-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"
|
||||
>
|
||||
<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={() => setStatusUpdateOpen(false)} disabled={isUpdating}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleStatusUpdate} loading={isUpdating}>
|
||||
بهروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderDetailPage;
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOrders, useOrderStats, useUpdateOrderStatus } from '../core/_hooks';
|
||||
import { Order, OrderFilters, OrderStatus } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
|
||||
import {
|
||||
ShoppingCart,
|
||||
Package,
|
||||
DollarSign,
|
||||
Clock,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Edit3,
|
||||
TrendingUp,
|
||||
Calendar
|
||||
} 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 = () => (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 7 }).map((__, j) => (
|
||||
<td key={j} className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const OrdersListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [statusUpdateId, setStatusUpdateId] = useState<string | null>(null);
|
||||
const [newStatus, setNewStatus] = useState<OrderStatus>('processing');
|
||||
const [filters, setFilters] = useState<OrderFilters>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
order_number: '',
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
const { data: ordersData, isLoading, error } = useOrders(filters);
|
||||
const { data: stats, isLoading: statsLoading } = useOrderStats();
|
||||
const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus();
|
||||
|
||||
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 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<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-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<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>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{statsLoading ? '...' : stats?.total_orders?.toLocaleString('fa-IR') || '0'}
|
||||
</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">
|
||||
{statsLoading ? '...' : formatCurrency(stats?.total_revenue || 0)}
|
||||
</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">
|
||||
{statsLoading ? '...' : (stats?.orders_by_status?.pending || 0)}
|
||||
</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">
|
||||
{statsLoading ? '...' : formatCurrency(stats?.avg_order_value || 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="شماره سفارش..."
|
||||
value={filters.order_number || ''}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, order_number: 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value as OrderStatus || undefined, page: 1 }))}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setFilters({ page: 1, limit: 20, order_number: '', status: undefined })}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{ordersData.orders.map((order: Order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
#{order.order_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div className="font-medium">{order.customer.first_name} {order.customer.last_name}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">{order.customer.email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(order.total_amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
||||
{getStatusText(order.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatDate(order.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleViewOrder(order.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(order.id, order.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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* صفحهبندی */}
|
||||
<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-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"
|
||||
>
|
||||
<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;
|
||||
|
|
@ -78,4 +78,9 @@ export const QUERY_KEYS = {
|
|||
CREATE_DISCOUNT_CODE: "create_discount_code",
|
||||
UPDATE_DISCOUNT_CODE: "update_discount_code",
|
||||
DELETE_DISCOUNT_CODE: "delete_discount_code",
|
||||
|
||||
// Orders
|
||||
GET_ORDERS: "get_orders",
|
||||
GET_ORDER: "get_order",
|
||||
UPDATE_ORDER_STATUS: "update_order_status",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue