Compare commits

...

7 Commits

Author SHA1 Message Date
morteza 0369553c39 up
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-24 18:34:37 +03:30
morteza 3676688849 up
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-24 18:16:47 +03:30
morteza 7d4e2865bb perm guard
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-24 13:23:11 +03:30
morteza 8563508674 update
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-24 12:58:08 +03:30
morteza 74fec3d2be side bar
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-23 19:40:08 +03:30
morteza b9bc80acf1 count 2026-07-23 18:47:40 +03:30
morteza 2599eac261 invoice 2026-07-23 18:13:48 +03:30
28 changed files with 960 additions and 445 deletions
+46
View File
@@ -0,0 +1,46 @@
import { Archive } from 'iconsax-react'
import { type FC, useState } from 'react'
import ModalConfrim from './ModalConfrim'
interface Props {
onArchive: () => void
isloading?: boolean
colorIcon?: string
title?: string
}
const ArchiveWithConfirm: FC<Props> = ({
onArchive,
isloading = false,
colorIcon,
title = 'آرشیو',
}) => {
const [isConfirm, setIsConfirm] = useState(false)
return (
<div>
<button
type="button"
title={title}
onClick={() => setIsConfirm(true)}
className="inline-flex"
>
<Archive className="size-5" color={colorIcon || '#8C90A3'} />
</button>
<ModalConfrim
isOpen={isConfirm}
close={() => setIsConfirm(false)}
onConfrim={() => {
onArchive()
setIsConfirm(false)
}}
isloading={isloading}
title_header="آرشیو پیش‌فاکتور"
label="آیا از آرشیو کردن این پیش‌فاکتور مطمئن هستید؟"
/>
</div>
)
}
export default ArchiveWithConfirm
+1 -1
View File
@@ -8,8 +8,8 @@ export const Paths = {
create: '/product/create', create: '/product/create',
update: '/product/update/', update: '/product/update/',
category: { category: {
create: '/product/category/create',
list: '/product/category/list', list: '/product/category/list',
children: '/product/category/list/',
update: '/product/category/update/', update: '/product/category/update/',
}, },
attribute: { attribute: {
+67
View File
@@ -0,0 +1,67 @@
import { Paths } from '@/config/Paths'
/** Permission names aligned with API PermissionEnum. */
export const Permissions = {
DASHBOARD: 'dashboard',
VIEW_ORDERS: 'view_orders',
VIEW_ASSIGNED_ORDERS: 'view_assigned_orders',
VIEW_INVOICES: 'view_invoices',
VIEW_REQUESTS: 'view_requests',
MANAGE_PAYMENTS: 'manage_payments',
VIEW_USERS: 'view_users',
VIEW_PRINT_FORM: 'view_print_form',
MANAGE_ADMINS: 'manage_admins',
MANAGE_ROLES: 'manage_roles',
MANAGE_PRODUCTS: 'manage_products',
MANAGE_TICKETS: 'manage_tickets',
MANAGE_ANNOUNCEMENTS: 'manage_announcements',
MANAGE_CRITICISMS: 'manage_criticisms',
MANAGE_LEARNINGS: 'manage_learnings',
} as const
export type PermissionName = (typeof Permissions)[keyof typeof Permissions]
export const ORDER_VIEW_PERMISSIONS: PermissionName[] = [
Permissions.VIEW_ORDERS,
Permissions.VIEW_ASSIGNED_ORDERS,
]
type LandingRoute = {
permissions: PermissionName | PermissionName[]
path: string
}
/** Sidebar-ordered landing candidates when choosing a post-login / fallback path. */
const LANDING_ROUTES: LandingRoute[] = [
{ permissions: Permissions.DASHBOARD, path: Paths.home },
{ permissions: ORDER_VIEW_PERMISSIONS, path: Paths.order.list },
{ permissions: Permissions.VIEW_INVOICES, path: Paths.perfomaInvoice.list },
{ permissions: Permissions.VIEW_REQUESTS, path: Paths.requests.list },
{ permissions: Permissions.MANAGE_PAYMENTS, path: Paths.payments.list },
{ permissions: Permissions.VIEW_USERS, path: Paths.users.list },
{ permissions: Permissions.VIEW_PRINT_FORM, path: Paths.print.list },
{ permissions: Permissions.MANAGE_ADMINS, path: Paths.admin.list },
{ permissions: Permissions.MANAGE_PRODUCTS, path: Paths.product.list },
{ permissions: Permissions.MANAGE_TICKETS, path: Paths.tickets.list },
{ permissions: Permissions.MANAGE_ANNOUNCEMENTS, path: Paths.announcement.list },
{ permissions: Permissions.MANAGE_CRITICISMS, path: Paths.criticisms.list },
{ permissions: Permissions.MANAGE_LEARNINGS, path: Paths.learning.list },
]
export const hasPermission = (
userPermissions: string[],
required: string | string[],
): boolean => {
const requiredList = Array.isArray(required) ? required : [required]
return requiredList.some((permission) => userPermissions.includes(permission))
}
/** First permitted app path, or profile if none match. */
export const getDefaultAdminPath = (permissions: string[]): string => {
for (const route of LANDING_ROUTES) {
if (hasPermission(permissions, route.permissions)) {
return route.path
}
}
return Paths.profile
}
+2
View File
@@ -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 });
}, },
})); }));
+1 -1
View File
@@ -37,7 +37,7 @@ const AdminList: FC = () => {
<Button className="w-fit px-6"> <Button className="w-fit px-6">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<AddSquare size={18} color="black" /> <AddSquare size={18} color="black" />
<div className="text-[13px] font-light">ادمین جدید</div> <div className="text-[13px] font-light">کاربر جدید</div>
</div> </div>
</Button> </Button>
</Link> </Link>
+6 -3
View File
@@ -11,9 +11,11 @@ import { useLoginWithOtp, useOtpVerify } from '../hooks/useAuthData'
import { extractErrorMessage, setRefreshToken } from '../../../config/func' import { extractErrorMessage, setRefreshToken } from '../../../config/func'
import { setToken } from '../../../config/func' import { setToken } from '../../../config/func'
import { toast } from '@/shared/toast' import { toast } from '@/shared/toast'
import { Paths } from '@/config/Paths'
import { useSessionAuth } from '@/config/sessionAuth' import { useSessionAuth } from '@/config/sessionAuth'
import { appNavigate } from '@/config/navigation' import { appNavigate } from '@/config/navigation'
import { getDefaultAdminPath } from '@/config/permissions'
import type { BaseResponse } from '@/shared/types/Types'
import type { OtpVerifyResponseType } from '../types/AuthTypes'
const LoginStep2: FC = () => { const LoginStep2: FC = () => {
@@ -39,11 +41,12 @@ const LoginStep2: FC = () => {
otp: values.otp otp: values.otp
} }
otpVerify.mutate(params, { otpVerify.mutate(params, {
onSuccess(data) { onSuccess(data: BaseResponse<OtpVerifyResponseType>) {
setToken(data?.data?.tokens?.accessToken?.token) setToken(data?.data?.tokens?.accessToken?.token)
setRefreshToken(data?.data?.tokens?.refreshToken?.token) setRefreshToken(data?.data?.tokens?.refreshToken?.token)
useSessionAuth.getState().setAuthenticated(true) useSessionAuth.getState().setAuthenticated(true)
appNavigate(Paths.home, { replace: true }) const permissions = data?.data?.admin?.permissions ?? []
appNavigate(getDefaultAdminPath(permissions), { replace: true })
}, },
onError(error) { onError(error) {
toast(extractErrorMessage(error), 'error') toast(extractErrorMessage(error), 'error')
+11 -4
View File
@@ -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 });
},
})); }));
+17
View File
@@ -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 = {
@@ -32,6 +33,22 @@ export type OtpVerifyType = {
otp: string; otp: string;
}; };
export type AdminLoginResponseType = {
firstName?: string;
lastName?: string;
phone: string;
role: string;
permissions: string[];
};
export type OtpVerifyResponseType = {
tokens: {
accessToken: { token: string };
refreshToken: { token: string };
};
admin: AdminLoginResponseType;
};
export type CheckHasAccountType = { export type CheckHasAccountType = {
email: string; email: string;
}; };
+5 -4
View File
@@ -11,7 +11,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
@@ -29,7 +29,7 @@ type Props = {
const ChatMessage: FC<Props> = ({ const ChatMessage: FC<Props> = ({
content, content,
attachments = [], attachments,
senderName, senderName,
senderLabel = 'پشتیبان', senderLabel = 'پشتیبان',
createdAt, createdAt,
@@ -44,8 +44,9 @@ const ChatMessage: FC<Props> = ({
onDelete, onDelete,
isDeleting = false, isDeleting = false,
}) => { }) => {
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)
+1 -1
View File
@@ -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
@@ -22,7 +22,7 @@ export type WeeklyOrdersPoint = {
}; };
export const getDashboardCounts = async (): Promise<BaseResponse<DashboardCountsType>> => { export const getDashboardCounts = async (): Promise<BaseResponse<DashboardCountsType>> => {
const { data } = await axios.get<BaseResponse<DashboardCountsType>>('/admin/dashboard/counts'); const { data } = await axios.get<BaseResponse<DashboardCountsType>>('/admin/menu/counts');
return data; return data;
}; };
+1 -1
View File
@@ -33,7 +33,7 @@ const Stats: FC = () => {
<StatCard <StatCard
count={stats?.unconfirmedInvoicesCount} count={stats?.unconfirmedInvoicesCount}
description={t('home.factureCount')} description={t('home.factureCount')}
to={`${Paths.perfomaInvoice.list}?tab=${ProformaInvoiceStatusEnum.NOT_CONFIRMED}`} to={`${Paths.perfomaInvoice.list}?tab=${ProformaInvoiceStatusEnum.PENDING}`}
icon={<ReceiptText icon={<ReceiptText
size={27} size={27}
color={COLORS.primary} color={COLORS.primary}
+59 -13
View File
@@ -1,14 +1,17 @@
import { type FC, type ReactNode } from 'react'; import { type FC, type ReactNode, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import moment from 'moment-jalaali'; import moment from 'moment-jalaali';
import { Calendar, Edit, Profile2User, ReceiptText, WalletMoney } from 'iconsax-react'; import { Archive, Calendar, Edit, Profile2User, ReceiptText, WalletMoney } from 'iconsax-react';
import { Paths } from '@/config/Paths'; import { Paths } from '@/config/Paths';
import BackButton from '@/components/BackButton'; import BackButton from '@/components/BackButton';
import RefreshButton from '@/components/RefreshButton'; import RefreshButton from '@/components/RefreshButton';
import { useGetInvoiceDetail } from './hooks/useInvoiceData'; import ModalConfrim from '@/components/ModalConfrim';
import { useArchiveInvoice, useGetInvoiceDetail } from './hooks/useInvoiceData';
import InvoicePaymentsSection from './components/InvoicePaymentsSection'; import InvoicePaymentsSection from './components/InvoicePaymentsSection';
import { formatItemDiscountDisplay } from './utils/invoiceItem'; import { formatItemDiscountDisplay } from './utils/invoiceItem';
import type { InvoiceItemType } from './types/Types'; import type { InvoiceItemType } from './types/Types';
import { toast } from 'react-toastify';
import { extractErrorMessage } from '@/config/func';
const formatAmount = (amount: number) => const formatAmount = (amount: number) =>
Number(amount).toLocaleString('fa-IR') + ' ریال'; Number(amount).toLocaleString('fa-IR') + ' ریال';
@@ -110,9 +113,26 @@ const InvoiceItemTableRow: FC<{ item: InvoiceItemType }> = ({ item }) => {
const DetailPerfomaInvoice: FC = () => { const DetailPerfomaInvoice: FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isArchiveConfirmOpen, setIsArchiveConfirmOpen] = useState(false);
const { data: invoiceData, isLoading, refetch, isFetching, isError } = useGetInvoiceDetail(id); const { data: invoiceData, isLoading, refetch, isFetching, isError } = useGetInvoiceDetail(id);
const { mutate: archiveInvoice, isPending: isArchiving } = useArchiveInvoice();
const invoice = invoiceData?.data; const invoice = invoiceData?.data;
const handleArchive = () => {
if (!id) return;
archiveInvoice(id, {
onSuccess: () => {
setIsArchiveConfirmOpen(false);
toast.success('پیش‌فاکتور با موفقیت آرشیو شد');
navigate(Paths.perfomaInvoice.list);
},
onError: (error) => {
toast.error(extractErrorMessage(error));
},
});
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="mt-5 flex min-h-[200px] items-center justify-center"> <div className="mt-5 flex min-h-[200px] items-center justify-center">
@@ -154,18 +174,44 @@ const DetailPerfomaInvoice: FC = () => {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RefreshButton onClick={() => refetch()} isLoading={isFetching} /> <RefreshButton onClick={() => refetch()} isLoading={isFetching} />
<Link to={Paths.perfomaInvoice.update + invoice.id}> {invoice.status !== 'archived' && (
<button <>
type="button" <button
className="flex items-center gap-2 rounded-xl bg-primary px-5 py-2.5 text-sm font-medium hover:opacity-90" type="button"
> onClick={() => setIsArchiveConfirmOpen(true)}
<Edit size={18} color="black" /> className="flex items-center gap-2 rounded-xl border border-border px-5 py-2.5 text-sm font-medium text-[#4B5563] transition-colors hover:bg-gray-50"
ویرایش >
</button> <Archive size={18} color="#4B5563" />
</Link> آرشیو
</button>
<Link to={Paths.perfomaInvoice.update + invoice.id}>
<button
type="button"
className="flex items-center gap-2 rounded-xl bg-primary px-5 py-2.5 text-sm font-medium hover:opacity-90"
>
<Edit size={18} color="black" />
ویرایش
</button>
</Link>
</>
)}
{invoice.status === 'archived' && (
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1.5 text-xs text-gray-700">
آرشیو شده
</span>
)}
</div> </div>
</div> </div>
<ModalConfrim
isOpen={isArchiveConfirmOpen}
close={() => setIsArchiveConfirmOpen(false)}
onConfrim={handleArchive}
isloading={isArchiving}
title_header="آرشیو پیش‌فاکتور"
label="آیا از آرشیو کردن این پیش‌فاکتور مطمئن هستید؟"
/>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_320px]"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="rounded-3xl border border-border/70 bg-white p-6 shadow-sm"> <div className="rounded-3xl border border-border/70 bg-white p-6 shadow-sm">
<div className="font-medium text-[#111827]">اطلاعات پیش فاکتور</div> <div className="font-medium text-[#111827]">اطلاعات پیش فاکتور</div>
+358 -233
View File
@@ -1,254 +1,379 @@
import Tabs from '@/components/Tabs' import Tabs from "@/components/Tabs";
import { useMemo, useState, type FC } from 'react' import { useCallback, useMemo, useState, type FC } from "react";
import { ProformaInvoiceStatusEnum } from './enum/InvoiceEnum' import {
import Filters from '@/components/Filters' invoiceListTabs,
import RefreshButton from '@/components/RefreshButton' invoiceListTabStatuses,
import Table from '@/components/Table' ProformaInvoiceStatusEnum,
import { Eye, Add, Edit2 } from 'iconsax-react' } from "./enum/InvoiceEnum";
import { Link, useSearchParams } from 'react-router-dom' import Filters from "@/components/Filters";
import { Paths } from '@/config/Paths' import type { FilterValues } from "@/components/Filters";
import moment from 'moment-jalaali' import RefreshButton from "@/components/RefreshButton";
import type { FilterValues } from '@/components/Filters' import Table from "@/components/Table";
import { useGetInvoice } from './hooks/useInvoiceData' import { Eye, Add, Edit2, ArrowDown2, Filter } from "iconsax-react";
import type { InvoiceType } from './types/Types' import { Link, useSearchParams } from "react-router-dom";
import { Paths } from "@/config/Paths";
import moment from "moment-jalaali";
import { useArchiveInvoice, useGetInvoice, useGetInvoiceTabCounts } from "./hooks/useInvoiceData";
import type { InvoiceType } from "./types/Types";
import ArchiveWithConfirm from "@/components/ArchiveWithConfirm";
import { toast } from "react-toastify";
import { extractErrorMessage } from "@/config/func";
import { clx } from "@/helpers/utils";
const INVOICE_TABS: ProformaInvoiceStatusEnum[] = [ const INVOICE_TABS = invoiceListTabs.map((tab) => tab.value);
ProformaInvoiceStatusEnum.ALL,
ProformaInvoiceStatusEnum.NOT_CONFIRMED,
ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED,
ProformaInvoiceStatusEnum.CONFIRMED,
]
const isValidInvoiceTab = (value: string | null): value is ProformaInvoiceStatusEnum => const INVOICE_LIST_FILTER_FIELDS = [
value !== null && INVOICE_TABS.includes(value as ProformaInvoiceStatusEnum) {
name: "search",
type: "input" as const,
placeholder: "جستجو (شماره سفارش / مشتری)",
},
{
name: "from",
type: "date" as const,
placeholder: "از تاریخ",
},
{
name: "to",
type: "date" as const,
placeholder: "تا تاریخ",
},
];
const isValidInvoiceTab = (
value: string | null,
): value is ProformaInvoiceStatusEnum =>
value !== null && INVOICE_TABS.includes(value as ProformaInvoiceStatusEnum);
const countActiveFilters = (filters: FilterValues) =>
[filters.search, filters.from, filters.to].filter(Boolean).length;
const ProformaInvoice: FC = () => { const ProformaInvoice: FC = () => {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab') const tabParam = searchParams.get("tab");
const activeTab = isValidInvoiceTab(tabParam) ? tabParam : ProformaInvoiceStatusEnum.ALL const activeTab = isValidInvoiceTab(tabParam)
const [page, setPage] = useState(1) ? tabParam
const [filters, setFilters] = useState<FilterValues>({}) : ProformaInvoiceStatusEnum.PENDING;
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<FilterValues>({});
const [isFiltersExpanded, setIsFiltersExpanded] = useState(false);
const [filtersKey, setFiltersKey] = useState(0);
const setActiveTab = (tab: ProformaInvoiceStatusEnum) => { const statuses = invoiceListTabStatuses[activeTab];
setSearchParams( const activeFilterCount = useMemo(
(current) => { () => countActiveFilters(filters),
const next = new URLSearchParams(current) [filters],
if (tab === ProformaInvoiceStatusEnum.ALL) { );
next.delete('tab') const hasActiveFilters = activeFilterCount > 0;
} else {
next.set('tab', tab)
}
return next
},
{ replace: true },
)
}
const { data, isLoading, refetch, isFetching } = useGetInvoice(page) const setActiveTab = (tab: ProformaInvoiceStatusEnum) => {
setPage(1);
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("tab", tab);
return next;
},
{ replace: true },
);
};
const meta = data?.meta const handleFiltersChange = useCallback((next: FilterValues) => {
setPage(1);
setFilters(next);
}, []);
const filteredData = useMemo(() => { const clearFilters = useCallback(() => {
const list = data?.data ?? [] setPage(1);
setFilters({ search: null, from: null, to: null });
setFiltersKey((key) => key + 1);
}, []);
if (activeTab === ProformaInvoiceStatusEnum.CONFIRMED) { const { data, isLoading, refetch, isFetching } = useGetInvoice({
return list.filter((o) => o.confirmStatus === 'confirmed') page,
} statuses,
if (activeTab === ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED) { search: filters.search?.trim() || null,
return list.filter((o) => o.confirmStatus === 'partially_confirmed') from: filters.from || null,
} to: filters.to || null,
if (activeTab === ProformaInvoiceStatusEnum.NOT_CONFIRMED) { });
return list.filter((o) => o.confirmStatus === 'pending') const { data: tabCounts } = useGetInvoiceTabCounts({
} search: filters.search?.trim() || null,
from: filters.from || null,
to: filters.to || null,
});
const { mutate: archiveInvoice, isPending: isArchiving } =
useArchiveInvoice();
let result = list const tabCountByTab: Record<ProformaInvoiceStatusEnum, number> = useMemo(
() => ({
[ProformaInvoiceStatusEnum.PENDING]: tabCounts?.pending ?? 0,
[ProformaInvoiceStatusEnum.CONFIRMED]: tabCounts?.confirmed ?? 0,
[ProformaInvoiceStatusEnum.ARCHIVED]: tabCounts?.archived ?? 0,
}),
[tabCounts],
);
const tabsWithCount = useMemo(
() =>
invoiceListTabs.map((tab) => {
const total = tabCountByTab[tab.value] ?? 0;
return {
...tab,
label: total > 0 ? `${tab.label} (${total.toLocaleString("fa-IR")})` : tab.label,
};
}),
[tabCountByTab],
);
const search = (filters.search ?? '').trim() const meta = data?.meta;
if (search) {
const lower = search.toLowerCase()
result = result.filter(
(o) =>
String(o.invoiceNumber).includes(search) ||
o.user?.firstName?.toLowerCase().includes(lower) ||
o.user?.lastName?.toLowerCase().includes(lower) ||
o.user?.phone?.includes(search)
)
}
const dateFilter = filters.date const handleArchive = useCallback(
if (dateFilter) { (id: string) => {
const filterDate = moment(dateFilter, 'jYYYY/jMM/jDD').format('YYYY-MM-DD') archiveInvoice(id, {
result = result.filter((o) => { onSuccess: () => {
const d = o.createdAt refetch();
return d && moment(d).format('YYYY-MM-DD') === filterDate toast.success("پیش‌فاکتور با موفقیت آرشیو شد");
}) },
} onError: (error) => {
toast.error(extractErrorMessage(error));
},
});
},
[archiveInvoice, refetch],
);
return result const getServiceLabel = (item: InvoiceType) => {
}, [data?.data, activeTab, filters.search, filters.date]) if (!item.items?.length) return "-";
if (item.items.length === 1) return item.items[0].product?.title ?? "-";
return (
<div className="flex flex-col gap-0.5">
{item.items.map((line) => (
<span key={line.id}>
{line.product?.title ?? "-"} ×{" "}
{line.quantity.toLocaleString("fa-IR")}
</span>
))}
</div>
);
};
const getPaymentStatus = (item: InvoiceType) => { const getCustomerLabel = (item: InvoiceType) => {
const total = Number(item.total) || 0 const fullName =
const paid = Number(item.paidAmount) || 0 item.user?.firstName || item.user?.lastName
if (total <= 0) return '-' ? [item.user.firstName, item.user.lastName].filter(Boolean).join(" ")
if (paid >= total) return 'پرداخت شده' : null;
if (paid > 0) return 'پرداخت جزئی' const phone = item.user?.phone;
return 'پرداخت نشده'
}
const getServiceLabel = (item: InvoiceType) => { if (!fullName && !phone) return "-";
if (!item.items?.length) return '-'
if (item.items.length === 1) return item.items[0].product?.title ?? '-'
return (
<div className='flex flex-col gap-0.5'>
{item.items.map((line) => (
<span key={line.id}>
{line.product?.title ?? '-'} ({line.quantity.toLocaleString('fa-IR')})
</span>
))}
</div>
)
}
return ( return (
<div className='mt-5'> <div className="flex flex-col gap-0.5">
<div className='flex justify-between items-center'> {fullName && <span>{fullName}</span>}
<h1 className='text-lg font-light'>پیش فاکتورها</h1> {phone && <span className="text-xs text-[#8C90A3]">{phone}</span>}
<div className='flex items-center gap-3'> </div>
<RefreshButton onClick={() => refetch()} isLoading={isFetching} /> );
<Link to={Paths.perfomaInvoice.create}> };
<button className='flex items-center gap-2 px-4 py-2 bg-primary text-black text-sm font-medium rounded-xl hover:opacity-90 transition-opacity'>
<Add size={18} />
پیش فاکتور جدید
</button>
</Link>
</div>
</div>
<div className='mt-8'> const formatAmount = (value: number | null | undefined) =>
<Tabs value != null ? Number(value).toLocaleString("fa-IR") + " ریال" : "-";
items={[
{ label: 'همه', value: ProformaInvoiceStatusEnum.ALL },
{ label: 'تایید نشده', value: ProformaInvoiceStatusEnum.NOT_CONFIRMED },
{ label: 'تایید جزئی', value: ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED },
{ label: 'تایید شده', value: ProformaInvoiceStatusEnum.CONFIRMED },
]}
activeTab={activeTab}
onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)}
/>
</div>
<div className='mt-8'> return (
<Filters <div className="mt-5 space-y-6">
fields={[ <div className="flex flex-wrap items-center justify-between gap-4">
{ <div>
type: 'input', <h1 className="text-lg font-light">پیش فاکتورها</h1>
name: 'search',
placeholder: 'جستجو (شماره سفارش / مشتری)',
},
{
type: 'date',
name: 'date',
placeholder: 'تاریخ',
},
]}
onChange={setFilters}
/>
</div>
<div className='mt-8'>
<Table
columns={[
{
key: 'invoiceNumber',
title: 'شماره',
render: (item) => item.invoiceNumber,
},
{
key: 'service',
title: 'خدمت',
className: '!whitespace-normal',
render: (item) => getServiceLabel(item),
},
{
key: 'issueDate',
title: 'تاریخ صدور',
render: (item) =>
item.createdAt
? moment(item.createdAt).format('jYYYY/jMM/jDD')
: '-',
},
{
key: 'lastConfirmDate',
title: 'آخرین مهلت تایید',
render: (item) =>
item.approvalDeadline
? moment(item.approvalDeadline).format('jYYYY/jMM/jDD')
: '-',
},
{
key: 'total',
title: 'مجموع',
render: (item) =>
item.total
? Number(item.total).toLocaleString('fa-IR') + ' ریال'
: '-',
},
{
key: 'confirmStatus',
title: 'وضعیت تایید',
render: (item) => {
const statusMap = {
confirmed: { className: 'bg-green-100 text-green-800', text: 'تایید شده' },
partially_confirmed: { className: 'bg-blue-100 text-blue-800', text: 'تایید جزئی' },
pending: { className: 'bg-amber-100 text-amber-800', text: 'تایید نشده' },
}
const status = statusMap[item.confirmStatus] ?? statusMap.pending
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2.5 text-xs ${status.className}`}
>
{status.text}
</span>
)
},
},
{
key: 'paymentStatus',
title: 'وضعیت پرداخت',
render: (item) => getPaymentStatus(item),
},
{
key: 'actions',
title: '',
render: (item) => (
<div className='flex gap-2 items-center'>
<Link to={Paths.perfomaInvoice.detail + item.id}>
<Eye size={20} color='#8C90A3' />
</Link>
<Link to={Paths.perfomaInvoice.update + item.id}>
<Edit2 size={20} color='#0037FF' />
</Link>
</div>
),
},
]}
data={filteredData}
isLoading={isLoading}
noDataMessage='پیش فاکتوری یافت نشد.'
pagination={
meta && meta.totalPages > 1
? {
currentPage: meta.page,
totalPages: meta.totalPages,
onPageChange: setPage,
}
: undefined
}
/>
</div>
</div> </div>
) <div className="flex items-center gap-3">
} <RefreshButton onClick={() => refetch()} isLoading={isFetching} />
<Link to={Paths.perfomaInvoice.create}>
<button className="flex items-center gap-2 px-4 py-2 bg-primary text-black text-sm font-medium rounded-xl hover:opacity-90 transition-opacity">
<Add size={18} />
پیش فاکتور جدید
</button>
</Link>
</div>
</div>
export default ProformaInvoice <Tabs
items={tabsWithCount}
activeTab={activeTab}
onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)}
/>
<div className="overflow-hidden rounded-3xl bg-white">
<div className="flex items-center justify-between gap-3 p-5 md:px-6">
<button
type="button"
onClick={() => setIsFiltersExpanded((prev) => !prev)}
className="flex flex-1 items-center gap-2 text-right"
>
<Filter size={18} color="#8C90A3" />
<span className="text-sm font-medium text-gray-700">فیلترها</span>
{hasActiveFilters && (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-black">
{activeFilterCount.toLocaleString("fa-IR")}
</span>
)}
<ArrowDown2
size={18}
color="#8C90A3"
className={clx(
"mr-auto transition-transform duration-200",
isFiltersExpanded && "rotate-180",
)}
/>
</button>
{hasActiveFilters && (
<button
type="button"
onClick={clearFilters}
className="shrink-0 text-xs text-[#0037FF] hover:underline"
>
پاک کردن فیلترها
</button>
)}
</div>
{isFiltersExpanded && (
<div className="space-y-5 border-t border-gray-100 px-5 pb-5 pt-4 md:px-6 md:pb-6">
<Filters
key={filtersKey}
fields={INVOICE_LIST_FILTER_FIELDS}
initialValues={filters}
onChange={handleFiltersChange}
className="w-full"
fieldClassName="grid w-full grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
/>
</div>
)}
</div>
<Table
columns={[
{
key: "invoiceNumber",
title: "شماره",
render: (item) => item.invoiceNumber,
},
{
key: "customer",
title: "مشتری",
className: "!whitespace-normal",
render: (item) => getCustomerLabel(item),
},
{
key: "service",
title: "آیتم ها",
className: "!whitespace-normal",
render: (item) => getServiceLabel(item),
},
{
key: "issueDate",
title: "تاریخ صدور",
render: (item) =>
item.createdAt
? moment(item.createdAt).format("jYYYY/jMM/jDD")
: "-",
},
{
key: "lastConfirmDate",
title: "آخرین مهلت تایید",
render: (item) =>
item.approvalDeadline
? moment(item.approvalDeadline).format("jYYYY/jMM/jDD")
: "-",
},
{
key: "total",
title: "مالی",
className: "!whitespace-normal",
render: (item) => (
<table className="w-full">
<tr>
<td className="text-xs text-[#8C90A3]">مجموع : </td>
<td className="text-left">{formatAmount(item.total)}</td>
</tr>
<tr>
<td className="text-xs text-[#8C90A3]">پرداخت : </td>
<td className="text-left">{formatAmount(item.paidAmount)}</td>
</tr>
<tr>
<td className="text-xs text-[#8C90A3]">مانده: </td>
<td className="text-left">{formatAmount(item.balance)}</td>
</tr>
</table>
),
},
{
key: "status",
title: "وضعیت",
render: (item) => {
const statusMap = {
confirmed: {
className: "bg-green-100 text-green-800",
text: "تایید شده",
},
partially_confirmed: {
className: "bg-blue-100 text-blue-800",
text: "تایید جزئی",
},
pending: {
className: "bg-amber-100 text-amber-800",
text: "در انتظار تایید",
},
archived: {
className: "bg-gray-100 text-gray-700",
text: "آرشیو شده",
},
};
const status = statusMap[item.status] ?? statusMap.pending;
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2.5 text-xs ${status.className}`}
>
{status.text}
</span>
);
},
},
{
key: "actions",
title: "",
render: (item) => (
<div className="flex gap-2 items-center">
<Link to={Paths.perfomaInvoice.detail + item.id}>
<Eye size={20} color="#8C90A3" />
</Link>
{item.status !== "archived" && (
<>
<Link to={Paths.perfomaInvoice.update + item.id}>
<Edit2 size={20} color="#0037FF" />
</Link>
<ArchiveWithConfirm
onArchive={() => handleArchive(item.id)}
isloading={isArchiving}
/>
</>
)}
</div>
),
},
]}
data={data?.data}
isLoading={isLoading}
noDataMessage="پیش فاکتوری یافت نشد."
pagination={
meta && meta.totalPages > 1
? {
currentPage: meta.page,
totalPages: meta.totalPages,
onPageChange: setPage,
}
: undefined
}
/>
</div>
);
};
export default ProformaInvoice;
+24 -4
View File
@@ -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
+34 -3
View File
@@ -9,14 +9,32 @@ export const useCreateInvoice = () => {
mutationFn: api.createInvoice, mutationFn: api.createInvoice,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invoice"] }); queryClient.invalidateQueries({ queryKey: ["invoice"] });
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
}, },
}); });
}; };
export const useGetInvoice = (page = 1) => { export const useGetInvoice = (params: api.GetInvoicesParams) => {
return useQuery({ return useQuery({
queryKey: ["invoice", page], queryKey: [
queryFn: () => api.getInvoice(page), "invoice",
params.page,
params.statuses?.join(",") ?? "",
params.search ?? "",
params.from ?? "",
params.to ?? "",
],
queryFn: () => api.getInvoice(params),
});
};
export const useGetInvoiceTabCounts = (
params?: api.GetInvoiceTabCountsParams,
) => {
return useQuery({
queryKey: ["invoice-tab-counts", params],
queryFn: () => api.getInvoiceTabCounts(params),
refetchInterval: 20_000,
}); });
}; };
@@ -49,6 +67,19 @@ export const useUpdateInvoice = () => {
}) => api.updateInvoice(id, params), }) => api.updateInvoice(id, params),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invoice"] }); queryClient.invalidateQueries({ queryKey: ["invoice"] });
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
},
});
};
export const useArchiveInvoice = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.archiveInvoice(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invoice"] });
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
}, },
}); });
}; };
+51 -2
View File
@@ -6,19 +6,63 @@ import type {
InvoiceType, InvoiceType,
UpdatePreInvoiceType, UpdatePreInvoiceType,
} from "../types/Types"; } from "../types/Types";
import type { InvoiceConfirmStatus } from "../enum/InvoiceEnum";
import type { BaseResponse } from "@/shared/types/Types";
export type GetInvoicesParams = {
page?: number;
statuses?: InvoiceConfirmStatus[];
search?: string | null;
from?: string | null;
to?: string | null;
};
export type GetInvoiceTabCountsParams = Omit<GetInvoicesParams, "page" | "statuses">;
export type InvoiceTabCountsResponseType = {
pending: number;
confirmed: number;
archived: number;
};
export const createInvoice = async (params: CreatePreInvoiceType) => { export const createInvoice = async (params: CreatePreInvoiceType) => {
const { data } = await axios.post("/admin/invoice", params); const { data } = await axios.post("/admin/invoice", params);
return data; return data;
}; };
export const getInvoice = async (page = 1): Promise<GetInvoiceResponseType> => { export const getInvoice = async (
params: GetInvoicesParams = {},
): Promise<GetInvoiceResponseType> => {
const { page = 1, statuses, search, from, to } = params;
const { data } = await axios.get<GetInvoiceResponseType>("/admin/invoice", { const { data } = await axios.get<GetInvoiceResponseType>("/admin/invoice", {
params: { page }, params: {
page,
...(statuses?.length ? { statuses: statuses.join(",") } : {}),
...(search ? { search } : {}),
...(from ? { from } : {}),
...(to ? { to } : {}),
},
}); });
return data; return data;
}; };
export const getInvoiceTabCounts = async (
params: GetInvoiceTabCountsParams = {},
): Promise<InvoiceTabCountsResponseType> => {
const { search, from, to } = params;
const { data } = await axios.get<BaseResponse<InvoiceTabCountsResponseType>>(
"/admin/invoice/tab-counts",
{
params: {
...(search ? { search } : {}),
...(from ? { from } : {}),
...(to ? { to } : {}),
},
},
);
return data.data;
};
export const getInvoiceById = async (id: string) => { export const getInvoiceById = async (id: string) => {
const { data } = await axios.get<{ data: InvoiceType }>(`/admin/invoice/${id}`); const { data } = await axios.get<{ data: InvoiceType }>(`/admin/invoice/${id}`);
return data; return data;
@@ -35,3 +79,8 @@ export const updateInvoice = async (id: string, params: UpdatePreInvoiceType) =>
const { data } = await axios.patch(`/admin/invoice/${id}`, params); const { data } = await axios.patch(`/admin/invoice/${id}`, params);
return data; return data;
}; };
export const archiveInvoice = async (id: string) => {
const { data } = await axios.patch(`/admin/invoice/${id}/archive`);
return data;
};
+1 -1
View File
@@ -104,7 +104,7 @@ export type InvoiceType = {
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: InvoiceAttachmentType[]; attachments: InvoiceAttachmentType[];
+6 -2
View File
@@ -15,6 +15,8 @@ import { toast } from 'react-toastify';
import type { ErrorType } from '@/helpers/types'; import type { ErrorType } from '@/helpers/types';
import { extractErrorMessage } from '@/config/func'; import { extractErrorMessage } from '@/config/func';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Paths } from '@/config/Paths'
import BackButton from '@/components/BackButton'
const CreateProduct: FC = () => { const CreateProduct: FC = () => {
@@ -71,8 +73,10 @@ const CreateProduct: FC = () => {
return ( return (
<div className='mt-5'> <div className='mt-5'>
<div className='flex justify-between items-center'> <div className='flex justify-between items-center'>
<div className='flex items-center gap-4'>
<h1 className='text-lg font-light'>محصول جدید</h1> <BackButton to={Paths.product.list} />
<h1 className='text-lg font-light'>محصول جدید</h1>
</div>
<Button <Button
className='w-fit px-6' className='w-fit px-6'
onClick={() => formik.handleSubmit()} onClick={() => formik.handleSubmit()}
+29 -23
View File
@@ -1,7 +1,6 @@
import Button from '@/components/Button' import Button from '@/components/Button'
import Filters, { type FilterValues } from '@/components/Filters' import Filters, { type FilterValues } from '@/components/Filters'
import Table from '@/components/Table' import Table from '@/components/Table'
import Tabs from '@/components/Tabs'
import { AddSquare, Edit, More2 } from 'iconsax-react' import { AddSquare, Edit, More2 } from 'iconsax-react'
import { type FC, useMemo, useState } from 'react' import { type FC, useMemo, useState } from 'react'
import { useDeleteProduct, useDuplicateProduct, useGetCategory, useGetProducts } from './hooks/useProductData' import { useDeleteProduct, useDuplicateProduct, useGetCategory, useGetProducts } from './hooks/useProductData'
@@ -11,39 +10,44 @@ import TrashWithConfrim from '@/components/TrashWithConfrim'
import CopyWithConfirm from '@/components/CopyWithConfirm' import CopyWithConfirm from '@/components/CopyWithConfirm'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func' import { extractErrorMessage } from '@/config/func'
import type { CategoryType } from './types/Types'
const flattenCategoryOptions = (categories: CategoryType[]) => {
const options: { label: string; value: string }[] = []
for (const category of categories) {
options.push({ label: category.title, value: category.id })
for (const child of category.children ?? []) {
options.push({ label: `${child.title}`, value: child.id })
}
}
return options
}
const ProductList: FC = () => { const ProductList: FC = () => {
const [page, setPage] = useState<number>(1) const [page, setPage] = useState<number>(1)
const [search, setSearch] = useState<string>('') const [search, setSearch] = useState<string>('')
const [categoryId, setCategoryId] = useState<string>('') const [categoryId, setCategoryId] = useState<string>('')
const { data: categoriesData } = useGetCategory({ limit: 1000 }) const { data: categoriesData } = useGetCategory({ limit: 1000, parentId: 'null' })
const { data: products, refetch } = useGetProducts({ const { data: products, refetch } = useGetProducts({
page, page,
search: search || undefined, search: search || undefined,
categoryId: categoryId || undefined, categoryId: categoryId || undefined,
}) })
const categoryTabs = useMemo( const categoryOptions = useMemo(
() => [ () => flattenCategoryOptions((categoriesData?.data ?? []) as CategoryType[]),
{ label: 'همه', value: '' },
...(categoriesData?.data?.map((category) => ({
label: category.title,
value: category.id,
})) ?? []),
],
[categoriesData?.data], [categoriesData?.data],
) )
const handleFiltersChange = (filters: FilterValues) => { const handleFiltersChange = (filters: FilterValues) => {
setSearch((filters.search as string) ?? '') setSearch(filters.search ?? '')
setCategoryId(filters.categoryId ?? '')
setPage(1) setPage(1)
} }
const handleCategoryTabChange = (tab: string) => {
setCategoryId(tab)
setPage(1)
}
const { mutate: deleteProduct, isPending: isDeleting } = useDeleteProduct() const { mutate: deleteProduct, isPending: isDeleting } = useDeleteProduct()
const { mutate: duplicateProduct, isPending: isDuplicating } = useDuplicateProduct() const { mutate: duplicateProduct, isPending: isDuplicating } = useDuplicateProduct()
const [duplicatingId, setDuplicatingId] = useState<string | null>(null) const [duplicatingId, setDuplicatingId] = useState<string | null>(null)
@@ -98,14 +102,6 @@ const ProductList: FC = () => {
</div> </div>
</div> </div>
<div className='mt-8'>
<Tabs
items={categoryTabs}
activeTab={categoryId}
onTabChange={handleCategoryTabChange}
/>
</div>
<div className='mt-8'> <div className='mt-8'>
<Filters <Filters
fields={[ fields={[
@@ -113,6 +109,12 @@ const ProductList: FC = () => {
name: 'search', name: 'search',
type: 'input', type: 'input',
placeholder: 'جستجوی محصول' placeholder: 'جستجوی محصول'
},
{
name: 'categoryId',
type: 'select',
placeholder: 'دسته‌بندی',
options: categoryOptions,
} }
]} ]}
onChange={handleFiltersChange} onChange={handleFiltersChange}
@@ -152,6 +154,10 @@ const ProductList: FC = () => {
) )
} }
}, },
{
key: 'order',
title: 'ترتیب',
},
{ {
key: 'actions', key: 'actions',
title: '', title: '',
+55 -49
View File
@@ -3,39 +3,45 @@ import { useFormik } from 'formik'
import { useState, type FC } from 'react' import { useState, type FC } from 'react'
import type { CreateCategoryType } from '../types/Types' import type { CreateCategoryType } from '../types/Types'
import * as Yup from 'yup' import * as Yup from 'yup'
import { useCreateCategory, useGetCategory } from '../hooks/useProductData' import { useCreateCategory } from '../hooks/useProductData'
import Select from '@/components/Select'
import Button from '@/components/Button' import Button from '@/components/Button'
import { AddSquare } from 'iconsax-react' import { AddSquare, TickCircle } from 'iconsax-react'
import UploadBox from '@/components/UploadBox' import UploadBox from '@/components/UploadBox'
import SwitchComponent from '@/components/Switch' import SwitchComponent from '@/components/Switch'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploader' import { useSingleUpload } from '@/pages/uploader/hooks/useUploader'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func' import { extractErrorMessage } from '@/config/func'
import { useNavigate } from 'react-router-dom' import DefaulModal from '@/components/DefaulModal'
import { Paths } from '@/config/Paths'
import BackButton from '@/components/BackButton'
const ProductCategory: FC = () => { type Props = {
parentId?: string
refetch: () => void
}
const navigate = useNavigate() const CreateCategory: FC<Props> = ({ parentId, refetch }) => {
const { data } = useGetCategory()
const { mutate, isPending } = useCreateCategory() const { mutate, isPending } = useCreateCategory()
const { mutate: upload, isPending: isUploading } = useSingleUpload() const { mutate: upload, isPending: isUploading } = useSingleUpload()
const [showModal, setShowModal] = useState(false)
const [file, setFile] = useState<File>() const [file, setFile] = useState<File>()
const handleClose = () => {
setShowModal(false)
setFile(undefined)
formik.resetForm()
}
const handleSave = (values: CreateCategoryType) => { const handleSave = (values: CreateCategoryType) => {
const payload: CreateCategoryType = { const payload: CreateCategoryType = {
...values, ...values,
parentId: values.parentId || undefined, parentId: parentId || undefined,
} }
mutate(payload, { mutate(payload, {
onSuccess: () => { onSuccess: () => {
toast.success('با موفقیت ذخیره شد') toast.success('با موفقیت ذخیره شد')
navigate(Paths.product.category.list) refetch()
handleClose()
}, },
onError: (error) => { onError: (error) => {
toast.error(extractErrorMessage(error)) toast.error(extractErrorMessage(error))
@@ -69,55 +75,39 @@ const ProductCategory: FC = () => {
}) })
return ( return (
<div className='mt-5'> <div>
<div className='flex justify-between items-center'> <Button
<div className='flex items-center gap-4'> className='w-fit px-6'
<BackButton to={Paths.product.category.list} /> onClick={() => setShowModal(true)}
<h1 className='text-lg font-light'>دسته جدید</h1> >
<div className='flex gap-1.5'>
<AddSquare size={18} color='black' />
<div className='text-[13px] font-light'>دستهبندی جدید</div>
</div> </div>
<Button </Button>
className='w-fit px-6'
isLoading={isPending || isUploading}
onClick={() => formik.handleSubmit()}
>
<div className='flex gap-1.5'>
<AddSquare size={18} color='black' />
<div className='text-[13px] font-light'>ساخت دسته</div>
</div>
</Button>
</div>
<div className='mt-8 bg-white p-6 rounded-3xl '>
<div> <DefaulModal
open={showModal}
close={handleClose}
isHeader
title_header='دسته جدید'
>
<div className='mt-5'>
<UploadBox <UploadBox
label='تصویر دسته (اختیاری)' label='تصویر دسته (اختیاری)'
onChange={(files) => setFile(files[0])} onChange={(files) => setFile(files[0])}
/> />
</div> </div>
<div className='rowTwoInput mt-6'> <div className='mt-5'>
<Input <Input
label='عنوان' label='عنوان'
{...formik.getFieldProps('title')} {...formik.getFieldProps('title')}
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''} error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''}
/> />
<Select
label='انتخاب والد درسته'
placeholder='اختیاری'
items={data?.data?.map((item) => {
return {
label: item.title,
value: item.id + ''
}
}) || []}
onChange={formik.handleChange}
name='parentId'
value={formik.values.parentId || ''}
/>
</div> </div>
<div className='mt-6'> <div className='mt-5'>
<Input <Input
label='ترتیب اولویت' label='ترتیب اولویت'
{...formik.getFieldProps('order')} {...formik.getFieldProps('order')}
@@ -125,15 +115,31 @@ const ProductCategory: FC = () => {
/> />
</div> </div>
<div className='mt-6'> <div className='mt-5'>
<SwitchComponent <SwitchComponent
active={formik.values.isActive} active={formik.values.isActive}
onChange={(value) => formik.setFieldValue('isActive', value)} onChange={(value) => formik.setFieldValue('isActive', value)}
/> />
</div> </div>
</div>
<div className='mt-6 flex justify-end'>
<Button
className='w-fit px-6'
isLoading={isPending || isUploading}
onClick={() => formik.handleSubmit()}
>
<div className='flex gap-2 items-center'>
<TickCircle
size={18}
color='black'
/>
<div>ذخیره</div>
</div>
</Button>
</div>
</DefaulModal>
</div> </div>
) )
} }
export default ProductCategory export default CreateCategory
+50 -28
View File
@@ -1,24 +1,34 @@
import { type FC, useMemo } from 'react' import { type FC, useMemo } from 'react'
import PresignedImage from '@/components/PresignedImage' import PresignedImage from '@/components/PresignedImage'
import { useDeleteCategory, useGetCategory } from '../hooks/useProductData' import { useDeleteCategory, useGetCategory, useGetCategoryDetail } from '../hooks/useProductData'
import Table from '@/components/Table' import Table from '@/components/Table'
import type { ColumnType, RowDataType } from '@/components/types/TableTypes' import type { ColumnType, RowDataType } from '@/components/types/TableTypes'
import type { CategoryType } from '../types/Types' import type { CategoryType } from '../types/Types'
import Button from '@/components/Button' import { ArrowLeft2, Edit } from 'iconsax-react'
import { AddSquare, Edit } from 'iconsax-react'
import TrashWithConfrim from '@/components/TrashWithConfrim' import TrashWithConfrim from '@/components/TrashWithConfrim'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func' import { extractErrorMessage } from '@/config/func'
import { Link } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { Paths } from '@/config/Paths' import { Paths } from '@/config/Paths'
import BackButton from '@/components/BackButton' import BackButton from '@/components/BackButton'
import CreateCategory from './Create'
const CategoryList: FC = () => { const CategoryList: FC = () => {
const { parentId } = useParams<{ parentId?: string }>()
const { data, refetch } = useGetCategory() const { data, refetch } = useGetCategory({
limit: 1000,
parentId: parentId ?? 'null',
})
const { data: parentDetail } = useGetCategoryDetail(parentId ?? '')
const { mutate, isPending } = useDeleteCategory() const { mutate, isPending } = useDeleteCategory()
const categories = (data?.data ?? []) as (CategoryType & RowDataType)[] const categories = (data?.data ?? []) as (CategoryType & RowDataType)[]
const parentCategory = parentDetail?.data
const backTo = parentCategory?.parent?.id
? Paths.product.category.children + parentCategory.parent.id
: Paths.product.category.list
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
mutate(id, { mutate(id, {
onSuccess: () => { onSuccess: () => {
@@ -39,19 +49,34 @@ const CategoryList: FC = () => {
{ {
title: 'عنوان', title: 'عنوان',
key: 'title', key: 'title',
render: (item) => {
return (
<Link
to={Paths.product.category.children + item.id}
className='inline-flex items-center gap-1.5 text-sm text-black hover:opacity-70'
>
<span>{item.title}</span>
<ArrowLeft2 size={16} color='#8C90A3' />
</Link>
)
}
}, },
{ {
title: 'والد', title: 'زیر دسته‌ها',
key: 'parent',
render: (item) => item.parent?.title || '-',
},
{
title: 'تعداد زیر دسته',
key: 'children', key: 'children',
render: (item) => <div>{item.children?.length || 0}</div> render: (item) => {
if (!item.children?.length) return '0'
return (
<div className='flex flex-col gap-0.5'>
{item.children.map((child) => (
<span key={child.id}>{child.title}</span>
))}
</div>
)
}
}, },
{ {
title: 'اولویت', title: 'ترتیب',
key: 'order', key: 'order',
render: (item) => item.order ?? '-', render: (item) => item.order ?? '-',
}, },
@@ -75,33 +100,30 @@ const CategoryList: FC = () => {
} }
], [isPending]) ], [isPending])
const title = parentCategory
? `زیر دسته‌های «${parentCategory.title}»`
: 'دسته بندی محصولات'
return ( return (
<div className='mt-5'> <div className='mt-5'>
<div className='flex justify-between items-center'> <div className='flex justify-between items-center'>
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<BackButton to={Paths.product.list} /> <BackButton to={parentId ? backTo : Paths.product.list} />
<h1 className='text-lg font-light'>دسته بندی محصولات</h1> <h1 className='text-lg font-light'>{title}</h1>
</div> </div>
<Link to={Paths.product.category.create}> <CreateCategory parentId={parentId} refetch={refetch} />
<Button className='w-fit px-6'>
<div className='flex gap-1.5'>
<AddSquare size={18} color='black' />
<div className='text-[13px] font-light'>دستهبندی جدید</div>
</div>
</Button>
</Link>
</div> </div>
<div className='mt-8'> <div className='mt-8'>
<Table<CategoryType & RowDataType> <Table<CategoryType & RowDataType>
columns={columns} columns={columns}
data={categories} data={categories}
/> />
</div> </div>
</div> </div>
) )
} }
export default CategoryList export default CategoryList
@@ -1,6 +1,7 @@
import { type FC, type SelectHTMLAttributes } from 'react' import { type FC, type SelectHTMLAttributes, useMemo } from 'react'
import { useGetCategory } from '../hooks/useProductData' import { useGetCategory } from '../hooks/useProductData'
import Select from '@/components/Select' import Select from '@/components/Select'
import type { CategoryType } from '../types/Types'
type Props = { type Props = {
error_text?: string, error_text?: string,
@@ -9,6 +10,19 @@ type Props = {
clearable?: boolean, clearable?: boolean,
} & SelectHTMLAttributes<HTMLSelectElement> } & SelectHTMLAttributes<HTMLSelectElement>
const flattenCategoryOptions = (categories: CategoryType[]) => {
const options: { label: string; value: string }[] = []
for (const category of categories) {
options.push({ label: category.title, value: category.id })
for (const child of category.children ?? []) {
options.push({ label: `${child.title}`, value: child.id })
}
}
return options
}
const CategoriesSelect: FC<Props> = ({ const CategoriesSelect: FC<Props> = ({
isDisableShowLable, isDisableShowLable,
placeholder, placeholder,
@@ -16,8 +30,12 @@ const CategoriesSelect: FC<Props> = ({
clearable = true, clearable = true,
...selectProps ...selectProps
}) => { }) => {
const { data: categories } = useGetCategory({ limit: 1000, parentId: 'null' })
const { data: categories } = useGetCategory() const items = useMemo(
() => flattenCategoryOptions((categories?.data ?? []) as CategoryType[]),
[categories?.data],
)
return ( return (
<Select <Select
@@ -25,15 +43,10 @@ const CategoriesSelect: FC<Props> = ({
placeholder={placeholder || 'انتخاب'} placeholder={placeholder || 'انتخاب'}
error_text={error_text} error_text={error_text}
clearable={clearable} clearable={clearable}
items={categories?.data?.map((item) => { items={items}
return {
label: item.title,
value: item.id + ''
}
}) || []}
{...selectProps} {...selectProps}
/> />
) )
} }
export default CategoriesSelect export default CategoriesSelect
+3 -2
View File
@@ -16,10 +16,11 @@ export const useGetCategory = (params?: api.GetCategoryParams) => {
const page = params?.page ?? 1; const page = params?.page ?? 1;
const limit = params?.limit; const limit = params?.limit;
const search = params?.search; const search = params?.search;
const parentId = params?.parentId;
return useQuery({ return useQuery({
queryKey: ["category", page, limit, search], queryKey: ["category", page, limit, search, parentId],
queryFn: () => api.getCategory({ page, limit, search }), queryFn: () => api.getCategory({ page, limit, search, parentId }),
}); });
}; };
@@ -5,6 +5,7 @@ export type GetCategoryParams = {
page?: number; page?: number;
limit?: number; limit?: number;
search?: string; search?: string;
parentId?: string;
}; };
export const getCategory = async (params?: GetCategoryParams) => { export const getCategory = async (params?: GetCategoryParams) => {
@@ -13,6 +14,7 @@ export const getCategory = async (params?: GetCategoryParams) => {
page: params?.page, page: params?.page,
limit: params?.limit, limit: params?.limit,
search: params?.search || undefined, search: params?.search || undefined,
parentId: params?.parentId || undefined,
}, },
}); });
return data; return data;
+70 -55
View File
@@ -1,8 +1,13 @@
import { type FC } from "react"; import { type FC, type ReactNode } from "react";
import { Route, Routes, useLocation } from "react-router-dom"; import { Route, Routes, useLocation } from "react-router-dom";
import Home from "../pages/home/Home"; import Home from "../pages/home/Home";
import AppLayout from "../shared/AppLayout"; import AppLayout from "../shared/AppLayout";
import PermissionGuard from "../shared/PermissionGuard";
import { Paths } from "@/config/Paths"; import { Paths } from "@/config/Paths";
import {
ORDER_VIEW_PERMISSIONS,
Permissions,
} from "@/config/permissions";
import ProformaInvoice from "@/pages/invoice/ProformaInvoice"; import ProformaInvoice from "@/pages/invoice/ProformaInvoice";
import CreateInvoice from "@/pages/invoice/Create"; import CreateInvoice from "@/pages/invoice/Create";
import RequestList from "@/pages/requests/RequestList"; import RequestList from "@/pages/requests/RequestList";
@@ -23,7 +28,6 @@ import LearningList from "@/pages/learning/List";
import LearningCreate from "@/pages/learning/Create"; import LearningCreate from "@/pages/learning/Create";
import LearningCategory from "@/pages/learning/Category"; import LearningCategory from "@/pages/learning/Category";
import CategoryList from "@/pages/product/category/List"; import CategoryList from "@/pages/product/category/List";
import CreateCategory from "@/pages/product/category/Create";
import UpdateCategory from "@/pages/product/category/Update"; import UpdateCategory from "@/pages/product/category/Update";
import UpdateProduct from "@/pages/product/Update"; import UpdateProduct from "@/pages/product/Update";
import CreateAttribute from "@/pages/product/attribute/Create"; import CreateAttribute from "@/pages/product/attribute/Create";
@@ -59,6 +63,13 @@ import TicketList from "@/pages/ticket/TicketList";
import TicketDetail from "@/pages/ticket/Detail"; import TicketDetail from "@/pages/ticket/Detail";
import PaymentsList from "@/pages/payment/List"; import PaymentsList from "@/pages/payment/List";
const withPermission = (
permission: string | string[],
element: ReactNode,
) => (
<PermissionGuard permission={permission}>{element}</PermissionGuard>
);
const MainRouter: FC = () => { const MainRouter: FC = () => {
const location = useLocation() const location = useLocation()
const isPrintPreview = /\/order\/print\/[^/]+\/preview/.test(location.pathname) const isPrintPreview = /\/order\/print\/[^/]+\/preview/.test(location.pathname)
@@ -74,74 +85,78 @@ const MainRouter: FC = () => {
return ( return (
<AppLayout> <AppLayout>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={withPermission(Permissions.DASHBOARD, <Home />)} />
<Route path={Paths.home} element={<Home />} /> <Route path={Paths.home} element={withPermission(Permissions.DASHBOARD, <Home />)} />
<Route path={Paths.profile} element={<Profile />} /> <Route path={Paths.profile} element={<Profile />} />
<Route path={Paths.perfomaInvoice.list} element={<ProformaInvoice />} />
<Route path={Paths.payments.list} element={<PaymentsList />} />
<Route path={Paths.perfomaInvoice.create} element={<CreateInvoice />} />
<Route path={Paths.perfomaInvoice.detail + ":id"} element={<DetailPerfomaInvoice />} />
<Route path={Paths.perfomaInvoice.update + ":id"} element={<UpdateInvoice />} />
<Route path={Paths.requests.list} element={<RequestList />} />
<Route path={Paths.requests.detail + ":id"} element={<RequestDetail />} />
<Route path={Paths.product.list} element={<ProductList />} />
<Route path={Paths.product.create} element={<CreateProduct />} />
<Route path={Paths.product.update + ":id"} element={<UpdateProduct />} />
<Route path={Paths.product.attribute.create + ":id"} element={<CreateAttribute />} />
<Route path={Paths.product.attribute.list + ":id"} element={<AttributeList />} />
<Route path={Paths.product.attribute.update + ":id"} element={<UpdateAttribute />} />
<Route path={Paths.product.attributeValue.list + ":id"} element={<AttributeValues />} />
<Route path={Paths.product.category.list} element={<CategoryList />} />
<Route path={Paths.product.category.create} element={<CreateCategory />} />
<Route path={Paths.product.category.update + ":id"} element={<UpdateCategory />} />
<Route path={Paths.formBuilder.list + ":id/:type"} element={<FormBuilderList />} /> <Route path={Paths.perfomaInvoice.list} element={withPermission(Permissions.VIEW_INVOICES, <ProformaInvoice />)} />
<Route path={Paths.formBuilder.create + ":id/:type"} element={<FormBuilderCreate />} /> <Route path={Paths.perfomaInvoice.create} element={withPermission(Permissions.VIEW_INVOICES, <CreateInvoice />)} />
<Route path={Paths.formBuilder.update + ":id"} element={<FormBuilderUpdate />} /> <Route path={Paths.perfomaInvoice.detail + ":id"} element={withPermission(Permissions.VIEW_INVOICES, <DetailPerfomaInvoice />)} />
<Route path={Paths.formBuilder.values + ":id"} element={<FormBuilderValues />} /> <Route path={Paths.perfomaInvoice.update + ":id"} element={withPermission(Permissions.VIEW_INVOICES, <UpdateInvoice />)} />
<Route path={Paths.order.list} element={<OrdersList />} /> <Route path={Paths.payments.list} element={withPermission(Permissions.MANAGE_PAYMENTS, <PaymentsList />)} />
<Route path={Paths.order.details + ":id"} element={<OrderDetail />} />
<Route path={Paths.order.create} element={<NewOrder />} />
<Route path={Paths.order.edit + ":id"} element={<EditOrder />} />
<Route path={Paths.convertToOrder} element={<ConvertToOrders />} />
<Route path={Paths.orderPrint + ':id/preview'} element={<OrderPrintPreview />} />
<Route path={Paths.orderPrint + ':id'} element={<OrderPrint />} />
<Route path={Paths.tickets.list} element={<TicketList />} /> <Route path={Paths.requests.list} element={withPermission(Permissions.VIEW_REQUESTS, <RequestList />)} />
<Route path={Paths.tickets.detail + ":id"} element={<TicketDetail />} /> <Route path={Paths.requests.detail + ":id"} element={withPermission(Permissions.VIEW_REQUESTS, <RequestDetail />)} />
<Route path={Paths.print.list} element={<SectionList />} /> <Route path={Paths.product.list} element={withPermission(Permissions.MANAGE_PRODUCTS, <ProductList />)} />
<Route path={Paths.print.create} element={<CreateSection />} /> <Route path={Paths.product.create} element={withPermission(Permissions.MANAGE_PRODUCTS, <CreateProduct />)} />
<Route path={Paths.print.update + ":id"} element={<UpdateSection />} /> <Route path={Paths.product.update + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <UpdateProduct />)} />
<Route path={Paths.print.form + ":itemId/:orderId"} element={<PrintForm />} /> <Route path={Paths.product.attribute.create + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <CreateAttribute />)} />
<Route path={Paths.product.attribute.list + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <AttributeList />)} />
<Route path={Paths.product.attribute.update + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <UpdateAttribute />)} />
<Route path={Paths.product.attributeValue.list + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <AttributeValues />)} />
<Route path={Paths.product.category.list} element={withPermission(Permissions.MANAGE_PRODUCTS, <CategoryList />)} />
<Route path={Paths.product.category.children + ":parentId"} element={withPermission(Permissions.MANAGE_PRODUCTS, <CategoryList />)} />
<Route path={Paths.product.category.update + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <UpdateCategory />)} />
<Route path={Paths.users.list} element={<UsersList />} /> <Route path={Paths.formBuilder.list + ":id/:type"} element={withPermission(Permissions.MANAGE_PRODUCTS, <FormBuilderList />)} />
<Route path={Paths.users.create} element={<CreateUser />} /> <Route path={Paths.formBuilder.create + ":id/:type"} element={withPermission(Permissions.MANAGE_PRODUCTS, <FormBuilderCreate />)} />
<Route path={Paths.users.update + ":id"} element={<UpdateUser />} /> <Route path={Paths.formBuilder.update + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <FormBuilderUpdate />)} />
<Route path={Paths.formBuilder.values + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <FormBuilderValues />)} />
<Route path={Paths.admin.list} element={<AdminList />} /> <Route path={Paths.order.list} element={withPermission(ORDER_VIEW_PERMISSIONS, <OrdersList />)} />
<Route path={Paths.admin.create} element={<CreateAdmin />} /> <Route path={Paths.order.details + ":id"} element={withPermission(ORDER_VIEW_PERMISSIONS, <OrderDetail />)} />
<Route path={Paths.admin.update + ":id"} element={<EditAdmin />} /> <Route path={Paths.order.create} element={withPermission(ORDER_VIEW_PERMISSIONS, <NewOrder />)} />
<Route path={Paths.order.edit + ":id"} element={withPermission(ORDER_VIEW_PERMISSIONS, <EditOrder />)} />
<Route path={Paths.convertToOrder} element={withPermission(ORDER_VIEW_PERMISSIONS, <ConvertToOrders />)} />
<Route path={Paths.orderPrint + ':id/preview'} element={withPermission(ORDER_VIEW_PERMISSIONS, <OrderPrintPreview />)} />
<Route path={Paths.orderPrint + ':id'} element={withPermission(ORDER_VIEW_PERMISSIONS, <OrderPrint />)} />
<Route path={Paths.role.list} element={<RoleList />} /> <Route path={Paths.tickets.list} element={withPermission(Permissions.MANAGE_TICKETS, <TicketList />)} />
<Route path={Paths.role.create} element={<CreateRole />} /> <Route path={Paths.tickets.detail + ":id"} element={withPermission(Permissions.MANAGE_TICKETS, <TicketDetail />)} />
<Route path={Paths.role.update + ":id"} element={<UpdateRole />} />
<Route path={Paths.print.list} element={withPermission(Permissions.VIEW_PRINT_FORM, <SectionList />)} />
<Route path={Paths.print.create} element={withPermission(Permissions.VIEW_PRINT_FORM, <CreateSection />)} />
<Route path={Paths.print.update + ":id"} element={withPermission(Permissions.VIEW_PRINT_FORM, <UpdateSection />)} />
<Route path={Paths.print.form + ":itemId/:orderId"} element={withPermission(Permissions.VIEW_PRINT_FORM, <PrintForm />)} />
<Route path={Paths.users.list} element={withPermission(Permissions.VIEW_USERS, <UsersList />)} />
<Route path={Paths.users.create} element={withPermission(Permissions.VIEW_USERS, <CreateUser />)} />
<Route path={Paths.users.update + ":id"} element={withPermission(Permissions.VIEW_USERS, <UpdateUser />)} />
<Route path={Paths.admin.list} element={withPermission(Permissions.MANAGE_ADMINS, <AdminList />)} />
<Route path={Paths.admin.create} element={withPermission(Permissions.MANAGE_ADMINS, <CreateAdmin />)} />
<Route path={Paths.admin.update + ":id"} element={withPermission(Permissions.MANAGE_ADMINS, <EditAdmin />)} />
<Route path={Paths.role.list} element={withPermission(Permissions.MANAGE_ROLES, <RoleList />)} />
<Route path={Paths.role.create} element={withPermission(Permissions.MANAGE_ROLES, <CreateRole />)} />
<Route path={Paths.role.update + ":id"} element={withPermission(Permissions.MANAGE_ROLES, <UpdateRole />)} />
<Route path={Paths.features.list} element={<FeaturesList />} /> <Route path={Paths.features.list} element={<FeaturesList />} />
<Route path={Paths.features.create} element={<CreateFeature />} /> <Route path={Paths.features.create} element={<CreateFeature />} />
<Route path={Paths.service.print} element={<PrintService />} /> <Route path={Paths.service.print} element={<PrintService />} />
<Route path={Paths.announcement.list} element={<AnnouncementList />} /> <Route path={Paths.announcement.list} element={withPermission(Permissions.MANAGE_ANNOUNCEMENTS, <AnnouncementList />)} />
<Route path={Paths.announcement.detail + ":id"} element={<AnnouncementUpdate />} /> <Route path={Paths.announcement.detail + ":id"} element={withPermission(Permissions.MANAGE_ANNOUNCEMENTS, <AnnouncementUpdate />)} />
<Route path={Paths.announcement.create} element={<AnnouncementCreate />} /> <Route path={Paths.announcement.create} element={withPermission(Permissions.MANAGE_ANNOUNCEMENTS, <AnnouncementCreate />)} />
<Route path={Paths.criticisms.list} element={<CriticismsList />} /> <Route path={Paths.criticisms.list} element={withPermission(Permissions.MANAGE_CRITICISMS, <CriticismsList />)} />
<Route path={Paths.criticisms.detail + ":id"} element={<CriticismsDetail />} /> <Route path={Paths.criticisms.detail + ":id"} element={withPermission(Permissions.MANAGE_CRITICISMS, <CriticismsDetail />)} />
<Route path={Paths.learning.list} element={<LearningList />} /> <Route path={Paths.learning.list} element={withPermission(Permissions.MANAGE_LEARNINGS, <LearningList />)} />
<Route path={Paths.learning.create} element={<LearningCreate />} /> <Route path={Paths.learning.create} element={withPermission(Permissions.MANAGE_LEARNINGS, <LearningCreate />)} />
<Route path={Paths.learning.category} element={<LearningCategory />} /> <Route path={Paths.learning.category} element={withPermission(Permissions.MANAGE_LEARNINGS, <LearningCategory />)} />
</Routes> </Routes>
</AppLayout> </AppLayout>
); );
+35
View File
@@ -0,0 +1,35 @@
import { type FC, type ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { useGetAdminMe } from '@/pages/admin/hooks/useAdminData'
import {
getDefaultAdminPath,
hasPermission,
} from '@/config/permissions'
type Props = {
permission: string | string[]
children: ReactNode
}
/**
* Blocks route access when the current admin lacks the required permission(s).
* Pass an array to allow access if the user has any of them.
*/
const PermissionGuard: FC<Props> = ({ permission, children }) => {
const { data: adminMe, isLoading } = useGetAdminMe()
if (isLoading) {
return null
}
const permissions =
adminMe?.data?.role?.permissions?.map((p) => p.name) ?? []
if (!hasPermission(permissions, permission)) {
return <Navigate to={getDefaultAdminPath(permissions)} replace />
}
return <>{children}</>
}
export default PermissionGuard
+2 -5
View File
@@ -66,7 +66,7 @@ const SideBar: FC = () => {
title={t('sidebar.mainPage')} title={t('sidebar.mainPage')}
isActive={isActive('/home')} isActive={isActive('/home')}
link={Paths.home} link={Paths.home}
activeName='' activeName='dashboard'
/> />
<SideBarItem <SideBarItem
@@ -106,9 +106,6 @@ const SideBar: FC = () => {
/> />
<SideBarItem <SideBarItem
icon={<People size={iconSizeSideBar} color="#4F5260" />} icon={<People size={iconSizeSideBar} color="#4F5260" />}
title={'مشتریان'} title={'مشتریان'}
@@ -127,7 +124,7 @@ const SideBar: FC = () => {
<SideBarItem <SideBarItem
icon={<User size={iconSizeSideBar} color="#4F5260" />} icon={<User size={iconSizeSideBar} color="#4F5260" />}
title={'مدیران'} title={'کاربران'}
isActive={isActive('admin')} isActive={isActive('admin')}
link={Paths.admin.list} link={Paths.admin.list}
activeName='manage_admins' activeName='manage_admins'