From 4eac2a6f8cb9dd6dcf71724b802d854a52adc08d Mon Sep 17 00:00:00 2001 From: "Muhammad A. Ghorbani" Date: Sun, 20 Sep 2026 22:36:23 +0330 Subject: [PATCH] feat: implement tours and billing features with supporting pages, components, and services --- src/components/shared/Shimmer.tsx | 87 +- src/components/shared/TablePagination.tsx | 2 +- src/components/shared/UniversalDatePicker.tsx | 331 ++++++ src/components/ui/dialog.tsx | 17 +- src/features/billing/BillingPage.tsx | 133 ++- .../billing/components/BillingDetailModal.tsx | 5 +- src/features/tours/OngoingToursPage.tsx | 26 +- src/features/tours/TourOrdersPage.tsx | 5 +- src/features/tours/ToursPage.tsx | 182 ++-- .../tours/components/CreateTourDialog.tsx | 957 ++++++++++++++++++ .../tours/components/TourDetailDialog.tsx | 948 +++++++++++++++++ .../tours/components/TourDetailSheet.tsx | 26 +- .../tours/components/TourStatusBadge.tsx | 3 +- .../components/TourTranslationsDialog.tsx | 37 +- src/features/tours/services/tour-api.ts | 31 + src/features/tours/types.ts | 1 + src/icons.tsx | 4 + src/index.css | 2 +- src/lib/date-utils.ts | 486 +++++++++ src/lib/jalali.ts | 42 +- src/services/http.ts | 7 + 21 files changed, 3120 insertions(+), 212 deletions(-) create mode 100644 src/components/shared/UniversalDatePicker.tsx create mode 100644 src/features/tours/components/CreateTourDialog.tsx create mode 100644 src/features/tours/components/TourDetailDialog.tsx create mode 100644 src/lib/date-utils.ts diff --git a/src/components/shared/Shimmer.tsx b/src/components/shared/Shimmer.tsx index 2b674e3..4ee3a65 100644 --- a/src/components/shared/Shimmer.tsx +++ b/src/components/shared/Shimmer.tsx @@ -21,15 +21,15 @@ export interface ShimmerProps { cardHeaderIcon?: IconName /** متن سمت چپ هدر کارت */ cardHeaderSub?: React.ReactNode - /** نوع نمایش: ردیفی (پیش‌فرض) یا کارت‌های گرید */ - variant?: 'list' | 'grid' | 'kpi' + /** نوع نمایش: ردیفی/جدولی (پیش‌فرض) یا کارت‌های گرید یا KPI */ + variant?: 'list' | 'table' | 'grid' | 'kpi' /** تعداد ستون‌ها در حالت grid (پیش‌فرض: 3) */ gridCols?: number } /** * کامپوننت یکپارچه و استاندارد Shimmer برای Loadingهای پنل مدیریت عقیله - * الگوی اصلی برگرفته از صفحه صورت‌حساب‌ها (Billing) + * دارای پالت خنثی با کنتراست استاندارد و انیمیشن روان پالس (در هر دو تم دارک و لایت) */ export function Shimmer({ rows, @@ -41,7 +41,7 @@ export function Shimmer({ cardHeaderTitle, cardHeaderIcon = 'list', cardHeaderSub, - variant = 'list', + variant = 'table', gridCols = 3, }: ShimmerProps) { const itemCount = rows !== undefined ? rows : count @@ -57,12 +57,12 @@ export function Shimmer({ } content = ( -
+
{Array.from({ length: itemCount }).map((_, i) => (
@@ -71,28 +71,85 @@ export function Shimmer({ ) } else if (variant === 'kpi') { content = ( -
+
{Array.from({ length: itemCount }).map((_, i) => (
+ > +
+
+
+
+
+
+ ))} +
+ ) + } else if (variant === 'table') { + // رندرر شبیه جدول با ستون‌های شکیل و تفکیک‌شده + content = ( +
+ {/* ردیف هدر جدول */} +
+
+
+
+
+
+
+
+ + {/* سطرهای شیمر جدول */} + {Array.from({ length: itemCount }).map((_, i) => ( +
+ {/* شناسه */} +
+ + {/* کاربر (آواتار + نام) */} +
+
+
+
+
+
+
+ + {/* بج سرویس */} +
+ + {/* مبلغ */} +
+ + {/* بج وضعیت */} +
+ + {/* تاریخ و اکشن */} +
+
+
))}
) } 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 && ( -
- الی {formatDateTime(step.ended_at)} +
+ {language === 'fa' ? 'تا' : language === 'ar' ? 'إلى' : 'to'} {formatDateTime(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([]) const [totalCount, setTotalCount] = useState(0) const [isLoading, setIsLoading] = useState(true) @@ -345,7 +348,7 @@ export default function TourOrdersPage() { {/* Date */} - {order.created ? new Date(order.created).toLocaleDateString('fa-IR') : '-'} + {order.created ? formatDateByLanguage(order.created, language as any) : '-'} {/* Actions */} diff --git a/src/features/tours/ToursPage.tsx b/src/features/tours/ToursPage.tsx index 7467d86..f719604 100644 --- a/src/features/tours/ToursPage.tsx +++ b/src/features/tours/ToursPage.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect, useMemo } from 'react' import { Ic } from '@/icons' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { @@ -16,16 +15,19 @@ import { import { fetchTours } from './services/tour-api' import type { TourListItem } from './types' import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge' -import { TourDetailSheet } from './components/TourDetailSheet' -import { TourTranslationsDialog } from './components/TourTranslationsDialog' +import { TourDetailDialog } from './components/TourDetailDialog' +import { CreateTourDialog } from './components/CreateTourDialog' import TablePagination from '@/components/shared/TablePagination' import FilterSelect from '@/components/shared/FilterSelect' import SortDropdown from '@/components/shared/SortDropdown' import FilterBar from '@/components/shared/FilterBar' import { Shimmer } from '@/components/shared/Shimmer' import { toFa } from '@/lib/utils' +import { useTranslation } from '@/i18n' +import { formatDateRangeByLanguage } from '@/lib/date-utils' export default function ToursPage() { + const { language } = useTranslation() const [tours, setTours] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isRefreshing, setIsRefreshing] = useState(false) @@ -38,20 +40,15 @@ export default function ToursPage() { const [countryFilter, setCountryFilter] = useState('ALL') const [sortBy, setSortBy] = useState<'id' | 'price_asc' | 'price_desc' | 'started_at'>('id') const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid') - const [languageCode, setLanguageCode] = useState('ALL') // صفحه‌بندی (Pagination) const [currentPage, setCurrentPage] = useState(1) - const [pageSize, setPageSize] = useState(8) // ۸ تور در هر صفحه (۲ ردیف ۴ تایی مناسب گرید و جدول) + const [pageSize, setPageSize] = useState(20) // ۲۰ تور در هر صفحه - // وضعیت شیت جزئیات تور + // وضعیت مودال جزئیات و ترجمه‌های یکپارچه تور const [selectedTourId, setSelectedTourId] = useState(null) const [isDetailOpen, setIsDetailOpen] = useState(false) - - // وضعیت دیالوگ ترجمه‌ها - const [transTourId, setTransTourId] = useState(null) - const [transTourTitle, setTransTourTitle] = useState('') - const [isTransOpen, setIsTransOpen] = useState(false) + const [isCreateOpen, setIsCreateOpen] = useState(false) const loadData = async (showRefresh = false) => { if (showRefresh) setIsRefreshing(true) @@ -61,7 +58,7 @@ export default function ToursPage() { try { const res = await fetchTours({ limit: 100, - language_code: languageCode !== 'ALL' ? languageCode : undefined, + language_code: language, }) setTours(res.results || []) } catch (err: any) { @@ -74,7 +71,7 @@ export default function ToursPage() { useEffect(() => { loadData() - }, [languageCode]) + }, [language]) // فیلتر، جستجو و مرتب‌سازی کلاینت const filteredTours = useMemo(() => { @@ -107,10 +104,10 @@ export default function ToursPage() { Boolean(searchTerm.trim()), ].filter(Boolean).length - // بازنشانی صفحه به ۱ در صورت تغییر هرکدام از فیلترها، جستجو، مرتب‌سازی یا زبان + // بازنشانی صفحه به ۱ در صورت تغییر هرکدام از فیلترها، جستجو یا مرتب‌سازی useEffect(() => { setCurrentPage(1) - }, [searchTerm, statusFilter, tripStatusFilter, countryFilter, languageCode, sortBy]) + }, [searchTerm, statusFilter, tripStatusFilter, countryFilter, sortBy]) // محاسبات صفحه‌بندی فهرست تورها const totalPages = Math.ceil(filteredTours.length / pageSize) || 1 @@ -161,27 +158,11 @@ export default function ToursPage() {

- پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران و اطلاعات قیمتی تورهای عقیله + پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران، اطلاعات قیمتی و ترجمه‌های چندزبانه

- {/* انتخاب زبان */} - - {/* تغییر نمای شبکه‌ای / جدولی */}
+ {/* دکمه ایجاد تور جدید */} + + {/* دکمه بروزرسانی زنده */} -
@@ -523,7 +514,7 @@ export default function ToursPage() { عنوان تور مقصد تاریخ برگزاری - قیمت بزرگسال + نرخ‌ها (بزرگسال / کودک / نوزاد) وضعیت فروش وضعیت سفر عملیات @@ -544,11 +535,14 @@ export default function ToursPage() { - - {tour.started_at} ~ {tour.ended_at} + + {formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)} - - ${parseFloat(tour.price).toLocaleString()} + +
${parseFloat(tour.price || '0').toLocaleString()}
+
+ کودک: ${parseFloat(tour.price_child || '0').toLocaleString()} | نوزاد: ${parseFloat(tour.price_infant || '0').toLocaleString()} +
@@ -557,34 +551,18 @@ export default function ToursPage() { -
- - -
+
))} @@ -623,10 +601,10 @@ export default function ToursPage() { setCurrentPage(1) }} options={[ - { value: '8', label: '۸' }, - { value: '12', label: '۱۲' }, - { value: '16', label: '۱۶' }, - { value: '24', label: '۲۴' }, + { value: '10', label: '۱۰' }, + { value: '20', label: '۲۰' }, + { value: '50', label: '۵۰' }, + { value: '100', label: '۱۰۰' }, ]} size="sm" className="w-20" @@ -647,22 +625,22 @@ export default function ToursPage() {
)} - {/* شیت جزئیات کامل تور */} - setIsDetailOpen(false)} + onUpdated={() => loadData()} /> - {/* دیالوگ مدیریت ترجمه‌های ۵ زبانه */} - loadData()} + {/* مودال ایجاد تور جدید به همراه ترجمه‌های ۵ زبانه */} + loadData(true)} />
) } + diff --git a/src/features/tours/components/CreateTourDialog.tsx b/src/features/tours/components/CreateTourDialog.tsx new file mode 100644 index 0000000..a87f080 --- /dev/null +++ b/src/features/tours/components/CreateTourDialog.tsx @@ -0,0 +1,957 @@ +import * as React from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Ic } from '@/icons' +import { createTour, type CreateTourPayload } from '../services/tour-api' +import type { TourTranslationData } from '../types' + +interface CreateTourDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: () => void +} + +const SUPPORTED_LANGUAGES = [ + { code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' }, + { code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' }, + { code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' }, + { code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' }, + { code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' }, +] 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'] + +export function CreateTourDialog({ + open, + onOpenChange, + onSuccess, +}: CreateTourDialogProps) { + const [activeTab, setActiveTab] = React.useState<'basic' | 'features_tips' | 'translations'>('basic') + const [selectedTransLang, setSelectedTransLang] = React.useState('en') + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState(null) + const [successMessage, setSuccessMessage] = React.useState(null) + + // Basic info states + const [title, setTitle] = React.useState('') + const [description, setDescription] = React.useState('') + const [startedAt, setStartedAt] = React.useState('') + const [endedAt, setEndedAt] = React.useState('') + const [capacity, setCapacity] = React.useState('40') + const [destinationCountry, setDestinationCountry] = React.useState('IQ') + const [primaryLang, setPrimaryLang] = React.useState('fa') + const [status, setStatus] = React.useState('AVAILABLE') + + // Pricing states + const [price, setPrice] = React.useState('') + const [priceChild, setPriceChild] = React.useState('') + const [priceInfant, setPriceInfant] = React.useState('') + const [percentOff, setPercentOff] = React.useState('0') + + // Base features & tips + const [features, setFeatures] = React.useState([]) + const [newFeature, setNewFeature] = React.useState('') + const [tips, setTips] = React.useState<{ title: string; desc: string }[]>([]) + const [newTipTitle, setNewTipTitle] = React.useState('') + const [newTipDesc, setNewTipDesc] = React.useState('') + + // Multi-language translation state + const [translations, setTranslations] = React.useState>({ + fa: { title: '', description: '', tour_feature: [], travel_tips: {} }, + ar: { title: '', description: '', tour_feature: [], travel_tips: {} }, + en: { title: '', description: '', tour_feature: [], travel_tips: {} }, + ru: { title: '', description: '', tour_feature: [], travel_tips: {} }, + id: { title: '', description: '', tour_feature: [], travel_tips: {} }, + }) + + // Reset form when opened + React.useEffect(() => { + if (open) { + setErrorMessage(null) + setSuccessMessage(null) + setActiveTab('basic') + } + }, [open]) + + const handleAddFeature = () => { + if (!newFeature.trim()) return + setFeatures((prev) => [...prev, newFeature.trim()]) + setNewFeature('') + } + + const handleRemoveFeature = (idx: number) => { + setFeatures((prev) => prev.filter((_, i) => i !== idx)) + } + + const handleAddTip = () => { + if (!newTipTitle.trim() || !newTipDesc.trim()) return + setTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }]) + setNewTipTitle('') + setNewTipDesc('') + } + + const handleRemoveTip = (idx: number) => { + setTips((prev) => prev.filter((_, i) => i !== idx)) + } + + // Helper for updating translation fields for selectedTransLang + const handleTransFieldChange = (field: 'title' | 'description', val: string) => { + setTranslations((prev) => ({ + ...prev, + [selectedTransLang]: { + ...prev[selectedTransLang], + [field]: val, + }, + })) + } + + const handleAddTransFeature = (text: string) => { + if (!text.trim()) return + setTranslations((prev) => { + const current = prev[selectedTransLang] || { tour_feature: [] } + const currFeats = current.tour_feature || [] + return { + ...prev, + [selectedTransLang]: { + ...current, + tour_feature: [...currFeats, { title: text.trim() }], + }, + } + }) + } + + const handleRemoveTransFeature = (idx: number) => { + setTranslations((prev) => { + const current = prev[selectedTransLang] || { tour_feature: [] } + const currFeats = current.tour_feature || [] + return { + ...prev, + [selectedTransLang]: { + ...current, + tour_feature: currFeats.filter((_, i) => i !== idx), + }, + } + }) + } + + const handleAddTransTip = (tipTitle: string, tipDesc: string) => { + if (!tipTitle.trim() || !tipDesc.trim()) return + setTranslations((prev) => { + const current = prev[selectedTransLang] || { travel_tips: {} } + const currTips = current.travel_tips || {} + return { + ...prev, + [selectedTransLang]: { + ...current, + travel_tips: { + ...currTips, + [tipTitle.trim()]: tipDesc.trim(), + }, + }, + } + }) + } + + const handleRemoveTransTip = (tipTitle: string) => { + setTranslations((prev) => { + const current = prev[selectedTransLang] || { travel_tips: {} } + const currTips = { ...(current.travel_tips || {}) } + delete currTips[tipTitle] + return { + ...prev, + [selectedTransLang]: { + ...current, + travel_tips: currTips, + }, + } + }) + } + + const [newTransFeatText, setNewTransFeatText] = React.useState('') + const [newTransTipTitle, setNewTransTipTitle] = React.useState('') + const [newTransTipDesc, setNewTransTipDesc] = React.useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setErrorMessage(null) + setSuccessMessage(null) + + // Validation + if (!title.trim()) { + setErrorMessage('لطفاً عنوان اصلی تور را وارد نمایید.') + setActiveTab('basic') + return + } + if (!startedAt) { + setErrorMessage('لطفاً تاریخ شروع تور را مشخص نمایید.') + setActiveTab('basic') + return + } + if (!endedAt) { + setErrorMessage('لطفاً تاریخ پایان تور را مشخص نمایید.') + setActiveTab('basic') + return + } + if (new Date(endedAt) < new Date(startedAt)) { + setErrorMessage('تاریخ پایان تور نمی‌تواند قبل از تاریخ شروع باشد.') + setActiveTab('basic') + return + } + if (!price || isNaN(Number(price)) || Number(price) <= 0) { + setErrorMessage('لطفاً قیمت معتبر برای بزرگسال وارد نمایید.') + setActiveTab('basic') + return + } + if (!capacity || isNaN(Number(capacity)) || Number(capacity) <= 0) { + setErrorMessage('لطفاً ظرفیت معتبر برای تور وارد نمایید.') + setActiveTab('basic') + return + } + + setIsSubmitting(true) + + try { + const tour_feature = features.map((f) => ({ title: f })) + const travel_tips: Record = {} + tips.forEach((t) => { + travel_tips[t.title] = t.desc + }) + + // Clean translations payload: include any language that has data + const cleanedTranslations: Record = {} + Object.entries(translations).forEach(([code, data]) => { + const hasTitle = Boolean(data.title?.trim()) + const hasDesc = Boolean(data.description?.trim()) + const hasFeats = Boolean(data.tour_feature && data.tour_feature.length > 0) + const hasTips = Boolean(data.travel_tips && Object.keys(data.travel_tips).length > 0) + + if (hasTitle || hasDesc || hasFeats || hasTips) { + cleanedTranslations[code] = { + title: data.title?.trim() || (code === primaryLang ? title.trim() : ''), + description: data.description?.trim() || '', + tour_feature: data.tour_feature || [], + travel_tips: data.travel_tips || {}, + } + } + }) + + // Ensure primary language translation is also present if not defined + if (!cleanedTranslations[primaryLang]) { + cleanedTranslations[primaryLang] = { + title: title.trim(), + description: description.trim(), + tour_feature, + travel_tips, + } + } + + const payload: CreateTourPayload = { + title: title.trim(), + description: description.trim(), + started_at: startedAt, + ended_at: endedAt, + capacity: Number(capacity), + price: String(price), + price_child: priceChild ? String(priceChild) : '0', + price_infant: priceInfant ? String(priceInfant) : '0', + percent_off: percentOff ? Number(percentOff) : 0, + destination_country_code: destinationCountry, + language_code: primaryLang, + status, + tour_feature, + travel_tips, + visibility: true, + translations: cleanedTranslations, + } + + const createdTour = await createTour(payload) + setSuccessMessage(`تور "${createdTour.title}" با موفقیت ایجاد و ترجمه‌ها در دیتابیس ثبت گردید.`) + + setTimeout(() => { + if (onSuccess) onSuccess() + onOpenChange(false) + }, 900) + } catch (err: any) { + setErrorMessage(err?.message || 'خطا در ثبت تور جدید در سرور.') + } finally { + setIsSubmitting(false) + } + } + + const activeTransConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedTransLang) + const currentTransData = translations[selectedTransLang] || { + title: '', + description: '', + tour_feature: [], + travel_tips: {}, + } + + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + className="max-w-[840px] p-0 overflow-hidden max-h-[92vh] flex flex-col" + > + {/* Header */} + +
+
+
+ +
+
+ + افزودن تور زیارتی و گردشگری جدید + + + ثبت مشخصات پایه، هزینه‌ها، ظرفیت و ورودی‌های ترجمه ۵ زبانه همزمان + +
+
+ +
+ {/* Step/Tab buttons */} +
+ + + +
+ + {/* دکمه علامت ضربدر بالای مودال */} + +
+
+
+ + {/* Form Body */} +
+
+ {errorMessage && ( +
+ + {errorMessage} +
+ )} + + {successMessage && ( +
+ + {successMessage} +
+ )} + + {/* TAB 1: Basic Info & Pricing */} + {activeTab === 'basic' && ( +
+
+
+ + setTitle(e.target.value)} + placeholder="مثال: تور زیارتی عتبات عالیات نجف و کربلا (ویژه اربعین)..." + className="bg-surface-2 border-border/50 text-sm font-medium" + required + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setStartedAt(e.target.value)} + className="bg-surface-2 border-border/50 text-sm font-mono" + required + /> +
+ +
+ + setEndedAt(e.target.value)} + className="bg-surface-2 border-border/50 text-sm font-mono" + required + /> +
+ +
+ + setCapacity(e.target.value)} + placeholder="۴۰" + className="bg-surface-2 border-border/50 text-sm font-mono" + required + /> +
+ +
+ + +
+
+ + {/* Pricing Sub-card */} +
+

+ + قیمت‌گذاری و تخفیفات (دلار) +

+ +
+
+ + setPrice(e.target.value)} + placeholder="520" + className="bg-surface-3 text-xs font-mono" + required + /> +
+
+ + setPriceChild(e.target.value)} + placeholder="280" + className="bg-surface-3 text-xs font-mono" + /> +
+
+ + setPriceInfant(e.target.value)} + placeholder="80" + className="bg-surface-3 text-xs font-mono" + /> +
+
+ + setPercentOff(e.target.value)} + placeholder="0" + className="bg-surface-3 text-xs font-mono" + /> +
+
+
+ + {/* Description */} +
+ +