Compare commits
9 Commits
17cb4afbca
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 18c0c721a1 | |||
| f1d0a661e7 | |||
| 1bea0ed3ec | |||
| cea411b912 | |||
| 04687b855d | |||
| 830e229cf3 | |||
| cd8677648e | |||
| 9f006b36ab | |||
| fff5cd9146 |
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { getToken, removeRefreshToken, removeToken } from "./func";
|
import { getToken, removeRefreshToken, removeToken } from "./func";
|
||||||
|
import { useAuthStore } from "@/pages/auth/store/AuthStore";
|
||||||
|
|
||||||
type SessionAuthStore = {
|
type SessionAuthStore = {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -14,6 +15,7 @@ export const useSessionAuth = create<SessionAuthStore>((set) => ({
|
|||||||
removeToken();
|
removeToken();
|
||||||
removeRefreshToken();
|
removeRefreshToken();
|
||||||
window.isRefreshTokenExpired = false;
|
window.isRefreshTokenExpired = false;
|
||||||
|
useAuthStore.getState().reset();
|
||||||
set({ isAuthenticated: false });
|
set({ isAuthenticated: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ export type StatusConfig = {
|
|||||||
*/
|
*/
|
||||||
export const statusRegistry: Record<string, StatusConfig> = {
|
export const statusRegistry: Record<string, StatusConfig> = {
|
||||||
// --- ProformaInvoiceStatusEnum ---
|
// --- ProformaInvoiceStatusEnum ---
|
||||||
[ProformaInvoiceStatusEnum.ALL]: { label: 'همه', variant: 'neutral' },
|
[ProformaInvoiceStatusEnum.PENDING]: { label: 'در انتظار تایید', variant: 'warning' },
|
||||||
[ProformaInvoiceStatusEnum.NOT_CONFIRMED]: { label: 'تایید نشده', variant: 'warning' },
|
|
||||||
[ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED]: { label: 'تایید جزئی', variant: 'info' },
|
|
||||||
[ProformaInvoiceStatusEnum.CONFIRMED]: { label: 'تایید شده', variant: 'success' },
|
[ProformaInvoiceStatusEnum.CONFIRMED]: { label: 'تایید شده', variant: 'success' },
|
||||||
|
[ProformaInvoiceStatusEnum.ARCHIVED]: { label: 'آرشیو شده', variant: 'neutral' },
|
||||||
|
|
||||||
// --- Invoice confirm status (from API; partially_confirmed/confirmed covered by enum above) ---
|
// --- Invoice confirm status (from API) ---
|
||||||
pending: { label: 'تایید نشده', variant: 'warning' },
|
pending: { label: 'در انتظار تایید', variant: 'warning' },
|
||||||
|
partially_confirmed: { label: 'تایید جزئی', variant: 'info' },
|
||||||
|
confirmed: { label: 'تایید شده', variant: 'success' },
|
||||||
|
archived: { label: 'آرشیو شده', variant: 'neutral' },
|
||||||
|
|
||||||
// --- OrderStatusEnum ---
|
// --- OrderStatusEnum ---
|
||||||
[OrderStatusEnum.CREATED]: { label: 'ایجاد شده', variant: 'info' },
|
[OrderStatusEnum.CREATED]: { label: 'ایجاد شده', variant: 'info' },
|
||||||
|
|||||||
+3
-1
@@ -35,7 +35,7 @@ export const fa = {
|
|||||||
factureCount: "پیش فاکتور تایید نشده",
|
factureCount: "پیش فاکتور تایید نشده",
|
||||||
orderCount: "سفارش های در حال انجام",
|
orderCount: "سفارش های در حال انجام",
|
||||||
orderDoneCount: "سفارشات انجام شده",
|
orderDoneCount: "سفارشات انجام شده",
|
||||||
services: "خدمات",
|
products: "محصولات",
|
||||||
submitNewOrder: "درخواست خودرا ثبت کنید",
|
submitNewOrder: "درخواست خودرا ثبت کنید",
|
||||||
submitNewOrderButton: "درخواست جدید",
|
submitNewOrderButton: "درخواست جدید",
|
||||||
specialSolution: "راهحل اختصاصی برای شما در نگاره!",
|
specialSolution: "راهحل اختصاصی برای شما در نگاره!",
|
||||||
@@ -47,6 +47,8 @@ export const fa = {
|
|||||||
freeConsultation: "دریافت مشاوره رایگان",
|
freeConsultation: "دریافت مشاوره رایگان",
|
||||||
contactUs: "با ما تماس بگیرید",
|
contactUs: "با ما تماس بگیرید",
|
||||||
phoneNumber: "۰۸۶۹۱۰۰۹۰۰۱",
|
phoneNumber: "۰۸۶۹۱۰۰۹۰۰۱",
|
||||||
|
noProducts: "خدماتی برای نمایش وجود ندارد.",
|
||||||
|
inProgressOrders: "سفارش های در حال انجام",
|
||||||
},
|
},
|
||||||
rial: "ریال",
|
rial: "ریال",
|
||||||
uploadBox: {
|
uploadBox: {
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { type AuthStoreType } from "../../auth/types/AuthTypes";
|
import { type AuthStoreType } from "../../auth/types/AuthTypes";
|
||||||
|
|
||||||
export const useAuthStore = create<AuthStoreType>((set) => ({
|
const initialState = {
|
||||||
phone: "",
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
stepLogin: 1,
|
||||||
|
devOtpCode: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthStoreType>((set) => ({
|
||||||
|
...initialState,
|
||||||
setPhone(value) {
|
setPhone(value) {
|
||||||
set({ phone: value });
|
set({ phone: value });
|
||||||
},
|
},
|
||||||
email: "",
|
|
||||||
setEmail(value) {
|
setEmail(value) {
|
||||||
set({ email: value });
|
set({ email: value });
|
||||||
},
|
},
|
||||||
stepLogin: 1,
|
|
||||||
setStepLogin(value) {
|
setStepLogin(value) {
|
||||||
set({ stepLogin: value });
|
set({ stepLogin: value });
|
||||||
},
|
},
|
||||||
devOtpCode: "",
|
|
||||||
setDevOtpCode(value) {
|
setDevOtpCode(value) {
|
||||||
set({ devOtpCode: value });
|
set({ devOtpCode: value });
|
||||||
},
|
},
|
||||||
|
reset() {
|
||||||
|
set({ ...initialState });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type AuthStoreType = {
|
|||||||
/** TODO: remove before production — dev-only OTP from API response */
|
/** TODO: remove before production — dev-only OTP from API response */
|
||||||
devOtpCode: string;
|
devOtpCode: string;
|
||||||
setDevOtpCode: (value: string) => void;
|
setDevOtpCode: (value: string) => void;
|
||||||
|
reset: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoginWithPasswordType = {
|
export type LoginWithPasswordType = {
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
} from 'iconsax-react'
|
} from 'iconsax-react'
|
||||||
import {
|
import {
|
||||||
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
|
useImperativeHandle,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
@@ -47,12 +49,21 @@ type PendingVoice = {
|
|||||||
status: 'uploading' | 'done' | 'error'
|
status: 'uploading' | 'done' | 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChatComposerHandle = {
|
||||||
|
getPayload: () => ChatComposerSubmitPayload
|
||||||
|
hasPendingUploads: () => boolean
|
||||||
|
hasUploadErrors: () => boolean
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void>
|
onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void>
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
submitLabel?: string
|
submitLabel?: string
|
||||||
label?: string
|
label?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
allowEmptySubmit?: boolean
|
||||||
|
showSubmitButton?: boolean
|
||||||
replyTo?: {
|
replyTo?: {
|
||||||
id: string
|
id: string
|
||||||
content: string
|
content: string
|
||||||
@@ -163,15 +174,17 @@ const VoicePreviewChip: FC<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatComposer: FC<Props> = ({
|
const ChatComposer = forwardRef<ChatComposerHandle, Props>(({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
submitLabel = 'ارسال پیام',
|
submitLabel = 'ارسال پیام',
|
||||||
label = 'پیام شما',
|
label = 'پیام شما',
|
||||||
placeholder = 'متن پیام خود را بنویسید...',
|
placeholder = 'متن پیام خود را بنویسید...',
|
||||||
|
allowEmptySubmit = false,
|
||||||
|
showSubmitButton = true,
|
||||||
replyTo = null,
|
replyTo = null,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
}) => {
|
}, ref) => {
|
||||||
const inputId = useId()
|
const inputId = useId()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
@@ -318,7 +331,7 @@ const ChatComposer: FC<Props> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const buildPayload = useCallback((): ChatComposerSubmitPayload => {
|
||||||
const uploadedFiles = pendingFiles.filter(
|
const uploadedFiles = pendingFiles.filter(
|
||||||
(item) => item.status === 'done' && item.key,
|
(item) => item.status === 'done' && item.key,
|
||||||
)
|
)
|
||||||
@@ -326,10 +339,49 @@ const ChatComposer: FC<Props> = ({
|
|||||||
(item) => item.status === 'done' && item.key,
|
(item) => item.status === 'done' && item.key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const attachments: ChatComposerAttachment[] = [
|
||||||
|
...uploadedFiles.map((item) => ({
|
||||||
|
type: 'uploads_attach',
|
||||||
|
url: item.key!,
|
||||||
|
})),
|
||||||
|
...uploadedVoices.map((item) => ({
|
||||||
|
type: 'voice',
|
||||||
|
url: item.key!,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: message.trim(),
|
||||||
|
attachments,
|
||||||
|
}
|
||||||
|
}, [message, pendingFiles, pendingVoices])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
getPayload: buildPayload,
|
||||||
|
hasPendingUploads: () => isUploading,
|
||||||
|
hasUploadErrors: () =>
|
||||||
|
pendingFiles.some((item) => item.status === 'error') ||
|
||||||
|
pendingVoices.some((item) => item.status === 'error'),
|
||||||
|
reset: resetComposer,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
buildPayload,
|
||||||
|
isUploading,
|
||||||
|
pendingFiles,
|
||||||
|
pendingVoices,
|
||||||
|
resetComposer,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const payload = buildPayload()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!message.trim() &&
|
!allowEmptySubmit &&
|
||||||
uploadedFiles.length === 0 &&
|
!payload.content &&
|
||||||
uploadedVoices.length === 0
|
payload.attachments.length === 0
|
||||||
) {
|
) {
|
||||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||||
return
|
return
|
||||||
@@ -351,22 +403,8 @@ const ChatComposer: FC<Props> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachments: ChatComposerAttachment[] = [
|
|
||||||
...uploadedFiles.map((item) => ({
|
|
||||||
type: 'uploads_attach',
|
|
||||||
url: item.key!,
|
|
||||||
})),
|
|
||||||
...uploadedVoices.map((item) => ({
|
|
||||||
type: 'voice',
|
|
||||||
url: item.key!,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit({
|
await onSubmit(payload)
|
||||||
content: message.trim(),
|
|
||||||
attachments,
|
|
||||||
})
|
|
||||||
resetComposer()
|
resetComposer()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(extractErrorMessage(error), 'error')
|
toast(extractErrorMessage(error), 'error')
|
||||||
@@ -526,16 +564,20 @@ const ChatComposer: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
{showSubmitButton && (
|
||||||
<Button
|
<div className="mt-6 flex justify-end">
|
||||||
label={submitLabel}
|
<Button
|
||||||
onClick={handleSubmit}
|
label={submitLabel}
|
||||||
className="w-[150px]"
|
onClick={handleSubmit}
|
||||||
isLoading={isBusy}
|
className="w-[150px]"
|
||||||
/>
|
isLoading={isBusy}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
ChatComposer.displayName = 'ChatComposer'
|
||||||
|
|
||||||
export default ChatComposer
|
export default ChatComposer
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getChatSenderName } from '../type/Types'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string
|
content: string
|
||||||
attachments?: ChatAttachmentType[]
|
attachments?: ChatAttachmentType[] | null
|
||||||
senderName?: string
|
senderName?: string
|
||||||
senderLabel?: string
|
senderLabel?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
@@ -25,7 +25,7 @@ type Props = {
|
|||||||
|
|
||||||
const ChatMessage: FC<Props> = ({
|
const ChatMessage: FC<Props> = ({
|
||||||
content,
|
content,
|
||||||
attachments = [],
|
attachments,
|
||||||
senderName,
|
senderName,
|
||||||
senderLabel = 'پشتیبان',
|
senderLabel = 'پشتیبان',
|
||||||
createdAt,
|
createdAt,
|
||||||
@@ -37,8 +37,9 @@ const ChatMessage: FC<Props> = ({
|
|||||||
parentSenderLabel,
|
parentSenderLabel,
|
||||||
onReply,
|
onReply,
|
||||||
}) => {
|
}) => {
|
||||||
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
|
const safeAttachments = attachments ?? []
|
||||||
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
|
const fileAttachments = safeAttachments.filter((a) => a.type !== 'voice')
|
||||||
|
const voiceAttachments = safeAttachments.filter((a) => a.type === 'voice')
|
||||||
|
|
||||||
const handleOpenLink = async (key: string) => {
|
const handleOpenLink = async (key: string) => {
|
||||||
const url = await getPresignedUrl(key)
|
const url = await getPresignedUrl(key)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export type ChatParentMessageType = {
|
|||||||
export type ChatMessageType = {
|
export type ChatMessageType = {
|
||||||
admin?: ChatParticipantType | null
|
admin?: ChatParticipantType | null
|
||||||
user?: ChatParticipantType | null
|
user?: ChatParticipantType | null
|
||||||
attachments: ChatAttachmentType[]
|
attachments?: ChatAttachmentType[] | null
|
||||||
content: string
|
content: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
+7
-71
@@ -1,84 +1,20 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import Stats from './components/Stats'
|
import HomeSidebar from './components/HomeSidebar'
|
||||||
import Services from './components/Services'
|
|
||||||
import Orders from './components/Orders'
|
import Orders from './components/Orders'
|
||||||
import NewOrderImage from '@/assets/images/new_order.png'
|
import Services from './components/Services'
|
||||||
import { t } from '@/locale'
|
import Stats from './components/Stats'
|
||||||
import Button from '@/components/Button'
|
|
||||||
import { AddSquare, MessageQuestion } from 'iconsax-react'
|
|
||||||
import { COLORS } from '@/constants/colors'
|
|
||||||
import SupportImage from '@/assets/images/support1.png'
|
|
||||||
import { Paths } from '@/config/Paths'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
const Home: FC = () => {
|
const Home: FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className='flex gap-6'>
|
<div className='flex flex-col gap-4 md:gap-6 xl:flex-row'>
|
||||||
<div className='flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<Stats />
|
<Stats />
|
||||||
<Services />
|
<Services />
|
||||||
<Orders />
|
<Orders />
|
||||||
</div>
|
</div>
|
||||||
<div className='min-w-[267px] mt-5'>
|
<HomeSidebar />
|
||||||
<div className='bg-white rounded-3xl p-6'>
|
|
||||||
<img src={NewOrderImage} className='w-[98px] mx-auto' />
|
|
||||||
<h4 className='mt-4 text-center font-medium'>
|
|
||||||
{t('home.submitNewOrder')}
|
|
||||||
</h4>
|
|
||||||
<Link to={Paths.newRequest}>
|
|
||||||
<Button
|
|
||||||
className='mt-2.5'>
|
|
||||||
<div className='flex gap-1'>
|
|
||||||
<AddSquare size={20} color='#292D32' />
|
|
||||||
<div className=''>{t('home.submitNewOrderButton')}</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-7 bg-white rounded-3xl p-6 relative'>
|
|
||||||
<h4>
|
|
||||||
{t('home.specialSolution')}
|
|
||||||
</h4>
|
|
||||||
<p className='mt-2 text-[#7B7E8B] text-xs'>
|
|
||||||
{t('home.specialSolutionDescription')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className='mt-2 flex flex-col gap-2'>
|
|
||||||
<div className='flex gap-2 items-center text-xs'>
|
|
||||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
|
||||||
<div>{t('home.specialSolutionFeature1')}</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex gap-2 items-center text-xs'>
|
|
||||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
|
||||||
<div>{t('home.specialSolutionFeature2')}</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex gap-2 items-center text-xs'>
|
|
||||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
|
||||||
<div>{t('home.specialSolutionFeature3')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
label={t('home.freeConsultation')}
|
|
||||||
className='mt-12'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='mt-2.5 bg-primary/11 py-6 px-4 rounded-[13px]'>
|
|
||||||
<div className='flex justify-between items-center'>
|
|
||||||
<div className='flex gap-2 items-center'>
|
|
||||||
<MessageQuestion size={20} color={COLORS.primary} />
|
|
||||||
<div className='text-xs font-light'>{t('home.contactUs')}</div>
|
|
||||||
</div>
|
|
||||||
<div>{t('home.phoneNumber')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img src={SupportImage} className='w-[50px] absolute bottom-[159px] left-2' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home
|
export default Home
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { clx } from '@/helpers/utils'
|
||||||
|
import type { CategoryType } from '@/pages/request/type/Types'
|
||||||
|
import { getCategoriesAtLevel } from '@/pages/request/utils/categoryUtils'
|
||||||
|
import { type FC } from 'react'
|
||||||
|
|
||||||
|
type CategoryTabsProps = {
|
||||||
|
categories: CategoryType[]
|
||||||
|
levels: number[]
|
||||||
|
selectedPath: string[]
|
||||||
|
onSelect: (level: number, categoryId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryTabs: FC<CategoryTabsProps> = ({
|
||||||
|
categories,
|
||||||
|
levels,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-3'>
|
||||||
|
{levels.map((level) => {
|
||||||
|
const items = getCategoriesAtLevel(categories, level, selectedPath)
|
||||||
|
|
||||||
|
if (!items.length) return null
|
||||||
|
|
||||||
|
const activeId = selectedPath[level] ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={level}
|
||||||
|
className='-mx-4 overflow-x-auto px-4 md:mx-0 md:px-0'
|
||||||
|
>
|
||||||
|
<div className='flex w-max gap-2 md:gap-3'>
|
||||||
|
{items.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => onSelect(level, category.id)}
|
||||||
|
className={clx(
|
||||||
|
'shrink-0 rounded-full px-4 py-2 text-xs transition-colors md:text-sm',
|
||||||
|
activeId === category.id
|
||||||
|
? 'bg-primary text-[#292D32]'
|
||||||
|
: 'bg-[#F5F5F7] text-[#7B7E8B] hover:bg-[#EBEBEF]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{category.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryTabs
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { clx } from '@/helpers/utils'
|
||||||
|
import { type FC, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
type HomeSectionProps = {
|
||||||
|
title?: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeSection: FC<HomeSectionProps> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={clx(
|
||||||
|
'rounded-2xl bg-white p-4 md:rounded-3xl md:p-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<h2 className='text-base font-light md:text-lg'>{title}</h2>
|
||||||
|
) : null}
|
||||||
|
<div className={clx(title && 'mt-4 md:mt-6', contentClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomeSection
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import NewOrderCard from './NewOrderCard'
|
||||||
|
import SpecialSolutionCard from './SpecialSolutionCard'
|
||||||
|
|
||||||
|
const HomeSidebar: FC = () => {
|
||||||
|
return (
|
||||||
|
<aside className='flex w-full shrink-0 flex-col gap-4 md:gap-6 xl:mt-5 xl:w-[267px]'>
|
||||||
|
<NewOrderCard />
|
||||||
|
<SpecialSolutionCard />
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomeSidebar
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import NewOrderImage from '@/assets/images/new_order.png'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import { Paths } from '@/config/Paths'
|
||||||
|
import { t } from '@/locale'
|
||||||
|
import { AddSquare } from 'iconsax-react'
|
||||||
|
import { type FC } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import HomeSection from './HomeSection'
|
||||||
|
|
||||||
|
const NewOrderCard: FC = () => {
|
||||||
|
return (
|
||||||
|
<HomeSection contentClassName='flex flex-col items-center'>
|
||||||
|
<img
|
||||||
|
src={NewOrderImage}
|
||||||
|
alt=''
|
||||||
|
className='mx-auto w-20 md:w-[98px]'
|
||||||
|
/>
|
||||||
|
<h3 className='mt-3 text-center text-sm font-medium md:mt-4 md:text-base'>
|
||||||
|
{t('home.submitNewOrder')}
|
||||||
|
</h3>
|
||||||
|
<Link to={Paths.newRequest} className='mt-2.5 w-full'>
|
||||||
|
<Button>
|
||||||
|
<span className='flex items-center justify-center gap-1'>
|
||||||
|
<AddSquare size={20} color='#292D32' />
|
||||||
|
{t('home.submitNewOrderButton')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</HomeSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewOrderCard
|
||||||
@@ -1,83 +1,30 @@
|
|||||||
import Table from '@/components/Table'
|
import Table from '@/components/Table'
|
||||||
import { type FC } from 'react'
|
import { t } from '@/locale'
|
||||||
import { type ColumnType } from '@/components/types/TableTypes'
|
|
||||||
import { type Order } from './types/OrderTypes'
|
|
||||||
import { useGetMyOrders } from '@/pages/order/hooks/useOrderData'
|
|
||||||
import { TabMyOrdersEnum } from '@/pages/order/enum/OrderEnum'
|
import { TabMyOrdersEnum } from '@/pages/order/enum/OrderEnum'
|
||||||
import { orderStatusLabels } from '@/pages/order/constants/statusLabels'
|
import { useGetMyOrders } from '@/pages/order/hooks/useOrderData'
|
||||||
import moment from 'moment-jalaali'
|
import { type FC } from 'react'
|
||||||
import {
|
import HomeSection from './HomeSection'
|
||||||
OrderNumberRenderer,
|
import { mapOrderToRow, orderColumns } from './orderColumns'
|
||||||
OrderTitleRenderer,
|
import { type Order } from './types/OrderTypes'
|
||||||
OrderDateRenderer,
|
|
||||||
OrderStatusRenderer,
|
|
||||||
OrderMessagesRenderer,
|
|
||||||
OrderViewRenderer
|
|
||||||
} from './OrderTableComponents'
|
|
||||||
import type { OrderType } from '@/pages/order/type/Types'
|
|
||||||
|
|
||||||
const mapOrderToRow = (order: OrderType): Order => ({
|
|
||||||
id: order.id,
|
|
||||||
number: String(order.orderNumber ?? order.id),
|
|
||||||
title: order.title,
|
|
||||||
creationDate: {
|
|
||||||
gregorian: moment(order.createdAt).format('YYYY-MM-DD'),
|
|
||||||
persian: moment(order.createdAt).format('jYYYY/jMM/jDD')
|
|
||||||
},
|
|
||||||
status: orderStatusLabels[order.status] ?? order.status,
|
|
||||||
unreadMessages: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const Orders: FC = () => {
|
const Orders: FC = () => {
|
||||||
const { data, isLoading } = useGetMyOrders(1, TabMyOrdersEnum.IN_PROGRESS)
|
const { data, isLoading } = useGetMyOrders(1, TabMyOrdersEnum.IN_PROGRESS)
|
||||||
const orders: Order[] = (data?.data ?? []).map(mapOrderToRow)
|
const orders: Order[] = (data?.data ?? []).map(mapOrderToRow)
|
||||||
|
|
||||||
const columns: ColumnType<Order>[] = [
|
|
||||||
{
|
|
||||||
title: 'شماره',
|
|
||||||
key: 'number',
|
|
||||||
render: (order) => <OrderNumberRenderer order={order} />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'عنوان سفارش',
|
|
||||||
key: 'title',
|
|
||||||
render: (order) => <OrderTitleRenderer order={order} />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'تاریخ ایجاد',
|
|
||||||
key: 'creationDate',
|
|
||||||
render: (order) => <OrderDateRenderer order={order} />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'وضعیت سفارش',
|
|
||||||
key: 'status',
|
|
||||||
render: (order) => <OrderStatusRenderer order={order} />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'پیام ها',
|
|
||||||
key: 'unreadMessages',
|
|
||||||
render: (order) => <OrderMessagesRenderer order={order} />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'actions',
|
|
||||||
render: (order) => <OrderViewRenderer order={order} />,
|
|
||||||
width: '60px'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-6 bg-white p-6 rounded-3xl'>
|
<HomeSection
|
||||||
<h4 className='text-lg font-light'>
|
title={t('home.inProgressOrders')}
|
||||||
سفارش های در حال انجام
|
className='mt-4 md:mt-6'
|
||||||
</h4>
|
contentClassName='mt-0 -mx-2 md:mx-0'
|
||||||
|
>
|
||||||
<Table<Order>
|
<div className='mt-4 overflow-x-auto md:mt-6'>
|
||||||
columns={columns}
|
<Table<Order>
|
||||||
data={orders}
|
columns={orderColumns}
|
||||||
isLoading={isLoading}
|
data={orders}
|
||||||
/>
|
isLoading={isLoading}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</HomeSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { type FC } from 'react'
|
|
||||||
import { type ProductType } from '@/pages/request/type/Types'
|
|
||||||
import PresignedImage from '@/components/PresignedImage'
|
import PresignedImage from '@/components/PresignedImage'
|
||||||
|
import { type ProductType } from '@/pages/request/type/Types'
|
||||||
|
import { type FC } from 'react'
|
||||||
|
|
||||||
const ServiceItem: FC<{ product: ProductType }> = ({ product }) => {
|
const ServiceItem: FC<{ product: ProductType }> = ({ product }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className='flex w-[88px] shrink-0 snap-start flex-col items-center gap-2'>
|
||||||
<div className='size-20 rounded-full bg-gray-200 border-[3px] border-primary overflow-hidden'>
|
<div className='size-20 overflow-hidden rounded-full border-[3px] border-primary bg-gray-200'>
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<PresignedImage
|
<PresignedImage
|
||||||
src={product.images[0]}
|
src={product.images[0]}
|
||||||
alt={product.title}
|
alt={product.title}
|
||||||
className='w-full h-full object-cover'
|
className='h-full w-full object-cover'
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<span className='line-clamp-2 w-full text-center text-xs font-light text-[#292D32]'>
|
||||||
|
{product.title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,121 @@
|
|||||||
import { t } from '@/locale'
|
import { t } from '@/locale'
|
||||||
import { type FC } from 'react'
|
import {
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
useGetCategories,
|
||||||
|
useGetProducts,
|
||||||
|
} from '@/pages/request/hooks/useRequestData'
|
||||||
|
import type { CategoryType } from '@/pages/request/type/Types'
|
||||||
|
import {
|
||||||
|
buildPathToFirstLeaf,
|
||||||
|
extractList,
|
||||||
|
getLevelsToShow,
|
||||||
|
} from '@/pages/request/utils/categoryUtils'
|
||||||
|
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||||
|
import CategoryTabs from './CategoryTabs'
|
||||||
|
import HomeSection from './HomeSection'
|
||||||
import ServiceItem from './ServiceItem'
|
import ServiceItem from './ServiceItem'
|
||||||
import { useGetProducts } from '@/pages/request/hooks/useRequestData'
|
|
||||||
|
|
||||||
|
const SKELETON_COUNT = 6
|
||||||
|
|
||||||
|
const CategoryTabsSkeleton: FC = () => (
|
||||||
|
<div className='flex gap-2 overflow-hidden'>
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='h-9 w-24 shrink-0 animate-pulse rounded-full bg-gray-100'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ServicesSkeleton: FC = () => (
|
||||||
|
<div className='flex w-max gap-4'>
|
||||||
|
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='flex w-[88px] shrink-0 flex-col items-center gap-2'
|
||||||
|
>
|
||||||
|
<div className='size-20 animate-pulse rounded-full bg-gray-200' />
|
||||||
|
<div className='h-8 w-full animate-pulse rounded bg-gray-100' />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const Services: FC = () => {
|
const Services: FC = () => {
|
||||||
const { data } = useGetProducts();
|
const { data: categoriesData, isLoading: isCategoriesLoading } =
|
||||||
|
useGetCategories()
|
||||||
|
const categories = useMemo(
|
||||||
|
() => extractList<CategoryType>(categoriesData),
|
||||||
|
[categoriesData],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!categories.length || selectedPath.length) return
|
||||||
|
|
||||||
|
setSelectedPath(
|
||||||
|
buildPathToFirstLeaf(categories, categories[0].id),
|
||||||
|
)
|
||||||
|
}, [categories, selectedPath.length])
|
||||||
|
|
||||||
|
const levelsToShow = useMemo(
|
||||||
|
() => getLevelsToShow(categories, selectedPath),
|
||||||
|
[categories, selectedPath],
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeCategoryId = selectedPath.at(-1) ?? ''
|
||||||
|
|
||||||
|
const { data: productsData, isLoading: isProductsLoading } = useGetProducts(
|
||||||
|
activeCategoryId || undefined,
|
||||||
|
{ enabled: !!activeCategoryId },
|
||||||
|
)
|
||||||
|
const products = productsData?.data ?? []
|
||||||
|
|
||||||
|
const handleCategorySelect = (level: number, categoryId: string) => {
|
||||||
|
setSelectedPath(
|
||||||
|
buildPathToFirstLeaf(
|
||||||
|
categories,
|
||||||
|
categoryId,
|
||||||
|
selectedPath.slice(0, level),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = isCategoriesLoading || isProductsLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-6 bg-white p-6 rounded-3xl'>
|
<HomeSection title={t('home.products')} className='mt-4 md:mt-6' contentClassName='mt-0'>
|
||||||
<h4 className='text-lg font-light'>
|
<div className='mt-4 space-y-4 md:mt-6 md:space-y-6'>
|
||||||
{t('home.services')}
|
{isCategoriesLoading ? (
|
||||||
</h4>
|
<CategoryTabsSkeleton />
|
||||||
|
) : categories.length > 0 ? (
|
||||||
|
<CategoryTabs
|
||||||
|
categories={categories}
|
||||||
|
levels={levelsToShow}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={handleCategorySelect}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className='mt-6'>
|
<div className='-mx-4 overflow-x-auto px-4 pb-1 md:mx-0 md:px-0'>
|
||||||
<Swiper
|
{isLoading ? (
|
||||||
spaceBetween={16}
|
<ServicesSkeleton />
|
||||||
slidesPerView={'auto'}
|
) : products.length === 0 ? (
|
||||||
className='h-full'
|
<p className='text-sm font-light text-[#7B7E8B]'>
|
||||||
breakpoints={{
|
{t('home.noProducts')}
|
||||||
640: {
|
</p>
|
||||||
spaceBetween: 24,
|
) : (
|
||||||
},
|
<div className='flex w-max snap-x snap-mandatory gap-4 md:gap-6'>
|
||||||
}}
|
{products.map((item) => (
|
||||||
>
|
<ServiceItem key={item.id} product={item} />
|
||||||
{
|
))}
|
||||||
data?.data.map((item) => (
|
</div>
|
||||||
<SwiperSlide className='w-auto!' key={item.id}>
|
)}
|
||||||
<ServiceItem product={item} />
|
</div>
|
||||||
</SwiperSlide>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Swiper>
|
|
||||||
</div>
|
</div>
|
||||||
|
</HomeSection>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Services
|
export default Services
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import SupportImage from '@/assets/images/support1.png'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import { COLORS } from '@/constants/colors'
|
||||||
|
import { t } from '@/locale'
|
||||||
|
import { MessageQuestion } from 'iconsax-react'
|
||||||
|
import { type FC } from 'react'
|
||||||
|
import HomeSection from './HomeSection'
|
||||||
|
|
||||||
|
const FEATURE_KEYS = [
|
||||||
|
'home.specialSolutionFeature1',
|
||||||
|
'home.specialSolutionFeature2',
|
||||||
|
'home.specialSolutionFeature3',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const SpecialSolutionCard: FC = () => {
|
||||||
|
return (
|
||||||
|
<HomeSection>
|
||||||
|
<h3 className='text-sm font-medium md:text-base'>
|
||||||
|
{t('home.specialSolution')}
|
||||||
|
</h3>
|
||||||
|
<p className='mt-2 text-xs text-[#7B7E8B]'>
|
||||||
|
{t('home.specialSolutionDescription')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className='mt-3 flex flex-col gap-2'>
|
||||||
|
{FEATURE_KEYS.map((key) => (
|
||||||
|
<li key={key} className='flex items-center gap-2 text-xs'>
|
||||||
|
<span className='size-1.5 shrink-0 rounded-full bg-primary' />
|
||||||
|
{t(key)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={t('home.freeConsultation')}
|
||||||
|
className='mt-8 md:mt-12'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='mt-3 flex items-end gap-3 md:mt-4'>
|
||||||
|
<img
|
||||||
|
src={SupportImage}
|
||||||
|
alt=''
|
||||||
|
className='w-10 shrink-0 md:w-[50px]'
|
||||||
|
/>
|
||||||
|
<div className='flex-1 rounded-[13px] bg-primary/11 px-4 py-4 md:py-6'>
|
||||||
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<MessageQuestion size={20} color={COLORS.primary} />
|
||||||
|
<span className='text-xs font-light'>
|
||||||
|
{t('home.contactUs')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className='text-sm font-medium' dir='ltr'>
|
||||||
|
{t('home.phoneNumber')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HomeSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpecialSolutionCard
|
||||||
@@ -4,34 +4,35 @@ import { type FC, type ReactNode } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon: ReactNode,
|
icon: ReactNode
|
||||||
count: number | undefined,
|
count: number | undefined
|
||||||
description: string,
|
description: string
|
||||||
to: string,
|
to: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatCard: FC<Props> = (props) => {
|
const StatCard: FC<Props> = ({ icon, count, description, to }) => {
|
||||||
const { icon, count, description, to } = props
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
className='flex-1 bg-white rounded-3xl p-6 block transition-shadow hover:shadow-md'
|
className='block flex-1 rounded-2xl bg-white p-4 transition-shadow hover:shadow-md md:rounded-3xl md:p-6'
|
||||||
>
|
>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex items-center justify-between'>
|
||||||
{icon}
|
{icon}
|
||||||
|
|
||||||
<div className='size-8 bg-[#FFF1D7] rounded-full flex justify-center items-center'>
|
<div className='flex size-7 items-center justify-center rounded-full bg-[#FFF1D7] md:size-8'>
|
||||||
<ArrowLeft size={16} color={COLORS.primary} className='rotate-45' />
|
<ArrowLeft
|
||||||
|
size={16}
|
||||||
|
color={COLORS.primary}
|
||||||
|
className='rotate-45'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-2 text-2xl font-semibold'>
|
<div className='mt-2 text-xl font-semibold md:text-2xl'>
|
||||||
{count}
|
{count ?? 0}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-2 text-sm'>
|
<div className='mt-1 text-xs md:mt-2 md:text-sm'>{description}</div>
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Stats: FC = () => {
|
|||||||
mobile={2}
|
mobile={2}
|
||||||
gapDesktop={24}
|
gapDesktop={24}
|
||||||
gapMobile={12}
|
gapMobile={12}
|
||||||
className='mt-5'
|
className='mt-4 md:mt-5'
|
||||||
>
|
>
|
||||||
<StatCard
|
<StatCard
|
||||||
count={data?.data.requestsCount}
|
count={data?.data.requestsCount}
|
||||||
@@ -33,7 +33,7 @@ const Stats: FC = () => {
|
|||||||
<StatCard
|
<StatCard
|
||||||
count={data?.data.invoicesCount}
|
count={data?.data.invoicesCount}
|
||||||
description={t('home.factureCount')}
|
description={t('home.factureCount')}
|
||||||
to={`${Paths.proformaInvoice}?tab=${ProformaInvoiceStatusEnum.NOT_CONFIRMED}`}
|
to={`${Paths.proformaInvoice}?tab=${ProformaInvoiceStatusEnum.PENDING}`}
|
||||||
icon={<ReceiptText
|
icon={<ReceiptText
|
||||||
size={27}
|
size={27}
|
||||||
color={COLORS.primary}
|
color={COLORS.primary}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { type ColumnType } from '@/components/types/TableTypes'
|
||||||
|
import moment from 'moment-jalaali'
|
||||||
|
import { orderStatusLabels } from '@/pages/order/constants/statusLabels'
|
||||||
|
import type { OrderType } from '@/pages/order/type/Types'
|
||||||
|
import {
|
||||||
|
OrderDateRenderer,
|
||||||
|
OrderMessagesRenderer,
|
||||||
|
OrderNumberRenderer,
|
||||||
|
OrderStatusRenderer,
|
||||||
|
OrderTitleRenderer,
|
||||||
|
OrderViewRenderer,
|
||||||
|
} from './OrderTableComponents'
|
||||||
|
import { type Order } from './types/OrderTypes'
|
||||||
|
|
||||||
|
export const mapOrderToRow = (order: OrderType): Order => ({
|
||||||
|
id: order.id,
|
||||||
|
number: String(order.orderNumber ?? order.id),
|
||||||
|
title: order.title,
|
||||||
|
creationDate: {
|
||||||
|
gregorian: moment(order.createdAt).format('YYYY-MM-DD'),
|
||||||
|
persian: moment(order.createdAt).format('jYYYY/jMM/jDD'),
|
||||||
|
},
|
||||||
|
status: orderStatusLabels[order.status] ?? order.status,
|
||||||
|
unreadMessages: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const orderColumns: ColumnType<Order>[] = [
|
||||||
|
{
|
||||||
|
title: 'شماره',
|
||||||
|
key: 'number',
|
||||||
|
render: (order) => <OrderNumberRenderer order={order} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'عنوان سفارش',
|
||||||
|
key: 'title',
|
||||||
|
render: (order) => <OrderTitleRenderer order={order} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تاریخ ایجاد',
|
||||||
|
key: 'creationDate',
|
||||||
|
render: (order) => <OrderDateRenderer order={order} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'وضعیت سفارش',
|
||||||
|
key: 'status',
|
||||||
|
render: (order) => <OrderStatusRenderer order={order} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'پیام ها',
|
||||||
|
key: 'unreadMessages',
|
||||||
|
render: (order) => <OrderMessagesRenderer order={order} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
render: (order) => <OrderViewRenderer order={order} />,
|
||||||
|
width: '60px',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import Tabs from '@/components/Tabs'
|
import Tabs from '@/components/Tabs'
|
||||||
import { useMemo, useState, type FC } from 'react'
|
import { useMemo, useState, type FC } from 'react'
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { ProformaInvoiceStatusEnum } from './enum/InvoiceEnum'
|
import {
|
||||||
|
invoiceListTabs,
|
||||||
|
invoiceListTabStatuses,
|
||||||
|
ProformaInvoiceStatusEnum,
|
||||||
|
} from './enum/InvoiceEnum'
|
||||||
import Filters from '@/components/Filters'
|
import Filters from '@/components/Filters'
|
||||||
import Table from '@/components/Table'
|
import Table from '@/components/Table'
|
||||||
import { useGetInvoices } from './hooks/useInvoiceData'
|
import { useGetInvoices, useGetInvoiceTabCounts } from './hooks/useInvoiceData'
|
||||||
import type { Invoice } from './types/InvoiceTypes'
|
import type { Invoice } from './types/InvoiceTypes'
|
||||||
import type { ColumnType } from '@/components/types/TableTypes'
|
import type { ColumnType } from '@/components/types/TableTypes'
|
||||||
import type { FilterValues } from '@/components/Filters'
|
import type { FilterValues } from '@/components/Filters'
|
||||||
@@ -14,11 +18,10 @@ import moment from 'moment-jalaali'
|
|||||||
import StatusWithText from '@/components/StatusWithText'
|
import StatusWithText from '@/components/StatusWithText'
|
||||||
import { Eye } from 'iconsax-react'
|
import { Eye } from 'iconsax-react'
|
||||||
|
|
||||||
|
const INVOICE_TABS = invoiceListTabs.map((tab) => tab.value)
|
||||||
|
|
||||||
const isInvoiceTab = (value: string | null): value is ProformaInvoiceStatusEnum =>
|
const isInvoiceTab = (value: string | null): value is ProformaInvoiceStatusEnum =>
|
||||||
value === ProformaInvoiceStatusEnum.ALL ||
|
value !== null && INVOICE_TABS.includes(value as ProformaInvoiceStatusEnum)
|
||||||
value === ProformaInvoiceStatusEnum.NOT_CONFIRMED ||
|
|
||||||
value === ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED ||
|
|
||||||
value === ProformaInvoiceStatusEnum.CONFIRMED
|
|
||||||
|
|
||||||
const getServiceNames = (invoice: Invoice): string => {
|
const getServiceNames = (invoice: Invoice): string => {
|
||||||
const names = [...new Set(invoice.items.map((item) => item.product.title))]
|
const names = [...new Set(invoice.items.map((item) => item.product.title))]
|
||||||
@@ -27,29 +30,53 @@ const getServiceNames = (invoice: Invoice): string => {
|
|||||||
|
|
||||||
const ProformaInvoice: FC = () => {
|
const ProformaInvoice: FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const tabFromQuery = searchParams.get('tab')
|
const tabFromQuery = searchParams.get('tab')
|
||||||
const [activeTab, setActiveTab] = useState<ProformaInvoiceStatusEnum>(
|
const activeTab = isInvoiceTab(tabFromQuery)
|
||||||
isInvoiceTab(tabFromQuery) ? tabFromQuery : ProformaInvoiceStatusEnum.ALL,
|
? tabFromQuery
|
||||||
)
|
: ProformaInvoiceStatusEnum.PENDING
|
||||||
const [filters, setFilters] = useState<FilterValues>({})
|
const [filters, setFilters] = useState<FilterValues>({})
|
||||||
|
|
||||||
const { data, isPending } = useGetInvoices()
|
const statuses = invoiceListTabStatuses[activeTab]
|
||||||
|
|
||||||
|
const setActiveTab = (tab: ProformaInvoiceStatusEnum) => {
|
||||||
|
setSearchParams(
|
||||||
|
(current) => {
|
||||||
|
const next = new URLSearchParams(current)
|
||||||
|
next.set('tab', tab)
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isPending } = useGetInvoices({ statuses })
|
||||||
|
const { data: tabCounts } = useGetInvoiceTabCounts()
|
||||||
const invoices = useMemo(() => data?.data ?? [], [data?.data])
|
const invoices = useMemo(() => data?.data ?? [], [data?.data])
|
||||||
|
|
||||||
|
const tabsWithCount = useMemo(
|
||||||
|
() =>
|
||||||
|
invoiceListTabs.map((tab) => {
|
||||||
|
const total =
|
||||||
|
tab.value === ProformaInvoiceStatusEnum.PENDING
|
||||||
|
? (tabCounts?.pending ?? 0)
|
||||||
|
: tab.value === ProformaInvoiceStatusEnum.CONFIRMED
|
||||||
|
? (tabCounts?.confirmed ?? 0)
|
||||||
|
: (tabCounts?.archived ?? 0)
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
label:
|
||||||
|
total > 0
|
||||||
|
? `${tab.label} (${total.toLocaleString('fa-IR')})`
|
||||||
|
: tab.label,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[tabCounts],
|
||||||
|
)
|
||||||
|
|
||||||
const filteredInvoices = useMemo(() => {
|
const filteredInvoices = useMemo(() => {
|
||||||
let result = invoices
|
let result = invoices
|
||||||
|
|
||||||
// فیلتر تب
|
|
||||||
if (activeTab === ProformaInvoiceStatusEnum.NOT_CONFIRMED) {
|
|
||||||
result = result.filter((inv) => inv.confirmStatus === 'pending')
|
|
||||||
} else if (activeTab === ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED) {
|
|
||||||
result = result.filter((inv) => inv.confirmStatus === 'partially_confirmed')
|
|
||||||
} else if (activeTab === ProformaInvoiceStatusEnum.CONFIRMED) {
|
|
||||||
result = result.filter((inv) => inv.confirmStatus === 'confirmed')
|
|
||||||
}
|
|
||||||
|
|
||||||
// فیلتر جستجو
|
|
||||||
const search = filters.search?.toString().trim()
|
const search = filters.search?.toString().trim()
|
||||||
if (search) {
|
if (search) {
|
||||||
const lower = search.toLowerCase()
|
const lower = search.toLowerCase()
|
||||||
@@ -62,7 +89,6 @@ const ProformaInvoice: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// فیلتر تاریخ
|
|
||||||
const dateFilter = filters.date?.toString()
|
const dateFilter = filters.date?.toString()
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
result = result.filter((inv) => {
|
result = result.filter((inv) => {
|
||||||
@@ -72,7 +98,7 @@ const ProformaInvoice: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}, [invoices, activeTab, filters])
|
}, [invoices, filters])
|
||||||
|
|
||||||
const columns: ColumnType<Invoice>[] = [
|
const columns: ColumnType<Invoice>[] = [
|
||||||
{
|
{
|
||||||
@@ -101,15 +127,16 @@ const ProformaInvoice: FC = () => {
|
|||||||
render: (item) => <span>{NumberFormat(item.total)} ریال</span>,
|
render: (item) => <span>{NumberFormat(item.total)} ریال</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'confirmStatus',
|
key: 'status',
|
||||||
title: 'وضعیت تایید',
|
title: 'وضعیت تایید',
|
||||||
render: (item) => {
|
render: (item) => {
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
confirmed: { variant: 'success' as const, text: 'تایید شده' },
|
confirmed: { variant: 'success' as const, text: 'تایید شده' },
|
||||||
partially_confirmed: { variant: 'info' as const, text: 'تایید جزئی' },
|
partially_confirmed: { variant: 'info' as const, text: 'تایید جزئی' },
|
||||||
pending: { variant: 'warning' as const, text: 'تایید نشده' },
|
pending: { variant: 'warning' as const, text: 'در انتظار تایید' },
|
||||||
|
archived: { variant: 'info' as const, text: 'آرشیو شده' },
|
||||||
}
|
}
|
||||||
const status = statusMap[item.confirmStatus] ?? statusMap.pending
|
const status = statusMap[item.status] ?? statusMap.pending
|
||||||
return (
|
return (
|
||||||
<StatusWithText
|
<StatusWithText
|
||||||
variant={status.variant}
|
variant={status.variant}
|
||||||
@@ -118,19 +145,6 @@ const ProformaInvoice: FC = () => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'paymentStatus',
|
|
||||||
title: 'وضعیت پرداخت',
|
|
||||||
render: (item) => {
|
|
||||||
const isPaid = item.paidAmount >= item.total && item.total > 0
|
|
||||||
return (
|
|
||||||
<StatusWithText
|
|
||||||
variant={isPaid ? 'success' : 'info'}
|
|
||||||
text={isPaid ? 'پرداخت شده' : 'پرداخت نشده'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'action',
|
key: 'action',
|
||||||
title: '',
|
title: '',
|
||||||
@@ -157,12 +171,7 @@ const ProformaInvoice: FC = () => {
|
|||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={tabsWithCount}
|
||||||
{ label: 'همه', value: ProformaInvoiceStatusEnum.ALL },
|
|
||||||
{ label: 'تایید نشده', value: ProformaInvoiceStatusEnum.NOT_CONFIRMED },
|
|
||||||
{ label: 'تایید جزئی', value: ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED },
|
|
||||||
{ label: 'تایید شده', value: ProformaInvoiceStatusEnum.CONFIRMED },
|
|
||||||
]}
|
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)}
|
onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
export const enum ProformaInvoiceStatusEnum {
|
export const enum ProformaInvoiceStatusEnum {
|
||||||
ALL = 'all',
|
PENDING = 'pending',
|
||||||
NOT_CONFIRMED = 'not_confirmed',
|
|
||||||
PARTIALLY_CONFIRMED = 'partially_confirmed',
|
|
||||||
CONFIRMED = 'confirmed',
|
CONFIRMED = 'confirmed',
|
||||||
}
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceConfirmStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'partially_confirmed'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'archived'
|
||||||
|
|
||||||
|
export const invoiceListTabStatuses: Record<
|
||||||
|
ProformaInvoiceStatusEnum,
|
||||||
|
InvoiceConfirmStatus[]
|
||||||
|
> = {
|
||||||
|
[ProformaInvoiceStatusEnum.PENDING]: ['pending'],
|
||||||
|
[ProformaInvoiceStatusEnum.CONFIRMED]: ['partially_confirmed', 'confirmed'],
|
||||||
|
[ProformaInvoiceStatusEnum.ARCHIVED]: ['archived'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invoiceListTabs = [
|
||||||
|
{ label: 'در انتظار تایید', value: ProformaInvoiceStatusEnum.PENDING },
|
||||||
|
{ label: 'تایید شده', value: ProformaInvoiceStatusEnum.CONFIRMED },
|
||||||
|
{ label: 'آرشیو شده', value: ProformaInvoiceStatusEnum.ARCHIVED },
|
||||||
|
] as const
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as api from "../service/InvoiceService";
|
import * as api from "../service/InvoiceService";
|
||||||
|
|
||||||
export const useGetInvoices = () => {
|
export const useGetInvoices = (params: api.GetInvoicesParams) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["invoices"],
|
queryKey: ["invoices", params.statuses?.join(",") ?? ""],
|
||||||
queryFn: api.getInvoices,
|
queryFn: () => api.getInvoices(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetInvoiceTabCounts = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["invoice-tab-counts"],
|
||||||
|
queryFn: () => api.getInvoiceTabCounts(),
|
||||||
|
refetchInterval: 20_000,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,7 +25,14 @@ export const useGetInvoiceDetail = (id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useConfirmInvoiceItem = () => {
|
export const useConfirmInvoiceItem = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: api.confrimInvoiceItem,
|
mutationFn: api.confrimInvoiceItem,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoice"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,38 @@ import type {
|
|||||||
InvoicesResponseType,
|
InvoicesResponseType,
|
||||||
InvoiceDetailResponseType,
|
InvoiceDetailResponseType,
|
||||||
} from "../types/InvoiceTypes";
|
} from "../types/InvoiceTypes";
|
||||||
|
import type { InvoiceConfirmStatus } from "../enum/InvoiceEnum";
|
||||||
|
import type { BaseResponse } from "@/shared/types/Types";
|
||||||
|
|
||||||
export const getInvoices = async (): Promise<InvoicesResponseType> => {
|
export type GetInvoicesParams = {
|
||||||
const { data } = await axios.get<InvoicesResponseType>("/public/invoice");
|
statuses?: InvoiceConfirmStatus[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvoiceTabCountsResponseType = {
|
||||||
|
pending: number;
|
||||||
|
confirmed: number;
|
||||||
|
archived: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInvoices = async (
|
||||||
|
params: GetInvoicesParams = {},
|
||||||
|
): Promise<InvoicesResponseType> => {
|
||||||
|
const { statuses } = params;
|
||||||
|
const { data } = await axios.get<InvoicesResponseType>("/public/invoice", {
|
||||||
|
params: {
|
||||||
|
...(statuses?.length ? { statuses: statuses.join(",") } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getInvoiceTabCounts = async (): Promise<InvoiceTabCountsResponseType> => {
|
||||||
|
const { data } = await axios.get<BaseResponse<InvoiceTabCountsResponseType>>(
|
||||||
|
"/public/invoice/tab-counts",
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const getInvoiceDetail = async (
|
export const getInvoiceDetail = async (
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<InvoiceDetailResponseType> => {
|
): Promise<InvoiceDetailResponseType> => {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export type Invoice = {
|
|||||||
total: number;
|
total: number;
|
||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
confirmStatus: 'pending' | 'partially_confirmed' | 'confirmed';
|
status: 'pending' | 'partially_confirmed' | 'confirmed' | 'archived';
|
||||||
enableTax: boolean;
|
enableTax: boolean;
|
||||||
approvalDeadline: string;
|
approvalDeadline: string;
|
||||||
attachments: unknown[];
|
attachments: unknown[];
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const MyOrders: FC = () => {
|
|||||||
items={[
|
items={[
|
||||||
{ label: 'همه', value: TabMyOrdersEnum.ALL },
|
{ label: 'همه', value: TabMyOrdersEnum.ALL },
|
||||||
{ label: 'در حال انجام', value: TabMyOrdersEnum.IN_PROGRESS },
|
{ label: 'در حال انجام', value: TabMyOrdersEnum.IN_PROGRESS },
|
||||||
{ label: 'تکمیل داده شده', value: TabMyOrdersEnum.DELIVERED },
|
{ label: 'تکمیل شده', value: TabMyOrdersEnum.DELIVERED },
|
||||||
{ label: 'کنسل شده', value: TabMyOrdersEnum.CANCELLED },
|
{ label: 'کنسل شده', value: TabMyOrdersEnum.CANCELLED },
|
||||||
]}
|
]}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, type FC } from 'react'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import Textarea from '@/components/Textarea'
|
||||||
|
import UploadBox from '@/components/UploadBox'
|
||||||
|
import PresignedImage from '@/components/PresignedImage'
|
||||||
|
import { useFormik } from 'formik'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import Error from '@/components/Error'
|
||||||
|
import { useGetMe, useUpdateProfile } from '@/pages/user/hooks/useUserData'
|
||||||
|
import { useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||||
|
import { toast } from '@/shared/toast'
|
||||||
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
import type { UpdateProfileType } from '@/pages/user/types/Types'
|
||||||
|
import { Edit } from 'iconsax-react'
|
||||||
|
|
||||||
|
const Profile: FC = () => {
|
||||||
|
const { data: user, isLoading } = useGetMe()
|
||||||
|
const updateProfile = useUpdateProfile()
|
||||||
|
const { mutate: upload, isPending: isUploading } = useSingleUpload()
|
||||||
|
const [file, setFile] = useState<File>()
|
||||||
|
|
||||||
|
const handleSave = (values: UpdateProfileType) => {
|
||||||
|
updateProfile.mutate(
|
||||||
|
{
|
||||||
|
firstName: values.firstName.trim(),
|
||||||
|
lastName: values.lastName.trim(),
|
||||||
|
address: values.address?.trim() || undefined,
|
||||||
|
...(values.avatarUrl ? { avatarUrl: values.avatarUrl } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
toast('پروفایل با موفقیت ویرایش شد', 'success')
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast(extractErrorMessage(error), 'error')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formik = useFormik<UpdateProfileType>({
|
||||||
|
enableReinitialize: true,
|
||||||
|
initialValues: {
|
||||||
|
firstName: user?.firstName ?? '',
|
||||||
|
lastName: user?.lastName ?? '',
|
||||||
|
address: user?.addresse ?? '',
|
||||||
|
avatarUrl: user?.avatarUrl ?? undefined,
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object({
|
||||||
|
firstName: Yup.string()
|
||||||
|
.trim()
|
||||||
|
.required('نام اجباری است.'),
|
||||||
|
lastName: Yup.string()
|
||||||
|
.trim()
|
||||||
|
.required('نام خانوادگی اجباری است.'),
|
||||||
|
}),
|
||||||
|
onSubmit(values) {
|
||||||
|
if (file) {
|
||||||
|
upload(file, {
|
||||||
|
onSuccess(data) {
|
||||||
|
handleSave({
|
||||||
|
...values,
|
||||||
|
avatarUrl: data?.data?.key,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast(extractErrorMessage(error), 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
handleSave(values)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className='mt-5 flex h-64 items-center justify-center'>
|
||||||
|
<div className='text-lg'>در حال بارگذاری...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-5'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h1 className='text-lg font-light'>پروفایل</h1>
|
||||||
|
<Button
|
||||||
|
className='w-fit px-6'
|
||||||
|
onClick={() => formik.handleSubmit()}
|
||||||
|
isLoading={updateProfile.isPending || isUploading}
|
||||||
|
>
|
||||||
|
<div className='flex gap-1.5'>
|
||||||
|
<Edit size={18} color='black' />
|
||||||
|
<div className='text-[13px] font-light'>ذخیره تغییرات</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-8 flex-1 rounded-3xl bg-white p-6'>
|
||||||
|
{formik.values.avatarUrl && !file && (
|
||||||
|
<PresignedImage
|
||||||
|
src={formik.values.avatarUrl}
|
||||||
|
className='mb-4 h-20 w-20 rounded-full object-cover'
|
||||||
|
alt='آواتار'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<UploadBox
|
||||||
|
label='آواتار (اختیاری)'
|
||||||
|
onChange={(files) => setFile(files[0])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='rowTwoInput mt-6'>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label='نام'
|
||||||
|
placeholder='نام خود را وارد کنید'
|
||||||
|
name='firstName'
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
value={formik.values.firstName}
|
||||||
|
/>
|
||||||
|
{formik.touched.firstName && formik.errors.firstName && (
|
||||||
|
<Error errorText={formik.errors.firstName} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label='نام خانوادگی'
|
||||||
|
placeholder='نام خانوادگی خود را وارد کنید'
|
||||||
|
name='lastName'
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
value={formik.values.lastName}
|
||||||
|
/>
|
||||||
|
{formik.touched.lastName && formik.errors.lastName && (
|
||||||
|
<Error errorText={formik.errors.lastName} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<Input
|
||||||
|
label='شماره موبایل'
|
||||||
|
value={user?.phone ? `0${user.phone}` : ''}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<Textarea
|
||||||
|
label='آدرس'
|
||||||
|
placeholder='آدرس خود را وارد کنید'
|
||||||
|
name='address'
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
value={formik.values.address ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import axios from "@/config/axios";
|
|
||||||
|
|
||||||
export interface ProfileData {
|
|
||||||
user: {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
profilePic?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useGetProfile = () => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["profile"],
|
|
||||||
queryFn: async (): Promise<{ data: ProfileData }> => {
|
|
||||||
const response = await axios.get("/profile");
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type FC } from 'react'
|
import { useEffect, useRef, useState, type FC } from 'react'
|
||||||
import Request from './components/Request'
|
import Request from './components/Request'
|
||||||
import RequestItemsList from './components/RequestItemsList'
|
import RequestItemsList from './components/RequestItemsList'
|
||||||
import { useRequestStore } from './store/RequestStore'
|
import { useRequestStore } from './store/RequestStore'
|
||||||
@@ -9,6 +9,10 @@ import { Paths } from '@/config/Paths'
|
|||||||
import { extractErrorMessage } from '@/config/func'
|
import { extractErrorMessage } from '@/config/func'
|
||||||
import { ArrowRight2 } from 'iconsax-react'
|
import { ArrowRight2 } from 'iconsax-react'
|
||||||
import ModalConfirm from '@/components/ModalConfirm'
|
import ModalConfirm from '@/components/ModalConfirm'
|
||||||
|
import ChatComposer, {
|
||||||
|
type ChatComposerHandle,
|
||||||
|
type ChatComposerSubmitPayload,
|
||||||
|
} from '@/pages/chat/components/ChatComposer'
|
||||||
|
|
||||||
const NewRequest: FC = () => {
|
const NewRequest: FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -17,6 +21,8 @@ const NewRequest: FC = () => {
|
|||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||||
const [formKey, setFormKey] = useState(0)
|
const [formKey, setFormKey] = useState(0)
|
||||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||||
|
const composerRef = useRef<ChatComposerHandle>(null)
|
||||||
|
const [pendingNote, setPendingNote] = useState<ChatComposerSubmitPayload | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setItems([])
|
setItems([])
|
||||||
@@ -42,59 +48,79 @@ const NewRequest: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (index: number) => {
|
const handleEdit = (index: number) => setEditingIndex(index)
|
||||||
setEditingIndex(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openSubmitConfirm = () => {
|
const openSubmitConfirm = () => {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error')
|
toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (composerRef.current?.hasPendingUploads()) {
|
||||||
|
toast('لطفاً تا پایان آپلود فایلها صبر کنید', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composerRef.current?.hasUploadErrors()) {
|
||||||
|
toast('برخی فایلها آپلود نشدند. آنها را حذف یا دوباره انتخاب کنید', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingNote(composerRef.current?.getPayload() ?? null)
|
||||||
setShowConfirmModal(true)
|
setShowConfirmModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
submitRequest(items, {
|
submitRequest(
|
||||||
onSuccess: () => {
|
{
|
||||||
setShowConfirmModal(false)
|
items,
|
||||||
setItems([])
|
...(pendingNote?.content ? { description: pendingNote.content } : {}),
|
||||||
setEditingIndex(null)
|
...(pendingNote?.attachments?.length ? { attachments: pendingNote.attachments } : {}),
|
||||||
setFormKey((k) => k + 1)
|
|
||||||
toast('سفارش با موفقیت ثبت شد', 'success')
|
|
||||||
navigate(Paths.myRequests, { state: { refresh: true } })
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
{
|
||||||
toast(extractErrorMessage(error), 'error')
|
onSuccess: () => {
|
||||||
|
setShowConfirmModal(false)
|
||||||
|
setPendingNote(null)
|
||||||
|
composerRef.current?.reset()
|
||||||
|
setItems([])
|
||||||
|
setEditingIndex(null)
|
||||||
|
setFormKey((k) => k + 1)
|
||||||
|
toast('سفارش با موفقیت ثبت شد', 'success')
|
||||||
|
navigate(Paths.myRequests, { state: { refresh: true } })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast(extractErrorMessage(error), 'error')
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-4 pb-12'>
|
<div className='pb-16'>
|
||||||
<div className='flex items-center justify-between gap-4'>
|
{/* Page header */}
|
||||||
<h1 className='text-lg font-light'>درخواست جدید</h1>
|
<div className='flex items-center justify-between gap-4 mb-1'>
|
||||||
|
<h1 className='text-lg font-semibold'>درخواست جدید</h1>
|
||||||
<Link
|
<Link
|
||||||
to={Paths.myRequests}
|
to={Paths.myRequests}
|
||||||
className='flex items-center gap-1 text-sm text-description hover:text-black shrink-0'
|
className='flex items-center gap-1 text-sm text-description hover:text-black transition-colors shrink-0'
|
||||||
>
|
>
|
||||||
<ArrowRight2 size={18} color='currentColor' />
|
<ArrowRight2 size={18} color='currentColor' />
|
||||||
بازگشت به لیست
|
بازگشت به لیست
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className='text-description text-xs mt-2 leading-5'>
|
<p className='text-description text-xs leading-6 mt-1 max-w-xl'>
|
||||||
برای هر محصول یک قلم اضافه کنید، سپس درخواست را ثبت نهایی کنید.
|
برای هر محصول یک قلم اضافه کنید. در صورت نیاز توضیحات یا فایل را در بخش پایین وارد کنید و از دکمه «ثبت نهایی درخواست» استفاده کنید.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className='flex flex-col-reverse xl:flex-row gap-6 xl:mt-8 mt-4'>
|
{/* Main content */}
|
||||||
<div className='flex-1 min-w-0'>
|
<div className='flex flex-col xl:flex-row gap-6 mt-6'>
|
||||||
|
{/* Left column: form + notes */}
|
||||||
|
<div className='flex-1 min-w-0 flex flex-col gap-6'>
|
||||||
<Request
|
<Request
|
||||||
key={editingIndex !== null ? `edit-${editingIndex}` : `new-${formKey}`}
|
key={editingIndex !== null ? `edit-${editingIndex}` : `new-${formKey}`}
|
||||||
editIndex={editingIndex}
|
editIndex={editingIndex}
|
||||||
initialItem={
|
initialItem={editingIndex !== null ? items[editingIndex] : undefined}
|
||||||
editingIndex !== null ? items[editingIndex] : undefined
|
|
||||||
}
|
|
||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
onCancelEdit={
|
onCancelEdit={
|
||||||
editingIndex !== null
|
editingIndex !== null
|
||||||
@@ -105,8 +131,24 @@ const NewRequest: FC = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section className='bg-white rounded-3xl p-6'>
|
||||||
|
<h2 className='text-sm font-medium mb-1'>توضیحات درخواست</h2>
|
||||||
|
<p className='text-description text-xs mb-5 leading-6'>
|
||||||
|
توضیحات، فایل یا پیام صوتی خود را اینجا بنویسید.
|
||||||
|
</p>
|
||||||
|
<ChatComposer
|
||||||
|
ref={composerRef}
|
||||||
|
onSubmit={async () => undefined}
|
||||||
|
showSubmitButton={false}
|
||||||
|
label='پیام شما'
|
||||||
|
placeholder='توضیحات یا درخواست خود را بنویسید...'
|
||||||
|
allowEmptySubmit
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: items list (sticky on xl) */}
|
||||||
<RequestItemsList
|
<RequestItemsList
|
||||||
items={items}
|
items={items}
|
||||||
editingIndex={editingIndex}
|
editingIndex={editingIndex}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { clx } from '@/helpers/utils'
|
||||||
|
import PresignedImage from '@/components/PresignedImage'
|
||||||
|
import placeholderImage from '@/assets/images/placeholder-product.svg'
|
||||||
|
|
||||||
|
export type AvatarSelectionOption = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
imageUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string
|
||||||
|
items: AvatarSelectionOption[]
|
||||||
|
selectedId?: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyMessage?: string
|
||||||
|
error_text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKELETON_COUNT = 6
|
||||||
|
|
||||||
|
const AvatarSelectionGrid: FC<Props> = ({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage = 'موردی یافت نشد',
|
||||||
|
error_text,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='w-full'>
|
||||||
|
<label className='text-sm font-medium text-primary-content'>{label}</label>
|
||||||
|
|
||||||
|
<div className='mt-3 w-full overflow-x-auto scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent pb-1'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='flex flex-nowrap gap-3'>
|
||||||
|
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
|
||||||
|
<div key={index} className='flex w-[72px] shrink-0 flex-col items-center gap-2'>
|
||||||
|
<div className='size-14 animate-pulse rounded-full bg-gray-200' />
|
||||||
|
<div className='h-6 w-full animate-pulse rounded-md bg-gray-100' />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className='text-sm font-light text-[#7B7E8B] py-2'>{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
<div className='flex flex-nowrap gap-3'>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isSelected = selectedId === item.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
className={clx(
|
||||||
|
'flex w-[72px] shrink-0 flex-col items-center gap-2 text-center rounded-2xl p-1.5 transition-all',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary/8'
|
||||||
|
: 'hover:bg-gray-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
'size-14 overflow-hidden rounded-full border-2 bg-[#F5F7FC] transition-all',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary shadow-sm shadow-primary/20'
|
||||||
|
: 'border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.imageUrl ? (
|
||||||
|
<PresignedImage
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt={item.title}
|
||||||
|
className='size-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={placeholderImage}
|
||||||
|
alt={item.title}
|
||||||
|
className='size-full object-cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={clx(
|
||||||
|
'line-clamp-2 w-full text-[11px] leading-4',
|
||||||
|
isSelected ? 'text-primary font-medium' : 'text-[#292D32] font-light',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error_text && (
|
||||||
|
<p className='mt-2 mr-1 text-right text-xs font-medium text-red-500'>
|
||||||
|
{error_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AvatarSelectionGrid
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useCallback, useRef, type FC } from 'react'
|
import { memo, useCallback, useRef, type FC } from 'react'
|
||||||
import type { AttributeType, RequestType } from '../type/Types'
|
import type { AttributeType, RequestItemType } from '../type/Types'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
import { FieldTypeEnum } from '../enum/RequestEnum'
|
import { FieldTypeEnum } from '../enum/RequestEnum'
|
||||||
import Select from '@/components/Select'
|
import Select from '@/components/Select'
|
||||||
@@ -12,7 +12,7 @@ import type { FormikProps } from 'formik'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
attributes?: AttributeType[],
|
attributes?: AttributeType[],
|
||||||
formik: FormikProps<RequestType>
|
formik: FormikProps<RequestItemType>
|
||||||
}
|
}
|
||||||
|
|
||||||
type AttributeFieldProps = {
|
type AttributeFieldProps = {
|
||||||
|
|||||||
@@ -1,95 +1,39 @@
|
|||||||
import { type ChangeEvent, type FC, type SelectHTMLAttributes, useEffect, useMemo, useState } from 'react'
|
import { type ChangeEvent, type FC, type SelectHTMLAttributes, useEffect, useMemo, useState } from 'react'
|
||||||
import { useGetCategories, useGetProducts } from '../hooks/useRequestData'
|
import { useGetCategories, useGetProducts } from '../hooks/useRequestData'
|
||||||
import Select from '@/components/Select'
|
|
||||||
import type { CategoryType, ProductType } from '../type/Types'
|
import type { CategoryType, ProductType } from '../type/Types'
|
||||||
|
import {
|
||||||
|
extractList,
|
||||||
|
findCategoryById,
|
||||||
|
findCategoryPath,
|
||||||
|
getCategoriesAtLevel,
|
||||||
|
getLevelsToShow,
|
||||||
|
} from '../utils/categoryUtils'
|
||||||
|
import AvatarSelectionGrid from './AvatarSelectionGrid'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
error_text?: string,
|
error_text?: string
|
||||||
onProductSelect?: (product: ProductType | undefined) => void,
|
onProductSelect?: (product: ProductType | undefined) => void
|
||||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||||
|
|
||||||
const findCategoryById = (categories: CategoryType[], targetId: string): CategoryType | null => {
|
|
||||||
for (const category of categories) {
|
|
||||||
if (category.id === targetId) {
|
|
||||||
return category
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category.children?.length) {
|
|
||||||
const found = findCategoryById(category.children, targetId)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const findCategoryPath = (categories: CategoryType[], targetId: string): string[] | null => {
|
|
||||||
for (const category of categories) {
|
|
||||||
if (category.id === targetId) {
|
|
||||||
return [category.id]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category.children?.length) {
|
|
||||||
const childPath = findCategoryPath(category.children, targetId)
|
|
||||||
if (childPath) {
|
|
||||||
return [category.id, ...childPath]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategoriesAtLevel = (
|
|
||||||
categories: CategoryType[],
|
|
||||||
level: number,
|
|
||||||
selectedPath: string[],
|
|
||||||
): CategoryType[] => {
|
|
||||||
if (level === 0) return categories
|
|
||||||
|
|
||||||
const parentId = selectedPath[level - 1]
|
|
||||||
if (!parentId) return []
|
|
||||||
|
|
||||||
const parent = findCategoryById(categories, parentId)
|
|
||||||
return parent?.children ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractList = <T,>(payload: unknown): T[] => {
|
|
||||||
if (Array.isArray(payload)) return payload
|
|
||||||
if (payload && typeof payload === 'object' && 'data' in payload) {
|
|
||||||
const maybeData = (payload as { data?: unknown }).data
|
|
||||||
return Array.isArray(maybeData) ? (maybeData as T[]) : []
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductsSelect: FC<Props> = (props) => {
|
const ProductsSelect: FC<Props> = (props) => {
|
||||||
const { error_text, onProductSelect, value, onChange, ...rest } = props
|
const { error_text, onProductSelect, value, onChange } = props
|
||||||
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
||||||
|
|
||||||
const { data: categoriesData } = useGetCategories()
|
const { data: categoriesData, isLoading: isCategoriesLoading } = useGetCategories()
|
||||||
const categories = useMemo(
|
const categories = useMemo(
|
||||||
() => extractList<CategoryType>(categoriesData),
|
() => extractList<CategoryType>(categoriesData),
|
||||||
[categoriesData],
|
[categoriesData],
|
||||||
)
|
)
|
||||||
|
|
||||||
const levelsToShow = useMemo(() => {
|
const levelsToShow = useMemo(
|
||||||
const levels = [0]
|
() => getLevelsToShow(categories, selectedPath),
|
||||||
|
[categories, selectedPath],
|
||||||
for (let level = 0; level < selectedPath.length; level++) {
|
)
|
||||||
const category = findCategoryById(categories, selectedPath[level])
|
|
||||||
if (category?.children?.length) {
|
|
||||||
levels.push(level + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return levels
|
|
||||||
}, [categories, selectedPath])
|
|
||||||
|
|
||||||
const activeCategoryId = selectedPath.at(-1) ?? ''
|
const activeCategoryId = selectedPath.at(-1) ?? ''
|
||||||
const canLoadProducts = !!activeCategoryId
|
const canLoadProducts = !!activeCategoryId
|
||||||
|
|
||||||
const { data: productsData } = useGetProducts(activeCategoryId || undefined, {
|
const { data: productsData, isLoading: isProductsLoading } = useGetProducts(activeCategoryId || undefined, {
|
||||||
enabled: canLoadProducts,
|
enabled: canLoadProducts,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,14 +52,7 @@ const ProductsSelect: FC<Props> = (props) => {
|
|||||||
[resolveProductsData],
|
[resolveProductsData],
|
||||||
)
|
)
|
||||||
|
|
||||||
const productItems = useMemo(
|
// Resolve initial selectedPath when editing an existing item
|
||||||
() => products.map((product) => ({
|
|
||||||
label: product.title,
|
|
||||||
value: product.id,
|
|
||||||
})),
|
|
||||||
[products],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value || selectedPath.length || !categories.length) return
|
if (!value || selectedPath.length || !categories.length) return
|
||||||
|
|
||||||
@@ -132,6 +69,7 @@ const ProductsSelect: FC<Props> = (props) => {
|
|||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
}, [value, resolveProducts, categories, selectedPath.length])
|
}, [value, resolveProducts, categories, selectedPath.length])
|
||||||
|
|
||||||
|
// Emit onProductSelect whenever the selected product changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
onProductSelect?.(undefined)
|
onProductSelect?.(undefined)
|
||||||
@@ -150,47 +88,59 @@ const ProductsSelect: FC<Props> = (props) => {
|
|||||||
} as ChangeEvent<HTMLSelectElement>)
|
} as ChangeEvent<HTMLSelectElement>)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCategoryChange = (level: number, nextValue: string) => {
|
const handleCategorySelect = (level: number, categoryId: string) => {
|
||||||
setSelectedPath((prev) => [...prev.slice(0, level), nextValue])
|
setSelectedPath((prev) => [...prev.slice(0, level), categoryId])
|
||||||
emitProductChange('')
|
emitProductChange('')
|
||||||
|
|
||||||
|
// If the selected category has children, keep drilling; don't auto-select product
|
||||||
|
const category = findCategoryById(categories, categoryId)
|
||||||
|
if (category?.children?.length) return
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
const handleProductSelect = (productId: string) => {
|
||||||
onChange?.(e)
|
emitProductChange(productId)
|
||||||
|
const product = products.find((item) => item.id === productId)
|
||||||
const product = products.find((item) => item.id === e.target.value)
|
|
||||||
onProductSelect?.(product)
|
onProductSelect?.(product)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-6'>
|
||||||
{levelsToShow.map((level) => {
|
{levelsToShow.map((level) => {
|
||||||
const items = getCategoriesAtLevel(categories, level, selectedPath).map((category) => ({
|
const levelCategories = getCategoriesAtLevel(categories, level, selectedPath)
|
||||||
label: category.title,
|
const levelLabel = level === 0 ? 'دستهبندی' : 'زیر دستهبندی'
|
||||||
value: category.id,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<AvatarSelectionGrid
|
||||||
key={level}
|
key={level}
|
||||||
items={items}
|
label={levelLabel}
|
||||||
label={level === 0 ? 'دستهبندی' : 'زیر دستهبندی'}
|
isLoading={isCategoriesLoading}
|
||||||
placeholder={level === 0 ? 'انتخاب دستهبندی' : 'انتخاب زیر دستهبندی'}
|
items={levelCategories.map((cat) => ({
|
||||||
value={selectedPath[level] ?? ''}
|
id: cat.id,
|
||||||
onChange={(e) => handleCategoryChange(level, e.target.value)}
|
title: cat.title,
|
||||||
|
imageUrl: cat.avatarUrl,
|
||||||
|
}))}
|
||||||
|
selectedId={selectedPath[level]}
|
||||||
|
onSelect={(id) => handleCategorySelect(level, id)}
|
||||||
|
emptyMessage='دستهبندی یافت نشد'
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Select
|
|
||||||
items={productItems}
|
{canLoadProducts && (
|
||||||
label='محصول'
|
<AvatarSelectionGrid
|
||||||
placeholder='انتخاب محصول'
|
label='محصول'
|
||||||
value={value}
|
isLoading={isProductsLoading}
|
||||||
onChange={handleProductChange}
|
items={products.map((product) => ({
|
||||||
error_text={error_text}
|
id: product.id,
|
||||||
disabled={!activeCategoryId}
|
title: product.title,
|
||||||
{...rest}
|
imageUrl: product.images?.[0],
|
||||||
/>
|
}))}
|
||||||
|
selectedId={typeof value === 'string' ? value : undefined}
|
||||||
|
onSelect={handleProductSelect}
|
||||||
|
error_text={error_text}
|
||||||
|
emptyMessage='محصولی برای این دستهبندی یافت نشد'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,148 +1,108 @@
|
|||||||
import { useCallback, useState, type ChangeEvent, type FC } from 'react'
|
import { useCallback, useState, type ChangeEvent, type FC } from 'react'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
import UploadBox from '@/components/UploadBox'
|
|
||||||
import VoiceRecorder from '@/components/VoiceRecorder'
|
|
||||||
import { COLORS } from '@/constants/colors'
|
import { COLORS } from '@/constants/colors'
|
||||||
import { AddSquare, CloseCircle, Edit } from 'iconsax-react'
|
import { AddSquare, CloseCircle, Edit2 } from 'iconsax-react'
|
||||||
import ProductsSelect from './ProductsSelect'
|
import ProductsSelect from './ProductsSelect'
|
||||||
import { useFormik } from 'formik'
|
import { useFormik } from 'formik'
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
import type { AttachmentsType, RequestType, ProductType } from '../type/Types'
|
import type { RequestItemType, ProductType } from '../type/Types'
|
||||||
import { useGetAttributes } from '../hooks/useRequestData'
|
import { useGetAttributes } from '../hooks/useRequestData'
|
||||||
import ManageAttribute from './ManageAttribute'
|
import ManageAttribute from './ManageAttribute'
|
||||||
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
|
||||||
import { useRequestStore } from '../store/RequestStore'
|
import { useRequestStore } from '../store/RequestStore'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
|
|
||||||
const emptyValues: RequestType = {
|
const emptyValues: RequestItemType = {
|
||||||
productId: undefined,
|
productId: undefined,
|
||||||
attachments: [],
|
|
||||||
description: '',
|
|
||||||
attributes: [],
|
attributes: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editIndex: number | null
|
editIndex: number | null
|
||||||
initialItem?: RequestType
|
initialItem?: RequestItemType
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
onCancelEdit?: () => void
|
onCancelEdit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) => {
|
const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) => {
|
||||||
const isEditing = editIndex !== null
|
const isEditing = editIndex !== null
|
||||||
|
|
||||||
const setItems = useRequestStore((state) => state.setItems)
|
const setItems = useRequestStore((state) => state.setItems)
|
||||||
const [productSelected, setProductSelected] = useState<ProductType>()
|
const [productSelected, setProductSelected] = useState<ProductType>()
|
||||||
const [voiceFile, setVoiceFile] = useState<File>()
|
|
||||||
const [files, setFiles] = useState<File[]>([])
|
|
||||||
const singleUpload = useSingleUpload()
|
|
||||||
const multiUpload = useMultiUpload()
|
|
||||||
|
|
||||||
const formik = useFormik<RequestType>({
|
const formik = useFormik<RequestItemType>({
|
||||||
initialValues: initialItem ?? emptyValues,
|
initialValues: initialItem ?? emptyValues,
|
||||||
enableReinitialize: true,
|
enableReinitialize: true,
|
||||||
validationSchema: Yup.object({
|
validationSchema: Yup.object({
|
||||||
productId: Yup.string().required('این فیلد اجباری می باشد'),
|
productId: Yup.string().required('این فیلد اجباری می باشد'),
|
||||||
}),
|
}),
|
||||||
onSubmit: async (values) => {
|
onSubmit: (values) => {
|
||||||
const attachments: AttachmentsType[] = [...(values.attachments ?? [])]
|
|
||||||
|
|
||||||
if (files.length) {
|
|
||||||
await multiUpload.mutateAsync(files, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
data?.data?.forEach((item) => {
|
|
||||||
attachments.push({
|
|
||||||
type: 'uploads_attach',
|
|
||||||
url: item.key,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (voiceFile) {
|
|
||||||
await singleUpload.mutateAsync(voiceFile, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
attachments.push({
|
|
||||||
type: 'voice',
|
|
||||||
url: data?.data?.key,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: RequestType = { ...values, attachments }
|
|
||||||
|
|
||||||
if (isEditing && editIndex !== null) {
|
if (isEditing && editIndex !== null) {
|
||||||
const items = useRequestStore.getState().items
|
const items = useRequestStore.getState().items
|
||||||
const updated = [...items]
|
const updated = [...items]
|
||||||
updated[editIndex] = payload
|
updated[editIndex] = values
|
||||||
setItems(updated)
|
setItems(updated)
|
||||||
} else {
|
} else {
|
||||||
const items = useRequestStore.getState().items
|
const items = useRequestStore.getState().items
|
||||||
setItems([...items, payload])
|
setItems([...items, values])
|
||||||
}
|
}
|
||||||
|
|
||||||
setFiles([])
|
|
||||||
setVoiceFile(undefined)
|
|
||||||
onSaved()
|
onSaved()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolvedProduct = productSelected
|
const { data: attributes } = useGetAttributes(productSelected?.id)
|
||||||
|
|
||||||
const { data: attributes } = useGetAttributes(resolvedProduct?.id)
|
const handleProductChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const productId = e.target.value
|
||||||
|
|
||||||
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
if (!productId) {
|
||||||
const productId = e.target.value
|
setProductSelected(undefined)
|
||||||
|
formik.setFieldValue('productId', undefined)
|
||||||
|
formik.setFieldValue('attributes', [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!productId) {
|
formik.setFieldValue('productId', productId)
|
||||||
setProductSelected(undefined)
|
|
||||||
formik.setFieldValue('productId', undefined)
|
|
||||||
formik.setFieldValue('attributes', [])
|
formik.setFieldValue('attributes', [])
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
formik.setFieldValue('productId', productId)
|
|
||||||
formik.setFieldValue('attributes', [])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleProductSelect = (product: ProductType | undefined) => {
|
|
||||||
setProductSelected(product)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isUploading = singleUpload.isPending || multiUpload.isPending
|
|
||||||
|
|
||||||
const handleDescriptionChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
formik.setFieldValue('description', value)
|
|
||||||
},
|
},
|
||||||
[formik.setFieldValue]
|
[formik],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleRecordingComplete = useCallback((blob: Blob) => {
|
const handleProductSelect = useCallback((product: ProductType | undefined) => {
|
||||||
const file = new File([blob], 'recording.wav', { type: blob.type })
|
setProductSelected(product)
|
||||||
setVoiceFile(file)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='bg-white rounded-3xl p-6'>
|
<div className='bg-white rounded-3xl p-6'>
|
||||||
<div className='flex items-center justify-between'>
|
{/* Section header */}
|
||||||
<div className='font-light'>
|
<div className='flex items-center justify-between mb-6'>
|
||||||
{isEditing ? 'ویرایش قلم' : 'افزودن قلم جدید'}
|
<div className='flex items-center gap-2'>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
'size-8 rounded-full flex items-center justify-center text-white text-xs font-bold',
|
||||||
|
isEditing ? 'bg-blue-500' : 'bg-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isEditing ? <Edit2 size={15} color='white' /> : <AddSquare size={15} color='white' />}
|
||||||
|
</div>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{isEditing ? 'ویرایش قلم' : 'افزودن قلم جدید'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditing && onCancelEdit && (
|
{isEditing && onCancelEdit && (
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={onCancelEdit}
|
onClick={onCancelEdit}
|
||||||
className='flex items-center gap-1 text-xs text-description hover:text-black'
|
className='flex items-center gap-1 text-xs text-description hover:text-black transition-colors'
|
||||||
>
|
>
|
||||||
<CloseCircle size={18} color='currentColor' />
|
<CloseCircle size={16} color='currentColor' />
|
||||||
انصراف
|
انصراف
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-6 rowTwoInput'>
|
{/* Product selector */}
|
||||||
|
<div className='rowTwoInput'>
|
||||||
<ProductsSelect
|
<ProductsSelect
|
||||||
value={formik.values.productId ?? ''}
|
value={formik.values.productId ?? ''}
|
||||||
onChange={handleProductChange}
|
onChange={handleProductChange}
|
||||||
@@ -155,52 +115,30 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Attributes */}
|
||||||
<ManageAttribute
|
<ManageAttribute
|
||||||
key={`${resolvedProduct?.id ?? 'none'}-${editIndex ?? 'new'}`}
|
key={`${productSelected?.id ?? 'none'}-${editIndex ?? 'new'}`}
|
||||||
attributes={attributes?.data}
|
attributes={attributes?.data}
|
||||||
formik={formik}
|
formik={formik}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6'>
|
|
||||||
<VoiceRecorder
|
|
||||||
value={formik.values.description ?? ''}
|
|
||||||
onChange={handleDescriptionChange}
|
|
||||||
label='پیام صوتی'
|
|
||||||
onRecordingComplete={handleRecordingComplete}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6'>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Action button */}
|
||||||
<div className='mt-6 flex justify-end'>
|
<div className='mt-6 flex justify-end'>
|
||||||
<Button
|
<Button
|
||||||
className={clx(
|
className={clx(
|
||||||
'w-fit px-6',
|
'w-fit px-8 gap-2',
|
||||||
isEditing
|
isEditing
|
||||||
? 'bg-transparent border border-[#3B82F6] text-[#3B82F6]'
|
? 'bg-transparent border border-blue-500 text-blue-500 hover:bg-blue-50'
|
||||||
: 'bg-transparent border border-primary text-primary'
|
: 'bg-transparent border border-primary text-primary hover:bg-primary/5',
|
||||||
)}
|
)}
|
||||||
onClick={() => formik.handleSubmit()}
|
onClick={() => formik.handleSubmit()}
|
||||||
isLoading={isUploading}
|
|
||||||
>
|
>
|
||||||
<div className='flex gap-1 items-center'>
|
{isEditing ? (
|
||||||
{isEditing ? (
|
<Edit2 color='currentColor' size={18} />
|
||||||
<Edit color='#3B82F6' size={20} />
|
) : (
|
||||||
) : (
|
<AddSquare color={COLORS.primary} size={18} />
|
||||||
<AddSquare color={COLORS.primary} size={20} />
|
)}
|
||||||
)}
|
<span>{isEditing ? 'ذخیره تغییرات' : 'افزودن به لیست'}</span>
|
||||||
<span>{isEditing ? 'ذخیره تغییرات' : 'افزودن به لیست'}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
import { Edit, ShoppingCart, TickSquare, Trash } from 'iconsax-react'
|
import { Edit2, ShoppingCart, TickSquare, Trash } from 'iconsax-react'
|
||||||
import { useGetProducts } from '../hooks/useRequestData'
|
import { useGetProducts } from '../hooks/useRequestData'
|
||||||
import type { RequestType } from '../type/Types'
|
import type { RequestItemType } from '../type/Types'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
items: RequestType[]
|
items: RequestItemType[]
|
||||||
editingIndex: number | null
|
editingIndex: number | null
|
||||||
onEdit: (index: number) => void
|
onEdit: (index: number) => void
|
||||||
onRemove: (index: number) => void
|
onRemove: (index: number) => void
|
||||||
@@ -28,65 +28,75 @@ const RequestItemsList: FC<Props> = ({
|
|||||||
productsData?.data?.find((p) => p.id === productId)?.title ?? 'محصول'
|
productsData?.data?.find((p) => p.id === productId)?.title ?? 'محصول'
|
||||||
|
|
||||||
return (
|
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='bg-white w-full xl:w-[340px] shrink-0 rounded-3xl xl:sticky xl:top-6 h-fit'>
|
||||||
<div className='flex items-center gap-2'>
|
{/* Header */}
|
||||||
<ShoppingCart size={20} color='black' />
|
<div className='flex items-center gap-2 px-5 pt-5 pb-4 border-b border-[#f0f2f8]'>
|
||||||
<span className='text-sm font-medium'>اقلام درخواست</span>
|
<ShoppingCart size={20} color='black' variant='Bold' />
|
||||||
|
<span className='text-sm font-semibold'>اقلام درخواست</span>
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<span className='text-xs bg-primary/20 text-black rounded-full px-2 py-0.5 mr-auto'>
|
<span className='mr-auto text-xs bg-primary/15 text-primary font-medium rounded-full px-2.5 py-0.5 tabular-nums'>
|
||||||
{items.length}
|
{items.length} قلم
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{/* Items */}
|
||||||
<p className='text-description text-xs mt-6 leading-6'>
|
<div className='px-5 py-4'>
|
||||||
هنوز قلمی اضافه نشده. فرم را پر کنید و روی «افزودن به لیست» بزنید.
|
{items.length === 0 ? (
|
||||||
</p>
|
<div className='flex flex-col items-center justify-center gap-3 py-8 text-center'>
|
||||||
) : (
|
<div className='size-12 rounded-full bg-gray-50 flex items-center justify-center'>
|
||||||
<ul className='mt-4 space-y-3 max-h-[360px] overflow-y-auto'>
|
<ShoppingCart size={22} color='#c0c4d0' />
|
||||||
{items.map((item, index) => (
|
</div>
|
||||||
<li
|
<p className='text-description text-xs leading-6 max-w-[200px]'>
|
||||||
key={index}
|
هنوز قلمی اضافه نشده. فرم را پر کنید و روی «افزودن به لیست» بزنید.
|
||||||
className={clx(
|
</p>
|
||||||
'border rounded-2xl p-3 transition-colors',
|
</div>
|
||||||
editingIndex === index
|
) : (
|
||||||
? 'border-primary bg-primary/5'
|
<ul className='space-y-2 max-h-[380px] overflow-y-auto -mx-1 px-1'>
|
||||||
: 'border-[#f0f2f8]'
|
{items.map((item, index) => (
|
||||||
)}
|
<li
|
||||||
>
|
key={index}
|
||||||
<div className='text-sm font-medium truncate'>
|
className={clx(
|
||||||
{getProductTitle(item.productId)}
|
'border rounded-2xl px-4 py-3 transition-all',
|
||||||
</div>
|
editingIndex === index
|
||||||
{item.attachments.length > 0 && (
|
? 'border-primary bg-primary/5 shadow-sm'
|
||||||
<div className='text-description text-xs mt-1'>
|
: 'border-[#eef0f6] hover:border-gray-300',
|
||||||
{item.attachments.length} پیوست
|
)}
|
||||||
|
>
|
||||||
|
<div className='flex items-start justify-between gap-2'>
|
||||||
|
<span className='text-sm font-medium truncate leading-6'>
|
||||||
|
{getProductTitle(item.productId)}
|
||||||
|
</span>
|
||||||
|
<span className='text-[10px] text-description shrink-0 bg-gray-100 rounded-full px-2 py-0.5'>
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className='flex gap-3 mt-2'>
|
||||||
<div className='flex gap-2 mt-3'>
|
<button
|
||||||
<button
|
type='button'
|
||||||
type='button'
|
onClick={() => onEdit(index)}
|
||||||
onClick={() => onEdit(index)}
|
className='flex items-center gap-1 text-xs text-primary hover:opacity-75 transition-opacity'
|
||||||
className='flex items-center gap-1 text-xs text-[#3B82F6] hover:opacity-80'
|
>
|
||||||
>
|
<Edit2 size={14} color='currentColor' />
|
||||||
<Edit size={16} color='#3B82F6' />
|
ویرایش
|
||||||
ویرایش
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
type='button'
|
||||||
type='button'
|
onClick={() => onRemove(index)}
|
||||||
onClick={() => onRemove(index)}
|
className='flex items-center gap-1 text-xs text-red-500 hover:opacity-75 transition-opacity mr-auto'
|
||||||
className='flex items-center gap-1 text-xs text-red-500 hover:opacity-80 mr-auto'
|
>
|
||||||
>
|
<Trash size={14} color='currentColor' />
|
||||||
<Trash size={16} color='#ef4444' />
|
حذف
|
||||||
حذف
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
))}
|
||||||
))}
|
</ul>
|
||||||
</ul>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className='mt-6 pt-4 border-t border-[#f0f2f8]'>
|
{/* Submit footer */}
|
||||||
|
<div className='px-5 pb-5 pt-3 border-t border-[#f0f2f8]'>
|
||||||
<Button
|
<Button
|
||||||
className='w-full'
|
className='w-full'
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
@@ -94,7 +104,7 @@ const RequestItemsList: FC<Props> = ({
|
|||||||
disabled={items.length === 0}
|
disabled={items.length === 0}
|
||||||
>
|
>
|
||||||
<div className='flex gap-2 items-center justify-center'>
|
<div className='flex gap-2 items-center justify-center'>
|
||||||
<TickSquare size={20} color='black' />
|
<TickSquare size={20} color='currentColor' />
|
||||||
<span>ثبت نهایی درخواست</span>
|
<span>ثبت نهایی درخواست</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import axios from "@/config/axios";
|
|||||||
import type {
|
import type {
|
||||||
AttributeResponseType,
|
AttributeResponseType,
|
||||||
CategoryType,
|
CategoryType,
|
||||||
|
CreateRequestPayload,
|
||||||
MyRequestsResponseType,
|
MyRequestsResponseType,
|
||||||
RequestDetailResponseType,
|
RequestDetailResponseType,
|
||||||
RequestType,
|
|
||||||
ProductsResponseType,
|
ProductsResponseType,
|
||||||
} from "../type/Types";
|
} from "../type/Types";
|
||||||
|
|
||||||
@@ -26,8 +26,24 @@ export const getAttributes = async (productId?: string) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const submitRequest = async (params: RequestType[]) => {
|
export const submitRequest = async (payload: CreateRequestPayload) => {
|
||||||
const { data } = await axios.post(`/public/request`, { items: params });
|
const { data } = await axios.post(`/public/request`, {
|
||||||
|
items: payload.items.map((item) => ({
|
||||||
|
productId: item.productId,
|
||||||
|
...(item.attributes?.length
|
||||||
|
? {
|
||||||
|
attributes: item.attributes.map((attr) => ({
|
||||||
|
fieldId: attr.fieldId,
|
||||||
|
value: String(attr.value),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})),
|
||||||
|
...(payload.description ? { description: payload.description } : {}),
|
||||||
|
...(payload.attachments?.length
|
||||||
|
? { attachments: payload.attachments }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,18 @@ export type AttachmentsType = {
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestType = {
|
export type RequestItemType = {
|
||||||
productId?: string;
|
productId?: string;
|
||||||
attributes: {
|
attributes: {
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
}[];
|
}[];
|
||||||
attachments: AttachmentsType[];
|
};
|
||||||
|
|
||||||
|
export type CreateRequestPayload = {
|
||||||
|
items: RequestItemType[];
|
||||||
description?: string;
|
description?: string;
|
||||||
|
attachments?: AttachmentsType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AttributeValueType = {
|
export type AttributeValueType = {
|
||||||
@@ -64,8 +68,8 @@ export type ProductsResponseType = BaseResponse<ProductType[]>;
|
|||||||
export type CategoriesResponseType = BaseResponse<CategoryType[]>;
|
export type CategoriesResponseType = BaseResponse<CategoryType[]>;
|
||||||
|
|
||||||
export type StoreType = {
|
export type StoreType = {
|
||||||
items: RequestType[];
|
items: RequestItemType[];
|
||||||
setItems: (value: RequestType[]) => void;
|
setItems: (value: RequestItemType[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MyRequestType extends RowDataType {
|
export interface MyRequestType extends RowDataType {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { CategoryType } from '../type/Types'
|
||||||
|
|
||||||
|
export const extractList = <T,>(payload: unknown): T[] => {
|
||||||
|
if (Array.isArray(payload)) return payload
|
||||||
|
if (payload && typeof payload === 'object' && 'data' in payload) {
|
||||||
|
const maybeData = (payload as { data?: unknown }).data
|
||||||
|
return Array.isArray(maybeData) ? (maybeData as T[]) : []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findCategoryById = (
|
||||||
|
categories: CategoryType[],
|
||||||
|
targetId: string,
|
||||||
|
): CategoryType | null => {
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.id === targetId) {
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.children?.length) {
|
||||||
|
const found = findCategoryById(category.children, targetId)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findCategoryPath = (
|
||||||
|
categories: CategoryType[],
|
||||||
|
targetId: string,
|
||||||
|
): string[] | null => {
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.id === targetId) {
|
||||||
|
return [category.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.children?.length) {
|
||||||
|
const childPath = findCategoryPath(category.children, targetId)
|
||||||
|
if (childPath) {
|
||||||
|
return [category.id, ...childPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCategoriesAtLevel = (
|
||||||
|
categories: CategoryType[],
|
||||||
|
level: number,
|
||||||
|
selectedPath: string[],
|
||||||
|
): CategoryType[] => {
|
||||||
|
if (level === 0) return categories
|
||||||
|
|
||||||
|
const parentId = selectedPath[level - 1]
|
||||||
|
if (!parentId) return []
|
||||||
|
|
||||||
|
const parent = findCategoryById(categories, parentId)
|
||||||
|
return parent?.children ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLevelsToShow = (
|
||||||
|
categories: CategoryType[],
|
||||||
|
selectedPath: string[],
|
||||||
|
): number[] => {
|
||||||
|
const levels = [0]
|
||||||
|
|
||||||
|
for (let level = 0; level < selectedPath.length; level++) {
|
||||||
|
const category = findCategoryById(categories, selectedPath[level])
|
||||||
|
if (category?.children?.length) {
|
||||||
|
levels.push(level + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildPathToFirstLeaf = (
|
||||||
|
categories: CategoryType[],
|
||||||
|
categoryId: string,
|
||||||
|
prefixPath: string[] = [],
|
||||||
|
): string[] => {
|
||||||
|
const path = [...prefixPath, categoryId]
|
||||||
|
let current = findCategoryById(categories, categoryId)
|
||||||
|
|
||||||
|
while (current?.children?.length) {
|
||||||
|
path.push(current.children[0].id)
|
||||||
|
current = current.children[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -19,5 +19,6 @@ export type GetMeResponseType = BaseResponse<UserMeType>;
|
|||||||
export type UpdateProfileType = {
|
export type UpdateProfileType = {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
address?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import LearningList from '@/pages/learning/List'
|
|||||||
import LearningDetail from '@/pages/learning/Detail'
|
import LearningDetail from '@/pages/learning/Detail'
|
||||||
import InvoiceDetail from '@/pages/invoice/Detail'
|
import InvoiceDetail from '@/pages/invoice/Detail'
|
||||||
import PayInvoice from '@/pages/payment/PayInvoice'
|
import PayInvoice from '@/pages/payment/PayInvoice'
|
||||||
|
import Profile from '@/pages/profile/Profile'
|
||||||
|
|
||||||
const MainRouter: FC = () => {
|
const MainRouter: FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -26,6 +27,7 @@ const MainRouter: FC = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path={Paths.home} element={<Home />} />
|
<Route path={Paths.home} element={<Home />} />
|
||||||
|
<Route path={Paths.profile} element={<Profile />} />
|
||||||
<Route path={Paths.myRequests} element={<MyRequests />} />
|
<Route path={Paths.myRequests} element={<MyRequests />} />
|
||||||
<Route path={Paths.order.myOrders} element={<MyOrders />} />
|
<Route path={Paths.order.myOrders} element={<MyOrders />} />
|
||||||
<Route path={Paths.proformaInvoice} element={<ProformaInvoice />} />
|
<Route path={Paths.proformaInvoice} element={<ProformaInvoice />} />
|
||||||
|
|||||||
+19
-29
@@ -1,12 +1,14 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import Input from '@/components/Input'
|
import Input from '@/components/Input'
|
||||||
import PresignedImage from '@/components/PresignedImage'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
import { HambergerMenu, Wallet } from 'iconsax-react'
|
import { HambergerMenu, Wallet } from 'iconsax-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import Notifications from '@/pages/notification/Notification'
|
import Notifications from '@/pages/notification/Notification'
|
||||||
import { useSharedStore } from './store/useSharedStore'
|
import { useSharedStore } from './store/useSharedStore'
|
||||||
import { t } from '@/locale'
|
import { t } from '@/locale'
|
||||||
import { useGetMe } from '@/pages/user/hooks/useUserData'
|
import { useGetMe } from '@/pages/user/hooks/useUserData'
|
||||||
import { NumberFormat } from '@/config/func'
|
import { NumberFormat } from '@/config/func'
|
||||||
|
import { Paths } from '@/config/Paths'
|
||||||
|
|
||||||
const Header: FC = () => {
|
const Header: FC = () => {
|
||||||
|
|
||||||
@@ -14,7 +16,6 @@ const Header: FC = () => {
|
|||||||
const { setOpenSidebar, openSidebar } = useSharedStore()
|
const { setOpenSidebar, openSidebar } = useSharedStore()
|
||||||
|
|
||||||
const displayName = [data?.firstName, data?.lastName].filter(Boolean).join(' ').trim()
|
const displayName = [data?.firstName, data?.lastName].filter(Boolean).join(' ').trim()
|
||||||
const initials = `${String(data?.firstName ?? '').charAt(0)}${String(data?.lastName ?? '').charAt(0)}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='fixed z-10 left-[var(--layout-frame)] right-[var(--layout-frame)] top-[var(--layout-frame)] flex h-[var(--layout-header-height)] items-center justify-between rounded-[32px] bg-white px-6 xl:left-auto xl:right-[calc(var(--layout-frame)+var(--layout-main-offset))] xl:h-[var(--layout-header-height-xl)] xl:w-[calc(100%-(var(--layout-frame)*2)-var(--layout-main-offset))]'>
|
<div className='fixed z-10 left-[var(--layout-frame)] right-[var(--layout-frame)] top-[var(--layout-frame)] flex h-[var(--layout-header-height)] items-center justify-between rounded-[32px] bg-white px-6 xl:left-auto xl:right-[calc(var(--layout-frame)+var(--layout-main-offset))] xl:h-[var(--layout-header-height-xl)] xl:w-[calc(100%-(var(--layout-frame)*2)-var(--layout-main-offset))]'>
|
||||||
@@ -29,15 +30,7 @@ const Header: FC = () => {
|
|||||||
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
|
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
|
||||||
<HambergerMenu size={24} color='black' />
|
<HambergerMenu size={24} color='black' />
|
||||||
</div>
|
</div>
|
||||||
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
|
|
||||||
<div className='flex xl:gap-6 gap-4 items-center'>
|
<div className='flex xl:gap-6 gap-4 items-center'>
|
||||||
{/* <Link to={Pages.services.other}>
|
|
||||||
<Element3 color='black' className='xl:size-[18px] size-[17px]' />
|
|
||||||
</Link>
|
|
||||||
<Link className='xl:hidden' to={Pages.wallet}>
|
|
||||||
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
|
|
||||||
</Link>
|
|
||||||
<Link className='hidden xl:block' to={Pages.wallet}> */}
|
|
||||||
<div className='flex items-center h-8 pl-2 rounded-full bg-[#EEF0F7]'>
|
<div className='flex items-center h-8 pl-2 rounded-full bg-[#EEF0F7]'>
|
||||||
<div className='px-3 text-xs'>
|
<div className='px-3 text-xs'>
|
||||||
{NumberFormat(data?.maxCredit) + ' ' + t('rial')}
|
{NumberFormat(data?.maxCredit) + ' ' + t('rial')}
|
||||||
@@ -46,29 +39,26 @@ const Header: FC = () => {
|
|||||||
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
|
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* </Link> */}
|
|
||||||
<Notifications />
|
<Notifications />
|
||||||
{displayName && (
|
{data && (
|
||||||
<div className='flex gap-2 items-center'>
|
<Link to={Paths.profile} className='flex gap-2 items-center'>
|
||||||
<div className='size-6 rounded-full bg-description overflow-hidden flex items-center justify-center text-white text-xs font-medium'>
|
<UserAvatar
|
||||||
{data?.avatarUrl ? (
|
src={data.avatarUrl}
|
||||||
<PresignedImage
|
firstName={data.firstName}
|
||||||
src={data.avatarUrl}
|
lastName={data.lastName}
|
||||||
className='size-full object-cover'
|
className='size-6'
|
||||||
alt=''
|
textClassName='text-xs'
|
||||||
/>
|
/>
|
||||||
) : (
|
{displayName && (
|
||||||
initials
|
<div className='xl:block hidden text-xs'>
|
||||||
)}
|
{displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className='xl:block hidden text-xs'>
|
)}
|
||||||
{displayName}
|
</Link>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Header
|
export default Header
|
||||||
|
|||||||
Reference in New Issue
Block a user