You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
719 lines
32 KiB
719 lines
32 KiB
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 { Badge } from '@/components/ui/badge'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { fetchTours, toggleTourVisibility } from './services/tour-api'
|
|
import type { TourListItem } from './types'
|
|
import { TourStatusBadge } from './components/TourStatusBadge'
|
|
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_frontend'
|
|
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<TourListItem[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// فیلترها و مرتبسازی
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState('ALL')
|
|
const [tripStatusFilter, setTripStatusFilter] = useState('ALL')
|
|
const [sortBy, setSortBy] = useState<'default' | 'started_at' | 'id' | 'price_asc' | 'price_desc'>('default')
|
|
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
|
|
|
|
// صفحهبندی (Pagination)
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
const [pageSize, setPageSize] = useState(20) // ۲۰ تور در هر صفحه
|
|
|
|
// وضعیت مودال جزئیات و ترجمههای یکپارچه تور
|
|
const [selectedTourId, setSelectedTourId] = useState<number | null>(null)
|
|
const [isDetailOpen, setIsDetailOpen] = useState(false)
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
|
|
|
const handleToggleTourVisibility = async (tourId: number, currentVisibility?: boolean) => {
|
|
const newVisibility = currentVisibility === false ? true : false
|
|
setTogglingId(tourId)
|
|
try {
|
|
await toggleTourVisibility(tourId, newVisibility)
|
|
setTours((prev) =>
|
|
prev.map((t) => (t.id === tourId ? { ...t, visibility: newVisibility } : t))
|
|
)
|
|
} catch (err: any) {
|
|
alert(err?.message || 'خطا در تغییر وضعیت فعال/غیرفعال بودن تور')
|
|
} finally {
|
|
setTogglingId(null)
|
|
}
|
|
}
|
|
|
|
const loadData = async (showRefresh = false) => {
|
|
if (showRefresh) setIsRefreshing(true)
|
|
else setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetchTours({
|
|
limit: 100,
|
|
language_code: language,
|
|
})
|
|
setTours(res.results || [])
|
|
} catch (err: any) {
|
|
setError(err.message || 'خطا در برقراری ارتباط با وبسرویس تورها')
|
|
} finally {
|
|
setIsLoading(false)
|
|
setIsRefreshing(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [language])
|
|
|
|
// فیلتر، جستجو و مرتبسازی کلاینت
|
|
const filteredTours = useMemo(() => {
|
|
const list = 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
|
|
|
|
return matchSearch && matchStatus && matchTripStatus
|
|
})
|
|
|
|
const now = new Date()
|
|
const nowMs = now.getTime()
|
|
const todayStartMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
|
|
|
return [...list].sort((a, b) => {
|
|
if (sortBy === 'price_asc') return (parseFloat(a.price) || 0) - (parseFloat(b.price) || 0)
|
|
if (sortBy === 'price_desc') return (parseFloat(b.price) || 0) - (parseFloat(a.price) || 0)
|
|
if (sortBy === 'started_at') {
|
|
const timeA = a.started_at ? new Date(a.started_at).getTime() : Infinity
|
|
const timeB = b.started_at ? new Date(b.started_at).getTime() : Infinity
|
|
return timeA - timeB
|
|
}
|
|
if (sortBy === 'id') return b.id - a.id
|
|
|
|
// مرتبسازی پیشفرض: نزدیکترین تور فعال یا در حال برگزاری
|
|
// رتبهبندی اولویت:
|
|
// ۱. در حال برگزاری (TRAVELING یا تطابق بازه تاریخی با زمان حال)
|
|
// ۲. فعال و در دسترس (AVAILABLE و منقضینشده، صعودی بر اساس نزدیکترین تاریخ شروع)
|
|
// ۳. سایر تورهای پیشرو (تکمیل ظرفیت SOLD_OUT و...)
|
|
// ۴. تورهای پایانیافته (FINISHED یا تاریخ گذشته)
|
|
const getTourBucket = (t: TourListItem) => {
|
|
const startMs = t.started_at ? new Date(t.started_at).setHours(0, 0, 0, 0) : NaN
|
|
const endMs = t.ended_at ? new Date(t.ended_at).setHours(23, 59, 59, 999) : startMs
|
|
|
|
const isTraveling =
|
|
t.trip_status === 'TRAVELING' ||
|
|
(!isNaN(startMs) && !isNaN(endMs) && nowMs >= startMs && nowMs <= endMs)
|
|
if (isTraveling) {
|
|
return { bucket: 1, date: !isNaN(startMs) ? startMs : 0 }
|
|
}
|
|
|
|
const isPast =
|
|
t.trip_status === 'FINISHED' ||
|
|
(!isNaN(endMs) && endMs < todayStartMs)
|
|
|
|
if (!isPast) {
|
|
if (t.status === 'AVAILABLE') {
|
|
return { bucket: 2, date: !isNaN(startMs) ? startMs : Infinity }
|
|
}
|
|
return { bucket: 3, date: !isNaN(startMs) ? startMs : Infinity }
|
|
}
|
|
|
|
return { bucket: 4, date: !isNaN(startMs) ? startMs : 0 }
|
|
}
|
|
|
|
const rankA = getTourBucket(a)
|
|
const rankB = getTourBucket(b)
|
|
|
|
if (rankA.bucket !== rankB.bucket) {
|
|
return rankA.bucket - rankB.bucket
|
|
}
|
|
|
|
// در رتبههای ۱، ۲ و ۳، نزدیکترین تاریخ حرکت در اولویت است
|
|
if (rankA.bucket <= 3) {
|
|
if (rankA.date !== rankB.date) {
|
|
return rankA.date - rankB.date
|
|
}
|
|
} else {
|
|
// در تورهای پایانیافته (رتبه ۴)، جدیدترین تاریخ به گذشته در ابتدا قرار میگیرد
|
|
if (rankA.date !== rankB.date) {
|
|
return rankB.date - rankA.date
|
|
}
|
|
}
|
|
|
|
return b.id - a.id
|
|
})
|
|
}, [tours, searchTerm, statusFilter, tripStatusFilter, sortBy])
|
|
|
|
// شمارش فیلترهای فعال
|
|
const activeFiltersCount = [
|
|
statusFilter !== 'ALL',
|
|
tripStatusFilter !== 'ALL',
|
|
Boolean(searchTerm.trim()),
|
|
].filter(Boolean).length
|
|
|
|
// بازنشانی صفحه به ۱ در صورت تغییر هرکدام از فیلترها، جستجو یا مرتبسازی
|
|
useEffect(() => {
|
|
setCurrentPage(1)
|
|
}, [searchTerm, statusFilter, tripStatusFilter, sortBy])
|
|
|
|
// محاسبات صفحهبندی فهرست تورها
|
|
const totalPages = Math.ceil(filteredTours.length / pageSize) || 1
|
|
const activePage = Math.min(currentPage, totalPages)
|
|
|
|
const paginatedTours = useMemo(() => {
|
|
const startIndex = (activePage - 1) * pageSize
|
|
return filteredTours.slice(startIndex, startIndex + pageSize)
|
|
}, [filteredTours, activePage, pageSize])
|
|
|
|
// شاخصهای کلیدی آماری (KPI Metrics)
|
|
const stats = useMemo(() => {
|
|
const total = tours.length
|
|
const active = tours.filter((t) => t.visibility !== false).length
|
|
const inactive = tours.filter((t) => t.visibility === false).length
|
|
const soldOut = tours.filter((t) => t.status === 'SOLD_OUT').length
|
|
const traveling = tours.filter((t) => t.trip_status === 'TRAVELING').length
|
|
const pending = tours.filter((t) => t.trip_status === 'PENDING').length
|
|
|
|
return { total, active, inactive, 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 (
|
|
<div className="space-y-6 pt-2 pb-12">
|
|
{/* سربرگ مدیریت تورها */}
|
|
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
<Ic name="map" className="size-5" />
|
|
</div>
|
|
<h1 className="text-xl font-black text-foreground">مدیریت تورها</h1>
|
|
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary font-bold">
|
|
{stats.total} تور ثبتشده
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-grey-3">
|
|
پایش وضعیت زنده فروش، برنامههای سفر، مسافران، اطلاعات قیمتی و ترجمههای چندزبانه
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
{/* تغییر نمای شبکهای / جدولی */}
|
|
<div className="flex rounded-xl border border-border-soft bg-surface-base p-1">
|
|
<Button
|
|
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
className="size-8 p-0"
|
|
onClick={() => setViewMode('grid')}
|
|
title="نمای کارتی"
|
|
>
|
|
<Ic name="grid" className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'table' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
className="size-8 p-0"
|
|
onClick={() => setViewMode('table')}
|
|
title="نمای جدول"
|
|
>
|
|
<Ic name="list" className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* دکمه ایجاد تور جدید */}
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setIsCreateOpen(true)}
|
|
className="bg-primary text-white hover:bg-primary/90 text-xs font-bold gap-1.5 shadow-sm rounded-xl h-9 px-3.5"
|
|
>
|
|
<Ic name="plus" className="size-4" />
|
|
<span>افزودن تور جدید</span>
|
|
</Button>
|
|
|
|
{/* دکمه بروزرسانی زنده */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadData(true)}
|
|
disabled={isRefreshing}
|
|
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
|
|
>
|
|
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
بروزرسانی دادهها
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* کارتهای شاخصهای کلیدی آماری (KPI Cards) */}
|
|
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-5">
|
|
<Card className="border-border-soft bg-card/40 shadow-xs">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">کل تورها</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.total)}</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
|
|
<Ic name="map" className="size-4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/40 shadow-xs">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">تورهای فعال (اپ و وب)</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
|
|
{toFa(stats.active)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
|
|
<Ic name="checkCircle" className="size-4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/40 shadow-xs">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">تکمیل ظرفیت</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-foreground">
|
|
{toFa(stats.soldOut)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
|
|
<Ic name="xCircle" className="size-4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/40 shadow-xs">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">در حال اجرا</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.traveling)}</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
|
|
<Ic name="calendar" className="size-4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/40 shadow-xs col-span-2 sm:col-span-1">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-muted-foreground font-medium">در انتظار شروع</p>
|
|
{isLoading ? (
|
|
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
|
|
) : (
|
|
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
|
|
{toFa(stats.pending)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="size-9 rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center justify-center">
|
|
<Ic name="clock" className="size-4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* نوار فیلتر و جستجو استاندارد */}
|
|
<FilterBar
|
|
searchQuery={searchTerm}
|
|
onSearchChange={setSearchTerm}
|
|
searchPlaceholder="جستجوی تور با عنوان یا اسلاگ..."
|
|
hasActiveFilters={activeFiltersCount > 0 || Boolean(searchTerm)}
|
|
activeFilterCount={activeFiltersCount}
|
|
onResetFilters={() => {
|
|
setSearchTerm('')
|
|
setStatusFilter('ALL')
|
|
setTripStatusFilter('ALL')
|
|
setSortBy('default')
|
|
}}
|
|
endContent={
|
|
<SortDropdown
|
|
value={sortBy}
|
|
onChange={(val) => setSortBy(val as any)}
|
|
options={[
|
|
{ value: 'default', label: 'پیشفرض (نزدیکترین فعال / در حال برگزاری)' },
|
|
{ value: 'started_at', label: 'نزدیکترین تاریخ حرکت' },
|
|
{ value: 'id', label: 'جدیدترین (شناسه)' },
|
|
{ value: 'price_asc', label: 'ارزانترین تور' },
|
|
{ value: 'price_desc', label: 'گرانترین تور' },
|
|
]}
|
|
size="sm"
|
|
/>
|
|
}
|
|
>
|
|
{/* وضعیت فروش */}
|
|
<FilterSelect
|
|
value={statusFilter}
|
|
onChange={setStatusFilter}
|
|
options={[
|
|
{ value: 'ALL', label: 'همه وضعیتهای فروش' },
|
|
{ value: 'AVAILABLE', label: 'فقط در دسترس (Available)' },
|
|
{ value: 'SOLD_OUT', label: 'تکمیل ظرفیت (Sold Out)' },
|
|
{ value: 'NO_SHOW', label: 'عدم نمایش (No Show)' },
|
|
]}
|
|
allValue="ALL"
|
|
clearable
|
|
size="sm"
|
|
/>
|
|
|
|
{/* وضعیت سفر */}
|
|
<FilterSelect
|
|
value={tripStatusFilter}
|
|
onChange={setTripStatusFilter}
|
|
options={[
|
|
{ value: 'ALL', label: 'همه وضعیتهای سفر' },
|
|
{ value: 'PENDING', label: 'در انتظار اجرا (Pending)' },
|
|
{ value: 'TRAVELING', label: 'در حال برگزاری (Traveling)' },
|
|
{ value: 'FINISHED', label: 'پایانیافته (Finished)' },
|
|
]}
|
|
allValue="ALL"
|
|
clearable
|
|
size="sm"
|
|
/>
|
|
</FilterBar>
|
|
|
|
{/* وضعیت لودینگ استاندارد Shimmer */}
|
|
{isLoading ? (
|
|
<Shimmer withCard cardHeaderTitle="فهرست همه تورها" cardHeaderIcon="map" rows={6} />
|
|
) : error ? (
|
|
/* وضعیت خطا */
|
|
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-12 text-center text-rose-500">
|
|
<Ic name="alert" className="mx-auto size-10 mb-3" />
|
|
<h3 className="text-base font-bold">خطا در بارگذاری فهرست تورها</h3>
|
|
<p className="mt-1 text-xs text-grey-3 max-w-md mx-auto">{error}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadData()}
|
|
className="mt-5 border-rose-500/30 text-rose-500 hover:bg-rose-500/10"
|
|
>
|
|
تلاش دوباره
|
|
</Button>
|
|
</div>
|
|
) : filteredTours.length === 0 ? (
|
|
/* وضعیت خالی (Empty State) */
|
|
<div className="rounded-2xl border border-dashed border-border-soft bg-card/20 p-16 text-center">
|
|
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
<Ic name="inbox" className="size-6" />
|
|
</div>
|
|
<h3 className="mt-4 text-sm font-bold text-foreground">توری یافت نشد</h3>
|
|
<p className="mt-1 text-xs text-grey-3 max-w-sm mx-auto">
|
|
هیچ توری با فیلترها و عبارت جستجوی انتخابی شما همخوانی ندارد.
|
|
</p>
|
|
</div>
|
|
) : viewMode === 'grid' ? (
|
|
/* نمای شبکهای (Cards Grid View) */
|
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{paginatedTours.map((tour) => {
|
|
const imgUrl =
|
|
tour.image?.image_url?.md ||
|
|
tour.image?.image_url?.medium ||
|
|
tour.image?.image_url?.lg ||
|
|
tour.image?.image_url?.large ||
|
|
tour.image?.image_url?.original ||
|
|
tour.image?.image_url?.sm ||
|
|
tour.image?.image_url?.small ||
|
|
'/placeholder.jpg'
|
|
|
|
return (
|
|
<div
|
|
key={tour.id}
|
|
onClick={() => handleOpenDetail(tour.id)}
|
|
className="group flex flex-col justify-between overflow-hidden rounded-2xl border border-border-soft bg-card/40 transition hover:border-primary/40 hover:bg-card/70 hover:shadow-md cursor-pointer"
|
|
>
|
|
<div>
|
|
{/* تصویر کاور تور و وضعیت */}
|
|
<div className="relative aspect-16/10 w-full overflow-hidden bg-surface-base">
|
|
<img
|
|
src={imgUrl}
|
|
alt={tour.title}
|
|
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
|
|
onError={(e) => {
|
|
;(e.target as HTMLImageElement).src =
|
|
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="400" height="250" viewBox="0 0 400 250" fill="%2318181b"><rect width="400" height="250"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="14" font-family="sans-serif">Aqila Tour</text></svg>'
|
|
}}
|
|
/>
|
|
{/* وضعیت تور و تاگل تعاملی نمایش */}
|
|
<div
|
|
className="absolute bottom-3 right-3 flex items-center gap-1.5 z-10"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-1.5 rounded-lg bg-black/70 backdrop-blur-md px-2 py-0.5 border border-white/10 shadow-xs">
|
|
<Switch
|
|
checked={tour.visibility !== false}
|
|
isLoading={togglingId === tour.id}
|
|
onCheckedChange={() => handleToggleTourVisibility(tour.id, tour.visibility !== false)}
|
|
className="scale-75 origin-right"
|
|
title="تغییر وضعیت فعال/غیرفعال بودن تور در اپ و وب"
|
|
/>
|
|
<span className={`text-[10px] font-bold ${tour.visibility !== false ? 'text-emerald-400' : 'text-rose-400'}`}>
|
|
{tour.visibility !== false ? 'فعال' : 'مخفی'}
|
|
</span>
|
|
</div>
|
|
<TourStatusBadge status={tour.trip_status} type="trip" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* بدنه کارت */}
|
|
<div className="p-4 space-y-3">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between text-[11px] text-grey-3">
|
|
<span className="font-mono" dir="ltr">#{tour.id}</span>
|
|
<span>{calculateDays(tour.started_at, tour.ended_at)}</span>
|
|
</div>
|
|
<h3 className="line-clamp-2 text-sm font-black text-foreground leading-snug">
|
|
{tour.title}
|
|
</h3>
|
|
</div>
|
|
|
|
{/* تاریخ برگزاری */}
|
|
<div className="flex items-center gap-1.5 text-xs text-grey-3 bg-surface-base p-2 rounded-xl border border-border-soft">
|
|
<Ic name="calendar" className="size-3.5 shrink-0 text-primary" />
|
|
<span className="truncate text-foreground font-medium text-[11px]">
|
|
{formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* جدول ۳ ردیف قیمت تور بر اساس رده سنی */}
|
|
<div className="space-y-1.5 pt-2 border-t border-border-soft text-xs">
|
|
<div className="flex items-center justify-between text-grey-2">
|
|
<span className="text-[11px] text-grey-3">بزرگسال <span className="text-[10px] opacity-80">(۷+ سال)</span>:</span>
|
|
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price || '0').toLocaleString()}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-grey-2">
|
|
<span className="text-[11px] text-grey-3">کودک <span className="text-[10px] opacity-80">(۲ تا ۷ سال)</span>:</span>
|
|
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price_child || '0').toLocaleString()}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-grey-2">
|
|
<span className="text-[11px] text-grey-3">نوزاد <span className="text-[10px] opacity-80">(۰ تا ۲ سال)</span>:</span>
|
|
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price_infant || '0').toLocaleString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* فوتر کارت و دکمه جزئیات */}
|
|
<div className="p-4 pt-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full text-xs font-bold border-border-soft group-hover:border-primary group-hover:bg-primary group-hover:text-white transition"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleOpenDetail(tour.id)
|
|
}}
|
|
>
|
|
<Ic name="eye" className="size-3.5 ml-1.5" />
|
|
مشاهده جزئیات کامل و ترجمهها
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
/* نمای جدول (Data Table View) */
|
|
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
|
|
<Table>
|
|
<TableHeader className="bg-surface-base">
|
|
<TableRow>
|
|
<TableHead className="text-right text-xs font-bold">شناسه</TableHead>
|
|
<TableHead className="text-right text-xs font-bold">عنوان تور</TableHead>
|
|
<TableHead className="text-right text-xs font-bold">تاریخ برگزاری</TableHead>
|
|
<TableHead className="text-right text-xs font-bold">نرخها (بزرگسال / کودک / نوزاد)</TableHead>
|
|
<TableHead className="text-right text-xs font-bold">وضعیت نمایش</TableHead>
|
|
<TableHead className="text-right text-xs font-bold">وضعیت سفر</TableHead>
|
|
<TableHead className="text-center text-xs font-bold">عملیات</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{paginatedTours.map((tour) => (
|
|
<TableRow
|
|
key={tour.id}
|
|
className="transition hover:bg-card/70 cursor-pointer"
|
|
onClick={() => handleOpenDetail(tour.id)}
|
|
>
|
|
<TableCell className="font-mono text-xs text-grey-3 font-bold">#{tour.id}</TableCell>
|
|
<TableCell className="max-w-xs">
|
|
<div className="font-bold text-xs text-foreground line-clamp-1">{tour.title}</div>
|
|
<div className="text-[10px] text-grey-3 truncate font-mono">{tour.slug}</div>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-grey-2">
|
|
{formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
|
|
</TableCell>
|
|
<TableCell className="text-xs font-mono">
|
|
<div className="font-black text-foreground">${parseFloat(tour.price || '0').toLocaleString()}</div>
|
|
<div className="text-[10px] text-grey-3">
|
|
کودک: ${parseFloat(tour.price_child || '0').toLocaleString()} | نوزاد: ${parseFloat(tour.price_infant || '0').toLocaleString()}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={tour.visibility !== false}
|
|
isLoading={togglingId === tour.id}
|
|
onCheckedChange={() => handleToggleTourVisibility(tour.id, tour.visibility !== false)}
|
|
title="تغییر وضعیت فعال/غیرفعال بودن تور در اپ و وب"
|
|
/>
|
|
<span className={`text-[11px] font-bold ${tour.visibility !== false ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-500'}`}>
|
|
{tour.visibility !== false ? 'روشن (فعال)' : 'خاموش (مخفی)'}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TourStatusBadge status={tour.trip_status} type="trip" />
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="size-8 p-0 text-primary hover:bg-primary/10 mx-auto"
|
|
title="مشاهده جزئیات کامل و ترجمهها"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleOpenDetail(tour.id)
|
|
}}
|
|
>
|
|
<Ic name="eye" className="size-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
{/* کنترل صفحهبندی فهرست تورها (Pagination Footer) */}
|
|
{!isLoading && !error && filteredTours.length > 0 && (
|
|
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs">
|
|
{/* نمایش خلاصه تعداد آیتمها و انتخاب تعداد در هر صفحه */}
|
|
<div className="flex flex-wrap items-center gap-3 text-xs text-grey-3">
|
|
<span>
|
|
نمایش{' '}
|
|
<strong className="text-foreground font-mono font-bold">
|
|
{toFa((activePage - 1) * pageSize + 1)}
|
|
</strong>{' '}
|
|
تا{' '}
|
|
<strong className="text-foreground font-mono font-bold">
|
|
{toFa(Math.min(activePage * pageSize, filteredTours.length))}
|
|
</strong>{' '}
|
|
از{' '}
|
|
<strong className="text-foreground font-mono font-bold">
|
|
{toFa(filteredTours.length)}
|
|
</strong>{' '}
|
|
تور
|
|
</span>
|
|
|
|
<div className="flex items-center gap-1.5 border-r border-border-soft pr-3">
|
|
<span className="text-[11px]">در هر صفحه:</span>
|
|
<FilterSelect
|
|
value={String(pageSize)}
|
|
onChange={(val) => {
|
|
setPageSize(Number(val))
|
|
setCurrentPage(1)
|
|
}}
|
|
options={[
|
|
{ value: '10', label: '۱۰' },
|
|
{ value: '20', label: '۲۰' },
|
|
{ value: '50', label: '۵۰' },
|
|
{ value: '100', label: '۱۰۰' },
|
|
]}
|
|
size="sm"
|
|
className="w-20"
|
|
triggerClassName="h-8 py-1 px-2.5 text-xs font-bold"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* کامپوننت صفحهبندی استاندارد پنل */}
|
|
<TablePagination
|
|
currentPage={activePage}
|
|
totalPages={totalPages}
|
|
onPageChange={(p) => {
|
|
setCurrentPage(p)
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* مودال پاپآپ جامع جزئیات و ترجمههای تور */}
|
|
<TourDetailDialog
|
|
tourId={selectedTourId}
|
|
isOpen={isDetailOpen}
|
|
onClose={() => setIsDetailOpen(false)}
|
|
onUpdated={() => loadData()}
|
|
/>
|
|
|
|
{/* مودال ایجاد تور جدید به همراه ترجمههای ۵ زبانه */}
|
|
<CreateTourDialog
|
|
open={isCreateOpen}
|
|
onOpenChange={setIsCreateOpen}
|
|
onSuccess={() => loadData(true)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|