Browse Source
feat: add core panel UI features including billing, tours, services, layout components, and i18n support
master
feat: add core panel UI features including billing, tours, services, layout components, and i18n support
master
27 changed files with 2629 additions and 1396 deletions
-
548guide/aqila_panel_ui_design_system_standard.md
-
6src/app/router.tsx
-
244src/components/layout/AppSidebar.tsx
-
67src/components/layout/LanguageSwitcher.tsx
-
60src/components/layout/Topbar.tsx
-
124src/config/navigation.ts
-
428src/features/billing/BillingPage.tsx
-
5src/features/billing/components/BillingDetailModal.tsx
-
21src/features/billing/components/BillingServiceBadge.tsx
-
14src/features/billing/components/BillingStatusBadge.tsx
-
28src/features/billing/components/RejectFactorDialog.tsx
-
47src/features/global-search/GlobalSearchDialog.tsx
-
402src/features/services/ServicesPage.tsx
-
4src/features/services/components/CategoriesServiceView.tsx
-
4src/features/services/components/CreateLocationDialog.tsx
-
8src/features/services/components/DuasServiceView.tsx
-
1368src/features/services/components/FaqServiceView.tsx
-
2src/features/services/components/ShrinesServiceView.tsx
-
26src/features/tours/OngoingToursPage.tsx
-
34src/features/tours/ToursPage.tsx
-
141src/i18n/index.ts
-
4src/index.css
-
133src/locales/ar.json
-
133src/locales/en.json
-
133src/locales/fa.json
-
2src/main.tsx
-
39src/stores/billing-badge.ts
@ -14,19 +14,59 @@ import { |
|||||
SidebarMenuBadge, |
SidebarMenuBadge, |
||||
} from '@/components/ui/sidebar' |
} from '@/components/ui/sidebar' |
||||
import { useAuth, useLogout } from '@/features/auth/use-auth' |
import { useAuth, useLogout } from '@/features/auth/use-auth' |
||||
import { navigationConfig } from '@/config/navigation' |
|
||||
|
import { getNavigationConfig, type NavItemConfig, type NavSubItemConfig } from '@/config/navigation' |
||||
import { siteConfig } from '@/config/site' |
import { siteConfig } from '@/config/site' |
||||
import { AqilaLogo } from '@/components/AqilaLogo' |
import { AqilaLogo } from '@/components/AqilaLogo' |
||||
|
import { useTranslation } from '@/i18n' |
||||
|
import { useBillingBadgeStore } from '@/stores/billing-badge' |
||||
|
|
||||
import * as React from 'react' |
import * as React from 'react' |
||||
import { cn } from '@/lib/utils' |
import { cn } from '@/lib/utils' |
||||
import type { NavItemConfig } from '@/config/navigation' |
|
||||
|
|
||||
|
function BillingSidebarBadges() { |
||||
|
const { pendingReviewCount, awaitingPaymentCount, fetchCounts } = useBillingBadgeStore() |
||||
|
const { t } = useTranslation() |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
fetchCounts() |
||||
|
// Poll every 30 seconds for live updates
|
||||
|
const interval = setInterval(fetchCounts, 30000) |
||||
|
return () => clearInterval(interval) |
||||
|
}, [fetchCounts]) |
||||
|
|
||||
|
if (pendingReviewCount === 0 && awaitingPaymentCount === 0) return null |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex items-center gap-1 ms-auto shrink-0"> |
||||
|
{/* ۱. دایره نارنجی: در انتظار بررسی ادمین (فیش پرداخت توسط کاربر آپلود شده) */} |
||||
|
{pendingReviewCount > 0 && ( |
||||
|
<span |
||||
|
className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-amber-500 text-white font-mono font-bold text-[10px] shadow-xs animate-pulse" |
||||
|
title={`${t('billing.pendingReview')}: ${pendingReviewCount}`} |
||||
|
> |
||||
|
{pendingReviewCount} |
||||
|
</span> |
||||
|
)} |
||||
|
|
||||
|
{/* ۲. دایره آبی: در انتظار پرداخت کاربر */} |
||||
|
{awaitingPaymentCount > 0 && ( |
||||
|
<span |
||||
|
className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-blue-500 text-white font-mono font-bold text-[10px] shadow-xs" |
||||
|
title={`${t('billing.awaitingPayment')}: ${awaitingPaymentCount}`} |
||||
|
> |
||||
|
{awaitingPaymentCount} |
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { |
function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { |
||||
|
const { t } = useTranslation() |
||||
if (soon) { |
if (soon) { |
||||
return ( |
return ( |
||||
<span className="ms-auto rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-grey-3"> |
<span className="ms-auto rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-grey-3"> |
||||
بهزودی |
|
||||
|
{t('navigation.soon')} |
||||
</span> |
</span> |
||||
) |
) |
||||
} |
} |
||||
@ -34,22 +74,141 @@ function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { |
|||||
return null |
return null |
||||
} |
} |
||||
|
|
||||
|
function checkSubItemActive(sub: NavSubItemConfig, pathname: string): boolean { |
||||
|
if (sub.to) { |
||||
|
if (sub.to === '/tours' || sub.to === '/services') { |
||||
|
if (pathname === sub.to) return true |
||||
|
} else if (pathname === sub.to || pathname.startsWith(sub.to + '/')) { |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
if (sub.children && sub.children.length > 0) { |
||||
|
return sub.children.some((c) => checkSubItemActive(c, pathname)) |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
function SubItem({ sub, pathname, dir }: { sub: NavSubItemConfig; pathname: string; dir: 'rtl' | 'ltr' }) { |
||||
|
const hasGrandChildren = Boolean(sub.children && sub.children.length > 0) |
||||
|
const isGrandChildActive = Boolean(hasGrandChildren && sub.children?.some((c) => checkSubItemActive(c, pathname))) |
||||
|
const [isOpen, setIsOpen] = React.useState<boolean>(isGrandChildActive) |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
if (isGrandChildActive) { |
||||
|
setIsOpen(true) |
||||
|
} |
||||
|
}, [isGrandChildActive]) |
||||
|
|
||||
|
if (hasGrandChildren && sub.children) { |
||||
|
return ( |
||||
|
<div className="flex flex-col"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setIsOpen((prev) => !prev)} |
||||
|
className={cn( |
||||
|
'flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors', |
||||
|
isGrandChildActive |
||||
|
? 'text-primary font-bold bg-primary/5' |
||||
|
: 'text-grey-3 hover:bg-white/5 hover:text-foreground' |
||||
|
)} |
||||
|
> |
||||
|
<div className="flex items-center gap-2 min-w-0"> |
||||
|
{sub.icon && <Ic name={sub.icon} className="size-3.5 shrink-0" />} |
||||
|
<span className="whitespace-nowrap">{sub.title}</span> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-1 ms-auto shrink-0"> |
||||
|
{sub.badge && ( |
||||
|
<span className="rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary"> |
||||
|
{sub.badge} |
||||
|
</span> |
||||
|
)} |
||||
|
<Ic |
||||
|
name={isOpen ? 'chevUp' : 'chevDown'} |
||||
|
className="size-3 text-grey-3 transition-transform shrink-0 opacity-70" |
||||
|
/> |
||||
|
</div> |
||||
|
</button> |
||||
|
|
||||
|
{isOpen && ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
'mt-1 flex flex-col gap-1 border-border/40', |
||||
|
dir === 'ltr' ? 'ml-3 border-l pl-2' : 'mr-3 border-r pr-2' |
||||
|
)} |
||||
|
> |
||||
|
{sub.children.map((grand) => { |
||||
|
const isGrandActive = grand.to |
||||
|
? grand.to === '/services' || grand.to === '/tours' |
||||
|
? pathname === grand.to |
||||
|
: pathname === grand.to || pathname.startsWith(grand.to + '/') |
||||
|
: false |
||||
|
return ( |
||||
|
<NavLink |
||||
|
key={grand.to || grand.title} |
||||
|
to={grand.to || '#'} |
||||
|
className={cn( |
||||
|
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors', |
||||
|
isGrandActive |
||||
|
? 'bg-primary/15 text-primary font-bold shadow-xs' |
||||
|
: 'text-grey-3 hover:bg-white/5 hover:text-foreground' |
||||
|
)} |
||||
|
> |
||||
|
{grand.icon && <Ic name={grand.icon} className="size-3 shrink-0" />} |
||||
|
<span className="whitespace-nowrap">{grand.title}</span> |
||||
|
{grand.badge && ( |
||||
|
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[9px] text-primary"> |
||||
|
{grand.badge} |
||||
|
</span> |
||||
|
)} |
||||
|
</NavLink> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const isSubActive = |
||||
|
sub.to === '/tours' || sub.to === '/services' |
||||
|
? pathname === sub.to |
||||
|
: Boolean(sub.to && (pathname === sub.to || pathname.startsWith(sub.to + '/'))) |
||||
|
|
||||
|
return ( |
||||
|
<NavLink |
||||
|
to={sub.to || '#'} |
||||
|
className={cn( |
||||
|
'flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors', |
||||
|
isSubActive |
||||
|
? 'bg-primary/15 text-primary font-bold shadow-xs' |
||||
|
: 'text-grey-3 hover:bg-white/5 hover:text-foreground' |
||||
|
)} |
||||
|
> |
||||
|
{sub.icon && <Ic name={sub.icon} className="size-3.5 shrink-0" />} |
||||
|
<span className="whitespace-nowrap">{sub.title}</span> |
||||
|
{sub.badge && ( |
||||
|
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary"> |
||||
|
{sub.badge} |
||||
|
</span> |
||||
|
)} |
||||
|
</NavLink> |
||||
|
) |
||||
|
} |
||||
|
|
||||
function NavItem({ item }: { item: NavItemConfig }) { |
function NavItem({ item }: { item: NavItemConfig }) { |
||||
const { pathname } = useLocation() |
const { pathname } = useLocation() |
||||
|
const { dir } = useTranslation() |
||||
const hasChildren = Boolean(item.children && item.children.length > 0) |
const hasChildren = Boolean(item.children && item.children.length > 0) |
||||
const isChildActive = |
const isChildActive = |
||||
hasChildren && |
|
||||
item.children?.some((c) => |
|
||||
c.to === '/tours' || c.to === '/services' ? pathname === c.to : pathname.startsWith(c.to) |
|
||||
) |
|
||||
|
hasChildren && item.children?.some((c) => checkSubItemActive(c, pathname)) |
||||
const isAlwaysOpen = Boolean(item.alwaysOpen) |
const isAlwaysOpen = Boolean(item.alwaysOpen) |
||||
const [isOpen, setIsOpen] = React.useState<boolean>(isAlwaysOpen || Boolean(isChildActive)) |
const [isOpen, setIsOpen] = React.useState<boolean>(isAlwaysOpen || Boolean(isChildActive)) |
||||
|
|
||||
React.useEffect(() => { |
React.useEffect(() => { |
||||
if (isAlwaysOpen || isChildActive) { |
|
||||
|
if (isChildActive) { |
||||
setIsOpen(true) |
setIsOpen(true) |
||||
} |
} |
||||
}, [isAlwaysOpen, isChildActive]) |
|
||||
|
}, [isChildActive]) |
||||
|
|
||||
if (hasChildren && item.children) { |
if (hasChildren && item.children) { |
||||
return ( |
return ( |
||||
@ -79,45 +238,34 @@ function NavItem({ item }: { item: NavItemConfig }) { |
|||||
</SidebarMenuButton> |
</SidebarMenuButton> |
||||
|
|
||||
{(isAlwaysOpen || isOpen) && ( |
{(isAlwaysOpen || isOpen) && ( |
||||
<div className="mr-5 mt-1 flex flex-col gap-1 border-r border-border/40 pr-2"> |
|
||||
{item.children.map((sub) => { |
|
||||
const isSubActive = |
|
||||
sub.to === '/tours' || sub.to === '/services' |
|
||||
? pathname === sub.to |
|
||||
: pathname.startsWith(sub.to) |
|
||||
return ( |
|
||||
<NavLink |
|
||||
key={sub.to} |
|
||||
to={sub.to} |
|
||||
className={cn( |
|
||||
'flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors', |
|
||||
isSubActive |
|
||||
? 'bg-primary/15 text-primary font-bold shadow-xs' |
|
||||
: 'text-grey-3 hover:bg-white/5 hover:text-foreground' |
|
||||
)} |
|
||||
> |
|
||||
<Ic name={sub.icon} className="size-3.5 shrink-0" /> |
|
||||
<span className="whitespace-nowrap">{sub.title}</span> |
|
||||
{sub.badge && ( |
|
||||
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary"> |
|
||||
{sub.badge} |
|
||||
</span> |
|
||||
)} |
|
||||
</NavLink> |
|
||||
) |
|
||||
})} |
|
||||
|
<div className={cn( |
||||
|
'mt-1 flex flex-col gap-1 border-border/40', |
||||
|
dir === 'ltr' ? 'ml-5 border-l pl-2' : 'mr-5 border-r pr-2' |
||||
|
)}> |
||||
|
{item.children.map((sub, idx) => ( |
||||
|
<SubItem key={sub.to || sub.title || idx} sub={sub} pathname={pathname} dir={dir} /> |
||||
|
))} |
||||
</div> |
</div> |
||||
)} |
)} |
||||
</SidebarMenuItem> |
</SidebarMenuItem> |
||||
) |
) |
||||
} |
} |
||||
|
|
||||
const isActive = item.to ? (item.to === '/' ? pathname === '/' : pathname.startsWith(item.to)) : false |
|
||||
|
const isActive = item.to |
||||
|
? item.to === '/' || item.to === '/services' || item.to === '/tours' |
||||
|
? pathname === item.to |
||||
|
: pathname === item.to || pathname.startsWith(item.to + '/') |
||||
|
: false |
||||
|
const isBilling = item.to === '/billing' |
||||
const inner = ( |
const inner = ( |
||||
<> |
<> |
||||
<Ic name={item.icon} className="size-4 shrink-0" /> |
<Ic name={item.icon} className="size-4 shrink-0" /> |
||||
<span className="whitespace-nowrap">{item.title}</span> |
<span className="whitespace-nowrap">{item.title}</span> |
||||
<Tail badge={item.badge} soon={item.soon} /> |
|
||||
|
{isBilling ? ( |
||||
|
<BillingSidebarBadges /> |
||||
|
) : ( |
||||
|
<Tail badge={item.badge} soon={item.soon} /> |
||||
|
)} |
||||
</> |
</> |
||||
) |
) |
||||
|
|
||||
@ -140,8 +288,9 @@ function NavItem({ item }: { item: NavItemConfig }) { |
|||||
function SidebarUserFooter() { |
function SidebarUserFooter() { |
||||
const { user } = useAuth() |
const { user } = useAuth() |
||||
const logout = useLogout() |
const logout = useLogout() |
||||
|
const { t } = useTranslation() |
||||
|
|
||||
const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'م' |
|
||||
|
const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'A' |
||||
|
|
||||
return ( |
return ( |
||||
<SidebarFooter className="mt-auto pb-4"> |
<SidebarFooter className="mt-auto pb-4"> |
||||
@ -152,7 +301,7 @@ function SidebarUserFooter() { |
|||||
</div> |
</div> |
||||
<div className="min-w-0 flex-1"> |
<div className="min-w-0 flex-1"> |
||||
<div className="truncate text-xs font-bold text-grey-1"> |
<div className="truncate text-xs font-bold text-grey-1"> |
||||
{user?.name?.trim() || 'مدیر کل سیستم'} |
|
||||
|
{user?.name?.trim() || t('navigation.adminRole')} |
||||
</div> |
</div> |
||||
<div dir="ltr" className="truncate text-start text-[11px] text-grey-3"> |
<div dir="ltr" className="truncate text-start text-[11px] text-grey-3"> |
||||
{user?.email ?? '[email protected]'} |
{user?.email ?? '[email protected]'} |
||||
@ -162,12 +311,12 @@ function SidebarUserFooter() { |
|||||
<SidebarMenu> |
<SidebarMenu> |
||||
<SidebarMenuItem> |
<SidebarMenuItem> |
||||
<SidebarMenuButton |
<SidebarMenuButton |
||||
tooltip="خروج" |
|
||||
|
tooltip={t('navigation.logout')} |
||||
onClick={logout} |
onClick={logout} |
||||
className="text-grey-2 hover:bg-rose-500/10 hover:text-rose-400" |
className="text-grey-2 hover:bg-rose-500/10 hover:text-rose-400" |
||||
> |
> |
||||
<Ic name="logout" className="size-4 shrink-0" /> |
<Ic name="logout" className="size-4 shrink-0" /> |
||||
<span className="truncate">خروج از سیستم</span> |
|
||||
|
<span className="truncate">{t('navigation.logout')}</span> |
||||
</SidebarMenuButton> |
</SidebarMenuButton> |
||||
</SidebarMenuItem> |
</SidebarMenuItem> |
||||
</SidebarMenu> |
</SidebarMenu> |
||||
@ -176,8 +325,11 @@ function SidebarUserFooter() { |
|||||
} |
} |
||||
|
|
||||
export default function AppSidebar() { |
export default function AppSidebar() { |
||||
|
const { t, dir } = useTranslation() |
||||
|
const navConfig = React.useMemo(() => getNavigationConfig(t), [t]) |
||||
|
|
||||
return ( |
return ( |
||||
<Sidebar side="right" collapsible="offcanvas" className="h-svh"> |
|
||||
|
<Sidebar side={dir === 'ltr' ? 'left' : 'right'} collapsible="offcanvas" className="h-svh"> |
||||
<SidebarHeader className="py-3"> |
<SidebarHeader className="py-3"> |
||||
<div className="flex items-center gap-3 px-2"> |
<div className="flex items-center gap-3 px-2"> |
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-white shadow-md"> |
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-white shadow-md"> |
||||
@ -185,7 +337,7 @@ export default function AppSidebar() { |
|||||
</div> |
</div> |
||||
<div className="min-w-0"> |
<div className="min-w-0"> |
||||
<div className="truncate text-base font-extrabold leading-tight text-foreground"> |
<div className="truncate text-base font-extrabold leading-tight text-foreground"> |
||||
{siteConfig.name} |
|
||||
|
{t('navigation.brand')} |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
@ -193,12 +345,12 @@ export default function AppSidebar() { |
|||||
<SidebarSeparator /> |
<SidebarSeparator /> |
||||
|
|
||||
<SidebarContent className="no-scrollbar px-1 flex-1"> |
<SidebarContent className="no-scrollbar px-1 flex-1"> |
||||
{navigationConfig.map((group, idx) => ( |
|
||||
|
{navConfig.map((group, idx) => ( |
||||
<SidebarGroup key={group.title || idx}> |
<SidebarGroup key={group.title || idx}> |
||||
{group.title ? <SidebarGroupLabel>{group.title}</SidebarGroupLabel> : null} |
{group.title ? <SidebarGroupLabel>{group.title}</SidebarGroupLabel> : null} |
||||
<SidebarMenu> |
<SidebarMenu> |
||||
{group.items.map((item) => ( |
{group.items.map((item) => ( |
||||
<NavItem key={item.title} item={item} /> |
|
||||
|
<NavItem key={item.titleKey || item.title} item={item} /> |
||||
))} |
))} |
||||
</SidebarMenu> |
</SidebarMenu> |
||||
</SidebarGroup> |
</SidebarGroup> |
||||
|
|||||
@ -0,0 +1,67 @@ |
|||||
|
import React from 'react' |
||||
|
import { |
||||
|
DropdownMenu, |
||||
|
DropdownMenuTrigger, |
||||
|
DropdownMenuContent, |
||||
|
DropdownMenuItem, |
||||
|
DropdownMenuLabel, |
||||
|
DropdownMenuSeparator, |
||||
|
} from '@/components/ui/dropdown-menu' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { useTranslation, SupportedLanguage } from '@/i18n' |
||||
|
|
||||
|
export default function LanguageSwitcher() { |
||||
|
const { language, setLanguage, languages, t, currentLanguageInfo } = useTranslation() |
||||
|
|
||||
|
return ( |
||||
|
<DropdownMenu> |
||||
|
<DropdownMenuTrigger asChild> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
className="h-9 px-2.5 sm:px-3 rounded-xl border-border/70 bg-card/40 hover:bg-card text-xs font-semibold gap-2 shadow-xs transition-all" |
||||
|
title={t('topbar.language')} |
||||
|
> |
||||
|
<span className="text-sm leading-none">{currentLanguageInfo.flag}</span> |
||||
|
<span className="hidden sm:inline-block text-[12px] text-foreground"> |
||||
|
{currentLanguageInfo.nativeName} |
||||
|
</span> |
||||
|
<Ic name="chevDown" className="size-3 text-muted-foreground transition-transform opacity-70" /> |
||||
|
</Button> |
||||
|
</DropdownMenuTrigger> |
||||
|
|
||||
|
<DropdownMenuContent align="end" className="w-44 rounded-2xl p-1.5 shadow-xl border-border/70"> |
||||
|
<DropdownMenuLabel className="text-[11px] font-bold text-muted-foreground px-2 py-1"> |
||||
|
{t('topbar.selectLanguage')} |
||||
|
</DropdownMenuLabel> |
||||
|
<DropdownMenuSeparator /> |
||||
|
|
||||
|
{languages.map((item) => { |
||||
|
const isSelected = item.code === language |
||||
|
return ( |
||||
|
<DropdownMenuItem |
||||
|
key={item.code} |
||||
|
onSelect={() => setLanguage(item.code as SupportedLanguage)} |
||||
|
onClick={() => setLanguage(item.code as SupportedLanguage)} |
||||
|
className={`flex items-center justify-between px-2.5 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors ${ |
||||
|
isSelected |
||||
|
? 'bg-primary/15 text-primary font-bold shadow-2xs' |
||||
|
: 'text-foreground hover:bg-muted/50' |
||||
|
}`}
|
||||
|
> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="text-base leading-none">{item.flag}</span> |
||||
|
<span>{item.nativeName}</span> |
||||
|
<span className="text-[10px] text-muted-foreground uppercase font-mono"> |
||||
|
({item.code}) |
||||
|
</span> |
||||
|
</div> |
||||
|
{isSelected && <Ic name="check" className="size-3.5 text-primary" />} |
||||
|
</DropdownMenuItem> |
||||
|
) |
||||
|
})} |
||||
|
</DropdownMenuContent> |
||||
|
</DropdownMenu> |
||||
|
) |
||||
|
} |
||||
1368
src/features/services/components/FaqServiceView.tsx
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,141 @@ |
|||||
|
import { create } from 'zustand' |
||||
|
import { persist } from 'zustand/middleware' |
||||
|
import fa from '@/locales/fa.json' |
||||
|
import ar from '@/locales/ar.json' |
||||
|
import en from '@/locales/en.json' |
||||
|
|
||||
|
export type SupportedLanguage = 'fa' | 'ar' | 'en' |
||||
|
export type Direction = 'rtl' | 'ltr' |
||||
|
|
||||
|
export interface LanguageOption { |
||||
|
code: SupportedLanguage |
||||
|
label: string |
||||
|
nativeName: string |
||||
|
dir: Direction |
||||
|
flag: string |
||||
|
} |
||||
|
|
||||
|
export const SUPPORTED_LANGUAGES: LanguageOption[] = [ |
||||
|
{ code: 'fa', label: 'فارسی (FA)', nativeName: 'فارسی', dir: 'rtl', flag: '🇮🇷' }, |
||||
|
{ code: 'ar', label: 'العربية (AR)', nativeName: 'العربية', dir: 'rtl', flag: '🇸🇦' }, |
||||
|
{ code: 'en', label: 'English (EN)', nativeName: 'English', dir: 'ltr', flag: '🇬🇧' }, |
||||
|
] |
||||
|
|
||||
|
const dictionaries: Record<SupportedLanguage, Record<string, any>> = { |
||||
|
fa, |
||||
|
ar, |
||||
|
en, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت مقدار ترجمه بر اساس کلید نقطهای (dot-notation) |
||||
|
* پشتیبانی از fallback به فارسی و پارامترهای درونمتنی {key} |
||||
|
*/ |
||||
|
export function translate( |
||||
|
lang: SupportedLanguage, |
||||
|
key: string, |
||||
|
params?: Record<string, string | number> |
||||
|
): string { |
||||
|
const getNestedValue = (obj: any, path: string): string | undefined => { |
||||
|
const keys = path.split('.') |
||||
|
let current = obj |
||||
|
for (const k of keys) { |
||||
|
if (current && typeof current === 'object' && k in current) { |
||||
|
current = current[k] |
||||
|
} else { |
||||
|
return undefined |
||||
|
} |
||||
|
} |
||||
|
return typeof current === 'string' ? current : undefined |
||||
|
} |
||||
|
|
||||
|
// تلاش برای خواندن از زبان فعلی
|
||||
|
let text = getNestedValue(dictionaries[lang], key) |
||||
|
|
||||
|
// در صورت نبودن، تلاش برای خواندن از زبان فارسی (Fallback)
|
||||
|
if (!text && lang !== 'fa') { |
||||
|
text = getNestedValue(dictionaries.fa, key) |
||||
|
} |
||||
|
|
||||
|
// اگر کلاً پیدا نشد، خود کلید بازگردانده شود
|
||||
|
if (!text) { |
||||
|
const lastPart = key.split('.').pop() || key |
||||
|
return lastPart |
||||
|
} |
||||
|
|
||||
|
// جایگذاری پارامترها مانند {current} یا {total}
|
||||
|
if (params) { |
||||
|
Object.entries(params).forEach(([paramKey, paramVal]) => { |
||||
|
text = text!.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramVal)) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return text |
||||
|
} |
||||
|
|
||||
|
export function applyLanguageToDOM(lang: SupportedLanguage) { |
||||
|
if (typeof document !== 'undefined') { |
||||
|
const isLtr = lang === 'en' |
||||
|
document.documentElement.lang = lang |
||||
|
document.documentElement.dir = isLtr ? 'ltr' : 'rtl' |
||||
|
if (isLtr) { |
||||
|
document.documentElement.classList.add('lang-en') |
||||
|
document.documentElement.classList.remove('lang-rtl') |
||||
|
} else { |
||||
|
document.documentElement.classList.add('lang-rtl') |
||||
|
document.documentElement.classList.remove('lang-en') |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
interface LanguageState { |
||||
|
language: SupportedLanguage |
||||
|
setLanguage: (lang: SupportedLanguage) => void |
||||
|
} |
||||
|
|
||||
|
export const useLanguageStore = create<LanguageState>()( |
||||
|
persist( |
||||
|
(set) => ({ |
||||
|
language: 'fa', |
||||
|
setLanguage: (lang: SupportedLanguage) => { |
||||
|
set({ language: lang }) |
||||
|
applyLanguageToDOM(lang) |
||||
|
}, |
||||
|
}), |
||||
|
{ |
||||
|
name: 'aqila_panel_language', |
||||
|
onRehydrateStorage: () => (state) => { |
||||
|
const lang = state?.language || 'fa' |
||||
|
applyLanguageToDOM(lang) |
||||
|
}, |
||||
|
} |
||||
|
) |
||||
|
) |
||||
|
|
||||
|
/** |
||||
|
* هوک سراسری ترجمه جهت استفاده آسان در تمامی کامپوننتهای پنل |
||||
|
*/ |
||||
|
export function useTranslation() { |
||||
|
const language = useLanguageStore((s) => s.language) |
||||
|
const setLanguage = useLanguageStore((s) => s.setLanguage) |
||||
|
|
||||
|
const dir: Direction = language === 'en' ? 'ltr' : 'rtl' |
||||
|
const isRTL = dir === 'rtl' |
||||
|
|
||||
|
const t = (key: string, params?: Record<string, string | number>): string => { |
||||
|
return translate(language, key, params) |
||||
|
} |
||||
|
|
||||
|
const currentLanguageInfo = |
||||
|
SUPPORTED_LANGUAGES.find((l) => l.code === language) || SUPPORTED_LANGUAGES[0] |
||||
|
|
||||
|
return { |
||||
|
t, |
||||
|
language, |
||||
|
setLanguage, |
||||
|
dir, |
||||
|
isRTL, |
||||
|
languages: SUPPORTED_LANGUAGES, |
||||
|
currentLanguageInfo, |
||||
|
} |
||||
|
} |
||||
@ -1,4 +1,5 @@ |
|||||
@import url('https://cdn.jsdelivr.net/gh/rastikerdar/[email protected]/Vazirmatn-font-face.css'); |
@import url('https://cdn.jsdelivr.net/gh/rastikerdar/[email protected]/Vazirmatn-font-face.css'); |
||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap'); |
||||
@import 'tailwindcss'; |
@import 'tailwindcss'; |
||||
|
|
||||
/* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */ |
/* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */ |
||||
@ -84,7 +85,8 @@ html.light { |
|||||
} |
} |
||||
|
|
||||
@theme inline { |
@theme inline { |
||||
--font-sans: 'Vazirmatn', system-ui, sans-serif; |
|
||||
|
--font-sans: 'Vazirmatn', 'Inter', system-ui, sans-serif; |
||||
|
--font-mono: 'JetBrains Mono', 'Inter', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; |
||||
--color-background: var(--background); |
--color-background: var(--background); |
||||
--color-foreground: var(--foreground); |
--color-foreground: var(--foreground); |
||||
--color-card: var(--card); |
--color-card: var(--card); |
||||
|
|||||
@ -0,0 +1,133 @@ |
|||||
|
{ |
||||
|
"navigation": { |
||||
|
"brand": "لوحة إدارة عقيلة", |
||||
|
"brandShort": "عقيلة", |
||||
|
"dashboard": "لوحة القيادة", |
||||
|
"dashboardTitle": "لوحة إدارة النظام", |
||||
|
"tours": "الجولات", |
||||
|
"allTours": "جميع الجولات", |
||||
|
"ongoingTours": "الجولات الجارية", |
||||
|
"tourOrders": "الطلبات والحجوزات", |
||||
|
"services": "مركز الخدمات (الكل)", |
||||
|
"simcard": "شرائح الاتصال (Simcard)", |
||||
|
"zayerGuide": "دليل الزائر (Zayer Guide)", |
||||
|
"exchange": "الصرافة الإلكترونية (Exchange)", |
||||
|
"duas": "الأدعية والزيارات (Duas)", |
||||
|
"duasList": "الأدعية والزيارات", |
||||
|
"categories": "التصنيفات", |
||||
|
"shrines": "المزارات والعتبات (Shrines)", |
||||
|
"store": "متجر معصومة (Store)", |
||||
|
"costEstimate": "تقدير التكاليف (Cost Estimate)", |
||||
|
"support": "الدعم والاستشارات (Support)", |
||||
|
"supporters": "فريق الدعم (Supporters)", |
||||
|
"faq": "الأسئلة الشائعة (FAQ)", |
||||
|
"faqCategories": "التصنيفات الموضوعية", |
||||
|
"faqQuestions": "الأسئلة والأجوبة", |
||||
|
"consultant": "الدعم والاستشارات (Support)", |
||||
|
"billing": "الفواتير والمالية", |
||||
|
"settings": "الإعدادات", |
||||
|
"logout": "تسجيل الخروج", |
||||
|
"soon": "قريباً", |
||||
|
"adminRole": "المدير العام للنظام" |
||||
|
}, |
||||
|
"topbar": { |
||||
|
"serverOnline": "الخادم متصل", |
||||
|
"serverOffline": "انقطع الاتصال بالخادم", |
||||
|
"theme": "المظهر", |
||||
|
"language": "تغيير اللغة", |
||||
|
"selectLanguage": "اختر لغة النظام", |
||||
|
"search": "بحث..." |
||||
|
}, |
||||
|
"common": { |
||||
|
"save": "حفظ التغييرات", |
||||
|
"cancel": "إلغاء", |
||||
|
"confirm": "تأكيد", |
||||
|
"delete": "حذف", |
||||
|
"edit": "تعديل", |
||||
|
"close": "إغلاق", |
||||
|
"back": "رجوع", |
||||
|
"refresh": "تحديث", |
||||
|
"loading": "جاري التحميل...", |
||||
|
"status": "الحالة", |
||||
|
"actions": "الإجراءات", |
||||
|
"all": "الكل", |
||||
|
"active": "نشط", |
||||
|
"inactive": "غير نشط", |
||||
|
"noData": "لا توجد بيانات للعرض.", |
||||
|
"pageOf": "صفحة {current} من {total}", |
||||
|
"success": "تمت العملية بنجاح.", |
||||
|
"error": "حدث خطأ في الاتصال بالخادم.", |
||||
|
"retry": "إعادة المحاولة", |
||||
|
"previous": "السابق", |
||||
|
"next": "التالي", |
||||
|
"showing": "عرض", |
||||
|
"to": "إلى", |
||||
|
"of": "من" |
||||
|
}, |
||||
|
"billing": { |
||||
|
"title": "إدارة الفواتير والمدفوعات", |
||||
|
"description": "مراجعة إيصالات التحويل البنكي، واعتماد أو رفض المدفوعات ومتابعة تفاصيل فواتير خدمات عقيلة", |
||||
|
"totalInvoices": "إجمالي الفواتير", |
||||
|
"pendingReview": "بانتظار مراجعة الإيصال", |
||||
|
"awaitingPayment": "بانتظار دفع العميل", |
||||
|
"approvedPayments": "المدفوعات المعتمدة", |
||||
|
"rejectedOrExpired": "مرفوض / منتهي", |
||||
|
"searchPlaceholder": "البحث في الفواتير (اسم المستخدم، البريد، رقم البطاقة، رقم الفاتورة)...", |
||||
|
"statusFilter": "حالة الفاتورة", |
||||
|
"serviceFilter": "نوع الخدمة", |
||||
|
"clearFilters": "مسح الفلاتر", |
||||
|
"invoiceList": "قائمة الفواتير", |
||||
|
"customer": "العميل", |
||||
|
"amount": "المبلغ", |
||||
|
"service": "الخدمة", |
||||
|
"date": "تاريخ الإصدار", |
||||
|
"receipt": "إيصال الدفع", |
||||
|
"hasReceipt": "عرض الإيصال", |
||||
|
"noReceipt": "لا يوجد", |
||||
|
"approve": "اعتماد الدفع", |
||||
|
"reject": "رفض الفاتورة", |
||||
|
"rejectTitle": "رفض الفاتورة", |
||||
|
"rejectReason": "سبب رفض الفاتورة / الإيصال البنكي:", |
||||
|
"rejectReasonPlaceholder": "مثال: الإيصال غير واضح، المبلغ غير مطابق، رقم المعاملة غير صحيح...", |
||||
|
"rejectNotice": "سيتم عرض هذا التوضيح في تطبيق المسافر ضمن تفاصيل الفاتورة.", |
||||
|
"submitReject": "تسجيل ورفض الفاتورة", |
||||
|
"submitting": "جاري التسجيل...", |
||||
|
"statusPending": "قيد مراجعة الإيصال", |
||||
|
"statusAwaiting": "بانتظار الدفع", |
||||
|
"statusApproved": "تم الدفع والاعتماد", |
||||
|
"statusRejected": "مرفوض", |
||||
|
"statusExpired": "منتهي الصلاحية", |
||||
|
"invoice": "فاتورة", |
||||
|
"invoices": "فواتير", |
||||
|
"id": "المعرف", |
||||
|
"viewDetails": "التفاصيل", |
||||
|
"viewReceipt": "عرض إيصال الدفع", |
||||
|
"noReceiptUploaded": "لم يتم رفع إيصال", |
||||
|
"serviceTour": "جولات الزيارة", |
||||
|
"serviceSimcard": "شرائح الاتصال", |
||||
|
"serviceShop": "متجر الهدايا", |
||||
|
"serviceExchange": "الصرافة (تصريف)", |
||||
|
"serviceCustomTrip": "رحلة خاصة", |
||||
|
"allStatuses": "جميع الحالات", |
||||
|
"allServices": "جميع الخدمات", |
||||
|
"noFactorsFound": "لم يتم العثور على فواتير.", |
||||
|
"noFactorsFilter": "لم يتم العثور على نتائج مطابقة للفلاتر الحالية. يمكنك مسح الفلاتر.", |
||||
|
"noFactorsEmpty": "لم تصدر أي فواتير في النظام بعد.", |
||||
|
"confirmClearReceipt": "هل أنت متأكد من حذف صورة الإيصال المرفوعة لهذا الفاتورة؟", |
||||
|
"receiptClearedSuccess": "تم مسح إيصال الدفع بنجاح.", |
||||
|
"tabAll": "جميع المعاملات", |
||||
|
"tabTour": "طلبات وحجوزات الجولات", |
||||
|
"tabSimcard": "شرائح الاتصال", |
||||
|
"tabExchange": "الصرافة والعملات", |
||||
|
"tabShop": "متجر الهدايا", |
||||
|
"tabCustomTrip": "الرحلات الخاصة", |
||||
|
"coreTourBadge": "المحور الأساسي", |
||||
|
"liveStatus": "الحالة اللحظية", |
||||
|
"quickFilter": "فلترة الحالة" |
||||
|
}, |
||||
|
"languages": { |
||||
|
"fa": "فارسی", |
||||
|
"ar": "العربية", |
||||
|
"en": "English" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,133 @@ |
|||||
|
{ |
||||
|
"navigation": { |
||||
|
"brand": "Aqila Admin Panel", |
||||
|
"brandShort": "Aqila", |
||||
|
"dashboard": "Dashboard", |
||||
|
"dashboardTitle": "Admin Dashboard", |
||||
|
"tours": "Tours", |
||||
|
"allTours": "All Tours", |
||||
|
"ongoingTours": "Ongoing Tours", |
||||
|
"tourOrders": "Orders & Bookings", |
||||
|
"services": "Services Center", |
||||
|
"simcard": "SIM Card", |
||||
|
"zayerGuide": "Zayer Guide", |
||||
|
"exchange": "Online Exchange", |
||||
|
"duas": "Duas & Ziyarat", |
||||
|
"duasList": "Duas & Ziyarat", |
||||
|
"categories": "Categories", |
||||
|
"shrines": "Holy Shrines", |
||||
|
"store": "Massoumah Store", |
||||
|
"costEstimate": "Cost Estimate", |
||||
|
"support": "Support Center", |
||||
|
"supporters": "Support Staff", |
||||
|
"faq": "FAQ", |
||||
|
"faqCategories": "Subject Categories", |
||||
|
"faqQuestions": "Questions & Answers", |
||||
|
"consultant": "Support", |
||||
|
"billing": "Billing & Finance", |
||||
|
"settings": "Settings", |
||||
|
"logout": "Logout", |
||||
|
"soon": "Soon", |
||||
|
"adminRole": "System Administrator" |
||||
|
}, |
||||
|
"topbar": { |
||||
|
"serverOnline": "Server is Online", |
||||
|
"serverOffline": "Server Disconnected", |
||||
|
"theme": "Theme", |
||||
|
"language": "Language", |
||||
|
"selectLanguage": "Select System Language", |
||||
|
"search": "Search..." |
||||
|
}, |
||||
|
"common": { |
||||
|
"save": "Save Changes", |
||||
|
"cancel": "Cancel", |
||||
|
"confirm": "Confirm", |
||||
|
"delete": "Delete", |
||||
|
"edit": "Edit", |
||||
|
"close": "Close", |
||||
|
"back": "Back", |
||||
|
"refresh": "Refresh", |
||||
|
"loading": "Loading...", |
||||
|
"status": "Status", |
||||
|
"actions": "Actions", |
||||
|
"all": "All", |
||||
|
"active": "Active", |
||||
|
"inactive": "Inactive", |
||||
|
"noData": "No records found.", |
||||
|
"pageOf": "Page {current} of {total}", |
||||
|
"success": "Operation completed successfully.", |
||||
|
"error": "Server connection error.", |
||||
|
"retry": "Retry", |
||||
|
"previous": "Previous", |
||||
|
"next": "Next", |
||||
|
"showing": "Showing", |
||||
|
"to": "to", |
||||
|
"of": "of" |
||||
|
}, |
||||
|
"billing": { |
||||
|
"title": "Invoices & Billing Management", |
||||
|
"description": "Review bank receipts, approve or reject payments, and track invoices across Aqila services", |
||||
|
"totalInvoices": "Total Invoices", |
||||
|
"pendingReview": "Pending Receipt Review", |
||||
|
"awaitingPayment": "Awaiting Customer Payment", |
||||
|
"approvedPayments": "Approved Payments", |
||||
|
"rejectedOrExpired": "Rejected / Expired", |
||||
|
"searchPlaceholder": "Search invoices (Customer name, email, card number, invoice ID)...", |
||||
|
"statusFilter": "Invoice Status", |
||||
|
"serviceFilter": "Service Type", |
||||
|
"clearFilters": "Clear Filters", |
||||
|
"invoiceList": "Invoice List", |
||||
|
"customer": "Customer", |
||||
|
"amount": "Amount", |
||||
|
"service": "Service", |
||||
|
"date": "Issue Date", |
||||
|
"receipt": "Payment Receipt", |
||||
|
"hasReceipt": "View Receipt", |
||||
|
"noReceipt": "None", |
||||
|
"approve": "Approve Payment", |
||||
|
"reject": "Reject Invoice", |
||||
|
"rejectTitle": "Reject Invoice", |
||||
|
"rejectReason": "Rejection Reason / Bank Receipt:", |
||||
|
"rejectReasonPlaceholder": "e.g., Unreadable receipt, amount mismatch, invalid transaction reference...", |
||||
|
"rejectNotice": "This explanation will be displayed to the traveler in their invoice details.", |
||||
|
"submitReject": "Submit & Reject Invoice", |
||||
|
"submitting": "Submitting...", |
||||
|
"statusPending": "Pending Receipt Review", |
||||
|
"statusAwaiting": "Awaiting Payment", |
||||
|
"statusApproved": "Payment Approved", |
||||
|
"statusRejected": "Rejected", |
||||
|
"statusExpired": "Expired", |
||||
|
"invoice": "Invoice", |
||||
|
"invoices": "Invoices", |
||||
|
"id": "ID", |
||||
|
"viewDetails": "Details", |
||||
|
"viewReceipt": "View Receipt", |
||||
|
"noReceiptUploaded": "No receipt uploaded", |
||||
|
"serviceTour": "Pilgrimage Tours", |
||||
|
"serviceSimcard": "SIM Card", |
||||
|
"serviceShop": "Massoumah Store", |
||||
|
"serviceExchange": "Exchange (Tasreef)", |
||||
|
"serviceCustomTrip": "Custom Trip", |
||||
|
"allStatuses": "All Statuses", |
||||
|
"allServices": "All Services", |
||||
|
"noFactorsFound": "No invoices found.", |
||||
|
"noFactorsFilter": "No records match current filters. You can clear filters.", |
||||
|
"noFactorsEmpty": "No invoices have been issued in the system yet.", |
||||
|
"confirmClearReceipt": "Are you sure you want to remove the uploaded receipt image?", |
||||
|
"receiptClearedSuccess": "Payment receipt cleared successfully.", |
||||
|
"tabAll": "All Transactions", |
||||
|
"tabTour": "Tour Orders & Bookings", |
||||
|
"tabSimcard": "SIM Cards", |
||||
|
"tabExchange": "Currency & Exchange", |
||||
|
"tabShop": "Souvenir Store", |
||||
|
"tabCustomTrip": "Custom Trips", |
||||
|
"coreTourBadge": "Primary Core", |
||||
|
"liveStatus": "Live Status", |
||||
|
"quickFilter": "Status Filter" |
||||
|
}, |
||||
|
"languages": { |
||||
|
"fa": "فارسی", |
||||
|
"ar": "العربية", |
||||
|
"en": "English" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,133 @@ |
|||||
|
{ |
||||
|
"navigation": { |
||||
|
"brand": "پنل مدیریت عقیله", |
||||
|
"brandShort": "عقیله", |
||||
|
"dashboard": "داشبورد", |
||||
|
"dashboardTitle": "داشبورد مدیریت", |
||||
|
"tours": "تورها", |
||||
|
"allTours": "فهرست همه تورها", |
||||
|
"ongoingTours": "تورهای در حال برگزاری", |
||||
|
"tourOrders": "سفارشات و رزروها", |
||||
|
"services": "مرکز خدمات (همه)", |
||||
|
"simcard": "سیمکارت (Simcard)", |
||||
|
"zayerGuide": "راهنمای زائر (Zayer Guide)", |
||||
|
"exchange": "صرافی آنلاین (Exchange)", |
||||
|
"duas": "ادعیه و زیارات (Duas)", |
||||
|
"duasList": "ادعیه و زیارات", |
||||
|
"categories": "دستهبندیها", |
||||
|
"shrines": "اماکن زیارتی (Shrines)", |
||||
|
"store": "فروشگاه معصومه (Store)", |
||||
|
"costEstimate": "تخمین هزینه (Cost Estimate)", |
||||
|
"support": "پشتیبانی (Support)", |
||||
|
"supporters": "پشتیبانها (Supporters)", |
||||
|
"faq": "سوالات متداول (FAQ)", |
||||
|
"faqCategories": "دستهبندیهای موضوعی", |
||||
|
"faqQuestions": "پرسش و پاسخها", |
||||
|
"consultant": "پشتیبانی (Support)", |
||||
|
"billing": "صورتحسابها و مالی", |
||||
|
"settings": "تنظیمات", |
||||
|
"logout": "خروج از سیستم", |
||||
|
"soon": "بهزودی", |
||||
|
"adminRole": "مدیر کل سیستم" |
||||
|
}, |
||||
|
"topbar": { |
||||
|
"serverOnline": "سرور متصل است", |
||||
|
"serverOffline": "قطع ارتباط با سرور", |
||||
|
"theme": "تغییر تم", |
||||
|
"language": "تغییر زبان", |
||||
|
"selectLanguage": "انتخاب زبان سامانه", |
||||
|
"search": "جستجو..." |
||||
|
}, |
||||
|
"common": { |
||||
|
"save": "ذخیره تغییرات", |
||||
|
"cancel": "انصراف", |
||||
|
"confirm": "تایید", |
||||
|
"delete": "حذف", |
||||
|
"edit": "ویرایش", |
||||
|
"close": "بستن", |
||||
|
"back": "بازگشت", |
||||
|
"refresh": "بهروزرسانی", |
||||
|
"loading": "در حال بارگذاری...", |
||||
|
"status": "وضعیت", |
||||
|
"actions": "عملیات", |
||||
|
"all": "همه", |
||||
|
"active": "فعال", |
||||
|
"inactive": "غیرفعال", |
||||
|
"noData": "موردی برای نمایش یافت نشد.", |
||||
|
"pageOf": "صفحه {current} از {total}", |
||||
|
"success": "عملیات با موفقیت انجام شد.", |
||||
|
"error": "خطا در برقراری ارتباط با سرور.", |
||||
|
"retry": "تلاش مجدد", |
||||
|
"previous": "قبلی", |
||||
|
"next": "بعدی", |
||||
|
"showing": "نمایش", |
||||
|
"to": "تا", |
||||
|
"of": "از" |
||||
|
}, |
||||
|
"billing": { |
||||
|
"title": "مدیریت صورتحسابها و فاکتورها", |
||||
|
"description": "بررسی فیشهای بانکی، تایید یا رد پرداختها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله", |
||||
|
"totalInvoices": "کل صورتحسابها", |
||||
|
"pendingReview": "در انتظار بررسی فیش", |
||||
|
"awaitingPayment": "در انتظار پرداخت مشتری", |
||||
|
"approvedPayments": "پرداختهای تایید شده", |
||||
|
"rejectedOrExpired": "رد شده / منقضی", |
||||
|
"searchPlaceholder": "جستجو در فاکتورها (نام کاربر، ایمیل، شماره کارت، شماره فاکتور)...", |
||||
|
"statusFilter": "وضعیت فاکتور", |
||||
|
"serviceFilter": "نوع سرویس", |
||||
|
"clearFilters": "پاکسازی فیلترها", |
||||
|
"invoiceList": "فهرست فاکتورها", |
||||
|
"customer": "مشتری", |
||||
|
"amount": "مبلغ", |
||||
|
"service": "سرویس", |
||||
|
"date": "تاریخ صدور", |
||||
|
"receipt": "فیش پرداختی", |
||||
|
"hasReceipt": "مشاهده فیش", |
||||
|
"noReceipt": "ندارد", |
||||
|
"approve": "تایید پرداخت", |
||||
|
"reject": "رد صورتحساب", |
||||
|
"rejectTitle": "رد صورتحساب", |
||||
|
"rejectReason": "دلیل رد صورتحساب / فیش پرداختی:", |
||||
|
"rejectReasonPlaceholder": "مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، شماره رهگیری نامعتبر است...", |
||||
|
"rejectNotice": "این توضیحات در اپلیکیشن مسافر / کلاینت در بخش جزئیات صورتحساب نمایش داده میشود.", |
||||
|
"submitReject": "ثبت و رد فاکتور", |
||||
|
"submitting": "در حال ثبت...", |
||||
|
"statusPending": "در انتظار بررسی فیش", |
||||
|
"statusAwaiting": "در انتظار پرداخت", |
||||
|
"statusApproved": "پرداخت تایید شده", |
||||
|
"statusRejected": "رد شده", |
||||
|
"statusExpired": "منقضی شده", |
||||
|
"invoice": "فاکتور", |
||||
|
"invoices": "فاکتور", |
||||
|
"id": "شناسه", |
||||
|
"viewDetails": "جزئیات", |
||||
|
"viewReceipt": "مشاهده فیش واریزی", |
||||
|
"noReceiptUploaded": "فیش واریزی بارگذاری نشده", |
||||
|
"serviceTour": "تورهای زیارتی", |
||||
|
"serviceSimcard": "سیمکارت", |
||||
|
"serviceShop": "فروشگاه سوغات", |
||||
|
"serviceExchange": "صرافی (تسریف)", |
||||
|
"serviceCustomTrip": "سفر اختصاصی", |
||||
|
"allStatuses": "همه وضعیتها", |
||||
|
"allServices": "همه سرویسها", |
||||
|
"noFactorsFound": "صورتحسابی یافت نشد.", |
||||
|
"noFactorsFilter": "با فیلترهای جستجوی فعلی موردی پیدا نشد. میتوانید فیلترها را پاکسازی نمایید.", |
||||
|
"noFactorsEmpty": "هنوز هیچ صورتحسابی در سیستم صادر نشده است.", |
||||
|
"confirmClearReceipt": "آیا از حذف تصویر فیش بارگذاریشده برای این صورتحساب اطمینان دارید؟", |
||||
|
"receiptClearedSuccess": "فیش واریزی با موفقیت پاک شد.", |
||||
|
"tabAll": "همه تراکنشها", |
||||
|
"tabTour": "سفارشات و رزروهای تور", |
||||
|
"tabSimcard": "خرید سیمکارت", |
||||
|
"tabExchange": "صرافی و ارز", |
||||
|
"tabShop": "فروشگاه سوغات", |
||||
|
"tabCustomTrip": "سفر اختصاصی", |
||||
|
"coreTourBadge": "محور اصلی", |
||||
|
"liveStatus": "وضعیت لحظهای", |
||||
|
"quickFilter": "فیلتر وضعیت" |
||||
|
}, |
||||
|
"languages": { |
||||
|
"fa": "فارسی", |
||||
|
"ar": "العربية", |
||||
|
"en": "English" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,39 @@ |
|||||
|
import { create } from 'zustand' |
||||
|
import { fetchFactors } from '@/features/billing/services/billing-api' |
||||
|
|
||||
|
interface BillingBadgeState { |
||||
|
pendingReviewCount: number |
||||
|
awaitingPaymentCount: number |
||||
|
isLoading: boolean |
||||
|
lastFetched: number | null |
||||
|
fetchCounts: () => Promise<void> |
||||
|
setCounts: (pending: number, awaiting: number) => void |
||||
|
} |
||||
|
|
||||
|
export const useBillingBadgeStore = create<BillingBadgeState>((set) => ({ |
||||
|
pendingReviewCount: 0, |
||||
|
awaitingPaymentCount: 0, |
||||
|
isLoading: false, |
||||
|
lastFetched: null, |
||||
|
fetchCounts: async () => { |
||||
|
try { |
||||
|
set({ isLoading: true }) |
||||
|
// دریافت تعداد فاکتورهای در انتظار بررسی فیش (Pending Review)
|
||||
|
const pendingRes = await fetchFactors({ status: 'pending', limit: 1 }) |
||||
|
// دریافت تعداد فاکتورهای در انتظار پرداخت کاربر (Awaiting Payment)
|
||||
|
const awaitingRes = await fetchFactors({ status: 'awaiting_payment', limit: 1 }) |
||||
|
|
||||
|
set({ |
||||
|
pendingReviewCount: pendingRes?.count || 0, |
||||
|
awaitingPaymentCount: awaitingRes?.count || 0, |
||||
|
isLoading: false, |
||||
|
lastFetched: Date.now(), |
||||
|
}) |
||||
|
} catch { |
||||
|
set({ isLoading: false }) |
||||
|
} |
||||
|
}, |
||||
|
setCounts: (pending: number, awaiting: number) => { |
||||
|
set({ pendingReviewCount: pending, awaitingPaymentCount: awaiting }) |
||||
|
}, |
||||
|
})) |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue