@@ -71,28 +71,85 @@ export function Shimmer({
)
} else if (variant === 'kpi') {
content = (
-
+ )
+ } else if (variant === 'table') {
+ // رندرر شبیه جدول با ستونهای شکیل و تفکیکشده
+ content = (
+
)
} else {
- // ردیفی استاندارد (مطابق ۱۰۰٪ با صورتحسابها)
+ // ردیفی ساده
content = (
-
+
{Array.from({ length: itemCount }).map((_, i) => (
@@ -103,11 +160,11 @@ export function Shimmer({
if (withCard) {
return (
-
+
{(cardHeaderTitle || cardHeaderSub) && (
-
+
{cardHeaderTitle && (
-
+
{cardHeaderTitle}
diff --git a/src/components/shared/TablePagination.tsx b/src/components/shared/TablePagination.tsx
index fd01b21..ea8272f 100644
--- a/src/components/shared/TablePagination.tsx
+++ b/src/components/shared/TablePagination.tsx
@@ -43,7 +43,7 @@ function pageWindow(page: number, totalPages: number): number[] {
export default function TablePagination({
page,
currentPage,
- pageSize = 10,
+ pageSize = 20,
total,
totalPages: propTotalPages,
onPageChange,
diff --git a/src/components/shared/UniversalDatePicker.tsx b/src/components/shared/UniversalDatePicker.tsx
new file mode 100644
index 0000000..bba0d8e
--- /dev/null
+++ b/src/components/shared/UniversalDatePicker.tsx
@@ -0,0 +1,331 @@
+import React, { useState, useEffect, useRef, useMemo } from 'react'
+import { Ic } from '@/icons'
+import { Button } from '@/components/ui/button'
+import { useTranslation } from '@/i18n'
+import {
+ parseSafeDate,
+ formatDateByLanguage,
+ gregorianToJalali,
+ jalaliToGregorian,
+ gregorianToHijri,
+ hijriToGregorian,
+ getCalendarMonthData,
+ JALALI_MONTH_NAMES,
+ HIJRI_MONTH_NAMES,
+ GREGORIAN_MONTH_NAMES,
+} from '@/lib/date-utils'
+import { cn } from '@/lib/utils'
+
+export interface UniversalDatePickerProps {
+ value?: string | null
+ onChange: (isoDate: string) => void
+ placeholder?: string
+ disabled?: boolean
+ label?: string
+ className?: string
+ minDate?: string
+ maxDate?: string
+ required?: boolean
+ size?: 'sm' | 'default' | 'lg'
+ clearable?: boolean
+}
+
+export function UniversalDatePicker({
+ value,
+ onChange,
+ placeholder,
+ disabled = false,
+ label,
+ className,
+ minDate,
+ maxDate,
+ required = false,
+ size = 'default',
+ clearable = true,
+}: UniversalDatePickerProps) {
+ const { language, dir } = useTranslation()
+ const [isOpen, setIsOpen] = useState(false)
+ const containerRef = useRef(null)
+
+ // تعیین نوع تقویم بر اساس زبان فعال
+ const calendarType: 'persian' | 'islamic' | 'gregorian' =
+ language === 'fa' ? 'persian' : language === 'ar' ? 'islamic' : 'gregorian'
+
+ // مقدار فعلی به صورت Date
+ const selectedDate = useMemo(() => parseSafeDate(value), [value])
+
+ // وضعیت ماه و سال در حال مشاهده در تقویم
+ const [viewYear, setViewYear] = useState(() => {
+ const d = selectedDate || new Date()
+ if (calendarType === 'persian') return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()).year
+ if (calendarType === 'islamic') return gregorianToHijri(d).year
+ return d.getFullYear()
+ })
+
+ const [viewMonth, setViewMonth] = useState(() => {
+ const d = selectedDate || new Date()
+ if (calendarType === 'persian') return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()).month
+ if (calendarType === 'islamic') return gregorianToHijri(d).month
+ return d.getMonth() + 1
+ })
+
+ // وقتی مقدار تغییر کند یا باز شود، تقویم روی تاریخ انتخابی یا امروز تنظیم شود
+ useEffect(() => {
+ if (isOpen) {
+ const d = selectedDate || new Date()
+ if (calendarType === 'persian') {
+ const j = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate())
+ setViewYear(j.year)
+ setViewMonth(j.month)
+ } else if (calendarType === 'islamic') {
+ const h = gregorianToHijri(d)
+ setViewYear(h.year)
+ setViewMonth(h.month)
+ } else {
+ setViewYear(d.getFullYear())
+ setViewMonth(d.getMonth() + 1)
+ }
+ }
+ }, [isOpen, selectedDate, calendarType])
+
+ // بستن پاپاور هنگام کلیک به بیرون یا فشردن Escape
+ useEffect(() => {
+ if (!isOpen) return
+ const handleClickOutside = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setIsOpen(false)
+ }
+ }
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setIsOpen(false)
+ }
+ document.addEventListener('mousedown', handleClickOutside)
+ document.addEventListener('keydown', handleKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [isOpen])
+
+ // رفتن به ماه قبل
+ const handlePrevMonth = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (viewMonth === 1) {
+ setViewMonth(12)
+ setViewYear((y) => y - 1)
+ } else {
+ setViewMonth((m) => m - 1)
+ }
+ }
+
+ // رفتن به ماه بعد
+ const handleNextMonth = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (viewMonth === 12) {
+ setViewMonth(1)
+ setViewYear((y) => y + 1)
+ } else {
+ setViewMonth((m) => m + 1)
+ }
+ }
+
+ // دادههای ماه و ماتریس روزها
+ const monthData = useMemo(() => {
+ return getCalendarMonthData(calendarType, viewYear, viewMonth)
+ }, [calendarType, viewYear, viewMonth])
+
+ // بررسی وضعیت امروز و انتخاب
+ const todayIso = useMemo(() => {
+ const n = new Date()
+ return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`
+ }, [])
+
+ const selectedIso = useMemo(() => {
+ if (!selectedDate) return ''
+ return `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
+ }, [selectedDate])
+
+ // انتخاب یک روز
+ const handleSelectDay = (dayNumber: number) => {
+ const isoStr = monthData.toIso(dayNumber)
+ onChange(isoStr)
+ setIsOpen(false)
+ }
+
+ // انتخاب تاریخ امروز
+ const handleSelectToday = () => {
+ onChange(todayIso)
+ setIsOpen(false)
+ }
+
+ // پاک کردن تاریخ
+ const handleClear = (e?: React.MouseEvent) => {
+ if (e) e.stopPropagation()
+ onChange('')
+ setIsOpen(false)
+ }
+
+ const formattedDisplayValue = value ? formatDateByLanguage(value, language, { style: 'medium' }) : ''
+ const defaultPlaceholder =
+ language === 'fa' ? 'انتخاب تاریخ...' : language === 'ar' ? 'اختر التاريخ...' : 'Select date...'
+
+ return (
+
+ {label && (
+
+ )}
+
+ {/* دکمه اینپوت تریگر انتخاب تاریخ */}
+
!disabled && setIsOpen((prev) => !prev)}
+ className={cn(
+ 'flex items-center justify-between gap-2 rounded-xl border border-border-soft bg-card/60 px-3 text-xs transition cursor-pointer select-none',
+ size === 'sm' && 'h-8 text-[11px]',
+ size === 'default' && 'h-9',
+ size === 'lg' && 'h-11 text-sm',
+ disabled && 'opacity-50 cursor-not-allowed pointer-events-none',
+ isOpen ? 'border-primary ring-1 ring-primary/40' : 'hover:border-primary/40',
+ !formattedDisplayValue && 'text-muted-foreground'
+ )}
+ >
+
+
+
+ {formattedDisplayValue || placeholder || defaultPlaceholder}
+
+
+
+
+ {clearable && Boolean(value) && !disabled && (
+
+ )}
+
+
+
+
+ {/* پاپاور تقویم ماهانه */}
+ {isOpen && (
+
+ {/* هدر کنترل ماه و سال */}
+
+
+
+
+ {monthData.monthName}
+ {monthData.yearFormatted}
+
+
+
+
+
+ {/* ردیف روزهای هفته */}
+
+ {monthData.weekdays.map((w, idx) => (
+
+ {w}
+
+ ))}
+
+
+ {/* ماتریس روزهای ماه */}
+
+ {/* خانههای خالی ابتدای ماه */}
+ {Array.from({ length: monthData.startOffset }).map((_, idx) => (
+
+ ))}
+
+ {/* روزهای ماه */}
+ {Array.from({ length: monthData.daysInMonth }).map((_, idx) => {
+ const dayNum = idx + 1
+ const dayIso = monthData.toIso(dayNum)
+ const isSelected = selectedIso === dayIso
+ const isToday = todayIso === dayIso
+
+ // بررسی minDate و maxDate
+ const isDisabled =
+ (minDate && dayIso < minDate) ||
+ (maxDate && dayIso > maxDate)
+
+ return (
+
+ )
+ })}
+
+
+ {/* فوتر تقویم با دکمههای سریع */}
+
+
+
+ {value && (
+
+ )}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index bd3742c..503e225 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -16,7 +16,13 @@ function DialogOverlay({ className, ...props }: React.ComponentProps) {
+function DialogContent({
+ className,
+ children,
+ onPointerDownOutside,
+ onInteractOutside,
+ ...props
+}: React.ComponentProps) {
return (
@@ -25,6 +31,15 @@ function DialogContent({ className, children, ...props }: React.ComponentProps {
+ // Prevent closing modal when clicking outside (backdrop)
+ e.preventDefault()
+ onPointerDownOutside?.(e)
+ }}
+ onInteractOutside={(e) => {
+ e.preventDefault()
+ onInteractOutside?.(e)
+ }}
{...props}
>
{children}
diff --git a/src/features/billing/BillingPage.tsx b/src/features/billing/BillingPage.tsx
index 06d83e8..02171a2 100644
--- a/src/features/billing/BillingPage.tsx
+++ b/src/features/billing/BillingPage.tsx
@@ -25,8 +25,9 @@ import { RejectFactorDialog } from './components/RejectFactorDialog'
import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog'
import { useTranslation } from '@/i18n'
import { useBillingBadgeStore } from '@/stores/billing-badge'
+import { formatDateByLanguage } from '@/lib/date-utils'
-const PAGE_SIZE = 15
+const PAGE_SIZE = 20
interface ServiceTabItem {
id: string
@@ -296,9 +297,13 @@ export default function BillingPage() {
- {totalCount} {t('billing.invoices')}
+ {isLoading ? (
+
+ ) : (
+ `${totalCount} ${t('billing.invoices')}`
+ )}
@@ -360,7 +365,11 @@ export default function BillingPage() {
{t('billing.totalInvoices')}
-
{stats.total}
+ {isLoading ? (
+
+ ) : (
+
{stats.total}
+ )}
@@ -375,9 +384,13 @@ export default function BillingPage() {
{t('billing.pendingReview')}
-
- {stats.pendingReview}
-
+ {isLoading ? (
+
+ ) : (
+
+ {stats.pendingReview}
+
+ )}
@@ -392,9 +405,13 @@ export default function BillingPage() {
{t('billing.awaitingPayment')}
-
- {stats.awaitingPayment}
-
+ {isLoading ? (
+
+ ) : (
+
+ {stats.awaitingPayment}
+
+ )}
@@ -409,9 +426,13 @@ export default function BillingPage() {
{t('billing.approvedPayments')}
-
- {stats.approved}
-
+ {isLoading ? (
+
+ ) : (
+
+ {stats.approved}
+
+ )}
@@ -426,9 +447,13 @@ export default function BillingPage() {
{t('billing.rejectedOrExpired')}
-
- {stats.rejectedOrExpired}
-
+ {isLoading ? (
+
+ ) : (
+
+ {stats.rejectedOrExpired}
+
+ )}
@@ -525,9 +550,7 @@ export default function BillingPage() {
- {isLoading ? (
-
- ) : error ? (
+ {error ? (
{error}
@@ -535,7 +558,7 @@ export default function BillingPage() {
{t('common.retry')}
- ) : factors.length === 0 ? (
+ ) : !isLoading && factors.length === 0 ? (
@@ -553,7 +576,13 @@ export default function BillingPage() {
)}
) : (
-
+
+ {/* خط انیمیشن لودینگ در بالای جدول هنگام بارگذاری مجدد */}
+ {isLoading && (
+
+ )}
@@ -568,7 +597,61 @@ export default function BillingPage() {
- {factors.map((factor) => {
+ {isLoading ? (
+ Array.from({ length: 8 }).map((_, i) => (
+
+ {/* شناسه */}
+
+
+
+
+ {/* خریدار */}
+
+
+
+
+ {/* سرویس */}
+
+
+
+
+ {/* مبلغ */}
+
+
+
+
+ {/* وضعیت */}
+
+
+
+
+ {/* تصویر فیش */}
+
+
+
+
+ {/* تاریخ */}
+
+
+
+
+ {/* عملیات */}
+
+
+
+
+ ))
+ ) : (
+ factors.map((factor) => {
const isPending = factor.status === 'pending'
const hasReceipt = Boolean(factor.transaction_receipt)
const user = factor.user_info
@@ -650,9 +733,7 @@ export default function BillingPage() {
{/* تاریخ صدور */}
- {new Date(factor.created_at).toLocaleDateString(
- language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US'
- )}
+ {formatDateByLanguage(factor.created_at, language as any)}
{/* دکمههای عملیات */}
@@ -701,7 +782,7 @@ export default function BillingPage() {
)
- })}
+ }))}
diff --git a/src/features/billing/components/BillingDetailModal.tsx b/src/features/billing/components/BillingDetailModal.tsx
index c6fcef4..91fa46a 100644
--- a/src/features/billing/components/BillingDetailModal.tsx
+++ b/src/features/billing/components/BillingDetailModal.tsx
@@ -16,6 +16,7 @@ import { BillingStatusBadge } from './BillingStatusBadge'
import { BillingServiceBadge } from './BillingServiceBadge'
import { useTranslation } from '@/i18n'
+import { formatDateByLanguage, formatDateTimeByLanguage } from '@/lib/date-utils'
interface Props {
factor: FactorItem | null
@@ -67,7 +68,7 @@ export function BillingDetailModal({
صورتحساب شماره #{factor.id}
- ثبت شده در: {new Date(factor.created_at).toLocaleString(language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US')}
+ ثبت شده در: {formatDateTimeByLanguage(factor.created_at, language as any)}
@@ -107,7 +108,7 @@ export function BillingDetailModal({
مهلت پرداخت (تاریخ انقضا):
- {factor.expiration_date}
+ {formatDateByLanguage(factor.expiration_date, language as any)}
)}
diff --git a/src/features/tours/OngoingToursPage.tsx b/src/features/tours/OngoingToursPage.tsx
index 8512e06..fd9953c 100644
--- a/src/features/tours/OngoingToursPage.tsx
+++ b/src/features/tours/OngoingToursPage.tsx
@@ -12,6 +12,8 @@ import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
import FilterSelect from '@/components/shared/FilterSelect'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
+import { useTranslation } from '@/i18n'
+import { formatDateByLanguage, formatDateRangeByLanguage } from '@/lib/date-utils'
interface TourWithItineraries {
tour: TourListItem
@@ -28,6 +30,7 @@ interface TourWithItineraries {
export default function OngoingToursPage() {
const navigate = useNavigate()
+ const { language } = useTranslation()
const [tours, setTours] = useState([])
const [itinerariesMap, setItinerariesMap] = useState>({})
const [isLoading, setIsLoading] = useState(true)
@@ -245,19 +248,10 @@ export default function OngoingToursPage() {
}))
}
- // قالببندی زمان و تاریخ به فرمت خوانا
+ // قالببندی زمان و تاریخ به فرمت خوانا متناسب با تقویم زبان فعال
const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return '-'
- try {
- const d = new Date(dateStr)
- return d.toLocaleDateString('fa-IR', {
- month: 'long',
- day: 'numeric',
- weekday: 'short',
- })
- } catch {
- return dateStr
- }
+ return formatDateByLanguage(dateStr, language as any, { style: 'medium' })
}
return (
@@ -565,17 +559,17 @@ export default function OngoingToursPage() {
{tour.title}
-
+
- {tour.started_at} ~ {tour.ended_at}
+ {formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
|
- {tour.slug}
+ {tour.slug}
@@ -772,8 +766,8 @@ export default function OngoingToursPage() {
{formatDateTime(step.started_at)}
{step.ended_at && (
-
diff --git a/src/features/tours/TourOrdersPage.tsx b/src/features/tours/TourOrdersPage.tsx
index 0ff301d..afbf14f 100644
--- a/src/features/tours/TourOrdersPage.tsx
+++ b/src/features/tours/TourOrdersPage.tsx
@@ -16,8 +16,11 @@ import { fetchTourOrders, updateTourOrder } from './services/tour-api'
import type { TourOrderItem, TourOrderStatus } from './types'
import { TourStatusBadge } from './components/TourStatusBadge'
import { Shimmer } from '@/components/shared/Shimmer'
+import { useTranslation } from '@/i18n'
+import { formatDateByLanguage } from '@/lib/date-utils'
export default function TourOrdersPage() {
+ const { language } = useTranslation()
const [orders, setOrders] = useState