Compare commits

...

6 Commits

Author SHA1 Message Date
morteza 18c0c721a1 zustand
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-24 13:23:40 +03:30
morteza f1d0a661e7 orders
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-23 19:40:17 +03:30
morteza 1bea0ed3ec count 2026-07-23 18:47:48 +03:30
morteza cea411b912 invoice 2026-07-23 18:13:39 +03:30
morteza 04687b855d profile
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-21 16:05:15 +03:30
morteza 830e229cf3 refactor new req
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-21 12:44:16 +03:30
23 changed files with 645 additions and 299 deletions
+2
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { getToken, removeRefreshToken, removeToken } from "./func";
import { useAuthStore } from "@/pages/auth/store/AuthStore";
type SessionAuthStore = {
isAuthenticated: boolean;
@@ -14,6 +15,7 @@ export const useSessionAuth = create<SessionAuthStore>((set) => ({
removeToken();
removeRefreshToken();
window.isRefreshTokenExpired = false;
useAuthStore.getState().reset();
set({ isAuthenticated: false });
},
}));
+7 -5
View File
@@ -17,13 +17,15 @@ export type StatusConfig = {
*/
export const statusRegistry: Record<string, StatusConfig> = {
// --- ProformaInvoiceStatusEnum ---
[ProformaInvoiceStatusEnum.ALL]: { label: 'همه', variant: 'neutral' },
[ProformaInvoiceStatusEnum.NOT_CONFIRMED]: { label: 'تایید نشده', variant: 'warning' },
[ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED]: { label: 'تایید جزئی', variant: 'info' },
[ProformaInvoiceStatusEnum.PENDING]: { label: 'در انتظار تایید', variant: 'warning' },
[ProformaInvoiceStatusEnum.CONFIRMED]: { label: 'تایید شده', variant: 'success' },
[ProformaInvoiceStatusEnum.ARCHIVED]: { label: 'آرشیو شده', variant: 'neutral' },
// --- Invoice confirm status (from API; partially_confirmed/confirmed covered by enum above) ---
pending: { label: 'تایید نشده', variant: 'warning' },
// --- Invoice confirm status (from API) ---
pending: { label: 'در انتظار تایید', variant: 'warning' },
partially_confirmed: { label: 'تایید جزئی', variant: 'info' },
confirmed: { label: 'تایید شده', variant: 'success' },
archived: { label: 'آرشیو شده', variant: 'neutral' },
// --- OrderStatusEnum ---
[OrderStatusEnum.CREATED]: { label: 'ایجاد شده', variant: 'info' },
+11 -4
View File
@@ -1,21 +1,28 @@
import { create } from "zustand";
import { type AuthStoreType } from "../../auth/types/AuthTypes";
export const useAuthStore = create<AuthStoreType>((set) => ({
const initialState = {
phone: "",
email: "",
stepLogin: 1,
devOtpCode: "",
};
export const useAuthStore = create<AuthStoreType>((set) => ({
...initialState,
setPhone(value) {
set({ phone: value });
},
email: "",
setEmail(value) {
set({ email: value });
},
stepLogin: 1,
setStepLogin(value) {
set({ stepLogin: value });
},
devOtpCode: "",
setDevOtpCode(value) {
set({ devOtpCode: value });
},
reset() {
set({ ...initialState });
},
}));
+1
View File
@@ -15,6 +15,7 @@ export type AuthStoreType = {
/** TODO: remove before production — dev-only OTP from API response */
devOtpCode: string;
setDevOtpCode: (value: string) => void;
reset: () => void;
};
export type LoginWithPasswordType = {
+5 -4
View File
@@ -10,7 +10,7 @@ import { getChatSenderName } from '../type/Types'
type Props = {
content: string
attachments?: ChatAttachmentType[]
attachments?: ChatAttachmentType[] | null
senderName?: string
senderLabel?: string
createdAt?: string
@@ -25,7 +25,7 @@ type Props = {
const ChatMessage: FC<Props> = ({
content,
attachments = [],
attachments,
senderName,
senderLabel = 'پشتیبان',
createdAt,
@@ -37,8 +37,9 @@ const ChatMessage: FC<Props> = ({
parentSenderLabel,
onReply,
}) => {
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
const safeAttachments = attachments ?? []
const fileAttachments = safeAttachments.filter((a) => a.type !== 'voice')
const voiceAttachments = safeAttachments.filter((a) => a.type === 'voice')
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key)
+1 -1
View File
@@ -29,7 +29,7 @@ export type ChatParentMessageType = {
export type ChatMessageType = {
admin?: ChatParticipantType | null
user?: ChatParticipantType | null
attachments: ChatAttachmentType[]
attachments?: ChatAttachmentType[] | null
content: string
createdAt: string
id: string
+1 -1
View File
@@ -33,7 +33,7 @@ const Stats: FC = () => {
<StatCard
count={data?.data.invoicesCount}
description={t('home.factureCount')}
to={`${Paths.proformaInvoice}?tab=${ProformaInvoiceStatusEnum.NOT_CONFIRMED}`}
to={`${Paths.proformaInvoice}?tab=${ProformaInvoiceStatusEnum.PENDING}`}
icon={<ReceiptText
size={27}
color={COLORS.primary}
+54 -45
View File
@@ -1,10 +1,14 @@
import Tabs from '@/components/Tabs'
import { useMemo, useState, type FC } from 'react'
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 Table from '@/components/Table'
import { useGetInvoices } from './hooks/useInvoiceData'
import { useGetInvoices, useGetInvoiceTabCounts } from './hooks/useInvoiceData'
import type { Invoice } from './types/InvoiceTypes'
import type { ColumnType } from '@/components/types/TableTypes'
import type { FilterValues } from '@/components/Filters'
@@ -14,11 +18,10 @@ import moment from 'moment-jalaali'
import StatusWithText from '@/components/StatusWithText'
import { Eye } from 'iconsax-react'
const INVOICE_TABS = invoiceListTabs.map((tab) => tab.value)
const isInvoiceTab = (value: string | null): value is ProformaInvoiceStatusEnum =>
value === ProformaInvoiceStatusEnum.ALL ||
value === ProformaInvoiceStatusEnum.NOT_CONFIRMED ||
value === ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED ||
value === ProformaInvoiceStatusEnum.CONFIRMED
value !== null && INVOICE_TABS.includes(value as ProformaInvoiceStatusEnum)
const getServiceNames = (invoice: Invoice): string => {
const names = [...new Set(invoice.items.map((item) => item.product.title))]
@@ -27,29 +30,53 @@ const getServiceNames = (invoice: Invoice): string => {
const ProformaInvoice: FC = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const tabFromQuery = searchParams.get('tab')
const [activeTab, setActiveTab] = useState<ProformaInvoiceStatusEnum>(
isInvoiceTab(tabFromQuery) ? tabFromQuery : ProformaInvoiceStatusEnum.ALL,
)
const activeTab = isInvoiceTab(tabFromQuery)
? tabFromQuery
: ProformaInvoiceStatusEnum.PENDING
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 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(() => {
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()
if (search) {
const lower = search.toLowerCase()
@@ -62,7 +89,6 @@ const ProformaInvoice: FC = () => {
)
}
// فیلتر تاریخ
const dateFilter = filters.date?.toString()
if (dateFilter) {
result = result.filter((inv) => {
@@ -72,7 +98,7 @@ const ProformaInvoice: FC = () => {
}
return result
}, [invoices, activeTab, filters])
}, [invoices, filters])
const columns: ColumnType<Invoice>[] = [
{
@@ -101,15 +127,16 @@ const ProformaInvoice: FC = () => {
render: (item) => <span>{NumberFormat(item.total)} ریال</span>,
},
{
key: 'confirmStatus',
key: 'status',
title: 'وضعیت تایید',
render: (item) => {
const statusMap = {
confirmed: { variant: 'success' 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 (
<StatusWithText
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',
title: '',
@@ -157,12 +171,7 @@ const ProformaInvoice: FC = () => {
<div className="mt-8">
<Tabs
items={[
{ label: 'همه', value: ProformaInvoiceStatusEnum.ALL },
{ label: 'تایید نشده', value: ProformaInvoiceStatusEnum.NOT_CONFIRMED },
{ label: 'تایید جزئی', value: ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED },
{ label: 'تایید شده', value: ProformaInvoiceStatusEnum.CONFIRMED },
]}
items={tabsWithCount}
activeTab={activeTab}
onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)}
/>
+23 -3
View File
@@ -1,6 +1,26 @@
export const enum ProformaInvoiceStatusEnum {
ALL = 'all',
NOT_CONFIRMED = 'not_confirmed',
PARTIALLY_CONFIRMED = 'partially_confirmed',
PENDING = 'pending',
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
+19 -4
View File
@@ -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";
export const useGetInvoices = () => {
export const useGetInvoices = (params: api.GetInvoicesParams) => {
return useQuery({
queryKey: ["invoices"],
queryFn: api.getInvoices,
queryKey: ["invoices", params.statuses?.join(",") ?? ""],
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 = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.confrimInvoiceItem,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["invoice"] });
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
},
});
};
+28 -2
View File
@@ -3,12 +3,38 @@ import type {
InvoicesResponseType,
InvoiceDetailResponseType,
} from "../types/InvoiceTypes";
import type { InvoiceConfirmStatus } from "../enum/InvoiceEnum";
import type { BaseResponse } from "@/shared/types/Types";
export const getInvoices = async (): Promise<InvoicesResponseType> => {
const { data } = await axios.get<InvoicesResponseType>("/public/invoice");
export type GetInvoicesParams = {
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;
};
export const getInvoiceTabCounts = async (): Promise<InvoiceTabCountsResponseType> => {
const { data } = await axios.get<BaseResponse<InvoiceTabCountsResponseType>>(
"/public/invoice/tab-counts",
);
return data.data;
};
export const getInvoiceDetail = async (
id: string,
): Promise<InvoiceDetailResponseType> => {
+1 -1
View File
@@ -55,7 +55,7 @@ export type Invoice = {
total: number;
paidAmount: number;
balance: number;
confirmStatus: 'pending' | 'partially_confirmed' | 'confirmed';
status: 'pending' | 'partially_confirmed' | 'confirmed' | 'archived';
enableTax: boolean;
approvalDeadline: string;
attachments: unknown[];
+1 -1
View File
@@ -34,7 +34,7 @@ const MyOrders: FC = () => {
items={[
{ label: 'همه', value: TabMyOrdersEnum.ALL },
{ label: 'در حال انجام', value: TabMyOrdersEnum.IN_PROGRESS },
{ label: 'تکمیل داده شده', value: TabMyOrdersEnum.DELIVERED },
{ label: 'تکمیل شده', value: TabMyOrdersEnum.DELIVERED },
{ label: 'کنسل شده', value: TabMyOrdersEnum.CANCELLED },
]}
activeTab={activeTab}
+168
View File
@@ -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
-21
View File
@@ -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;
},
});
};
+33 -43
View File
@@ -22,8 +22,7 @@ const NewRequest: FC = () => {
const [formKey, setFormKey] = useState(0)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const composerRef = useRef<ChatComposerHandle>(null)
const [pendingNote, setPendingNote] =
useState<ChatComposerSubmitPayload | null>(null)
const [pendingNote, setPendingNote] = useState<ChatComposerSubmitPayload | null>(null)
useEffect(() => {
setItems([])
@@ -49,9 +48,7 @@ const NewRequest: FC = () => {
}
}
const handleEdit = (index: number) => {
setEditingIndex(index)
}
const handleEdit = (index: number) => setEditingIndex(index)
const openSubmitConfirm = () => {
if (items.length === 0) {
@@ -65,15 +62,11 @@ const NewRequest: FC = () => {
}
if (composerRef.current?.hasUploadErrors()) {
toast(
'برخی فایل‌ها آپلود نشدند. آن‌ها را حذف یا دوباره انتخاب کنید',
'error',
)
toast('برخی فایل‌ها آپلود نشدند. آن‌ها را حذف یا دوباره انتخاب کنید', 'error')
return
}
const note = composerRef.current?.getPayload() ?? null
setPendingNote(note)
setPendingNote(composerRef.current?.getPayload() ?? null)
setShowConfirmModal(true)
}
@@ -82,9 +75,7 @@ const NewRequest: FC = () => {
{
items,
...(pendingNote?.content ? { description: pendingNote.content } : {}),
...(pendingNote?.attachments?.length
? { attachments: pendingNote.attachments }
: {}),
...(pendingNote?.attachments?.length ? { attachments: pendingNote.attachments } : {}),
},
{
onSuccess: () => {
@@ -105,32 +96,31 @@ const NewRequest: FC = () => {
}
return (
<div className='mt-4 pb-12'>
<div className='flex items-center justify-between gap-4'>
<h1 className='text-lg font-light'>درخواست جدید</h1>
<div className='pb-16'>
{/* Page header */}
<div className='flex items-center justify-between gap-4 mb-1'>
<h1 className='text-lg font-semibold'>درخواست جدید</h1>
<Link
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' />
بازگشت به لیست
</Link>
</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>
<div className='flex flex-col-reverse xl:flex-row gap-6 xl:mt-8 mt-4'>
<div className='flex-1 min-w-0'>
{/* Main content */}
<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
key={editingIndex !== null ? `edit-${editingIndex}` : `new-${formKey}`}
editIndex={editingIndex}
initialItem={
editingIndex !== null ? items[editingIndex] : undefined
}
initialItem={editingIndex !== null ? items[editingIndex] : undefined}
onSaved={handleSaved}
onCancelEdit={
editingIndex !== null
@@ -141,8 +131,24 @@ const NewRequest: FC = () => {
: 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>
{/* Right column: items list (sticky on xl) */}
<RequestItemsList
items={items}
editingIndex={editingIndex}
@@ -153,22 +159,6 @@ const NewRequest: FC = () => {
/>
</div>
<section className='bg-white rounded-3xl p-6 mt-6'>
<h2 className='text-sm font-light mb-1'>توضیحات درخواست</h2>
<p className='text-description text-xs mb-6 leading-5'>
توضیحات، فایل یا پیام صوتی خود را اینجا بنویسید. برای ارسال
درخواست از دکمه کنار لیست اقلام استفاده کنید.
</p>
<ChatComposer
ref={composerRef}
onSubmit={async () => undefined}
showSubmitButton={false}
label='پیام شما'
placeholder='توضیحات یا درخواست خود را بنویسید...'
allowEmptySubmit
/>
</section>
<ModalConfirm
open={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
@@ -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
+52 -53
View File
@@ -1,46 +1,39 @@
import { type ChangeEvent, type FC, type SelectHTMLAttributes, useEffect, useMemo, useState } from 'react'
import { useGetCategories, useGetProducts } from '../hooks/useRequestData'
import Select from '@/components/Select'
import type { CategoryType, ProductType } from '../type/Types'
import {
extractList,
findCategoryById,
findCategoryPath,
getCategoriesAtLevel,
getLevelsToShow,
} from '../utils/categoryUtils'
import AvatarSelectionGrid from './AvatarSelectionGrid'
type Props = {
error_text?: string,
onProductSelect?: (product: ProductType | undefined) => void,
error_text?: string
onProductSelect?: (product: ProductType | undefined) => void
} & SelectHTMLAttributes<HTMLSelectElement>
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 { data: categoriesData } = useGetCategories()
const { data: categoriesData, isLoading: isCategoriesLoading } = useGetCategories()
const categories = useMemo(
() => extractList<CategoryType>(categoriesData),
[categoriesData],
)
const levelsToShow = useMemo(() => {
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
}, [categories, selectedPath])
const levelsToShow = useMemo(
() => getLevelsToShow(categories, selectedPath),
[categories, selectedPath],
)
const activeCategoryId = selectedPath.at(-1) ?? ''
const canLoadProducts = !!activeCategoryId
const { data: productsData } = useGetProducts(activeCategoryId || undefined, {
const { data: productsData, isLoading: isProductsLoading } = useGetProducts(activeCategoryId || undefined, {
enabled: canLoadProducts,
})
@@ -59,14 +52,7 @@ const ProductsSelect: FC<Props> = (props) => {
[resolveProductsData],
)
const productItems = useMemo(
() => products.map((product) => ({
label: product.title,
value: product.id,
})),
[products],
)
// Resolve initial selectedPath when editing an existing item
useEffect(() => {
if (!value || selectedPath.length || !categories.length) return
@@ -83,6 +69,7 @@ const ProductsSelect: FC<Props> = (props) => {
setSelectedPath(path)
}, [value, resolveProducts, categories, selectedPath.length])
// Emit onProductSelect whenever the selected product changes
useEffect(() => {
if (!value) {
onProductSelect?.(undefined)
@@ -101,47 +88,59 @@ const ProductsSelect: FC<Props> = (props) => {
} as ChangeEvent<HTMLSelectElement>)
}
const handleCategoryChange = (level: number, nextValue: string) => {
setSelectedPath((prev) => [...prev.slice(0, level), nextValue])
const handleCategorySelect = (level: number, categoryId: string) => {
setSelectedPath((prev) => [...prev.slice(0, level), categoryId])
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>) => {
onChange?.(e)
const product = products.find((item) => item.id === e.target.value)
const handleProductSelect = (productId: string) => {
emitProductChange(productId)
const product = products.find((item) => item.id === productId)
onProductSelect?.(product)
}
return (
<div className='space-y-4'>
<div className='space-y-6'>
{levelsToShow.map((level) => {
const items = getCategoriesAtLevel(categories, level, selectedPath).map((category) => ({
label: category.title,
value: category.id,
}))
const levelCategories = getCategoriesAtLevel(categories, level, selectedPath)
const levelLabel = level === 0 ? 'دسته‌بندی' : 'زیر دسته‌بندی'
return (
<Select
<AvatarSelectionGrid
key={level}
items={items}
label={level === 0 ? 'دسته‌بندی' : 'زیر دسته‌بندی'}
placeholder={level === 0 ? 'انتخاب دسته‌بندی' : 'انتخاب زیر دسته‌بندی'}
value={selectedPath[level] ?? ''}
onChange={(e) => handleCategoryChange(level, e.target.value)}
label={levelLabel}
isLoading={isCategoriesLoading}
items={levelCategories.map((cat) => ({
id: cat.id,
title: cat.title,
imageUrl: cat.avatarUrl,
}))}
selectedId={selectedPath[level]}
onSelect={(id) => handleCategorySelect(level, id)}
emptyMessage='دسته‌بندی یافت نشد'
/>
)
})}
<Select
items={productItems}
label='محصول'
placeholder='انتخاب محصول'
value={value}
onChange={handleProductChange}
error_text={error_text}
disabled={!activeCategoryId}
{...rest}
/>
{canLoadProducts && (
<AvatarSelectionGrid
label='محصول'
isLoading={isProductsLoading}
items={products.map((product) => ({
id: product.id,
title: product.title,
imageUrl: product.images?.[0],
}))}
selectedId={typeof value === 'string' ? value : undefined}
onSelect={handleProductSelect}
error_text={error_text}
emptyMessage='محصولی برای این دسته‌بندی یافت نشد'
/>
)}
</div>
)
}
+38 -31
View File
@@ -1,7 +1,7 @@
import { useCallback, useState, type ChangeEvent, type FC } from 'react'
import Button from '@/components/Button'
import { COLORS } from '@/constants/colors'
import { AddSquare, CloseCircle, Edit } from 'iconsax-react'
import { AddSquare, CloseCircle, Edit2 } from 'iconsax-react'
import ProductsSelect from './ProductsSelect'
import { useFormik } from 'formik'
import * as Yup from 'yup'
@@ -25,7 +25,6 @@ type Props = {
const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) => {
const isEditing = editIndex !== null
const setItems = useRequestStore((state) => state.setItems)
const [productSelected, setProductSelected] = useState<ProductType>()
@@ -45,14 +44,11 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
const items = useRequestStore.getState().items
setItems([...items, values])
}
onSaved()
},
})
const resolvedProduct = productSelected
const { data: attributes } = useGetAttributes(resolvedProduct?.id)
const { data: attributes } = useGetAttributes(productSelected?.id)
const handleProductChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
@@ -68,7 +64,7 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
formik.setFieldValue('productId', productId)
formik.setFieldValue('attributes', [])
},
[formik]
[formik],
)
const handleProductSelect = useCallback((product: ProductType | undefined) => {
@@ -77,23 +73,36 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
return (
<div className='bg-white rounded-3xl p-6'>
<div className='flex items-center justify-between'>
<div className='font-light'>
{isEditing ? 'ویرایش قلم' : 'افزودن قلم جدید'}
{/* Section header */}
<div className='flex items-center justify-between mb-6'>
<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>
{isEditing && onCancelEdit && (
<button
type='button'
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>
)}
</div>
<div className='mt-6 rowTwoInput'>
{/* Product selector */}
<div className='rowTwoInput'>
<ProductsSelect
value={formik.values.productId ?? ''}
onChange={handleProductChange}
@@ -106,32 +115,30 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
/>
</div>
<div>
<ManageAttribute
key={`${resolvedProduct?.id ?? 'none'}-${editIndex ?? 'new'}`}
attributes={attributes?.data}
formik={formik}
/>
</div>
{/* Attributes */}
<ManageAttribute
key={`${productSelected?.id ?? 'none'}-${editIndex ?? 'new'}`}
attributes={attributes?.data}
formik={formik}
/>
{/* Action button */}
<div className='mt-6 flex justify-end'>
<Button
className={clx(
'w-fit px-6',
'w-fit px-8 gap-2',
isEditing
? 'bg-transparent border border-[#3B82F6] text-[#3B82F6]'
: 'bg-transparent border border-primary text-primary'
? 'bg-transparent border border-blue-500 text-blue-500 hover:bg-blue-50'
: 'bg-transparent border border-primary text-primary hover:bg-primary/5',
)}
onClick={() => formik.handleSubmit()}
>
<div className='flex gap-1 items-center'>
{isEditing ? (
<Edit color='#3B82F6' size={20} />
) : (
<AddSquare color={COLORS.primary} size={20} />
)}
<span>{isEditing ? 'ذخیره تغییرات' : 'افزودن به لیست'}</span>
</div>
{isEditing ? (
<Edit2 color='currentColor' size={18} />
) : (
<AddSquare color={COLORS.primary} size={18} />
)}
<span>{isEditing ? 'ذخیره تغییرات' : 'افزودن به لیست'}</span>
</Button>
</div>
</div>
@@ -1,6 +1,6 @@
import { type FC } from 'react'
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 type { RequestItemType } from '../type/Types'
import { clx } from '@/helpers/utils'
@@ -28,60 +28,75 @@ const RequestItemsList: FC<Props> = ({
productsData?.data?.find((p) => p.id === productId)?.title ?? 'محصول'
return (
<div className='bg-white w-full xl:w-[320px] shrink-0 py-6 px-5 h-fit rounded-3xl xl:sticky xl:top-6'>
<div className='flex items-center gap-2'>
<ShoppingCart size={20} color='black' />
<span className='text-sm font-medium'>اقلام درخواست</span>
<div className='bg-white w-full xl:w-[340px] shrink-0 rounded-3xl xl:sticky xl:top-6 h-fit'>
{/* Header */}
<div className='flex items-center gap-2 px-5 pt-5 pb-4 border-b border-[#f0f2f8]'>
<ShoppingCart size={20} color='black' variant='Bold' />
<span className='text-sm font-semibold'>اقلام درخواست</span>
{items.length > 0 && (
<span className='text-xs bg-primary/20 text-black rounded-full px-2 py-0.5 mr-auto'>
{items.length}
<span className='mr-auto text-xs bg-primary/15 text-primary font-medium rounded-full px-2.5 py-0.5 tabular-nums'>
{items.length} قلم
</span>
)}
</div>
{items.length === 0 ? (
<p className='text-description text-xs mt-6 leading-6'>
هنوز قلمی اضافه نشده. فرم را پر کنید و روی «افزودن به لیست» بزنید.
</p>
) : (
<ul className='mt-4 space-y-3 max-h-[360px] overflow-y-auto'>
{items.map((item, index) => (
<li
key={index}
className={clx(
'border rounded-2xl p-3 transition-colors',
editingIndex === index
? 'border-primary bg-primary/5'
: 'border-[#f0f2f8]'
)}
>
<div className='text-sm font-medium truncate'>
{getProductTitle(item.productId)}
</div>
<div className='flex gap-2 mt-3'>
<button
type='button'
onClick={() => onEdit(index)}
className='flex items-center gap-1 text-xs text-[#3B82F6] hover:opacity-80'
>
<Edit size={16} color='#3B82F6' />
ویرایش
</button>
<button
type='button'
onClick={() => onRemove(index)}
className='flex items-center gap-1 text-xs text-red-500 hover:opacity-80 mr-auto'
>
<Trash size={16} color='#ef4444' />
حذف
</button>
</div>
</li>
))}
</ul>
)}
{/* Items */}
<div className='px-5 py-4'>
{items.length === 0 ? (
<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'>
<ShoppingCart size={22} color='#c0c4d0' />
</div>
<p className='text-description text-xs leading-6 max-w-[200px]'>
هنوز قلمی اضافه نشده. فرم را پر کنید و روی «افزودن به لیست» بزنید.
</p>
</div>
) : (
<ul className='space-y-2 max-h-[380px] overflow-y-auto -mx-1 px-1'>
{items.map((item, index) => (
<li
key={index}
className={clx(
'border rounded-2xl px-4 py-3 transition-all',
editingIndex === index
? 'border-primary bg-primary/5 shadow-sm'
: 'border-[#eef0f6] hover:border-gray-300',
)}
>
<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 className='flex gap-3 mt-2'>
<button
type='button'
onClick={() => onEdit(index)}
className='flex items-center gap-1 text-xs text-primary hover:opacity-75 transition-opacity'
>
<Edit2 size={14} color='currentColor' />
ویرایش
</button>
<button
type='button'
onClick={() => onRemove(index)}
className='flex items-center gap-1 text-xs text-red-500 hover:opacity-75 transition-opacity mr-auto'
>
<Trash size={14} color='currentColor' />
حذف
</button>
</div>
</li>
))}
</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
className='w-full'
onClick={onSubmit}
@@ -89,7 +104,7 @@ const RequestItemsList: FC<Props> = ({
disabled={items.length === 0}
>
<div className='flex gap-2 items-center justify-center'>
<TickSquare size={20} color='black' />
<TickSquare size={20} color='currentColor' />
<span>ثبت نهایی درخواست</span>
</div>
</Button>
+1
View File
@@ -19,5 +19,6 @@ export type GetMeResponseType = BaseResponse<UserMeType>;
export type UpdateProfileType = {
firstName: string;
lastName: string;
address?: string;
avatarUrl?: string;
};
+2
View File
@@ -19,6 +19,7 @@ import LearningList from '@/pages/learning/List'
import LearningDetail from '@/pages/learning/Detail'
import InvoiceDetail from '@/pages/invoice/Detail'
import PayInvoice from '@/pages/payment/PayInvoice'
import Profile from '@/pages/profile/Profile'
const MainRouter: FC = () => {
return (
@@ -26,6 +27,7 @@ const MainRouter: FC = () => {
<Routes>
<Route path="/" element={<Home />} />
<Route path={Paths.home} element={<Home />} />
<Route path={Paths.profile} element={<Profile />} />
<Route path={Paths.myRequests} element={<MyRequests />} />
<Route path={Paths.order.myOrders} element={<MyOrders />} />
<Route path={Paths.proformaInvoice} element={<ProformaInvoice />} />
+18 -28
View File
@@ -1,12 +1,14 @@
import { type FC } from 'react'
import Input from '@/components/Input'
import PresignedImage from '@/components/PresignedImage'
import UserAvatar from '@/components/UserAvatar'
import { HambergerMenu, Wallet } from 'iconsax-react'
import { Link } from 'react-router-dom'
import Notifications from '@/pages/notification/Notification'
import { useSharedStore } from './store/useSharedStore'
import { t } from '@/locale'
import { useGetMe } from '@/pages/user/hooks/useUserData'
import { NumberFormat } from '@/config/func'
import { Paths } from '@/config/Paths'
const Header: FC = () => {
@@ -14,7 +16,6 @@ const Header: FC = () => {
const { setOpenSidebar, openSidebar } = useSharedStore()
const displayName = [data?.firstName, data?.lastName].filter(Boolean).join(' ').trim()
const initials = `${String(data?.firstName ?? '').charAt(0)}${String(data?.lastName ?? '').charAt(0)}`
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))]'>
@@ -29,15 +30,7 @@ const Header: FC = () => {
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
<HambergerMenu size={24} color='black' />
</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'>
{/* <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='px-3 text-xs'>
{NumberFormat(data?.maxCredit) + ' ' + t('rial')}
@@ -46,25 +39,22 @@ const Header: FC = () => {
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
</div>
</div>
{/* </Link> */}
<Notifications />
{displayName && (
<div 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'>
{data?.avatarUrl ? (
<PresignedImage
src={data.avatarUrl}
className='size-full object-cover'
alt=''
/>
) : (
initials
)}
</div>
<div className='xl:block hidden text-xs'>
{displayName}
</div>
</div>
{data && (
<Link to={Paths.profile} className='flex gap-2 items-center'>
<UserAvatar
src={data.avatarUrl}
firstName={data.firstName}
lastName={data.lastName}
className='size-6'
textClassName='text-xs'
/>
{displayName && (
<div className='xl:block hidden text-xs'>
{displayName}
</div>
)}
</Link>
)}
</div>
</div>