From a860b8cf66a2d3f7f53aa59185d637daffcebb16 Mon Sep 17 00:00:00 2001 From: "Muhammad A. Ghorbani" Date: Sun, 4 Oct 2026 17:47:42 +0330 Subject: [PATCH] feat: add tour orders page, billing management, and custom trip configuration features --- src/app/router.tsx | 9 + src/components/shared/FilterSelect.tsx | 84 +- src/components/shared/MultiFilterSelect.tsx | 298 +++ src/components/ui/select.tsx | 4 +- src/components/ui/sidebar.tsx | 128 +- src/config/navigation.ts | 27 +- src/features/billing/BillingPage.tsx | 388 ++-- .../billing/components/BillingDetailModal.tsx | 285 ++- src/features/billing/services/billing-api.ts | 26 + src/features/billing/types.ts | 2 + .../CustomTripDestinationDetailPage.tsx | 1616 +++++++++++++++++ src/features/custom-trip/CustomTripPage.tsx | 317 ++++ .../components/CustomTripDestinationsTab.tsx | 914 ++++++++++ .../components/CustomTripHotelsTab.tsx | 833 +++++++++ .../components/CustomTripOrdersTab.tsx | 421 +++++ .../components/CustomTripOriginsTab.tsx | 578 ++++++ .../components/CustomTripTransportsTab.tsx | 749 ++++++++ .../custom-trip/services/custom-trip-api.ts | 715 ++++++++ src/features/custom-trip/types.ts | 111 ++ src/features/tours/TourOrdersPage.tsx | 25 + src/features/tours/types.ts | 1 + .../users/components/UserFactorsTab.tsx | 2 +- src/features/users/types.ts | 1 + src/icons.tsx | 11 + src/locales/ar.json | 154 +- src/locales/en.json | 110 +- src/locales/fa.json | 158 +- 27 files changed, 7597 insertions(+), 370 deletions(-) create mode 100644 src/components/shared/MultiFilterSelect.tsx create mode 100644 src/features/custom-trip/CustomTripDestinationDetailPage.tsx create mode 100644 src/features/custom-trip/CustomTripPage.tsx create mode 100644 src/features/custom-trip/components/CustomTripDestinationsTab.tsx create mode 100644 src/features/custom-trip/components/CustomTripHotelsTab.tsx create mode 100644 src/features/custom-trip/components/CustomTripOrdersTab.tsx create mode 100644 src/features/custom-trip/components/CustomTripOriginsTab.tsx create mode 100644 src/features/custom-trip/components/CustomTripTransportsTab.tsx create mode 100644 src/features/custom-trip/services/custom-trip-api.ts create mode 100644 src/features/custom-trip/types.ts diff --git a/src/app/router.tsx b/src/app/router.tsx index 36de01e..a765a73 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -13,6 +13,8 @@ const OngoingToursPage = lazy(() => import('@/features/tours/OngoingToursPage')) const ServicesPage = lazy(() => import('@/features/services/ServicesPage')) const BillingPage = lazy(() => import('@/features/billing/BillingPage')) const SettingsPage = lazy(() => import('@/features/settings/SettingsPage')) +const CustomTripPage = lazy(() => import('@/features/custom-trip/CustomTripPage')) +const CustomTripDestinationDetailPage = lazy(() => import('@/features/custom-trip/CustomTripDestinationDetailPage')) export default function AppRouter() { return ( @@ -31,6 +33,13 @@ export default function AppRouter() { } /> } /> } /> + } /> + + {/* ماژول مستقل سفرهای اختصاصی (Custom Trip) */} + } /> + } /> + } /> + } /> } /> } /> diff --git a/src/components/shared/FilterSelect.tsx b/src/components/shared/FilterSelect.tsx index 21d3135..2255702 100644 --- a/src/components/shared/FilterSelect.tsx +++ b/src/components/shared/FilterSelect.tsx @@ -4,7 +4,6 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, } from '@/components/ui/select' import { Ic, type IconName } from '@/icons' import { cn } from '@/lib/utils' @@ -58,6 +57,10 @@ export default function FilterSelect({ } } + // نمایش تک‌آیکون دقیق و یکتا بدون هیچ‌گونه آیکون تکراری + const activeIcon = selectedOption?.icon || icon + const displayLabel = selectedOption ? selectedOption.label : placeholder + return (
{label && ( @@ -70,54 +73,71 @@ export default function FilterSelect({
- {icon && ( + {activeIcon && typeof activeIcon === 'string' ? ( - )} - {selectedOption?.icon && typeof selectedOption.icon === 'string' ? ( - ) : ( - selectedOption?.icon + activeIcon )} - + {displayLabel}
- - {options.map((option) => ( - -
- {option.icon && typeof option.icon === 'string' ? ( - - ) : ( - option.icon + + {options.map((option) => { + const isSelected = option.value === value + return ( + +
+ {option.icon && ( +
+ {typeof option.icon === 'string' ? ( + + ) : ( + option.icon + )} +
+ )} + {option.label} +
+ {option.badge !== undefined && ( + + {option.badge} + )} - {option.label} -
- {option.badge !== undefined && ( - - {option.badge} - - )} -
- ))} + + ) + })}
diff --git a/src/components/shared/MultiFilterSelect.tsx b/src/components/shared/MultiFilterSelect.tsx new file mode 100644 index 0000000..7a7322f --- /dev/null +++ b/src/components/shared/MultiFilterSelect.tsx @@ -0,0 +1,298 @@ +import React, { useState } from 'react' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Ic, type IconName } from '@/icons' +import { cn } from '@/lib/utils' + +export interface MultiFilterOption { + value: T + label: string + icon?: IconName | React.ReactNode + badge?: string | number + disabled?: boolean +} + +export interface MultiFilterSelectProps { + values: T[] + onChange: (values: T[]) => void + options: MultiFilterOption[] + placeholder?: string + label?: string + icon?: IconName + allValue?: T + selectedCountLabel?: (count: number) => string + clearLabel?: string + disabled?: boolean + className?: string + triggerClassName?: string + contentClassName?: string +} + +export default function MultiFilterSelect({ + values, + onChange, + options, + placeholder = 'انتخاب کنید...', + label, + icon, + allValue = 'all' as T, + selectedCountLabel, + clearLabel = 'پاک کردن فیلتر', + disabled = false, + className, + triggerClassName, + contentClassName, +}: MultiFilterSelectProps) { + const [open, setOpen] = useState(false) + + // آیا حالت "همه" انتخاب شده است؟ (یا لیست خالی است یا شامل allValue است) + const isAllSelected = values.length === 0 || values.includes(allValue) + const isFiltered = !isAllSelected && values.length > 0 + + // پیدا کردن گزینه All + const allOption = options.find((opt) => opt.value === allValue) + + // پیدا کردن گزینه‌های انتخاب‌شده فعلی (غیر از all) + const selectedOptions = options.filter( + (opt) => opt.value !== allValue && values.includes(opt.value) + ) + + // عنوان و آیکون نمایشی در تریگر + let displayLabel: string = placeholder + let activeIcon: IconName | React.ReactNode | undefined = icon + + if (isAllSelected) { + displayLabel = allOption ? allOption.label : placeholder + activeIcon = allOption?.icon || icon + } else if (selectedOptions.length === 1) { + displayLabel = selectedOptions[0].label + activeIcon = selectedOptions[0].icon || icon + } else if (selectedOptions.length > 1) { + displayLabel = selectedCountLabel + ? selectedCountLabel(selectedOptions.length) + : `${selectedOptions.length} مورد انتخاب‌شده` + activeIcon = icon || selectedOptions[0].icon + } + + // کلیک روی گزینه All + const handleSelectAll = () => { + onChange([allValue]) + } + + // کلیک روی یکی از گزینه‌های تکی + const handleToggleOption = (val: T) => { + if (isAllSelected) { + // اگر قبلاً همه انتخاب شده بود، فقط همین مورد جدید فعال شود + onChange([val]) + return + } + + if (values.includes(val)) { + // حذف این مورد + const next = values.filter((v) => v !== val) + if (next.length === 0) { + // اگر هیچ موردی نماند، خودکار به All برمی‌گردیم + onChange([allValue]) + } else { + onChange(next) + } + } else { + // اضافه کردن این مورد + onChange([...values.filter((v) => v !== allValue), val]) + } + } + + // پاک کردن تمام فیلترها و برگشت به All + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + onChange([allValue]) + } + + return ( +
+ {label && ( + + {label} + + )} + + + + + + + + {/* گزینه اول: انتخاب همه */} + {allOption && ( +
+
+ {allOption.icon && ( +
+ {typeof allOption.icon === 'string' ? ( + + ) : ( + allOption.icon + )} +
+ )} + {allOption.label} +
+ + {/* نشانگر تیک برای گزینه All */} +
+ {isAllSelected && } +
+
+ )} + + {/* جداکننده ظریف */} +
+ + {/* گزینه‌های تکی چندانتخابی */} +
+ {options + .filter((opt) => opt.value !== allValue) + .map((option) => { + const isSelected = !isAllSelected && values.includes(option.value) + + return ( +
!option.disabled && handleToggleOption(option.value)} + className={cn( + 'group flex items-center justify-between gap-2.5 rounded-xl px-2.5 py-1.5 text-xs transition-colors cursor-pointer select-none', + isSelected + ? 'bg-primary/10 text-primary font-bold' + : 'hover:bg-surface-2 text-foreground', + option.disabled && 'opacity-40 cursor-not-allowed pointer-events-none' + )} + > +
+ {option.icon && ( +
+ {typeof option.icon === 'string' ? ( + + ) : ( + option.icon + )} +
+ )} + {option.label} +
+ + {/* چک‌باکس تیک‌دار هماهنگ با سیستم دیزاین */} +
+ {isSelected && } +
+
+ ) + })} +
+ + {/* فوتر بازنشانی در صورت انتخاب هر یک از گزینه‌ها */} + {isFiltered && ( +
+ +
+ )} + + +
+ ) +} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 7e2a450..3e12997 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -48,7 +48,7 @@ function SelectContent({ {children} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 44a6551..cfdb211 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -48,7 +48,7 @@ function useSidebar() { } function SidebarProvider({ - defaultOpen = false, + defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, @@ -63,9 +63,15 @@ function SidebarProvider({ const isMobile = useIsMobile() const [openMobile, setOpenMobile] = React.useState(false) - const [_open, _setOpen] = React.useState(defaultOpen) - const [isPinned, setIsPinnedState] = React.useState(defaultOpen) - const isPinnedRef = React.useRef(defaultOpen) + const [_open, _setOpen] = React.useState(() => { + if (typeof window !== 'undefined') { + const match = document.cookie.match(new RegExp(`(^| )${SIDEBAR_COOKIE_NAME}=([^;]+)`)) + if (match) { + return match[2] === 'true' + } + } + return defaultOpen + }) const open = openProp ?? _open const setOpen = React.useCallback( @@ -78,72 +84,12 @@ function SidebarProvider({ [setOpenProp, open], ) - const setIsPinned = React.useCallback((value: boolean | ((prev: boolean) => boolean)) => { - setIsPinnedState((prev) => { - const next = typeof value === 'function' ? value(prev) : value - isPinnedRef.current = next - return next - }) - }, []) - - const hoverTimeoutRef = React.useRef | null>(null) - - const cancelCloseTimer = React.useCallback(() => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current) - hoverTimeoutRef.current = null - } - }, []) - - const scheduleCloseTimer = React.useCallback(() => { - cancelCloseTimer() - hoverTimeoutRef.current = setTimeout(() => { - if (!isPinnedRef.current) { - setOpen(false) - } - }, 300) - }, [cancelCloseTimer, setOpen]) - - const handleTriggerMouseEnter = React.useCallback(() => { - if (isMobile) return - cancelCloseTimer() - setOpen(true) - }, [isMobile, cancelCloseTimer, setOpen]) - - const handleTriggerMouseLeave = React.useCallback(() => { - if (isMobile || isPinnedRef.current) return - scheduleCloseTimer() - }, [isMobile, scheduleCloseTimer]) - - const handleSidebarMouseEnter = React.useCallback(() => { - if (isMobile) return - cancelCloseTimer() - }, [isMobile, cancelCloseTimer]) - - const handleSidebarMouseLeave = React.useCallback(() => { - if (isMobile || isPinnedRef.current) return - scheduleCloseTimer() - }, [isMobile, scheduleCloseTimer]) - - React.useEffect(() => { - return () => { - cancelCloseTimer() - } - }, [cancelCloseTimer]) - const toggleSidebar = React.useCallback(() => { - cancelCloseTimer() if (isMobile) { return setOpenMobile((o) => !o) } - - // اگر در وضعیت پین/فیکس بود، با کلیک مجدد بسته شده و به رفتار اولیه (هاور موقت) بازمی‌گردد. - // اگر فیکس نبود (حتی اگر با هاور باز شده باشد)، قفل/فیکس شده و باز می‌ماند. - const nextPinned = !isPinnedRef.current - isPinnedRef.current = nextPinned - setIsPinnedState(nextPinned) - setOpen(nextPinned) - }, [cancelCloseTimer, isMobile, setOpen, setOpenMobile]) + setOpen((o) => !o) + }, [isMobile, setOpen, setOpenMobile]) React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -158,6 +104,8 @@ function SidebarProvider({ const state = open ? 'expanded' : 'collapsed' + const noop = React.useCallback(() => {}, []) + const contextValue = React.useMemo( () => ({ state, @@ -166,13 +114,13 @@ function SidebarProvider({ openMobile, setOpenMobile, isMobile, - isPinned, - setIsPinned, + isPinned: open, + setIsPinned: setOpen, toggleSidebar, - handleTriggerMouseEnter, - handleTriggerMouseLeave, - handleSidebarMouseEnter, - handleSidebarMouseLeave, + handleTriggerMouseEnter: noop, + handleTriggerMouseLeave: noop, + handleSidebarMouseEnter: noop, + handleSidebarMouseLeave: noop, }), [ state, @@ -181,13 +129,8 @@ function SidebarProvider({ openMobile, setOpenMobile, isMobile, - isPinned, - setIsPinned, toggleSidebar, - handleTriggerMouseEnter, - handleTriggerMouseLeave, - handleSidebarMouseEnter, - handleSidebarMouseLeave, + noop, ], ) @@ -303,18 +246,6 @@ function Sidebar({ : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', className, )} - onMouseEnter={(event) => { - onMouseEnter?.(event) - if (autoCloseOnLeave) { - handleSidebarMouseEnter?.() - } - }} - onMouseLeave={(event) => { - onMouseLeave?.(event) - if (autoCloseOnLeave) { - handleSidebarMouseLeave?.() - } - }} {...props} >
) { - const { toggleSidebar, handleTriggerMouseEnter, handleTriggerMouseLeave, isPinned } = useSidebar() + const { toggleSidebar } = useSidebar() return (
- {/* نوار تب‌های تفکیک سرویس‌ها (Service Category Tabs) با اولویت محور اصلی تورها */} -
- {SERVICE_TABS.map((tab) => { - const isActive = selectedService === tab.id - return ( - - ) - })} -
{/* کارت‌های KPI شاخص‌های کلیدی مالی */}
@@ -465,12 +478,12 @@ export default function BillingPage() {
- {/* نوار جستجو و فیلترهای وضعیت لحظه‌ای */} + {/* نوار جستجو و فیلترهای دوگانه: ۱. نوع سرویس ۲. وضعیت صورت‌حساب */} -
+
{/* جستجوی متنی */} -
+
- {/* چیپ‌های فیلتر سریع وضعیت */} -
- {[ - { id: 'all', label: t('billing.allStatuses') }, - { id: 'pending', label: t('billing.statusPending') }, - { id: 'awaiting_payment', label: t('billing.statusAwaiting') }, - { id: 'approved', label: t('billing.statusApproved') }, - { id: 'rejected', label: t('billing.statusRejected') }, - { id: 'expired', label: t('billing.statusExpired') }, - { id: 'cancelled', label: t('billing.statusCancelled') }, - ].map((st) => { - const isSelected = selectedStatus === st.id - return ( - - ) - })} -
+ {/* فیلترهای دوگانه چندانتخابی مجزا: نوع سرویس و وضعیت صورت‌حساب */} +
+ {/* فیلتر چندگزینه‌ای نوع سرویس */} +
+ t('billing.selectedServicesCount', { count })} + clearLabel={t('billing.clearFilters')} + triggerClassName="h-9 text-xs rounded-xl bg-surface-2 border-border-soft" + /> +
- {/* دکمه بازنشانی فیلترها */} - {(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && ( - - )} + {/* فیلتر چندگزینه‌ای وضعیت صورت‌حساب */} +
+ t('billing.selectedStatusesCount', { count })} + clearLabel={t('billing.clearFilters')} + triggerClassName="h-9 text-xs rounded-xl bg-surface-2 border-border-soft" + /> +
+ + {/* دکمه بازنشانی فیلترها */} + {(searchQuery || + (!selectedStatuses.includes('all') && selectedStatuses.length > 0) || + (!selectedServices.includes('all') && selectedServices.length > 0)) && ( + + )} +
@@ -542,9 +560,11 @@ export default function BillingPage() { {t('billing.invoiceList')} - {selectedService !== 'all' && ( + {selectedServices.length > 0 && !selectedServices.includes('all') && ( - {t(`billing.tab${selectedService.charAt(0).toUpperCase() + selectedService.slice(1)}` as any) || selectedService} + {selectedServices.length === 1 + ? (serviceFilterOptions.find((opt) => opt.value === selectedServices[0])?.label || selectedServices[0]) + : t('billing.selectedServicesCount', { count: selectedServices.length })} )} @@ -568,16 +588,27 @@ export default function BillingPage() {

{t('billing.noFactorsFound')}

-

- {searchQuery || selectedStatus !== 'all' || selectedService !== 'all' - ? t('billing.noFactorsFilter') - : t('billing.noFactorsEmpty')} -

- {(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && ( - - )} + {(() => { + const isAnyFilterActive = + Boolean(searchQuery) || + (!selectedStatuses.includes('all') && selectedStatuses.length > 0) || + (!selectedServices.includes('all') && selectedServices.length > 0) + + return ( + <> +

+ {isAnyFilterActive + ? t('billing.noFactorsFilter') + : t('billing.noFactorsEmpty')} +

+ {isAnyFilterActive && ( + + )} + + ) + })()}
) : (
@@ -590,8 +621,9 @@ export default function BillingPage() { - {t('billing.id')} + {t('billing.id')} {t('billing.customer')} + {t('billing.factorNumber')} {t('billing.service')} {t('billing.amount')} {t('billing.liveStatus')} @@ -604,12 +636,12 @@ export default function BillingPage() { {isLoading ? ( Array.from({ length: 8 }).map((_, i) => ( - {/* شناسه */} + {/* ۱. شناسه */} - + - {/* خریدار */} + {/* ۲. خریدار */}
@@ -620,32 +652,37 @@ export default function BillingPage() {
- {/* سرویس */} + {/* ۳. شماره فاکتور */} + + + + + {/* ۴. سرویس */} - {/* مبلغ */} + {/* ۵. مبلغ */} - {/* وضعیت */} + {/* ۶. وضعیت */} - {/* تصویر فیش */} + {/* ۷. تصویر فیش */} - {/* تاریخ */} + {/* ۸. تاریخ و ساعت */} - + - {/* عملیات */} + {/* ۹. عملیات */}
@@ -666,44 +703,63 @@ export default function BillingPage() { isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : '' }`} > - {/* شناسه */} - - #{factor.id} + {/* ۱. شناسه دیتابیس (بدون هشتگ و فونت ریزتر) */} + + {factor.id} - {/* مشخصات مشتری و خریدار */} + {/* ۲. مشخصات مشتری و خریدار (نام و اطلاعات تماس بولدتر) */}
- -
-

+ +

+

{user?.fullname || '—'}

-

+

{user?.phone_number || user?.email || '—'}

- {/* سرویس */} + {/* ۳. شماره فاکتور (بین مشتری و سرویس، فونت بزرگتر، کلیک جهت کپی) */} + + {factor.factor_number ? ( + + ) : ( + — + )} + + + {/* ۴. سرویس */} - {/* مبلغ */} + {/* ۵. مبلغ */} ${Number(factor.amount).toLocaleString()} - {/* وضعیت لحظه‌ای */} + {/* ۶. وضعیت لحظه‌ای */} - {/* پیش‌نمایش تصویر فیش */} + {/* ۷. پیش‌نمایش تصویر فیش */} {hasReceipt ? (
- صورت‌حساب شماره #{activeFactor.id} + صورت‌حساب شماره #{activeFactor.factor_number || activeFactor.id} + {activeFactor.factor_number && ( + + (شناسه: #{activeFactor.id}) + + )} ثبت شده در: {formatDateTimeByLanguage(activeFactor.created_at, language as any)} @@ -300,6 +373,23 @@ export function BillingDetailModal({
+ {/* هشدار عدم امکان تایید صورت‌حساب بدون بارگذاری بلیت */} + {isApprovedSelectedWithoutTicket && ( +
+
+ +
+
+

+ {t('billing.ticketRequiredForApprovedTitle')} +

+

+ {t('billing.ticketRequiredForApprovedDesc')} +

+
+
+ )} + {/* کادر ویرایش/مشاهده دلیل رد صورت‌حساب در صورت انتخاب وضعیت رد شده */} {selectedStatus === 'rejected' && (
@@ -466,7 +556,21 @@ export function BillingDetailModal({
)} -
+
+ {activeFactor.factor_number && ( +
+ {t('billing.factorNumber')}: + + #{activeFactor.factor_number} + +
+ )} +
+ {t('billing.id')}: + + #{activeFactor.id} + +
شناسه سفارش مرجع: @@ -489,7 +593,7 @@ export function BillingDetailModal({ - ریز اقلام و مشخصات سرویس ({activeFactor.service.toUpperCase()}) + ریز اقلام و مشخصات سرویس ({activeFactor.service?.toUpperCase() || ''}) @@ -520,7 +624,9 @@ export function BillingDetailModal({ بزرگسال ({detail.passenger_data.adults?.count} نفر): - {detail.passenger_data.adults?.names.join('، ')} + {Array.isArray(detail.passenger_data.adults?.names) + ? detail.passenger_data.adults?.names.join('، ') + : String(detail.passenger_data.adults?.names || '')}
)} @@ -532,7 +638,9 @@ export function BillingDetailModal({ کودک ({detail.passenger_data.children?.count} نفر): - {detail.passenger_data.children?.names.join('، ')} + {Array.isArray(detail.passenger_data.children?.names) + ? detail.passenger_data.children?.names.join('، ') + : String(detail.passenger_data.children?.names || '')}
)} @@ -544,7 +652,9 @@ export function BillingDetailModal({ نوزاد ({detail.passenger_data.infants?.count} نفر): - {detail.passenger_data.infants?.names.join('، ')} + {Array.isArray(detail.passenger_data.infants?.names) + ? detail.passenger_data.infants?.names.join('، ') + : String(detail.passenger_data.infants?.names || '')}
)} @@ -668,6 +778,144 @@ export function BillingDetailModal({ + {/* کارت مدیریت فایل بلیت (ویژه تور و سفرهای اختصاصی) */} + {isTourOrCustomTrip && ( + + + +
+ +
+ {t('billing.ticketCardTitle')} +
+ {activeFactor.ticket_file ? ( + + + {t('billing.ticketIssuedBadge')} + + ) : ( + + + {t('billing.ticketPendingBadge')} + + )} +
+ + + {activeFactor.ticket_file ? ( +
+
+
+ +
+
+

+ {t('billing.ticketIssuedTitle')} +

+

+ {activeFactor.ticket_file.split('/').pop() || activeFactor.ticket_file} +

+
+
+
+ + + +
+
+ ) : ( +
{ + e.preventDefault() + setIsDraggingTicket(true) + }} + onDragLeave={() => setIsDraggingTicket(false)} + onDrop={(e) => { + e.preventDefault() + setIsDraggingTicket(false) + const file = e.dataTransfer.files?.[0] + if (file) handleUploadTicketFile(file) + }} + className={`flex flex-col items-center justify-center p-6 rounded-xl border border-dashed transition-all duration-200 text-center space-y-3 ${ + isDraggingTicket + ? 'border-primary bg-primary/10 ring-2 ring-primary/20' + : 'border-border/80 bg-muted/20 hover:border-primary/50 hover:bg-muted/30' + }`} + > +
+ +
+
+

+ {t('billing.ticketUploadPrompt')} +

+

+ {t('billing.ticketDragDropPrompt')} +

+

+ {t('billing.ticketSupportedFormats')} +

+
+ + {!isApproved && ( +
+ + {t('billing.ticketAutoApproveNotice')} +
+ )} +
+ )} +
+
+ )} + {/* کارت تصویر فیش پرداختی آپلود شده توسط کاربر */} @@ -820,12 +1068,25 @@ export function BillingDetailModal({ + + ) + } + + return ( +
+ {/* Top Breadcrumb Navigation */} +
+
+ + + شهرهای مقصد + + / + + {destination.flag || '📍'} + {destination.name} + +
+ +
+ + + {destination.isActive ? 'مقصد فعال و قابل رزرو' : 'مقصد غیرفعال'} + +
+
+ + {/* Tabs Navigation Header: 1. Info & Description | 2. Hotels | 3. Transportation */} +
+ {/* Tab 1: City Info & Custom Trip Description */} + + + {/* Tab 2: Hotels */} + + + {/* Tab 3: Transportation */} + +
+ + {/* ========================================================================= */} + {/* TAB 1: DESTINATION INFO & DESCRIPTION (صفحه اول: مشخصات و توضیحات شهر) */} + {/* ========================================================================= */} + {currentTab === 'info' && ( +
+
+
+
+
+

+ {destFlag || '📍'} + اطلاعات و شناسنامه شهر {destination.name} +

+

+ ویرایش مشخصات اصلی، موقعیت کشور، اماکن مقدسه و توضیحات شهر برای ماژول سفر اختصاصی (Custom Trip). +

+
+ + {isDestSavedSuccess && ( +
+ + تغییرات با موفقیت ذخیره شد +
+ )} +
+ + {/* Form Grid */} +
+ {/* 1. Country */} +
+ + +
+ + {/* 2. City Name FA */} +
+ + setDestName(e.target.value)} + placeholder="مثال: کربلای معلی، نجف اشرف، مشهد مقدس..." + className="h-11 rounded-xl text-xs sm:text-sm" + required + /> +
+ + {/* 3. City Name EN */} +
+ + setDestNameEn(e.target.value)} + placeholder="e.g. Karbala, Najaf, Mashhad..." + className="h-11 rounded-xl text-xs sm:text-sm font-mono" + /> +
+ + {/* 4. Active Status Toggle */} +
+ + +
+
+ + {/* 5. Holy Places */} +
+ + setDestHolyPlaces(e.target.value)} + placeholder="مثال: حرم مطهر امام حسین (ع)، حرم حضرت ابوالفضل العباس (ع)، تل زینبیه، مقام علی‌اکبر (ع)..." + className="h-11 rounded-xl text-xs sm:text-sm" + /> +
+ + {/* 6. City Description for Custom Trip (توضیح برای آن شهر که در Custom Trip در توضیحات شهر قرار داده می‌شود) */} +
+
+ + + این متن در اپلیکیشن هنگام انتخاب این مقصد به زائر نمایش داده می‌شود. + +
+