new UX for new request

This commit is contained in:
2026-05-24 15:50:02 +03:30
parent 131220fcd9
commit 86475dc72a
5 changed files with 400 additions and 123 deletions
+39 -2
View File
@@ -1,7 +1,9 @@
import { type FC, useState, useMemo, useEffect } from 'react'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { useParams } from 'react-router-dom'
import { Link, useParams } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { Paths } from '@/config/Paths'
import Input from '@/components/Input'
import Select from '@/components/Select'
import type { ItemsSelectType } from '@/components/Select'
@@ -39,10 +41,12 @@ const PayInvoice: FC = () => {
const { data: user } = useGetMe()
const { id } = useParams()
const queryClient = useQueryClient()
const { data, isPending } = useGetInvoiceDetail(id ?? '')
const { mutate: payInvoice, isPending: isPaying } = usePayInvoice()
const multiUpload = useMultiUpload()
const [attachmentFiles, setAttachmentFiles] = useState<File[]>([])
const [isPaymentDone, setIsPaymentDone] = useState(false)
const userCredit = user?.maxCredit ?? 0
const methodItems = useMemo(() => getMethodItems(userCredit), [userCredit])
@@ -103,13 +107,25 @@ const PayInvoice: FC = () => {
},
},
{
onSuccess: (res) => {
onSuccess: (res, variables) => {
const paymentUrl = res?.data?.paymentUrl
if (paymentUrl) {
toast('در حال انتقال به درگاه پرداخت', 'success')
window.location.href = paymentUrl
return
}
const method = variables.params.method
if (
method === PaymentMethodEnum.Cash ||
method === PaymentMethodEnum.Credit
) {
queryClient.invalidateQueries({ queryKey: ['invoice', id] })
queryClient.invalidateQueries({ queryKey: ['payment', id] })
setIsPaymentDone(true)
return
}
toast('پرداخت با موفقیت ثبت شد', 'success')
},
onError: (error: ErrorType) => {
@@ -139,6 +155,27 @@ const PayInvoice: FC = () => {
)
}
if (isPaymentDone) {
return (
<div className="mt-4 text-sm">
<div className="bg-white rounded-2xl p-8 flex flex-col items-center justify-center min-h-[280px] gap-6">
<TickCircle size={64} color="#22C55E" variant="Bold" />
<div className="text-center">
<div className="text-lg font-medium text-black mb-2">پرداخت انجام شد</div>
<p className="text-xs text-[#8C90A3]">
پرداخت شما با موفقیت ثبت شد.
</p>
</div>
<Link to={`${Paths.proformaInvoice}/${id}`} className="w-full max-w-xs">
<Button type="button" className="w-full">
بازگشت به جزئیات صورت حساب
</Button>
</Link>
</div>
</div>
)
}
return (
<div className="mt-4 text-sm">
<div className="text-sm mb-6">پرداخت صورت حساب</div>
+91 -40
View File
@@ -1,64 +1,115 @@
import { useState, type FC } from 'react'
import { useEffect, useState, type FC } from 'react'
import Request from './components/Request'
import Button from '@/components/Button'
import { TickSquare } from 'iconsax-react'
import RequestItemsList from './components/RequestItemsList'
import { useRequestStore } from './store/RequestStore'
import { useSubmitRequest } from './hooks/useRequestData'
import { toast } from '@/shared/toast'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { Paths } from '@/config/Paths'
import { extractErrorMessage } from '@/config/func'
import { ArrowRight2 } from 'iconsax-react'
const NewRequest: FC = () => {
const navigate = useNavigate()
const { mutate: submitRequest, isPending } = useSubmitRequest()
const [showRequests, setShowRequests] = useState([0])
const { items, setItems } = useRequestStore()
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [formKey, setFormKey] = useState(0)
const onSubmit = () => {
if (items.length > 0) {
submitRequest(items, {
onSuccess: () => {
setItems([])
setShowRequests([0])
toast('سفارش با موفقیت ثبت شد', 'success')
navigate(Paths.myRequests, { state: { refresh: true } })
},
onError: (error) => {
toast(extractErrorMessage(error), 'error')
}
})
} else {
toast('حداقل یک سفارش باید اضافه کنید.', 'error')
useEffect(() => {
setItems([])
return () => setItems([])
}, [setItems])
const handleSaved = () => {
const wasEditing = editingIndex !== null
setEditingIndex(null)
setFormKey((k) => k + 1)
toast(wasEditing ? 'قلم ویرایش شد' : 'قلم به لیست اضافه شد', 'success')
}
const handleRemove = (index: number) => {
const next = items.filter((_, i) => i !== index)
setItems(next)
if (editingIndex === index) {
setEditingIndex(null)
setFormKey((k) => k + 1)
} else if (editingIndex !== null && editingIndex > index) {
setEditingIndex(editingIndex - 1)
}
}
const handleEdit = (index: number) => {
setEditingIndex(index)
}
const onSubmit = () => {
if (items.length === 0) {
toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error')
return
}
submitRequest(items, {
onSuccess: () => {
setItems([])
setEditingIndex(null)
setFormKey((k) => k + 1)
toast('سفارش با موفقیت ثبت شد', 'success')
navigate(Paths.myRequests, { state: { refresh: true } })
},
onError: (error) => {
toast(extractErrorMessage(error), 'error')
},
})
}
return (
<div className='mt-5'>
<div className='justify-between items-center flex'>
<h1 className='text-lg font-light'>
درخواست جدید
</h1>
<Button
className='w-fit px-5'
onClick={onSubmit}
isLoading={isPending}
<div className='mt-4 pb-12'>
<div className='flex items-center justify-between gap-4'>
<h1 className='text-lg font-light'>درخواست جدید</h1>
<Link
to={Paths.myRequests}
className='flex items-center gap-1 text-sm text-description hover:text-black shrink-0'
>
<div className='flex gap-2 items-center'>
<TickSquare size={20} color='black' />
<div>ثبت نهایی درخواست</div>
</div>
</Button>
<ArrowRight2 size={18} color='currentColor' />
بازگشت به لیست
</Link>
</div>
{showRequests.map((item) => {
return (
<Request addNewItem={() => setShowRequests([...showRequests, showRequests.length])} key={item} />
)
})}
<p className='text-description text-xs mt-2 leading-5'>
برای هر محصول یک قلم اضافه کنید، سپس درخواست را ثبت نهایی کنید.
</p>
<div className='flex flex-col-reverse xl:flex-row gap-6 xl:mt-8 mt-4'>
<div className='flex-1 min-w-0'>
<Request
key={editingIndex !== null ? `edit-${editingIndex}` : `new-${formKey}`}
editIndex={editingIndex}
initialItem={
editingIndex !== null ? items[editingIndex] : undefined
}
onSaved={handleSaved}
onCancelEdit={
editingIndex !== null
? () => {
setEditingIndex(null)
setFormKey((k) => k + 1)
}
: undefined
}
/>
</div>
<RequestItemsList
items={items}
editingIndex={editingIndex}
onEdit={handleEdit}
onRemove={handleRemove}
onSubmit={onSubmit}
isPending={isPending}
/>
</div>
</div>
)
}
@@ -19,16 +19,20 @@ const ManageAttribute: FC<Props> = (props) => {
const { attributes, formik } = props
const getAttributeValue = (fieldId: string) => {
const attr = formik.values.attributes.find((o) => o.fieldId === fieldId)
return attr?.value != null ? String(attr.value) : ''
}
const handleChange = (attributeId: string, value: string) => {
const attribute = formik.values.attributes
const index: number = attribute.findIndex(o => o.fieldId === attributeId)
const next = [...formik.values.attributes]
const index = next.findIndex((o) => o.fieldId === attributeId)
if (index > -1) {
attribute[index].value = value
next[index] = { ...next[index], value }
} else {
attribute.push({ fieldId: attributeId, value: value })
next.push({ fieldId: attributeId, value })
}
formik.setFieldValue('attributes', attribute)
formik.setFieldValue('attributes', next)
}
@@ -44,6 +48,7 @@ const ManageAttribute: FC<Props> = (props) => {
<Select
placeholder={`انتخاب ${item.isRequired ? '(اجباری)' : '(اختاری)'}`}
label={item.name}
value={getAttributeValue(item.id)}
items={item.options?.map((value) => {
return {
label: value.value,
@@ -100,7 +105,9 @@ const ManageAttribute: FC<Props> = (props) => {
return (
<div key={item.id} className='mt-6'>
<DatePickerComponent
key={`${item.id}-${getAttributeValue(item.id)}`}
onChange={(date) => handleChange(item.id, date)}
defaultValue={getAttributeValue(item.id) || undefined}
placeholder=''
label={item.name}
/>
@@ -112,6 +119,7 @@ const ManageAttribute: FC<Props> = (props) => {
<Input
label={item.name}
type={item.type}
value={getAttributeValue(item.id)}
onChange={(e) => handleChange(item.id, e.target.value)}
/>
</div>
@@ -121,6 +129,7 @@ const ManageAttribute: FC<Props> = (props) => {
<div key={item.id} className='mt-6'>
<Textarea
label={item.name}
value={getAttributeValue(item.id)}
onChange={(e) => handleChange(item.id, e.target.value)}
/>
</div>
+142 -76
View File
@@ -1,11 +1,11 @@
import { useState, type ChangeEvent, type FC } from 'react'
import { useEffect, useMemo, useState, type ChangeEvent, type FC } from 'react'
import Button from '@/components/Button'
import Input from '@/components/Input'
import Select from '@/components/Select'
import UploadBox from '@/components/UploadBox'
import VoiceRecorder from '@/components/VoiceRecorder'
import { COLORS } from '@/constants/colors'
import { AddSquare, Edit } from 'iconsax-react'
import { AddSquare, CloseCircle, Edit } from 'iconsax-react'
import ProductsSelect from './ProductsSelect'
import { useFormik } from 'formik'
import * as Yup from 'yup'
@@ -16,49 +16,53 @@ import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploa
import { useRequestStore } from '../store/RequestStore'
import { clx } from '@/helpers/utils'
type Props = {
addNewItem: () => void,
const emptyValues: RequestType = {
productId: undefined,
attachments: [],
quantity: undefined,
description: '',
attributes: [],
}
const Request: FC<Props> = ({ addNewItem }) => {
type Props = {
editIndex: number | null
initialItem?: RequestType
onSaved: () => void
onCancelEdit?: () => void
}
const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) => {
const isEditing = editIndex !== null
const { data } = useGetProducts()
const { setItems, items } = useRequestStore()
const [indexRequest, setIndexRequest] = useState<number>(-1)
const [isEditMode, setIsEditMode] = useState<boolean>(false)
const [productSelected, setProductSelected] = useState<ProductType>()
/** مقدار انتخاب‌شده در دراپ‌داون تعداد؛ '0' = انتخاب دلخواه تا Input قابل ویرایش بماند */
const [quantitySelect, setQuantitySelect] = useState<string>('')
const [voiceFile, setVoiceFile] = useState<File>()
const [files, setFiles] = useState<File[]>([])
const { data: attributes } = useGetAttributes(productSelected?.id)
const singleUpload = useSingleUpload()
const multiUpload = useMultiUpload()
const formik = useFormik<RequestType>({
initialValues: {
productId: undefined,
attachments: [],
quantity: undefined,
description: '',
attributes: []
},
initialValues: initialItem ?? emptyValues,
enableReinitialize: true,
validationSchema: Yup.object({
productId: Yup.string().required('این فیلد اجباری می باشد'),
quantity: Yup.number().required('این فیلد اجباری می باشد'),
}),
onSubmit: async (values) => {
const attachments: AttachmentsType[] = []
const attachments: AttachmentsType[] = [...(values.attachments ?? [])]
if (files.length) {
await multiUpload.mutateAsync(files, {
onSuccess: (data) => {
data?.data?.map((item) => {
data?.data?.forEach((item) => {
attachments.push({
type: 'uploads_attach',
url: item.url
url: item.url,
})
})
}
},
})
}
if (voiceFile) {
@@ -66,57 +70,110 @@ const Request: FC<Props> = ({ addNewItem }) => {
onSuccess: (data) => {
attachments.push({
type: 'voice',
url: data?.data?.url
url: data?.data?.url,
})
}
},
})
}
if (indexRequest === -1) {
values.attachments = attachments
setIndexRequest(items.length)
setItems([...items, values])
addNewItem()
setIsEditMode(true)
} else {
if (values.attachments.length) {
values.attachments = attachments
}
const items_edit = [...items]
items_edit[indexRequest] = values
setItems(items_edit)
const payload: RequestType = { ...values, attachments }
if (isEditing && editIndex !== null) {
const updated = [...items]
updated[editIndex] = payload
setItems(updated)
} else {
setItems([...items, payload])
}
}
setFiles([])
setVoiceFile(undefined)
onSaved()
},
})
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
const productId: string = e.target.value
const product = data?.data?.find(o => o.id === productId)
const activeProductId = formik.values.productId ?? initialItem?.productId
const resolvedProduct = useMemo(() => {
if (productSelected) return productSelected
if (!activeProductId || !data?.data) return undefined
const product = data.data.find((p) => p.id === activeProductId)
if (!product) return undefined
const quantities = [...product.quantities]
if (quantities[0] !== 0) quantities.unshift(0)
return { ...product, quantities }
}, [productSelected, activeProductId, data])
const { data: attributes } = useGetAttributes(resolvedProduct?.id)
useEffect(() => {
if (!initialItem?.productId || !data?.data) return
const product = data.data.find((p) => p.id === initialItem.productId)
if (!product) return
const quantities = [...product.quantities]
if (quantities[0] !== 0) quantities.unshift(0)
setProductSelected({ ...product, quantities })
const qty = initialItem.quantity
if (qty !== undefined && quantities.includes(qty)) {
setQuantitySelect(String(qty))
} else {
setQuantitySelect('0')
}
}, [initialItem, data])
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => {
const productId = e.target.value
const product = data?.data?.find((o) => o.id === productId)
if (product) {
if (product.quantities[0] !== 0) {
product.quantities.unshift(0)
}
setProductSelected(product)
const quantities = [...product.quantities]
if (quantities[0] !== 0) quantities.unshift(0)
setProductSelected({ ...product, quantities })
formik.setFieldValue('productId', productId)
formik.setFieldValue('attributes', [])
setQuantitySelect('')
}
}
const isUploading = singleUpload.isPending || multiUpload.isPending
return (
<div className='bg-white rounded-3xl p-6 mt-8'>
<div className='font-light'>اطلاعات درخواست</div>
<div className='bg-white rounded-3xl p-6'>
<div className='flex items-center justify-between'>
<div className='font-light'>
{isEditing ? 'ویرایش قلم' : 'افزودن قلم جدید'}
</div>
{isEditing && onCancelEdit && (
<button
type='button'
onClick={onCancelEdit}
className='flex items-center gap-1 text-xs text-description hover:text-black'
>
<CloseCircle size={18} color='currentColor' />
انصراف
</button>
)}
</div>
<div className='mt-6 rowTwoInput'>
<ProductsSelect
onChange={handleChange}
error_text={formik.touched.productId && formik.errors.productId ? formik.errors.productId : ''}
value={formik.values.productId ?? ''}
onChange={handleProductChange}
error_text={
formik.touched.productId && formik.errors.productId
? formik.errors.productId
: ''
}
/>
</div>
<div>
<ManageAttribute
key={`${resolvedProduct?.id ?? 'none'}-${editIndex ?? 'new'}`}
attributes={attributes?.data}
formik={formik}
/>
@@ -124,12 +181,12 @@ const Request: FC<Props> = ({ addNewItem }) => {
<div className='mt-6 rowTwoInput'>
<Select
items={productSelected?.quantities?.map((item) => {
return {
label: item === 0 ? 'انتخاب دلخواه' : item + '',
value: item + ''
}
}) || []}
items={
resolvedProduct?.quantities?.map((item) => ({
label: item === 0 ? 'انتخاب دلخواه' : String(item),
value: String(item),
})) ?? []
}
label='تعداد'
placeholder='انتخاب'
value={quantitySelect}
@@ -142,7 +199,11 @@ const Request: FC<Props> = ({ addNewItem }) => {
}}
onBlur={formik.handleBlur}
name='quantity'
error_text={formik.touched.quantity && formik.errors.quantity ? formik.errors.quantity : ''}
error_text={
formik.touched.quantity && formik.errors.quantity
? formik.errors.quantity
: ''
}
/>
<Input
@@ -150,49 +211,54 @@ const Request: FC<Props> = ({ addNewItem }) => {
readOnly={quantitySelect !== '0'}
{...formik.getFieldProps('quantity')}
type='number'
error_text={formik.touched.quantity && formik.errors.quantity ? formik.errors.quantity : ''}
error_text={
formik.touched.quantity && formik.errors.quantity
? formik.errors.quantity
: ''
}
/>
</div>
<div className='mt-6'>
<VoiceRecorder
value={formik.values.description ?? ''}
onChange={(value) => formik.setFieldValue('description', value)}
label='پیام صوتی'
onRecordingComplete={(blob) => {
const file = new File([blob], "recording.wav", { type: blob.type });
setVoiceFile(file);
const file = new File([blob], 'recording.wav', { type: blob.type })
setVoiceFile(file)
}}
/>
</div>
<div className='mt-6'>
<UploadBox
label='آپلود فایل'
isMultiple
onChange={setFiles}
/>
<UploadBox label='آپلود فایل' isMultiple onChange={setFiles} />
</div>
{isEditing && (initialItem?.attachments?.length ?? 0) > 0 && files.length === 0 && (
<p className='text-description text-xs mt-3'>
{initialItem!.attachments.length} پیوست قبلی حفظ میشود. برای جایگزینی، فایل جدید آپلود کنید.
</p>
)}
<div className='mt-6 flex justify-end'>
<Button
className={clx(
'bg-transparent border border-primary text-primary w-fit px-6',
isEditMode && 'bg-transparent border-[#3B82F6] text-[#3B82F6] '
'w-fit px-6',
isEditing
? 'bg-transparent border border-[#3B82F6] text-[#3B82F6]'
: 'bg-transparent border border-primary text-primary'
)}
onClick={() => formik.handleSubmit()}
isLoading={singleUpload.isPending || multiUpload.isPending}
isLoading={isUploading}
>
<div className='flex gap-1'>
{
isEditMode ?
<Edit color='#3B82F6' size={20} />
:
<AddSquare color={COLORS.primary} size={20} />
}
<div>
{isEditMode ? 'ویرایش کردن' : 'اضافه کردن'}
</div>
<div className='flex gap-1 items-center'>
{isEditing ? (
<Edit color='#3B82F6' size={20} />
) : (
<AddSquare color={COLORS.primary} size={20} />
)}
<span>{isEditing ? 'ذخیره تغییرات' : 'افزودن به لیست'}</span>
</div>
</Button>
</div>
@@ -0,0 +1,114 @@
import { type FC } from 'react'
import Button from '@/components/Button'
import { Edit, ShoppingCart, TickSquare, Trash } from 'iconsax-react'
import { useGetProducts } from '../hooks/useRequestData'
import type { RequestType } from '../type/Types'
import { clx } from '@/helpers/utils'
type Props = {
items: RequestType[]
editingIndex: number | null
onEdit: (index: number) => void
onRemove: (index: number) => void
onSubmit: () => void
isPending: boolean
}
const RequestItemsList: FC<Props> = ({
items,
editingIndex,
onEdit,
onRemove,
onSubmit,
isPending,
}) => {
const { data: productsData } = useGetProducts()
const getProductTitle = (productId?: string) =>
productsData?.data?.find((p) => p.id === productId)?.title ?? 'محصول'
return (
<div className='bg-white w-full xl:w-[320px] shrink-0 py-6 px-5 h-fit rounded-3xl xl:sticky xl:top-6'>
<div className='flex items-center gap-2'>
<ShoppingCart size={20} color='black' />
<span className='text-sm font-medium'>اقلام درخواست</span>
{items.length > 0 && (
<span className='text-xs bg-primary/20 text-black rounded-full px-2 py-0.5 mr-auto'>
{items.length}
</span>
)}
</div>
{items.length === 0 ? (
<p className='text-description text-xs mt-6 leading-6'>
هنوز قلمی اضافه نشده. فرم را پر کنید و روی «افزودن به لیست» بزنید.
</p>
) : (
<ul className='mt-4 space-y-3 max-h-[360px] overflow-y-auto'>
{items.map((item, index) => (
<li
key={index}
className={clx(
'border rounded-2xl p-3 transition-colors',
editingIndex === index
? 'border-primary bg-primary/5'
: 'border-[#f0f2f8]'
)}
>
<div className='text-sm font-medium truncate'>
{getProductTitle(item.productId)}
</div>
<div className='text-description text-xs mt-1'>
تعداد: {item.quantity}
{item.attachments.length > 0 && (
<span className='mr-2'>
· {item.attachments.length} پیوست
</span>
)}
</div>
<div className='flex gap-2 mt-3'>
<button
type='button'
onClick={() => onEdit(index)}
className='flex items-center gap-1 text-xs text-[#3B82F6] hover:opacity-80'
>
<Edit size={16} color='#3B82F6' />
ویرایش
</button>
<button
type='button'
onClick={() => onRemove(index)}
className='flex items-center gap-1 text-xs text-red-500 hover:opacity-80 mr-auto'
>
<Trash size={16} color='#ef4444' />
حذف
</button>
</div>
</li>
))}
</ul>
)}
<div className='mt-6 pt-4 border-t border-[#f0f2f8]'>
<Button
className='w-full'
onClick={onSubmit}
isLoading={isPending}
disabled={items.length === 0}
>
<div className='flex gap-2 items-center justify-center'>
<TickSquare size={20} color='black' />
<span>ثبت نهایی درخواست</span>
</div>
</Button>
{items.length === 0 && (
<p className='text-description text-[10px] text-center mt-2'>
حداقل یک قلم برای ثبت لازم است
</p>
)}
</div>
</div>
)
}
export default RequestItemsList