Browse Source

feat: add core admin panel application with layout, UI components, and feature modules

master
Muhammad A. Ghorbani 3 weeks ago
parent
commit
a5ce6adc58
  1. 4
      src/app/providers.tsx
  2. 2
      src/components/layout/AppSidebar.tsx
  3. 12
      src/components/layout/ServerHealthTrigger.tsx
  4. 9
      src/components/layout/ServerStatusDialog.tsx
  5. 2
      src/components/shared/AppImage.tsx
  6. 5
      src/components/shared/DeleteConfirmDialog.tsx
  7. 190
      src/components/shared/Shimmer.tsx
  8. 390
      src/components/shared/shimmer_frontend.tsx
  9. 42
      src/components/ui/avatar.tsx
  10. 100
      src/components/ui/button.tsx
  11. 10
      src/components/ui/multi-select.tsx
  12. 5
      src/components/ui/shimmer.tsx
  13. 10
      src/components/ui/single-select.tsx
  14. 6
      src/components/ui/skeleton.tsx
  15. 25
      src/components/ui/switch.tsx
  16. 95
      src/components/ui/toast-container.tsx
  17. 21
      src/config/navigation.ts
  18. 8
      src/features/auth/LoginPage.tsx
  19. 146
      src/features/billing/BillingPage.tsx
  20. 553
      src/features/billing/components/BillingDetailModal.tsx
  21. 117
      src/features/billing/components/BillingServiceBadge.tsx
  22. 55
      src/features/billing/components/BillingStatusBadge.tsx
  23. 248
      src/features/billing/components/ReceiptLightboxDialog.tsx
  24. 9
      src/features/billing/components/RejectFactorDialog.tsx
  25. 16
      src/features/billing/services/billing-api.ts
  26. 2
      src/features/services/ServicesPage.tsx
  27. 18
      src/features/services/components/CategoriesServiceView.tsx
  28. 12
      src/features/services/components/DuasServiceView.tsx
  29. 342
      src/features/services/components/FaqServiceView.tsx
  30. 3
      src/features/services/components/LocationDetailSheet.tsx
  31. 7
      src/features/services/components/ShrinesServiceView.tsx
  32. 29
      src/features/services/components/ZayerGuideServiceView.tsx
  33. 270
      src/features/tours/OngoingToursPage.tsx
  34. 46
      src/features/tours/TourOrdersPage.tsx
  35. 177
      src/features/tours/ToursPage.tsx
  36. 236
      src/features/tours/components/CreateTourDialog.tsx
  37. 1740
      src/features/tours/components/TourDetailDialog.tsx
  38. 27
      src/features/tours/components/TourDetailSheet.tsx
  39. 255
      src/features/tours/components/TourDetailShimmer.tsx
  40. 44
      src/features/tours/components/TourStatusBadge.tsx
  41. 20
      src/features/tours/components/TourTranslationsDialog.tsx
  42. 127
      src/features/tours/services/tour-api.ts
  43. 2
      src/features/tours/types.ts
  44. 8
      src/i18n/index.ts
  45. 1
      src/icons.tsx
  46. 48
      src/index.css
  47. 47
      src/lib/utils.ts
  48. 2
      src/locales/ar.json
  49. 2
      src/locales/en.json
  50. 4
      src/locales/fa.json
  51. 18
      src/stores/billing-badge.ts
  52. 63
      src/stores/toast.ts
  53. 4
      vite.config.ts

4
src/app/providers.tsx

@ -5,14 +5,16 @@ import { DirectionProvider } from '@radix-ui/react-direction'
import { queryClient } from '@/lib/query-client' import { queryClient } from '@/lib/query-client'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { ToastContainer } from '@/components/ui/toast-container'
/* پروایدرهای سراسری: React Query + RTL + Tooltips + Router */
/* پروایدرهای سراسری: React Query + RTL + Tooltips + Router + Toasts */
export default function Providers({ children }: { children: React.ReactNode }) { export default function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<DirectionProvider dir="rtl"> <DirectionProvider dir="rtl">
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<BrowserRouter>{children}</BrowserRouter> <BrowserRouter>{children}</BrowserRouter>
<ToastContainer />
</TooltipProvider> </TooltipProvider>
</DirectionProvider> </DirectionProvider>
</QueryClientProvider> </QueryClientProvider>

2
src/components/layout/AppSidebar.tsx

@ -41,7 +41,7 @@ function BillingSidebarBadges() {
{/* ۱. دایره نارنجی: در انتظار بررسی ادمین (فیش پرداخت توسط کاربر آپلود شده) */} {/* ۱. دایره نارنجی: در انتظار بررسی ادمین (فیش پرداخت توسط کاربر آپلود شده) */}
{pendingReviewCount > 0 && ( {pendingReviewCount > 0 && (
<span <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"
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"
title={`${t('billing.pendingReview')}: ${pendingReviewCount}`} title={`${t('billing.pendingReview')}: ${pendingReviewCount}`}
> >
{pendingReviewCount} {pendingReviewCount}

12
src/components/layout/ServerHealthTrigger.tsx

@ -29,19 +29,13 @@ export default function ServerHealthTrigger() {
} }
className="relative flex size-9 items-center justify-center rounded-xl border border-border-soft bg-card/60 transition-all hover:bg-card hover:border-primary/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shadow-sm cursor-pointer" className="relative flex size-9 items-center justify-center rounded-xl border border-border-soft bg-card/60 transition-all hover:bg-card hover:border-primary/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shadow-sm cursor-pointer"
> >
<span className="relative flex size-2.5">
{health.status === 'connected' && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
)}
{health.status === 'error' && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rose-400 opacity-75" />
)}
<span className="flex size-2.5">
<span <span
className={`relative inline-flex size-2.5 rounded-full ${
className={`inline-flex size-2.5 rounded-full ${
health.status === 'connected' health.status === 'connected'
? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]'
: health.status === 'checking' : health.status === 'checking'
? 'bg-amber-400 animate-pulse'
? 'bg-amber-400'
: 'bg-rose-500 shadow-[0_0_8px_#f43f5e]' : 'bg-rose-500 shadow-[0_0_8px_#f43f5e]'
}`} }`}
/> />

9
src/components/layout/ServerStatusDialog.tsx

@ -60,11 +60,12 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing}
isLoading={isRefreshing}
loadingText="در حال تست..."
className="h-8 gap-1.5 text-xs" className="h-8 gap-1.5 text-xs"
> >
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>{isRefreshing ? 'در حال تست...' : 'بررسی مجدد'}</span>
<Ic name="refresh" className="size-3.5" />
<span>بررسی مجدد</span>
</Button> </Button>
</div> </div>
</DialogHeader> </DialogHeader>
@ -79,7 +80,7 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
health.status === 'connected' health.status === 'connected'
? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]'
: health.status === 'checking' : health.status === 'checking'
? 'bg-amber-400 animate-pulse'
? 'bg-amber-400'
: 'bg-rose-500 shadow-[0_0_8px_#f43f5e]' : 'bg-rose-500 shadow-[0_0_8px_#f43f5e]'
}`} }`}
/> />

2
src/components/shared/AppImage.tsx

@ -119,7 +119,7 @@ export function AppImage({
return ( return (
<span className="absolute inset-0 block overflow-hidden"> <span className="absolute inset-0 block overflow-hidden">
{showSkeleton && ( {showSkeleton && (
<span className={cn('absolute inset-0 animate-pulse bg-white/[0.06]', placeholderClassName)} aria-hidden />
<span className={cn('absolute inset-0 shimmer-wave', placeholderClassName)} aria-hidden />
)} )}
{showFallback && fallback} {showFallback && fallback}

5
src/components/shared/DeleteConfirmDialog.tsx

@ -45,12 +45,13 @@ export default function DeleteConfirmDialog({
<AlertDialogFooter className="mt-4 gap-2 sm:justify-start"> <AlertDialogFooter className="mt-4 gap-2 sm:justify-start">
<Button <Button
variant="destructive" variant="destructive"
disabled={isLoading}
isLoading={isLoading}
loadingText="در حال حذف..."
onClick={() => { onClick={() => {
onConfirm() onConfirm()
}} }}
> >
{isLoading ? 'در حال حذف...' : confirmText}
{confirmText}
</Button> </Button>
<AlertDialogCancel asChild> <AlertDialogCancel asChild>
<Button variant="outline" disabled={isLoading}> <Button variant="outline" disabled={isLoading}>

190
src/components/shared/Shimmer.tsx

@ -1,190 +0,0 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Ic, IconName } from '@/icons'
export interface ShimmerProps {
/** تعداد ردیف‌ها یا آیتم‌های اسکلتون (پیش‌فرض: ۵) */
rows?: number
count?: number
/** ارتفاع هر ردیف (پیش‌فرض: h-12) */
rowHeight?: string
/** کلاس‌های سفارشی برای بدنه و والد */
className?: string
/** کلاس‌های سفارشی برای هر آیتم */
itemClassName?: string
/** آیا درون یک Card با کادر و سایه استاندارد قرار گیرد؟ */
withCard?: boolean
/** عنوان هدر کارت در صورت استفاده از withCard */
cardHeaderTitle?: string
/** آیکون هدر کارت */
cardHeaderIcon?: IconName
/** متن سمت چپ هدر کارت */
cardHeaderSub?: React.ReactNode
/** نوع نمایش: ردیفی/جدولی (پیش‌فرض) یا کارت‌های گرید یا KPI */
variant?: 'list' | 'table' | 'grid' | 'kpi'
/** تعداد ستون‌ها در حالت grid (پیش‌فرض: 3) */
gridCols?: number
}
/**
* کامپوننت یکپارچه و استاندارد Shimmer برای Loadingهای پنل مدیریت عقیله
* دارای پالت خنثی با کنتراست استاندارد و انیمیشن روان پالس (در هر دو تم دارک و لایت)
*/
export function Shimmer({
rows,
count = 5,
rowHeight = 'h-12',
className,
itemClassName,
withCard = false,
cardHeaderTitle,
cardHeaderIcon = 'list',
cardHeaderSub,
variant = 'table',
gridCols = 3,
}: ShimmerProps) {
const itemCount = rows !== undefined ? rows : count
let content: React.ReactNode
if (variant === 'grid') {
const colClasses: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
}
content = (
<div className={cn('grid gap-4 p-4 sm:p-6', colClasses[gridCols] || 'grid-cols-1 sm:grid-cols-3', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'h-36 rounded-2xl bg-slate-200/90 dark:bg-white/10 animate-pulse border border-border-soft',
itemClassName
)}
/>
))}
</div>
)
} else if (variant === 'kpi') {
content = (
<div className={cn('grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'h-20 rounded-2xl bg-slate-200/90 dark:bg-white/10 animate-pulse border border-border-soft p-4 flex items-center justify-between',
itemClassName
)}
>
<div className="space-y-2">
<div className="h-3 w-16 rounded bg-slate-300 dark:bg-white/15" />
<div className="h-5 w-10 rounded bg-slate-300 dark:bg-white/15" />
</div>
<div className="size-9 rounded-xl bg-slate-300/80 dark:bg-white/15" />
</div>
))}
</div>
)
} else if (variant === 'table') {
// رندرر شبیه جدول با ستون‌های شکیل و تفکیک‌شده
content = (
<div className={cn('divide-y divide-border-soft', className)}>
{/* ردیف هدر جدول */}
<div className="p-4 bg-muted/40 flex items-center justify-between gap-4">
<div className="h-4 w-12 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-32 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-20 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-24 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-20 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
<div className="h-4 w-16 rounded bg-slate-300 dark:bg-white/15 animate-pulse" />
</div>
{/* سطرهای شیمر جدول */}
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'p-4 flex items-center justify-between gap-4 hover:bg-muted/10 transition-colors',
itemClassName
)}
>
{/* شناسه */}
<div className="h-4 w-10 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
{/* کاربر (آواتار + نام) */}
<div className="flex items-center gap-2.5 min-w-0 flex-1 max-w-[200px]">
<div className="size-8 rounded-full bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
<div className="space-y-1.5 flex-1">
<div className="h-3.5 w-24 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse" />
<div className="h-2.5 w-16 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse" />
</div>
</div>
{/* بج سرویس */}
<div className="h-6 w-16 rounded-lg bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
{/* مبلغ */}
<div className="h-4 w-20 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
{/* بج وضعیت */}
<div className="h-6 w-24 rounded-full bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
{/* تاریخ و اکشن */}
<div className="h-3.5 w-20 rounded bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
<div className="h-8 w-16 rounded-xl bg-slate-200/90 dark:bg-white/10 animate-pulse shrink-0" />
</div>
))}
</div>
)
} else {
// ردیفی ساده
content = (
<div className={cn('p-6 space-y-3', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
rowHeight,
'bg-slate-200/90 dark:bg-white/10 animate-pulse rounded-xl transition-all duration-300 border border-border-soft/40',
itemClassName
)}
/>
))}
</div>
)
}
if (withCard) {
return (
<Card className="border-border-soft bg-card/40 shadow-xs overflow-hidden">
{(cardHeaderTitle || cardHeaderSub) && (
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between">
{cardHeaderTitle && (
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground">
<Ic name={cardHeaderIcon} className="size-4 text-primary" />
<span>{cardHeaderTitle}</span>
</CardTitle>
)}
{cardHeaderSub && (
<span className="text-xs text-muted-foreground font-mono">
{cardHeaderSub}
</span>
)}
</CardHeader>
)}
<CardContent className="p-0">
{content}
</CardContent>
</Card>
)
}
return <>{content}</>
}
export const shimmer = Shimmer
export default Shimmer

390
src/components/shared/shimmer_frontend.tsx

@ -0,0 +1,390 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Ic, IconName } from '@/icons'
/**
* shimmer_frontend (Shimmer UI Component & Atoms for Aqila Web Panel)
*
* الگوبرداری‌شده از معماری استاندارد AppShimmer در فلاتر (Neutral Slate Palette):
* - حالت لایت (Light Mode): رنگ پایه #E2E8F0 و هایلایت #F8FAFC
* - حالت دارک (Dark Mode): رنگ پایه #22252C و هایلایت #333742
* - دوره تناوب (Period): ۱۵۰۰ میلی‌ثانیه برای حرکت نرم و یکدست موج نوری
*
* شامل اتم‌های لگویی:
* - ShimmerBox: برای کارت‌ها، تصاویر، فیلدها و دکمه‌ها
* - ShimmerCircle: برای آواتارها، آیکون‌ها و بج‌های دایره‌ای
* - ShimmerLine: برای خطوط متنی و عناوین تایپوگرافی
*/
/* ─────────────────────────────────────────────────────────
۱. اتم‌های پایه لودینگ شیمر (Shimmer Atoms)
───────────────────────────────────────────────────────── */
export interface ShimmerBoxProps extends React.HTMLAttributes<HTMLDivElement> {
width?: string | number
height?: string | number
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
className?: string
style?: React.CSSProperties
children?: React.ReactNode
}
/**
* اتم مستطیلی اسکلتون (مشابه ShimmerBox در فلاتر)
*/
export function ShimmerBox({
width,
height,
rounded = 'xl',
className,
style,
children,
...props
}: ShimmerBoxProps) {
const roundedClass = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
'3xl': 'rounded-3xl',
full: 'rounded-full',
}[rounded]
const customStyle: React.CSSProperties = {
...(width !== undefined ? { width: typeof width === 'number' ? `${width}px` : width } : {}),
...(height !== undefined ? { height: typeof height === 'number' ? `${height}px` : height } : {}),
...style,
}
return (
<div
className={cn(
'shimmer-wave border border-border-soft shrink-0',
roundedClass,
className
)}
style={customStyle}
{...props}
>
{children}
</div>
)
}
export interface ShimmerCircleProps extends React.HTMLAttributes<HTMLDivElement> {
size?: number | string
radius?: number
className?: string
style?: React.CSSProperties
}
/**
* اتم دایره‌ای اسکلتون (مشابه ShimmerCircle در فلاتر)
*/
export function ShimmerCircle({
size = 40,
radius,
className,
style,
...props
}: ShimmerCircleProps) {
const finalSize = radius !== undefined ? radius * 2 : size
const sizeValue = typeof finalSize === 'number' ? `${finalSize}px` : finalSize
return (
<div
className={cn(
'shimmer-wave rounded-full border border-border-soft shrink-0',
className
)}
style={{
width: sizeValue,
height: sizeValue,
...style,
}}
{...props}
/>
)
}
export interface ShimmerLineProps extends React.HTMLAttributes<HTMLDivElement> {
width?: string | number
height?: string | number
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
className?: string
style?: React.CSSProperties
}
/**
* اتم خطی متنی اسکلتون (مشابه ShimmerLine در فلاتر)
*/
export function ShimmerLine({
width = '100%',
height = '14px',
rounded = 'sm',
className,
style,
...props
}: ShimmerLineProps) {
const roundedClass = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
}[rounded]
return (
<div
className={cn(
'shimmer-wave border border-border-soft shrink-0',
roundedClass,
className
)}
style={{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
...style,
}}
{...props}
/>
)
}
/* ─────────────────────────────────────────────────────────
۲. کامپوننت جامع قالب‌ها (Composite Shimmer Component)
───────────────────────────────────────────────────────── */
export interface ShimmerProps {
/** تعداد ردیف‌ها یا آیتم‌های اسکلتون (پیش‌فرض: ۵) */
rows?: number
count?: number
/** ارتفاع هر ردیف (در صورت استفاده سفارشی) */
rowHeight?: string
/** کلاس‌های سفارشی برای بدنه و والد */
className?: string
/** کلاس‌های سفارشی برای هر آیتم */
itemClassName?: string
/** آیا درون یک Card با کادر و سایه استاندارد قرار گیرد؟ */
withCard?: boolean
/** عنوان هدر کارت در صورت استفاده از withCard */
cardHeaderTitle?: string
/** آیکون هدر کارت */
cardHeaderIcon?: IconName
/** متن سمت چپ هدر کارت */
cardHeaderSub?: React.ReactNode
/** نوع نمایش: جدولی (پیش‌فرض)، ردیفی، کارتی، آمار KPI یا جزئیات */
variant?: 'table' | 'grid' | 'kpi' | 'list' | 'detail' | 'custom'
/** تعداد ستون‌ها در حالت grid (پیش‌فرض: 3) */
gridCols?: number
/** فرزندان سفارشی در صورت استفاده از کامپوننت به عنوان Wrapper */
children?: React.ReactNode
/** آیا انیمیشن فعال باشد؟ (پیش‌فرض: true) */
isEnabled?: boolean
}
/**
* کامپوننت مرجع و یکپارچه Shimmer برای پنل مدیریت عقیله
*/
export function Shimmer({
rows,
count = 5,
rowHeight,
className,
itemClassName,
withCard = false,
cardHeaderTitle,
cardHeaderIcon = 'list',
cardHeaderSub,
variant = 'table',
gridCols = 3,
children,
isEnabled = true,
}: ShimmerProps) {
if (!isEnabled) {
return <>{children}</>
}
const itemCount = rows !== undefined ? rows : count
let content: React.ReactNode
if (children && variant === 'custom') {
content = <div className={className}>{children}</div>
} else if (variant === 'grid') {
const colClasses: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
}
content = (
<div className={cn('grid gap-4 p-4 sm:p-6', colClasses[gridCols] || 'grid-cols-1 sm:grid-cols-3', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4',
itemClassName
)}
>
<ShimmerBox height={160} rounded="xl" className="w-full" />
<div className="space-y-2 pt-1">
<ShimmerLine width="75%" height={16} rounded="sm" />
<ShimmerLine width="45%" height={12} rounded="sm" />
</div>
<div className="flex items-center justify-between pt-2 border-t border-border-soft">
<ShimmerLine width="30%" height={12} rounded="sm" />
<ShimmerBox width={60} height={24} rounded="lg" />
</div>
</div>
))}
</div>
)
} else if (variant === 'kpi') {
content = (
<div className={cn('grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'h-20 rounded-2xl border border-border-soft bg-card/40 p-4 flex items-center justify-between',
itemClassName
)}
>
<div className="space-y-2">
<ShimmerLine width={60} height={12} rounded="sm" />
<ShimmerLine width={40} height={20} rounded="sm" />
</div>
<ShimmerBox width={36} height={36} rounded="xl" />
</div>
))}
</div>
)
} else if (variant === 'list') {
content = (
<div className={cn('space-y-3 p-4', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'flex items-center justify-between rounded-xl border border-border-soft bg-card/40 p-3.5',
itemClassName
)}
>
<div className="flex items-center gap-3">
<ShimmerCircle size={36} />
<div className="space-y-1.5">
<ShimmerLine width={140} height={14} rounded="sm" />
<ShimmerLine width={80} height={10} rounded="sm" />
</div>
</div>
<ShimmerBox width={70} height={28} rounded="lg" />
</div>
))}
</div>
)
} else if (variant === 'detail') {
content = (
<div className={cn('space-y-5 p-4 sm:p-6', className)}>
<div className="flex items-center justify-between pb-4 border-b border-border-soft">
<div className="flex items-center gap-3">
<ShimmerCircle size={48} />
<div className="space-y-2">
<ShimmerLine width={180} height={18} rounded="sm" />
<ShimmerLine width={110} height={12} rounded="sm" />
</div>
</div>
<div className="flex items-center gap-2">
<ShimmerBox width={80} height={32} rounded="xl" />
<ShimmerBox width={32} height={32} rounded="xl" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ShimmerBox height={110} rounded="2xl" />
<ShimmerBox height={110} rounded="2xl" />
</div>
<div className="space-y-2.5 pt-2">
<ShimmerLine width="100%" height={14} rounded="sm" />
<ShimmerLine width="90%" height={14} rounded="sm" />
<ShimmerLine width="60%" height={14} rounded="sm" />
</div>
</div>
)
} else {
// پیش‌فرض: Table (جدول داده‌ها با تفکیک ستون‌ها)
content = (
<div className={cn('divide-y divide-border-soft', className)}>
{/* ردیف هدر جدول */}
<div className="p-4 bg-muted/40 flex items-center justify-between gap-4">
<ShimmerLine width={48} height={14} rounded="sm" />
<ShimmerLine width={120} height={14} rounded="sm" />
<ShimmerLine width={80} height={14} rounded="sm" />
<ShimmerLine width={64} height={14} rounded="sm" />
<ShimmerLine width={32} height={14} rounded="sm" />
</div>
{/* ردیف‌های محتوای جدول */}
{Array.from({ length: itemCount }).map((_, i) => (
<div key={i} className={cn('p-4 flex items-center justify-between gap-4', itemClassName)}>
<div className="flex items-center gap-3">
<ShimmerBox width={18} height={18} rounded="sm" />
<ShimmerLine width={36} height={14} rounded="sm" />
</div>
<div className="space-y-1.5 flex-1 max-w-xs">
<ShimmerLine width="80%" height={14} rounded="sm" />
<ShimmerLine width="45%" height={10} rounded="sm" />
</div>
<ShimmerLine width={70} height={14} rounded="sm" />
<ShimmerBox width={80} height={24} rounded="full" />
<ShimmerBox width={28} height={28} rounded="lg" />
</div>
))}
</div>
)
}
if (withCard) {
return (
<Card className="border-border-soft bg-card/40 shadow-xs overflow-hidden">
{(cardHeaderTitle || cardHeaderSub) && (
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between">
{cardHeaderTitle && (
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground">
<Ic name={cardHeaderIcon} className="size-4 text-primary" />
<span>{cardHeaderTitle}</span>
</CardTitle>
)}
{cardHeaderSub && (
<span className="text-xs text-muted-foreground font-mono">
{cardHeaderSub}
</span>
)}
</CardHeader>
)}
<CardContent className="p-0">
{content}
</CardContent>
</Card>
)
}
return <>{content}</>
}
// ضمیمه کردن متدهای کمکی استاتیک (Static Helper Properties)
Shimmer.Box = ShimmerBox
Shimmer.Circle = ShimmerCircle
Shimmer.Line = ShimmerLine
// نام‌های مستعار و سازگاری
export const shimmer = Shimmer
export const ShimmerFrontend = Shimmer
export const AppShimmer = Shimmer
export const shimmer_frontend = Shimmer
export default Shimmer

42
src/components/ui/avatar.tsx

@ -1,35 +1,49 @@
import * as React from 'react' import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Ic, type IconName } from '@/icons'
type AvatarProps = React.ComponentProps<'div'> & { type AvatarProps = React.ComponentProps<'div'> & {
tone?: string
icon?: IconName
iconClassName?: string
/** پارامترهای سازگاری با فراخوانی‌های قبلی */
src?: string | null
letter?: string letter?: string
tone?: string
ring?: boolean ring?: boolean
/** URL تصویر واقعی؛ اگر باشد به‌جای گرادیان+حرف نمایش داده می‌شود. */
src?: string | null
} }
/* Hosseinieh letter-avatar: gradient circle + Persian initial
اگر src داده شود، تصویر واقعی نمایش داده می‌شود و گرادیان حذف می‌شود. */
function Avatar({ className, tone, letter = 'م', ring = false, src, children, ...props }: AvatarProps) {
/**
* کامپوننت آواتار استاندارد پنل مدیریت عقیله
* به صورت پیش‌فرض آیکون ادمک (User Icon) با کنتراست بالا، پالت خنثی و ظاهری شارپ رندر می‌کند.
*/
function Avatar({
className,
icon = 'user',
iconClassName,
children,
src,
letter,
tone,
ring,
...props
}: AvatarProps) {
return ( return (
<div <div
className={cn( className={cn(
'relative flex shrink-0 items-center justify-center rounded-full text-white select-none',
!src && tone,
src && 'overflow-hidden',
ring && 'ring-2 ring-red-mid/70 ring-offset-2 ring-offset-background',
'relative flex shrink-0 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700/60 text-slate-600 dark:text-slate-300 select-none shadow-2xs',
className, className,
)} )}
{...props} {...props}
> >
{src ? (
<img src={src} alt="" loading="lazy" className="size-full object-cover" />
) : (
children ?? letter
{children ?? (
<Ic
name={icon}
className={cn('size-4 shrink-0', iconClassName)}
/>
)} )}
</div> </div>
) )
} }
export { Avatar } export { Avatar }

100
src/components/ui/button.tsx

@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring outline-none [&_svg]:shrink-0',
'inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-all duration-150 cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring outline-none [&_svg]:shrink-0 active:scale-[0.98]',
{ {
variants: { variants: {
variant: { variant: {
@ -26,11 +26,103 @@ const buttonVariants = cva(
}, },
) )
type ButtonProps = React.ComponentProps<'button'> & VariantProps<typeof buttonVariants>
export interface ButtonSpinnerProps {
className?: string
size?: 'small' | 'medium' | 'large'
}
/**
* Standard Button Loading Spinner based on Flutter AppLoading (16px, 2.0/2.5 stroke width)
*/
export function ButtonSpinner({ className, size = 'small' }: ButtonSpinnerProps) {
const sizeClass = {
small: 'size-4', // 16px (matches AppLoading.small / AppLoading.button)
medium: 'size-5', // 20px
large: 'size-6', // 24px (matches AppLoading.medium)
}[size]
function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
return (
<svg
className={cn('animate-spin text-current shrink-0', sizeClass, className)}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="9.5"
stroke="currentColor"
strokeWidth="2.5"
/>
<path
className="opacity-95"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray="45 150"
d="M12 2.5 A 9.5 9.5 0 0 1 21.5 12"
/>
</svg>
)
}
type ButtonProps = React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
isLoading?: boolean
loadingText?: React.ReactNode
spinnerPlacement?: 'start' | 'end'
}
function Button({
className,
variant,
size,
isLoading = false,
loadingText,
spinnerPlacement = 'start',
disabled,
children,
...props
}: ButtonProps) {
const spinnerSize = size === 'lg' ? 'medium' : 'small'
return (
<button
className={cn(
buttonVariants({ variant, size }),
isLoading && 'cursor-wait opacity-80 pointer-events-none',
className,
)}
disabled={disabled || isLoading}
aria-busy={isLoading}
data-loading={isLoading ? 'true' : undefined}
{...props}
>
{isLoading ? (
loadingText !== undefined ? (
<>
{spinnerPlacement === 'start' && <ButtonSpinner size={spinnerSize} />}
<span>{loadingText}</span>
{spinnerPlacement === 'end' && <ButtonSpinner size={spinnerSize} />}
</>
) : (
<>
{spinnerPlacement === 'start' && <ButtonSpinner size={spinnerSize} />}
{children}
{spinnerPlacement === 'end' && <ButtonSpinner size={spinnerSize} />}
</>
)
) : (
children
)}
</button>
)
} }
export { Button, buttonVariants } export { Button, buttonVariants }
export type { ButtonProps } export type { ButtonProps }

10
src/components/ui/multi-select.tsx

@ -7,6 +7,8 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { ShimmerLine } from '@/components/shared/shimmer_frontend'
import { ButtonSpinner } from '@/components/ui/button'
type MultiSelectProps = { type MultiSelectProps = {
label?: string label?: string
@ -147,7 +149,11 @@ export default function MultiSelect({
)} )}
<div ref={listRef} onScroll={onListScroll} className="max-h-48 overflow-y-auto p-1.5"> <div ref={listRef} onScroll={onListScroll} className="max-h-48 overflow-y-auto p-1.5">
{infinite.isLoading ? ( {infinite.isLoading ? (
<p className="px-3 py-4 text-center text-[12px] text-grey-4">در حال بارگذاری…</p>
<div className="space-y-1.5 p-2">
<ShimmerLine height={28} rounded="md" />
<ShimmerLine height={28} rounded="md" />
<ShimmerLine height={28} rounded="md" />
</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<p className="px-3 py-4 text-center text-[12px] text-grey-4">نتیجه‌ای یافت نشد</p> <p className="px-3 py-4 text-center text-[12px] text-grey-4">نتیجه‌ای یافت نشد</p>
) : ( ) : (
@ -178,7 +184,7 @@ export default function MultiSelect({
)} )}
{infinite.isFetchingNextPage && ( {infinite.isFetchingNextPage && (
<div className="flex items-center justify-center gap-2 px-3 py-2 text-[11.5px] text-grey-3"> <div className="flex items-center justify-center gap-2 px-3 py-2 text-[11.5px] text-grey-3">
<span className="size-3 animate-spin rounded-full border border-white/20 border-t-red-mid" />
<ButtonSpinner size="small" className="size-3 text-primary" />
بارگذاری موارد بیشتر… بارگذاری موارد بیشتر…
</div> </div>
)} )}

5
src/components/ui/shimmer.tsx

@ -1,2 +1,3 @@
export * from '@/components/shared/Shimmer'
export { default } from '@/components/shared/Shimmer'
export * from '@/components/shared/shimmer_frontend'
export { default } from '@/components/shared/shimmer_frontend'

10
src/components/ui/single-select.tsx

@ -3,6 +3,8 @@ import { useInfiniteQuery } from '@tanstack/react-query'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ShimmerLine } from '@/components/shared/shimmer_frontend'
import { ButtonSpinner } from '@/components/ui/button'
type SingleSelectProps = { type SingleSelectProps = {
label?: string label?: string
@ -117,7 +119,11 @@ export default function SingleSelect({
<div ref={listRef} onScroll={onListScroll} className="max-h-48 overflow-y-auto p-1.5"> <div ref={listRef} onScroll={onListScroll} className="max-h-48 overflow-y-auto p-1.5">
{infinite.isLoading ? ( {infinite.isLoading ? (
<p className="px-3 py-4 text-center text-[12px] text-grey-4">در حال بارگذاری…</p>
<div className="space-y-1.5 p-2">
<ShimmerLine height={28} rounded="md" />
<ShimmerLine height={28} rounded="md" />
<ShimmerLine height={28} rounded="md" />
</div>
) : renderedOptions.length === 0 ? ( ) : renderedOptions.length === 0 ? (
<p className="px-3 py-4 text-center text-[12px] text-grey-4">نتیجه‌ای یافت نشد</p> <p className="px-3 py-4 text-center text-[12px] text-grey-4">نتیجه‌ای یافت نشد</p>
) : ( ) : (
@ -143,7 +149,7 @@ export default function SingleSelect({
)} )}
{infinite.isFetchingNextPage && ( {infinite.isFetchingNextPage && (
<div className="flex items-center justify-center gap-2 px-3 py-2 text-[11.5px] text-grey-3"> <div className="flex items-center justify-center gap-2 px-3 py-2 text-[11.5px] text-grey-3">
<span className="size-3 animate-spin rounded-full border border-white/20 border-t-red-mid" />
<ButtonSpinner size="small" className="size-3 text-primary" />
بارگذاری موارد بیشتر… بارگذاری موارد بیشتر…
</div> </div>
)} )}

6
src/components/ui/skeleton.tsx

@ -1,13 +1,15 @@
import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }) {
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-white/8', className)}
className={cn('shimmer-wave rounded-md border border-border-soft', className)}
{...props} {...props}
/> />
) )
} }
export { Skeleton } export { Skeleton }

25
src/components/ui/switch.tsx

@ -2,18 +2,35 @@ import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch' import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
export interface SwitchProps extends React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> {
isLoading?: boolean
}
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
SwitchProps
>(({ className, isLoading, disabled, ...props }, ref) => {
return ( return (
<SwitchPrimitive.Root <SwitchPrimitive.Root
ref={ref}
disabled={disabled || isLoading}
className={cn( className={cn(
'peer inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors data-[state=checked]:bg-primary data-[state=unchecked]:bg-grey-3/30 outline-none focus-visible:ring-2 focus-visible:ring-ring',
'peer inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed',
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-slate-300 dark:data-[state=unchecked]:bg-slate-700',
isLoading && 'opacity-60 cursor-wait pointer-events-none data-[state=checked]:bg-primary/70 data-[state=unchecked]:bg-slate-300/70 dark:data-[state=unchecked]:bg-slate-700/70',
className, className,
)} )}
{...props} {...props}
> >
<SwitchPrimitive.Thumb className="pointer-events-none block size-[17px] rounded-full bg-white shadow-sm transition-transform data-[state=checked]:-translate-x-[19px] data-[state=unchecked]:-translate-x-[2px]" />
<SwitchPrimitive.Thumb className="pointer-events-none flex size-[17px] items-center justify-center rounded-full bg-white shadow-sm transition-transform data-[state=checked]:-translate-x-[19px] data-[state=unchecked]:-translate-x-[2px]">
{isLoading ? (
<span className="inline-block size-2.5 animate-spin rounded-full border-[1.5px] border-slate-300 border-t-primary" />
) : null}
</SwitchPrimitive.Thumb>
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
) )
}
})
Switch.displayName = SwitchPrimitive.Root.displayName
export { Switch } export { Switch }

95
src/components/ui/toast-container.tsx

@ -0,0 +1,95 @@
import React from 'react'
import { useToastStore, type ToastItem } from '@/stores/toast'
import { Ic, type IconName } from '@/icons'
import { cn } from '@/lib/utils'
export function ToastContainer() {
const { toasts, removeToast } = useToastStore()
if (toasts.length === 0) return null
return (
<div
dir="rtl"
aria-live="polite"
className="fixed top-5 left-5 z-[9999] flex max-w-sm w-full flex-col gap-2.5 pointer-events-none select-none transition-all"
>
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
))}
</div>
)
}
function ToastCard({ toast, onClose }: { toast: ToastItem; onClose: () => void }) {
const configs: Record<
ToastItem['type'],
{
containerClass: string
icon: IconName
iconClass: string
borderClass: string
}
> = {
success: {
containerClass:
'bg-card/95 text-foreground dark:bg-card/95 border-emerald-500/30 shadow-lg shadow-emerald-500/5',
icon: 'checkCircle',
iconClass: 'text-emerald-500 dark:text-emerald-400',
borderClass: 'border-l-4 border-l-emerald-500',
},
error: {
containerClass:
'bg-card/95 text-foreground dark:bg-card/95 border-rose-500/30 shadow-lg shadow-rose-500/5',
icon: 'xCircle',
iconClass: 'text-rose-500 dark:text-rose-400',
borderClass: 'border-l-4 border-l-rose-500',
},
warning: {
containerClass:
'bg-card/95 text-foreground dark:bg-card/95 border-amber-500/30 shadow-lg shadow-amber-500/5',
icon: 'alert',
iconClass: 'text-amber-500 dark:text-amber-400',
borderClass: 'border-l-4 border-l-amber-500',
},
info: {
containerClass:
'bg-card/95 text-foreground dark:bg-card/95 border-primary/30 shadow-lg shadow-primary/5',
icon: 'helpCircle',
iconClass: 'text-primary dark:text-primary',
borderClass: 'border-l-4 border-l-primary',
},
}
const current = configs[toast.type] || configs.info
return (
<div
role="alert"
className={cn(
'pointer-events-auto flex items-start gap-3 rounded-2xl border p-3.5 backdrop-blur-md transition-all duration-300 animate-in fade-in slide-in-from-top-3',
current.containerClass,
current.borderClass,
)}
>
<div className="mt-0.5 shrink-0">
<Ic name={current.icon} className={cn('size-4.5', current.iconClass)} />
</div>
<div className="flex-1 min-w-0">
{toast.title && (
<h5 className="text-xs font-bold leading-tight text-foreground mb-0.5">{toast.title}</h5>
)}
<p className="text-xs font-medium leading-relaxed text-muted-foreground">{toast.message}</p>
</div>
<button
onClick={onClose}
aria-label="بستن اعلان"
className="shrink-0 p-1 -m-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<Ic name="x" className="size-3.5" />
</button>
</div>
)
}

21
src/config/navigation.ts

@ -78,25 +78,8 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[
{ title: t('navigation.shrines'), titleKey: 'navigation.shrines', to: '/services/shrines', icon: 'shrine' }, { title: t('navigation.shrines'), titleKey: 'navigation.shrines', to: '/services/shrines', icon: 'shrine' },
{ title: t('navigation.store'), titleKey: 'navigation.store', to: '/services/store', icon: 'store' }, { title: t('navigation.store'), titleKey: 'navigation.store', to: '/services/store', icon: 'store' },
{ title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' }, { title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' },
{
title: t('navigation.support'),
titleKey: 'navigation.support',
icon: 'headset',
children: [
{
title: t('navigation.supporters'),
titleKey: 'navigation.supporters',
to: '/services/consultant',
icon: 'headset',
},
{
title: t('navigation.faq'),
titleKey: 'navigation.faq',
to: '/services/faq',
icon: 'helpCircle',
},
],
},
{ title: t('navigation.support'), titleKey: 'navigation.support', to: '/services/consultant', icon: 'headset' },
{ title: t('navigation.faq'), titleKey: 'navigation.faq', to: '/services/faq', icon: 'helpCircle' },
{ title: t('navigation.settings'), titleKey: 'navigation.settings', to: '/settings', icon: 'settings' }, { title: t('navigation.settings'), titleKey: 'navigation.settings', to: '/settings', icon: 'settings' },
], ],
}, },

8
src/features/auth/LoginPage.tsx

@ -139,10 +139,12 @@ export default function LoginPage() {
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
className="w-full"
disabled={!valid || loginMutation.isPending}
className="w-full font-bold"
disabled={!valid}
isLoading={loginMutation.isPending}
loadingText="در حال ورود…"
> >
{loginMutation.isPending ? 'در حال ورود…' : 'ورود به حساب کاربری'}
ورود به حساب کاربری
</Button> </Button>
</form> </form>

146
src/features/billing/BillingPage.tsx

@ -15,7 +15,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Ic, IconName } from '@/icons' import { Ic, IconName } from '@/icons'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer, ShimmerBox, ShimmerCircle, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { fetchFactors, reviewFactor, clearFactorReceipt } from './services/billing-api' import { fetchFactors, reviewFactor, clearFactorReceipt } from './services/billing-api'
import type { FactorItem, FactorStatus, FactorService, BillingStats } from './types' import type { FactorItem, FactorStatus, FactorService, BillingStats } from './types'
import { BillingStatusBadge } from './components/BillingStatusBadge' import { BillingStatusBadge } from './components/BillingStatusBadge'
@ -26,6 +26,8 @@ import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { useBillingBadgeStore } from '@/stores/billing-badge' import { useBillingBadgeStore } from '@/stores/billing-badge'
import { formatDateByLanguage } from '@/lib/date-utils' import { formatDateByLanguage } from '@/lib/date-utils'
import { resolveMediaUrl, getMediaFallbackUrl } from '@/lib/utils'
import { toast } from '@/stores/toast'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@ -81,7 +83,7 @@ export default function BillingPage() {
const params = new URLSearchParams() const params = new URLSearchParams()
if (service && service !== 'all') params.set('service', service) if (service && service !== 'all') params.set('service', service)
if (status && status !== 'all') params.set('status', status) if (status && status !== 'all') params.set('status', status)
if (search) params.set('search', search)
if (search.trim()) params.set('search', search.trim())
if (page > 1) params.set('page', page.toString()) if (page > 1) params.set('page', page.toString())
setSearchParams(params, { replace: true }) setSearchParams(params, { replace: true })
}, },
@ -95,10 +97,10 @@ export default function BillingPage() {
const urlSearch = searchParams.get('search') || '' const urlSearch = searchParams.get('search') || ''
const urlPage = parseInt(searchParams.get('page') || '1', 10) || 1 const urlPage = parseInt(searchParams.get('page') || '1', 10) || 1
setSelectedService(urlService)
setSelectedStatus(urlStatus)
setSearchQuery(urlSearch)
setCurrentPage(urlPage)
setSelectedService((prev) => (prev !== urlService ? urlService : prev))
setSelectedStatus((prev) => (prev !== urlStatus ? urlStatus : prev))
setSearchQuery((prev) => (prev !== urlSearch ? urlSearch : prev))
setCurrentPage((prev) => (prev !== urlPage ? urlPage : prev))
}, [searchParams]) }, [searchParams])
// Load factors from API // Load factors from API
@ -110,18 +112,18 @@ export default function BillingPage() {
const res = await fetchFactors({ const res = await fetchFactors({
status: selectedStatus !== 'all' ? selectedStatus : undefined, status: selectedStatus !== 'all' ? selectedStatus : undefined,
service: selectedService !== 'all' ? selectedService : undefined, service: selectedService !== 'all' ? selectedService : undefined,
search: searchQuery || undefined,
search: searchQuery.trim() || undefined,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, offset,
}) })
setFactors(res.results || []) setFactors(res.results || [])
setTotalCount(res.count || 0) setTotalCount(res.count || 0)
} catch (err: any) { } catch (err: any) {
setError(err?.message || t('common.error'))
setError(err?.message || 'خطا در بارگذاری فهرست صورت‌حساب‌ها')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, [selectedStatus, selectedService, searchQuery, currentPage, t])
}, [selectedStatus, selectedService, searchQuery, currentPage])
useEffect(() => { useEffect(() => {
loadFactors() loadFactors()
@ -252,9 +254,10 @@ export default function BillingPage() {
if (selectedFactorForLightbox?.id === factorId) { if (selectedFactorForLightbox?.id === factorId) {
setSelectedFactorForLightbox((prev) => (prev ? { ...prev, transaction_receipt: null } : null)) setSelectedFactorForLightbox((prev) => (prev ? { ...prev, transaction_receipt: null } : null))
} }
toast.success(t('billing.receiptClearedSuccess'))
showToastMessage(t('billing.receiptClearedSuccess')) showToastMessage(t('billing.receiptClearedSuccess'))
} catch (err: any) { } catch (err: any) {
alert(`Error: ${err?.message || 'Unexpected issue'}`)
toast.error(err?.message || 'خطا در حذف فیش')
} finally { } finally {
setUpdatingId(null) setUpdatingId(null)
} }
@ -300,7 +303,7 @@ export default function BillingPage() {
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs min-w-[65px] justify-center" className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs min-w-[65px] justify-center"
> >
{isLoading ? ( {isLoading ? (
<span className="h-3 w-8 inline-block rounded bg-primary/30 animate-pulse" />
<ShimmerBox width={36} height={14} rounded="md" className="inline-block" />
) : ( ) : (
`${totalCount} ${t('billing.invoices')}` `${totalCount} ${t('billing.invoices')}`
)} )}
@ -366,7 +369,7 @@ export default function BillingPage() {
<div> <div>
<p className="text-[11px] text-grey-3 font-medium">{t('billing.totalInvoices')}</p> <p className="text-[11px] text-grey-3 font-medium">{t('billing.totalInvoices')}</p>
{isLoading ? ( {isLoading ? (
<div className="h-6 w-12 rounded-md bg-slate-200/90 dark:bg-white/10 animate-pulse mt-1" />
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.total}</p> <p className="text-xl font-black mt-1 font-mono text-foreground">{stats.total}</p>
)} )}
@ -385,7 +388,7 @@ export default function BillingPage() {
{t('billing.pendingReview')} {t('billing.pendingReview')}
</p> </p>
{isLoading ? ( {isLoading ? (
<div className="h-6 w-12 rounded-md bg-blue-500/20 animate-pulse mt-1" />
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400"> <p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400">
{stats.pendingReview} {stats.pendingReview}
@ -393,7 +396,7 @@ export default function BillingPage() {
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center"> <div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center">
<Ic name="clock" className="size-4 animate-pulse" />
<Ic name="clock" className="size-4" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -406,7 +409,7 @@ export default function BillingPage() {
{t('billing.awaitingPayment')} {t('billing.awaitingPayment')}
</p> </p>
{isLoading ? ( {isLoading ? (
<div className="h-6 w-12 rounded-md bg-amber-500/20 animate-pulse mt-1" />
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400"> <p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
{stats.awaitingPayment} {stats.awaitingPayment}
@ -427,7 +430,7 @@ export default function BillingPage() {
{t('billing.approvedPayments')} {t('billing.approvedPayments')}
</p> </p>
{isLoading ? ( {isLoading ? (
<div className="h-6 w-12 rounded-md bg-emerald-500/20 animate-pulse mt-1" />
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400"> <p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.approved} {stats.approved}
@ -448,7 +451,7 @@ export default function BillingPage() {
{t('billing.rejectedOrExpired')} {t('billing.rejectedOrExpired')}
</p> </p>
{isLoading ? ( {isLoading ? (
<div className="h-6 w-12 rounded-md bg-rose-500/20 animate-pulse mt-1" />
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400"> <p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400">
{stats.rejectedOrExpired} {stats.rejectedOrExpired}
@ -602,50 +605,49 @@ export default function BillingPage() {
<TableRow key={i} className="hover:bg-transparent"> <TableRow key={i} className="hover:bg-transparent">
{/* شناسه */} {/* شناسه */}
<TableCell className="text-center"> <TableCell className="text-center">
<div className="h-4 w-9 rounded-md bg-slate-200 dark:bg-white/10 animate-pulse mx-auto" />
<ShimmerBox width={36} height={16} rounded="md" className="mx-auto" />
</TableCell> </TableCell>
{/* خریدار */} {/* خریدار */}
<TableCell> <TableCell>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="size-8 rounded-full bg-slate-200 dark:bg-white/10 animate-pulse shrink-0" />
<ShimmerCircle size={32} />
<div className="space-y-1.5 flex-1 min-w-0"> <div className="space-y-1.5 flex-1 min-w-0">
<div className="h-3.5 w-24 rounded bg-slate-200 dark:bg-white/10 animate-pulse" />
<div className="h-2.5 w-16 rounded bg-slate-200/70 dark:bg-white/5 animate-pulse" />
<ShimmerLine width={96} height={14} rounded="sm" />
<ShimmerLine width={64} height={10} rounded="sm" />
</div> </div>
</div> </div>
</TableCell> </TableCell>
{/* سرویس */} {/* سرویس */}
<TableCell> <TableCell>
<div className="h-6 w-20 rounded-lg bg-slate-200 dark:bg-white/10 animate-pulse" />
<ShimmerBox width={80} height={24} rounded="full" />
</TableCell> </TableCell>
{/* مبلغ */} {/* مبلغ */}
<TableCell> <TableCell>
<div className="h-4 w-16 rounded bg-slate-200 dark:bg-white/10 animate-pulse" />
<ShimmerLine width={64} height={16} rounded="sm" />
</TableCell> </TableCell>
{/* وضعیت */} {/* وضعیت */}
<TableCell> <TableCell>
<div className="h-6 w-24 rounded-full bg-slate-200 dark:bg-white/10 animate-pulse" />
<ShimmerBox width={96} height={24} rounded="full" />
</TableCell> </TableCell>
{/* تصویر فیش */} {/* تصویر فیش */}
<TableCell className="text-center"> <TableCell className="text-center">
<div className="size-8 rounded-lg bg-slate-200 dark:bg-white/10 animate-pulse mx-auto" />
<ShimmerBox width={36} height={36} rounded="lg" className="mx-auto" />
</TableCell> </TableCell>
{/* تاریخ */} {/* تاریخ */}
<TableCell> <TableCell>
<div className="h-3.5 w-20 rounded bg-slate-200 dark:bg-white/10 animate-pulse" />
<ShimmerLine width={80} height={14} rounded="sm" />
</TableCell> </TableCell>
{/* عملیات */} {/* عملیات */}
<TableCell className="text-end pl-4"> <TableCell className="text-end pl-4">
<div className="flex items-center justify-end gap-1.5">
<div className="size-7 rounded-lg bg-slate-200 dark:bg-white/10 animate-pulse" />
<div className="size-7 rounded-lg bg-slate-200 dark:bg-white/10 animate-pulse" />
<div className="flex items-center justify-end">
<ShimmerBox width={105} height={32} rounded="xl" />
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -671,11 +673,7 @@ export default function BillingPage() {
{/* مشخصات مشتری و خریدار */} {/* مشخصات مشتری و خریدار */}
<TableCell> <TableCell>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Avatar
className="size-8 border border-border-soft shrink-0 text-[10px] font-bold"
src={user?.avatar || undefined}
letter={user?.fullname ? user.fullname.slice(0, 2) : 'U'}
/>
<Avatar className="size-8 shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<p className="font-bold text-foreground truncate max-w-[140px] sm:max-w-[180px]"> <p className="font-bold text-foreground truncate max-w-[140px] sm:max-w-[180px]">
{user?.fullname || '—'} {user?.fullname || '—'}
@ -708,14 +706,31 @@ export default function BillingPage() {
<TableCell className="text-center"> <TableCell className="text-center">
{hasReceipt ? ( {hasReceipt ? (
<button <button
type="button"
onClick={() => setSelectedFactorForLightbox(factor)} onClick={() => setSelectedFactorForLightbox(factor)}
className="relative group size-9 rounded-lg overflow-hidden border border-border-soft mx-auto block hover:scale-105 transition-transform"
className="relative group size-8 rounded-lg overflow-hidden border border-primary/20 bg-primary/10 mx-auto flex items-center justify-center hover:border-primary/50 hover:scale-105 transition-all shadow-2xs cursor-pointer"
title={t('billing.viewReceipt')} title={t('billing.viewReceipt')}
> >
<img <img
src={factor.transaction_receipt!}
alt={t('billing.receipt')}
src={resolveMediaUrl(factor.transaction_receipt)}
alt=""
className="size-full object-cover" className="size-full object-cover"
onError={(e) => {
const el = e.currentTarget
if (!el.dataset.fallbackApplied && factor.transaction_receipt) {
el.dataset.fallbackApplied = 'true'
const fallback = getMediaFallbackUrl(factor.transaction_receipt)
if (fallback && el.src !== fallback) {
el.src = fallback
return
}
}
el.style.display = 'none'
}}
/>
<Ic
name="image"
className="size-3.5 text-primary pointer-events-none group-hover:scale-110 transition-transform"
/> />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity">
<Ic name="eye" className="size-3" /> <Ic name="eye" className="size-3" />
@ -723,7 +738,7 @@ export default function BillingPage() {
</button> </button>
) : ( ) : (
<span <span
className="text-[10px] text-grey-4 block text-center"
className="inline-block text-[10.5px] font-medium text-muted-foreground/70 bg-muted/40 px-2 py-0.5 rounded-md border border-border-soft select-none"
title={t('billing.noReceiptUploaded')} title={t('billing.noReceiptUploaded')}
> >
{t('billing.noReceipt')} {t('billing.noReceipt')}
@ -732,52 +747,24 @@ export default function BillingPage() {
</TableCell> </TableCell>
{/* تاریخ صدور */} {/* تاریخ صدور */}
<TableCell className="text-grey-3 whitespace-nowrap text-[11px] font-medium">
<TableCell className="text-muted-foreground whitespace-nowrap text-[11.5px] font-medium">
{formatDateByLanguage(factor.created_at, language as any)} {formatDateByLanguage(factor.created_at, language as any)}
</TableCell> </TableCell>
{/* دکمه‌های عملیات */} {/* دکمه‌های عملیات */}
<TableCell className="text-end pl-4"> <TableCell className="text-end pl-4">
<div className="flex items-center justify-end gap-1.5">
<div className="flex items-center justify-end">
{/* دکمه مشاهده جزئیات کامل خریدار و پرداخت */} {/* دکمه مشاهده جزئیات کامل خریدار و پرداخت */}
<Button <Button
variant="outline"
variant="default"
size="sm" size="sm"
className="h-8 px-2.5 text-xs gap-1 rounded-xl border-border-soft hover:bg-surface-2"
className="h-8 px-3 text-xs gap-1.5 rounded-lg font-bold shadow-2xs hover:brightness-105 active:scale-[0.98] transition-all shrink-0"
onClick={() => setSelectedFactorForDetail(factor)} onClick={() => setSelectedFactorForDetail(factor)}
title={t('billing.viewDetails')} title={t('billing.viewDetails')}
> >
<Ic name="eye" className="size-3.5" /> <Ic name="eye" className="size-3.5" />
<span className="hidden sm:inline">{t('billing.viewDetails')}</span>
<span>{t('billing.viewDetails')}</span>
</Button> </Button>
{/* تایید سریع فیش */}
{factor.status !== 'approved' && (
<Button
variant="ghost"
size="icon"
className="size-8 rounded-xl text-emerald-600 hover:text-emerald-700 hover:bg-emerald-500/10"
onClick={() => handleApprove(factor.id)}
disabled={updatingId === factor.id}
title={t('billing.approve')}
>
<Ic name="checkCircle" className="size-4" />
</Button>
)}
{/* رد سریع فیش */}
{factor.status !== 'rejected' && (
<Button
variant="ghost"
size="icon"
className="size-8 rounded-xl text-rose-600 hover:text-rose-700 hover:bg-rose-500/10"
onClick={() => setSelectedFactorForReject(factor)}
disabled={updatingId === factor.id}
title={t('billing.reject')}
>
<Ic name="xCircle" className="size-4" />
</Button>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -836,28 +823,23 @@ export default function BillingPage() {
factor={selectedFactorForDetail} factor={selectedFactorForDetail}
open={Boolean(selectedFactorForDetail)} open={Boolean(selectedFactorForDetail)}
onClose={() => setSelectedFactorForDetail(null)} onClose={() => setSelectedFactorForDetail(null)}
onApprove={handleApprove}
onOpenRejectDialog={(f) => {
onFactorUpdated={(updated) => {
setFactors((prev) => prev.map((f) => (f.id === updated.id ? updated : f)))
setSelectedFactorForDetail(null) setSelectedFactorForDetail(null)
setSelectedFactorForReject(f)
loadFactors()
useBillingBadgeStore.getState().fetchCounts()
}} }}
onOpenReceiptLightbox={(f) => setSelectedFactorForLightbox(f)} onOpenReceiptLightbox={(f) => setSelectedFactorForLightbox(f)}
onClearReceipt={handleClearReceipt} onClearReceipt={handleClearReceipt}
isUpdating={updatingId === selectedFactorForDetail?.id} isUpdating={updatingId === selectedFactorForDetail?.id}
/> />
{/* ۲. مودال لایت‌باکس پیش‌نمایش بزرگ فیش واریزی */}
{/* ۲. مودال لایت‌باکس پیش‌نمایش بزرگ فیش واریزی (صرفاً بزرگ‌نمایی و دکمه بستن) */}
<ReceiptLightboxDialog <ReceiptLightboxDialog
factor={selectedFactorForLightbox} factor={selectedFactorForLightbox}
open={Boolean(selectedFactorForLightbox)} open={Boolean(selectedFactorForLightbox)}
onClose={() => setSelectedFactorForLightbox(null)} onClose={() => setSelectedFactorForLightbox(null)}
onApprove={handleApprove}
onReject={(factorId) => {
setSelectedFactorForLightbox(null)
const f = factors.find((item) => item.id === factorId) || null
setSelectedFactorForReject(f)
}}
isUpdating={updatingId === selectedFactorForLightbox?.id}
onClearReceipt={handleClearReceipt}
/> />
{/* ۳. مودال دریافت دلیل رد فاکتور */} {/* ۳. مودال دریافت دلیل رد فاکتور */}

553
src/features/billing/components/BillingDetailModal.tsx

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -10,39 +10,124 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Avatar } from '@/components/ui/avatar' import { Avatar } from '@/components/ui/avatar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import type { FactorItem } from '../types'
import { BillingStatusBadge } from './BillingStatusBadge'
import type { FactorItem, FactorStatus } from '../types'
import { BillingServiceBadge } from './BillingServiceBadge' import { BillingServiceBadge } from './BillingServiceBadge'
import { fetchFactorDetail, reviewFactor } from '../services/billing-api'
import { ShimmerLine, ShimmerBox } from '@/components/shared/shimmer_frontend'
import { toast } from '@/stores/toast'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateByLanguage, formatDateTimeByLanguage } from '@/lib/date-utils' import { formatDateByLanguage, formatDateTimeByLanguage } from '@/lib/date-utils'
import { cn, resolveMediaUrl, getMediaFallbackUrl } from '@/lib/utils'
interface Props { interface Props {
factor: FactorItem | null factor: FactorItem | null
open: boolean open: boolean
onClose: () => void onClose: () => void
onApprove: (factorId: number) => Promise<void>
onOpenRejectDialog: (factor: FactorItem) => void
onFactorUpdated?: (updatedFactor: FactorItem) => void
onApprove?: (factorId: number) => Promise<void>
onOpenRejectDialog?: (factor: FactorItem) => void
onOpenReceiptLightbox: (factor: FactorItem) => void onOpenReceiptLightbox: (factor: FactorItem) => void
onClearReceipt?: (factorId: number) => Promise<void> onClearReceipt?: (factorId: number) => Promise<void>
isUpdating?: boolean isUpdating?: boolean
} }
const STATUS_OPTIONS: {
value: FactorStatus
label: string
dotClass: string
}[] = [
{
value: 'awaiting_payment',
label: 'در انتظار پرداخت',
dotClass: 'bg-amber-500',
},
{
value: 'pending',
label: 'در انتظار بررسی فیش',
dotClass: 'bg-blue-500',
},
{
value: 'approved',
label: 'تایید شده / پرداخت شده',
dotClass: 'bg-emerald-500',
},
{
value: 'rejected',
label: 'رد شده',
dotClass: 'bg-rose-500',
},
{
value: 'expired',
label: 'منقضی شده',
dotClass: 'bg-slate-400',
},
]
export function BillingDetailModal({ export function BillingDetailModal({
factor, factor,
open, open,
onClose, onClose,
onApprove,
onOpenRejectDialog,
onFactorUpdated,
onOpenReceiptLightbox, onOpenReceiptLightbox,
onClearReceipt, onClearReceipt,
isUpdating, isUpdating,
}: Props) { }: Props) {
const { t, language } = useTranslation() const { t, language } = useTranslation()
const [copiedCard, setCopiedCard] = useState(false) const [copiedCard, setCopiedCard] = useState(false)
const [currentFactor, setCurrentFactor] = useState<FactorItem | null>(factor)
const [selectedStatus, setSelectedStatus] = useState<FactorStatus>(factor?.status || 'awaiting_payment')
const [rejectReason, setRejectReason] = useState<string>(factor?.rejected_description || '')
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [receiptError, setReceiptError] = useState(false)
const [isClearingReceipt, setIsClearingReceipt] = useState(false)
const lastLoadedFactorIdRef = useRef<number | null>(null)
useEffect(() => {
if (!open || !factor) {
lastLoadedFactorIdRef.current = null
return
}
if (!factor) return null
// مقداردهی اولیه وضعیت و فیلدها منحصراً در زمان باز شدن مودال یا تغییر شناسه صورت‌حساب
if (lastLoadedFactorIdRef.current !== factor.id) {
lastLoadedFactorIdRef.current = factor.id
setCurrentFactor(factor)
setSelectedStatus(factor.status)
setRejectReason(factor.rejected_description || '')
setHasChanges(false)
setReceiptError(false)
if (!factor.detail_service) {
setIsLoadingDetail(true)
fetchFactorDetail(factor.id)
.then((res) => {
// بروزرسانی جزئیات بدون بازنویسی وضعیت تغییریافته توسط کاربر (جلوگیری قطعی از برگشت به حالت رد شده)
setCurrentFactor((prev) => (prev ? { ...prev, ...res } : res))
})
.catch((err) => {
console.error('Failed to load factor details:', err)
})
.finally(() => {
setIsLoadingDetail(false)
})
}
}
}, [factor?.id, open])
const activeFactor = currentFactor || factor
if (!factor || !activeFactor) return null
const handleCopyCard = (cardNumber: string) => { const handleCopyCard = (cardNumber: string) => {
navigator.clipboard.writeText(cardNumber) navigator.clipboard.writeText(cardNumber)
@ -50,65 +135,215 @@ export function BillingDetailModal({
setTimeout(() => setCopiedCard(false), 2000) setTimeout(() => setCopiedCard(false), 2000)
} }
const detail = factor.detail_service
const user = factor.user_info
const handleSaveStatus = async () => {
if (!activeFactor) return
if (selectedStatus === 'rejected' && !rejectReason.trim()) {
toast.error('وارد کردن دلیل رد صورت‌حساب الزامی است.', 'دلیل رد الزامی است')
return
}
setIsSaving(true)
try {
const payload = {
status: selectedStatus,
rejected_description: selectedStatus === 'rejected' ? rejectReason.trim() : undefined,
}
const updated = await reviewFactor(activeFactor.id, payload)
setCurrentFactor(updated)
setSelectedStatus(updated.status)
setRejectReason(updated.rejected_description || '')
setHasChanges(false)
if (onFactorUpdated) {
onFactorUpdated(updated)
}
const statusLabel =
STATUS_OPTIONS.find((s) => s.value === selectedStatus)?.label || selectedStatus
toast.success(
`وضعیت صورت‌حساب #${activeFactor.id} با موفقیت به «${statusLabel}» تغییر یافت.`,
'بروزرسانی وضعیت'
)
onClose()
} catch (err: any) {
toast.error(err?.message || 'خطا در ذخیره‌سازی وضعیت صورت‌حساب.', 'خطا در عملیات')
} finally {
setIsSaving(false)
}
}
const detail = activeFactor.detail_service
const user = activeFactor.user_info
return ( return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}> <Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto border border-border/60 bg-background/95 backdrop-blur-md p-0">
{/* هدر مودال */}
<DialogHeader className="p-5 border-b border-border/40 sticky top-0 bg-background/95 backdrop-blur-sm z-10">
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col border border-border/60 bg-card p-0 overflow-hidden shadow-2xl rounded-2xl">
{/* هدر مودال ثابت با پس‌زمینه سالید بدون تداخل اسکرول */}
<DialogHeader className="p-4 px-5 border-b border-border/40 bg-card shrink-0 z-10">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="size-10 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold">
<div className="size-10 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold shrink-0">
<Ic name="receipt" className="size-5" /> <Ic name="receipt" className="size-5" />
</div> </div>
<div> <div>
<DialogTitle className="text-lg font-bold flex items-center gap-2"> <DialogTitle className="text-lg font-bold flex items-center gap-2">
<span>صورت‌حساب شماره #{factor.id}</span>
<span>صورت‌حساب شماره #{activeFactor.id}</span>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5"> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
ثبت شده در: {formatDateTimeByLanguage(factor.created_at, language as any)}
ثبت شده در: {formatDateTimeByLanguage(activeFactor.created_at, language as any)}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
<div className="flex items-center gap-2.5">
<BillingServiceBadge service={activeFactor.service} />
{/* دراپ‌داون تعاملی انتخاب وضعیت صورت‌حساب و دکمه ذخیره سریع در هدر */}
<div className="flex items-center gap-2">
<div className="relative">
<Select
value={selectedStatus}
onValueChange={(val) => {
const newStatus = val as FactorStatus
setSelectedStatus(newStatus)
setHasChanges(newStatus !== activeFactor.status)
}}
>
<SelectTrigger className="h-8.5 px-3 min-w-[175px] text-xs font-bold rounded-xl border-border-soft bg-card shadow-2xs hover:bg-muted/40 transition-colors">
<SelectValue placeholder="انتخاب وضعیت...">
{(() => {
const curOpt = STATUS_OPTIONS.find((o) => o.value === selectedStatus)
if (!curOpt) return selectedStatus
return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BillingServiceBadge service={factor.service} />
<BillingStatusBadge status={factor.status} />
<span className={cn('size-2 rounded-full shrink-0', curOpt.dotClass)} />
<span className="truncate">{curOpt.label}</span>
</div>
)
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-xl border-border-soft bg-card shadow-xl p-1 z-50">
{STATUS_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
className="rounded-lg text-xs font-medium py-2 gap-2 cursor-pointer"
>
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full shrink-0', opt.dotClass)} />
<span>{opt.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* دکمه ذخیره وضعیت سریع در کنار منو برای جلوگیری از سردرگمی کاربر */}
{hasChanges && (
<Button
size="sm"
disabled={isSaving || isUpdating || (selectedStatus === 'rejected' && !rejectReason.trim())}
isLoading={isSaving || isUpdating}
onClick={handleSaveStatus}
className="h-8.5 px-3.5 text-xs font-bold gap-1 rounded-xl bg-primary text-primary-foreground shadow-xs hover:brightness-110 animate-in fade-in zoom-in-95 duration-150 shrink-0"
title="ذخیره وضعیت انتخاب‌شده"
>
<Ic name="check" className="size-3.5" />
<span>ذخیره وضعیت</span>
</Button>
)}
</div>
</div> </div>
</div> </div>
</DialogHeader> </DialogHeader>
<div className="p-5 space-y-5">
{/* کارت وضعیت انقضا یا هشدار رد */}
{factor.status === 'rejected' && factor.rejected_description && (
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-4 flex items-start gap-3 text-rose-700 dark:text-rose-300">
<Ic name="xCircle" className="size-5 shrink-0 mt-0.5 text-rose-500" />
<div>
<h4 className="font-bold text-xs mb-1">دلیل رد صورت‌حساب:</h4>
<p className="text-xs leading-relaxed">{factor.rejected_description}</p>
{/* بدنه محتوا با اسکرول داخلی مجزا */}
<div className="p-5 space-y-5 flex-1 overflow-y-auto">
{/* کادر ویرایش/مشاهده دلیل رد صورت‌حساب در صورت انتخاب وضعیت رد شده */}
{selectedStatus === 'rejected' && (
<div className="bg-rose-500/10 border border-rose-500/25 rounded-2xl p-4 space-y-2.5 text-foreground">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="size-6 rounded-lg bg-rose-500/20 text-rose-600 dark:text-rose-400 flex items-center justify-center shrink-0">
<Ic name="xCircle" className="size-3.5" />
</div>
<h4 className="font-bold text-xs text-rose-700 dark:text-rose-300 flex items-center gap-1.5">
<span>دلیل رد صورت‌حساب:</span>
<span className="text-[11px] font-bold text-rose-600 dark:text-rose-400 bg-rose-500/15 px-2 py-0.5 rounded-md">
(الزامی)
</span>
</h4>
</div>
<span className="text-[10px] text-rose-600/80 font-medium">نمایش برای مسافر در اپلیکیشن</span>
</div>
<Textarea
value={rejectReason}
onChange={(e) => {
setRejectReason(e.target.value)
setHasChanges(true)
}}
placeholder="علت رد فاکتور را حتماً وارد نمایید (مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، تصویر جعلی است و...)"
className={cn(
'text-xs bg-background/90 rounded-xl resize-none min-h-[76px] transition-colors',
!rejectReason.trim()
? 'border-rose-500/50 focus:border-rose-500 focus:ring-1 focus:ring-rose-500/30'
: 'border-rose-500/25 focus:border-rose-500/50'
)}
/>
{!rejectReason.trim() && (
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium flex items-center gap-1.5 pt-0.5">
<Ic name="alert" className="size-3.5 shrink-0" />
<span>برای ثبت وضعیت «رد شده»، وارد کردن توضیح و دلیل الزامی است.</span>
</p>
)}
</div>
)}
{/* کارت وضعیت انقضای قبلی صورت‌حساب */}
{activeFactor.status === 'expired' && selectedStatus === 'expired' && (
<div className="bg-slate-100/80 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/60 rounded-2xl p-4 flex items-start gap-3.5">
<div className="size-8 rounded-xl bg-slate-200 dark:bg-slate-700 text-slate-800 dark:text-slate-100 flex items-center justify-center shrink-0 mt-0.5">
<Ic name="clock" className="size-4" />
</div>
<div className="space-y-1 flex-1">
<h4 className="font-bold text-xs text-slate-900 dark:text-slate-100">دلیل انقضای صورت‌حساب:</h4>
<p className="text-xs text-slate-600 dark:text-slate-300 leading-relaxed font-medium">
{activeFactor.rejected_description || 'انقضای مهلت پرداخت (سپری شدن مهلت ۷۲ ساعته)'}
</p>
</div> </div>
</div> </div>
)} )}
{factor.status === 'expired' && (
<div className="bg-slate-500/10 border border-slate-500/20 rounded-xl p-4 flex items-start gap-3 text-slate-700 dark:text-slate-300">
<Ic name="clock" className="size-5 shrink-0 mt-0.5 text-slate-500" />
<div>
<h4 className="font-bold text-xs mb-1">دلیل انقضا:</h4>
<p className="text-xs leading-relaxed">{factor.rejected_description || 'انقضای مهلت پرداخت (سپری شدن ۷۲ ساعت)'}</p>
{/* اطلاع‌رسانی تمدید خودکار مهلت ۳ روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده */}
{(activeFactor.status === 'expired' || activeFactor.status === 'rejected') && (selectedStatus === 'awaiting_payment' || selectedStatus === 'pending') && (
<div className="bg-slate-800 border border-amber-500/40 rounded-2xl p-4 flex items-start gap-3.5 shadow-md">
<div className="size-8 rounded-xl bg-amber-500/20 text-amber-400 flex items-center justify-center shrink-0 mt-0.5">
<Ic name="clock" className="size-4" />
</div>
<div className="space-y-1 flex-1 text-xs">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-bold text-white">
تمدید مجدد مهلت پرداخت صورت‌حساب:
</h4>
<span className="text-[11px] font-bold text-amber-300 bg-amber-500/20 border border-amber-500/30 px-2 py-0.5 rounded-md">
+۳ روز مهلت جدید
</span>
</div>
<p className="text-slate-200 leading-relaxed font-normal">
با ثبت وضعیت <strong className="font-bold text-amber-300">«{selectedStatus === 'awaiting_payment' ? 'در انتظار پرداخت' : 'در انتظار بررسی فیش'}»</strong>، این صورت‌حساب مجدداً فعال شده و یک مهلت پرداخت ۳ روزهٔ جدید (۷۲ ساعت از تاریخ امروز) برای مسافر در نظر گرفته می‌شود.
</p>
</div> </div>
</div> </div>
)} )}
{factor.expiration_date && (
<div className="bg-muted/40 border border-border/40 rounded-xl p-3 flex items-center justify-between text-xs">
<span className="text-muted-foreground flex items-center gap-1.5">
<Ic name="clock" className="size-4 text-amber-500" />
{activeFactor.expiration_date && (
<div className="bg-muted/50 border border-border-soft rounded-xl p-3 flex items-center justify-between text-xs">
<span className="text-foreground/80 font-medium flex items-center gap-1.5">
<Ic name="clock" className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span>مهلت پرداخت (تاریخ انقضا):</span> <span>مهلت پرداخت (تاریخ انقضا):</span>
</span> </span>
<span className="font-semibold">{formatDateByLanguage(factor.expiration_date, language as any)}</span>
<span className="font-bold text-foreground font-mono">
{formatDateByLanguage(activeFactor.expiration_date, language as any)}
</span>
</div> </div>
)} )}
@ -117,23 +352,21 @@ export function BillingDetailModal({
{/* کارت مشخصات خریدار */} {/* کارت مشخصات خریدار */}
<Card className="border-border/50 shadow-xs"> <Card className="border-border/50 shadow-xs">
<CardHeader className="py-3 px-4 border-b border-border/40"> <CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-bold text-muted-foreground flex items-center gap-2">
<CardTitle className="text-xs font-bold text-foreground flex items-center gap-2">
<Ic name="users" className="size-4 text-primary" /> <Ic name="users" className="size-4 text-primary" />
<span>اطلاعات مشتری</span> <span>اطلاعات مشتری</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-4 space-y-3"> <CardContent className="p-4 space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar
className="size-11 border border-border/40 text-xs font-bold"
src={user?.avatar || undefined}
letter={user?.fullname ? user.fullname.slice(0, 2) : 'ک'}
/>
<Avatar className="size-11 shrink-0" iconClassName="size-5.5" />
<div className="min-w-0"> <div className="min-w-0">
<p className="font-bold text-sm truncate text-foreground"> <p className="font-bold text-sm truncate text-foreground">
{user?.fullname || 'نام ثبت نشده'} {user?.fullname || 'نام ثبت نشده'}
</p> </p>
<p className="text-xs text-muted-foreground truncate">{user?.email || '—'}</p>
<p className="text-xs text-muted-foreground truncate font-mono dir-ltr text-start">
{user?.email || '—'}
</p>
</div> </div>
</div> </div>
@ -155,7 +388,7 @@ export function BillingDetailModal({
{/* کارت مشخصات مالی فاکتور */} {/* کارت مشخصات مالی فاکتور */}
<Card className="border-border/50 shadow-xs"> <Card className="border-border/50 shadow-xs">
<CardHeader className="py-3 px-4 border-b border-border/40"> <CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-bold text-muted-foreground flex items-center gap-2">
<CardTitle className="text-xs font-bold text-foreground flex items-center gap-2">
<Ic name="creditCard" className="size-4 text-primary" /> <Ic name="creditCard" className="size-4 text-primary" />
<span>مشخصات مالی و واریز</span> <span>مشخصات مالی و واریز</span>
</CardTitle> </CardTitle>
@ -163,24 +396,24 @@ export function BillingDetailModal({
<CardContent className="p-4 space-y-3"> <CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">مبلغ کل فاکتور:</span> <span className="text-xs text-muted-foreground">مبلغ کل فاکتور:</span>
<span className="text-lg font-black text-primary">
{Number(factor.amount).toLocaleString()} $
<span className="text-lg font-black text-primary font-mono">
${Number(activeFactor.amount).toLocaleString()}
</span> </span>
</div> </div>
{factor.card_number && (
{activeFactor.card_number && (
<div className="bg-muted/40 p-2.5 rounded-lg border border-border/40 flex items-center justify-between text-xs"> <div className="bg-muted/40 p-2.5 rounded-lg border border-border/40 flex items-center justify-between text-xs">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] text-muted-foreground">شماره کارت مقصد:</span> <span className="text-[10px] text-muted-foreground">شماره کارت مقصد:</span>
<span className="font-mono font-bold tracking-wider dir-ltr text-foreground"> <span className="font-mono font-bold tracking-wider dir-ltr text-foreground">
{factor.card_number}
{activeFactor.card_number}
</span> </span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 text-muted-foreground hover:text-foreground" className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => handleCopyCard(factor.card_number!)}
onClick={() => handleCopyCard(activeFactor.card_number!)}
title="کپی شماره کارت" title="کپی شماره کارت"
> >
<Ic name={copiedCard ? 'check' : 'clipboardCheck'} className="size-3.5" /> <Ic name={copiedCard ? 'check' : 'clipboardCheck'} className="size-3.5" />
@ -192,13 +425,13 @@ export function BillingDetailModal({
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">شناسه سفارش مرجع:</span> <span className="text-muted-foreground">شناسه سفارش مرجع:</span>
<span className="font-mono font-medium"> <span className="font-mono font-medium">
{factor.order_id || factor.object_id}
{activeFactor.order_id || activeFactor.object_id}
</span> </span>
</div> </div>
{factor.description && (
{activeFactor.description && (
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-muted-foreground shrink-0">توضیحات:</span> <span className="text-muted-foreground shrink-0">توضیحات:</span>
<span className="text-foreground text-end truncate">{factor.description}</span>
<span className="text-foreground text-end truncate">{activeFactor.description}</span>
</div> </div>
)} )}
</div> </div>
@ -211,12 +444,20 @@ export function BillingDetailModal({
<CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20"> <CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20">
<CardTitle className="text-xs font-bold flex items-center gap-2"> <CardTitle className="text-xs font-bold flex items-center gap-2">
<Ic name="list" className="size-4 text-primary" /> <Ic name="list" className="size-4 text-primary" />
<span>ریز اقلام و مشخصات سرویس ({factor.service.toUpperCase()})</span>
<span>ریز اقلام و مشخصات سرویس ({activeFactor.service.toUpperCase()})</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-4 text-xs space-y-4"> <CardContent className="p-4 text-xs space-y-4">
{isLoadingDetail ? (
<div className="space-y-3 py-2">
<ShimmerLine width="50%" height={16} rounded="md" />
<ShimmerBox width="100%" height={44} rounded="xl" />
<ShimmerBox width="100%" height={44} rounded="xl" />
</div>
) : (
<>
{/* ۱. تور */} {/* ۱. تور */}
{factor.service === 'tour' && (
{activeFactor.service === 'tour' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between pb-2 border-b border-border/40"> <div className="flex items-center justify-between pb-2 border-b border-border/40">
<span className="text-muted-foreground">عنوان تور:</span> <span className="text-muted-foreground">عنوان تور:</span>
@ -229,11 +470,11 @@ export function BillingDetailModal({
{/* بزرگسالان */} {/* بزرگسالان */}
{(detail.passenger_data.adults?.count ?? 0) > 0 && ( {(detail.passenger_data.adults?.count ?? 0) > 0 && (
<div className="bg-muted/30 p-2.5 rounded-lg border border-border/30">
<span className="font-bold text-purple-600 dark:text-purple-400">
<div className="bg-muted/40 p-2.5 rounded-xl border border-border-soft">
<span className="font-bold text-foreground">
بزرگسال ({detail.passenger_data.adults?.count} نفر): بزرگسال ({detail.passenger_data.adults?.count} نفر):
</span> </span>
<span className="ms-2 text-foreground">
<span className="ms-2 text-muted-foreground">
{detail.passenger_data.adults?.names.join('، ')} {detail.passenger_data.adults?.names.join('، ')}
</span> </span>
</div> </div>
@ -241,11 +482,11 @@ export function BillingDetailModal({
{/* کودکان */} {/* کودکان */}
{(detail.passenger_data.children?.count ?? 0) > 0 && ( {(detail.passenger_data.children?.count ?? 0) > 0 && (
<div className="bg-muted/30 p-2.5 rounded-lg border border-border/30">
<span className="font-bold text-blue-600 dark:text-blue-400">
<div className="bg-muted/40 p-2.5 rounded-xl border border-border-soft">
<span className="font-bold text-foreground">
کودک ({detail.passenger_data.children?.count} نفر): کودک ({detail.passenger_data.children?.count} نفر):
</span> </span>
<span className="ms-2 text-foreground">
<span className="ms-2 text-muted-foreground">
{detail.passenger_data.children?.names.join('، ')} {detail.passenger_data.children?.names.join('، ')}
</span> </span>
</div> </div>
@ -253,11 +494,11 @@ export function BillingDetailModal({
{/* نوزادان */} {/* نوزادان */}
{(detail.passenger_data.infants?.count ?? 0) > 0 && ( {(detail.passenger_data.infants?.count ?? 0) > 0 && (
<div className="bg-muted/30 p-2.5 rounded-lg border border-border/30">
<span className="font-bold text-pink-600 dark:text-pink-400">
<div className="bg-muted/40 p-2.5 rounded-xl border border-border-soft">
<span className="font-bold text-foreground">
نوزاد ({detail.passenger_data.infants?.count} نفر): نوزاد ({detail.passenger_data.infants?.count} نفر):
</span> </span>
<span className="ms-2 text-foreground">
<span className="ms-2 text-muted-foreground">
{detail.passenger_data.infants?.names.join('، ')} {detail.passenger_data.infants?.names.join('، ')}
</span> </span>
</div> </div>
@ -270,7 +511,7 @@ export function BillingDetailModal({
)} )}
{/* ۲. سیم‌کارت */} {/* ۲. سیم‌کارت */}
{factor.service === 'simcard' && (
{activeFactor.service === 'simcard' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="divide-y divide-border/40"> <div className="divide-y divide-border/40">
{detail?.items?.map((item, idx) => ( {detail?.items?.map((item, idx) => (
@ -284,7 +525,7 @@ export function BillingDetailModal({
</span> </span>
</div> </div>
<span className="font-bold font-mono"> <span className="font-bold font-mono">
{Number(item.cost).toLocaleString()} $
${Number(item.cost).toLocaleString()}
</span> </span>
</div> </div>
))} ))}
@ -292,28 +533,28 @@ export function BillingDetailModal({
{detail?.total_cost && ( {detail?.total_cost && (
<div className="flex justify-between pt-2 border-t border-border/40 font-bold"> <div className="flex justify-between pt-2 border-t border-border/40 font-bold">
<span>هزینه کل اقلام:</span> <span>هزینه کل اقلام:</span>
<span className="text-primary font-mono">{detail.total_cost} $</span>
<span className="text-primary font-mono">${detail.total_cost}</span>
</div> </div>
)} )}
</div> </div>
)} )}
{/* ۳. فروشگاه */} {/* ۳. فروشگاه */}
{factor.service === 'shop' && (
{activeFactor.service === 'shop' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="divide-y divide-border/40"> <div className="divide-y divide-border/40">
{detail?.items?.map((item, idx) => ( {detail?.items?.map((item, idx) => (
<div key={idx} className="py-2 flex items-center justify-between"> <div key={idx} className="py-2 flex items-center justify-between">
<div> <div>
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
محصول {item.product_slug || `#${item.product_id}`}
{item.product_slug || `محصول #${item.product_id}`}
</span> </span>
<span className="ms-2 text-muted-foreground"> <span className="ms-2 text-muted-foreground">
({item.quantity} عدد × {item.price} $)
({item.quantity} عدد × ${item.price})
</span> </span>
</div> </div>
<span className="font-bold font-mono"> <span className="font-bold font-mono">
{Number(item.cost).toLocaleString()} $
${Number(item.cost).toLocaleString()}
</span> </span>
</div> </div>
))} ))}
@ -321,14 +562,14 @@ export function BillingDetailModal({
{detail?.total_cost && ( {detail?.total_cost && (
<div className="flex justify-between pt-2 border-t border-border/40 font-bold"> <div className="flex justify-between pt-2 border-t border-border/40 font-bold">
<span>جمع اقلام سبد خرید:</span> <span>جمع اقلام سبد خرید:</span>
<span className="text-primary font-mono">{detail.total_cost} $</span>
<span className="text-primary font-mono">${detail.total_cost}</span>
</div> </div>
)} )}
</div> </div>
)} )}
{/* ۴. صرافی / تسریف */} {/* ۴. صرافی / تسریف */}
{factor.service === 'tasrif' && (
{activeFactor.service === 'tasrif' && (
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/40"> <div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/40">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">تبدیل ارز:</span> <span className="text-muted-foreground">تبدیل ارز:</span>
@ -340,17 +581,17 @@ export function BillingDetailModal({
<span className="text-muted-foreground">مبلغ درخواستی:</span> <span className="text-muted-foreground">مبلغ درخواستی:</span>
<span className="font-mono font-medium">{detail?.amount}</span> <span className="font-mono font-medium">{detail?.amount}</span>
</div> </div>
<div className="flex justify-between pt-2 border-t border-border/40 font-bold">
<div className="flex justify-between pt-2 border-border/40 font-bold">
<span>مبلغ نهایی معادل:</span> <span>مبلغ نهایی معادل:</span>
<span className="text-emerald-600 dark:text-emerald-400 font-mono text-sm"> <span className="text-emerald-600 dark:text-emerald-400 font-mono text-sm">
{detail?.total_price} $
${detail?.total_price}
</span> </span>
</div> </div>
</div> </div>
)} )}
{/* ۵. سفر اختصاصی (Custom Trip) */} {/* ۵. سفر اختصاصی (Custom Trip) */}
{factor.service === 'custom_trip' && (
{activeFactor.service === 'custom_trip' && (
<div className="space-y-3"> <div className="space-y-3">
{detail?.destinations && detail.destinations.length > 0 && ( {detail?.destinations && detail.destinations.length > 0 && (
<div> <div>
@ -377,107 +618,163 @@ export function BillingDetailModal({
)} )}
</div> </div>
)} )}
</>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* تصویر فیش واریزی */}
{/* کارت تصویر فیش پرداختی آپلود شده توسط کاربر */}
<Card className="border-border/50 shadow-xs"> <Card className="border-border/50 shadow-xs">
<CardHeader className="py-3 px-4 border-b border-border/40 flex flex-row items-center justify-between">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-bold flex items-center gap-2"> <CardTitle className="text-xs font-bold flex items-center gap-2">
<Ic name="image" className="size-4 text-primary" />
<Ic name="receipt" className="size-4 text-primary" />
<span>فیش واریزی و رسید بانکی</span> <span>فیش واریزی و رسید بانکی</span>
</CardTitle> </CardTitle>
{factor.transaction_receipt && onClearReceipt && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-destructive"
onClick={() => onClearReceipt(factor.id)}
disabled={isUpdating}
>
<Ic name="trash" className="size-3.5 me-1" />
<span>حذف فیش</span>
</Button>
)}
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
{factor.transaction_receipt ? (
{activeFactor.transaction_receipt ? (
<div className="flex flex-col sm:flex-row items-center gap-4 bg-muted/20 p-3 rounded-xl border border-border/40"> <div className="flex flex-col sm:flex-row items-center gap-4 bg-muted/20 p-3 rounded-xl border border-border/40">
<div <div
onClick={() => onOpenReceiptLightbox(factor)}
className="relative cursor-pointer group overflow-hidden rounded-lg border border-border/40 size-32 shrink-0 bg-black/5"
onClick={() => onOpenReceiptLightbox(activeFactor)}
className="relative cursor-pointer group overflow-hidden rounded-xl border border-border/60 size-28 sm:size-32 shrink-0 bg-muted/40 shadow-2xs flex items-center justify-center hover:border-primary/50 transition-colors"
title="مشاهده در اندازه بزرگ"
> >
<img <img
src={factor.transaction_receipt}
alt="فیش پرداختی"
src={resolveMediaUrl(activeFactor.transaction_receipt)}
alt={`فیش صورت‌حساب #${activeFactor.id}`}
className="size-full object-cover group-hover:scale-105 transition-transform" className="size-full object-cover group-hover:scale-105 transition-transform"
onError={(e) => {
const el = e.currentTarget
if (!el.dataset.fallbackApplied && activeFactor.transaction_receipt) {
el.dataset.fallbackApplied = 'true'
const fallback = getMediaFallbackUrl(activeFactor.transaction_receipt)
if (fallback && el.src !== fallback) {
el.src = fallback
return
}
}
el.style.display = 'none'
setReceiptError(true)
const fallback = el.nextElementSibling as HTMLElement
if (fallback) fallback.style.display = 'flex'
}}
/> />
<div
style={{ display: 'none' }}
className="size-full flex flex-col items-center justify-center text-muted-foreground p-2 text-center"
>
<Ic name="alert" className="size-6 text-amber-500 mb-1" />
<span className="text-[10px] font-semibold text-amber-600 dark:text-amber-400">عدم لود تصویر</span>
</div>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity text-xs font-medium"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity text-xs font-medium">
<Ic name="eye" className="size-5" /> <Ic name="eye" className="size-5" />
</div> </div>
</div> </div>
<div className="space-y-2 text-xs flex-1"> <div className="space-y-2 text-xs flex-1">
{receiptError ? (
<>
<p className="font-bold text-destructive flex items-center gap-1.5">
<Ic name="alert" className="size-3.5" />
<span>فایل تصویر رسید در سرور یافت نشد یا آسیب دیده است</span>
</p>
<p className="text-muted-foreground text-[11px] leading-relaxed">
آدرس فیش در پایگاه داده ثبت شده، اما فایل فیزیکی در سرور در دسترس نیست. جهت جلوگیری از سردرگمی می‌توانید فیش نامعتبر را پاکسازی کنید.
</p>
</>
) : (
<>
<p className="font-bold text-foreground">فیش بانکی توسط مشتری آپلود شده است.</p> <p className="font-bold text-foreground">فیش بانکی توسط مشتری آپلود شده است.</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-[11px] leading-relaxed">
جهت بررسی صحت اطلاعات و انطباق با واریز حساب، می‌توانید تصویر فیش را در سایز جهت بررسی صحت اطلاعات و انطباق با واریز حساب، می‌توانید تصویر فیش را در سایز
کامل باز کرده و سپس وضعیت صورت‌حساب را تایید یا رد نمایید.
کامل باز کرده و جزئیات آن را بررسی نمایید.
</p> </p>
</>
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 gap-1 text-xs"
onClick={() => onOpenReceiptLightbox(factor)}
className="h-8 gap-1.5 text-xs rounded-xl shadow-2xs border-border-soft hover:bg-muted/60"
onClick={() => onOpenReceiptLightbox(activeFactor)}
> >
<Ic name="eye" className="size-3.5" />
<Ic name="eye" className="size-3.5 text-primary" />
<span>مشاهده در اندازه بزرگ</span> <span>مشاهده در اندازه بزرگ</span>
</Button> </Button>
{onClearReceipt && (
<Button
variant="outline"
size="sm"
disabled={isClearingReceipt}
className="h-8 gap-1.5 text-xs rounded-xl shadow-2xs border-destructive/30 text-destructive hover:bg-destructive/10"
onClick={async () => {
setIsClearingReceipt(true)
try {
await onClearReceipt(activeFactor.id)
setCurrentFactor((prev) => (prev ? { ...prev, transaction_receipt: null } : null))
setReceiptError(false)
} finally {
setIsClearingReceipt(false)
}
}}
>
<Ic name="trash" className="size-3.5" />
<span>{isClearingReceipt ? 'در حال پاکسازی...' : receiptError ? 'پاکسازی فیش نامعتبر' : 'حذف فیش'}</span>
</Button>
)}
</div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-3 p-4 bg-amber-500/10 rounded-xl border border-amber-500/20 text-amber-700 dark:text-amber-300">
<Ic name="alert" className="size-5 text-amber-500 shrink-0" />
<p className="text-xs">
هنوز تصویر فیش واریزی توسط مشتری در سامانه بارگذاری نشده است.
<div className="flex items-center gap-3.5 p-4 rounded-xl border border-amber-500/30 bg-amber-500/10 text-foreground">
<div className="size-9 rounded-xl bg-amber-500/20 text-amber-700 dark:text-amber-400 flex items-center justify-center shrink-0">
<Ic name="alert" className="size-5" />
</div>
<div className="space-y-0.5">
<p className="text-xs font-bold text-amber-950 dark:text-amber-200">
عدم بارگذاری فیش واریزی
</p> </p>
<p className="text-xs text-foreground/80 font-medium leading-relaxed">
هنوز تصویر فیش واریزی توسط مشتری در سامانه بارگذاری نشده است (صورت‌حساب در انتظار پرداخت می‌باشد).
</p>
</div>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* فوتر مودال و دکمه‌های عملیاتی ادمین */}
<DialogFooter className="p-4 border-t border-border/40 bg-muted/20 sticky bottom-0 z-10 flex items-center justify-between sm:justify-between w-full">
<Button variant="outline" size="sm" onClick={onClose}>
بستن
</Button>
<div className="flex items-center gap-2">
{factor.status !== 'approved' && (
{/* فوتر مودال و دکمه‌های عملیاتی: ثابت و بدون تداخل با اسکرول */}
<DialogFooter className="p-4 px-5 border-t border-border-soft bg-card shrink-0 flex flex-row items-center justify-between w-full z-10">
<Button <Button
variant="default"
variant="outline"
size="sm" size="sm"
className="bg-emerald-600 hover:bg-emerald-700 text-white gap-1.5"
disabled={isUpdating}
onClick={() => onApprove(factor.id)}
onClick={onClose}
className="rounded-xl h-9 px-4 text-xs font-bold border-border-soft hover:bg-muted/50"
> >
<Ic name="checkCircle" className="size-4" />
<span>تایید پرداخت و فاکتور</span>
{t('common.close')}
</Button> </Button>
)}
{factor.status !== 'rejected' && (
<div className="flex items-center gap-2">
<Button <Button
variant="destructive"
variant="default"
size="sm" size="sm"
className="gap-1.5"
disabled={isUpdating}
onClick={() => onOpenRejectDialog(factor)}
disabled={isSaving || isUpdating || (selectedStatus === 'rejected' && !rejectReason.trim())}
className={cn(
'rounded-xl h-9 px-5 text-xs font-bold gap-1.5 shadow-xs transition-all',
hasChanges && !(selectedStatus === 'rejected' && !rejectReason.trim())
? 'bg-primary text-primary-foreground hover:brightness-110 ring-2 ring-primary/30'
: 'bg-primary text-primary-foreground hover:brightness-105'
)}
isLoading={isSaving || isUpdating}
loadingText="در حال ذخیره..."
onClick={handleSaveStatus}
> >
<Ic name="xCircle" className="size-4" />
<span>رد صورت‌حساب</span>
<Ic name="check" className="size-4" />
<span>{hasChanges ? 'ذخیره تغییرات' : 'ثبت وضعیت'}</span>
</Button> </Button>
)}
</div> </div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

117
src/features/billing/components/BillingServiceBadge.tsx

@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons'
import { Ic, type IconName } from '@/icons'
import type { FactorService } from '../types' import type { FactorService } from '../types'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
@ -9,65 +8,69 @@ interface Props {
className?: string className?: string
} }
interface ServiceBadgeStyle {
labelKey: string
icon: IconName
containerClass: string
iconClass: string
}
export function BillingServiceBadge({ service, className }: Props) { export function BillingServiceBadge({ service, className }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
switch (service) {
case 'tour':
return (
<Badge
variant="outline"
className={`bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="map" className="size-3.5 text-purple-500 shrink-0" />
<span>{t('billing.serviceTour')}</span>
</Badge>
)
case 'simcard':
return (
<Badge
variant="outline"
className={`bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="simcard" className="size-3.5 text-cyan-500 shrink-0" />
<span>{t('billing.serviceSimcard')}</span>
</Badge>
)
case 'shop':
return (
<Badge
variant="outline"
className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="store" className="size-3.5 text-amber-500 shrink-0" />
<span>{t('billing.serviceShop')}</span>
</Badge>
)
case 'tasrif':
return (
<Badge
variant="outline"
className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="exchange" className="size-3.5 text-emerald-500 shrink-0" />
<span>{t('billing.serviceExchange')}</span>
</Badge>
)
case 'custom_trip':
const config: Record<FactorService, ServiceBadgeStyle> = {
tour: {
labelKey: 'billing.serviceTour',
icon: 'map',
containerClass:
'border-indigo-200 bg-indigo-50/90 text-indigo-950 dark:border-indigo-800/60 dark:bg-indigo-950/60 dark:text-indigo-100',
iconClass: 'text-indigo-600 dark:text-indigo-400',
},
tasrif: {
labelKey: 'billing.serviceExchange',
icon: 'exchange',
containerClass:
'border-violet-200 bg-violet-50/90 text-violet-950 dark:border-violet-800/60 dark:bg-violet-950/60 dark:text-violet-100',
iconClass: 'text-violet-600 dark:text-violet-400',
},
simcard: {
labelKey: 'billing.serviceSimcard',
icon: 'simcard',
containerClass:
'border-cyan-200 bg-cyan-50/90 text-cyan-950 dark:border-cyan-800/60 dark:bg-cyan-950/60 dark:text-cyan-100',
iconClass: 'text-cyan-700 dark:text-cyan-400',
},
shop: {
labelKey: 'billing.serviceShop',
icon: 'store',
containerClass:
'border-emerald-200 bg-emerald-50/90 text-emerald-950 dark:border-emerald-800/60 dark:bg-emerald-950/60 dark:text-emerald-100',
iconClass: 'text-emerald-600 dark:text-emerald-400',
},
custom_trip: {
labelKey: 'billing.serviceCustomTrip',
icon: 'calculator',
containerClass:
'border-amber-200 bg-amber-50/90 text-amber-950 dark:border-amber-800/60 dark:bg-amber-950/60 dark:text-amber-100',
iconClass: 'text-amber-600 dark:text-amber-400',
},
}
const item = config[service] || {
labelKey: service,
icon: 'receipt' as IconName,
containerClass:
'border-slate-200 bg-slate-50 text-slate-950 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100',
iconClass: 'text-slate-600 dark:text-slate-400',
}
return ( return (
<Badge
variant="outline"
className={`bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border-indigo-500/30 gap-1.5 font-medium ${className || ''}`}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold border shrink-0 transition-colors select-none ${item.containerClass} ${className || ''}`}
> >
<Ic name="calculator" className="size-3.5 text-indigo-500 shrink-0" />
<span>{t('billing.serviceCustomTrip')}</span>
</Badge>
)
default:
return (
<Badge variant="outline" className={className}>
{service}
</Badge>
<Ic name={item.icon} className={`size-3.5 shrink-0 ${item.iconClass}`} />
<span>{t(item.labelKey)}</span>
</span>
) )
}
} }

55
src/features/billing/components/BillingStatusBadge.tsx

@ -1,8 +1,5 @@
import React from 'react' import React from 'react'
import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons'
import type { FactorStatus } from '../types' import type { FactorStatus } from '../types'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
interface Props { interface Props {
@ -16,59 +13,57 @@ export function BillingStatusBadge({ status, className }: Props) {
switch (status) { switch (status) {
case 'awaiting_payment': case 'awaiting_payment':
return ( return (
<Badge
variant="outline"
className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold border border-amber-200 bg-amber-50/90 text-amber-950 dark:border-amber-800/60 dark:bg-amber-950/60 dark:text-amber-100 shrink-0 ${className || ''}`}
> >
<Ic name="clock" className="size-3.5 text-amber-500 shrink-0" />
<span className="size-1.5 rounded-full bg-amber-600 dark:bg-amber-400 shrink-0" />
<span>{t('billing.statusAwaiting')}</span> <span>{t('billing.statusAwaiting')}</span>
</Badge>
</span>
) )
case 'pending': case 'pending':
return ( return (
<Badge
variant="outline"
className={`bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30 gap-1.5 font-medium animate-pulse ${className || ''}`}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold border border-blue-200 bg-blue-50/90 text-blue-950 dark:border-blue-800/60 dark:bg-blue-950/60 dark:text-blue-100 shrink-0 ${className || ''}`}
> >
<Ic name="clock" className="size-3.5 text-blue-500 shrink-0" />
<span className="size-1.5 rounded-full bg-blue-600 dark:bg-blue-400 shrink-0" />
<span>{t('billing.statusPending')}</span> <span>{t('billing.statusPending')}</span>
</Badge>
</span>
) )
case 'approved': case 'approved':
return ( return (
<Badge
variant="outline"
className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold border border-emerald-200 bg-emerald-50/90 text-emerald-950 dark:border-emerald-800/60 dark:bg-emerald-950/60 dark:text-emerald-100 shrink-0 ${className || ''}`}
> >
<Ic name="checkCircle" className="size-3.5 text-emerald-500 shrink-0" />
<span className="size-1.5 rounded-full bg-emerald-600 dark:bg-emerald-400 shrink-0" />
<span>{t('billing.statusApproved')}</span> <span>{t('billing.statusApproved')}</span>
</Badge>
</span>
) )
case 'rejected': case 'rejected':
return ( return (
<Badge
variant="outline"
className={`bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30 gap-1.5 font-medium ${className || ''}`}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold border border-rose-200 bg-rose-50/90 text-rose-950 dark:border-rose-800/60 dark:bg-rose-950/60 dark:text-rose-100 shrink-0 ${className || ''}`}
> >
<Ic name="xCircle" className="size-3.5 text-rose-500 shrink-0" />
<span className="size-1.5 rounded-full bg-rose-600 dark:bg-rose-400 shrink-0" />
<span>{t('billing.statusRejected')}</span> <span>{t('billing.statusRejected')}</span>
</Badge>
</span>
) )
case 'expired': case 'expired':
return ( return (
<Badge
variant="outline"
className={`bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30 gap-1.5 font-medium ${className || ''}`}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold border border-slate-200 bg-slate-100/90 text-slate-950 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-200 shrink-0 ${className || ''}`}
> >
<Ic name="clock" className="size-3.5 text-slate-500 shrink-0" />
<span className="size-1.5 rounded-full bg-slate-600 dark:bg-slate-400 shrink-0" />
<span>{t('billing.statusExpired')}</span> <span>{t('billing.statusExpired')}</span>
</Badge>
</span>
) )
default: default:
return ( return (
<Badge variant="outline" className={className}>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-medium border border-border-soft bg-muted/40 text-foreground dark:text-slate-200 shrink-0 ${className || ''}`}
>
{status} {status}
</Badge>
</span>
) )
} }
} }

248
src/features/billing/components/ReceiptLightboxDialog.tsx

@ -1,4 +1,4 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -11,128 +11,260 @@ import { Button } from '@/components/ui/button'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import type { FactorItem } from '../types' import type { FactorItem } from '../types'
import { BillingStatusBadge } from './BillingStatusBadge' import { BillingStatusBadge } from './BillingStatusBadge'
import { resolveMediaUrl, getMediaFallbackUrl, toFa } from '@/lib/utils'
interface Props { interface Props {
factor: FactorItem | null factor: FactorItem | null
open: boolean open: boolean
onClose: () => void onClose: () => void
onApprove?: (factorId: number) => void
onReject?: (factorId: number) => void
isUpdating?: boolean
onClearReceipt?: (factorId: number) => Promise<void>
} }
export function ReceiptLightboxDialog({ export function ReceiptLightboxDialog({
factor, factor,
open, open,
onClose, onClose,
onApprove,
onReject,
isUpdating,
onClearReceipt,
}: Props) { }: Props) {
const [zoom, setZoom] = useState<number>(1)
const [rotation, setRotation] = useState<number>(0)
const [imageError, setImageError] = useState(false)
const [imageLoading, setImageLoading] = useState(true)
const [imgSrc, setImgSrc] = useState<string>('')
const [fallbackTried, setFallbackTried] = useState(false)
useEffect(() => {
if (open && factor) {
setZoom(1)
setRotation(0)
setImageError(false)
setImageLoading(true)
setFallbackTried(false)
setImgSrc(resolveMediaUrl(factor.transaction_receipt))
}
}, [open, factor?.id, factor?.transaction_receipt])
if (!factor) return null if (!factor) return null
const hasReceipt = Boolean(factor.transaction_receipt)
const receiptUrl = imgSrc || resolveMediaUrl(factor.transaction_receipt)
const hasReceipt = Boolean(receiptUrl)
const handleZoomIn = () => setZoom((prev) => Math.min(4, +(prev + 0.25).toFixed(2)))
const handleZoomOut = () => setZoom((prev) => Math.max(0.5, +(prev - 0.25).toFixed(2)))
const handleReset = () => {
setZoom(1)
setRotation(0)
}
const handleRotate = () => setRotation((prev) => (prev + 90) % 360)
const handleToggleZoom = () => {
setZoom((prev) => (prev > 1 ? 1 : 2))
}
return ( return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}> <Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-2xl sm:max-w-3xl overflow-hidden p-0 border border-border/60 bg-background/95 backdrop-blur-md">
<DialogHeader className="p-4 border-b border-border/40 flex flex-row items-center justify-between">
<DialogContent className="max-w-3xl sm:max-w-4xl max-h-[92vh] overflow-hidden p-0 border border-border/60 bg-background/95 backdrop-blur-md flex flex-col">
{/* هدر مدال پیش‌نمایش فیش */}
<DialogHeader className="p-4 px-5 border-b border-border/40 flex flex-row items-center justify-between shrink-0 bg-background/95">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold shrink-0">
<Ic name="receipt" className="size-4.5" />
</div>
<div> <div>
<DialogTitle className="text-base font-bold flex items-center gap-2"> <DialogTitle className="text-base font-bold flex items-center gap-2">
<Ic name="receipt" className="size-5 text-primary" />
<span>تصویر فیش پرداختی صورت‌حساب #{factor.id}</span> <span>تصویر فیش پرداختی صورت‌حساب #{factor.id}</span>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5"> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
مشتری: {factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'} • مبلغ:{' '} مشتری: {factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'} • مبلغ:{' '}
{Number(factor.amount).toLocaleString()} $
<span className="font-mono font-bold text-foreground">
${Number(factor.amount).toLocaleString()}
</span>
</DialogDescription> </DialogDescription>
</div> </div>
</div>
<div className="flex items-center gap-2 pl-6"> <div className="flex items-center gap-2 pl-6">
<BillingStatusBadge status={factor.status} /> <BillingStatusBadge status={factor.status} />
</div> </div>
</DialogHeader> </DialogHeader>
<div className="p-4 flex flex-col items-center justify-center min-h-[350px] max-h-[70vh] overflow-auto bg-muted/20">
{hasReceipt ? (
<div className="relative group max-w-full">
<img
src={factor.transaction_receipt!}
alt={`فیش پرداختی فاکتور #${factor.id}`}
className="rounded-lg object-contain max-h-[60vh] max-w-full shadow-md border border-border/40"
/>
<div className="absolute top-2 left-2 flex gap-1.5 opacity-90 group-hover:opacity-100 transition-opacity">
{/* نوار ابزار بزرگنمایی و کنترل تصویر */}
{hasReceipt && !imageError && (
<div className="px-5 py-2 border-b border-border-soft bg-muted/40 flex items-center justify-between text-xs shrink-0">
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="h-7.5 px-2.5 rounded-lg text-xs gap-1 border-border-soft bg-card shadow-2xs hover:bg-muted/60"
onClick={handleZoomIn}
title="بزرگنمایی (+)"
>
<Ic name="plus" className="size-3.5" />
<span>بزرگ‌نمایی</span>
</Button>
<Button
variant="outline"
size="sm"
className="h-7.5 px-2.5 rounded-lg text-xs gap-1 border-border-soft bg-card shadow-2xs hover:bg-muted/60"
onClick={handleZoomOut}
disabled={zoom <= 0.5}
title="کوچک‌نمایی (-)"
>
<span className="font-bold text-base leading-none">−</span>
<span>کوچک‌نمایی</span>
</Button>
<Button
variant="outline"
size="sm"
className="h-7.5 px-2.5 rounded-lg text-xs gap-1 border-border-soft bg-card shadow-2xs hover:bg-muted/60"
onClick={handleRotate}
title="چرخش ۹۰ درجه"
>
<Ic name="refresh" className="size-3.5" />
<span>چرخش</span>
</Button>
{(zoom !== 1 || rotation !== 0) && (
<Button
variant="ghost"
size="sm"
className="h-7.5 px-2 rounded-lg text-xs text-muted-foreground hover:text-foreground"
onClick={handleReset}
title="بازنشانی اندازه"
>
<span>اندازه اولیه (۱۰۰٪)</span>
</Button>
)}
<span className="text-[11px] font-mono text-muted-foreground ms-1">
{toFa(Math.round(zoom * 100))}%
</span>
</div>
<div className="flex items-center gap-1.5">
<a <a
href={factor.transaction_receipt!}
href={receiptUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="bg-black/70 hover:bg-black text-white px-2.5 py-1 rounded-md text-xs flex items-center gap-1 backdrop-blur-sm"
className="inline-flex items-center gap-1.5 h-7.5 px-3 rounded-lg text-xs font-medium border border-border-soft bg-card hover:bg-muted/60 text-foreground transition-colors shadow-2xs"
title="مشاهده فایل اصلی در تب جدید"
> >
<Ic name="external" className="size-3.5" /> <Ic name="external" className="size-3.5" />
<span>اندازه اصلی</span> <span>اندازه اصلی</span>
</a> </a>
</div> </div>
</div> </div>
)}
{/* کادر نمایش تصویر فیش */}
<div className="relative flex-1 min-h-[380px] max-h-[62vh] overflow-auto p-4 flex items-center justify-center bg-slate-950/20 dark:bg-black/40">
{hasReceipt && !imageError ? (
<div className="relative flex items-center justify-center min-w-full min-h-full">
{imageLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground gap-2">
<span className="size-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-xs font-medium">در حال بارگذاری تصویر فیش...</span>
</div>
)}
<img
src={receiptUrl}
alt={`فیش پرداختی فاکتور #${factor.id}`}
className={`rounded-xl object-contain shadow-lg border border-border/40 transition-transform duration-150 select-none max-h-[56vh] max-w-full ${
zoom > 1 ? 'cursor-zoom-out' : 'cursor-zoom-in'
}`}
style={{
transform: `scale(${zoom}) rotate(${rotation}deg)`,
transformOrigin: 'center center',
}}
onDoubleClick={handleToggleZoom}
title={zoom > 1 ? 'دوبار کلیک برای بازنشانی اندازه' : 'دوبار کلیک برای بزرگ‌نمایی'}
onLoad={() => setImageLoading(false)}
onError={() => {
if (!fallbackTried && factor.transaction_receipt) {
setFallbackTried(true)
const fallback = getMediaFallbackUrl(factor.transaction_receipt)
if (fallback && imgSrc !== fallback) {
setImgSrc(fallback)
return
}
}
setImageLoading(false)
setImageError(true)
}}
/>
</div>
) : ( ) : (
<div className="flex flex-col items-center justify-center text-center p-8 text-muted-foreground"> <div className="flex flex-col items-center justify-center text-center p-8 text-muted-foreground">
<div className="size-14 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500 mb-3"> <div className="size-14 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500 mb-3">
<Ic name="alert" className="size-7" /> <Ic name="alert" className="size-7" />
</div> </div>
<p className="font-medium text-foreground">هنوز فیش واریزی برای این صورت‌حساب ثبت نشده است.</p>
<p className="font-bold text-sm text-foreground">
{imageError
? 'خطا در بارگذاری تصویر فیش پرداختی.'
: 'هنوز فیش واریزی برای این صورت‌حساب ثبت نشده است.'}
</p>
<p className="text-xs mt-1 text-muted-foreground"> <p className="text-xs mt-1 text-muted-foreground">
وضعیت فاکتور در انتظار پرداخت توسط مشتری می‌باشد.
{imageError
? 'آدرس فایل در سرور در دسترس نیست یا فرمت تصویر نامعتبر می‌باشد.'
: 'وضعیت فاکتور در انتظار پرداخت توسط مشتری می‌باشد.'}
</p> </p>
{imageError && onClearReceipt && (
<Button
variant="outline"
size="sm"
onClick={async () => {
await onClearReceipt(factor.id)
onClose()
}}
className="mt-4 rounded-xl h-8 px-4 text-xs font-semibold border-destructive/40 text-destructive hover:bg-destructive/10 gap-1.5 shadow-2xs"
>
<Ic name="trash" className="size-3.5" />
<span>پاکسازی و حذف فیش نامعتبر</span>
</Button>
)}
</div> </div>
)} )}
</div>
{/* اطلاعات شماره کارت و توضیحات جانبی */}
{factor.card_number && ( {factor.card_number && (
<div className="mt-4 w-full max-w-md bg-muted/40 p-3 rounded-lg border border-border/40 flex items-center justify-between text-xs">
<div className="px-5 py-2.5 border-t border-border-soft bg-card/50 flex items-center justify-between text-xs shrink-0">
<span className="text-muted-foreground">شماره کارت مقصد:</span> <span className="text-muted-foreground">شماره کارت مقصد:</span>
<span className="font-mono font-bold tracking-wider dir-ltr">
<span className="font-mono font-bold tracking-wider dir-ltr text-foreground">
{factor.card_number} {factor.card_number}
</span> </span>
</div> </div>
)} )}
{factor.rejected_description && (
<div className="mt-2 w-full max-w-md bg-rose-500/10 text-rose-700 dark:text-rose-300 p-3 rounded-lg border border-rose-500/20 text-xs">
<span className="font-bold">علت رد فاکتور: </span>
<span>{factor.rejected_description}</span>
</div>
)}
</div>
<DialogFooter className="p-3 border-t border-border/40 bg-muted/30 flex items-center justify-between sm:justify-between w-full">
<Button variant="outline" size="sm" onClick={onClose}>
بستن
</Button>
<div className="flex items-center gap-2">
{factor.status !== 'approved' && onApprove && (
{/* فوتر مودال: دکمه بستن و دکمه حذف فیش (در صورت وجود) */}
<DialogFooter className="p-4 px-5 border-t border-border-soft bg-card/80 flex flex-row items-center justify-between w-full shrink-0">
<div>
{onClearReceipt && factor.transaction_receipt && (
<Button <Button
variant="default"
variant="outline"
size="sm" size="sm"
className="bg-emerald-600 hover:bg-emerald-700 text-white gap-1.5"
disabled={isUpdating}
onClick={() => onApprove(factor.id)}
onClick={async () => {
await onClearReceipt(factor.id)
onClose()
}}
className="rounded-xl h-9 px-3.5 text-xs font-semibold border-destructive/30 text-destructive hover:bg-destructive/10 gap-1.5"
> >
<Ic name="checkCircle" className="size-4" />
<span>تایید پرداخت</span>
<Ic name="trash" className="size-3.5" />
<span>حذف فیش</span>
</Button> </Button>
)} )}
{factor.status !== 'rejected' && onReject && (
</div>
<Button <Button
variant="destructive"
variant="outline"
size="sm" size="sm"
className="gap-1.5"
disabled={isUpdating}
onClick={() => onReject(factor.id)}
onClick={onClose}
className="rounded-xl h-9 px-6 text-xs font-bold border-border-soft hover:bg-muted/50"
> >
<Ic name="xCircle" className="size-4" />
<span>رد فاکتور</span>
بستن
</Button> </Button>
)}
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

9
src/features/billing/components/RejectFactorDialog.tsx

@ -135,17 +135,12 @@ export function RejectFactorDialog({
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={handleConfirm} onClick={handleConfirm}
disabled={isSubmitting}
isLoading={isSubmitting}
loadingText={t('billing.submitting')}
className="rounded-xl h-9 text-xs px-4 gap-1.5 bg-rose-600 hover:bg-rose-700 text-white font-semibold shadow-xs" className="rounded-xl h-9 text-xs px-4 gap-1.5 bg-rose-600 hover:bg-rose-700 text-white font-semibold shadow-xs"
> >
{isSubmitting ? (
<span>{t('billing.submitting')}</span>
) : (
<>
<Ic name="xCircle" className="size-4" /> <Ic name="xCircle" className="size-4" />
<span>{t('billing.submitReject')}</span> <span>{t('billing.submitReject')}</span>
</>
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

16
src/features/billing/services/billing-api.ts

@ -59,6 +59,22 @@ export async function reviewFactor(
}) })
} }
/**
* دریافت آمارهای شمارشی وضعیت فاکتورها در یک درخواست فوق‌سریع
*/
export interface BillingCounts {
total: number
pending: number
awaiting_payment: number
approved: number
rejected: number
expired: number
}
export async function fetchBillingCounts(): Promise<BillingCounts> {
return apiFetch<BillingCounts>('factors/counts/')
}
/** /**
* پاکسازی تصویر فیش ثبت‌شده برای فاکتور * پاکسازی تصویر فیش ثبت‌شده برای فاکتور
*/ */

2
src/features/services/ServicesPage.tsx

@ -5,7 +5,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { ZayerGuideServiceView } from './components/ZayerGuideServiceView' import { ZayerGuideServiceView } from './components/ZayerGuideServiceView'
import { DuasServiceView } from './components/DuasServiceView' import { DuasServiceView } from './components/DuasServiceView'

18
src/features/services/components/CategoriesServiceView.tsx

@ -4,7 +4,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer, ShimmerBox } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { import {
Dialog, Dialog,
@ -314,19 +314,19 @@ export function CategoriesServiceView() {
<div className="rounded-2xl border border-border-soft bg-card/40 p-4"> <div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">کل دسته‌بندی‌های فعال</span> <span className="text-xs text-grey-3 font-medium">کل دسته‌بندی‌های فعال</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono"> <div className="mt-2 text-2xl font-black text-foreground font-mono">
{isLoading ? <Shimmer className="h-8 w-16" /> : toFa(totalCategoriesCount)}
{isLoading ? <ShimmerBox width={64} height={32} rounded="lg" /> : toFa(totalCategoriesCount)}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4"> <div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span> <span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono"> <div className="mt-2 text-2xl font-black text-foreground font-mono">
{isLoading ? <Shimmer className="h-8 w-12" /> : toFa(coveredCitiesCount)}
{isLoading ? <ShimmerBox width={48} height={32} rounded="lg" /> : toFa(coveredCitiesCount)}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4"> <div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">پیوندهای ادعیه به پوشه‌ها</span> <span className="text-xs text-grey-3 font-medium">پیوندهای ادعیه به پوشه‌ها</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono"> <div className="mt-2 text-2xl font-black text-foreground font-mono">
{isLoading ? <Shimmer className="h-8 w-14" /> : toFa(totalAssignedDuas)}
{isLoading ? <ShimmerBox width={56} height={32} rounded="lg" /> : toFa(totalAssignedDuas)}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4"> <div className="rounded-2xl border border-border-soft bg-card/40 p-4">
@ -769,10 +769,11 @@ export function CategoriesServiceView() {
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isSaving}
isLoading={isSaving}
loadingText="در حال ذخیره‌سازی..."
className="rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90" className="rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90"
> >
{isSaving ? 'در حال ذخیره‌سازی...' : editingCategory ? 'ثبت تغییرات' : 'ایجاد دسته‌بندی'}
{editingCategory ? 'ثبت تغییرات' : 'ایجاد دسته‌بندی'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@ -846,10 +847,11 @@ export function CategoriesServiceView() {
<Button <Button
type="button" type="button"
onClick={handleSaveQuickAssign} onClick={handleSaveQuickAssign}
disabled={isSavingQuickAssign}
isLoading={isSavingQuickAssign}
loadingText="در حال ذخیره‌سازی..."
className="rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90" className="rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90"
> >
{isSavingQuickAssign ? 'در حال ذخیره‌سازی...' : 'ذخیره تخصیص ادعیه'}
ذخیره تخصیص ادعیه
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

12
src/features/services/components/DuasServiceView.tsx

@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { import {
Dialog, Dialog,
@ -722,10 +722,11 @@ export function DuasServiceView() {
<DialogFooter className="gap-2 sm:justify-start pt-2"> <DialogFooter className="gap-2 sm:justify-start pt-2">
<Button <Button
type="submit" type="submit"
disabled={isSavingDua}
isLoading={isSavingDua}
loadingText="در حال ذخیره..."
className="rounded-xl bg-primary text-primary-foreground text-xs font-bold" className="rounded-xl bg-primary text-primary-foreground text-xs font-bold"
> >
{isSavingDua ? 'در حال ذخیره...' : editingDua ? 'به‌روزرسانی دعا و تخصیص‌ها' : 'ثبت دعا'}
{editingDua ? 'به‌روزرسانی دعا و تخصیص‌ها' : 'ثبت دعا'}
</Button> </Button>
<Button <Button
type="button" type="button"
@ -899,11 +900,12 @@ export function DuasServiceView() {
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<Button <Button
size="sm" size="sm"
disabled={isSavingVerse}
isLoading={isSavingVerse}
loadingText="در حال ذخیره..."
onClick={() => handleSaveVerse(v.index)} onClick={() => handleSaveVerse(v.index)}
className="rounded-xl bg-primary text-primary-foreground text-xs font-bold" className="rounded-xl bg-primary text-primary-foreground text-xs font-bold"
> >
{isSavingVerse ? 'در حال ذخیره...' : 'ذخیره تغییرات فراز'}
ذخیره تغییرات فراز
</Button> </Button>
<Button <Button
size="sm" size="sm"

342
src/features/services/components/FaqServiceView.tsx

@ -7,8 +7,9 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer, ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { toFa, cn } from '@/lib/utils' import { toFa, cn } from '@/lib/utils'
import { toast } from '@/stores/toast'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -48,6 +49,95 @@ const AVAILABLE_ICONS = [
{ id: 'help', label: 'راهنمای عمومی (Help)' }, { id: 'help', label: 'راهنمای عمومی (Help)' },
] ]
function FaqCategoryCardSkeleton() {
return (
<Card className="border-border/50 bg-card/40 rounded-2xl flex flex-col justify-between shadow-2xs">
<CardContent className="p-4 space-y-3">
{/* هدر کارت: آیکون، عنوان و سوئیچ */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<ShimmerBox width={40} height={40} rounded="xl" />
<div className="space-y-1.5">
<ShimmerLine width={110} height={14} rounded="sm" />
<div className="flex items-center gap-1.5">
<ShimmerLine width={48} height={10} rounded="sm" />
<ShimmerLine width={36} height={10} rounded="sm" />
</div>
</div>
</div>
<ShimmerBox width={36} height={20} rounded="full" />
</div>
{/* توضیحات */}
<div className="space-y-1.5 min-h-[32px] pt-0.5">
<ShimmerLine width="100%" height={12} rounded="sm" />
<ShimmerLine width="65%" height={12} rounded="sm" />
</div>
{/* تگ‌های زبان‌ها و تعداد سوالات */}
<div className="flex items-center justify-between pt-2 border-t border-border/40">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, li) => (
<ShimmerBox key={li} width={22} height={16} rounded="sm" />
))}
</div>
<ShimmerLine width={44} height={12} rounded="sm" />
</div>
{/* دکمه‌های اکشن */}
<div className="flex items-center justify-between pt-2 border-t border-border/40">
<ShimmerBox width={96} height={28} rounded="lg" />
<div className="flex items-center gap-1">
<ShimmerBox width={28} height={28} rounded="lg" />
<ShimmerBox width={28} height={28} rounded="lg" />
</div>
</div>
</CardContent>
</Card>
)
}
function FaqQuestionCardSkeleton() {
return (
<Card className="border-border/50 bg-card/40 rounded-2xl shadow-2xs">
<CardContent className="p-4 space-y-3">
{/* هدر: بج‌ها + عنوان سوال + سوئیچ + اکشن‌ها */}
<div className="flex items-start justify-between gap-3">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2">
<ShimmerBox width={52} height={18} rounded="md" />
<ShimmerBox width={88} height={18} rounded="full" />
</div>
<ShimmerLine width="75%" height={14} rounded="sm" />
</div>
<div className="flex items-center gap-2 shrink-0">
<ShimmerBox width={36} height={20} rounded="full" />
<ShimmerBox width={28} height={28} rounded="lg" />
<ShimmerBox width={28} height={28} rounded="lg" />
</div>
</div>
{/* کادر پاسخ */}
<div className="rounded-xl bg-muted/40 p-3 border border-border/30 space-y-1.5">
<ShimmerLine width="100%" height={12} rounded="sm" />
<ShimmerLine width="80%" height={12} rounded="sm" />
</div>
{/* فوتر چندزبانه */}
<div className="flex items-center justify-between pt-1 border-t border-border/30">
<ShimmerLine width={90} height={10} rounded="sm" />
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, li) => (
<ShimmerBox key={li} width={22} height={16} rounded="sm" />
))}
</div>
</div>
</CardContent>
</Card>
)
}
export function FaqServiceView() { export function FaqServiceView() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const categoryParam = searchParams.get('category') const categoryParam = searchParams.get('category')
@ -58,7 +148,8 @@ export function FaqServiceView() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [loadingCategoryIds, setLoadingCategoryIds] = useState<Set<number>>(new Set())
const [loadingItemIds, setLoadingItemIds] = useState<Set<number>>(new Set())
// جستجو // جستجو
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@ -135,13 +226,6 @@ export function FaqServiceView() {
loadData() loadData()
}, []) }, [])
// نمایش پیام موفقیت موقت
const showToast = (msg: string) => {
setSuccessMessage(msg)
setTimeout(() => {
setSuccessMessage(null)
}, 4000)
}
// دسته‌بندی انتخاب‌شده جهت دریل‌داون و نمایش سوالات // دسته‌بندی انتخاب‌شده جهت دریل‌داون و نمایش سوالات
const selectedCategory = useMemo(() => { const selectedCategory = useMemo(() => {
@ -236,13 +320,32 @@ export function FaqServiceView() {
setIsCatDialogOpen(true) setIsCatDialogOpen(true)
} }
const handleCopyCatTranslationsToAll = () => {
const current = catTranslations[catActiveLang]
if (!current?.title.trim()) {
toast.warning('ابتدا عنوان زبان جاری را وارد نمایید.')
return
}
setCatTranslations((prev) => {
const next = { ...prev }
SUPPORTED_LANGUAGES.forEach((l) => {
next[l.code] = {
title: current.title,
description: current.description || '',
}
})
return next
})
toast.success(`محتوای زبان ${catActiveLang.toUpperCase()} با موفقیت در تمامی ۵ زبان همگام‌سازی شد.`)
}
const handleSaveCategory = async () => { const handleSaveCategory = async () => {
if (!catSlug.trim()) { if (!catSlug.trim()) {
alert('لطفاً شناسه یکتا (Slug) دسته‌بندی را وارد کنید.')
toast.error('لطفاً شناسه یکتا (Slug) دسته‌بندی را وارد کنید.')
return return
} }
if (!catTranslations.fa.title.trim()) { if (!catTranslations.fa.title.trim()) {
alert('عنوان دسته‌بندی به زبان فارسی الزامی است.')
toast.error('عنوان دسته‌بندی به زبان فارسی الزامی است.')
return return
} }
@ -259,15 +362,15 @@ export function FaqServiceView() {
if (editingCategory) { if (editingCategory) {
const updated = await updateFaqCategory(editingCategory.id, payload) const updated = await updateFaqCategory(editingCategory.id, payload)
setCategories((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) setCategories((prev) => prev.map((c) => (c.id === updated.id ? updated : c)))
showToast('دسته‌بندی با موفقیت ویرایش شد.')
toast.success('دسته‌بندی با موفقیت ویرایش شد.')
} else { } else {
const created = await createFaqCategory(payload) const created = await createFaqCategory(payload)
setCategories((prev) => [...prev, created]) setCategories((prev) => [...prev, created])
showToast('دسته‌بندی جدید با موفقیت ایجاد شد.')
toast.success('دسته‌بندی جدید با موفقیت ایجاد شد.')
} }
setIsCatDialogOpen(false) setIsCatDialogOpen(false)
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'خطا در ذخیره دسته‌بندی.')
toast.error(err?.message || 'خطا در ذخیره دسته‌بندی.')
} finally { } finally {
setIsSavingCat(false) setIsSavingCat(false)
} }
@ -279,20 +382,30 @@ export function FaqServiceView() {
} }
const nextState = typeof checkedOrEvent === 'boolean' ? checkedOrEvent : !cat.is_active const nextState = typeof checkedOrEvent === 'boolean' ? checkedOrEvent : !cat.is_active
// بروزرسانی آنی در استیت
setCategories((prev) =>
prev.map((c) => (c.id === cat.id ? { ...c, is_active: nextState } : c))
)
if (loadingCategoryIds.has(cat.id)) return
setLoadingCategoryIds((prev) => new Set(prev).add(cat.id))
try { try {
await toggleFaqCategoryActive(cat.id, nextState) await toggleFaqCategoryActive(cat.id, nextState)
showToast(`وضعیت نمایش «${cat.title_fa}» ${nextState ? 'فعال' : 'غیرفعال'} شد.`)
} catch (err: any) {
// بازگرداندن به حالت قبل در صورت خطا
setCategories((prev) => setCategories((prev) =>
prev.map((c) => (c.id === cat.id ? { ...c, is_active: !nextState } : c))
prev.map((c) => (c.id === cat.id ? { ...c, is_active: nextState } : c))
)
toast.success(
`وضعیت نمایش «${cat.title_fa}» با موفقیت ${nextState ? 'فعال' : 'غیرفعال'} شد.`,
'بروزرسانی دسته‌بندی'
) )
alert(err?.message || 'خطا در تغییر وضعیت دسته‌بندی.')
} catch (err: any) {
toast.error(
err?.message || 'خطا در تغییر وضعیت دسته‌بندی.',
'خطا در انجام عملیات'
)
} finally {
setLoadingCategoryIds((prev) => {
const next = new Set(prev)
next.delete(cat.id)
return next
})
} }
} }
@ -349,17 +462,36 @@ export function FaqServiceView() {
setIsItemDialogOpen(true) setIsItemDialogOpen(true)
} }
const handleCopyItemTranslationsToAll = () => {
const current = itemTranslations[itemActiveLang]
if (!current?.question.trim()) {
toast.warning('ابتدا متن سوال زبان جاری را وارد نمایید.')
return
}
setItemTranslations((prev) => {
const next = { ...prev }
SUPPORTED_LANGUAGES.forEach((l) => {
next[l.code] = {
question: current.question,
answer: current.answer || '',
}
})
return next
})
toast.success(`محتوای سوال زبان ${itemActiveLang.toUpperCase()} با موفقیت در تمامی ۵ زبان همگام‌سازی شد.`)
}
const handleSaveItem = async () => { const handleSaveItem = async () => {
if (!itemCategoryId) { if (!itemCategoryId) {
alert('لطفاً دسته‌بندی موضوعی سوال را انتخاب کنید.')
toast.error('لطفاً دسته‌بندی موضوعی سوال را انتخاب کنید.')
return return
} }
if (!itemTranslations.fa.question.trim()) { if (!itemTranslations.fa.question.trim()) {
alert('متن سوال به زبان فارسی الزامی است.')
toast.error('متن سوال به زبان فارسی الزامی است.')
return return
} }
if (!itemTranslations.fa.answer.trim()) { if (!itemTranslations.fa.answer.trim()) {
alert('متن پاسخ به زبان فارسی الزامی است.')
toast.error('متن پاسخ به زبان فارسی الزامی است.')
return return
} }
@ -375,7 +507,7 @@ export function FaqServiceView() {
if (editingItem) { if (editingItem) {
const updated = await updateFaqItem(editingItem.id, payload) const updated = await updateFaqItem(editingItem.id, payload)
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it))) setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
showToast('پرسش و پاسخ با موفقیت ویرایش شد.')
toast.success('پرسش و پاسخ با موفقیت ویرایش شد.')
} else { } else {
const created = await createFaqItem(payload) const created = await createFaqItem(payload)
setItems((prev) => [...prev, created]) setItems((prev) => [...prev, created])
@ -385,11 +517,11 @@ export function FaqServiceView() {
c.id === created.category ? { ...c, items_count: c.items_count + 1 } : c c.id === created.category ? { ...c, items_count: c.items_count + 1 } : c
) )
) )
showToast('پرسش و پاسخ جدید با موفقیت ایجاد شد.')
toast.success('پرسش و پاسخ جدید با موفقیت ایجاد شد.')
} }
setIsItemDialogOpen(false) setIsItemDialogOpen(false)
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'خطا در ذخیره سوال متداول.')
toast.error(err?.message || 'خطا در ذخیره سوال متداول.')
} finally { } finally {
setIsSavingItem(false) setIsSavingItem(false)
} }
@ -401,20 +533,30 @@ export function FaqServiceView() {
} }
const nextState = typeof checkedOrEvent === 'boolean' ? checkedOrEvent : !item.is_active const nextState = typeof checkedOrEvent === 'boolean' ? checkedOrEvent : !item.is_active
// بروزرسانی آنی در استیت
setItems((prev) =>
prev.map((it) => (it.id === item.id ? { ...it, is_active: nextState } : it))
)
if (loadingItemIds.has(item.id)) return
setLoadingItemIds((prev) => new Set(prev).add(item.id))
try { try {
await toggleFaqItemActive(item.id, nextState) await toggleFaqItemActive(item.id, nextState)
showToast(`وضعیت نمایش سوال ${nextState ? 'فعال' : 'غیرفعال'} شد.`)
} catch (err: any) {
// بازگرداندن
setItems((prev) => setItems((prev) =>
prev.map((it) => (it.id === item.id ? { ...it, is_active: !nextState } : it))
prev.map((it) => (it.id === item.id ? { ...it, is_active: nextState } : it))
) )
alert(err?.message || 'خطا در تغییر وضعیت سوال.')
toast.success(
`وضعیت نمایش سوال با موفقیت ${nextState ? 'فعال' : 'غیرفعال'} شد.`,
'بروزرسانی سوال'
)
} catch (err: any) {
toast.error(
err?.message || 'خطا در تغییر وضعیت سوال.',
'خطا در انجام عملیات'
)
} finally {
setLoadingItemIds((prev) => {
const next = new Set(prev)
next.delete(item.id)
return next
})
} }
} }
@ -440,7 +582,7 @@ export function FaqServiceView() {
if (selectedCategory?.id === deleteDialogData.id) { if (selectedCategory?.id === deleteDialogData.id) {
handleBackToCategories() handleBackToCategories()
} }
showToast('دسته‌بندی و سوالات زیرمجموعه آن با موفقیت حذف شدند.')
toast.success('دسته‌بندی و سوالات زیرمجموعه آن با موفقیت حذف شدند.')
} else { } else {
await deleteFaqItem(deleteDialogData.id) await deleteFaqItem(deleteDialogData.id)
setItems((prev) => prev.filter((it) => it.id !== deleteDialogData.id)) setItems((prev) => prev.filter((it) => it.id !== deleteDialogData.id))
@ -451,11 +593,11 @@ export function FaqServiceView() {
: c : c
) )
) )
showToast('سوال متداول با موفقیت حذف شد.')
toast.success('سوال متداول با موفقیت حذف شد.')
} }
setDeleteDialogData((prev) => ({ ...prev, isOpen: false })) setDeleteDialogData((prev) => ({ ...prev, isOpen: false }))
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'خطا در حذف مورد.')
toast.error(err?.message || 'خطا در حذف مورد.')
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
} }
@ -463,13 +605,6 @@ export function FaqServiceView() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* پیام اعلان موفقیت */}
{successMessage && (
<div className="flex items-center gap-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-xs text-emerald-600 dark:text-emerald-400">
<Ic name="checkCircle" className="size-4 shrink-0" />
<span className="font-medium">{successMessage}</span>
</div>
)}
{/* پیام خطا در صورت وجود */} {/* پیام خطا در صورت وجود */}
{errorMessage && ( {errorMessage && (
@ -540,6 +675,7 @@ export function FaqServiceView() {
<span className="text-[11px] text-muted-foreground font-medium">وضعیت دسته‌بندی:</span> <span className="text-[11px] text-muted-foreground font-medium">وضعیت دسته‌بندی:</span>
<Switch <Switch
checked={selectedCategory.is_active} checked={selectedCategory.is_active}
isLoading={loadingCategoryIds.has(selectedCategory.id)}
onCheckedChange={(checked) => handleToggleCategoryActive(selectedCategory, checked)} onCheckedChange={(checked) => handleToggleCategoryActive(selectedCategory, checked)}
/> />
<span <span
@ -577,6 +713,20 @@ export function FaqServiceView() {
{/* کارت‌های آماری سوالات این دسته‌بندی */} {/* کارت‌های آماری سوالات این دسته‌بندی */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div className="space-y-2">
<ShimmerLine width={80} height={11} rounded="sm" />
<ShimmerLine width={36} height={20} rounded="sm" />
</div>
<ShimmerBox width={36} height={36} rounded="xl" />
</CardContent>
</Card>
))
) : (
<>
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
@ -628,6 +778,8 @@ export function FaqServiceView() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</>
)}
</div> </div>
{/* نوار جستجو و تعویض سریع دسته‌بندی */} {/* نوار جستجو و تعویض سریع دسته‌بندی */}
@ -668,9 +820,9 @@ export function FaqServiceView() {
{/* فهرست سوالات دسته‌بندی */} {/* فهرست سوالات دسته‌بندی */}
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-3">
<Shimmer className="h-24 w-full rounded-2xl" />
<Shimmer className="h-24 w-full rounded-2xl" />
<Shimmer className="h-24 w-full rounded-2xl" />
{Array.from({ length: 4 }).map((_, i) => (
<FaqQuestionCardSkeleton key={i} />
))}
</div> </div>
) : categoryQuestions.length === 0 ? ( ) : categoryQuestions.length === 0 ? (
<div className="py-12 text-center rounded-2xl border border-dashed border-border/60 p-8 bg-card/40"> <div className="py-12 text-center rounded-2xl border border-dashed border-border/60 p-8 bg-card/40">
@ -725,6 +877,7 @@ export function FaqServiceView() {
<div className="flex items-center gap-1.5" title="فعال / غیرفعال‌سازی نمایش در اپلیکیشن"> <div className="flex items-center gap-1.5" title="فعال / غیرفعال‌سازی نمایش در اپلیکیشن">
<Switch <Switch
checked={item.is_active} checked={item.is_active}
isLoading={loadingItemIds.has(item.id)}
onCheckedChange={(checked) => handleToggleItemActive(item, checked)} onCheckedChange={(checked) => handleToggleItemActive(item, checked)}
/> />
</div> </div>
@ -838,6 +991,20 @@ export function FaqServiceView() {
{/* کارت‌های آماری بالای صفحه (دسته‌بندی‌ها) */} {/* کارت‌های آماری بالای صفحه (دسته‌بندی‌ها) */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div className="space-y-2">
<ShimmerLine width={75} height={11} rounded="sm" />
<ShimmerLine width={36} height={20} rounded="sm" />
</div>
<ShimmerBox width={36} height={36} rounded="xl" />
</CardContent>
</Card>
))
) : (
<>
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
@ -889,6 +1056,8 @@ export function FaqServiceView() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</>
)}
</div> </div>
{/* نوار جستجوی دسته‌بندی‌ها */} {/* نوار جستجوی دسته‌بندی‌ها */}
@ -904,10 +1073,10 @@ export function FaqServiceView() {
{/* گرید دسته‌بندی‌ها */} {/* گرید دسته‌بندی‌ها */}
{isLoading ? ( {isLoading ? (
<div className="space-y-3">
<Shimmer className="h-24 w-full rounded-2xl" />
<Shimmer className="h-24 w-full rounded-2xl" />
<Shimmer className="h-24 w-full rounded-2xl" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3.5">
{Array.from({ length: 6 }).map((_, i) => (
<FaqCategoryCardSkeleton key={i} />
))}
</div> </div>
) : filteredCategories.length === 0 ? ( ) : filteredCategories.length === 0 ? (
<div className="py-12 text-center rounded-2xl border border-dashed border-border/60 p-8 bg-card/40"> <div className="py-12 text-center rounded-2xl border border-dashed border-border/60 p-8 bg-card/40">
@ -970,6 +1139,7 @@ export function FaqServiceView() {
> >
<Switch <Switch
checked={cat.is_active} checked={cat.is_active}
isLoading={loadingCategoryIds.has(cat.id)}
onCheckedChange={(checked) => handleToggleCategoryActive(cat, checked)} onCheckedChange={(checked) => handleToggleCategoryActive(cat, checked)}
/> />
</div> </div>
@ -1157,9 +1327,22 @@ export function FaqServiceView() {
</div> </div>
<span className="text-xs font-bold text-foreground">عناوین چندزبانه دسته‌بندی (پوشش ۵ زبان)</span> <span className="text-xs font-bold text-foreground">عناوین چندزبانه دسته‌بندی (پوشش ۵ زبان)</span>
</div> </div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] border-border/60 px-2 py-0.5 font-medium text-muted-foreground"> <Badge variant="outline" className="text-[10px] border-border/60 px-2 py-0.5 font-medium text-muted-foreground">
زبان انتخابی: {SUPPORTED_LANGUAGES.find((l) => l.code === catActiveLang)?.label} زبان انتخابی: {SUPPORTED_LANGUAGES.find((l) => l.code === catActiveLang)?.label}
</Badge> </Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopyCatTranslationsToAll}
className="h-6 text-[10px] px-2 text-primary hover:bg-primary/10 rounded-lg gap-1"
title="کپی کردن متن این زبان به تمامی ۴ زبان دیگر"
>
<Ic name="repeat" className="size-3" />
<span>کپی در تمام زبان‌ها</span>
</Button>
</div>
</div> </div>
{/* تب‌های انتخاب زبان */} {/* تب‌های انتخاب زبان */}
@ -1255,20 +1438,12 @@ export function FaqServiceView() {
<Button <Button
size="sm" size="sm"
onClick={handleSaveCategory} onClick={handleSaveCategory}
disabled={isSavingCat}
isLoading={isSavingCat}
loadingText="در حال ذخیره..."
className="rounded-xl px-5 text-xs font-bold bg-primary hover:bg-primary/90 text-primary-foreground shadow-xs gap-1.5" className="rounded-xl px-5 text-xs font-bold bg-primary hover:bg-primary/90 text-primary-foreground shadow-xs gap-1.5"
> >
{isSavingCat ? (
<>
<Ic name="refresh" className="size-3.5 animate-spin" />
<span>در حال ذخیره...</span>
</>
) : (
<>
<Ic name="check" className="size-3.5" /> <Ic name="check" className="size-3.5" />
<span>{editingCategory ? 'ثبت تغییرات' : 'ایجاد دسته‌بندی'}</span> <span>{editingCategory ? 'ثبت تغییرات' : 'ایجاد دسته‌بندی'}</span>
</>
)}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@ -1363,9 +1538,22 @@ export function FaqServiceView() {
</div> </div>
<span className="text-xs font-bold text-foreground">متن سوال و پاسخ (پوشش ۵ زبان)</span> <span className="text-xs font-bold text-foreground">متن سوال و پاسخ (پوشش ۵ زبان)</span>
</div> </div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] border-border/60 px-2 py-0.5 font-medium text-muted-foreground"> <Badge variant="outline" className="text-[10px] border-border/60 px-2 py-0.5 font-medium text-muted-foreground">
زبان انتخابی: {SUPPORTED_LANGUAGES.find((l) => l.code === itemActiveLang)?.label} زبان انتخابی: {SUPPORTED_LANGUAGES.find((l) => l.code === itemActiveLang)?.label}
</Badge> </Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopyItemTranslationsToAll}
className="h-6 text-[10px] px-2 text-primary hover:bg-primary/10 rounded-lg gap-1"
title="کپی کردن متن این زبان به تمامی ۴ زبان دیگر"
>
<Ic name="repeat" className="size-3" />
<span>کپی در تمام زبان‌ها</span>
</Button>
</div>
</div> </div>
{/* تب‌های انتخاب زبان */} {/* تب‌های انتخاب زبان */}
@ -1464,20 +1652,12 @@ export function FaqServiceView() {
<Button <Button
size="sm" size="sm"
onClick={handleSaveItem} onClick={handleSaveItem}
disabled={isSavingItem}
isLoading={isSavingItem}
loadingText="در حال ذخیره..."
className="rounded-xl px-5 text-xs font-bold bg-primary hover:bg-primary/90 text-primary-foreground shadow-xs gap-1.5" className="rounded-xl px-5 text-xs font-bold bg-primary hover:bg-primary/90 text-primary-foreground shadow-xs gap-1.5"
> >
{isSavingItem ? (
<>
<Ic name="refresh" className="size-3.5 animate-spin" />
<span>در حال ذخیره...</span>
</>
) : (
<>
<Ic name="check" className="size-3.5" /> <Ic name="check" className="size-3.5" />
<span>{editingItem ? 'ثبت تغییرات' : 'ایجاد سوال'}</span> <span>{editingItem ? 'ثبت تغییرات' : 'ایجاد سوال'}</span>
</>
)}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@ -1542,20 +1722,12 @@ export function FaqServiceView() {
<Button <Button
size="sm" size="sm"
onClick={handleConfirmDelete} onClick={handleConfirmDelete}
disabled={isDeleting}
isLoading={isDeleting}
loadingText="در حال حذف..."
className="rounded-xl px-5 text-xs font-bold bg-rose-600 hover:bg-rose-700 text-white shadow-xs gap-1.5" className="rounded-xl px-5 text-xs font-bold bg-rose-600 hover:bg-rose-700 text-white shadow-xs gap-1.5"
> >
{isDeleting ? (
<>
<Ic name="refresh" className="size-3.5 animate-spin" />
<span>در حال حذف...</span>
</>
) : (
<>
<Ic name="trash" className="size-3.5" /> <Ic name="trash" className="size-3.5" />
<span>بله، حذف شود</span> <span>بله، حذف شود</span>
</>
)}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

3
src/features/services/components/LocationDetailSheet.tsx

@ -17,6 +17,7 @@ interface LocationDetailSheetProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onToggleStatus: (id: number, currentStatus: boolean) => void onToggleStatus: (id: number, currentStatus: boolean) => void
isLoadingStatus?: boolean
onEdit: (location: CityGuideItem) => void onEdit: (location: CityGuideItem) => void
} }
@ -35,6 +36,7 @@ export function LocationDetailSheet({
open, open,
onOpenChange, onOpenChange,
onToggleStatus, onToggleStatus,
isLoadingStatus,
onEdit, onEdit,
}: LocationDetailSheetProps) { }: LocationDetailSheetProps) {
if (!location) return null if (!location) return null
@ -101,6 +103,7 @@ export function LocationDetailSheet({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch <Switch
checked={location.is_active ?? true} checked={location.is_active ?? true}
isLoading={isLoadingStatus}
onCheckedChange={() => onToggleStatus(location.id, location.is_active ?? true)} onCheckedChange={() => onToggleStatus(location.id, location.is_active ?? true)}
/> />
<div className="text-xs"> <div className="text-xs">

7
src/features/services/components/ShrinesServiceView.tsx

@ -4,7 +4,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { import {
Dialog, Dialog,
@ -592,10 +592,11 @@ export function ShrinesServiceView() {
<DialogFooter className="gap-2 sm:justify-start pt-2"> <DialogFooter className="gap-2 sm:justify-start pt-2">
<Button <Button
type="submit" type="submit"
disabled={isSaving}
isLoading={isSaving}
loadingText="در حال ذخیره..."
className="rounded-xl bg-primary text-primary-foreground text-xs font-bold" className="rounded-xl bg-primary text-primary-foreground text-xs font-bold"
> >
{isSaving ? 'در حال ذخیره...' : editingShrine ? 'ذخیره تغییرات' : 'ثبت مکان زیارتی'}
{editingShrine ? 'ذخیره تغییرات' : 'ثبت مکان زیارتی'}
</Button> </Button>
<Button <Button
type="button" type="button"

29
src/features/services/components/ZayerGuideServiceView.tsx

@ -6,8 +6,9 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { toast } from '@/stores/toast'
import type { ServiceMeta } from '../ServicesPage' import type { ServiceMeta } from '../ServicesPage'
import type { import type {
CityGuideItem, CityGuideItem,
@ -38,6 +39,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [loadingLocationIds, setLoadingLocationIds] = useState<Set<number>>(new Set())
// فیلترها و مرتب‌سازی // فیلترها و مرتب‌سازی
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@ -162,8 +164,15 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
}, [locations]) }, [locations])
// تغییر سوییچ وضعیت فعال/غیرفعال برای یک مکان // تغییر سوییچ وضعیت فعال/غیرفعال برای یک مکان
const handleToggleStatus = (id: number, currentStatus: boolean) => {
const handleToggleStatus = async (id: number, currentStatus: boolean) => {
if (loadingLocationIds.has(id)) return
const targetLoc = locations.find((l) => l.id === id)
const newStatus = !currentStatus const newStatus = !currentStatus
setLoadingLocationIds((prev) => new Set(prev).add(id))
try {
await new Promise((resolve) => setTimeout(resolve, 300))
toggleCityGuideStatusLocal(id, newStatus) toggleCityGuideStatusLocal(id, newStatus)
setLocations((prev) => setLocations((prev) =>
prev.map((loc) => (loc.id === id ? { ...loc, is_active: newStatus } : loc)) prev.map((loc) => (loc.id === id ? { ...loc, is_active: newStatus } : loc))
@ -171,6 +180,19 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
if (detailLocation?.id === id) { if (detailLocation?.id === id) {
setDetailLocation((prev) => (prev ? { ...prev, is_active: newStatus } : null)) setDetailLocation((prev) => (prev ? { ...prev, is_active: newStatus } : null))
} }
toast.success(
`وضعیت نمایش «${targetLoc?.title || 'مکان'}» با موفقیت ${newStatus ? 'فعال' : 'غیرفعال'} شد.`,
'بروزرسانی وضعیت'
)
} catch (err: any) {
toast.error(err?.message || 'خطا در تغییر وضعیت مکان.', 'خطا در انجام عملیات')
} finally {
setLoadingLocationIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}
} }
// عملیات انتخاب چندگانه (Multi-Select) // عملیات انتخاب چندگانه (Multi-Select)
@ -544,6 +566,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<div className="flex items-center gap-1.5" title={isActive ? 'فعال در اپلیکیشن' : 'غیرفعال / پنهان'}> <div className="flex items-center gap-1.5" title={isActive ? 'فعال در اپلیکیشن' : 'غیرفعال / پنهان'}>
<Switch <Switch
checked={isActive} checked={isActive}
isLoading={loadingLocationIds.has(item.id)}
onCheckedChange={() => handleToggleStatus(item.id, isActive)} onCheckedChange={() => handleToggleStatus(item.id, isActive)}
/> />
<span className={`text-[10px] font-bold ${isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'}`}> <span className={`text-[10px] font-bold ${isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'}`}>
@ -695,6 +718,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<Switch <Switch
checked={isActive} checked={isActive}
isLoading={loadingLocationIds.has(item.id)}
onCheckedChange={() => handleToggleStatus(item.id, isActive)} onCheckedChange={() => handleToggleStatus(item.id, isActive)}
/> />
<Badge <Badge
@ -763,6 +787,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
open={isDetailOpen} open={isDetailOpen}
onOpenChange={setIsDetailOpen} onOpenChange={setIsDetailOpen}
onToggleStatus={handleToggleStatus} onToggleStatus={handleToggleStatus}
isLoadingStatus={detailLocation ? loadingLocationIds.has(detailLocation.id) : false}
onEdit={(loc) => { onEdit={(loc) => {
setEditingLocation(loc) setEditingLocation(loc)
setIsCreateOpen(true) setIsCreateOpen(true)

270
src/features/tours/OngoingToursPage.tsx

@ -6,11 +6,11 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { fetchTours, fetchTourItineraries } from './services/tour-api'
import { fetchTours, fetchTourItineraries, setTourCurrentStep } from './services/tour-api'
import type { TourListItem, TourItineraryItem } from './types' import type { TourListItem, TourItineraryItem } from './types'
import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
import { TourStatusBadge } from './components/TourStatusBadge'
import FilterSelect from '@/components/shared/FilterSelect' import FilterSelect from '@/components/shared/FilterSelect'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer, ShimmerBox, ShimmerLine, ShimmerCircle } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateByLanguage, formatDateRangeByLanguage } from '@/lib/date-utils' import { formatDateByLanguage, formatDateRangeByLanguage } from '@/lib/date-utils'
@ -36,11 +36,11 @@ export default function OngoingToursPage() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [updatingStepTourId, setUpdatingStepTourId] = useState<number | null>(null)
// فیلترها و جستجو // فیلترها و جستجو
const [activeTab, setActiveTab] = useState<'ALL' | 'TRAVELING' | 'IMMINENT'>('ALL') const [activeTab, setActiveTab] = useState<'ALL' | 'TRAVELING' | 'IMMINENT'>('ALL')
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [countryFilter, setCountryFilter] = useState('ALL')
const [expandedTourIds, setExpandedTourIds] = useState<Record<number, boolean>>({}) const [expandedTourIds, setExpandedTourIds] = useState<Record<number, boolean>>({})
// لود فهرست همه تورها // لود فهرست همه تورها
@ -54,12 +54,8 @@ export default function OngoingToursPage() {
const rawTours = res.results || [] const rawTours = res.results || []
setTours(rawTours) setTours(rawTours)
// باز کردن پیش‌فرض کارت اولین تورهای در حال اجرا
const initialExpanded: Record<number, boolean> = {}
rawTours.forEach((t) => {
initialExpanded[t.id] = true
})
setExpandedTourIds(initialExpanded)
// حالت پیش‌فرض: برنامه سفر تورها بسته است (Collapsed)
setExpandedTourIds({})
} catch (err: any) { } catch (err: any) {
setError(err.message || 'خطا در برقراری ارتباط با وب‌سرویس تورها') setError(err.message || 'خطا در برقراری ارتباط با وب‌سرویس تورها')
} finally { } finally {
@ -68,6 +64,62 @@ export default function OngoingToursPage() {
} }
} }
// تغییر گام جاری تور با همگام‌سازی بک‌اند و کلاینت‌ها
const handleStepChange = async (tourId: number, targetIndex: number) => {
const itState = itinerariesMap[tourId]
if (!itState || !itState.data || itState.data.length === 0) return
if (targetIndex < 0 || targetIndex >= itState.data.length) return
setUpdatingStepTourId(tourId)
// بروزرسانی آنی محلی (Optimistic UI Update)
const previousData = [...itState.data]
const updatedData = previousData.map((it, idx) => ({
...it,
status: idx < targetIndex ? 'Completed' : idx === targetIndex ? 'In Progress' : 'Not Started',
}))
setItinerariesMap((prev) => ({
...prev,
[tourId]: { ...prev[tourId], data: updatedData },
}))
try {
const res = await setTourCurrentStep(tourId, { step_index: targetIndex })
if (res && res.itineraries) {
setItinerariesMap((prev) => {
const currentList = prev[tourId]?.data || []
const syncedList = currentList.map((item, idx) => {
const match = res.itineraries.find((r) => r.id === item.id)
return {
...item,
status: match
? match.status
: idx < targetIndex
? 'Completed'
: idx === targetIndex
? 'In Progress'
: 'Not Started',
}
})
return {
...prev,
[tourId]: { ...prev[tourId], data: syncedList },
}
})
}
} catch (err: any) {
// بازگردانی در صورت بروز خطای سرور
setItinerariesMap((prev) => ({
...prev,
[tourId]: { ...prev[tourId], data: previousData },
}))
alert(err.message || 'خطا در تغییر مرحله تور.')
} finally {
setUpdatingStepTourId(null)
}
}
useEffect(() => { useEffect(() => {
loadToursData() loadToursData()
}, []) }, [])
@ -99,7 +151,7 @@ export default function OngoingToursPage() {
const itState = itinerariesMap[tour.id] const itState = itinerariesMap[tour.id]
const itineraries = itState?.data || [] const itineraries = itState?.data || []
const isLoadingItinerary = itState?.loading || false
const isLoadingItinerary = itState === undefined ? true : itState.loading
const itineraryError = itState?.error || null const itineraryError = itState?.error || null
// تعیین مرحله جاری سفر (Current Itinerary Step) // تعیین مرحله جاری سفر (Current Itinerary Step)
@ -216,9 +268,6 @@ export default function OngoingToursPage() {
if (activeTab === 'TRAVELING' && !item.isCurrentlyRunning) return false if (activeTab === 'TRAVELING' && !item.isCurrentlyRunning) return false
if (activeTab === 'IMMINENT' && !item.isImminent) return false if (activeTab === 'IMMINENT' && !item.isImminent) return false
// فیلتر کشور
if (countryFilter !== 'ALL' && item.tour.destination_country !== countryFilter) return false
// جستجو // جستجو
if (searchTerm.trim()) { if (searchTerm.trim()) {
const query = searchTerm.toLowerCase() const query = searchTerm.toLowerCase()
@ -229,17 +278,17 @@ export default function OngoingToursPage() {
return true return true
}) })
}, [processedTours, activeTab, countryFilter, searchTerm])
}, [processedTours, activeTab, searchTerm])
// شاخص‌های آماری بالای صفحه // شاخص‌های آماری بالای صفحه
const stats = useMemo(() => { const stats = useMemo(() => {
const totalOngoing = processedTours.length const totalOngoing = processedTours.length
const currentlyTraveling = processedTours.filter((t) => t.isCurrentlyRunning).length const currentlyTraveling = processedTours.filter((t) => t.isCurrentlyRunning).length
const startingSoon = processedTours.filter((t) => t.isImminent).length const startingSoon = processedTours.filter((t) => t.isImminent).length
const distinctCountries = new Set(processedTours.map((t) => t.tour.destination_country).filter(Boolean)).size
const totalItineraries = Object.values(itinerariesMap).reduce((acc, curr) => acc + (curr.data?.length || 0), 0)
return { totalOngoing, currentlyTraveling, startingSoon, distinctCountries }
}, [processedTours])
return { totalOngoing, currentlyTraveling, startingSoon, totalItineraries }
}, [processedTours, itinerariesMap])
const toggleExpand = (tourId: number) => { const toggleExpand = (tourId: number) => {
setExpandedTourIds((prev) => ({ setExpandedTourIds((prev) => ({
@ -333,10 +382,7 @@ export default function OngoingToursPage() {
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-sky-500/10 text-sky-500 flex items-center justify-center"> <div className="size-9 rounded-xl bg-sky-500/10 text-sky-500 flex items-center justify-center">
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full size-2 bg-sky-500"></span>
</span>
<span className="size-2 rounded-full bg-sky-500" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -362,15 +408,15 @@ export default function OngoingToursPage() {
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-muted-foreground font-medium">مقاصد در حال میزبانی</p>
<p className="text-[11px] text-muted-foreground font-medium">مراحل سفر ثبت‌شده</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.distinctCountries)}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.totalItineraries)}</p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground"> <div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="globe" className="size-4" />
<Ic name="map" className="size-4" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -416,7 +462,7 @@ export default function OngoingToursPage() {
</Button> </Button>
</div> </div>
{/* فیلتر مقصد و جستجو */}
{/* جستجو */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] flex-1 sm:w-64 sm:flex-none"> <div className="relative min-w-[200px] flex-1 sm:w-64 sm:flex-none">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" /> <Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
@ -427,21 +473,6 @@ export default function OngoingToursPage() {
className="h-8 pr-8 text-xs bg-surface-base border-border-soft" className="h-8 pr-8 text-xs bg-surface-base border-border-soft"
/> />
</div> </div>
<FilterSelect
value={countryFilter}
onChange={setCountryFilter}
options={[
{ value: 'ALL', label: 'همه مقاصد' },
{ value: 'IQ', label: '🇮🇶 عراق' },
{ value: 'IR', label: '🇮🇷 ایران' },
{ value: 'SA', label: '🇸🇦 عربستان' },
{ value: 'SY', label: '🇸🇾 سوریه' },
]}
allValue="ALL"
size="sm"
className="w-32"
/>
</div> </div>
</div> </div>
@ -534,15 +565,11 @@ export default function OngoingToursPage() {
<div className="min-w-0 space-y-1.5 flex-1"> <div className="min-w-0 space-y-1.5 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-mono font-bold text-grey-3">#{tour.id}</span> <span className="text-xs font-mono font-bold text-grey-3">#{tour.id}</span>
<CountryFlagBadge countryCode={tour.destination_country} />
{/* وضعیت زمانی زنده */} {/* وضعیت زمانی زنده */}
{isCurrentlyRunning ? ( {isCurrentlyRunning ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-sky-500/15 px-2.5 py-0.5 text-xs font-bold text-sky-500"> <span className="inline-flex items-center gap-1.5 rounded-full bg-sky-500/15 px-2.5 py-0.5 text-xs font-bold text-sky-500">
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full size-2 bg-sky-500"></span>
</span>
<span className="size-2 rounded-full bg-sky-500" />
هم‌اکنون در حال برگزاری هم‌اکنون در حال برگزاری
</span> </span>
) : isImminent ? ( ) : isImminent ? (
@ -578,7 +605,16 @@ export default function OngoingToursPage() {
{/* دکمه‌ها و خلاصه پیشرفت مرحله */} {/* دکمه‌ها و خلاصه پیشرفت مرحله */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 lg:self-center shrink-0"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 lg:self-center shrink-0">
{/* نوار کوچک پیشرفت مرحله */} {/* نوار کوچک پیشرفت مرحله */}
{itineraries.length > 0 && (
{isLoadingItinerary ? (
<div className="rounded-xl border border-border-soft bg-surface-base p-2.5 min-w-[200px] space-y-2">
<div className="flex items-center justify-between">
<ShimmerLine width={90} height={12} rounded="md" />
<ShimmerLine width={32} height={12} rounded="md" />
</div>
<ShimmerLine width="100%" height={6} rounded="full" />
<ShimmerLine width={110} height={10} rounded="md" />
</div>
) : itineraries.length > 0 ? (
<div className="rounded-xl border border-border-soft bg-surface-base p-2.5 min-w-[200px] space-y-1.5"> <div className="rounded-xl border border-border-soft bg-surface-base p-2.5 min-w-[200px] space-y-1.5">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-grey-3 text-[11px]">وضعیت اجرای برنامه:</span> <span className="text-grey-3 text-[11px]">وضعیت اجرای برنامه:</span>
@ -601,52 +637,94 @@ export default function OngoingToursPage() {
)} )}
</div> </div>
</div> </div>
)}
) : null}
{/* دکمه باز/بسته کردن تایم‌لاین Itinerary */} {/* دکمه باز/بسته کردن تایم‌لاین Itinerary */}
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => toggleExpand(tour.id)} onClick={() => toggleExpand(tour.id)}
className="rounded-xl text-xs font-bold gap-1.5 h-10 px-4"
disabled={isLoadingItinerary || itineraries.length === 0}
className="rounded-xl text-xs font-bold gap-1.5 h-10 px-4 disabled:opacity-50"
> >
<Ic name="list" className="size-4 text-primary" /> <Ic name="list" className="size-4 text-primary" />
<span>{isExpanded ? 'بستن برنامه سفر' : 'مشاهده برنامه سفر (Itinerary)'}</span>
<span>
{isLoadingItinerary
? 'در حال دریافت برنامه...'
: isExpanded
? 'بستن برنامه سفر'
: itineraries.length > 0
? `مشاهده کل برنامه (${toFa(itineraries.length)} گام)`
: 'مشاهده برنامه سفر (Itinerary)'}
</span>
{!isLoadingItinerary && (
<Ic <Ic
name={isExpanded ? 'chevUp' : 'chevDown'} name={isExpanded ? 'chevUp' : 'chevDown'}
className="size-3.5 text-grey-3 transition-transform" className="size-3.5 text-grey-3 transition-transform"
/> />
)}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
{/* بخش ۲: بدنه Itineraryها و تایم‌لاین زنده مراحل سفر */} {/* بخش ۲: بدنه Itineraryها و تایم‌لاین زنده مراحل سفر */}
{isExpanded && (
<div className="p-5 sm:p-6 space-y-6"> <div className="p-5 sm:p-6 space-y-6">
{/* اطلاعات مرحله جاری (Current Active Step Callout) */}
{itineraries.length > 0 && currentStepIndex >= 0 && (
<div className="relative overflow-hidden rounded-2xl border border-primary/30 bg-gradient-to-r from-primary/10 via-amber-500/5 to-transparent p-4 sm:p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
{/* اطلاعات مرحله جاری (Current Active Step Callout) - لودینگ شیمر استاندارد یا کارت فعال */}
{isLoadingItinerary ? (
<div className="relative overflow-hidden rounded-2xl border border-border-soft bg-card/40 p-4 sm:p-5 shadow-2xs">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="space-y-2.5 flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="relative flex size-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full size-2.5 bg-primary"></span>
</span>
<ShimmerCircle size={10} />
<ShimmerLine width={180} height={14} rounded="md" />
<ShimmerBox width={64} height={18} rounded="md" />
</div>
<ShimmerLine width="55%" height={20} rounded="lg" />
<ShimmerLine width="80%" height={14} rounded="md" />
</div>
<div className="flex flex-col sm:items-end gap-3 shrink-0">
<div className="space-y-1 sm:text-end">
<ShimmerLine width={120} height={14} rounded="md" />
<ShimmerLine width={90} height={12} rounded="md" />
</div>
<div className="flex items-center gap-1.5 p-1 rounded-xl border border-border-soft bg-card/50">
<ShimmerBox width={74} height={32} rounded="lg" />
<div className="h-4 w-px bg-border-soft" />
<ShimmerBox width={74} height={32} rounded="lg" />
</div>
</div>
</div>
</div>
) : itineraries.length > 0 && currentStepIndex >= 0 ? (
<div className="relative overflow-hidden rounded-2xl border border-primary/30 bg-gradient-to-r from-primary/10 via-amber-500/5 to-transparent p-4 sm:p-5 shadow-2xs">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="size-2.5 rounded-full bg-primary animate-pulse" />
<span className="text-xs font-black text-primary"> <span className="text-xs font-black text-primary">
مرحله جاری تور (هم‌اکنون در حال برگزاری): مرحله جاری تور (هم‌اکنون در حال برگزاری):
</span> </span>
<Badge variant="outline" className="text-[10px] font-mono border-primary/30 text-primary font-bold">
گام {toFa(currentStepIndex + 1)} از {toFa(itineraries.length)}
</Badge>
</div> </div>
<h3 className="text-sm sm:text-base font-black text-foreground"> <h3 className="text-sm sm:text-base font-black text-foreground">
{toFa(currentStepIndex + 1)}. {itineraries[currentStepIndex]?.title} {toFa(currentStepIndex + 1)}. {itineraries[currentStepIndex]?.title}
</h3> </h3>
{itineraries[currentStepIndex]?.summary && (
<p className="text-xs text-grey-2 leading-relaxed max-w-3xl"> <p className="text-xs text-grey-2 leading-relaxed max-w-3xl">
{itineraries[currentStepIndex]?.summary} {itineraries[currentStepIndex]?.summary}
</p> </p>
)}
</div> </div>
<div className="text-start sm:text-end shrink-0 text-xs font-mono text-grey-3">
<div className="flex flex-col sm:items-end gap-3 shrink-0">
{/* تاریخ و زمان مرحله */}
<div className="text-start sm:text-end text-xs font-mono text-grey-3">
<div className="font-bold text-foreground"> <div className="font-bold text-foreground">
{formatDateTime(itineraries[currentStepIndex]?.started_at)} {formatDateTime(itineraries[currentStepIndex]?.started_at)}
</div> </div>
@ -656,10 +734,51 @@ export default function OngoingToursPage() {
</div> </div>
)} )}
</div> </div>
{/* دکمه‌های کنترل تغییر مرحله (عقب / جلو) */}
<div className="flex items-center gap-1.5 bg-card/80 p-1 rounded-xl border border-border-soft shadow-xs">
<Button
variant="ghost"
size="sm"
disabled={currentStepIndex <= 0 || updatingStepTourId === tour.id}
onClick={() => handleStepChange(tour.id, currentStepIndex - 1)}
className="h-8 px-2.5 text-xs font-bold gap-1 rounded-lg hover:bg-surface-2 disabled:opacity-40"
title="انتقال به مرحله قبل"
>
<Ic name="chevronRight" className="size-3.5 rtl:rotate-0 ltr:rotate-180 text-primary" />
<span>مرحله قبل</span>
</Button>
<div className="h-4 w-px bg-border-soft" />
<Button
variant="ghost"
size="sm"
disabled={currentStepIndex >= itineraries.length - 1 || updatingStepTourId === tour.id}
onClick={() => handleStepChange(tour.id, currentStepIndex + 1)}
className="h-8 px-2.5 text-xs font-bold gap-1 rounded-lg hover:bg-surface-2 text-primary disabled:opacity-40"
title="انتقال به مرحله بعد"
>
<span>مرحله بعد</span>
{updatingStepTourId === tour.id ? (
<Ic name="refresh" className="size-3.5 animate-spin" />
) : (
<Ic name="chevronLeft" className="size-3.5 rtl:rotate-0 ltr:rotate-180" />
)}
</Button>
</div> </div>
</div> </div>
)}
</div>
</div>
) : !isExpanded ? (
<div className="rounded-xl border border-dashed border-border-soft p-4 text-center text-xs text-grey-3">
{itineraryError || 'برنامه زمانی برای این تور یافت نشد.'}
</div>
) : null}
{/* اگر منوی تایم‌لاین باز باشد، لیست کامل گام‌ها نمایش داده می‌شود */}
{isExpanded && (
<>
{/* وضعیت لودینگ Itinerary */} {/* وضعیت لودینگ Itinerary */}
{isLoadingItinerary ? ( {isLoadingItinerary ? (
<Shimmer rows={3} withCard={false} /> <Shimmer rows={3} withCard={false} />
@ -674,7 +793,7 @@ export default function OngoingToursPage() {
</div> </div>
) : ( ) : (
/* تایم‌لاین عمودی مراحل سفر (Vertical Stepper Timeline) */ /* تایم‌لاین عمودی مراحل سفر (Vertical Stepper Timeline) */
<div className="relative space-y-6 before:absolute before:right-4 before:top-3 before:bottom-3 before:w-0.5 before:bg-border-soft">
<div className="relative space-y-6 before:absolute before:right-4 before:top-3 before:bottom-3 before:w-0.5 before:bg-border-soft pt-2">
{itineraries.map((step, idx) => { {itineraries.map((step, idx) => {
const isCurrent = idx === currentStepIndex const isCurrent = idx === currentStepIndex
const isPast = currentStepIndex >= 0 ? idx < currentStepIndex : false const isPast = currentStepIndex >= 0 ? idx < currentStepIndex : false
@ -731,10 +850,7 @@ export default function OngoingToursPage() {
{/* برچسب وضعیت مرحله */} {/* برچسب وضعیت مرحله */}
{isInProgress ? ( {isInProgress ? (
<Badge className="bg-primary text-white font-bold text-[11px] gap-1 shadow-2xs"> <Badge className="bg-primary text-white font-bold text-[11px] gap-1 shadow-2xs">
<span className="relative flex size-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex rounded-full size-1.5 bg-white"></span>
</span>
<span className="size-1.5 rounded-full bg-white" />
مرحله جاری (In Progress) مرحله جاری (In Progress)
</Badge> </Badge>
) : isCompleted ? ( ) : isCompleted ? (
@ -759,6 +875,22 @@ export default function OngoingToursPage() {
</h4> </h4>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
{/* دکمه انتقال مستقیم به این گام */}
{!isInProgress && (
<Button
variant="outline"
size="sm"
disabled={updatingStepTourId === tour.id}
onClick={() => handleStepChange(tour.id, idx)}
className="h-7 text-[11px] font-bold gap-1 rounded-lg border-border-soft hover:bg-primary/10 hover:text-primary"
title="فعال‌سازی این مرحله به عنوان وضعیت کنونی تور"
>
<Ic name="arrowUpDown" className="size-3" />
<span>تنظیم به عنوان مرحله جاری</span>
</Button>
)}
{/* تاریخ و زمان مرحله */} {/* تاریخ و زمان مرحله */}
<div className="text-start sm:text-end text-xs font-mono text-grey-3 bg-surface-base px-2.5 py-1.5 rounded-xl border border-border-soft shrink-0"> <div className="text-start sm:text-end text-xs font-mono text-grey-3 bg-surface-base px-2.5 py-1.5 rounded-xl border border-border-soft shrink-0">
<div className="flex items-center gap-1 font-bold text-foreground"> <div className="flex items-center gap-1 font-bold text-foreground">
@ -772,6 +904,7 @@ export default function OngoingToursPage() {
)} )}
</div> </div>
</div> </div>
</div>
{/* خلاصه و توضیحات مرحله */} {/* خلاصه و توضیحات مرحله */}
{step.summary && ( {step.summary && (
@ -813,9 +946,10 @@ export default function OngoingToursPage() {
})} })}
</div> </div>
)} )}
</div>
</>
)} )}
</div> </div>
</div>
) )
})} })}
</div> </div>

46
src/features/tours/TourOrdersPage.tsx

@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Avatar } from '@/components/ui/avatar'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -15,7 +16,8 @@ import { Ic } from '@/icons'
import { fetchTourOrders, updateTourOrder } from './services/tour-api' import { fetchTourOrders, updateTourOrder } from './services/tour-api'
import type { TourOrderItem, TourOrderStatus } from './types' import type { TourOrderItem, TourOrderStatus } from './types'
import { TourStatusBadge } from './components/TourStatusBadge' import { TourStatusBadge } from './components/TourStatusBadge'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer, ShimmerBox } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateByLanguage } from '@/lib/date-utils' import { formatDateByLanguage } from '@/lib/date-utils'
@ -147,9 +149,13 @@ export default function TourOrdersPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-xs font-bold text-grey-3">کل سفارشات ثبت‌شده</p> <p className="text-xs font-bold text-grey-3">کل سفارشات ثبت‌شده</p>
{isLoading ? (
<ShimmerBox width={50} height={26} rounded="lg" className="mt-1" />
) : (
<h3 className="text-2xl font-black text-foreground mt-1"> <h3 className="text-2xl font-black text-foreground mt-1">
{isLoading ? '...' : totalCount}
{toFa(totalCount)}
</h3> </h3>
)}
</div> </div>
<div className="size-11 rounded-2xl bg-primary/10 text-primary flex items-center justify-center"> <div className="size-11 rounded-2xl bg-primary/10 text-primary flex items-center justify-center">
<Ic name="clipboardCheck" className="size-5" /> <Ic name="clipboardCheck" className="size-5" />
@ -161,9 +167,13 @@ export default function TourOrdersPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-xs font-bold text-grey-3">رزروهای تأییدشده</p> <p className="text-xs font-bold text-grey-3">رزروهای تأییدشده</p>
{isLoading ? (
<ShimmerBox width={50} height={26} rounded="lg" className="mt-1" />
) : (
<h3 className="text-2xl font-black text-emerald-500 mt-1"> <h3 className="text-2xl font-black text-emerald-500 mt-1">
{isLoading ? '...' : stats.approved}
{toFa(stats.approved)}
</h3> </h3>
)}
</div> </div>
<div className="size-11 rounded-2xl bg-emerald-500/10 text-emerald-500 flex items-center justify-center"> <div className="size-11 rounded-2xl bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
<Ic name="check" className="size-5" /> <Ic name="check" className="size-5" />
@ -175,9 +185,13 @@ export default function TourOrdersPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-xs font-bold text-grey-3">در انتظار بررسی و پرداخت</p> <p className="text-xs font-bold text-grey-3">در انتظار بررسی و پرداخت</p>
{isLoading ? (
<ShimmerBox width={50} height={26} rounded="lg" className="mt-1" />
) : (
<h3 className="text-2xl font-black text-amber-500 mt-1"> <h3 className="text-2xl font-black text-amber-500 mt-1">
{isLoading ? '...' : stats.pending}
{toFa(stats.pending)}
</h3> </h3>
)}
</div> </div>
<div className="size-11 rounded-2xl bg-amber-500/10 text-amber-500 flex items-center justify-center"> <div className="size-11 rounded-2xl bg-amber-500/10 text-amber-500 flex items-center justify-center">
<Ic name="clock" className="size-5" /> <Ic name="clock" className="size-5" />
@ -189,9 +203,13 @@ export default function TourOrdersPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-xs font-bold text-grey-3">مجموع ارزش مالی</p> <p className="text-xs font-bold text-grey-3">مجموع ارزش مالی</p>
<h3 className="text-xl font-black text-foreground mt-1">
{isLoading ? '...' : `$${stats.totalAmount.toLocaleString()}`}
{isLoading ? (
<ShimmerBox width={80} height={26} rounded="lg" className="mt-1" />
) : (
<h3 className="text-xl font-black text-foreground mt-1 font-mono">
${stats.totalAmount.toLocaleString()}
</h3> </h3>
)}
</div> </div>
<div className="size-11 rounded-2xl bg-blue-500/10 text-blue-500 flex items-center justify-center"> <div className="size-11 rounded-2xl bg-blue-500/10 text-blue-500 flex items-center justify-center">
<Ic name="chart" className="size-5" /> <Ic name="chart" className="size-5" />
@ -289,7 +307,10 @@ export default function TourOrdersPage() {
{/* User */} {/* User */}
<td className="p-4 font-bold text-grey-1"> <td className="p-4 font-bold text-grey-1">
{order.user || 'نامشخص'}
<div className="flex items-center gap-2">
<Avatar className="size-7 shrink-0" iconClassName="size-3.5" />
<span className="truncate max-w-[150px]">{order.user || 'نامشخص'}</span>
</div>
</td> </td>
{/* Tour Title */} {/* Tour Title */}
@ -358,16 +379,19 @@ export default function TourOrdersPage() {
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setDetailOrder(order)} onClick={() => setDetailOrder(order)}
className="size-8 p-0 rounded-xl hover:bg-surface-3 text-grey-2"
className="size-8 p-0 rounded-xl hover:bg-surface-3 text-grey-2 shrink-0"
title="مشاهده جزئیات کامل" title="مشاهده جزئیات کامل"
> >
<Ic name="eye" className="size-4" /> <Ic name="eye" className="size-4" />
</Button> </Button>
<div className="min-w-[58px] h-8 flex items-center justify-center shrink-0">
{order.status !== 'APPROVED' && ( {order.status !== 'APPROVED' && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
isLoading={updatingId === order.id}
loadingText="در حال ثبت..."
disabled={isUpdating} disabled={isUpdating}
onClick={() => handleUpdateStatus(order.id, 'APPROVED')} onClick={() => handleUpdateStatus(order.id, 'APPROVED')}
className="h-8 px-2.5 rounded-xl border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10 text-xs font-bold gap-1" className="h-8 px-2.5 rounded-xl border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10 text-xs font-bold gap-1"
@ -376,7 +400,9 @@ export default function TourOrdersPage() {
<span>تایید</span> <span>تایید</span>
</Button> </Button>
)} )}
</div>
<div className="size-8 flex items-center justify-center shrink-0">
{order.status !== 'REJECTED' && ( {order.status !== 'REJECTED' && (
<Button <Button
size="sm" size="sm"
@ -390,6 +416,7 @@ export default function TourOrdersPage() {
</Button> </Button>
)} )}
</div> </div>
</div>
</td> </td>
</tr> </tr>
) )
@ -484,8 +511,11 @@ export default function TourOrdersPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="rounded-xl bg-surface-2 p-3 space-y-1"> <div className="rounded-xl bg-surface-2 p-3 space-y-1">
<p className="text-grey-3 font-bold">نام کاربر ثبت‌کننده</p> <p className="text-grey-3 font-bold">نام کاربر ثبت‌کننده</p>
<div className="flex items-center gap-2 pt-0.5">
<Avatar className="size-6 shrink-0" iconClassName="size-3" />
<p className="font-extrabold text-foreground text-sm">{detailOrder.user}</p> <p className="font-extrabold text-foreground text-sm">{detailOrder.user}</p>
</div> </div>
</div>
<div className="rounded-xl bg-surface-2 p-3 space-y-1"> <div className="rounded-xl bg-surface-2 p-3 space-y-1">
<p className="text-grey-3 font-bold">عنوان تور</p> <p className="text-grey-3 font-bold">عنوان تور</p>
<p className="font-extrabold text-foreground text-sm truncate"> <p className="font-extrabold text-foreground text-sm truncate">

177
src/features/tours/ToursPage.tsx

@ -3,6 +3,7 @@ import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
Table, Table,
@ -12,16 +13,16 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { fetchTours } from './services/tour-api'
import { fetchTours, toggleTourVisibility } from './services/tour-api'
import type { TourListItem } from './types' import type { TourListItem } from './types'
import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
import { TourStatusBadge } from './components/TourStatusBadge'
import { TourDetailDialog } from './components/TourDetailDialog' import { TourDetailDialog } from './components/TourDetailDialog'
import { CreateTourDialog } from './components/CreateTourDialog' import { CreateTourDialog } from './components/CreateTourDialog'
import TablePagination from '@/components/shared/TablePagination' import TablePagination from '@/components/shared/TablePagination'
import FilterSelect from '@/components/shared/FilterSelect' import FilterSelect from '@/components/shared/FilterSelect'
import SortDropdown from '@/components/shared/SortDropdown' import SortDropdown from '@/components/shared/SortDropdown'
import FilterBar from '@/components/shared/FilterBar' import FilterBar from '@/components/shared/FilterBar'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateRangeByLanguage } from '@/lib/date-utils' import { formatDateRangeByLanguage } from '@/lib/date-utils'
@ -37,8 +38,7 @@ export default function ToursPage() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('ALL') const [statusFilter, setStatusFilter] = useState('ALL')
const [tripStatusFilter, setTripStatusFilter] = useState('ALL') const [tripStatusFilter, setTripStatusFilter] = useState('ALL')
const [countryFilter, setCountryFilter] = useState('ALL')
const [sortBy, setSortBy] = useState<'id' | 'price_asc' | 'price_desc' | 'started_at'>('id')
const [sortBy, setSortBy] = useState<'default' | 'started_at' | 'id' | 'price_asc' | 'price_desc'>('default')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid') const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
// صفحه‌بندی (Pagination) // صفحه‌بندی (Pagination)
@ -49,6 +49,22 @@ export default function ToursPage() {
const [selectedTourId, setSelectedTourId] = useState<number | null>(null) const [selectedTourId, setSelectedTourId] = useState<number | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false) const [isDetailOpen, setIsDetailOpen] = useState(false)
const [isCreateOpen, setIsCreateOpen] = useState(false) const [isCreateOpen, setIsCreateOpen] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const handleToggleTourVisibility = async (tourId: number, currentVisibility?: boolean) => {
const newVisibility = currentVisibility === false ? true : false
setTogglingId(tourId)
try {
await toggleTourVisibility(tourId, newVisibility)
setTours((prev) =>
prev.map((t) => (t.id === tourId ? { ...t, visibility: newVisibility } : t))
)
} catch (err: any) {
alert(err?.message || 'خطا در تغییر وضعیت فعال/غیرفعال بودن تور')
} finally {
setTogglingId(null)
}
}
const loadData = async (showRefresh = false) => { const loadData = async (showRefresh = false) => {
if (showRefresh) setIsRefreshing(true) if (showRefresh) setIsRefreshing(true)
@ -83,31 +99,89 @@ export default function ToursPage() {
const matchStatus = statusFilter === 'ALL' || tour.status === statusFilter const matchStatus = statusFilter === 'ALL' || tour.status === statusFilter
const matchTripStatus = tripStatusFilter === 'ALL' || tour.trip_status === tripStatusFilter const matchTripStatus = tripStatusFilter === 'ALL' || tour.trip_status === tripStatusFilter
const matchCountry = countryFilter === 'ALL' || tour.destination_country === countryFilter
return matchSearch && matchStatus && matchTripStatus && matchCountry
return matchSearch && matchStatus && matchTripStatus
}) })
const now = new Date()
const nowMs = now.getTime()
const todayStartMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
return [...list].sort((a, b) => { return [...list].sort((a, b) => {
if (sortBy === 'price_asc') return parseFloat(a.price) - parseFloat(b.price)
if (sortBy === 'price_desc') return parseFloat(b.price) - parseFloat(a.price)
if (sortBy === 'started_at') return new Date(a.started_at).getTime() - new Date(b.started_at).getTime()
if (sortBy === 'price_asc') return (parseFloat(a.price) || 0) - (parseFloat(b.price) || 0)
if (sortBy === 'price_desc') return (parseFloat(b.price) || 0) - (parseFloat(a.price) || 0)
if (sortBy === 'started_at') {
const timeA = a.started_at ? new Date(a.started_at).getTime() : Infinity
const timeB = b.started_at ? new Date(b.started_at).getTime() : Infinity
return timeA - timeB
}
if (sortBy === 'id') return b.id - a.id
// مرتب‌سازی پیش‌فرض: نزدیک‌ترین تور فعال یا در حال برگزاری
// رتبه‌بندی اولویت:
// ۱. در حال برگزاری (TRAVELING یا تطابق بازه تاریخی با زمان حال)
// ۲. فعال و در دسترس (AVAILABLE و منقضی‌نشده، صعودی بر اساس نزدیک‌ترین تاریخ شروع)
// ۳. سایر تورهای پیش‌رو (تکمیل ظرفیت SOLD_OUT و...)
// ۴. تورهای پایان‌یافته (FINISHED یا تاریخ گذشته)
const getTourBucket = (t: TourListItem) => {
const startMs = t.started_at ? new Date(t.started_at).setHours(0, 0, 0, 0) : NaN
const endMs = t.ended_at ? new Date(t.ended_at).setHours(23, 59, 59, 999) : startMs
const isTraveling =
t.trip_status === 'TRAVELING' ||
(!isNaN(startMs) && !isNaN(endMs) && nowMs >= startMs && nowMs <= endMs)
if (isTraveling) {
return { bucket: 1, date: !isNaN(startMs) ? startMs : 0 }
}
const isPast =
t.trip_status === 'FINISHED' ||
(!isNaN(endMs) && endMs < todayStartMs)
if (!isPast) {
if (t.status === 'AVAILABLE') {
return { bucket: 2, date: !isNaN(startMs) ? startMs : Infinity }
}
return { bucket: 3, date: !isNaN(startMs) ? startMs : Infinity }
}
return { bucket: 4, date: !isNaN(startMs) ? startMs : 0 }
}
const rankA = getTourBucket(a)
const rankB = getTourBucket(b)
if (rankA.bucket !== rankB.bucket) {
return rankA.bucket - rankB.bucket
}
// در رتبه‌های ۱، ۲ و ۳، نزدیک‌ترین تاریخ حرکت در اولویت است
if (rankA.bucket <= 3) {
if (rankA.date !== rankB.date) {
return rankA.date - rankB.date
}
} else {
// در تورهای پایان‌یافته (رتبه ۴)، جدیدترین تاریخ به گذشته در ابتدا قرار می‌گیرد
if (rankA.date !== rankB.date) {
return rankB.date - rankA.date
}
}
return b.id - a.id return b.id - a.id
}) })
}, [tours, searchTerm, statusFilter, tripStatusFilter, countryFilter, sortBy])
}, [tours, searchTerm, statusFilter, tripStatusFilter, sortBy])
// شمارش فیلترهای فعال // شمارش فیلترهای فعال
const activeFiltersCount = [ const activeFiltersCount = [
statusFilter !== 'ALL', statusFilter !== 'ALL',
tripStatusFilter !== 'ALL', tripStatusFilter !== 'ALL',
countryFilter !== 'ALL',
Boolean(searchTerm.trim()), Boolean(searchTerm.trim()),
].filter(Boolean).length ].filter(Boolean).length
// بازنشانی صفحه به ۱ در صورت تغییر هرکدام از فیلترها، جستجو یا مرتب‌سازی // بازنشانی صفحه به ۱ در صورت تغییر هرکدام از فیلترها، جستجو یا مرتب‌سازی
useEffect(() => { useEffect(() => {
setCurrentPage(1) setCurrentPage(1)
}, [searchTerm, statusFilter, tripStatusFilter, countryFilter, sortBy])
}, [searchTerm, statusFilter, tripStatusFilter, sortBy])
// محاسبات صفحه‌بندی فهرست تورها // محاسبات صفحه‌بندی فهرست تورها
const totalPages = Math.ceil(filteredTours.length / pageSize) || 1 const totalPages = Math.ceil(filteredTours.length / pageSize) || 1
@ -121,12 +195,13 @@ export default function ToursPage() {
// شاخص‌های کلیدی آماری (KPI Metrics) // شاخص‌های کلیدی آماری (KPI Metrics)
const stats = useMemo(() => { const stats = useMemo(() => {
const total = tours.length const total = tours.length
const available = tours.filter((t) => t.status === 'AVAILABLE').length
const active = tours.filter((t) => t.visibility !== false).length
const inactive = tours.filter((t) => t.visibility === false).length
const soldOut = tours.filter((t) => t.status === 'SOLD_OUT').length const soldOut = tours.filter((t) => t.status === 'SOLD_OUT').length
const traveling = tours.filter((t) => t.trip_status === 'TRAVELING').length const traveling = tours.filter((t) => t.trip_status === 'TRAVELING').length
const pending = tours.filter((t) => t.trip_status === 'PENDING').length const pending = tours.filter((t) => t.trip_status === 'PENDING').length
return { total, available, soldOut, traveling, pending }
return { total, active, inactive, soldOut, traveling, pending }
}, [tours]) }, [tours])
const handleOpenDetail = (id: number) => { const handleOpenDetail = (id: number) => {
@ -230,12 +305,12 @@ export default function ToursPage() {
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-muted-foreground font-medium">در دسترس</p>
<p className="text-[11px] text-muted-foreground font-medium">تورهای فعال (اپ و وب)</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400"> <p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{toFa(stats.available)}
{toFa(stats.active)}
</p> </p>
)} )}
</div> </div>
@ -309,17 +384,18 @@ export default function ToursPage() {
setSearchTerm('') setSearchTerm('')
setStatusFilter('ALL') setStatusFilter('ALL')
setTripStatusFilter('ALL') setTripStatusFilter('ALL')
setCountryFilter('ALL')
setSortBy('default')
}} }}
endContent={ endContent={
<SortDropdown <SortDropdown
value={sortBy} value={sortBy}
onChange={(val) => setSortBy(val as any)} onChange={(val) => setSortBy(val as any)}
options={[ options={[
{ value: 'default', label: 'پیش‌فرض (نزدیک‌ترین فعال / در حال برگزاری)' },
{ value: 'started_at', label: 'نزدیک‌ترین تاریخ حرکت' },
{ value: 'id', label: 'جدیدترین (شناسه)' }, { value: 'id', label: 'جدیدترین (شناسه)' },
{ value: 'price_asc', label: 'ارزان‌ترین تور' }, { value: 'price_asc', label: 'ارزان‌ترین تور' },
{ value: 'price_desc', label: 'گران‌ترین تور' }, { value: 'price_desc', label: 'گران‌ترین تور' },
{ value: 'started_at', label: 'نزدیک‌ترین تاریخ حرکت' },
]} ]}
size="sm" size="sm"
/> />
@ -354,22 +430,6 @@ export default function ToursPage() {
clearable clearable
size="sm" size="sm"
/> />
{/* کشور مقصد */}
<FilterSelect
value={countryFilter}
onChange={setCountryFilter}
options={[
{ value: 'ALL', label: 'همه مقاصد' },
{ value: 'IQ', label: '🇮🇶 عراق' },
{ value: 'IR', label: '🇮🇷 ایران' },
{ value: 'SA', label: '🇸🇦 عربستان' },
{ value: 'SY', label: '🇸🇾 سوریه' },
]}
allValue="ALL"
clearable
size="sm"
/>
</FilterBar> </FilterBar>
{/* وضعیت لودینگ استاندارد Shimmer */} {/* وضعیت لودینگ استاندارد Shimmer */}
@ -422,7 +482,7 @@ export default function ToursPage() {
className="group flex flex-col justify-between overflow-hidden rounded-2xl border border-border-soft bg-card/40 transition hover:border-primary/40 hover:bg-card/70 hover:shadow-md cursor-pointer" className="group flex flex-col justify-between overflow-hidden rounded-2xl border border-border-soft bg-card/40 transition hover:border-primary/40 hover:bg-card/70 hover:shadow-md cursor-pointer"
> >
<div> <div>
{/* تصویر کاور تور با نشان کشور و وضعیت */}
{/* تصویر کاور تور و وضعیت */}
<div className="relative aspect-16/10 w-full overflow-hidden bg-surface-base"> <div className="relative aspect-16/10 w-full overflow-hidden bg-surface-base">
<img <img
src={imgUrl} src={imgUrl}
@ -433,16 +493,23 @@ export default function ToursPage() {
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="400" height="250" viewBox="0 0 400 250" fill="%2318181b"><rect width="400" height="250"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="14" font-family="sans-serif">Aqila Tour</text></svg>' 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="400" height="250" viewBox="0 0 400 250" fill="%2318181b"><rect width="400" height="250"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="14" font-family="sans-serif">Aqila Tour</text></svg>'
}} }}
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/20" />
{/* بج کشور مقصد */}
<div className="absolute top-3 right-3">
<CountryFlagBadge countryCode={tour.destination_country} />
{/* وضعیت تور و تاگل تعاملی نمایش */}
<div
className="absolute bottom-3 right-3 flex items-center gap-1.5 z-10"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-1.5 rounded-lg bg-black/70 backdrop-blur-md px-2 py-0.5 border border-white/10 shadow-xs">
<Switch
checked={tour.visibility !== false}
isLoading={togglingId === tour.id}
onCheckedChange={() => handleToggleTourVisibility(tour.id, tour.visibility !== false)}
className="scale-75 origin-right"
title="تغییر وضعیت فعال/غیرفعال بودن تور در اپ و وب"
/>
<span className={`text-[10px] font-bold ${tour.visibility !== false ? 'text-emerald-400' : 'text-rose-400'}`}>
{tour.visibility !== false ? 'فعال' : 'مخفی'}
</span>
</div> </div>
{/* وضعیت تجاری تور */}
<div className="absolute bottom-3 right-3 flex items-center gap-1.5">
<TourStatusBadge status={tour.status} type="sale" />
<TourStatusBadge status={tour.trip_status} type="trip" /> <TourStatusBadge status={tour.trip_status} type="trip" />
</div> </div>
</div> </div>
@ -512,10 +579,9 @@ export default function ToursPage() {
<TableRow> <TableRow>
<TableHead className="text-right text-xs font-bold">شناسه</TableHead> <TableHead className="text-right text-xs font-bold">شناسه</TableHead>
<TableHead className="text-right text-xs font-bold">عنوان تور</TableHead> <TableHead className="text-right text-xs font-bold">عنوان تور</TableHead>
<TableHead className="text-right text-xs font-bold">مقصد</TableHead>
<TableHead className="text-right text-xs font-bold">تاریخ برگزاری</TableHead> <TableHead className="text-right text-xs font-bold">تاریخ برگزاری</TableHead>
<TableHead className="text-right text-xs font-bold">نرخ‌ها (بزرگسال / کودک / نوزاد)</TableHead> <TableHead className="text-right text-xs font-bold">نرخ‌ها (بزرگسال / کودک / نوزاد)</TableHead>
<TableHead className="text-right text-xs font-bold">وضعیت فروش</TableHead>
<TableHead className="text-right text-xs font-bold">وضعیت نمایش</TableHead>
<TableHead className="text-right text-xs font-bold">وضعیت سفر</TableHead> <TableHead className="text-right text-xs font-bold">وضعیت سفر</TableHead>
<TableHead className="text-center text-xs font-bold">عملیات</TableHead> <TableHead className="text-center text-xs font-bold">عملیات</TableHead>
</TableRow> </TableRow>
@ -532,9 +598,6 @@ export default function ToursPage() {
<div className="font-bold text-xs text-foreground line-clamp-1">{tour.title}</div> <div className="font-bold text-xs text-foreground line-clamp-1">{tour.title}</div>
<div className="text-[10px] text-grey-3 truncate font-mono">{tour.slug}</div> <div className="text-[10px] text-grey-3 truncate font-mono">{tour.slug}</div>
</TableCell> </TableCell>
<TableCell>
<CountryFlagBadge countryCode={tour.destination_country} />
</TableCell>
<TableCell className="text-xs text-grey-2"> <TableCell className="text-xs text-grey-2">
{formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)} {formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
</TableCell> </TableCell>
@ -544,8 +607,18 @@ export default function ToursPage() {
کودک: ${parseFloat(tour.price_child || '0').toLocaleString()} | نوزاد: ${parseFloat(tour.price_infant || '0').toLocaleString()} کودک: ${parseFloat(tour.price_child || '0').toLocaleString()} | نوزاد: ${parseFloat(tour.price_infant || '0').toLocaleString()}
</div> </div>
</TableCell> </TableCell>
<TableCell>
<TourStatusBadge status={tour.status} type="sale" />
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<Switch
checked={tour.visibility !== false}
isLoading={togglingId === tour.id}
onCheckedChange={() => handleToggleTourVisibility(tour.id, tour.visibility !== false)}
title="تغییر وضعیت فعال/غیرفعال بودن تور در اپ و وب"
/>
<span className={`text-[11px] font-bold ${tour.visibility !== false ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-500'}`}>
{tour.visibility !== false ? 'روشن (فعال)' : 'خاموش (مخفی)'}
</span>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<TourStatusBadge status={tour.trip_status} type="trip" /> <TourStatusBadge status={tour.trip_status} type="trip" />

236
src/features/tours/components/CreateTourDialog.tsx

@ -8,10 +8,10 @@ import {
DialogFooter, DialogFooter,
DialogClose, DialogClose,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Button, ButtonSpinner } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { createTour, type CreateTourPayload } from '../services/tour-api'
import { createTour, uploadTourImage, type CreateTourPayload } from '../services/tour-api'
import type { TourTranslationData } from '../types' import type { TourTranslationData } from '../types'
interface CreateTourDialogProps { interface CreateTourDialogProps {
@ -21,21 +21,13 @@ interface CreateTourDialogProps {
} }
const SUPPORTED_LANGUAGES = [ const SUPPORTED_LANGUAGES = [
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
{ code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' }, { code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' },
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
{ code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' }, { code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' },
{ code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' }, { code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' },
{ code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' }, { code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' },
] as const ] as const
const DESTINATION_COUNTRIES = [
{ code: 'IQ', name: 'عراق (Iraq)', flag: '🇮🇶' },
{ code: 'SA', name: 'عربستان (Saudi Arabia)', flag: '🇸🇦' },
{ code: 'IR', name: 'ایران (Iran)', flag: '🇮🇷' },
{ code: 'SY', name: 'سوریه (Syria)', flag: '🇸🇾' },
{ code: 'OM', name: 'عمان (Oman)', flag: '🇴🇲' },
] as const
type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code'] type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code']
export function CreateTourDialog({ export function CreateTourDialog({
@ -55,9 +47,17 @@ export function CreateTourDialog({
const [startedAt, setStartedAt] = React.useState('') const [startedAt, setStartedAt] = React.useState('')
const [endedAt, setEndedAt] = React.useState('') const [endedAt, setEndedAt] = React.useState('')
const [capacity, setCapacity] = React.useState('40') const [capacity, setCapacity] = React.useState('40')
const [destinationCountry, setDestinationCountry] = React.useState('IQ')
const [primaryLang, setPrimaryLang] = React.useState<LangCode>('fa') const [primaryLang, setPrimaryLang] = React.useState<LangCode>('fa')
const [status, setStatus] = React.useState('AVAILABLE') const [status, setStatus] = React.useState('AVAILABLE')
const [visibility, setVisibility] = React.useState<boolean>(true)
// Image states (mandatory cover photo)
const [imageId, setImageId] = React.useState<number | null>(null)
const [imageUrl, setImageUrl] = React.useState<string | null>(null)
const [imageName, setImageName] = React.useState<string | null>(null)
const [isUploadingImage, setIsUploadingImage] = React.useState<boolean>(false)
const [imageUploadError, setImageUploadError] = React.useState<string | null>(null)
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
// Pricing states // Pricing states
const [price, setPrice] = React.useState('') const [price, setPrice] = React.useState('')
@ -87,9 +87,53 @@ export function CreateTourDialog({
setErrorMessage(null) setErrorMessage(null)
setSuccessMessage(null) setSuccessMessage(null)
setActiveTab('basic') setActiveTab('basic')
setImageId(null)
setImageUrl(null)
setImageName(null)
setImageUploadError(null)
} }
}, [open]) }, [open])
const processImageUpload = async (file: File) => {
if (!file.type.startsWith('image/')) {
setImageUploadError('لطفاً فقط فایل تصویری (JPG، PNG، WEBP) انتخاب نمایید.')
return
}
if (file.size > 15 * 1024 * 1024) {
setImageUploadError('حجم تصویر نباید بیشتر از ۱۵ مگابایت باشد.')
return
}
setImageUploadError(null)
setIsUploadingImage(true)
try {
const res = await uploadTourImage(file)
setImageId(res.id)
setImageUrl(res.url || URL.createObjectURL(file))
setImageName(file.name)
} catch (err: any) {
setImageUploadError(err?.message || 'خطا در آپلود تصویر در سرور.')
} finally {
setIsUploadingImage(false)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
await processImageUpload(file)
}
const handleRemoveImage = () => {
setImageId(null)
setImageUrl(null)
setImageName(null)
setImageUploadError(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleAddFeature = () => { const handleAddFeature = () => {
if (!newFeature.trim()) return if (!newFeature.trim()) return
setFeatures((prev) => [...prev, newFeature.trim()]) setFeatures((prev) => [...prev, newFeature.trim()])
@ -199,6 +243,11 @@ export function CreateTourDialog({
setActiveTab('basic') setActiveTab('basic')
return return
} }
if (!imageId) {
setErrorMessage('بارگذاری تصویر شاخص برای ایجاد تور الزامی است.')
setActiveTab('basic')
return
}
if (!startedAt) { if (!startedAt) {
setErrorMessage('لطفاً تاریخ شروع تور را مشخص نمایید.') setErrorMessage('لطفاً تاریخ شروع تور را مشخص نمایید.')
setActiveTab('basic') setActiveTab('basic')
@ -272,13 +321,14 @@ export function CreateTourDialog({
price_child: priceChild ? String(priceChild) : '0', price_child: priceChild ? String(priceChild) : '0',
price_infant: priceInfant ? String(priceInfant) : '0', price_infant: priceInfant ? String(priceInfant) : '0',
percent_off: percentOff ? Number(percentOff) : 0, percent_off: percentOff ? Number(percentOff) : 0,
destination_country_code: destinationCountry,
language_code: primaryLang, language_code: primaryLang,
status, status,
tour_feature, tour_feature,
travel_tips, travel_tips,
visibility: true,
visibility,
translations: cleanedTranslations, translations: cleanedTranslations,
image_id: imageId,
image_ids: [imageId],
} }
const createdTour = await createTour(payload) const createdTour = await createTour(payload)
@ -308,7 +358,7 @@ export function CreateTourDialog({
<DialogContent <DialogContent
onPointerDownOutside={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()}
className="max-w-[840px] p-0 overflow-hidden max-h-[92vh] flex flex-col"
className="max-w-5xl xl:max-w-6xl w-[95vw] p-0 overflow-hidden h-[90vh] min-h-[600px] max-h-[92vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-2xl"
> >
{/* Header */} {/* Header */}
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15"> <DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
@ -322,7 +372,7 @@ export function CreateTourDialog({
افزودن تور زیارتی و گردشگری جدید افزودن تور زیارتی و گردشگری جدید
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5"> <DialogDescription className="text-xs text-grey-3 mt-0.5">
ثبت مشخصات پایه، هزینه‌ها، ظرفیت و ورودی‌های ترجمه ۵ زبانه همزمان
ثبت مشخصات پایه، هزینه‌ها، ظرفیت، تصویر شاخص و ورودی‌های ترجمه ۵ زبانه
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@ -414,24 +464,124 @@ export function CreateTourDialog({
/> />
</div> </div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
کشور مقصد <span className="text-rose-500">*</span>:
{/* بخش آپلود تصویر شاخص تور (الزامی) */}
<div className="sm:col-span-2">
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>تصویر شاخص تور (کاور)</span>
<span className="text-rose-500 font-black">*</span>
<span className="text-[11px] font-normal text-grey-3">(الزامی - JPG، PNG، WEBP)</span>
</label> </label>
<select
value={destinationCountry}
onChange={(e) => setDestinationCountry(e.target.value)}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none"
{imageId && (
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/20">
<Ic name="check" className="size-3" />
تصویر بارگذاری شد (کد: #{imageId})
</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/jpg"
onChange={handleFileChange}
className="hidden"
/>
{imageUrl ? (
<div className="relative overflow-hidden rounded-2xl border border-border-soft bg-surface-2 p-3 flex items-center gap-4">
<div className="relative size-20 sm:size-24 rounded-xl overflow-hidden bg-black/40 border border-border-soft shrink-0">
<img
src={imageUrl}
alt="پیش‌نمایش کاور تور"
className="size-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-bold text-foreground truncate">
{imageName || 'تصویر تور'}
</p>
<span className="text-[10px] font-mono text-grey-3 bg-card px-1.5 py-0.5 rounded border border-border/40 shrink-0">
#{imageId}
</span>
</div>
<p className="text-xs text-grey-3 mt-1">
تصویر با موفقیت در سرور ذخیره شد و به عنوان تصویر شاخص تور نمایش داده می‌شود.
</p>
<div className="mt-2.5 flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingImage}
className="h-7 text-xs border-border/60 hover:bg-surface-3 cursor-pointer"
> >
{DESTINATION_COUNTRIES.map((c) => (
<option key={c.code} value={c.code}>
{c.flag} {c.name}
</option>
))}
</select>
<Ic name="pencil" className="size-3 ml-1" />
تغییر تصویر
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRemoveImage}
disabled={isUploadingImage}
className="h-7 text-xs text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 cursor-pointer"
>
<Ic name="trash" className="size-3 ml-1" />
حذف تصویر
</Button>
</div>
</div>
</div>
) : (
<div
onClick={() => !isUploadingImage && fileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
const file = e.dataTransfer.files?.[0]
if (file) processImageUpload(file)
}}
className={`relative flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-6 text-center transition-all cursor-pointer ${
imageUploadError
? 'border-rose-500/40 bg-rose-500/5'
: 'border-border-soft hover:border-primary/50 bg-surface-2/60 hover:bg-surface-2'
}`}
>
{isUploadingImage ? (
<div className="flex flex-col items-center gap-2 py-2">
<ButtonSpinner size="large" className="text-primary size-8" />
<p className="text-xs font-bold text-foreground">در حال بارگذاری و بهینه‌سازی تصویر در سرور...</p>
</div>
) : (
<>
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary mb-3">
<Ic name="upload" className="size-6" />
</div>
<p className="text-xs font-bold text-foreground">
برای انتخاب تصویر شاخص کلیک کنید یا فایل را اینجا رها کنید (Drag & Drop)
</p>
<p className="text-[11px] text-grey-3 mt-1">
فرمت‌های پشتیبانی‌شده: JPG، PNG، WEBP — حداکثر حجم: ۱۵ مگابایت
</p>
{imageUploadError && (
<p className="text-xs text-rose-400 font-medium mt-2">
{imageUploadError}
</p>
)}
</>
)}
</div>
)}
</div> </div>
<div>
<div className="sm:col-span-2">
<label className="block text-xs font-bold text-foreground mb-1.5"> <label className="block text-xs font-bold text-foreground mb-1.5">
زبان اصلی ثبت داده: زبان اصلی ثبت داده:
</label> </label>
@ -502,6 +652,20 @@ export function CreateTourDialog({
<option value="SOLD_OUT">تکمیل ظرفیت (Sold Out)</option> <option value="SOLD_OUT">تکمیل ظرفیت (Sold Out)</option>
</select> </select>
</div> </div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
نمایش در اپ و وب:
</label>
<select
value={visibility ? 'true' : 'false'}
onChange={(e) => setVisibility(e.target.value === 'true')}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none font-bold"
>
<option value="true">فعال (قابل مشاهده در اپ و وب)</option>
<option value="false">غیرفعال (مخفی در اپ و وب)</option>
</select>
</div>
</div> </div>
{/* Pricing Sub-card */} {/* Pricing Sub-card */}
@ -933,20 +1097,12 @@ export function CreateTourDialog({
<Button <Button
type="submit" type="submit"
size="sm" size="sm"
disabled={isSubmitting}
isLoading={isSubmitting}
loadingText="در حال ثبت تور..."
className="rounded-xl bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold gap-1.5 shadow-sm" className="rounded-xl bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold gap-1.5 shadow-sm"
> >
{isSubmitting ? (
<>
<div className="size-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>در حال ثبت تور...</span>
</>
) : (
<>
<Ic name="check" className="size-3.5" /> <Ic name="check" className="size-3.5" />
<span>ایجاد و ثبت نهایی تور</span> <span>ایجاد و ثبت نهایی تور</span>
</>
)}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>

1740
src/features/tours/components/TourDetailDialog.tsx
File diff suppressed because it is too large
View File

27
src/features/tours/components/TourDetailSheet.tsx

@ -8,8 +8,15 @@ import {
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Avatar } from '@/components/ui/avatar'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import {
TourDetailModalShimmer,
TourItineraryShimmer,
TourPassengersShimmer,
TourCommentsShimmer,
} from './TourDetailShimmer'
import { import {
fetchTourDetail, fetchTourDetail,
fetchTourItineraries, fetchTourItineraries,
@ -22,7 +29,7 @@ import type {
TourPassengerGroupItem, TourPassengerGroupItem,
TourCommentItem, TourCommentItem,
} from '../types' } from '../types'
import { TourStatusBadge, CountryFlagBadge } from './TourStatusBadge'
import { TourStatusBadge } from './TourStatusBadge'
import { TourTranslationsDialog } from './TourTranslationsDialog' import { TourTranslationsDialog } from './TourTranslationsDialog'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { formatDateByLanguage } from '@/lib/date-utils' import { formatDateByLanguage } from '@/lib/date-utils'
@ -171,7 +178,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{isLoading ? ( {isLoading ? (
<Shimmer className="p-0 space-y-3" rows={5} />
<TourDetailModalShimmer activeTab={activeTab} />
) : error ? ( ) : error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-6 text-center text-rose-500"> <div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-6 text-center text-rose-500">
<Ic name="alert" className="mx-auto size-8 mb-2" /> <Ic name="alert" className="mx-auto size-8 mb-2" />
@ -311,7 +318,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
{activeTab === 'itinerary' && ( {activeTab === 'itinerary' && (
<div className="space-y-4"> <div className="space-y-4">
{isLoadingSub ? ( {isLoadingSub ? (
<Shimmer rows={3} withCard={false} />
<TourItineraryShimmer />
) : itineraries.length === 0 ? ( ) : itineraries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> <div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="calendar" className="mx-auto size-8 mb-2 opacity-50" /> <Ic name="calendar" className="mx-auto size-8 mb-2 opacity-50" />
@ -349,7 +356,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
{activeTab === 'passengers' && ( {activeTab === 'passengers' && (
<div className="space-y-4"> <div className="space-y-4">
{isLoadingSub ? ( {isLoadingSub ? (
<Shimmer rows={2} withCard={false} />
<TourPassengersShimmer />
) : passengersGroup.length === 0 ? ( ) : passengersGroup.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> <div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="users" className="mx-auto size-8 mb-2 opacity-50" /> <Ic name="users" className="mx-auto size-8 mb-2 opacity-50" />
@ -454,7 +461,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
{activeTab === 'comments' && ( {activeTab === 'comments' && (
<div className="space-y-4"> <div className="space-y-4">
{isLoadingSub ? ( {isLoadingSub ? (
<Shimmer rows={2} withCard={false} />
<TourCommentsShimmer />
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> <div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="inbox" className="mx-auto size-8 mb-2 opacity-50" /> <Ic name="inbox" className="mx-auto size-8 mb-2 opacity-50" />
@ -469,13 +476,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{c.user_avatar ? (
<img src={c.user_avatar} alt="" className="size-7 rounded-full object-cover" />
) : (
<div className="flex size-7 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{c.user?.[0] || 'U'}
</div>
)}
<Avatar className="size-7 shrink-0" iconClassName="size-3.5" />
<span className="text-xs font-bold text-foreground">{c.user}</span> <span className="text-xs font-bold text-foreground">{c.user}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

255
src/features/tours/components/TourDetailShimmer.tsx

@ -0,0 +1,255 @@
import React from 'react'
import { ShimmerBox, ShimmerCircle, ShimmerLine } from '@/components/shared/shimmer_frontend'
/**
* Shimmer اسکلتون استاندارد برای تب «مشخصات و متون» در پنجره جزئیات تور
* منطبق با توکن‌های AppShimmer و معماری Slate خنثی
*/
export function TourDetailOverviewShimmer() {
return (
<div className="space-y-6">
{/* بخش ۱: ترجمه پویا برای عنوان و توضیحات تور */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between pb-1 border-b border-border-soft/60">
<div className="flex items-center gap-2">
<ShimmerCircle size={18} />
<ShimmerLine width={220} height={14} rounded="sm" />
</div>
<ShimmerLine width={80} height={12} rounded="sm" />
</div>
<div className="space-y-3 pt-1">
<div className="space-y-1.5">
<ShimmerLine width={90} height={12} rounded="sm" />
<ShimmerBox height={38} rounded="xl" className="w-full" />
</div>
<div className="space-y-1.5">
<ShimmerLine width={120} height={12} rounded="sm" />
<ShimmerBox height={96} rounded="xl" className="w-full" />
</div>
</div>
</div>
{/* بخش ۲: مشخصات عمومی و زمان‌بندی تور */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border-soft">
<ShimmerCircle size={18} />
<ShimmerLine width={180} height={14} rounded="sm" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<ShimmerLine width={70} height={12} rounded="sm" />
<ShimmerBox height={38} rounded="xl" className="w-full" />
</div>
))}
</div>
</div>
{/* بخش ۳: جدول ۳ ردیف نرخ‌های مصوب و تخفیف */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-border-soft">
<div className="flex items-center gap-2">
<ShimmerCircle size={18} />
<ShimmerLine width={200} height={14} rounded="sm" />
</div>
<ShimmerBox width={80} height={22} rounded="md" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border-soft bg-surface-base/50 p-3 space-y-2">
<ShimmerLine width={110} height={12} rounded="sm" />
<ShimmerBox height={34} rounded="xl" className="w-full" />
<ShimmerLine width={130} height={10} rounded="sm" />
</div>
))}
</div>
</div>
{/* بخش ۴: وضعیت تکمیل ظرفیت */}
<div className="rounded-xl border border-border-soft bg-card/20 p-4 space-y-2.5">
<div className="flex justify-between">
<ShimmerLine width={120} height={14} rounded="sm" />
<ShimmerLine width={40} height={14} rounded="sm" />
</div>
<ShimmerBox height={8} rounded="full" className="w-full" />
<div className="flex justify-between">
<ShimmerLine width={140} height={12} rounded="sm" />
<ShimmerLine width={160} height={12} rounded="sm" />
</div>
</div>
{/* بخش ۵: گالری تصاویر تور */}
<div className="space-y-2">
<ShimmerLine width={100} height={12} rounded="sm" />
<div className="flex gap-2.5 overflow-x-auto pb-2 no-scrollbar">
{Array.from({ length: 4 }).map((_, i) => (
<ShimmerBox key={i} width={96} height={96} rounded="xl" />
))}
</div>
</div>
</div>
)
}
/**
* Shimmer اسکلتون استاندارد برای تب «برنامه سفر»
*/
export function TourItineraryShimmer() {
return (
<div className="space-y-4">
{/* نوار بالایی هدر زبان */}
<div className="flex items-center justify-between bg-surface-base p-3 rounded-xl border border-border-soft">
<div className="flex items-center gap-2">
<ShimmerCircle size={18} />
<ShimmerLine width={200} height={14} rounded="sm" />
</div>
<div className="flex items-center gap-3">
<ShimmerLine width={80} height={12} rounded="sm" />
<ShimmerBox width={120} height={28} rounded="xl" />
</div>
</div>
{/* ۳ ردیف مراحل روزانه سفر */}
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShimmerCircle size={24} />
<ShimmerLine width={130} height={14} rounded="sm" />
</div>
<ShimmerBox width={75} height={28} rounded="xl" />
</div>
<div className="space-y-2">
<ShimmerBox height={36} rounded="xl" className="w-full" />
<ShimmerBox height={56} rounded="xl" className="w-full" />
</div>
</div>
))}
</div>
</div>
)
}
/**
* Shimmer اسکلتون استاندارد برای تب «امکانات و نکات»
*/
export function TourFeaturesShimmer() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between bg-surface-base p-3 rounded-xl border border-border-soft">
<div className="flex items-center gap-2">
<ShimmerCircle size={18} />
<ShimmerLine width={200} height={14} rounded="sm" />
</div>
<ShimmerBox width={120} height={28} rounded="xl" />
</div>
{/* امکانات */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<ShimmerLine width={150} height={14} rounded="sm" />
<div className="flex flex-wrap gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<ShimmerBox key={i} width={85 + (i % 3) * 15} height={28} rounded="xl" />
))}
</div>
<div className="flex items-center gap-2 pt-2">
<ShimmerBox height={36} rounded="xl" className="flex-1" />
<ShimmerBox width={75} height={36} rounded="xl" />
</div>
</div>
{/* نکات راهنما */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<ShimmerLine width={150} height={14} rounded="sm" />
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border-soft bg-surface-base/50 p-3 space-y-1.5">
<ShimmerLine width={130} height={14} rounded="sm" />
<ShimmerLine width="90%" height={12} rounded="sm" />
</div>
))}
</div>
</div>
</div>
)
}
/**
* Shimmer اسکلتون استاندارد برای تب «مسافران»
*/
export function TourPassengersShimmer() {
return (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between border-b border-border-soft pb-2">
<ShimmerLine width={140} height={14} rounded="sm" />
<ShimmerLine width={60} height={12} rounded="sm" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{Array.from({ length: 2 }).map((_, j) => (
<div key={j} className="rounded-xl border border-border-soft bg-surface-base/50 p-3 space-y-2">
<ShimmerLine width={120} height={14} rounded="sm" />
<div className="flex justify-between">
<ShimmerLine width={90} height={10} rounded="sm" />
<ShimmerLine width={70} height={10} rounded="sm" />
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}
/**
* Shimmer اسکلتون استاندارد برای تب «نظرات»
*/
export function TourCommentsShimmer() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShimmerCircle size={24} />
<ShimmerLine width={100} height={14} rounded="sm" />
</div>
<ShimmerLine width={40} height={12} rounded="sm" />
</div>
<ShimmerLine width="95%" height={12} rounded="sm" />
<ShimmerLine width="60%" height={12} rounded="sm" />
</div>
))}
</div>
)
}
interface TourDetailModalShimmerProps {
activeTab?: 'overview' | 'itinerary' | 'features' | 'passengers' | 'comments'
}
/**
* رندرر هوشمند و جامع Shimmer متناسب با تب فعال در پنجره جزئیات تور
*/
export function TourDetailModalShimmer({ activeTab = 'overview' }: TourDetailModalShimmerProps) {
switch (activeTab) {
case 'itinerary':
return <TourItineraryShimmer />
case 'features':
return <TourFeaturesShimmer />
case 'passengers':
return <TourPassengersShimmer />
case 'comments':
return <TourCommentsShimmer />
case 'overview':
default:
return <TourDetailOverviewShimmer />
}
}

44
src/features/tours/components/TourStatusBadge.tsx

@ -16,11 +16,11 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium text-[11px] gap-1.5',
'border-emerald-200 bg-emerald-50/90 text-emerald-950 dark:border-emerald-800/60 dark:bg-emerald-950/60 dark:text-emerald-100 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="size-1.5 rounded-full bg-emerald-600 dark:bg-emerald-400" />
در دسترس در دسترس
</Badge> </Badge>
) )
@ -29,11 +29,11 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400 font-medium text-[11px] gap-1.5',
'border-rose-200 bg-rose-50/90 text-rose-950 dark:border-rose-800/60 dark:bg-rose-950/60 dark:text-rose-100 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
<span className="size-1.5 rounded-full bg-rose-500" />
<span className="size-1.5 rounded-full bg-rose-600 dark:bg-rose-400" />
تکمیل ظرفیت تکمیل ظرفیت
</Badge> </Badge>
) )
@ -42,7 +42,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-grey-3/30 bg-grey-3/10 text-grey-2 font-medium text-[11px] gap-1.5',
'border-slate-200 bg-slate-100/90 text-slate-950 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-200 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
@ -51,7 +51,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
) )
default: default:
return ( return (
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}>
<Badge variant="outline" className={cn('text-xs text-grey-2 bg-muted/40 border-border-soft', className)}>
{status || 'نامشخص'} {status || 'نامشخص'}
</Badge> </Badge>
) )
@ -65,11 +65,11 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium text-[11px] gap-1.5',
'border-amber-200 bg-amber-50/90 text-amber-950 dark:border-amber-800/60 dark:bg-amber-950/60 dark:text-amber-100 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
<span className="size-1.5 rounded-full bg-amber-500" />
<span className="size-1.5 rounded-full bg-amber-600 dark:bg-amber-400" />
در انتظار اجرا در انتظار اجرا
</Badge> </Badge>
) )
@ -78,11 +78,11 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium text-[11px] gap-1.5 animate-pulse',
'border-sky-200 bg-sky-50/90 text-sky-950 dark:border-sky-800/60 dark:bg-sky-950/60 dark:text-sky-100 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
<span className="size-1.5 rounded-full bg-sky-500" />
<span className="size-1.5 rounded-full bg-sky-600 dark:bg-sky-400" />
در حال برگزاری در حال برگزاری
</Badge> </Badge>
) )
@ -92,7 +92,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-slate-500/30 bg-slate-500/10 text-slate-500 dark:text-slate-400 font-medium text-[11px] gap-1.5',
'border-slate-200 bg-slate-100/90 text-slate-950 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-200 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
@ -101,7 +101,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
) )
default: default:
return ( return (
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}>
<Badge variant="outline" className={cn('text-xs text-grey-2 bg-muted/40 border-border-soft', className)}>
{status || 'نامشخص'} {status || 'نامشخص'}
</Badge> </Badge>
) )
@ -115,11 +115,11 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium text-[11px] gap-1.5',
'border-emerald-200 bg-emerald-50/90 text-emerald-950 dark:border-emerald-800/60 dark:bg-emerald-950/60 dark:text-emerald-100 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="size-1.5 rounded-full bg-emerald-600 dark:bg-emerald-400" />
تایید شده تایید شده
</Badge> </Badge>
) )
@ -128,11 +128,11 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium text-[11px] gap-1.5',
'border-amber-200 bg-amber-50/90 text-amber-950 dark:border-amber-800/60 dark:bg-amber-950/60 dark:text-amber-100 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
<span className="size-1.5 rounded-full bg-amber-500" />
<span className="size-1.5 rounded-full bg-amber-600 dark:bg-amber-400" />
در انتظار پرداخت در انتظار پرداخت
</Badge> </Badge>
) )
@ -141,11 +141,11 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-indigo-500/30 bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 font-medium text-[11px] gap-1.5',
'border-blue-200 bg-blue-50/90 text-blue-950 dark:border-blue-800/60 dark:bg-blue-950/60 dark:text-blue-100 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
<span className="size-1.5 rounded-full bg-indigo-500" />
<span className="size-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
در انتظار بررسی فیش در انتظار بررسی فیش
</Badge> </Badge>
) )
@ -154,11 +154,11 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400 font-medium text-[11px] gap-1.5',
'border-rose-200 bg-rose-50/90 text-rose-950 dark:border-rose-800/60 dark:bg-rose-950/60 dark:text-rose-100 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
<span className="size-1.5 rounded-full bg-rose-500" />
<span className="size-1.5 rounded-full bg-rose-600 dark:bg-rose-400" />
رد شده رد شده
</Badge> </Badge>
) )
@ -167,7 +167,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'border-zinc-500/30 bg-zinc-500/10 text-zinc-500 font-medium text-[11px] gap-1.5',
'border-slate-200 bg-slate-100/90 text-slate-950 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-200 font-bold text-[11px] gap-1.5 shadow-xs',
className className
)} )}
> >
@ -176,7 +176,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
) )
default: default:
return ( return (
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}>
<Badge variant="outline" className={cn('text-xs text-grey-2 bg-muted/40 border-border-soft', className)}>
{status || 'نامشخص'} {status || 'نامشخص'}
</Badge> </Badge>
) )

20
src/features/tours/components/TourTranslationsDialog.tsx

@ -11,7 +11,8 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { Shimmer } from '@/components/shared/Shimmer'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { TourDetailOverviewShimmer } from './TourDetailShimmer'
import { import {
fetchTourTranslations, fetchTourTranslations,
saveTourTranslations, saveTourTranslations,
@ -33,8 +34,8 @@ interface TourTranslationsDialogProps {
} }
const SUPPORTED_LANGUAGES = [ const SUPPORTED_LANGUAGES = [
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
{ code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' }, { code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' },
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
{ code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' }, { code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' },
{ code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' }, { code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' },
{ code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' }, { code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' },
@ -338,7 +339,7 @@ export function TourTranslationsDialog({
{/* Dialog Body */} {/* Dialog Body */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5"> <div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{isLoading ? ( {isLoading ? (
<Shimmer className="p-2 space-y-3" rows={5} />
<TourDetailOverviewShimmer />
) : ( ) : (
<> <>
{errorMessage && ( {errorMessage && (
@ -623,21 +624,14 @@ export function TourTranslationsDialog({
<Button <Button
type="button" type="button"
size="sm" size="sm"
disabled={isSaving || isPrimary || isLoading}
disabled={isPrimary || isLoading}
isLoading={isSaving}
loadingText="در حال ذخیره..."
onClick={handleSave} onClick={handleSave}
className="rounded-xl bg-primary text-white hover:bg-primary/90 gap-1.5 shadow-sm" className="rounded-xl bg-primary text-white hover:bg-primary/90 gap-1.5 shadow-sm"
> >
{isSaving ? (
<>
<div className="size-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>در حال ذخیره...</span>
</>
) : (
<>
<Ic name="check" className="size-3.5" /> <Ic name="check" className="size-3.5" />
<span>ذخیره ترجمه {activeLangConfig?.label}</span> <span>ذخیره ترجمه {activeLangConfig?.label}</span>
</>
)}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>

127
src/features/tours/services/tour-api.ts

@ -16,8 +16,10 @@ export async function fetchTours(params?: {
limit?: number limit?: number
offset?: number offset?: number
language_code?: string language_code?: string
view?: string
}): Promise<DrfPaginatedResponse<TourListItem>> { }): Promise<DrfPaginatedResponse<TourListItem>> {
const query = new URLSearchParams() const query = new URLSearchParams()
query.set('view', params?.view ?? 'admin')
if (params?.limit !== undefined) query.set('limit', String(params.limit)) if (params?.limit !== undefined) query.set('limit', String(params.limit))
if (params?.offset !== undefined) query.set('offset', String(params.offset)) if (params?.offset !== undefined) query.set('offset', String(params.offset))
if (params?.language_code) query.set('language_code', params.language_code) if (params?.language_code) query.set('language_code', params.language_code)
@ -31,7 +33,7 @@ export async function fetchTours(params?: {
* دریافت جزئیات جامع یک تور با شناسه یکتا * دریافت جزئیات جامع یک تور با شناسه یکتا
*/ */
export async function fetchTourDetail(tourId: number): Promise<TourDetailItem> { export async function fetchTourDetail(tourId: number): Promise<TourDetailItem> {
return apiFetch<TourDetailItem>(`tours/${tourId}/`)
return apiFetch<TourDetailItem>(`tours/${tourId}/?view=admin`)
} }
/** /**
@ -143,6 +145,55 @@ export async function saveTourItineraryTranslation(
}) })
} }
/**
* ذخیره دسته‌ای ترجمه‌های مراحل سفر (Batch Itinerary Translations)
*/
export async function saveTourItineraryTranslationsBatch(
tourId: number,
itineraries: Array<{
itinerary_id: number
lang_code: string
title: string
summary: string
}>
): Promise<{ status: string; message: string }> {
return apiFetch<{ status: string; message: string }>(`tours/${tourId}/itinerary_translations/`, {
method: 'POST',
body: { itineraries },
})
}
/**
* ایجاد یک مرحله / روز جدید برای برنامه سفر تور
*/
export async function createTourItineraryStep(
tourId: number,
data: {
title: string
summary: string
lang_code?: string
started_at?: string
ended_at?: string
}
): Promise<{ status: string; message: string; itinerary_id: number }> {
return apiFetch<{ status: string; message: string; itinerary_id: number }>(`tours/${tourId}/itinerary_translations/`, {
method: 'POST',
body: data,
})
}
/**
* حذف یک مرحله از برنامه سفر تور
*/
export async function deleteTourItineraryStep(
tourId: number,
itineraryId: number
): Promise<{ status: string; message: string }> {
return apiFetch<{ status: string; message: string }>(`tours/${tourId}/itinerary_translations/?itinerary_id=${itineraryId}`, {
method: 'DELETE',
})
}
export interface CreateTourPayload { export interface CreateTourPayload {
title: string title: string
description?: string description?: string
@ -162,6 +213,25 @@ export interface CreateTourPayload {
travel_tips?: Record<string, string> travel_tips?: Record<string, string>
visibility?: boolean visibility?: boolean
translations?: Record<string, import('../types').TourTranslationData> translations?: Record<string, import('../types').TourTranslationData>
image_id?: number
image_ids?: number[]
deleted_image_ids?: number[]
}
/**
* آپلود تصویر تور در فرم ساخت و دریافت شناسه و آدرس تصویر ذخیره‌شده
*/
export async function uploadTourImage(file: File): Promise<{
id: number
url: string
original_filename?: string
}> {
const form = new FormData()
form.append('file', file)
return apiFetch<{ id: number; url: string; original_filename?: string }>('tours/upload_image/', {
method: 'POST',
body: form,
})
} }
/** /**
@ -174,3 +244,58 @@ export async function createTour(payload: CreateTourPayload): Promise<TourDetail
}) })
} }
/**
* ویرایش اطلاعات جامع یک تور (مشخصات اصلی، تاریخ‌ها، ظرفیت، قیمت‌ها، وضعیت‌ها و کشور مقصد)
*/
export async function updateTour(
tourId: number,
payload: Partial<CreateTourPayload> & { slug?: string }
): Promise<TourDetailItem> {
return apiFetch<TourDetailItem>(`tours/${tourId}/`, {
method: 'PATCH',
body: payload,
})
}
/**
* تنظیم یا تغییر گام/مرحله جاری تور (Set Tour Current Step)
*/
export async function setTourCurrentStep(
tourId: number,
params: { step_index?: number; itinerary_id?: number; direction?: 'next' | 'previous' }
): Promise<{
status: string
message: string
current_step_index: number
total_steps: number
current_itinerary_id: number
itineraries: { id: number; title: string; status: string; status_code: number }[]
}> {
return apiFetch<{
status: string
message: string
current_step_index: number
total_steps: number
current_itinerary_id: number
itineraries: { id: number; title: string; status: string; status_code: number }[]
}>(`tours/${tourId}/set_current_step/`, {
method: 'POST',
body: params,
})
}
/**
* حذف کامل یک تور از سیستم
*/
export async function deleteTour(tourId: number): Promise<void> {
return apiFetch<void>(`tours/${tourId}/`, {
method: 'DELETE',
})
}
/**
* تغییر سریع وضعیت فعال/غیرفعال بودن تور (نمایش در اپلیکیشن و وب‌سایت)
*/
export async function toggleTourVisibility(tourId: number, visibility: boolean): Promise<TourDetailItem> {
return updateTour(tourId, { visibility })
}

2
src/features/tours/types.ts

@ -36,6 +36,7 @@ export interface TourListItem {
image: TourImageItem | null image: TourImageItem | null
destination_country: string | null destination_country: string | null
is_access: boolean is_access: boolean
visibility?: boolean
} }
export interface TourFeatureItem { export interface TourFeatureItem {
@ -70,6 +71,7 @@ export interface TourDetailItem {
trip_status: TourTripStatus trip_status: TourTripStatus
destination_country?: string | null destination_country?: string | null
is_access: boolean is_access: boolean
visibility?: boolean
} }
export interface TourItineraryItem { export interface TourItineraryItem {

8
src/i18n/index.ts

@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import fa from '@/locales/fa.json' import fa from '@/locales/fa.json'
@ -122,9 +123,12 @@ export function useTranslation() {
const dir: Direction = language === 'en' ? 'ltr' : 'rtl' const dir: Direction = language === 'en' ? 'ltr' : 'rtl'
const isRTL = dir === 'rtl' const isRTL = dir === 'rtl'
const t = (key: string, params?: Record<string, string | number>): string => {
const t = useCallback(
(key: string, params?: Record<string, string | number>): string => {
return translate(language, key, params) return translate(language, key, params)
}
},
[language]
)
const currentLanguageInfo = const currentLanguageInfo =
SUPPORTED_LANGUAGES.find((l) => l.code === language) || SUPPORTED_LANGUAGES[0] SUPPORTED_LANGUAGES.find((l) => l.code === language) || SUPPORTED_LANGUAGES[0]

1
src/icons.tsx

@ -28,6 +28,7 @@ const PATHS: Record<string, string> = {
layoutDashboard: '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>', layoutDashboard: '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
sparkles: '<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2 2M16.4 16.4l2 2M18.4 5.6l-2 2M7.6 16.4l-2 2"/><circle cx="12" cy="12" r="3.5"/>', sparkles: '<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2 2M16.4 16.4l2 2M18.4 5.6l-2 2M7.6 16.4l-2 2"/><circle cx="12" cy="12" r="3.5"/>',
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="10.5" x2="15.4" y2="6.5"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/>', share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="10.5" x2="15.4" y2="6.5"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.9"/><path d="M16 3.1a4 4 0 0 1 0 7.8"/>', users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.9"/><path d="M16 3.1a4 4 0 0 1 0 7.8"/>',
filter: '<path d="M3 5h18l-7 8v6l-4 2v-8Z"/>', filter: '<path d="M3 5h18l-7 8v6l-4 2v-8Z"/>',
plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>', plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',

48
src/index.css

@ -2,6 +2,8 @@
@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 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';
@custom-variant dark (&:where(.dark, .dark *));
/* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */ /* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */
:root { :root {
--background: #18191d; --background: #18191d;
@ -242,3 +244,49 @@ html.light {
} }
#nprogress .spinner { display: none; } #nprogress .spinner { display: none; }
/* ─── Shimmer Wave Animation (Aligned with Flutter AppShimmer Neutral Slate Tokens) ─── */
@keyframes shimmerWave {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.shimmer-wave {
background: linear-gradient(
90deg,
#e2e8f0 0%,
#f8fafc 50%,
#e2e8f0 100%
);
background-size: 200% 100%;
animation: shimmerWave 1.5s infinite linear;
}
html.dark .shimmer-wave,
.dark .shimmer-wave {
background: linear-gradient(
90deg,
#22252c 0%,
#333742 50%,
#22252c 100%
);
background-size: 200% 100%;
animation: shimmerWave 1.5s infinite linear;
}
html.light .shimmer-wave,
.light .shimmer-wave {
background: linear-gradient(
90deg,
#e2e8f0 0%,
#f8fafc 50%,
#e2e8f0 100%
);
background-size: 200% 100%;
animation: shimmerWave 1.5s infinite linear;
}

47
src/lib/utils.ts

@ -23,6 +23,53 @@ export function thumbnailUrl(thumbnail: unknown): string | null {
return null return null
} }
export const REMOTE_MEDIA_ORIGIN = 'https://aqila.newhorizonco.uk'
/**
* دریافت آدرس جایگزین از سرور اصلی ریموت در صورتی که تصویر در لوکال هاست موجود نباشد
*/
export function getMediaFallbackUrl(url: string | null | undefined): string | null {
if (!url) return null
const trimmed = String(url).trim()
if (!trimmed || trimmed.startsWith('data:') || trimmed.startsWith('blob:')) return null
if (trimmed.startsWith(REMOTE_MEDIA_ORIGIN)) return trimmed
const cleanPath = trimmed
.replace(/^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\//i, '')
.replace(/^\/?(media\/)?/, '')
return `${REMOTE_MEDIA_ORIGIN}/media/${cleanPath}`
}
/**
* استانداردسازی آدرس تصاویر و فایل‌های چندرسانه‌ای
* آدرس‌های نسبی، فاقد پروتکل یا لوکال‌هاست را به آدرس معتبر و قابل رندر تبدیل می‌کند.
*/
export function resolveMediaUrl(url: string | null | undefined): string {
if (!url) return ''
const trimmed = String(url).trim()
if (!trimmed) return ''
if (trimmed.startsWith('data:') || trimmed.startsWith('blob:')) {
return trimmed
}
// تبدیل URL کامل لوکال‌هاست به مسیر نسبی تا از طریق پراکسیِ Vite بدون خطای CORS بارگذاری شود
const localMatch = trimmed.match(/^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/(media|static)\/(.*)$/i)
if (localMatch && localMatch[1] && localMatch[2]) {
return `/${localMatch[1]}/${localMatch[2]}`
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed
}
if (trimmed.startsWith('media/') || trimmed.startsWith('static/')) {
return `/${trimmed}`
}
if (trimmed.startsWith('factors/')) {
return `/media/${trimmed}`
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}
/** ثانیه → «م:ثث» با ارقام فارسی (مثلاً ۳:۰۵). مقادیر نامعتبر → ۰:۰۰ */ /** ثانیه → «م:ثث» با ارقام فارسی (مثلاً ۳:۰۵). مقادیر نامعتبر → ۰:۰۰ */
export function formatTime(seconds: number): string { export function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0 if (!Number.isFinite(seconds) || seconds < 0) seconds = 0

2
src/locales/ar.json

@ -100,7 +100,7 @@
"invoice": "فاتورة", "invoice": "فاتورة",
"invoices": "فواتير", "invoices": "فواتير",
"id": "المعرف", "id": "المعرف",
"viewDetails": "التفاصيل",
"viewDetails": "عرض التفاصيل",
"viewReceipt": "عرض إيصال الدفع", "viewReceipt": "عرض إيصال الدفع",
"noReceiptUploaded": "لم يتم رفع إيصال", "noReceiptUploaded": "لم يتم رفع إيصال",
"serviceTour": "جولات الزيارة", "serviceTour": "جولات الزيارة",

2
src/locales/en.json

@ -100,7 +100,7 @@
"invoice": "Invoice", "invoice": "Invoice",
"invoices": "Invoices", "invoices": "Invoices",
"id": "ID", "id": "ID",
"viewDetails": "Details",
"viewDetails": "View Details",
"viewReceipt": "View Receipt", "viewReceipt": "View Receipt",
"noReceiptUploaded": "No receipt uploaded", "noReceiptUploaded": "No receipt uploaded",
"serviceTour": "Pilgrimage Tours", "serviceTour": "Pilgrimage Tours",

4
src/locales/fa.json

@ -83,7 +83,7 @@
"date": "تاریخ صدور", "date": "تاریخ صدور",
"receipt": "فیش پرداختی", "receipt": "فیش پرداختی",
"hasReceipt": "مشاهده فیش", "hasReceipt": "مشاهده فیش",
"noReceipt": "ندارد",
"noReceipt": "فاقد فیش",
"approve": "تایید پرداخت", "approve": "تایید پرداخت",
"reject": "رد صورت‌حساب", "reject": "رد صورت‌حساب",
"rejectTitle": "رد صورت‌حساب", "rejectTitle": "رد صورت‌حساب",
@ -100,7 +100,7 @@
"invoice": "فاکتور", "invoice": "فاکتور",
"invoices": "فاکتور", "invoices": "فاکتور",
"id": "شناسه", "id": "شناسه",
"viewDetails": "جزئیات",
"viewDetails": "مشاهده جزئیات",
"viewReceipt": "مشاهده فیش واریزی", "viewReceipt": "مشاهده فیش واریزی",
"noReceiptUploaded": "فیش واریزی بارگذاری نشده", "noReceiptUploaded": "فیش واریزی بارگذاری نشده",
"serviceTour": "تورهای زیارتی", "serviceTour": "تورهای زیارتی",

18
src/stores/billing-badge.ts

@ -1,5 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { fetchFactors } from '@/features/billing/services/billing-api'
import { fetchBillingCounts } from '@/features/billing/services/billing-api'
interface BillingBadgeState { interface BillingBadgeState {
pendingReviewCount: number pendingReviewCount: number
@ -10,22 +10,22 @@ interface BillingBadgeState {
setCounts: (pending: number, awaiting: number) => void setCounts: (pending: number, awaiting: number) => void
} }
export const useBillingBadgeStore = create<BillingBadgeState>((set) => ({
export const useBillingBadgeStore = create<BillingBadgeState>((set, get) => ({
pendingReviewCount: 0, pendingReviewCount: 0,
awaitingPaymentCount: 0, awaitingPaymentCount: 0,
isLoading: false, isLoading: false,
lastFetched: null, lastFetched: null,
fetchCounts: async () => { fetchCounts: async () => {
const now = Date.now()
const last = get().lastFetched
if (last && now - last < 10000) return
try { try {
set({ isLoading: true }) set({ isLoading: true })
// دریافت تعداد فاکتورهای در انتظار بررسی فیش (Pending Review)
const pendingRes = await fetchFactors({ status: 'pending', limit: 1 })
// دریافت تعداد فاکتورهای در انتظار پرداخت کاربر (Awaiting Payment)
const awaitingRes = await fetchFactors({ status: 'awaiting_payment', limit: 1 })
const counts = await fetchBillingCounts()
set({ set({
pendingReviewCount: pendingRes?.count || 0,
awaitingPaymentCount: awaitingRes?.count || 0,
pendingReviewCount: counts.pending || 0,
awaitingPaymentCount: counts.awaiting_payment || 0,
isLoading: false, isLoading: false,
lastFetched: Date.now(), lastFetched: Date.now(),
}) })

63
src/stores/toast.ts

@ -0,0 +1,63 @@
import { create } from 'zustand'
export type ToastType = 'success' | 'error' | 'info' | 'warning'
export interface ToastItem {
id: string
type: ToastType
title?: string
message: string
duration?: number
}
interface ToastStore {
toasts: ToastItem[]
addToast: (toast: Omit<ToastItem, 'id'>) => string
removeToast: (id: string) => void
clearToasts: () => void
}
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
addToast: (toast) => {
const id = Math.random().toString(36).substring(2, 9)
const newToast: ToastItem = { ...toast, id }
set((state) => ({
toasts: [newToast, ...state.toasts.slice(0, 4)], // Keep at most 5 toasts
}))
const duration = toast.duration ?? 4000
if (duration > 0) {
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
}, duration)
}
return id
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
},
clearToasts: () => set({ toasts: [] }),
}))
/**
* Convenient standalone toast helper callable anywhere (components, hooks, async handlers).
*/
export const toast = {
success: (message: string, title?: string, duration?: number) =>
useToastStore.getState().addToast({ type: 'success', message, title, duration }),
error: (message: string, title?: string, duration?: number) =>
useToastStore.getState().addToast({ type: 'error', message, title, duration }),
info: (message: string, title?: string, duration?: number) =>
useToastStore.getState().addToast({ type: 'info', message, title, duration }),
warning: (message: string, title?: string, duration?: number) =>
useToastStore.getState().addToast({ type: 'warning', message, title, duration }),
remove: (id: string) => useToastStore.getState().removeToast(id),
clear: () => useToastStore.getState().clearToasts(),
}

4
vite.config.ts

@ -129,6 +129,10 @@ export default defineConfig(({ mode }) => {
target: apiTarget, target: apiTarget,
changeOrigin: true, changeOrigin: true,
}, },
'/media': {
target: apiTarget,
changeOrigin: true,
},
'/static': { '/static': {
target: apiTarget, target: apiTarget,
changeOrigin: true, changeOrigin: true,

Loading…
Cancel
Save