cancel order

This commit is contained in:
hamid zarghami
2025-10-07 09:32:08 +03:30
parent 9fd7395004
commit 1b7e9eb168
9 changed files with 407 additions and 34 deletions
@@ -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
+131
View File
@@ -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>
+22 -12
View File
@@ -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
+14 -1
View File
@@ -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,
});
};
+16 -13
View File
@@ -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;
};
+22
View File
@@ -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[];
}