Browse Source
feat: implement tours management feature with pages, components, API services, and routing
master
feat: implement tours management feature with pages, components, API services, and routing
master
12 changed files with 2981 additions and 25 deletions
-
5src/app/router.tsx
-
105src/components/layout/AppSidebar.tsx
-
75src/components/ui/card.tsx
-
16src/config/navigation.ts
-
548src/features/tours/TourOrdersPage.tsx
-
556src/features/tours/ToursPage.tsx
-
525src/features/tours/components/TourDetailSheet.tsx
-
213src/features/tours/components/TourStatusBadge.tsx
-
632src/features/tours/components/TourTranslationsDialog.tsx
-
145src/features/tours/services/tour-api.ts
-
185src/features/tours/types.ts
-
1src/icons.tsx
@ -0,0 +1,75 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const Card = React.forwardRef< |
||||
|
HTMLDivElement, |
||||
|
React.HTMLAttributes<HTMLDivElement> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<div |
||||
|
ref={ref} |
||||
|
className={cn( |
||||
|
'rounded-2xl border border-border-soft bg-card text-card-foreground shadow-xs', |
||||
|
className |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
Card.displayName = 'Card' |
||||
|
|
||||
|
const CardHeader = React.forwardRef< |
||||
|
HTMLDivElement, |
||||
|
React.HTMLAttributes<HTMLDivElement> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<div |
||||
|
ref={ref} |
||||
|
className={cn('flex flex-col space-y-1.5 p-5', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
CardHeader.displayName = 'CardHeader' |
||||
|
|
||||
|
const CardTitle = React.forwardRef< |
||||
|
HTMLParagraphElement, |
||||
|
React.HTMLAttributes<HTMLHeadingElement> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<h3 |
||||
|
ref={ref} |
||||
|
className={cn('text-base font-bold leading-none tracking-tight', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
CardTitle.displayName = 'CardTitle' |
||||
|
|
||||
|
const CardDescription = React.forwardRef< |
||||
|
HTMLParagraphElement, |
||||
|
React.HTMLAttributes<HTMLParagraphElement> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<p |
||||
|
ref={ref} |
||||
|
className={cn('text-xs text-grey-3', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
CardDescription.displayName = 'CardDescription' |
||||
|
|
||||
|
const CardContent = React.forwardRef< |
||||
|
HTMLDivElement, |
||||
|
React.HTMLAttributes<HTMLDivElement> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} /> |
||||
|
)) |
||||
|
CardContent.displayName = 'CardContent' |
||||
|
|
||||
|
const CardFooter = React.forwardRef< |
||||
|
HTMLDivElement, |
||||
|
React.HTMLAttributes<HTMLDivElement> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<div |
||||
|
ref={ref} |
||||
|
className={cn('flex items-center p-5 pt-0', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
CardFooter.displayName = 'CardFooter' |
||||
|
|
||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } |
||||
@ -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<TourOrderItem[]>([]) |
||||
|
const [totalCount, setTotalCount] = useState<number>(0) |
||||
|
const [isLoading, setIsLoading] = useState<boolean>(true) |
||||
|
const [error, setError] = useState<string | null>(null) |
||||
|
|
||||
|
// Filters
|
||||
|
const [searchQuery, setSearchQuery] = useState<string>('') |
||||
|
const [selectedStatus, setSelectedStatus] = useState<string>('') |
||||
|
|
||||
|
// Receipt Modal
|
||||
|
const [previewReceiptUrl, setPreviewReceiptUrl] = useState<string | null>(null) |
||||
|
const [previewReceiptOrderId, setPreviewReceiptOrderId] = useState<number | null>(null) |
||||
|
const [previewReceiptVerified, setPreviewReceiptVerified] = useState<boolean>(false) |
||||
|
|
||||
|
// Order Details Modal
|
||||
|
const [detailOrder, setDetailOrder] = useState<TourOrderItem | null>(null) |
||||
|
|
||||
|
// Updating action state
|
||||
|
const [updatingId, setUpdatingId] = useState<number | null>(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 ( |
||||
|
<div className="space-y-6 pb-12"> |
||||
|
{/* Page Header */} |
||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> |
||||
|
<div> |
||||
|
<h1 className="text-2xl font-black text-foreground tracking-tight"> |
||||
|
سفارشات و رزروهای تور |
||||
|
</h1> |
||||
|
<p className="text-xs text-grey-3 mt-1"> |
||||
|
پیگیری سفارشات، نظارت بر پرداختها و اعتبارسنجی مدارک و فیشهای واریزی مسافران |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={loadOrders} |
||||
|
disabled={isLoading} |
||||
|
className="gap-2 rounded-xl border-border-soft hover:bg-surface-2" |
||||
|
> |
||||
|
<Ic name="refresh" className={`size-4 ${isLoading ? 'animate-spin' : ''}`} /> |
||||
|
<span>بروزرسانی دادهها</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* KPI Cards */} |
||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> |
||||
|
<Card className="bg-card border-border-soft shadow-xs"> |
||||
|
<CardContent className="p-4 flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-xs font-bold text-grey-3">کل سفارشات ثبتشده</p> |
||||
|
<h3 className="text-2xl font-black text-foreground mt-1"> |
||||
|
{isLoading ? '...' : totalCount} |
||||
|
</h3> |
||||
|
</div> |
||||
|
<div className="size-11 rounded-2xl bg-primary/10 text-primary flex items-center justify-center"> |
||||
|
<Ic name="clipboardCheck" className="size-5" /> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
<Card className="bg-card border-border-soft shadow-xs"> |
||||
|
<CardContent className="p-4 flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-xs font-bold text-grey-3">رزروهای تأییدشده</p> |
||||
|
<h3 className="text-2xl font-black text-emerald-500 mt-1"> |
||||
|
{isLoading ? '...' : stats.approved} |
||||
|
</h3> |
||||
|
</div> |
||||
|
<div className="size-11 rounded-2xl bg-emerald-500/10 text-emerald-500 flex items-center justify-center"> |
||||
|
<Ic name="check" className="size-5" /> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
<Card className="bg-card border-border-soft shadow-xs"> |
||||
|
<CardContent className="p-4 flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-xs font-bold text-grey-3">در انتظار بررسی و پرداخت</p> |
||||
|
<h3 className="text-2xl font-black text-amber-500 mt-1"> |
||||
|
{isLoading ? '...' : stats.pending} |
||||
|
</h3> |
||||
|
</div> |
||||
|
<div className="size-11 rounded-2xl bg-amber-500/10 text-amber-500 flex items-center justify-center"> |
||||
|
<Ic name="clock" className="size-5" /> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
<Card className="bg-card border-border-soft shadow-xs"> |
||||
|
<CardContent className="p-4 flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-xs font-bold text-grey-3">مجموع ارزش مالی</p> |
||||
|
<h3 className="text-xl font-black text-foreground mt-1"> |
||||
|
{isLoading ? '...' : `$${stats.totalAmount.toLocaleString()}`} |
||||
|
</h3> |
||||
|
</div> |
||||
|
<div className="size-11 rounded-2xl bg-blue-500/10 text-blue-500 flex items-center justify-center"> |
||||
|
<Ic name="chart" className="size-5" /> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
</div> |
||||
|
|
||||
|
{/* Filter and Search Bar */} |
||||
|
<Card className="bg-card border-border-soft shadow-xs"> |
||||
|
<CardContent className="p-4 flex flex-col sm:flex-row items-center justify-between gap-3"> |
||||
|
<div className="relative w-full sm:w-80"> |
||||
|
<Ic |
||||
|
name="search" |
||||
|
className="absolute start-3 top-1/2 -translate-y-1/2 size-4 text-grey-3 pointer-events-none" |
||||
|
/> |
||||
|
<Input |
||||
|
type="text" |
||||
|
placeholder="جستجو در سفارشات (عنوان تور، کاربر...)" |
||||
|
value={searchQuery} |
||||
|
onChange={(e) => setSearchQuery(e.target.value)} |
||||
|
className="ps-9 bg-surface-2 border-border/50 text-xs rounded-xl" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* Status Filter */} |
||||
|
<div className="flex items-center gap-1.5 w-full sm:w-auto overflow-x-auto pb-1 sm:pb-0"> |
||||
|
{[ |
||||
|
{ id: '', label: 'همه سفارشات' }, |
||||
|
{ id: 'APPROVED', label: 'تأییدشده' }, |
||||
|
{ id: 'PENDING', label: 'در انتظار بررسی' }, |
||||
|
{ id: 'AWAITING_PAYMENT', label: 'منتظر پرداخت' }, |
||||
|
{ id: 'REJECTED', label: 'ردشده' }, |
||||
|
].map((st) => ( |
||||
|
<button |
||||
|
key={st.id} |
||||
|
type="button" |
||||
|
onClick={() => setSelectedStatus(st.id)} |
||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${ |
||||
|
selectedStatus === st.id |
||||
|
? 'bg-primary text-white shadow-xs' |
||||
|
: 'bg-surface-2 text-grey-3 hover:text-foreground hover:bg-surface-3' |
||||
|
}`}
|
||||
|
> |
||||
|
{st.label} |
||||
|
</button> |
||||
|
))} |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Orders Table */} |
||||
|
<Card className="bg-card border-border-soft shadow-xs overflow-hidden"> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<table className="w-full text-start text-xs border-collapse"> |
||||
|
<thead> |
||||
|
<tr className="border-b border-border/50 bg-secondary/15 text-grey-2"> |
||||
|
<th className="p-4 font-bold text-start">کد سفارش</th> |
||||
|
<th className="p-4 font-bold text-start">کاربر</th> |
||||
|
<th className="p-4 font-bold text-start">تور مقصد</th> |
||||
|
<th className="p-4 font-bold text-start">مبلغ کل</th> |
||||
|
<th className="p-4 font-bold text-start">روش پرداخت</th> |
||||
|
<th className="p-4 font-bold text-start">فیش واریزی</th> |
||||
|
<th className="p-4 font-bold text-start">وضعیت رزرو</th> |
||||
|
<th className="p-4 font-bold text-start">تاریخ ثبت</th> |
||||
|
<th className="p-4 font-bold text-center">عملیات</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody className="divide-y divide-border/40"> |
||||
|
{isLoading ? ( |
||||
|
Array.from({ length: 5 }).map((_, i) => ( |
||||
|
<tr key={i} className="animate-pulse"> |
||||
|
<td colSpan={9} className="p-4 text-center text-grey-4"> |
||||
|
در حال دریافت لیست سفارشات از دیتابیس... |
||||
|
</td> |
||||
|
</tr> |
||||
|
)) |
||||
|
) : error ? ( |
||||
|
<tr> |
||||
|
<td colSpan={9} className="p-8 text-center text-rose-400 font-bold"> |
||||
|
{error} |
||||
|
</td> |
||||
|
</tr> |
||||
|
) : orders.length === 0 ? ( |
||||
|
<tr> |
||||
|
<td colSpan={9} className="p-12 text-center text-grey-3"> |
||||
|
<Ic name="inbox" className="size-8 mx-auto mb-2 text-grey-4" /> |
||||
|
<p className="font-bold">هیچ سفارشی با مشخصات انتخابی یافت نشد.</p> |
||||
|
</td> |
||||
|
</tr> |
||||
|
) : ( |
||||
|
orders.map((order) => { |
||||
|
const isUpdating = updatingId === order.id |
||||
|
return ( |
||||
|
<tr key={order.id} className="hover:bg-surface-2/40 transition-colors"> |
||||
|
{/* ID */} |
||||
|
<td className="p-4 font-extrabold text-foreground"> |
||||
|
#{order.id} |
||||
|
</td> |
||||
|
|
||||
|
{/* User */} |
||||
|
<td className="p-4 font-bold text-grey-1"> |
||||
|
{order.user || 'نامشخص'} |
||||
|
</td> |
||||
|
|
||||
|
{/* Tour Title */} |
||||
|
<td className="p-4 font-semibold text-foreground max-w-[220px] truncate"> |
||||
|
{order.tour_title || order.tour_slug || '-'} |
||||
|
</td> |
||||
|
|
||||
|
{/* Total Price */} |
||||
|
<td className="p-4 font-black text-foreground"> |
||||
|
${parseFloat(order.total_price || '0').toLocaleString()} |
||||
|
</td> |
||||
|
|
||||
|
{/* Payment Method */} |
||||
|
<td className="p-4 text-grey-3 font-medium"> |
||||
|
{order.payment_method || (order.is_paid ? 'آنلاین' : 'کارت به کارت / فیش')} |
||||
|
</td> |
||||
|
|
||||
|
{/* Payment Receipt */} |
||||
|
<td className="p-4"> |
||||
|
{order.payment_receipt ? ( |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => { |
||||
|
setPreviewReceiptUrl(order.payment_receipt || null) |
||||
|
setPreviewReceiptOrderId(order.id) |
||||
|
setPreviewReceiptVerified(order.receipt_verified) |
||||
|
}} |
||||
|
className="size-8 rounded-lg overflow-hidden border border-border/50 hover:opacity-80 transition-opacity shrink-0" |
||||
|
> |
||||
|
<img |
||||
|
src={order.payment_receipt} |
||||
|
alt="Receipt" |
||||
|
className="w-full h-full object-cover" |
||||
|
/> |
||||
|
</button> |
||||
|
<span |
||||
|
className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${ |
||||
|
order.receipt_verified |
||||
|
? 'bg-emerald-500/15 text-emerald-400' |
||||
|
: 'bg-amber-500/15 text-amber-400' |
||||
|
}`}
|
||||
|
> |
||||
|
{order.receipt_verified ? 'تأییدشده' : 'بررسی نشده'} |
||||
|
</span> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<span className="text-[11px] text-grey-4">بدون فیش</span> |
||||
|
)} |
||||
|
</td> |
||||
|
|
||||
|
{/* Status */} |
||||
|
<td className="p-4"> |
||||
|
<TourStatusBadge status={order.status} type="order" /> |
||||
|
</td> |
||||
|
|
||||
|
{/* Date */} |
||||
|
<td className="p-4 text-grey-3 font-medium text-[11px]"> |
||||
|
{order.created ? new Date(order.created).toLocaleDateString('fa-IR') : '-'} |
||||
|
</td> |
||||
|
|
||||
|
{/* Actions */} |
||||
|
<td className="p-4 text-center"> |
||||
|
<div className="flex items-center justify-center gap-1.5"> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
variant="ghost" |
||||
|
onClick={() => setDetailOrder(order)} |
||||
|
className="size-8 p-0 rounded-xl hover:bg-surface-3 text-grey-2" |
||||
|
title="مشاهده جزئیات کامل" |
||||
|
> |
||||
|
<Ic name="eye" className="size-4" /> |
||||
|
</Button> |
||||
|
|
||||
|
{order.status !== 'APPROVED' && ( |
||||
|
<Button |
||||
|
size="sm" |
||||
|
variant="outline" |
||||
|
disabled={isUpdating} |
||||
|
onClick={() => handleUpdateStatus(order.id, 'APPROVED')} |
||||
|
className="h-8 px-2.5 rounded-xl border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10 text-xs font-bold gap-1" |
||||
|
> |
||||
|
<Ic name="check" className="size-3.5" /> |
||||
|
<span>تایید</span> |
||||
|
</Button> |
||||
|
)} |
||||
|
|
||||
|
{order.status !== 'REJECTED' && ( |
||||
|
<Button |
||||
|
size="sm" |
||||
|
variant="ghost" |
||||
|
disabled={isUpdating} |
||||
|
onClick={() => handleUpdateStatus(order.id, 'REJECTED')} |
||||
|
className="size-8 p-0 rounded-xl hover:bg-rose-500/10 text-rose-400" |
||||
|
title="رد سفارش" |
||||
|
> |
||||
|
<Ic name="x" className="size-4" /> |
||||
|
</Button> |
||||
|
)} |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
) |
||||
|
}) |
||||
|
)} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Receipt Image Preview Dialog */} |
||||
|
<Dialog |
||||
|
open={Boolean(previewReceiptUrl)} |
||||
|
onOpenChange={(op) => !op && setPreviewReceiptUrl(null)} |
||||
|
> |
||||
|
<DialogContent className="max-w-[560px] p-0 overflow-hidden"> |
||||
|
<DialogHeader className="p-5 border-b border-border/40"> |
||||
|
<DialogTitle className="text-base font-black text-foreground"> |
||||
|
فیش واریزی بانکی سفارش #{previewReceiptOrderId} |
||||
|
</DialogTitle> |
||||
|
<DialogDescription className="text-xs text-grey-3"> |
||||
|
بررسی و تطبیق شماره پیگیری، مبلغ واریزی و صحت تراکنش |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
<div className="p-5 flex justify-center bg-black/40 max-h-[60vh] overflow-auto"> |
||||
|
{previewReceiptUrl && ( |
||||
|
<img |
||||
|
src={previewReceiptUrl} |
||||
|
alt="فیش واریزی بانکی" |
||||
|
className="rounded-xl max-h-[50vh] object-contain shadow-lg" |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<DialogFooter className="p-4 bg-secondary/15 border-t border-border/40 flex items-center justify-between"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="text-xs text-grey-3">وضعیت فیش:</span> |
||||
|
<span |
||||
|
className={`text-xs font-bold px-2 py-0.5 rounded-full ${ |
||||
|
previewReceiptVerified |
||||
|
? 'bg-emerald-500/15 text-emerald-400' |
||||
|
: 'bg-amber-500/15 text-amber-400' |
||||
|
}`}
|
||||
|
> |
||||
|
{previewReceiptVerified ? 'تأییدشده' : 'در انتظار تأیید'} |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2"> |
||||
|
<DialogClose asChild> |
||||
|
<Button variant="outline" size="sm" className="rounded-xl"> |
||||
|
بستن |
||||
|
</Button> |
||||
|
</DialogClose> |
||||
|
{previewReceiptOrderId && ( |
||||
|
<Button |
||||
|
size="sm" |
||||
|
onClick={() => |
||||
|
handleToggleReceiptVerify(previewReceiptOrderId, previewReceiptVerified) |
||||
|
} |
||||
|
className={`rounded-xl font-bold gap-1.5 ${ |
||||
|
previewReceiptVerified |
||||
|
? 'bg-amber-500 text-white hover:bg-amber-600' |
||||
|
: 'bg-emerald-500 text-white hover:bg-emerald-600' |
||||
|
}`}
|
||||
|
> |
||||
|
<Ic name="check" className="size-3.5" /> |
||||
|
<span>{previewReceiptVerified ? 'لغو تایید فیش' : 'تایید فیش و پذیرش رزرو'}</span> |
||||
|
</Button> |
||||
|
)} |
||||
|
</div> |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
|
||||
|
{/* Order Detail Modal */} |
||||
|
<Dialog open={Boolean(detailOrder)} onOpenChange={(op) => !op && setDetailOrder(null)}> |
||||
|
<DialogContent className="max-w-[600px] p-0 overflow-hidden"> |
||||
|
<DialogHeader className="p-5 border-b border-border/40"> |
||||
|
<DialogTitle className="text-base font-black text-foreground"> |
||||
|
جزئیات سفارش تور #{detailOrder?.id} |
||||
|
</DialogTitle> |
||||
|
<DialogDescription className="text-xs text-grey-3"> |
||||
|
اطلاعات کامل رزرو، خریدار و پارامترهای مالی |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
{detailOrder && ( |
||||
|
<div className="p-6 space-y-4 text-xs"> |
||||
|
<div className="grid grid-cols-2 gap-4"> |
||||
|
<div className="rounded-xl bg-surface-2 p-3 space-y-1"> |
||||
|
<p className="text-grey-3 font-bold">نام کاربر ثبتکننده</p> |
||||
|
<p className="font-extrabold text-foreground text-sm">{detailOrder.user}</p> |
||||
|
</div> |
||||
|
<div className="rounded-xl bg-surface-2 p-3 space-y-1"> |
||||
|
<p className="text-grey-3 font-bold">عنوان تور</p> |
||||
|
<p className="font-extrabold text-foreground text-sm truncate"> |
||||
|
{detailOrder.tour_title} |
||||
|
</p> |
||||
|
</div> |
||||
|
<div className="rounded-xl bg-surface-2 p-3 space-y-1"> |
||||
|
<p className="text-grey-3 font-bold">قیمت پایه هر نفر</p> |
||||
|
<p className="font-black text-foreground text-sm"> |
||||
|
${parseFloat(detailOrder.price || '0').toLocaleString()} |
||||
|
</p> |
||||
|
</div> |
||||
|
<div className="rounded-xl bg-surface-2 p-3 space-y-1"> |
||||
|
<p className="text-grey-3 font-bold">مبلغ کل پرداختی</p> |
||||
|
<p className="font-black text-emerald-400 text-sm"> |
||||
|
${parseFloat(detailOrder.total_price || '0').toLocaleString()} |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{detailOrder.request_details && ( |
||||
|
<div className="rounded-xl bg-surface-2 p-3.5 space-y-1"> |
||||
|
<p className="font-bold text-grey-3">یادداشت و جزئیات درخواست مسافر:</p> |
||||
|
<p className="text-foreground leading-relaxed"> |
||||
|
{detailOrder.request_details} |
||||
|
</p> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-surface-2/60 border border-border/40"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="text-grey-3">وضعیت فعلی سفارش:</span> |
||||
|
<TourStatusBadge status={detailOrder.status} type="order" /> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="text-grey-3">تراکنش مالی:</span> |
||||
|
<span |
||||
|
className={`font-bold ${ |
||||
|
detailOrder.is_paid ? 'text-emerald-400' : 'text-amber-400' |
||||
|
}`}
|
||||
|
> |
||||
|
{detailOrder.is_paid ? 'پرداخت قطعی' : 'پرداختنشده / نیازمند بررسی'} |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<DialogFooter className="p-4 bg-secondary/15 border-t border-border/40 flex justify-end"> |
||||
|
<DialogClose asChild> |
||||
|
<Button variant="outline" size="sm" className="rounded-xl"> |
||||
|
بستن |
||||
|
</Button> |
||||
|
</DialogClose> |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -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<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 [countryFilter, setCountryFilter] = useState('ALL') |
||||
|
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid') |
||||
|
const [languageCode, setLanguageCode] = useState<string>('') |
||||
|
|
||||
|
// وضعیت شیت جزئیات تور
|
||||
|
const [selectedTourId, setSelectedTourId] = useState<number | null>(null) |
||||
|
const [isDetailOpen, setIsDetailOpen] = useState(false) |
||||
|
|
||||
|
// وضعیت دیالوگ ترجمهها
|
||||
|
const [transTourId, setTransTourId] = useState<number | null>(null) |
||||
|
const [transTourTitle, setTransTourTitle] = useState<string>('') |
||||
|
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 ( |
||||
|
<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"> |
||||
|
{/* انتخاب زبان */} |
||||
|
<select |
||||
|
value={languageCode} |
||||
|
onChange={(e) => setLanguageCode(e.target.value)} |
||||
|
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary shadow-xs" |
||||
|
> |
||||
|
<option value="">همه زبانها</option> |
||||
|
<option value="fa">فارسی (fa)</option> |
||||
|
<option value="en">انگلیسی (en)</option> |
||||
|
<option value="ar">عربی (ar)</option> |
||||
|
</select> |
||||
|
|
||||
|
{/* تغییر نمای شبکهای / جدولی */} |
||||
|
<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 |
||||
|
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-5"> |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs transition hover:border-border"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs text-grey-3 font-medium">کل تورها</span> |
||||
|
<div className="flex size-7 items-center justify-center rounded-lg bg-foreground/5 text-foreground"> |
||||
|
<Ic name="map" className="size-3.5" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 text-2xl font-black text-foreground font-mono">{stats.total}</div> |
||||
|
<div className="mt-1 text-[11px] text-grey-3">تورهای منتشرشده</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4 shadow-xs transition hover:border-emerald-500/40"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">در دسترس</span> |
||||
|
<div className="flex size-7 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500"> |
||||
|
<Ic name="check" className="size-3.5" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono"> |
||||
|
{stats.available} |
||||
|
</div> |
||||
|
<div className="mt-1 text-[11px] text-emerald-700/60 dark:text-emerald-300/60">آماده ثبتنام مسافر</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-4 shadow-xs transition hover:border-rose-500/40"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs text-rose-600 dark:text-rose-400 font-medium">تکمیل ظرفیت</span> |
||||
|
<div className="flex size-7 items-center justify-center rounded-lg bg-rose-500/10 text-rose-500"> |
||||
|
<Ic name="users" className="size-3.5" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 text-2xl font-black text-rose-600 dark:text-rose-400 font-mono"> |
||||
|
{stats.soldOut} |
||||
|
</div> |
||||
|
<div className="mt-1 text-[11px] text-rose-700/60 dark:text-rose-300/60">ظرفیت تکمیل شده</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-sky-500/20 bg-sky-500/5 p-4 shadow-xs transition hover:border-sky-500/40"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs text-sky-600 dark:text-sky-400 font-medium">در حال اجرا</span> |
||||
|
<div className="flex size-7 items-center justify-center rounded-lg bg-sky-500/10 text-sky-500"> |
||||
|
<Ic name="calendar" className="size-3.5" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 text-2xl font-black text-sky-600 dark:text-sky-400 font-mono"> |
||||
|
{stats.traveling} |
||||
|
</div> |
||||
|
<div className="mt-1 text-[11px] text-sky-700/60 dark:text-sky-300/60">سفرهای جاری</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4 shadow-xs transition hover:border-amber-500/40"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">در انتظار شروع</span> |
||||
|
<div className="flex size-7 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500"> |
||||
|
<Ic name="clock" className="size-3.5" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono"> |
||||
|
{stats.pending} |
||||
|
</div> |
||||
|
<div className="mt-1 text-[11px] text-amber-700/60 dark:text-amber-300/60">تاریخ شروع در آینده</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* نوار فیلتر و جستجو */} |
||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/30 p-4 lg:flex-row lg:items-center lg:justify-between"> |
||||
|
<div className="relative flex-1 max-w-md"> |
||||
|
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-grey-3" /> |
||||
|
<Input |
||||
|
value={searchTerm} |
||||
|
onChange={(e) => setSearchTerm(e.target.value)} |
||||
|
placeholder="جستجوی تور با عنوان یا اسلاگ..." |
||||
|
className="pr-9 text-xs bg-surface-base" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex flex-wrap items-center gap-2"> |
||||
|
{/* وضعیت فروش */} |
||||
|
<select |
||||
|
value={statusFilter} |
||||
|
onChange={(e) => setStatusFilter(e.target.value)} |
||||
|
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden" |
||||
|
> |
||||
|
<option value="ALL">همه وضعیتهای فروش</option> |
||||
|
<option value="AVAILABLE">فقط در دسترس (Available)</option> |
||||
|
<option value="SOLD_OUT">تکمیل ظرفیت (Sold Out)</option> |
||||
|
<option value="NO_SHOW">عدم نمایش (No Show)</option> |
||||
|
</select> |
||||
|
|
||||
|
{/* وضعیت اجرا */} |
||||
|
<select |
||||
|
value={tripStatusFilter} |
||||
|
onChange={(e) => setTripStatusFilter(e.target.value)} |
||||
|
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden" |
||||
|
> |
||||
|
<option value="ALL">همه وضعیتهای سفر</option> |
||||
|
<option value="PENDING">در انتظار اجرا (Pending)</option> |
||||
|
<option value="TRAVELING">در حال برگزاری (Traveling)</option> |
||||
|
<option value="FINISHED">پایانیافته (Finished)</option> |
||||
|
</select> |
||||
|
|
||||
|
{/* کشور مقصد */} |
||||
|
<select |
||||
|
value={countryFilter} |
||||
|
onChange={(e) => setCountryFilter(e.target.value)} |
||||
|
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden" |
||||
|
> |
||||
|
<option value="ALL">همه مقاصد</option> |
||||
|
<option value="IQ">🇮🇶 عراق</option> |
||||
|
<option value="IR">🇮🇷 ایران</option> |
||||
|
<option value="SA">🇸🇦 عربستان</option> |
||||
|
<option value="SY">🇸🇾 سوریه</option> |
||||
|
</select> |
||||
|
|
||||
|
{(searchTerm || statusFilter !== 'ALL' || tripStatusFilter !== 'ALL' || countryFilter !== 'ALL') && ( |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => { |
||||
|
setSearchTerm('') |
||||
|
setStatusFilter('ALL') |
||||
|
setTripStatusFilter('ALL') |
||||
|
setCountryFilter('ALL') |
||||
|
}} |
||||
|
className="text-xs text-rose-500 hover:bg-rose-500/10" |
||||
|
> |
||||
|
پاکسازی فیلترها |
||||
|
</Button> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* وضعیت لودینگ */} |
||||
|
{isLoading ? ( |
||||
|
viewMode === 'grid' ? ( |
||||
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> |
||||
|
{Array.from({ length: 8 }).map((_, i) => ( |
||||
|
<div key={i} className="rounded-2xl border border-border-soft bg-card/30 p-4 space-y-3"> |
||||
|
<Skeleton className="h-44 w-full rounded-xl" /> |
||||
|
<Skeleton className="h-5 w-3/4" /> |
||||
|
<Skeleton className="h-4 w-1/2" /> |
||||
|
<div className="flex justify-between pt-2"> |
||||
|
<Skeleton className="h-6 w-20" /> |
||||
|
<Skeleton className="h-6 w-16" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/30 p-6 space-y-3"> |
||||
|
{Array.from({ length: 6 }).map((_, i) => ( |
||||
|
<Skeleton key={i} className="h-12 w-full rounded-xl" /> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
) : 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"> |
||||
|
{filteredTours.map((tour) => { |
||||
|
const imgUrl = |
||||
|
tour.image?.image_url?.medium || |
||||
|
tour.image?.image_url?.large || |
||||
|
tour.image?.image_url?.original || |
||||
|
'/placeholder.jpg' |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
key={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" |
||||
|
> |
||||
|
<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 inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/20" /> |
||||
|
|
||||
|
{/* بج کشور مقصد */} |
||||
|
<div className="absolute top-3 right-3"> |
||||
|
<CountryFlagBadge countryCode={tour.destination_country} /> |
||||
|
</div> |
||||
|
|
||||
|
{/* وضعیت تجاری تور */} |
||||
|
<div className="absolute bottom-3 right-3 flex items-center gap-1.5"> |
||||
|
<TourStatusBadge status={tour.status} type="sale" /> |
||||
|
<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 font-mono" dir="ltr"> |
||||
|
<Ic name="calendar" className="size-3.5 shrink-0 text-primary" /> |
||||
|
<span className="truncate">{tour.started_at} الی {tour.ended_at}</span> |
||||
|
</div> |
||||
|
|
||||
|
{/* قیمت پایه و رده قیمتی */} |
||||
|
<div className="flex items-baseline justify-between pt-1 border-t border-border-soft"> |
||||
|
<span className="text-[11px] text-grey-3">قیمت بزرگسال:</span> |
||||
|
<div className="text-sm font-black text-foreground"> |
||||
|
${parseFloat(tour.price).toLocaleString()} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* فوتر کارت و دکمه جزئیات */} |
||||
|
<div className="p-4 pt-0 flex items-center gap-2"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
className="flex-1 text-xs font-bold border-border-soft group-hover:border-primary group-hover:bg-primary group-hover:text-white transition" |
||||
|
onClick={() => handleOpenDetail(tour.id)} |
||||
|
> |
||||
|
<Ic name="eye" className="size-3.5 ml-1.5" /> |
||||
|
مشاهده جزئیات کامل |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
className="size-8 p-0 rounded-xl text-grey-3 hover:text-primary hover:bg-primary/10 transition shrink-0" |
||||
|
title="مدیریت ترجمههای ۵ زبانه" |
||||
|
onClick={(e) => { |
||||
|
e.stopPropagation() |
||||
|
setTransTourId(tour.id) |
||||
|
setTransTourTitle(tour.title) |
||||
|
setIsTransOpen(true) |
||||
|
}} |
||||
|
> |
||||
|
<Ic name="globe" className="size-4" /> |
||||
|
</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-right text-xs font-bold">وضعیت سفر</TableHead> |
||||
|
<TableHead className="text-center text-xs font-bold">عملیات</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{filteredTours.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> |
||||
|
<CountryFlagBadge countryCode={tour.destination_country} /> |
||||
|
</TableCell> |
||||
|
<TableCell className="font-mono text-xs text-grey-2" dir="ltr"> |
||||
|
{tour.started_at} ~ {tour.ended_at} |
||||
|
</TableCell> |
||||
|
<TableCell className="font-black text-xs text-foreground font-mono"> |
||||
|
${parseFloat(tour.price).toLocaleString()} |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<TourStatusBadge status={tour.status} type="sale" /> |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<TourStatusBadge status={tour.trip_status} type="trip" /> |
||||
|
</TableCell> |
||||
|
<TableCell className="text-center"> |
||||
|
<div className="flex items-center justify-center gap-1"> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
className="size-8 p-0 text-primary hover:bg-primary/10" |
||||
|
title="مشاهده جزئیات کامل" |
||||
|
onClick={(e) => { |
||||
|
e.stopPropagation() |
||||
|
handleOpenDetail(tour.id) |
||||
|
}} |
||||
|
> |
||||
|
<Ic name="eye" className="size-4" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
className="size-8 p-0 text-grey-3 hover:text-primary hover:bg-primary/10" |
||||
|
title="مدیریت ترجمههای ۵ زبانه" |
||||
|
onClick={(e) => { |
||||
|
e.stopPropagation() |
||||
|
setTransTourId(tour.id) |
||||
|
setTransTourTitle(tour.title) |
||||
|
setIsTransOpen(true) |
||||
|
}} |
||||
|
> |
||||
|
<Ic name="globe" className="size-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
))} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* شیت جزئیات کامل تور */} |
||||
|
<TourDetailSheet |
||||
|
tourId={selectedTourId} |
||||
|
isOpen={isDetailOpen} |
||||
|
onClose={() => setIsDetailOpen(false)} |
||||
|
/> |
||||
|
|
||||
|
{/* دیالوگ مدیریت ترجمههای ۵ زبانه */} |
||||
|
<TourTranslationsDialog |
||||
|
tourId={transTourId} |
||||
|
tourTitle={transTourTitle} |
||||
|
open={isTransOpen} |
||||
|
onOpenChange={setIsTransOpen} |
||||
|
onSuccess={() => loadData()} |
||||
|
/> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
@ -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<boolean>(false) |
||||
|
|
||||
|
const [tour, setTour] = useState<TourDetailItem | null>(null) |
||||
|
const [itineraries, setItineraries] = useState<TourItineraryItem[]>([]) |
||||
|
const [passengersGroup, setPassengersGroup] = useState<TourPassengerGroupItem[]>([]) |
||||
|
const [comments, setComments] = useState<TourCommentItem[]>([]) |
||||
|
|
||||
|
const [isLoading, setIsLoading] = useState(false) |
||||
|
const [isLoadingSub, setIsLoadingSub] = useState(false) |
||||
|
const [error, setError] = useState<string | null>(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 ( |
||||
|
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}> |
||||
|
<SheetContent side="left" className="w-full sm:max-w-2xl overflow-y-auto p-0 border-r border-border-soft bg-background"> |
||||
|
<SheetHeader className="sticky top-0 z-20 border-b border-border-soft bg-background/95 p-5 backdrop-blur-md"> |
||||
|
<div className="flex items-start justify-between gap-4"> |
||||
|
<div className="space-y-1 text-start"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<SheetTitle className="text-lg font-black text-foreground"> |
||||
|
{tour?.title || 'جزئیات تور'} |
||||
|
</SheetTitle> |
||||
|
</div> |
||||
|
<SheetDescription className="text-xs text-grey-3"> |
||||
|
کد شناسایی: #{tourId} | اسلاگ سیستم: {tour?.slug} |
||||
|
</SheetDescription> |
||||
|
</div> |
||||
|
{tour && ( |
||||
|
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 shrink-0"> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
variant="outline" |
||||
|
onClick={() => setIsTransDialogOpen(true)} |
||||
|
className="gap-1.5 text-xs rounded-xl border-primary/40 text-primary hover:bg-primary/10 h-7 px-2.5" |
||||
|
> |
||||
|
<Ic name="globe" className="size-3.5" /> |
||||
|
<span>مدیریت ترجمهها (۵ زبان)</span> |
||||
|
</Button> |
||||
|
<div className="flex items-center gap-1.5"> |
||||
|
<TourStatusBadge status={tour.status} type="sale" /> |
||||
|
<TourStatusBadge status={tour.trip_status} type="trip" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<Tabs |
||||
|
value={activeTab} |
||||
|
onValueChange={(val: any) => setActiveTab(val)} |
||||
|
className="mt-4 w-full" |
||||
|
> |
||||
|
<TabsList className="grid w-full grid-cols-5 bg-surface-base border border-border-soft p-1"> |
||||
|
<TabsTrigger value="overview" className="text-xs">مشخصات</TabsTrigger> |
||||
|
<TabsTrigger value="itinerary" className="text-xs">برنامه سفر</TabsTrigger> |
||||
|
<TabsTrigger value="passengers" className="text-xs">مسافران</TabsTrigger> |
||||
|
<TabsTrigger value="features" className="text-xs">امکانات</TabsTrigger> |
||||
|
<TabsTrigger value="comments" className="text-xs">نظرات</TabsTrigger> |
||||
|
</TabsList> |
||||
|
</Tabs> |
||||
|
</SheetHeader> |
||||
|
|
||||
|
<div className="p-6 space-y-6"> |
||||
|
{isLoading ? ( |
||||
|
<div className="space-y-4"> |
||||
|
<Skeleton className="h-44 w-full rounded-2xl" /> |
||||
|
<div className="grid grid-cols-2 gap-3"> |
||||
|
<Skeleton className="h-20 rounded-xl" /> |
||||
|
<Skeleton className="h-20 rounded-xl" /> |
||||
|
</div> |
||||
|
<Skeleton className="h-32 w-full rounded-xl" /> |
||||
|
</div> |
||||
|
) : error ? ( |
||||
|
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-6 text-center text-rose-500"> |
||||
|
<Ic name="alert" className="mx-auto size-8 mb-2" /> |
||||
|
<p className="font-bold text-sm">{error}</p> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
className="mt-4 border-rose-500/30 text-rose-500" |
||||
|
onClick={() => tourId && fetchTourDetail(tourId).then(setTour)} |
||||
|
> |
||||
|
تلاش مجدد |
||||
|
</Button> |
||||
|
</div> |
||||
|
) : tour ? ( |
||||
|
<> |
||||
|
{/* تب ۱: مشخصات کلی و مالی */} |
||||
|
{activeTab === 'overview' && ( |
||||
|
<div className="space-y-6"> |
||||
|
{/* گالری تصاویر */} |
||||
|
{tour.images && tour.images.length > 0 && ( |
||||
|
<div className="space-y-2"> |
||||
|
<h4 className="text-xs font-bold text-grey-2">گالری تصاویر تور</h4> |
||||
|
<div className="flex gap-2.5 overflow-x-auto pb-2 no-scrollbar"> |
||||
|
{tour.images.map((img, i) => ( |
||||
|
<div |
||||
|
key={img.id || i} |
||||
|
className="relative size-24 shrink-0 overflow-hidden rounded-xl border border-border-soft bg-surface-base shadow-2xs" |
||||
|
> |
||||
|
<img |
||||
|
src={img.image_url?.medium || img.image_url?.original || '/placeholder.jpg'} |
||||
|
alt="" |
||||
|
className="h-full w-full object-cover" |
||||
|
/> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* توضیحات تور */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2"> |
||||
|
<h4 className="text-xs font-extrabold text-foreground">درباره این تور</h4> |
||||
|
<p className="text-xs leading-relaxed text-grey-2 whitespace-pre-line"> |
||||
|
{tour.description} |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
{/* بازه تاریخ و ظرفیت */} |
||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> |
||||
|
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> |
||||
|
<div className="text-[11px] text-grey-3">تاریخ شروع</div> |
||||
|
<div className="mt-1 font-bold text-xs text-foreground font-mono" dir="ltr">{tour.started_at}</div> |
||||
|
</div> |
||||
|
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> |
||||
|
<div className="text-[11px] text-grey-3">تاریخ پایان</div> |
||||
|
<div className="mt-1 font-bold text-xs text-foreground font-mono" dir="ltr">{tour.ended_at}</div> |
||||
|
</div> |
||||
|
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> |
||||
|
<div className="text-[11px] text-grey-3">ظرفیت کل</div> |
||||
|
<div className="mt-1 font-extrabold text-sm text-foreground">{tour.capacity} نفر</div> |
||||
|
</div> |
||||
|
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> |
||||
|
<div className="text-[11px] text-grey-3">فروخته شده</div> |
||||
|
<div className="mt-1 font-extrabold text-sm text-primary">{tour.number_sold} نفر</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* نوار تکمیل ظرفیت */} |
||||
|
<div className="rounded-xl border border-border-soft bg-card/20 p-4 space-y-2"> |
||||
|
<div className="flex justify-between text-xs font-bold"> |
||||
|
<span>وضعیت تکمیل ظرفیت</span> |
||||
|
<span className="text-primary font-mono"> |
||||
|
{Math.round((tour.number_sold / (tour.capacity || 1)) * 100)}% |
||||
|
</span> |
||||
|
</div> |
||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-border-soft"> |
||||
|
<div |
||||
|
className="h-full rounded-full bg-gradient-to-r from-primary to-amber-500" |
||||
|
style={{ |
||||
|
width: `${Math.min(100, Math.round((tour.number_sold / (tour.capacity || 1)) * 100))}%`, |
||||
|
}} |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="flex justify-between text-[11px] text-grey-3"> |
||||
|
<span>ظرفیت باقیمانده: {Math.max(0, tour.capacity - tour.number_sold)} صندلی</span> |
||||
|
<span>ظرفیت کل: {tour.capacity}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* تفکیک نرخها و قیمتگذاری */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"> |
||||
|
<h4 className="text-xs font-extrabold text-foreground">جدول نرخهای مصوب</h4> |
||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> |
||||
|
<div className="rounded-xl border border-border-soft bg-surface-base p-3"> |
||||
|
<span className="text-[11px] text-grey-3">بزرگسال (Adult 12+)</span> |
||||
|
<div className="mt-1 text-sm font-black text-foreground"> |
||||
|
${parseFloat(tour.price).toLocaleString()} |
||||
|
</div> |
||||
|
{tour.percent_off > 0 && ( |
||||
|
<div className="mt-1 flex items-center gap-1.5 text-[11px]"> |
||||
|
<span className="rounded bg-rose-500/10 px-1.5 py-0.2 font-bold text-rose-500"> |
||||
|
{tour.percent_off}% تخفیف |
||||
|
</span> |
||||
|
<span className="font-bold text-emerald-600 dark:text-emerald-400 font-mono"> |
||||
|
نهایی: ${parseFloat(tour.final_price).toLocaleString()} |
||||
|
</span> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-xl border border-border-soft bg-surface-base p-3"> |
||||
|
<span className="text-[11px] text-grey-3">کودک (Child 2-12)</span> |
||||
|
<div className="mt-1 text-sm font-black text-foreground"> |
||||
|
${parseFloat(tour.price_child || '0').toLocaleString()} |
||||
|
</div> |
||||
|
<span className="text-[10px] text-grey-3">صندلی و خدمات کودک</span> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-xl border border-border-soft bg-surface-base p-3"> |
||||
|
<span className="text-[11px] text-grey-3">نوزاد (Infant <2)</span> |
||||
|
<div className="mt-1 text-sm font-black text-foreground"> |
||||
|
${parseFloat(tour.price_infant || '0').toLocaleString()} |
||||
|
</div> |
||||
|
<span className="text-[10px] text-grey-3">بیمه و ترانسفر نوزاد</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* تب ۲: مراحل سفر روزانه (Itinerary) */} |
||||
|
{activeTab === 'itinerary' && ( |
||||
|
<div className="space-y-4"> |
||||
|
{isLoadingSub ? ( |
||||
|
<div className="space-y-3"> |
||||
|
<Skeleton className="h-24 w-full rounded-xl" /> |
||||
|
<Skeleton className="h-24 w-full rounded-xl" /> |
||||
|
<Skeleton className="h-24 w-full rounded-xl" /> |
||||
|
</div> |
||||
|
) : itineraries.length === 0 ? ( |
||||
|
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> |
||||
|
<Ic name="calendar" className="mx-auto size-8 mb-2 opacity-50" /> |
||||
|
<p className="text-xs">برنامه زمانبندی روزانهای برای این تور ثبت نشده است.</p> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="space-y-3"> |
||||
|
{itineraries.map((step, idx) => ( |
||||
|
<div |
||||
|
key={step.id || idx} |
||||
|
className="relative rounded-2xl border border-border-soft bg-card/40 p-4 transition hover:border-primary/40 hover:bg-card/70" |
||||
|
> |
||||
|
<div className="flex items-start justify-between gap-2"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="flex size-6 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary"> |
||||
|
{idx + 1} |
||||
|
</span> |
||||
|
<h5 className="text-xs font-bold text-foreground">{step.title}</h5> |
||||
|
</div> |
||||
|
<div className="text-[11px] text-grey-3 font-mono" dir="ltr"> |
||||
|
{step.started_at ? new Date(step.started_at).toLocaleDateString('fa-IR') : '-'} |
||||
|
</div> |
||||
|
</div> |
||||
|
<p className="mt-2 text-xs leading-relaxed text-grey-2 pr-8"> |
||||
|
{step.summary} |
||||
|
</p> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* تب ۳: مسافران (Passengers) */} |
||||
|
{activeTab === 'passengers' && ( |
||||
|
<div className="space-y-4"> |
||||
|
{isLoadingSub ? ( |
||||
|
<div className="space-y-3"> |
||||
|
<Skeleton className="h-20 w-full rounded-xl" /> |
||||
|
<Skeleton className="h-20 w-full rounded-xl" /> |
||||
|
</div> |
||||
|
) : passengersGroup.length === 0 ? ( |
||||
|
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> |
||||
|
<Ic name="users" className="mx-auto size-8 mb-2 opacity-50" /> |
||||
|
<p className="text-xs">هنوز مسافری برای این تور به ثبت نرسیده است.</p> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="space-y-4"> |
||||
|
{passengersGroup.map((group, gIdx) => ( |
||||
|
<div |
||||
|
key={group.user_id || gIdx} |
||||
|
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3" |
||||
|
> |
||||
|
<div className="flex items-center justify-between border-b border-border-soft pb-2"> |
||||
|
<div className="text-xs font-bold text-foreground"> |
||||
|
سرپرست رزرو: {group.user} |
||||
|
</div> |
||||
|
<span className="rounded-md bg-primary/10 px-2 py-0.5 text-[11px] font-bold text-primary"> |
||||
|
{group.passengers?.length || 0} مسافر |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> |
||||
|
{group.passengers?.map((p) => ( |
||||
|
<div |
||||
|
key={p.id} |
||||
|
className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1" |
||||
|
> |
||||
|
<div className="font-bold text-xs text-foreground">{p.fullname}</div> |
||||
|
<div className="text-[11px] text-grey-3 flex justify-between"> |
||||
|
<span>گذرنامه:</span> |
||||
|
<span className="font-mono font-medium text-foreground">{p.passport_number}</span> |
||||
|
</div> |
||||
|
<div className="text-[11px] text-grey-3 flex justify-between"> |
||||
|
<span>تولد:</span> |
||||
|
<span className="font-mono">{p.birthdate}</span> |
||||
|
</div> |
||||
|
<div className="text-[11px] text-grey-3 flex justify-between"> |
||||
|
<span>تماس:</span> |
||||
|
<span className="font-mono" dir="ltr">{p.phone_number}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* تب ۴: امکانات و نکات سفر */} |
||||
|
{activeTab === 'features' && ( |
||||
|
<div className="space-y-6"> |
||||
|
{/* ویژگیها */} |
||||
|
<div className="space-y-3"> |
||||
|
<h4 className="text-xs font-extrabold text-foreground">ویژگیها و خدمات گنجاندهشده</h4> |
||||
|
{tour.tour_features && tour.tour_features.length > 0 ? ( |
||||
|
<div className="flex flex-wrap gap-2"> |
||||
|
{tour.tour_features.map((feat) => ( |
||||
|
<div |
||||
|
key={feat.id} |
||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-border-soft bg-surface-base px-3 py-1.5 text-xs text-foreground shadow-2xs" |
||||
|
> |
||||
|
<Ic name="check" className="size-3.5 text-emerald-500" /> |
||||
|
<span>{feat.title}</span> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) : ( |
||||
|
<p className="text-xs text-grey-3">ویژگی خاصی درج نشده است.</p> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* نکات سفر */} |
||||
|
<div className="space-y-3"> |
||||
|
<h4 className="text-xs font-extrabold text-foreground">راهنما و توصیههای سفر</h4> |
||||
|
{tour.travel_tips && tour.travel_tips.length > 0 ? ( |
||||
|
<div className="space-y-2.5"> |
||||
|
{tour.travel_tips.map((tip) => ( |
||||
|
<div |
||||
|
key={tip.id} |
||||
|
className="rounded-xl border border-border-soft bg-surface-base p-3.5 space-y-1" |
||||
|
> |
||||
|
<div className="flex items-center gap-2 font-bold text-xs text-primary"> |
||||
|
<Ic name="sparkles" className="size-3.5" /> |
||||
|
<span>{tip.title}</span> |
||||
|
</div> |
||||
|
<p className="text-xs leading-relaxed text-grey-2 pr-5"> |
||||
|
{tip.description} |
||||
|
</p> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
) : ( |
||||
|
<p className="text-xs text-grey-3">توصیه خاصی ثبت نشده است.</p> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* تب ۵: نظرات و امتیازات (Comments) */} |
||||
|
{activeTab === 'comments' && ( |
||||
|
<div className="space-y-4"> |
||||
|
{isLoadingSub ? ( |
||||
|
<div className="space-y-3"> |
||||
|
<Skeleton className="h-20 w-full rounded-xl" /> |
||||
|
<Skeleton className="h-20 w-full rounded-xl" /> |
||||
|
</div> |
||||
|
) : comments.length === 0 ? ( |
||||
|
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> |
||||
|
<Ic name="inbox" className="mx-auto size-8 mb-2 opacity-50" /> |
||||
|
<p className="text-xs">تاکنون نظری برای این تور ثبت نشده است.</p> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="space-y-3"> |
||||
|
{comments.map((c) => ( |
||||
|
<div |
||||
|
key={c.id} |
||||
|
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2" |
||||
|
> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
{c.user_avatar ? ( |
||||
|
<img src={c.user_avatar} alt="" className="size-7 rounded-full object-cover" /> |
||||
|
) : ( |
||||
|
<div className="flex size-7 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary"> |
||||
|
{c.user?.[0] || 'U'} |
||||
|
</div> |
||||
|
)} |
||||
|
<span className="text-xs font-bold text-foreground">{c.user}</span> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-1"> |
||||
|
<span className="text-xs font-bold text-amber-500 font-mono">{c.score}★</span> |
||||
|
<span className="text-[10px] text-grey-3 mr-2"> |
||||
|
({c.duration?.num} {c.duration?.type} پیش) |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<p className="text-xs text-grey-2 pr-9 leading-relaxed"> |
||||
|
{c.text || 'بدون متن'} |
||||
|
</p> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
)} |
||||
|
</> |
||||
|
) : null} |
||||
|
</div> |
||||
|
</SheetContent> |
||||
|
|
||||
|
<TourTranslationsDialog |
||||
|
tourId={tourId} |
||||
|
tourTitle={tour?.title} |
||||
|
open={isTransDialogOpen} |
||||
|
onOpenChange={setIsTransDialogOpen} |
||||
|
onSuccess={() => { |
||||
|
if (tourId) { |
||||
|
fetchTourDetail(tourId).then(setTour).catch(() => {}) |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
</Sheet> |
||||
|
) |
||||
|
} |
||||
|
|
||||
@ -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 ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
<span className="size-1.5 rounded-full bg-emerald-500" /> |
||||
|
در دسترس |
||||
|
</Badge> |
||||
|
) |
||||
|
case 'SOLD_OUT': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
<span className="size-1.5 rounded-full bg-rose-500" /> |
||||
|
تکمیل ظرفیت |
||||
|
</Badge> |
||||
|
) |
||||
|
case 'NO_SHOW': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-grey-3/30 bg-grey-3/10 text-grey-2 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
عدم نمایش |
||||
|
</Badge> |
||||
|
) |
||||
|
default: |
||||
|
return ( |
||||
|
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}> |
||||
|
{status || 'نامشخص'} |
||||
|
</Badge> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (type === 'trip') { |
||||
|
switch (status?.toUpperCase()) { |
||||
|
case 'PENDING': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
<span className="size-1.5 rounded-full bg-amber-500" /> |
||||
|
در انتظار اجرا |
||||
|
</Badge> |
||||
|
) |
||||
|
case 'TRAVELING': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium text-[11px] gap-1.5 animate-pulse', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
<span className="size-1.5 rounded-full bg-sky-500" /> |
||||
|
در حال برگزاری |
||||
|
</Badge> |
||||
|
) |
||||
|
case 'FINISHED': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-slate-500/30 bg-slate-500/10 text-slate-500 dark:text-slate-400 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
پایانیافته |
||||
|
</Badge> |
||||
|
) |
||||
|
default: |
||||
|
return ( |
||||
|
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}> |
||||
|
{status || 'نامشخص'} |
||||
|
</Badge> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// نوع سفارش (Order Status)
|
||||
|
switch (status?.toUpperCase()) { |
||||
|
case 'APPROVED': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
<span className="size-1.5 rounded-full bg-emerald-500" /> |
||||
|
تایید شده |
||||
|
</Badge> |
||||
|
) |
||||
|
case 'AWAITING_PAYMENT': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
<span className="size-1.5 rounded-full bg-amber-500" /> |
||||
|
در انتظار پرداخت |
||||
|
</Badge> |
||||
|
) |
||||
|
case 'PENDING': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-indigo-500/30 bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
<span className="size-1.5 rounded-full bg-indigo-500" /> |
||||
|
در انتظار بررسی فیش |
||||
|
</Badge> |
||||
|
) |
||||
|
case 'REJECTED': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
<span className="size-1.5 rounded-full bg-rose-500" /> |
||||
|
رد شده |
||||
|
</Badge> |
||||
|
) |
||||
|
case 'EXPIRED': |
||||
|
return ( |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={cn( |
||||
|
'border-zinc-500/30 bg-zinc-500/10 text-zinc-500 font-medium text-[11px] gap-1.5', |
||||
|
className |
||||
|
)} |
||||
|
> |
||||
|
منقضی شده |
||||
|
</Badge> |
||||
|
) |
||||
|
default: |
||||
|
return ( |
||||
|
<Badge variant="outline" className={cn('text-xs text-grey-2', className)}> |
||||
|
{status || 'نامشخص'} |
||||
|
</Badge> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function CountryFlagBadge({ countryCode }: { countryCode: string | null }) { |
||||
|
if (!countryCode) return <span className="text-grey-3 text-xs">-</span> |
||||
|
|
||||
|
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 ( |
||||
|
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border-soft bg-surface-base px-2 py-0.5 text-xs font-semibold text-foreground shadow-xs"> |
||||
|
<span>{flag}</span> |
||||
|
<span>{name}</span> |
||||
|
</span> |
||||
|
) |
||||
|
} |
||||
@ -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<LangCode>('ar') |
||||
|
const [activeSection, setActiveSection] = React.useState<'base' | 'features' | 'tips' | 'itinerary'>('base') |
||||
|
const [isLoading, setIsLoading] = React.useState<boolean>(false) |
||||
|
const [isSaving, setIsSaving] = React.useState<boolean>(false) |
||||
|
const [errorMessage, setErrorMessage] = React.useState<string | null>(null) |
||||
|
const [successMessage, setSuccessMessage] = React.useState<string | null>(null) |
||||
|
|
||||
|
// Translations data
|
||||
|
const [transData, setTransData] = React.useState<TourTranslationsResponse | null>(null) |
||||
|
const [itineraries, setItineraries] = React.useState<TourItineraryTranslationsResponseItem[]>([]) |
||||
|
|
||||
|
// Local draft states for current selected language
|
||||
|
const [formTitle, setFormTitle] = React.useState<string>('') |
||||
|
const [formDesc, setFormDesc] = React.useState<string>('') |
||||
|
const [formFeatures, setFormFeatures] = React.useState<string[]>([]) |
||||
|
const [newFeatureText, setNewFeatureText] = React.useState<string>('') |
||||
|
const [formTips, setFormTips] = React.useState<{ title: string; desc: string }[]>([]) |
||||
|
const [newTipTitle, setNewTipTitle] = React.useState<string>('') |
||||
|
const [newTipDesc, setNewTipDesc] = React.useState<string>('') |
||||
|
|
||||
|
// Drafts for itineraries: { [itineraryId]: { title: string, summary: string } }
|
||||
|
const [itinDrafts, setItinDrafts] = React.useState<Record<number, { title: string; summary: string }>>({}) |
||||
|
|
||||
|
// 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<number, { title: string; summary: string }> = {} |
||||
|
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<string, string> = {} |
||||
|
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 open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent className="max-w-[760px] p-0 overflow-hidden max-h-[90vh] flex flex-col"> |
||||
|
{/* Dialog Header */} |
||||
|
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<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="globe" className="size-5" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<DialogTitle className="text-base font-extrabold text-foreground"> |
||||
|
مدیریت ترجمههای ۵ زبانه تور |
||||
|
</DialogTitle> |
||||
|
<DialogDescription className="text-xs text-grey-3 mt-0.5"> |
||||
|
{tourTitle || `تور شناسه #${tourId}`} • ذخیرهسازی پویا در سرور و پایگاهداده |
||||
|
</DialogDescription> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Primary Language Indicator */} |
||||
|
{transData && ( |
||||
|
<div className="flex items-center gap-1.5 rounded-full bg-surface-2 px-3 py-1 text-xs text-grey-2 border border-border/40"> |
||||
|
<span className="text-grey-3">زبان پایه دیتابیس:</span> |
||||
|
<span className="font-bold text-foreground"> |
||||
|
{SUPPORTED_LANGUAGES.find((l) => l.code === transData.primary_language)?.label || |
||||
|
transData.primary_language} |
||||
|
</span> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* 5-Language Selector Bar */} |
||||
|
<div className="flex items-center gap-1.5 mt-4 overflow-x-auto pb-1"> |
||||
|
{SUPPORTED_LANGUAGES.map((lang) => { |
||||
|
const isSelected = lang.code === selectedLang |
||||
|
const hasTrans = Boolean( |
||||
|
transData?.primary_language === lang.code || |
||||
|
transData?.translations[lang.code]?.title |
||||
|
) |
||||
|
return ( |
||||
|
<button |
||||
|
key={lang.code} |
||||
|
type="button" |
||||
|
onClick={() => handleLanguageChange(lang.code)} |
||||
|
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${ |
||||
|
isSelected |
||||
|
? 'bg-primary text-white shadow-md' |
||||
|
: 'bg-surface-2 text-grey-2 hover:bg-surface-3 hover:text-foreground border border-border/30' |
||||
|
}`}
|
||||
|
> |
||||
|
<span className="text-sm">{lang.flag}</span> |
||||
|
<span>{lang.label}</span> |
||||
|
{hasTrans && !isSelected && ( |
||||
|
<span className="size-1.5 rounded-full bg-emerald-500" /> |
||||
|
)} |
||||
|
{transData?.primary_language === lang.code && ( |
||||
|
<span className="text-[10px] opacity-80">(اصلی)</span> |
||||
|
)} |
||||
|
</button> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
{/* Dialog Body */} |
||||
|
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5"> |
||||
|
{isLoading ? ( |
||||
|
<div className="flex flex-col items-center justify-center py-16 text-grey-3"> |
||||
|
<div className="size-8 animate-spin rounded-full border-2 border-primary border-t-transparent mb-3" /> |
||||
|
<p className="text-xs">در حال بارگذاری ترجمهها از Backend...</p> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<> |
||||
|
{errorMessage && ( |
||||
|
<div className="flex items-center gap-2 rounded-xl bg-rose-500/10 border border-rose-500/20 px-4 py-3 text-xs text-rose-400"> |
||||
|
<Ic name="alert" className="size-4 shrink-0" /> |
||||
|
<span>{errorMessage}</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{successMessage && ( |
||||
|
<div className="flex items-center gap-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-xs text-emerald-400"> |
||||
|
<Ic name="check" className="size-4 shrink-0" /> |
||||
|
<span>{successMessage}</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Section Tabs */} |
||||
|
<div className="flex items-center gap-2 border-b border-border/40 pb-2"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setActiveSection('base')} |
||||
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${ |
||||
|
activeSection === 'base' |
||||
|
? 'bg-primary/15 text-primary' |
||||
|
: 'text-grey-3 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
اطلاعات کلی (عنوان و شرح) |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setActiveSection('features')} |
||||
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${ |
||||
|
activeSection === 'features' |
||||
|
? 'bg-primary/15 text-primary' |
||||
|
: 'text-grey-3 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
ویژگیهای تور ({formFeatures.length}) |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setActiveSection('tips')} |
||||
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${ |
||||
|
activeSection === 'tips' |
||||
|
? 'bg-primary/15 text-primary' |
||||
|
: 'text-grey-3 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
نکات و راهنمای سفر ({formTips.length}) |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setActiveSection('itinerary')} |
||||
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${ |
||||
|
activeSection === 'itinerary' |
||||
|
? 'bg-primary/15 text-primary' |
||||
|
: 'text-grey-3 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
مراحل سفر ({itineraries.length}) |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
{/* Active Section Form */} |
||||
|
<div dir={activeLangConfig?.dir || 'rtl'} className="space-y-4"> |
||||
|
{/* 1. Base Info */} |
||||
|
{activeSection === 'base' && ( |
||||
|
<div className="space-y-4"> |
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-grey-2 mb-1.5"> |
||||
|
عنوان تور به زبان {activeLangConfig?.label} ({selectedLang}): |
||||
|
</label> |
||||
|
<Input |
||||
|
value={formTitle} |
||||
|
onChange={(e) => setFormTitle(e.target.value)} |
||||
|
placeholder={`عنوان تور به زبان ${activeLangConfig?.label}...`} |
||||
|
className="bg-surface-2 border-border/50 text-sm" |
||||
|
disabled={isPrimary} |
||||
|
/> |
||||
|
{isPrimary && ( |
||||
|
<p className="text-[11px] text-amber-400/80 mt-1"> |
||||
|
* این زبان، زبان اصلی تور است و از فرم اصلی مدیریت میشود. |
||||
|
</p> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-grey-2 mb-1.5"> |
||||
|
شرح و توضیحات تور: |
||||
|
</label> |
||||
|
<textarea |
||||
|
rows={4} |
||||
|
value={formDesc} |
||||
|
onChange={(e) => setFormDesc(e.target.value)} |
||||
|
placeholder={`توضیحات کامل تور به زبان ${activeLangConfig?.label}...`} |
||||
|
disabled={isPrimary} |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-60" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* 2. Tour Features */} |
||||
|
{activeSection === 'features' && ( |
||||
|
<div className="space-y-3"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Input |
||||
|
value={newFeatureText} |
||||
|
onChange={(e) => setNewFeatureText(e.target.value)} |
||||
|
placeholder={`افزودن ویژگی جدید به ${activeLangConfig?.label}...`} |
||||
|
className="bg-surface-2 border-border/50 text-sm" |
||||
|
onKeyDown={(e) => { |
||||
|
if (e.key === 'Enter') { |
||||
|
e.preventDefault() |
||||
|
handleAddFeature() |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={handleAddFeature} |
||||
|
className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white" |
||||
|
> |
||||
|
<Ic name="plus" className="size-4" /> |
||||
|
<span>افزودن</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-2 mt-3"> |
||||
|
{formFeatures.length === 0 ? ( |
||||
|
<p className="text-xs text-grey-4 text-center py-6"> |
||||
|
هنوز ویژگی برای این زبان اضافه نشده است. |
||||
|
</p> |
||||
|
) : ( |
||||
|
formFeatures.map((feat, idx) => ( |
||||
|
<div |
||||
|
key={idx} |
||||
|
className="flex items-center justify-between gap-3 rounded-xl bg-surface-2 border border-border/40 px-3.5 py-2 text-xs" |
||||
|
> |
||||
|
<span className="font-medium text-foreground">{feat}</span> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => handleRemoveFeature(idx)} |
||||
|
className="text-grey-4 hover:text-rose-400 p-1" |
||||
|
> |
||||
|
<Ic name="x" className="size-3.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
)) |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* 3. Travel Tips */} |
||||
|
{activeSection === 'tips' && ( |
||||
|
<div className="space-y-3"> |
||||
|
<div className="rounded-xl bg-surface-2/60 border border-border/40 p-3 space-y-2"> |
||||
|
<Input |
||||
|
value={newTipTitle} |
||||
|
onChange={(e) => setNewTipTitle(e.target.value)} |
||||
|
placeholder="عنوان نکته (مثلاً: لباس مناسب / Walking Shoes)..." |
||||
|
className="bg-surface-2 text-xs" |
||||
|
/> |
||||
|
<textarea |
||||
|
rows={2} |
||||
|
value={newTipDesc} |
||||
|
onChange={(e) => setNewTipDesc(e.target.value)} |
||||
|
placeholder="توضیح نکته سفر..." |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none" |
||||
|
/> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={handleAddTip} |
||||
|
className="w-full bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-8" |
||||
|
> |
||||
|
<Ic name="plus" className="size-3.5" /> |
||||
|
<span>افزودن نکته سفر</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-2 mt-3"> |
||||
|
{formTips.length === 0 ? ( |
||||
|
<p className="text-xs text-grey-4 text-center py-6"> |
||||
|
هنوز نکته سفری برای این زبان ثبت نشده است. |
||||
|
</p> |
||||
|
) : ( |
||||
|
formTips.map((tip, idx) => ( |
||||
|
<div |
||||
|
key={idx} |
||||
|
className="rounded-xl bg-surface-2 border border-border/40 p-3 text-xs space-y-1 relative" |
||||
|
> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="font-bold text-foreground">{tip.title}</span> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => handleRemoveTip(idx)} |
||||
|
className="text-grey-4 hover:text-rose-400 p-0.5" |
||||
|
> |
||||
|
<Ic name="x" className="size-3.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
<p className="text-grey-3 leading-relaxed">{tip.desc}</p> |
||||
|
</div> |
||||
|
)) |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* 4. Itinerary Steps Translations */} |
||||
|
{activeSection === 'itinerary' && ( |
||||
|
<div className="space-y-4"> |
||||
|
{itineraries.length === 0 ? ( |
||||
|
<p className="text-xs text-grey-4 text-center py-8"> |
||||
|
هیچ مرحله سفری برای این تور ثبت نشده است. |
||||
|
</p> |
||||
|
) : ( |
||||
|
itineraries.map((it, idx) => { |
||||
|
const draft = itinDrafts[it.id] || { title: '', summary: '' } |
||||
|
return ( |
||||
|
<div |
||||
|
key={it.id} |
||||
|
className="rounded-xl bg-surface-2 border border-border/40 p-3.5 space-y-2.5" |
||||
|
> |
||||
|
<div className="flex items-center justify-between text-xs"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="flex size-5 items-center justify-center rounded-full bg-primary/20 text-primary text-[10px] font-bold"> |
||||
|
{idx + 1} |
||||
|
</span> |
||||
|
<span className="font-bold text-foreground"> |
||||
|
عنوان اصلی: {it.title} |
||||
|
</span> |
||||
|
</div> |
||||
|
<span className="text-[10px] text-grey-3">شناسه #{it.id}</span> |
||||
|
</div> |
||||
|
|
||||
|
<Input |
||||
|
value={draft.title} |
||||
|
onChange={(e) => |
||||
|
handleItineraryDraftChange(it.id, 'title', e.target.value) |
||||
|
} |
||||
|
placeholder={`عنوان مرحله به ${activeLangConfig?.label}...`} |
||||
|
className="bg-surface-3 border-border/50 text-xs" |
||||
|
/> |
||||
|
|
||||
|
<textarea |
||||
|
rows={2} |
||||
|
value={draft.summary} |
||||
|
onChange={(e) => |
||||
|
handleItineraryDraftChange(it.id, 'summary', e.target.value) |
||||
|
} |
||||
|
placeholder={`شرح مرحله به ${activeLangConfig?.label}...`} |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-3 px-3 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none" |
||||
|
/> |
||||
|
</div> |
||||
|
) |
||||
|
}) |
||||
|
)} |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
</> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* Dialog Footer */} |
||||
|
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between"> |
||||
|
<div className="text-xs text-grey-3"> |
||||
|
زبان در حال ویرایش: <span className="font-bold text-primary">{activeLangConfig?.label}</span> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2"> |
||||
|
<DialogClose asChild> |
||||
|
<Button type="button" variant="outline" size="sm" className="rounded-xl"> |
||||
|
بستن |
||||
|
</Button> |
||||
|
</DialogClose> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
disabled={isSaving || isPrimary || isLoading} |
||||
|
onClick={handleSave} |
||||
|
className="rounded-xl bg-primary text-white hover:bg-primary/90 gap-1.5 shadow-sm" |
||||
|
> |
||||
|
{isSaving ? ( |
||||
|
<> |
||||
|
<div className="size-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" /> |
||||
|
<span>در حال ذخیره...</span> |
||||
|
</> |
||||
|
) : ( |
||||
|
<> |
||||
|
<Ic name="check" className="size-3.5" /> |
||||
|
<span>ذخیره ترجمه {activeLangConfig?.label}</span> |
||||
|
</> |
||||
|
)} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,145 @@ |
|||||
|
import { apiFetch } from '@/services/http' |
||||
|
import type { |
||||
|
DrfPaginatedResponse, |
||||
|
TourListItem, |
||||
|
TourDetailItem, |
||||
|
TourItineraryItem, |
||||
|
TourPassengerGroupItem, |
||||
|
TourCommentItem, |
||||
|
TourOrderItem, |
||||
|
} from '../types' |
||||
|
|
||||
|
/** |
||||
|
* دریافت فهرست تورها با قابلیت صفحهبندی و فیلتر زبان |
||||
|
*/ |
||||
|
export async function fetchTours(params?: { |
||||
|
limit?: number |
||||
|
offset?: number |
||||
|
language_code?: string |
||||
|
}): Promise<DrfPaginatedResponse<TourListItem>> { |
||||
|
const query = new URLSearchParams() |
||||
|
if (params?.limit !== undefined) query.set('limit', String(params.limit)) |
||||
|
if (params?.offset !== undefined) query.set('offset', String(params.offset)) |
||||
|
if (params?.language_code) query.set('language_code', params.language_code) |
||||
|
|
||||
|
const queryString = query.toString() |
||||
|
const path = queryString ? `tours/?${queryString}` : 'tours/' |
||||
|
return apiFetch<DrfPaginatedResponse<TourListItem>>(path) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت جزئیات جامع یک تور با شناسه یکتا |
||||
|
*/ |
||||
|
export async function fetchTourDetail(tourId: number): Promise<TourDetailItem> { |
||||
|
return apiFetch<TourDetailItem>(`tours/${tourId}/`) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت مراحل و برنامه زمانبندی سفر یک تور |
||||
|
*/ |
||||
|
export async function fetchTourItineraries(tourId: number): Promise<DrfPaginatedResponse<TourItineraryItem>> { |
||||
|
return apiFetch<DrfPaginatedResponse<TourItineraryItem>>(`tours/${tourId}/itinerary/`) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت مسافران ثبتنامشده برای تور |
||||
|
*/ |
||||
|
export async function fetchTourPassengers(tourId: number): Promise<DrfPaginatedResponse<TourPassengerGroupItem>> { |
||||
|
return apiFetch<DrfPaginatedResponse<TourPassengerGroupItem>>(`tours/${tourId}/passengers/`) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت نظرات و امتیازات کاربران برای یک تور از طریق اسلاگ |
||||
|
*/ |
||||
|
export async function fetchTourComments(tourSlug: string): Promise<DrfPaginatedResponse<TourCommentItem>> { |
||||
|
return apiFetch<DrfPaginatedResponse<TourCommentItem>>(`tours/${tourSlug}/comments/`) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت سفارشات و رزروهای تور ویژه مدیران پنل |
||||
|
*/ |
||||
|
export async function fetchTourOrders(params?: { |
||||
|
status?: string |
||||
|
search?: string |
||||
|
tour_id?: number |
||||
|
limit?: number |
||||
|
offset?: number |
||||
|
}): Promise<DrfPaginatedResponse<TourOrderItem>> { |
||||
|
const query = new URLSearchParams() |
||||
|
query.set('view', 'admin') |
||||
|
if (params?.status) query.set('status', params.status) |
||||
|
if (params?.search) query.set('search', params.search) |
||||
|
if (params?.tour_id) query.set('tour_id', String(params.tour_id)) |
||||
|
if (params?.limit !== undefined) query.set('limit', String(params.limit)) |
||||
|
if (params?.offset !== undefined) query.set('offset', String(params.offset)) |
||||
|
|
||||
|
return apiFetch<DrfPaginatedResponse<TourOrderItem>>(`tours/orders/?${query.toString()}`) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* بهروزرسانی وضعیت سفارش تور و تایید فیش واریز |
||||
|
*/ |
||||
|
export async function updateTourOrder( |
||||
|
orderId: number, |
||||
|
data: { status?: string; receipt_verified?: boolean } |
||||
|
): Promise<TourOrderItem> { |
||||
|
return apiFetch<TourOrderItem>(`tours/orders/${orderId}/`, { |
||||
|
method: 'PATCH', |
||||
|
body: data, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت ترجمههای ۵ زبانه یک تور |
||||
|
*/ |
||||
|
export async function fetchTourTranslations(tourId: number): Promise<import('../types').TourTranslationsResponse> { |
||||
|
return apiFetch<import('../types').TourTranslationsResponse>(`tours/${tourId}/translations/`) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* ذخیره یا ویرایش ترجمه تور برای یک زبان یا چندین زبان |
||||
|
*/ |
||||
|
export async function saveTourTranslations( |
||||
|
tourId: number, |
||||
|
payload: { |
||||
|
lang_code?: string |
||||
|
title?: string |
||||
|
description?: string |
||||
|
tour_feature?: { title: string }[] |
||||
|
travel_tips?: Record<string, string> |
||||
|
translations?: Record<string, import('../types').TourTranslationData> |
||||
|
} |
||||
|
): Promise<{ status: string; message: string }> { |
||||
|
return apiFetch<{ status: string; message: string }>(`tours/${tourId}/translations/`, { |
||||
|
method: 'POST', |
||||
|
body: payload, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت ترجمههای ۵ زبانه مراحل سفر (Itinerary) |
||||
|
*/ |
||||
|
export async function fetchTourItineraryTranslations( |
||||
|
tourId: number |
||||
|
): Promise<import('../types').TourItineraryTranslationsResponseItem[]> { |
||||
|
return apiFetch<import('../types').TourItineraryTranslationsResponseItem[]>(`tours/${tourId}/itinerary_translations/`) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* ذخیره ترجمه یک مرحله سفر برای یک زبان خاص |
||||
|
*/ |
||||
|
export async function saveTourItineraryTranslation( |
||||
|
tourId: number, |
||||
|
data: { |
||||
|
itinerary_id: number |
||||
|
lang_code: string |
||||
|
title: string |
||||
|
summary: string |
||||
|
} |
||||
|
): Promise<{ status: string; message: string }> { |
||||
|
return apiFetch<{ status: string; message: string }>(`tours/${tourId}/itinerary_translations/`, { |
||||
|
method: 'POST', |
||||
|
body: data, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,185 @@ |
|||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* تایپهای جامع ماژول مدیریت تورها (Tours Management Types) |
||||
|
* منطبق با ساختار واقعی مدلها و وبسرویسهای Django REST Framework در بکاند عقیله |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export interface TourImageThumbnails { |
||||
|
original?: string |
||||
|
large?: string |
||||
|
medium?: string |
||||
|
small?: string |
||||
|
icon?: string |
||||
|
} |
||||
|
|
||||
|
export interface TourImageItem { |
||||
|
id: number |
||||
|
image_url?: TourImageThumbnails | null |
||||
|
} |
||||
|
|
||||
|
export type TourSaleStatus = 'AVAILABLE' | 'SOLD_OUT' | 'NO_SHOW' | string |
||||
|
export type TourTripStatus = 'PENDING' | 'TRAVELING' | 'FINISHED' | string |
||||
|
|
||||
|
export interface TourListItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
slug: string |
||||
|
started_at: string |
||||
|
ended_at: string |
||||
|
status: TourSaleStatus |
||||
|
trip_status: TourTripStatus |
||||
|
price: string |
||||
|
price_child: string |
||||
|
price_infant: string |
||||
|
image: TourImageItem | null |
||||
|
destination_country: string | null |
||||
|
is_access: boolean |
||||
|
} |
||||
|
|
||||
|
export interface TourFeatureItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
} |
||||
|
|
||||
|
export interface TravelTipItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
description: string |
||||
|
} |
||||
|
|
||||
|
export interface TourDetailItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
slug: string |
||||
|
description: string |
||||
|
started_at: string |
||||
|
ended_at: string |
||||
|
capacity: number |
||||
|
number_sold: number |
||||
|
tour_features: TourFeatureItem[] |
||||
|
travel_tips: TravelTipItem[] |
||||
|
status: TourSaleStatus |
||||
|
price: string |
||||
|
price_child: string |
||||
|
price_infant: string |
||||
|
percent_off: number |
||||
|
final_price: string |
||||
|
images: TourImageItem[] |
||||
|
trip_status: TourTripStatus |
||||
|
is_access: boolean |
||||
|
} |
||||
|
|
||||
|
export interface TourItineraryItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
summary: string |
||||
|
started_at: string |
||||
|
ended_at?: string | null |
||||
|
status?: string | number | null |
||||
|
images?: TourImageItem[] |
||||
|
} |
||||
|
|
||||
|
export interface PassengerItem { |
||||
|
id: number |
||||
|
fullname: string |
||||
|
passport_number: string |
||||
|
birthdate: string |
||||
|
phone_number: string |
||||
|
passport_image?: string | null |
||||
|
} |
||||
|
|
||||
|
export interface TourPassengerGroupItem { |
||||
|
tour: string |
||||
|
tour_id: number |
||||
|
tour_slug: string |
||||
|
user: string |
||||
|
user_id: number |
||||
|
passengers: PassengerItem[] |
||||
|
} |
||||
|
|
||||
|
export interface TourCommentDuration { |
||||
|
num: number |
||||
|
type: 'minute' | 'hour' | 'day' | 'month' | 'year' | string |
||||
|
} |
||||
|
|
||||
|
export interface TourCommentItem { |
||||
|
id: number |
||||
|
user: string |
||||
|
text?: string | null |
||||
|
score: number |
||||
|
duration: TourCommentDuration |
||||
|
user_avatar?: string | null |
||||
|
} |
||||
|
|
||||
|
export type TourOrderStatus = |
||||
|
| 'AWAITING_PAYMENT' |
||||
|
| 'PENDING' |
||||
|
| 'APPROVED' |
||||
|
| 'REJECTED' |
||||
|
| 'EXPIRED' |
||||
|
| string |
||||
|
|
||||
|
export interface TourOrderItem { |
||||
|
id: number |
||||
|
tour_id?: number |
||||
|
user: string |
||||
|
tour_slug: string |
||||
|
tour_title: string |
||||
|
price: string |
||||
|
total_price: string |
||||
|
status: TourOrderStatus |
||||
|
created: string |
||||
|
updated: string |
||||
|
request_details?: string | null |
||||
|
is_paid: boolean |
||||
|
payment_method?: string | null |
||||
|
transaction_id?: string | null |
||||
|
payment_receipt?: string | null |
||||
|
receipt_verified: boolean |
||||
|
} |
||||
|
|
||||
|
export interface DrfPaginatedResponse<T> { |
||||
|
count: number |
||||
|
next: string | null |
||||
|
previous: string | null |
||||
|
results: T[] |
||||
|
} |
||||
|
|
||||
|
export interface TourTranslationData { |
||||
|
id?: number |
||||
|
title: string |
||||
|
description?: string |
||||
|
tour_feature?: { title: string }[] |
||||
|
travel_tips?: Record<string, string> |
||||
|
} |
||||
|
|
||||
|
export interface TourTranslationsResponse { |
||||
|
primary_language: string |
||||
|
primary_data: { |
||||
|
title: string |
||||
|
description: string |
||||
|
tour_feature: { title: string }[] |
||||
|
travel_tips: Record<string, string> |
||||
|
} |
||||
|
translations: Record<string, TourTranslationData> |
||||
|
} |
||||
|
|
||||
|
export interface ItineraryTranslationData { |
||||
|
id?: number |
||||
|
title: string |
||||
|
summary?: string |
||||
|
} |
||||
|
|
||||
|
export interface TourItineraryTranslationsResponseItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
summary: string |
||||
|
translations: Record<string, ItineraryTranslationData> |
||||
|
} |
||||
|
|
||||
|
export interface TourFilterOptions { |
||||
|
search: string |
||||
|
status: string |
||||
|
tripStatus: string |
||||
|
country: string |
||||
|
sortBy: 'id' | 'price_asc' | 'price_desc' | 'capacity_desc' | 'started_at' |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue