cancel order
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import * as yup from 'yup'
|
||||
import Select from '@/components/Select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface CancelFormData {
|
||||
reason: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface CancelFormProps {
|
||||
onSubmit: (data: CancelFormData) => void
|
||||
isLoading: boolean
|
||||
cancelReasons: Array<{ _id: string; title: string }>
|
||||
selectedItemsCount: number
|
||||
}
|
||||
|
||||
const cancelFormSchema = yup.object({
|
||||
reason: yup.string().required('دلیل لغو سفارش را انتخاب کنید'),
|
||||
description: yup.string().required('توضیحات را وارد کنید').min(10, 'توضیحات باید حداقل ۱۰ کاراکتر باشد')
|
||||
})
|
||||
|
||||
const CancelForm: React.FC<CancelFormProps> = ({
|
||||
onSubmit,
|
||||
isLoading,
|
||||
cancelReasons,
|
||||
selectedItemsCount
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
reset
|
||||
} = useForm<CancelFormData>({
|
||||
resolver: yupResolver(cancelFormSchema)
|
||||
})
|
||||
|
||||
const handleFormSubmit = (data: CancelFormData) => {
|
||||
onSubmit(data)
|
||||
reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<h3 className='text-lg font-semibold'>دلیل لغو و توضیحات</h3>
|
||||
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className='space-y-6'>
|
||||
<Select
|
||||
placeholder='دلیل لغو سفارش'
|
||||
items={cancelReasons.map((item) => ({
|
||||
label: item.title,
|
||||
value: item._id
|
||||
}))}
|
||||
className='bg-white border'
|
||||
value={watch('reason')}
|
||||
name='reason'
|
||||
onChange={(e) => setValue('reason', e.target.value)}
|
||||
error_text={errors.reason?.message}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">توضیحات :</div>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
className='h-[123px] rounded-xl p-3 text-sm border w-full resize-none focus:outline-none focus:ring-2 focus:ring-primary/20'
|
||||
placeholder='متن توضیحات خود را بنویسید (حداقل ۱۰ کاراکتر)'
|
||||
/>
|
||||
{errors.description && (
|
||||
<div className='text-xs text-red-500 mt-1'>
|
||||
{errors.description.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 text-xs text-[#7F7F7F] p-3 bg-primary/10 rounded-lg'>
|
||||
<div className="size-5 bg-primary/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-primary text-xs">!</span>
|
||||
</div>
|
||||
<span>
|
||||
توجه داشته باشید که بعد از ثبت لغو سفارش طبق
|
||||
</span>
|
||||
<span className='text-primary font-medium'>
|
||||
جدول جرائم
|
||||
</span>
|
||||
<span>
|
||||
جریمه میشود
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className='w-full'
|
||||
disabled={isLoading || selectedItemsCount === 0}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
لغو سفارش ({selectedItemsCount} آیتم)
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CancelForm
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ShipmentItemDetail } from '../../../types/Types'
|
||||
|
||||
interface ProductSelectionProps {
|
||||
items: ShipmentItemDetail[]
|
||||
selectedItems: string[]
|
||||
onItemSelect: (itemId: string, checked: boolean) => void
|
||||
}
|
||||
|
||||
const ProductSelection: React.FC<ProductSelectionProps> = ({
|
||||
items,
|
||||
selectedItems,
|
||||
onItemSelect
|
||||
}) => {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<h3 className='mb-4'>انتخاب آیتمهای مورد نظر برای کنسل کردن</h3>
|
||||
{items.map((item) => {
|
||||
const availableQuantity = item.quantity - item.cancelled_quantity - item.returned_quantity
|
||||
const canCancel = availableQuantity > 0
|
||||
|
||||
return (
|
||||
<div key={item._id} className={`p-4 border rounded-lg ${!canCancel ? 'opacity-50' : ''}`}>
|
||||
<div className='flex items-start gap-4'>
|
||||
<Checkbox
|
||||
id={item._id}
|
||||
checked={selectedItems.includes(item._id)}
|
||||
onCheckedChange={(checked) => onItemSelect(item._id, checked as boolean)}
|
||||
disabled={!canCancel}
|
||||
className='mt-1'
|
||||
/>
|
||||
|
||||
<Image
|
||||
src={item.product.imagesUrl.cover}
|
||||
width={60}
|
||||
height={60}
|
||||
className='w-15 h-15 object-cover rounded-lg border flex-shrink-0'
|
||||
alt={item.product.title_fa}
|
||||
/>
|
||||
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium truncate'>
|
||||
{item.product.title_fa}
|
||||
</div>
|
||||
|
||||
<div className='mt-2 text-sm text-gray-600 space-y-1'>
|
||||
<div>تعداد کل: {item.quantity}</div>
|
||||
<div>قابل کنسل: {availableQuantity}</div>
|
||||
<div className='text-primary font-medium'>
|
||||
{Intl.NumberFormat('fa-IR').format(item.selling_price)} تومان
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canCancel && (
|
||||
<div className='text-xs text-red-500 mt-2'>
|
||||
این آیتم قابل کنسل کردن نیست
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductSelection
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
import Layout from '@/hoc/Layout'
|
||||
import { type NextPage } from 'next'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useGetOrderDetail, useCancelOrder, useGetCancelReasons } from '../../hooks/useOrdersData'
|
||||
import ProductSelection from './components/ProductSelection'
|
||||
import CancelForm from './components/CancelForm'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { extractErrorMessage } from '@/helpers/errorUtils'
|
||||
|
||||
const CancelOrderPage: NextPage = () => {
|
||||
const { id } = useParams()
|
||||
const router = useRouter()
|
||||
const { data: order, isLoading: isOrderLoading } = useGetOrderDetail(id as string)
|
||||
const cancelOrderMutation = useCancelOrder()
|
||||
const { data: cancelReasons } = useGetCancelReasons()
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([])
|
||||
|
||||
const handleItemSelect = (itemId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedItems(prev => [...prev, itemId])
|
||||
} else {
|
||||
setSelectedItems(prev => prev.filter(id => id !== itemId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelSubmit = async (formData: { reason: string; description: string }) => {
|
||||
if (selectedItems.length === 0) {
|
||||
toast('هیچ آیتمی انتخاب نشده است', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const cancelItems = selectedItems.map(itemId => {
|
||||
// Find the order item that contains this shipment item
|
||||
const orderItem = order?.results.order.orderItems.find(orderItem =>
|
||||
orderItem.shipmentItems.some(shipmentItem => shipmentItem._id === itemId)
|
||||
)
|
||||
|
||||
const shipmentItem = orderItem?.shipmentItems.find(item => item._id === itemId)
|
||||
|
||||
if (!orderItem || !shipmentItem) return null
|
||||
|
||||
const availableQuantity = shipmentItem.quantity - shipmentItem.cancelled_quantity - shipmentItem.returned_quantity
|
||||
|
||||
return {
|
||||
orderItemId: orderItem._id,
|
||||
shipmentItemId: shipmentItem._id,
|
||||
quantity: availableQuantity,
|
||||
comment: formData.description,
|
||||
reason: formData.reason
|
||||
}
|
||||
}).filter(Boolean) as { orderItemId: string; shipmentItemId: string; quantity: number; comment: string; reason: string }[]
|
||||
|
||||
await cancelOrderMutation.mutateAsync({
|
||||
orderId: Number(id),
|
||||
items: cancelItems
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast('درخواست لغو سفارش با موفقیت ثبت شد', 'success')
|
||||
router.push(`/profile/orders/${id}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// Flatten all shipment items from all order items
|
||||
const allShipmentItems = order?.results.order.orderItems
|
||||
.flatMap(orderItem => orderItem.shipmentItems) || []
|
||||
|
||||
if (isOrderLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className='mt-14 px-4 sm:px-8 lg:px-20'>
|
||||
<div className='flex justify-center items-center min-h-[400px]'>
|
||||
<div className='text-center'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4'></div>
|
||||
<p>در حال بارگذاری...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className='mt-14 px-4 sm:px-8 lg:px-20'>
|
||||
<div className='text-center min-h-[400px] flex items-center justify-center'>
|
||||
<p>سفارش یافت نشد</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className='mt-14 px-4 sm:px-8 lg:px-20 mx-auto'>
|
||||
<div className='mb-8'>
|
||||
<h1 className='text-2xl font-bold mb-2'>لغو سفارش</h1>
|
||||
<p className='text-gray-600'>شماره سفارش: {order.results.order._id}</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-8'>
|
||||
<ProductSelection
|
||||
items={allShipmentItems}
|
||||
selectedItems={selectedItems}
|
||||
onItemSelect={handleItemSelect}
|
||||
/>
|
||||
|
||||
{selectedItems.length > 0 && (
|
||||
<CancelForm
|
||||
onSubmit={handleCancelSubmit}
|
||||
isLoading={cancelOrderMutation.isPending}
|
||||
cancelReasons={cancelReasons?.results.reasons || []}
|
||||
selectedItemsCount={selectedItems.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CancelOrderPage
|
||||
@@ -3,6 +3,8 @@ import React from 'react'
|
||||
import Image from 'next/image'
|
||||
import { MessageText, ShieldTick, Truck, CloseCircle } from 'iconsax-react'
|
||||
import { ShipmentItemDetail } from '../../types/Types'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface OrderProductItemProps {
|
||||
item: ShipmentItemDetail
|
||||
@@ -10,7 +12,10 @@ interface OrderProductItemProps {
|
||||
onCancelClick: () => void
|
||||
}
|
||||
|
||||
const OrderProductItem: React.FC<OrderProductItemProps> = ({ item, shipperName, onCancelClick }) => {
|
||||
const OrderProductItem: React.FC<OrderProductItemProps> = ({ item, shipperName }) => {
|
||||
|
||||
const { id } = useParams()
|
||||
|
||||
return (
|
||||
<div className='mt-8 py-7 border-t items-start flex gap-6 sm:flex-row flex-col'>
|
||||
<Image
|
||||
@@ -80,13 +85,14 @@ const OrderProductItem: React.FC<OrderProductItemProps> = ({ item, shipperName,
|
||||
</div>
|
||||
|
||||
{item.quantity !== item.cancelled_quantity && (
|
||||
<button
|
||||
onClick={onCancelClick}
|
||||
className='flex cursor-pointer gap-2 text-[#B3261E] hover:bg-red-50 p-2 rounded-lg transition-colors'
|
||||
>
|
||||
<div>لغو سفارش</div>
|
||||
<CloseCircle size={16} />
|
||||
</button>
|
||||
<Link href={`/profile/orders/${id}/cancel`}>
|
||||
<button
|
||||
className='flex items-center cursor-pointer gap-2 text-[#B3261E] hover:bg-red-50 p-2 rounded-lg transition-colors'
|
||||
>
|
||||
<div>لغو سفارش</div>
|
||||
<CloseCircle className='mt-0.5' color='#B3261E' size={16} />
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,7 @@ import Menu from '../../components/Menu'
|
||||
import OrderSidebar from './components/OrderSidebar'
|
||||
import OrderMainContent from './components/OrderMainContent'
|
||||
import CancelOrderModal from './components/CancelOrderModal'
|
||||
import { useGetOrderDetail } from '../hooks/useOrdersData'
|
||||
import * as api from '../service/Service'
|
||||
import { useCancelOrder, useGetOrderDetail } from '../hooks/useOrdersData'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
|
||||
@@ -17,7 +16,7 @@ const OrderDetail: FC = () => {
|
||||
const { id } = useParams()
|
||||
const { data: orderDetail } = useGetOrderDetail(id as string)
|
||||
const [showModalCancel, setShowModalCancel] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const cancelOrderMutation = useCancelOrder()
|
||||
const router = useRouter()
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
@@ -32,11 +31,24 @@ const OrderDetail: FC = () => {
|
||||
|
||||
const onSubmit = async (cancelData: { reason: string; count: string; description: string }) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await api.cancelOrder(id as string, {
|
||||
reason: cancelData.reason,
|
||||
count: parseInt(cancelData.count),
|
||||
description: cancelData.description
|
||||
// Prepare cancel items from all order items
|
||||
const cancelItems = orderDetail?.results.order.orderItems.flatMap(orderItem =>
|
||||
orderItem.shipmentItems.map(shipmentItem => {
|
||||
const availableQuantity = shipmentItem.quantity - shipmentItem.cancelled_quantity - shipmentItem.returned_quantity
|
||||
|
||||
return {
|
||||
orderItemId: orderItem._id,
|
||||
shipmentItemId: shipmentItem._id,
|
||||
quantity: availableQuantity,
|
||||
comment: cancelData.description,
|
||||
reason: cancelData.reason
|
||||
}
|
||||
})
|
||||
) || []
|
||||
|
||||
await cancelOrderMutation.mutateAsync({
|
||||
orderId: Number(id),
|
||||
items: cancelItems
|
||||
})
|
||||
|
||||
toast("درخواست لغو سفارش با موفقیت ثبت شد", "success")
|
||||
@@ -45,8 +57,6 @@ const OrderDetail: FC = () => {
|
||||
router.push('/profile/orders')
|
||||
} catch {
|
||||
toast("مشکلی در ثبت درخواست لغو سفارش رخ داد", "error")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +76,7 @@ const OrderDetail: FC = () => {
|
||||
<div className="text-lg font-semibold">جزئیات سفارش</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{cancelOrderMutation.isPending ? (
|
||||
<PageLoading />
|
||||
) : orderDetail?.results.order ? (
|
||||
<div className='mt-6 lg:flex-row flex-col flex gap-6 items-start'>
|
||||
@@ -99,7 +109,7 @@ const OrderDetail: FC = () => {
|
||||
showModalCancel={showModalCancel}
|
||||
setShowModalCancel={setShowModalCancel}
|
||||
onSubmit={onSubmit}
|
||||
isLoading={isLoading}
|
||||
isLoading={cancelOrderMutation.isPending}
|
||||
cancelReasons={orderDetail?.results.order.cancelReasons}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextPage } from 'next'
|
||||
|
||||
const ReturnPage: NextPage = () => {
|
||||
return (
|
||||
<div>page</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReturnPage
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import * as api from "../service/Service";
|
||||
import { OrdersResponse, OrderDetailResponse } from "../types/Types";
|
||||
|
||||
@@ -17,3 +17,16 @@ export const useGetOrderDetail = (orderId: string) => {
|
||||
enabled: !!orderId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelOrder = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.cancelOrder,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCancelReasons = () => {
|
||||
return useQuery({
|
||||
queryKey: ["cancelReasons"],
|
||||
queryFn: api.getCancelReasons,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import axios from "@/config/axios";
|
||||
import { OrdersResponse, OrderDetailResponse } from "../types/Types";
|
||||
import {
|
||||
OrdersResponse,
|
||||
OrderDetailResponse,
|
||||
CancelOrderType,
|
||||
CancelReasonsResponse,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getOrders = async (activeTab: string): Promise<OrdersResponse> => {
|
||||
const { data } = await axios.get(`/user/profile/orders?status=${activeTab}`);
|
||||
@@ -13,21 +18,19 @@ export const getOrderDetail = async (
|
||||
return data;
|
||||
};
|
||||
|
||||
export const cancelOrder = async (
|
||||
orderId: string,
|
||||
cancelData: {
|
||||
reason: string;
|
||||
count: number;
|
||||
description: string;
|
||||
}
|
||||
) => {
|
||||
const { data } = await axios.post(`/order/${orderId}/cancel`, cancelData);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getInvoice = async (orderId: string) => {
|
||||
const { data } = await axios.get(`/order/${orderId}/invoice`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCancelReasons = async (): Promise<CancelReasonsResponse> => {
|
||||
const { data } = await axios.get(`/cancel/reasons`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const cancelOrder = async (params: CancelOrderType) => {
|
||||
const { data } = await axios.post(`/cancel/user`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -259,6 +259,15 @@ export interface OrderItemDetail {
|
||||
export interface CancelReason {
|
||||
_id: string;
|
||||
title: string;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface CancelReasonsResponse {
|
||||
status: number;
|
||||
success: boolean;
|
||||
results: {
|
||||
reasons: CancelReason[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrderDetail {
|
||||
@@ -291,3 +300,16 @@ export interface OrdersResponse {
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CancelOrderItem {
|
||||
orderItemId: string;
|
||||
shipmentItemId: string;
|
||||
quantity: number;
|
||||
comment: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CancelOrderType {
|
||||
orderId: number;
|
||||
items: CancelOrderItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user