diff --git a/src/app/router.tsx b/src/app/router.tsx index d8bfd92..3a3228f 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,6 +6,8 @@ import RequireAuth from '@/features/auth/RequireAuth' /* لود صفحات اصلی */ const LoginPage = lazy(() => import('@/features/auth/LoginPage')) const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome')) +const ToursPage = lazy(() => import('@/features/tours/ToursPage')) +const TourOrdersPage = lazy(() => import('@/features/tours/TourOrdersPage')) export default function AppRouter() { return ( @@ -19,6 +21,8 @@ export default function AppRouter() { {/* تمام مسیرهای محافظت‌شده پشت گارد امنیتی */} }> } /> + } /> + } /> {/* هدایت مسیرهای ناشناخته به صفحه اصلی */} } /> @@ -28,3 +32,4 @@ export default function AppRouter() { ) } + diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 313763a..7d52788 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -18,15 +18,11 @@ import { navigationConfig } from '@/config/navigation' import { siteConfig } from '@/config/site' import { AqilaLogo } from '@/components/AqilaLogo' -type NavItemProps = { - to?: string - icon: IconName - label: string - badge?: string - soon?: boolean -} +import * as React from 'react' +import { cn } from '@/lib/utils' +import type { NavItemConfig } from '@/config/navigation' -function Tail({ badge, soon }: Pick) { +function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { if (soon) { return ( @@ -38,25 +34,90 @@ function Tail({ badge, soon }: Pick) { return null } -function NavItem({ to, icon, label, badge, soon }: NavItemProps) { +function NavItem({ item }: { item: NavItemConfig }) { const { pathname } = useLocation() - const isActive = to ? (to === '/' ? pathname === '/' : pathname.startsWith(to)) : false + const hasChildren = Boolean(item.children && item.children.length > 0) + const isChildActive = hasChildren && item.children?.some((c) => pathname.startsWith(c.to)) + const [isOpen, setIsOpen] = React.useState(Boolean(isChildActive || pathname.startsWith('/tours'))) + + React.useEffect(() => { + if (isChildActive) { + setIsOpen(true) + } + }, [isChildActive]) + + if (hasChildren && item.children) { + return ( + + setIsOpen((prev) => !prev)} + className={cn( + 'w-full justify-between transition-colors', + isChildActive && 'text-primary font-semibold' + )} + tooltip={item.title} + > +
+ + {item.title} +
+
+ + +
+
+ + {isOpen && ( +
+ {item.children.map((sub) => { + const isSubActive = sub.to === '/tours' ? pathname === '/tours' : pathname.startsWith(sub.to) + return ( + + + {sub.title} + {sub.badge && ( + + {sub.badge} + + )} + + ) + })} +
+ )} +
+ ) + } + + const isActive = item.to ? (item.to === '/' ? pathname === '/' : pathname.startsWith(item.to)) : false const inner = ( <> - - {label} - + + {item.title} + ) return ( - {to ? ( - - {inner} + {item.to ? ( + + {inner} ) : ( - + {inner} )} @@ -64,6 +125,7 @@ function NavItem({ to, icon, label, badge, soon }: NavItemProps) { ) } + function SidebarUserFooter() { const { user } = useAuth() const logout = useLogout() @@ -125,14 +187,7 @@ export default function AppSidebar() { {group.title ? {group.title} : null} {group.items.map((item) => ( - + ))} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..71e95fc --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/config/navigation.ts b/src/config/navigation.ts index b3945e7..de150a6 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -1,11 +1,19 @@ import type { IconName } from '@/icons' +export interface NavSubItemConfig { + title: string + to: string + icon: IconName + badge?: string +} + export interface NavItemConfig { title: string to?: string icon: IconName badge?: string soon?: boolean + children?: NavSubItemConfig[] } export interface NavGroupConfig { @@ -18,6 +26,14 @@ export const navigationConfig: NavGroupConfig[] = [ title: '', items: [ { title: 'داشبورد', to: '/', icon: 'home' }, + { + title: 'تورها', + icon: 'map', + children: [ + { title: 'فهرست تورها', to: '/tours', icon: 'list' }, + { title: 'سفارشات و رزروها', to: '/tours/orders', icon: 'clipboardCheck' }, + ], + }, ], }, ] diff --git a/src/features/tours/TourOrdersPage.tsx b/src/features/tours/TourOrdersPage.tsx new file mode 100644 index 0000000..04a3ac7 --- /dev/null +++ b/src/features/tours/TourOrdersPage.tsx @@ -0,0 +1,548 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react' +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog' +import { Ic } from '@/icons' +import { fetchTourOrders, updateTourOrder } from './services/tour-api' +import type { TourOrderItem, TourOrderStatus } from './types' +import { TourStatusBadge } from './components/TourStatusBadge' + +export default function TourOrdersPage() { + const [orders, setOrders] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Filters + const [searchQuery, setSearchQuery] = useState('') + const [selectedStatus, setSelectedStatus] = useState('') + + // Receipt Modal + const [previewReceiptUrl, setPreviewReceiptUrl] = useState(null) + const [previewReceiptOrderId, setPreviewReceiptOrderId] = useState(null) + const [previewReceiptVerified, setPreviewReceiptVerified] = useState(false) + + // Order Details Modal + const [detailOrder, setDetailOrder] = useState(null) + + // Updating action state + const [updatingId, setUpdatingId] = useState(null) + + const loadOrders = useCallback(async () => { + setIsLoading(true) + setError(null) + try { + const res = await fetchTourOrders({ + search: searchQuery || undefined, + status: selectedStatus || undefined, + }) + setOrders(res.results || []) + setTotalCount(res.count || 0) + } catch (err: any) { + setError(err?.message || 'خطا در برقراری ارتباط با سرور و دریافت سفارشات.') + } finally { + setIsLoading(false) + } + }, [searchQuery, selectedStatus]) + + useEffect(() => { + loadOrders() + }, [loadOrders]) + + // Statistics KPI calculations + const stats = useMemo(() => { + const total = orders.length + const approved = orders.filter((o) => o.status === 'APPROVED').length + const pending = orders.filter( + (o) => o.status === 'PENDING' || o.status === 'AWAITING_PAYMENT' + ).length + const totalAmount = orders.reduce((sum, o) => { + const val = parseFloat(o.total_price || '0') + return sum + (isNaN(val) ? 0 : val) + }, 0) + + return { total, approved, pending, totalAmount } + }, [orders]) + + const handleUpdateStatus = async (orderId: number, newStatus: TourOrderStatus) => { + setUpdatingId(orderId) + try { + const updated = await updateTourOrder(orderId, { status: newStatus }) + setOrders((prev) => prev.map((o) => (o.id === orderId ? { ...o, status: updated.status } : o))) + if (detailOrder && detailOrder.id === orderId) { + setDetailOrder((prev) => (prev ? { ...prev, status: updated.status } : null)) + } + } catch (err: any) { + alert(`خطا در ویرایش وضعیت سفارش: ${err?.message || 'مشکل غیرمنتظره'}`) + } finally { + setUpdatingId(null) + } + } + + const handleToggleReceiptVerify = async (orderId: number, currentVerified: boolean) => { + setUpdatingId(orderId) + try { + const updated = await updateTourOrder(orderId, { + receipt_verified: !currentVerified, + status: !currentVerified ? 'APPROVED' : undefined, + }) + setOrders((prev) => + prev.map((o) => + o.id === orderId + ? { ...o, receipt_verified: updated.receipt_verified, status: updated.status } + : o + ) + ) + setPreviewReceiptVerified(updated.receipt_verified) + } catch (err: any) { + alert(`خطا در اعتبارسنجی فیش: ${err?.message || 'خطای سرور'}`) + } finally { + setUpdatingId(null) + } + } + + return ( +
+ {/* Page Header */} +
+
+

+ سفارشات و رزروهای تور +

+

+ پیگیری سفارشات، نظارت بر پرداخت‌ها و اعتبارسنجی مدارک و فیش‌های واریزی مسافران +

+
+ +
+ +
+
+ + {/* KPI Cards */} +
+ + +
+

کل سفارشات ثبت‌شده

+

+ {isLoading ? '...' : totalCount} +

+
+
+ +
+
+
+ + + +
+

رزروهای تأییدشده

+

+ {isLoading ? '...' : stats.approved} +

+
+
+ +
+
+
+ + + +
+

در انتظار بررسی و پرداخت

+

+ {isLoading ? '...' : stats.pending} +

+
+
+ +
+
+
+ + + +
+

مجموع ارزش مالی

+

+ {isLoading ? '...' : `$${stats.totalAmount.toLocaleString()}`} +

+
+
+ +
+
+
+
+ + {/* Filter and Search Bar */} + + +
+ + setSearchQuery(e.target.value)} + className="ps-9 bg-surface-2 border-border/50 text-xs rounded-xl" + /> +
+ + {/* Status Filter */} +
+ {[ + { id: '', label: 'همه سفارشات' }, + { id: 'APPROVED', label: 'تأییدشده' }, + { id: 'PENDING', label: 'در انتظار بررسی' }, + { id: 'AWAITING_PAYMENT', label: 'منتظر پرداخت' }, + { id: 'REJECTED', label: 'ردشده' }, + ].map((st) => ( + + ))} +
+
+
+ + {/* Orders Table */} + +
+ + + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + )) + ) : error ? ( + + + + ) : orders.length === 0 ? ( + + + + ) : ( + orders.map((order) => { + const isUpdating = updatingId === order.id + return ( + + {/* ID */} + + + {/* User */} + + + {/* Tour Title */} + + + {/* Total Price */} + + + {/* Payment Method */} + + + {/* Payment Receipt */} + + + {/* Status */} + + + {/* Date */} + + + {/* Actions */} + + + ) + }) + )} + +
کد سفارشکاربرتور مقصدمبلغ کلروش پرداختفیش واریزیوضعیت رزروتاریخ ثبتعملیات
+ در حال دریافت لیست سفارشات از دیتابیس... +
+ {error} +
+ +

هیچ سفارشی با مشخصات انتخابی یافت نشد.

+
+ #{order.id} + + {order.user || 'نامشخص'} + + {order.tour_title || order.tour_slug || '-'} + + ${parseFloat(order.total_price || '0').toLocaleString()} + + {order.payment_method || (order.is_paid ? 'آنلاین' : 'کارت به کارت / فیش')} + + {order.payment_receipt ? ( +
+ + + {order.receipt_verified ? 'تأییدشده' : 'بررسی نشده'} + +
+ ) : ( + بدون فیش + )} +
+ + + {order.created ? new Date(order.created).toLocaleDateString('fa-IR') : '-'} + +
+ + + {order.status !== 'APPROVED' && ( + + )} + + {order.status !== 'REJECTED' && ( + + )} +
+
+
+
+ + {/* Receipt Image Preview Dialog */} + !op && setPreviewReceiptUrl(null)} + > + + + + فیش واریزی بانکی سفارش #{previewReceiptOrderId} + + + بررسی و تطبیق شماره پیگیری، مبلغ واریزی و صحت تراکنش + + + +
+ {previewReceiptUrl && ( + فیش واریزی بانکی + )} +
+ + +
+ وضعیت فیش: + + {previewReceiptVerified ? 'تأییدشده' : 'در انتظار تأیید'} + +
+ +
+ + + + {previewReceiptOrderId && ( + + )} +
+
+
+
+ + {/* Order Detail Modal */} + !op && setDetailOrder(null)}> + + + + جزئیات سفارش تور #{detailOrder?.id} + + + اطلاعات کامل رزرو، خریدار و پارامترهای مالی + + + + {detailOrder && ( +
+
+
+

نام کاربر ثبت‌کننده

+

{detailOrder.user}

+
+
+

عنوان تور

+

+ {detailOrder.tour_title} +

+
+
+

قیمت پایه هر نفر

+

+ ${parseFloat(detailOrder.price || '0').toLocaleString()} +

+
+
+

مبلغ کل پرداختی

+

+ ${parseFloat(detailOrder.total_price || '0').toLocaleString()} +

+
+
+ + {detailOrder.request_details && ( +
+

یادداشت و جزئیات درخواست مسافر:

+

+ {detailOrder.request_details} +

+
+ )} + +
+
+ وضعیت فعلی سفارش: + +
+
+ تراکنش مالی: + + {detailOrder.is_paid ? 'پرداخت قطعی' : 'پرداخت‌نشده / نیازمند بررسی'} + +
+
+
+ )} + + + + + + +
+
+
+ ) +} diff --git a/src/features/tours/ToursPage.tsx b/src/features/tours/ToursPage.tsx new file mode 100644 index 0000000..e5d7da8 --- /dev/null +++ b/src/features/tours/ToursPage.tsx @@ -0,0 +1,556 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { Ic } from '@/icons' +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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +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' + +export default function ToursPage() { + const [tours, setTours] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isRefreshing, setIsRefreshing] = useState(false) + const [error, setError] = useState(null) + + // فیلترها و مرتب‌سازی + const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState('ALL') + const [tripStatusFilter, setTripStatusFilter] = useState('ALL') + const [countryFilter, setCountryFilter] = useState('ALL') + const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid') + const [languageCode, setLanguageCode] = useState('') + + // وضعیت شیت جزئیات تور + 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 loadData = async (showRefresh = false) => { + if (showRefresh) setIsRefreshing(true) + else setIsLoading(true) + setError(null) + + try { + const res = await fetchTours({ + limit: 100, + language_code: languageCode || undefined, + }) + setTours(res.results || []) + } catch (err: any) { + setError(err.message || 'خطا در برقراری ارتباط با وب‌سرویس تورها') + } finally { + setIsLoading(false) + setIsRefreshing(false) + } + } + + useEffect(() => { + loadData() + }, [languageCode]) + + // فیلتر و جستجوی کلاینت + const filteredTours = useMemo(() => { + return tours.filter((tour) => { + const matchSearch = + !searchTerm.trim() || + tour.title.toLowerCase().includes(searchTerm.toLowerCase()) || + tour.slug.toLowerCase().includes(searchTerm.toLowerCase()) + + const matchStatus = statusFilter === 'ALL' || tour.status === statusFilter + const matchTripStatus = tripStatusFilter === 'ALL' || tour.trip_status === tripStatusFilter + const matchCountry = countryFilter === 'ALL' || tour.destination_country === countryFilter + + return matchSearch && matchStatus && matchTripStatus && matchCountry + }) + }, [tours, searchTerm, statusFilter, tripStatusFilter, countryFilter]) + + // شاخص‌های کلیدی آماری (KPI Metrics) + const stats = useMemo(() => { + const total = tours.length + const available = tours.filter((t) => t.status === 'AVAILABLE').length + const soldOut = tours.filter((t) => t.status === 'SOLD_OUT').length + const traveling = tours.filter((t) => t.trip_status === 'TRAVELING').length + const pending = tours.filter((t) => t.trip_status === 'PENDING').length + + return { total, available, soldOut, traveling, pending } + }, [tours]) + + const handleOpenDetail = (id: number) => { + setSelectedTourId(id) + setIsDetailOpen(true) + } + + // محاسبه طول مدت سفر به روز + const calculateDays = (start: string, end: string) => { + if (!start || !end) return '-' + const s = new Date(start).getTime() + const e = new Date(end).getTime() + const diff = Math.ceil((e - s) / (1000 * 3600 * 24)) + return diff > 0 ? `${diff} روز` : '۱ روز' + } + + return ( +
+ {/* سربرگ مدیریت تورها */} +
+
+
+
+ +
+

مدیریت تورها

+ + {stats.total} تور ثبت‌شده + +
+

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

+
+ +
+ {/* انتخاب زبان */} + + + {/* تغییر نمای شبکه‌ای / جدولی */} +
+ + +
+ + {/* دکمه بروزرسانی زنده */} + +
+
+ + {/* کارت‌های شاخص‌های کلیدی آماری (KPI Cards) */} +
+
+
+ کل تورها +
+ +
+
+
{stats.total}
+
تورهای منتشرشده
+
+ +
+
+ در دسترس +
+ +
+
+
+ {stats.available} +
+
آماده ثبت‌نام مسافر
+
+ +
+
+ تکمیل ظرفیت +
+ +
+
+
+ {stats.soldOut} +
+
ظرفیت تکمیل شده
+
+ +
+
+ در حال اجرا +
+ +
+
+
+ {stats.traveling} +
+
سفرهای جاری
+
+ +
+
+ در انتظار شروع +
+ +
+
+
+ {stats.pending} +
+
تاریخ شروع در آینده
+
+
+ + {/* نوار فیلتر و جستجو */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="جستجوی تور با عنوان یا اسلاگ..." + className="pr-9 text-xs bg-surface-base" + /> +
+ +
+ {/* وضعیت فروش */} + + + {/* وضعیت اجرا */} + + + {/* کشور مقصد */} + + + {(searchTerm || statusFilter !== 'ALL' || tripStatusFilter !== 'ALL' || countryFilter !== 'ALL') && ( + + )} +
+
+ + {/* وضعیت لودینگ */} + {isLoading ? ( + viewMode === 'grid' ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + +
+ + +
+
+ ))} +
+ ) : ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + ) : error ? ( + /* وضعیت خطا */ +
+ +

خطا در بارگذاری فهرست تورها

+

{error}

+ +
+ ) : filteredTours.length === 0 ? ( + /* وضعیت خالی (Empty State) */ +
+
+ +
+

توری یافت نشد

+

+ هیچ توری با فیلترها و عبارت جستجوی انتخابی شما همخوانی ندارد. +

+
+ ) : viewMode === 'grid' ? ( + /* نمای شبکه‌ای (Cards Grid View) */ +
+ {filteredTours.map((tour) => { + const imgUrl = + tour.image?.image_url?.medium || + tour.image?.image_url?.large || + tour.image?.image_url?.original || + '/placeholder.jpg' + + return ( +
+
+ {/* تصویر کاور تور با نشان کشور و وضعیت */} +
+ {tour.title} { + ;(e.target as HTMLImageElement).src = + 'data:image/svg+xml;utf8,Aqila Tour' + }} + /> +
+ + {/* بج کشور مقصد */} +
+ +
+ + {/* وضعیت تجاری تور */} +
+ + +
+
+ + {/* بدنه کارت */} +
+
+
+ #{tour.id} + {calculateDays(tour.started_at, tour.ended_at)} +
+

+ {tour.title} +

+
+ + {/* تاریخ برگزاری */} +
+ + {tour.started_at} الی {tour.ended_at} +
+ + {/* قیمت پایه و رده قیمتی */} +
+ قیمت بزرگسال: +
+ ${parseFloat(tour.price).toLocaleString()} +
+
+
+
+ + {/* فوتر کارت و دکمه جزئیات */} +
+ + +
+
+ ) + })} +
+ ) : ( + /* نمای جدول (Data Table View) */ +
+ + + + شناسه + عنوان تور + مقصد + تاریخ برگزاری + قیمت بزرگسال + وضعیت فروش + وضعیت سفر + عملیات + + + + {filteredTours.map((tour) => ( + handleOpenDetail(tour.id)} + > + #{tour.id} + +
{tour.title}
+
{tour.slug}
+
+ + + + + {tour.started_at} ~ {tour.ended_at} + + + ${parseFloat(tour.price).toLocaleString()} + + + + + + + + +
+ + +
+
+
+ ))} +
+
+
+ )} + + {/* شیت جزئیات کامل تور */} + setIsDetailOpen(false)} + /> + + {/* دیالوگ مدیریت ترجمه‌های ۵ زبانه */} + loadData()} + /> +
+ ) +} + diff --git a/src/features/tours/components/TourDetailSheet.tsx b/src/features/tours/components/TourDetailSheet.tsx new file mode 100644 index 0000000..3e616ed --- /dev/null +++ b/src/features/tours/components/TourDetailSheet.tsx @@ -0,0 +1,525 @@ +import React, { useState, useEffect } from 'react' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@/components/ui/sheet' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Skeleton } from '@/components/ui/skeleton' +import { Button } from '@/components/ui/button' +import { Ic } from '@/icons' +import { + fetchTourDetail, + fetchTourItineraries, + fetchTourPassengers, + fetchTourComments, +} from '../services/tour-api' +import type { + TourDetailItem, + TourItineraryItem, + TourPassengerGroupItem, + TourCommentItem, +} from '../types' +import { TourStatusBadge, CountryFlagBadge } from './TourStatusBadge' +import { TourTranslationsDialog } from './TourTranslationsDialog' + +interface TourDetailSheetProps { + tourId: number | null + isOpen: boolean + onClose: () => void +} + +export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProps) { + const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'passengers' | 'features' | 'comments'>('overview') + const [isTransDialogOpen, setIsTransDialogOpen] = useState(false) + + const [tour, setTour] = useState(null) + const [itineraries, setItineraries] = useState([]) + const [passengersGroup, setPassengersGroup] = useState([]) + const [comments, setComments] = useState([]) + + const [isLoading, setIsLoading] = useState(false) + const [isLoadingSub, setIsLoadingSub] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!tourId || !isOpen) { + setTour(null) + return + } + + let isMounted = true + setIsLoading(true) + setError(null) + setActiveTab('overview') + + fetchTourDetail(tourId) + .then((data) => { + if (isMounted) { + setTour(data) + setIsLoading(false) + } + }) + .catch((err: any) => { + if (isMounted) { + setError(err.message || 'خطا در دریافت اطلاعات تور') + setIsLoading(false) + } + }) + + return () => { + isMounted = false + } + }, [tourId, isOpen]) + + // لود داده‌های مربوط به تب‌های فرعی هنگام انتخاب + useEffect(() => { + if (!tourId || !isOpen || !tour) return + + let isMounted = true + + if (activeTab === 'itinerary' && itineraries.length === 0) { + setIsLoadingSub(true) + fetchTourItineraries(tourId) + .then((res) => { + if (isMounted) { + setItineraries(res.results || []) + setIsLoadingSub(false) + } + }) + .catch(() => isMounted && setIsLoadingSub(false)) + } else if (activeTab === 'passengers' && passengersGroup.length === 0) { + setIsLoadingSub(true) + fetchTourPassengers(tourId) + .then((res) => { + if (isMounted) { + setPassengersGroup(res.results || []) + setIsLoadingSub(false) + } + }) + .catch(() => isMounted && setIsLoadingSub(false)) + } else if (activeTab === 'comments' && comments.length === 0 && tour.slug) { + setIsLoadingSub(true) + fetchTourComments(tour.slug) + .then((res) => { + if (isMounted) { + setComments(res.results || []) + setIsLoadingSub(false) + } + }) + .catch(() => isMounted && setIsLoadingSub(false)) + } + + return () => { + isMounted = false + } + }, [activeTab, tourId, isOpen, tour]) + + return ( + !open && onClose()}> + + +
+
+
+ + {tour?.title || 'جزئیات تور'} + +
+ + کد شناسایی: #{tourId} | اسلاگ سیستم: {tour?.slug} + +
+ {tour && ( +
+ +
+ + +
+
+ )} +
+ + setActiveTab(val)} + className="mt-4 w-full" + > + + مشخصات + برنامه سفر + مسافران + امکانات + نظرات + + +
+ +
+ {isLoading ? ( +
+ +
+ + +
+ +
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : tour ? ( + <> + {/* تب ۱: مشخصات کلی و مالی */} + {activeTab === 'overview' && ( +
+ {/* گالری تصاویر */} + {tour.images && tour.images.length > 0 && ( +
+

گالری تصاویر تور

+
+ {tour.images.map((img, i) => ( +
+ +
+ ))} +
+
+ )} + + {/* توضیحات تور */} +
+

درباره این تور

+

+ {tour.description} +

+
+ + {/* بازه تاریخ و ظرفیت */} +
+
+
تاریخ شروع
+
{tour.started_at}
+
+
+
تاریخ پایان
+
{tour.ended_at}
+
+
+
ظرفیت کل
+
{tour.capacity} نفر
+
+
+
فروخته شده
+
{tour.number_sold} نفر
+
+
+ + {/* نوار تکمیل ظرفیت */} +
+
+ وضعیت تکمیل ظرفیت + + {Math.round((tour.number_sold / (tour.capacity || 1)) * 100)}% + +
+
+
+
+
+ ظرفیت باقیمانده: {Math.max(0, tour.capacity - tour.number_sold)} صندلی + ظرفیت کل: {tour.capacity} +
+
+ + {/* تفکیک نرخ‌ها و قیمت‌گذاری */} +
+

جدول نرخ‌های مصوب

+
+
+ بزرگسال (Adult 12+) +
+ ${parseFloat(tour.price).toLocaleString()} +
+ {tour.percent_off > 0 && ( +
+ + {tour.percent_off}% تخفیف + + + نهایی: ${parseFloat(tour.final_price).toLocaleString()} + +
+ )} +
+ +
+ کودک (Child 2-12) +
+ ${parseFloat(tour.price_child || '0').toLocaleString()} +
+ صندلی و خدمات کودک +
+ +
+ نوزاد (Infant <2) +
+ ${parseFloat(tour.price_infant || '0').toLocaleString()} +
+ بیمه و ترانسفر نوزاد +
+
+
+
+ )} + + {/* تب ۲: مراحل سفر روزانه (Itinerary) */} + {activeTab === 'itinerary' && ( +
+ {isLoadingSub ? ( +
+ + + +
+ ) : itineraries.length === 0 ? ( +
+ +

برنامه زمان‌بندی روزانه‌ای برای این تور ثبت نشده است.

+
+ ) : ( +
+ {itineraries.map((step, idx) => ( +
+
+
+ + {idx + 1} + +
{step.title}
+
+
+ {step.started_at ? new Date(step.started_at).toLocaleDateString('fa-IR') : '-'} +
+
+

+ {step.summary} +

+
+ ))} +
+ )} +
+ )} + + {/* تب ۳: مسافران (Passengers) */} + {activeTab === 'passengers' && ( +
+ {isLoadingSub ? ( +
+ + +
+ ) : passengersGroup.length === 0 ? ( +
+ +

هنوز مسافری برای این تور به ثبت نرسیده است.

+
+ ) : ( +
+ {passengersGroup.map((group, gIdx) => ( +
+
+
+ سرپرست رزرو: {group.user} +
+ + {group.passengers?.length || 0} مسافر + +
+ +
+ {group.passengers?.map((p) => ( +
+
{p.fullname}
+
+ گذرنامه: + {p.passport_number} +
+
+ تولد: + {p.birthdate} +
+
+ تماس: + {p.phone_number} +
+
+ ))} +
+
+ ))} +
+ )} +
+ )} + + {/* تب ۴: امکانات و نکات سفر */} + {activeTab === 'features' && ( +
+ {/* ویژگی‌ها */} +
+

ویژگی‌ها و خدمات گنجانده‌شده

+ {tour.tour_features && tour.tour_features.length > 0 ? ( +
+ {tour.tour_features.map((feat) => ( +
+ + {feat.title} +
+ ))} +
+ ) : ( +

ویژگی خاصی درج نشده است.

+ )} +
+ + {/* نکات سفر */} +
+

راهنما و توصیه‌های سفر

+ {tour.travel_tips && tour.travel_tips.length > 0 ? ( +
+ {tour.travel_tips.map((tip) => ( +
+
+ + {tip.title} +
+

+ {tip.description} +

+
+ ))} +
+ ) : ( +

توصیه خاصی ثبت نشده است.

+ )} +
+
+ )} + + {/* تب ۵: نظرات و امتیازات (Comments) */} + {activeTab === 'comments' && ( +
+ {isLoadingSub ? ( +
+ + +
+ ) : comments.length === 0 ? ( +
+ +

تاکنون نظری برای این تور ثبت نشده است.

+
+ ) : ( +
+ {comments.map((c) => ( +
+
+
+ {c.user_avatar ? ( + + ) : ( +
+ {c.user?.[0] || 'U'} +
+ )} + {c.user} +
+
+ {c.score}★ + + ({c.duration?.num} {c.duration?.type} پیش) + +
+
+

+ {c.text || 'بدون متن'} +

+
+ ))} +
+ )} +
+ )} + + ) : null} +
+ + + { + if (tourId) { + fetchTourDetail(tourId).then(setTour).catch(() => {}) + } + }} + /> + + ) +} + diff --git a/src/features/tours/components/TourStatusBadge.tsx b/src/features/tours/components/TourStatusBadge.tsx new file mode 100644 index 0000000..9d1b5ec --- /dev/null +++ b/src/features/tours/components/TourStatusBadge.tsx @@ -0,0 +1,213 @@ +import React from 'react' +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' + +interface TourStatusBadgeProps { + status: string + type?: 'sale' | 'trip' | 'order' + className?: string +} + +export function TourStatusBadge({ status, type = 'sale', className }: TourStatusBadgeProps) { + if (type === 'sale') { + switch (status?.toUpperCase()) { + case 'AVAILABLE': + return ( + + + در دسترس + + ) + case 'SOLD_OUT': + return ( + + + تکمیل ظرفیت + + ) + case 'NO_SHOW': + return ( + + عدم نمایش + + ) + default: + return ( + + {status || 'نامشخص'} + + ) + } + } + + if (type === 'trip') { + switch (status?.toUpperCase()) { + case 'PENDING': + return ( + + + در انتظار اجرا + + ) + case 'TRAVELING': + return ( + + + در حال برگزاری + + ) + case 'FINISHED': + return ( + + پایان‌یافته + + ) + default: + return ( + + {status || 'نامشخص'} + + ) + } + } + + // نوع سفارش (Order Status) + switch (status?.toUpperCase()) { + case 'APPROVED': + return ( + + + تایید شده + + ) + case 'AWAITING_PAYMENT': + return ( + + + در انتظار پرداخت + + ) + case 'PENDING': + return ( + + + در انتظار بررسی فیش + + ) + case 'REJECTED': + return ( + + + رد شده + + ) + case 'EXPIRED': + return ( + + منقضی شده + + ) + default: + return ( + + {status || 'نامشخص'} + + ) + } +} + +export function CountryFlagBadge({ countryCode }: { countryCode: string | null }) { + if (!countryCode) return - + + const getCountryName = (code: string) => { + switch (code.toUpperCase()) { + case 'IQ': + return { name: 'عراق', flag: '🇮🇶' } + case 'IR': + return { name: 'ایران', flag: '🇮🇷' } + case 'SA': + return { name: 'عربستان', flag: '🇸🇦' } + case 'SY': + return { name: 'سوریه', flag: '🇸🇾' } + case 'OM': + return { name: 'عمان', flag: '🇴🇲' } + default: + return { name: code, flag: '🌐' } + } + } + + const { name, flag } = getCountryName(countryCode) + + return ( + + {flag} + {name} + + ) +} diff --git a/src/features/tours/components/TourTranslationsDialog.tsx b/src/features/tours/components/TourTranslationsDialog.tsx new file mode 100644 index 0000000..8c50750 --- /dev/null +++ b/src/features/tours/components/TourTranslationsDialog.tsx @@ -0,0 +1,632 @@ +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 { + fetchTourTranslations, + saveTourTranslations, + fetchTourItineraryTranslations, + saveTourItineraryTranslation, +} from '../services/tour-api' +import type { + TourTranslationsResponse, + TourTranslationData, + TourItineraryTranslationsResponseItem, +} from '../types' + +interface TourTranslationsDialogProps { + tourId: number | null + tourTitle?: string + 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 + +type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code'] + +export function TourTranslationsDialog({ + tourId, + tourTitle, + open, + onOpenChange, + onSuccess, +}: TourTranslationsDialogProps) { + const [selectedLang, setSelectedLang] = React.useState('ar') + const [activeSection, setActiveSection] = React.useState<'base' | 'features' | 'tips' | 'itinerary'>('base') + const [isLoading, setIsLoading] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState(null) + const [successMessage, setSuccessMessage] = React.useState(null) + + // Translations data + const [transData, setTransData] = React.useState(null) + const [itineraries, setItineraries] = React.useState([]) + + // Local draft states for current selected language + const [formTitle, setFormTitle] = React.useState('') + const [formDesc, setFormDesc] = React.useState('') + const [formFeatures, setFormFeatures] = React.useState([]) + const [newFeatureText, setNewFeatureText] = React.useState('') + const [formTips, setFormTips] = React.useState<{ title: string; desc: string }[]>([]) + const [newTipTitle, setNewTipTitle] = React.useState('') + const [newTipDesc, setNewTipDesc] = React.useState('') + + // Drafts for itineraries: { [itineraryId]: { title: string, summary: string } } + const [itinDrafts, setItinDrafts] = React.useState>({}) + + // Load translations on open + React.useEffect(() => { + if (!open || !tourId) return + + let isMounted = true + setIsLoading(true) + setErrorMessage(null) + setSuccessMessage(null) + + Promise.all([ + fetchTourTranslations(tourId), + fetchTourItineraryTranslations(tourId).catch(() => []), + ]) + .then(([tourTrans, itinTrans]) => { + if (!isMounted) return + setTransData(tourTrans) + setItineraries(itinTrans || []) + + // Default to a language different from primary if primary is English/Persian + const defaultLang = tourTrans.primary_language === 'fa' ? 'ar' : 'fa' + setSelectedLang(defaultLang as LangCode) + initDraftForLanguage(defaultLang as LangCode, tourTrans, itinTrans || []) + }) + .catch((err) => { + if (!isMounted) return + setErrorMessage(err?.message || 'خطا در بارگذاری ترجمه‌های تور از سرور.') + }) + .finally(() => { + if (isMounted) setIsLoading(false) + }) + + return () => { + isMounted = false + } + }, [open, tourId]) + + // Sync draft whenever selectedLang changes + const initDraftForLanguage = ( + lang: LangCode, + tourTrans: TourTranslationsResponse | null, + itinList: TourItineraryTranslationsResponseItem[] + ) => { + if (!tourTrans) return + + const isPrimary = tourTrans.primary_language === lang + const existingTour = isPrimary + ? { + title: tourTrans.primary_data.title || '', + description: tourTrans.primary_data.description || '', + tour_feature: tourTrans.primary_data.tour_feature || [], + travel_tips: tourTrans.primary_data.travel_tips || {}, + } + : tourTrans.translations[lang] || { + title: '', + description: '', + tour_feature: [], + travel_tips: {}, + } + + setFormTitle(existingTour.title || '') + setFormDesc(existingTour.description || '') + + const featuresList = (existingTour.tour_feature || []).map((f) => f.title) + setFormFeatures(featuresList) + + const tipsList = Object.entries(existingTour.travel_tips || {}).map(([title, desc]) => ({ + title, + desc: String(desc), + })) + setFormTips(tipsList) + + // Init itinerary drafts for this language + const drafts: Record = {} + itinList.forEach((it) => { + const itTrans = it.translations[lang] + drafts[it.id] = { + title: itTrans?.title || '', + summary: itTrans?.summary || '', + } + }) + setItinDrafts(drafts) + } + + const handleLanguageChange = (lang: LangCode) => { + setSelectedLang(lang) + setErrorMessage(null) + setSuccessMessage(null) + initDraftForLanguage(lang, transData, itineraries) + } + + const handleAddFeature = () => { + if (!newFeatureText.trim()) return + setFormFeatures((prev) => [...prev, newFeatureText.trim()]) + setNewFeatureText('') + } + + const handleRemoveFeature = (idx: number) => { + setFormFeatures((prev) => prev.filter((_, i) => i !== idx)) + } + + const handleAddTip = () => { + if (!newTipTitle.trim() || !newTipDesc.trim()) return + setFormTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }]) + setNewTipTitle('') + setNewTipDesc('') + } + + const handleRemoveTip = (idx: number) => { + setFormTips((prev) => prev.filter((_, i) => i !== idx)) + } + + const handleItineraryDraftChange = (itId: number, field: 'title' | 'summary', val: string) => { + setItinDrafts((prev) => ({ + ...prev, + [itId]: { + title: prev[itId]?.title || '', + summary: prev[itId]?.summary || '', + [field]: val, + }, + })) + } + + const handleSave = async () => { + if (!tourId || !transData) return + setIsSaving(true) + setErrorMessage(null) + setSuccessMessage(null) + + try { + // Build tour_feature array + const tour_feature = formFeatures.map((title) => ({ title })) + + // Build travel_tips object + const travel_tips: Record = {} + formTips.forEach((item) => { + travel_tips[item.title] = item.desc + }) + + // 1. Save Tour translation + await saveTourTranslations(tourId, { + lang_code: selectedLang, + title: formTitle.trim(), + description: formDesc.trim(), + tour_feature, + travel_tips, + }) + + // 2. Save Itinerary translations + const itinSavePromises = Object.entries(itinDrafts).map(([itIdStr, draft]) => { + const itId = Number(itIdStr) + if (!draft.title.trim() && !draft.summary.trim()) return Promise.resolve() + return saveTourItineraryTranslation(tourId, { + itinerary_id: itId, + lang_code: selectedLang, + title: draft.title.trim(), + summary: draft.summary.trim(), + }) + }) + + await Promise.all(itinSavePromises) + + // Refresh data + const [updatedTour, updatedItin] = await Promise.all([ + fetchTourTranslations(tourId), + fetchTourItineraryTranslations(tourId).catch(() => []), + ]) + setTransData(updatedTour) + setItineraries(updatedItin || []) + + setSuccessMessage(`ترجمه زبان ${SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang)?.label} با موفقیت در دیتابیس ذخیره شد.`) + if (onSuccess) onSuccess() + } catch (err: any) { + setErrorMessage(err?.message || 'خطا در ذخیره ترجمه در سرور.') + } finally { + setIsSaving(false) + } + } + + const activeLangConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang) + const isPrimary = transData?.primary_language === selectedLang + + return ( + + + {/* Dialog Header */} + +
+
+
+ +
+
+ + مدیریت ترجمه‌های ۵ زبانه تور + + + {tourTitle || `تور شناسه #${tourId}`} • ذخیره‌سازی پویا در سرور و پایگاه‌داده + +
+
+ + {/* Primary Language Indicator */} + {transData && ( +
+ زبان پایه دیتابیس: + + {SUPPORTED_LANGUAGES.find((l) => l.code === transData.primary_language)?.label || + transData.primary_language} + +
+ )} +
+ + {/* 5-Language Selector Bar */} +
+ {SUPPORTED_LANGUAGES.map((lang) => { + const isSelected = lang.code === selectedLang + const hasTrans = Boolean( + transData?.primary_language === lang.code || + transData?.translations[lang.code]?.title + ) + return ( + + ) + })} +
+
+ + {/* Dialog Body */} +
+ {isLoading ? ( +
+
+

در حال بارگذاری ترجمه‌ها از Backend...

+
+ ) : ( + <> + {errorMessage && ( +
+ + {errorMessage} +
+ )} + + {successMessage && ( +
+ + {successMessage} +
+ )} + + {/* Section Tabs */} +
+ + + + +
+ + {/* Active Section Form */} +
+ {/* 1. Base Info */} + {activeSection === 'base' && ( +
+
+ + setFormTitle(e.target.value)} + placeholder={`عنوان تور به زبان ${activeLangConfig?.label}...`} + className="bg-surface-2 border-border/50 text-sm" + disabled={isPrimary} + /> + {isPrimary && ( +

+ * این زبان، زبان اصلی تور است و از فرم اصلی مدیریت می‌شود. +

+ )} +
+ +
+ +