You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1679 lines
85 KiB
1679 lines
85 KiB
import React, { useState, useEffect, useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Ic } from '@/icons'
|
|
import { toFa, toEn, formatAmount } from '@/lib/utils'
|
|
import { formatDualDate } from '@/lib/date-utils'
|
|
import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker'
|
|
import { useTranslation } from '@/i18n'
|
|
import { useBillingBadgeStore } from '@/stores/billing-badge'
|
|
import { toast } from '@/stores/toast'
|
|
import type {
|
|
CustomTripOrderItem,
|
|
CustomTripOrderStatus,
|
|
CustomTripLeg,
|
|
CustomTripOriginItem,
|
|
CustomTripDestinationItem,
|
|
CustomTripHotelItem,
|
|
} from '../types'
|
|
import {
|
|
updateCustomTripOrder,
|
|
fetchCustomTripOrigins,
|
|
fetchCustomTripDestinations,
|
|
fetchCustomTripHotels,
|
|
} from '../services/custom-trip-api'
|
|
|
|
export interface CustomTripOrderReviewDialogProps {
|
|
order: CustomTripOrderItem | null
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onOrderUpdated: (updated: CustomTripOrderItem) => void
|
|
getStatusBadge: (status: CustomTripOrderStatus) => React.ReactNode
|
|
origins?: CustomTripOriginItem[]
|
|
destinations?: CustomTripDestinationItem[]
|
|
hotels?: CustomTripHotelItem[]
|
|
}
|
|
|
|
function addDaysIso(dateStr: string, days: number): string {
|
|
const clean = (dateStr || '').slice(0, 10)
|
|
const d = new Date(`${clean}T00:00:00Z`)
|
|
if (Number.isNaN(d.getTime())) {
|
|
const fallback = new Date(Date.now() + 7 * 86400000)
|
|
fallback.setUTCDate(fallback.getUTCDate() + days)
|
|
return fallback.toISOString().slice(0, 10)
|
|
}
|
|
d.setUTCDate(d.getUTCDate() + days)
|
|
return d.toISOString().slice(0, 10)
|
|
}
|
|
|
|
function diffDaysIso(startStr: string, endStr: string): number {
|
|
const s = new Date(`${(startStr || '').slice(0, 10)}T00:00:00Z`)
|
|
const e = new Date(`${(endStr || '').slice(0, 10)}T00:00:00Z`)
|
|
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return 0
|
|
return Math.round((e.getTime() - s.getTime()) / 86400000)
|
|
}
|
|
|
|
function getDefaultBaseDate(createdStr?: string): string {
|
|
const d = createdStr ? new Date(createdStr) : new Date()
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
if (Number.isNaN(d.getTime()) || d.getTime() < today.getTime()) {
|
|
const nextWeek = new Date(Date.now() + 7 * 86400000)
|
|
return `${nextWeek.getFullYear()}-${String(nextWeek.getMonth() + 1).padStart(2, '0')}-${String(nextWeek.getDate()).padStart(2, '0')}`
|
|
}
|
|
d.setUTCDate(d.getUTCDate() + 7)
|
|
return d.toISOString().slice(0, 10)
|
|
}
|
|
|
|
function normalizeLegsChain(
|
|
origin: string,
|
|
legs: CustomTripLeg[],
|
|
fallbackStartDate?: string
|
|
): CustomTripLeg[] {
|
|
const destLegs = legs.filter((l) => !l.isReturn)
|
|
const existingReturn = legs.find((l) => l.isReturn)
|
|
|
|
if (destLegs.length === 0) {
|
|
destLegs.push({
|
|
fromCity: origin,
|
|
toCity: '',
|
|
transportMethod: 'پرواز (هوایی)',
|
|
transportMethodCode: 1,
|
|
hotelId: null,
|
|
hotelName: '',
|
|
stayNights: 3,
|
|
mealPlan: 'FULL_BOARD',
|
|
departureDate: fallbackStartDate || getDefaultBaseDate(),
|
|
departureTime: '09:00',
|
|
isReturn: false,
|
|
})
|
|
}
|
|
|
|
let curDate =
|
|
destLegs[0]?.departureDate ||
|
|
destLegs[0]?.checkIn ||
|
|
fallbackStartDate ||
|
|
getDefaultBaseDate()
|
|
|
|
const normalizedDestLegs: CustomTripLeg[] = destLegs.map((leg, idx) => {
|
|
const fromCity = idx === 0 ? origin || leg.fromCity : destLegs[idx - 1]?.toCity || leg.fromCity
|
|
const stayNights = Math.max(1, Number(leg.stayNights) || 3)
|
|
const legDep = leg.departureDate && leg.departureDate >= curDate ? leg.departureDate : curDate
|
|
const depDate = idx === 0 ? (leg.departureDate || curDate) : legDep
|
|
const checkIn = depDate
|
|
const checkOut = addDaysIso(depDate, stayNights)
|
|
curDate = checkOut
|
|
|
|
return {
|
|
...leg,
|
|
fromCity,
|
|
transportMethodCode: (leg.transportMethodCode || 1) as 1 | 2 | 3,
|
|
transportMethod:
|
|
leg.transportMethod ||
|
|
(leg.transportMethodCode === 2
|
|
? 'قطار (ریلی)'
|
|
: leg.transportMethodCode === 3
|
|
? 'زمینی (اتوبوس / ون VIP)'
|
|
: 'پرواز (هوایی)'),
|
|
stayNights,
|
|
mealPlan: leg.mealPlan || 'FULL_BOARD',
|
|
departureDate: depDate,
|
|
departureTime: leg.departureTime || '09:00',
|
|
checkIn,
|
|
checkOut,
|
|
isReturn: false,
|
|
}
|
|
})
|
|
|
|
const lastDestLegWithCity = [...normalizedDestLegs].reverse().find((l) => Boolean(l.toCity))
|
|
const lastDestCity = lastDestLegWithCity
|
|
? lastDestLegWithCity.toCity
|
|
: (normalizedDestLegs[normalizedDestLegs.length - 1]?.fromCity || origin)
|
|
const retMethodCode = (existingReturn?.transportMethodCode || 1) as 1 | 2 | 3
|
|
let returnToCity = existingReturn?.toCity || origin || ''
|
|
if (returnToCity && lastDestCity && returnToCity.trim() === lastDestCity.trim()) {
|
|
if (origin && origin.trim() !== lastDestCity.trim()) {
|
|
returnToCity = origin
|
|
} else {
|
|
returnToCity = ''
|
|
}
|
|
}
|
|
const returnLeg: CustomTripLeg = {
|
|
fromCity: lastDestCity,
|
|
toCity: returnToCity,
|
|
transportMethodCode: retMethodCode,
|
|
transportMethod:
|
|
existingReturn?.transportMethod ||
|
|
(retMethodCode === 2
|
|
? 'قطار (ریلی)'
|
|
: retMethodCode === 3
|
|
? 'زمینی (اتوبوس / ون VIP)'
|
|
: 'پرواز (هوایی)'),
|
|
transportId: existingReturn?.transportId ?? null,
|
|
hotelId: null,
|
|
hotelName: '',
|
|
stayNights: 0,
|
|
mealPlan: '',
|
|
departureDate:
|
|
existingReturn?.departureDate && existingReturn.departureDate >= curDate
|
|
? existingReturn.departureDate
|
|
: curDate,
|
|
departureTime: existingReturn?.departureTime || '18:00',
|
|
checkIn: null,
|
|
checkOut: null,
|
|
isReturn: true,
|
|
}
|
|
|
|
return [...normalizedDestLegs, returnLeg]
|
|
}
|
|
|
|
export function CustomTripOrderReviewDialog({
|
|
order,
|
|
open,
|
|
onOpenChange,
|
|
onOrderUpdated,
|
|
getStatusBadge,
|
|
origins = [],
|
|
destinations = [],
|
|
hotels = [],
|
|
}: CustomTripOrderReviewDialogProps) {
|
|
const { t, language } = useTranslation()
|
|
const navigate = useNavigate()
|
|
|
|
const [editOriginCity, setEditOriginCity] = useState<string>('')
|
|
const [editLegs, setEditLegs] = useState<CustomTripLeg[]>([])
|
|
const [editPrice, setEditPrice] = useState<number>(0)
|
|
const [editNotes, setEditNotes] = useState<string>('')
|
|
const [isSavingStage1, setIsSavingStage1] = useState(false)
|
|
const [isTransferringStage2, setIsTransferringStage2] = useState(false)
|
|
const [isRejecting, setIsRejecting] = useState(false)
|
|
const [scrollToLegIndex, setScrollToLegIndex] = useState<number | null>(null)
|
|
|
|
const [localOrigins, setLocalOrigins] = useState<CustomTripOriginItem[]>(origins)
|
|
const [localDestinations, setLocalDestinations] = useState<CustomTripDestinationItem[]>(destinations)
|
|
const [localHotels, setLocalHotels] = useState<CustomTripHotelItem[]>(hotels)
|
|
|
|
useEffect(() => {
|
|
if (origins && origins.length > 0) {
|
|
setLocalOrigins(origins)
|
|
} else {
|
|
fetchCustomTripOrigins().then(setLocalOrigins).catch(() => {})
|
|
}
|
|
|
|
if (destinations && destinations.length > 0) {
|
|
setLocalDestinations(destinations)
|
|
} else {
|
|
fetchCustomTripDestinations().then(setLocalDestinations).catch(() => {})
|
|
}
|
|
|
|
if (hotels && hotels.length > 0) {
|
|
setLocalHotels(hotels)
|
|
} else {
|
|
fetchCustomTripHotels().then(setLocalHotels).catch(() => {})
|
|
}
|
|
}, [origins, destinations, hotels])
|
|
|
|
useEffect(() => {
|
|
if (scrollToLegIndex !== null) {
|
|
const timer = setTimeout(() => {
|
|
const el = document.getElementById(`leg-card-${scrollToLegIndex}`)
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
const select = el.querySelector('select')
|
|
if (select) {
|
|
select.focus()
|
|
}
|
|
}
|
|
setScrollToLegIndex(null)
|
|
}, 120)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [scrollToLegIndex])
|
|
|
|
// Drag & Drop state for reordering destination legs
|
|
const [draggedLegIdx, setDraggedLegIdx] = useState<number | null>(null)
|
|
const [dragOverLegIdx, setDragOverLegIdx] = useState<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (order) {
|
|
const orig = order.originCity || ''
|
|
const defaultStart = getDefaultBaseDate(order.created)
|
|
setEditOriginCity(orig)
|
|
const initialLegs: CustomTripLeg[] =
|
|
order.legs && order.legs.length > 0
|
|
? order.legs.map((l) => ({
|
|
...l,
|
|
transportMethodCode: (l.transportMethodCode || 1) as 1 | 2 | 3,
|
|
stayNights: l.isReturn ? 0 : l.stayNights || 3,
|
|
mealPlan: l.isReturn ? '' : l.mealPlan || 'FULL_BOARD',
|
|
departureTime: l.departureTime || (l.isReturn ? '18:00' : '09:00'),
|
|
}))
|
|
: (order.destinations || []).map((dest, idx) => ({
|
|
fromCity: idx === 0 ? orig : order.destinations[idx - 1],
|
|
toCity: dest,
|
|
transportMethod: 'پرواز (هوایی)',
|
|
transportMethodCode: 1,
|
|
hotelId: null,
|
|
hotelName: '',
|
|
stayNights: 3,
|
|
mealPlan: 'FULL_BOARD',
|
|
departureDate: idx === 0 ? defaultStart : null,
|
|
departureTime: '09:00',
|
|
isReturn: false,
|
|
}))
|
|
setEditLegs(normalizeLegsChain(orig, initialLegs, defaultStart))
|
|
setEditPrice(order.finalPrice ?? 0)
|
|
setEditNotes(order.adminNotes || '')
|
|
}
|
|
}, [order])
|
|
|
|
if (!order) return null
|
|
|
|
const isAlreadyInvoiced = Boolean(order.factorId) && order.status !== 'PENDING'
|
|
const destOnlyLegs = editLegs.filter((l) => !l.isReturn)
|
|
const destLegsCount = destOnlyLegs.length
|
|
|
|
// Validation: Mandatory fields
|
|
const isPriceValid = Boolean(editPrice && !isNaN(editPrice) && Number(editPrice) > 0)
|
|
const isOriginValid = Boolean(editOriginCity && editOriginCity.trim().length > 0)
|
|
const hasValidDestLegs =
|
|
destLegsCount > 0 &&
|
|
destOnlyLegs.every((l) => Boolean(l.toCity && l.toCity.trim().length > 0 && Number(l.stayNights || 0) >= 1))
|
|
|
|
const isFormValid = isPriceValid && isOriginValid && hasValidDestLegs
|
|
|
|
const handleOriginChange = (newOrigin: string) => {
|
|
setEditOriginCity(newOrigin)
|
|
setEditLegs((prev) => {
|
|
const updated = prev.map((leg) => {
|
|
if (leg.isReturn && (!leg.toCity || leg.toCity === editOriginCity)) {
|
|
return { ...leg, toCity: newOrigin }
|
|
}
|
|
return leg
|
|
})
|
|
return normalizeLegsChain(newOrigin, updated)
|
|
})
|
|
}
|
|
|
|
const handleUpdateLeg = (index: number, patch: Partial<CustomTripLeg>) => {
|
|
setEditLegs((prev) => {
|
|
const copy = prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))
|
|
// If user edited departureDate on a leg after index 0, adjust previous leg's stayNights if valid so dates remain sequential
|
|
if (patch.departureDate && index > 0) {
|
|
const prevLeg = copy[index - 1]
|
|
if (prevLeg && !prevLeg.isReturn && prevLeg.departureDate) {
|
|
const diff = diffDaysIso(prevLeg.departureDate, patch.departureDate)
|
|
if (diff >= 1 && diff <= 60) {
|
|
copy[index - 1] = { ...prevLeg, stayNights: diff }
|
|
}
|
|
}
|
|
}
|
|
return normalizeLegsChain(editOriginCity, copy)
|
|
})
|
|
}
|
|
|
|
const handleMoveLeg = (index: number, direction: 'up' | 'down') => {
|
|
const destOnly = editLegs.filter((l) => !l.isReturn)
|
|
const retLeg = editLegs.find((l) => l.isReturn)
|
|
if (index < 0 || index >= destOnly.length) return
|
|
const targetIdx = direction === 'up' ? index - 1 : index + 1
|
|
if (targetIdx < 0 || targetIdx >= destOnly.length) return
|
|
|
|
const firstStartDate = destOnly[0]?.departureDate || getDefaultBaseDate(order.created)
|
|
const copy = [...destOnly]
|
|
const temp = copy[index]
|
|
copy[index] = copy[targetIdx]
|
|
copy[targetIdx] = temp
|
|
if (copy[0]) {
|
|
copy[0] = { ...copy[0], departureDate: firstStartDate }
|
|
}
|
|
setEditLegs(normalizeLegsChain(editOriginCity, retLeg ? [...copy, retLeg] : copy, firstStartDate))
|
|
}
|
|
|
|
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
|
if (editLegs[index]?.isReturn) {
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
setDraggedLegIdx(index)
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/plain', String(index))
|
|
}
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
|
if (draggedLegIdx === null || editLegs[index]?.isReturn) return
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
if (dragOverLegIdx !== index) {
|
|
setDragOverLegIdx(index)
|
|
}
|
|
}
|
|
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>, dropIndex: number) => {
|
|
e.preventDefault()
|
|
if (draggedLegIdx === null || draggedLegIdx === dropIndex || editLegs[dropIndex]?.isReturn) {
|
|
setDraggedLegIdx(null)
|
|
setDragOverLegIdx(null)
|
|
return
|
|
}
|
|
|
|
const destOnly = editLegs.filter((l) => !l.isReturn)
|
|
const retLeg = editLegs.find((l) => l.isReturn)
|
|
if (draggedLegIdx >= destOnly.length || dropIndex >= destOnly.length) {
|
|
setDraggedLegIdx(null)
|
|
setDragOverLegIdx(null)
|
|
return
|
|
}
|
|
|
|
const firstStartDate = destOnly[0]?.departureDate || getDefaultBaseDate(order.created)
|
|
const reordered = [...destOnly]
|
|
const [moved] = reordered.splice(draggedLegIdx, 1)
|
|
reordered.splice(dropIndex, 0, moved)
|
|
if (reordered[0]) {
|
|
reordered[0] = { ...reordered[0], departureDate: firstStartDate }
|
|
}
|
|
|
|
setEditLegs(
|
|
normalizeLegsChain(editOriginCity, retLeg ? [...reordered, retLeg] : reordered, firstStartDate)
|
|
)
|
|
setDraggedLegIdx(null)
|
|
setDragOverLegIdx(null)
|
|
}
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggedLegIdx(null)
|
|
setDragOverLegIdx(null)
|
|
}
|
|
|
|
const handleAddLeg = () => {
|
|
setEditLegs((prev) => {
|
|
const destOnly = prev.filter((l) => !l.isReturn)
|
|
const retLeg = prev.find((l) => l.isReturn)
|
|
const lastDestCity = destOnly.length > 0 ? destOnly[destOnly.length - 1].toCity : editOriginCity
|
|
|
|
const newDestLeg: CustomTripLeg = {
|
|
fromCity: lastDestCity || editOriginCity,
|
|
toCity: '', // Must be blank/empty when added as requested
|
|
transportMethod: 'پرواز (هوایی)',
|
|
transportMethodCode: 1,
|
|
hotelId: null,
|
|
hotelName: '',
|
|
stayNights: 3,
|
|
mealPlan: 'FULL_BOARD',
|
|
departureDate: null,
|
|
departureTime: '09:00',
|
|
isReturn: false,
|
|
}
|
|
const newIndex = destOnly.length
|
|
setScrollToLegIndex(newIndex)
|
|
|
|
const next = retLeg ? [...destOnly, newDestLeg, retLeg] : [...destOnly, newDestLeg]
|
|
return normalizeLegsChain(editOriginCity, next)
|
|
})
|
|
}
|
|
|
|
const handleRemoveLeg = (index: number) => {
|
|
const legToRemove = editLegs[index]
|
|
if (!legToRemove || legToRemove.isReturn || destLegsCount <= 1) return
|
|
setEditLegs((prev) => {
|
|
const next = prev.filter((_, idx) => idx !== index)
|
|
return normalizeLegsChain(editOriginCity, next)
|
|
})
|
|
}
|
|
|
|
// تاریخ استاندارد امروز برای جلوگیری از انتخاب تاریخ گذشته در هتل و پرواز
|
|
const todayIso = useMemo(() => {
|
|
const n = new Date()
|
|
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`
|
|
}, [])
|
|
|
|
// محاسبه حداقل تاریخ مجاز برای هر مرحله از مسیر (عدم انتخاب تاریخ گذشته)
|
|
const getLegMinDate = (idx: number): string => {
|
|
if (idx === 0) return todayIso
|
|
const prevLeg = editLegs[idx - 1]
|
|
const prevCheckOut = (prevLeg?.checkOut || prevLeg?.departureDate || '').slice(0, 10)
|
|
if (prevCheckOut && prevCheckOut > todayIso) return prevCheckOut
|
|
return todayIso
|
|
}
|
|
|
|
// حداقل تاریخ مجاز برای پرواز / مسیر بازگشت
|
|
const returnMinDate = useMemo(() => {
|
|
const destLegs = editLegs.filter((l) => !l.isReturn)
|
|
const lastDest = destLegs[destLegs.length - 1]
|
|
const lastCheckOut = (lastDest?.checkOut || lastDest?.departureDate || '').slice(0, 10)
|
|
if (lastCheckOut && lastCheckOut > todayIso) return lastCheckOut
|
|
return todayIso
|
|
}, [editLegs, todayIso])
|
|
|
|
const validateItinerary = (): boolean => {
|
|
if (!editOriginCity.trim()) {
|
|
toast.error('لطفاً شهر مبدأ سفر را مشخص نمایید.')
|
|
return false
|
|
}
|
|
|
|
const destLegs = editLegs.filter((l) => !l.isReturn)
|
|
if (destLegs.length === 0) {
|
|
toast.error('حداقل یک مقصد برای برنامه سفر باید ثبت شده باشد.')
|
|
return false
|
|
}
|
|
|
|
for (let i = 0; i < destLegs.length; i++) {
|
|
if (!destLegs[i].toCity || !destLegs[i].toCity.trim()) {
|
|
toast.error(`${t('customTrip.emptyDestinationError') || 'لطفاً نام شهر مقصد را مشخص نمایید.'} (${toFa(i + 1)})`)
|
|
const el = document.getElementById(`leg-card-${i}`)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
el?.querySelector('select')?.focus()
|
|
return false
|
|
}
|
|
}
|
|
|
|
const retLeg = editLegs.find((l) => l.isReturn)
|
|
if (retLeg && (!retLeg.toCity || !retLeg.toCity.trim())) {
|
|
toast.error(t('customTrip.selectReturnDestinationPlaceholder') || 'لطفاً شهر مقصد در مسیر بازگشت را انتخاب نمایید.')
|
|
const retEl = document.getElementById(`leg-card-${editLegs.length - 1}`)
|
|
retEl?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
retEl?.querySelector('select')?.focus()
|
|
return false
|
|
}
|
|
|
|
const seenRoutes = new Set<string>()
|
|
const seenDestCities = new Set<string>()
|
|
|
|
for (let i = 0; i < editLegs.length; i++) {
|
|
const leg = editLegs[i]
|
|
const legFromCity = (
|
|
leg.fromCity ||
|
|
(i === 0 ? editOriginCity : editLegs[i - 1]?.toCity) ||
|
|
editOriginCity
|
|
).trim()
|
|
const legToCity = (leg.toCity || '').trim()
|
|
|
|
if (!legToCity) continue
|
|
|
|
// Rule 1: No leg can have fromCity == toCity
|
|
if (legFromCity === legToCity) {
|
|
toast.error(
|
|
`${t('customTrip.sameOriginAndDestinationError') || 'شهر مبدأ و مقصد در یک مرحله نمیتواند یکسان باشد.'} (${leg.isReturn ? 'مسیر بازگشت' : `مرحله ${toFa(i + 1)}`}: ${legToCity})`
|
|
)
|
|
const el = document.getElementById(`leg-card-${i}`)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
el?.querySelector('select')?.focus()
|
|
return false
|
|
}
|
|
|
|
// Rule 2: No duplicate route (fromCity -> toCity) anywhere in the itinerary
|
|
const routeKey = `${legFromCity}-->${legToCity}`
|
|
if (seenRoutes.has(routeKey)) {
|
|
toast.error(
|
|
`${t('customTrip.duplicateRouteError') || 'این مسیر (مبدأ به مقصد) قبلاً در مراحل سفر ثبت شده است و نمیتواند تکرار شود.'} (${legFromCity} به ${legToCity})`
|
|
)
|
|
const el = document.getElementById(`leg-card-${i}`)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
el?.querySelector('select')?.focus()
|
|
return false
|
|
}
|
|
seenRoutes.add(routeKey)
|
|
|
|
// Rules for intermediate destination legs:
|
|
if (!leg.isReturn) {
|
|
// Origin city cannot be intermediate destination
|
|
if (legToCity === editOriginCity.trim()) {
|
|
toast.error(
|
|
`${t('customTrip.originAsDestinationError') || 'شهر مبدأ سفر نمیتواند به عنوان مقصد میانی انتخاب شود؛ مسیر بازگشت برای برگشت به این شهر اختصاص دارد.'} (${legToCity})`
|
|
)
|
|
const el = document.getElementById(`leg-card-${i}`)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
return false
|
|
}
|
|
// No duplicate intermediate destinations
|
|
if (seenDestCities.has(legToCity)) {
|
|
toast.error(
|
|
`${t('customTrip.duplicateDestinationError') || 'هیچ یک از شهرهای مقصد میانی نمیتواند تکراری باشد.'} (${legToCity})`
|
|
)
|
|
const el = document.getElementById(`leg-card-${i}`)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
return false
|
|
}
|
|
seenDestCities.add(legToCity)
|
|
}
|
|
}
|
|
|
|
// اعتبارسنجی عدم انتخاب تاریخ گذشته برای تمامی پروازها، هتلها و مسیرهای سفر
|
|
for (let i = 0; i < editLegs.length; i++) {
|
|
const leg = editLegs[i]
|
|
const depDate = (leg.departureDate || '').slice(0, 10)
|
|
if (!depDate) {
|
|
toast.error(
|
|
t('customTrip.emptyDepartureDateError') ||
|
|
`لطفاً تاریخ ${leg.isReturn ? 'بازگشت' : `مرحله ${toFa(i + 1)}`} را مشخص نمایید.`
|
|
)
|
|
const el = document.getElementById(leg.isReturn ? 'leg-card-return' : `leg-card-${i}`)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
return false
|
|
}
|
|
if (depDate < todayIso) {
|
|
toast.error(
|
|
t('customTrip.pastDateNotAllowedError') ||
|
|
'امکان انتخاب تاریخ گذشته وجود ندارد. لطفاً تاریخی از امروز به بعد را انتخاب نمایید.'
|
|
)
|
|
const el = document.getElementById(leg.isReturn ? 'leg-card-return' : `leg-card-${i}`)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Stage 1: Save modifications & keep in Custom Trip Submitted Orders (no Factor created)
|
|
const handleSaveStage1Review = async () => {
|
|
if (!isFormValid) {
|
|
if (!isPriceValid) {
|
|
toast.error(t('customTrip.priceRequiredNotice') || 'تعیین مبلغ نهایی فاکتور الزامی است.')
|
|
} else {
|
|
toast.error(t('customTrip.requiredFieldsAlert') || 'لطفاً تمامی موارد اجباری را تکمیل نمایید.')
|
|
}
|
|
return
|
|
}
|
|
if (!validateItinerary()) return
|
|
setIsSavingStage1(true)
|
|
try {
|
|
const updated = await updateCustomTripOrder(order.id, {
|
|
status: isAlreadyInvoiced ? order.status : 'PENDING',
|
|
priceEstimate: Number(editPrice),
|
|
adminNotes: editNotes,
|
|
originCity: editOriginCity,
|
|
legs: editLegs,
|
|
transferToBilling: false,
|
|
})
|
|
onOrderUpdated(updated)
|
|
toast.success(t('customTrip.stage1SavedToast'))
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'خطا در ذخیره تغییرات بررسی سفارش')
|
|
} finally {
|
|
setIsSavingStage1(false)
|
|
}
|
|
}
|
|
|
|
// Stage 2: Finalize order items & invoice and transfer to Financial Invoices (Billing)
|
|
const handleFinalizeAndTransferToBilling = async () => {
|
|
if (!isFormValid) {
|
|
if (!isPriceValid) {
|
|
toast.error(t('customTrip.priceRequiredNotice') || 'تعیین مبلغ نهایی فاکتور الزامی است.')
|
|
} else {
|
|
toast.error(t('customTrip.requiredFieldsAlert') || 'لطفاً تمامی موارد اجباری را تکمیل نمایید.')
|
|
}
|
|
return
|
|
}
|
|
if (!validateItinerary()) return
|
|
const finalAmount = Number(editPrice)
|
|
setIsTransferringStage2(true)
|
|
try {
|
|
const updated = await updateCustomTripOrder(order.id, {
|
|
status: 'AWAITING_PAYMENT',
|
|
priceEstimate: finalAmount,
|
|
adminNotes: editNotes,
|
|
originCity: editOriginCity,
|
|
legs: editLegs,
|
|
transferToBilling: true,
|
|
})
|
|
onOrderUpdated(updated)
|
|
useBillingBadgeStore.setState({ lastFetched: null })
|
|
useBillingBadgeStore.getState().fetchCounts()
|
|
toast.success(t('customTrip.stage2TransferredToast'))
|
|
onOpenChange(false)
|
|
const targetFactor = updated.factorNumber || updated.factorId || order.factorNumber || order.factorId
|
|
if (targetFactor) {
|
|
navigate(`/billing/${targetFactor}`)
|
|
} else {
|
|
navigate('/billing')
|
|
}
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'خطا در تأیید نهایی و صدور صورتحساب مالی')
|
|
} finally {
|
|
setIsTransferringStage2(false)
|
|
}
|
|
}
|
|
|
|
const handleRejectOrder = async () => {
|
|
setIsRejecting(true)
|
|
try {
|
|
const updated = await updateCustomTripOrder(order.id, {
|
|
status: 'REJECTED',
|
|
adminNotes: editNotes || 'رد شده پس از بررسی در بخش سفر اختصاصی',
|
|
})
|
|
onOrderUpdated(updated)
|
|
useBillingBadgeStore.setState({ lastFetched: null })
|
|
useBillingBadgeStore.getState().fetchCounts()
|
|
onOpenChange(false)
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'خطا در رد سفارش')
|
|
} finally {
|
|
setIsRejecting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-5xl lg:max-w-6xl xl:max-w-7xl w-[96vw] max-h-[92vh] flex flex-col p-0 overflow-hidden rounded-2xl bg-card border border-border/70 shadow-2xl">
|
|
{/* Header */}
|
|
<DialogHeader className="p-4 sm:p-6 pb-4 pe-14 border-b border-border/60 shrink-0 bg-card">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<DialogTitle className="text-base sm:text-xl font-bold text-foreground flex items-center gap-2.5">
|
|
<Ic name="clipboardCheck" className="size-5.5 text-primary" />
|
|
<span>
|
|
{t('customTrip.orderDetailTitle')} #{toFa(order.id)}
|
|
</span>
|
|
{order.trackingCode && (
|
|
<span className="text-xs font-mono font-normal text-muted-foreground bg-muted px-2.5 py-0.5 rounded-md">
|
|
{order.trackingCode}
|
|
</span>
|
|
)}
|
|
</DialogTitle>
|
|
<div className="flex items-center gap-2">
|
|
{getStatusBadge(order.status)}
|
|
{isAlreadyInvoiced ? (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30">
|
|
<Ic name="receipt" className="size-3.5" />
|
|
<span>
|
|
{t('customTrip.invoiceTransferredBadge')} (#{toFa(order.factorNumber || order.factorId || '')})
|
|
</span>
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-blue-500/15 text-blue-700 dark:text-blue-300 border border-blue-500/30">
|
|
<Ic name="clock" className="size-3.5" />
|
|
<span>{t('customTrip.notInvoicedYetBadge')}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DialogDescription className="text-xs sm:text-sm text-muted-foreground mt-1.5">
|
|
{t('customTrip.orderDetailDesc')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Scrollable Body */}
|
|
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-5">
|
|
{/* Two-Stage Workflow Banner */}
|
|
<div
|
|
className={`p-3.5 rounded-2xl border flex flex-col sm:flex-row sm:items-center justify-between gap-3 ${
|
|
isAlreadyInvoiced
|
|
? 'bg-emerald-500/10 border-emerald-500/30'
|
|
: 'bg-blue-500/10 border-blue-500/30'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className={`size-9 rounded-xl flex items-center justify-center shrink-0 mt-0.5 font-bold ${
|
|
isAlreadyInvoiced
|
|
? 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-400'
|
|
: 'bg-blue-500/20 text-blue-600 dark:text-blue-400'
|
|
}`}
|
|
>
|
|
<Ic name={isAlreadyInvoiced ? 'receipt' : 'sliders'} className="size-4.5" />
|
|
</div>
|
|
<div className="space-y-0.5 text-xs">
|
|
<h4 className="font-bold text-foreground">
|
|
{isAlreadyInvoiced ? t('customTrip.stage2Title') : t('customTrip.stage1Title')}
|
|
</h4>
|
|
<p className="text-muted-foreground leading-relaxed">
|
|
{isAlreadyInvoiced ? t('customTrip.stage2Desc') : t('customTrip.stage1Desc')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isAlreadyInvoiced && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
onOpenChange(false)
|
|
navigate(`/billing/${order.factorNumber || order.factorId}`)
|
|
}}
|
|
className="rounded-xl text-xs font-bold h-8 px-3 border-emerald-500/40 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/20 shrink-0 gap-1.5 cursor-pointer"
|
|
>
|
|
<Ic name="receipt" className="size-3.5" />
|
|
<span>{t('customTrip.viewInBilling')}</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Customer Info Card */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 p-3.5 rounded-xl bg-muted/40 border border-border/60 text-xs sm:text-sm">
|
|
<div>
|
|
<span className="text-muted-foreground text-xs block">{t('customTrip.passengerName')}:</span>
|
|
<span className="font-bold text-foreground">
|
|
{order.user.fullName || order.user.username}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs block">{t('customTrip.contactPhone')}:</span>
|
|
<span className="font-mono text-foreground font-medium dir-ltr inline-block">
|
|
{order.user.phoneNumber || '—'}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs block">{t('customTrip.passengers')}:</span>
|
|
<span className="font-semibold text-foreground">
|
|
{toFa(order.adultsCount)} {t('customTrip.adults')}
|
|
{order.childrenCount > 0 && `، ${toFa(order.childrenCount)} ${t('customTrip.children')}`}
|
|
{order.infantsCount > 0 && `، ${toFa(order.infantsCount)} ${t('customTrip.infants')}`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Origin City Selector */}
|
|
<div className="p-4 rounded-xl border border-border/70 bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Ic name="mapPin" className="size-4.5 text-primary" />
|
|
<span className="text-sm font-bold text-foreground">{t('customTrip.originCity')}:</span>
|
|
</div>
|
|
<div className="w-full sm:w-80">
|
|
{localOrigins.length > 0 ? (
|
|
<select
|
|
value={editOriginCity}
|
|
onChange={(e) => handleOriginChange(e.target.value)}
|
|
className="w-full h-9.5 px-3 text-xs sm:text-sm rounded-xl bg-background border border-border/70 text-foreground font-semibold focus:ring-1 focus:ring-primary focus:outline-none"
|
|
>
|
|
{!localOrigins.some((o) => o.name === editOriginCity) && editOriginCity && (
|
|
<option value={editOriginCity}>{editOriginCity}</option>
|
|
)}
|
|
{localOrigins.map((orig) => (
|
|
<option key={orig.id} value={orig.name}>
|
|
{orig.flag ? `${orig.flag} ` : ''}
|
|
{orig.name} ({orig.country})
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<Input
|
|
value={editOriginCity}
|
|
onChange={(e) => handleOriginChange(e.target.value)}
|
|
className="h-9.5 text-xs sm:text-sm rounded-xl"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Editable Itinerary Legs (Drag & Drop, Return Leg, Sequential Dates & Ticket Times) */}
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h4 className="text-sm font-bold text-foreground flex items-center gap-2">
|
|
<Ic name="route" className="size-4.5 text-primary" />
|
|
<span>برنامه و مقاصد سفر:</span>
|
|
</h4>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
برای جابجایی ترتیب مقاصد، کارتها را بکشید و رها کنید.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleAddLeg}
|
|
className="h-8.5 px-3 rounded-xl text-xs font-semibold gap-1.5 border-primary/30 text-primary hover:bg-primary/10 cursor-pointer"
|
|
>
|
|
<Ic name="plus" className="size-4" />
|
|
<span>{t('customTrip.addDestinationLeg')}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{editLegs.map((leg, index) => {
|
|
const isReturnLeg = Boolean(leg.isReturn)
|
|
const cityHotels = isReturnLeg
|
|
? []
|
|
: localHotels.filter(
|
|
(h) =>
|
|
h.cityName === leg.toCity ||
|
|
leg.toCity.includes(h.cityName) ||
|
|
h.cityName.includes(leg.toCity)
|
|
)
|
|
const isDragging = draggedLegIdx === index
|
|
const isDragOver = dragOverLegIdx === index && draggedLegIdx !== index
|
|
|
|
return (
|
|
<div
|
|
id={`leg-card-${index}`}
|
|
key={index}
|
|
draggable={!isReturnLeg}
|
|
onDragStart={(e) => handleDragStart(e, index)}
|
|
onDragOver={(e) => handleDragOver(e, index)}
|
|
onDrop={(e) => handleDrop(e, index)}
|
|
onDragEnd={handleDragEnd}
|
|
className={`p-4 sm:p-5 rounded-2xl border transition-all space-y-3.5 ${
|
|
isReturnLeg
|
|
? 'border-primary/40 bg-primary/5 shadow-2xs'
|
|
: isDragOver
|
|
? 'border-primary ring-2 ring-primary/30 bg-primary/10'
|
|
: isDragging
|
|
? 'opacity-50 border-dashed border-primary/60 bg-muted/40'
|
|
: 'border-border/80 bg-card shadow-2xs'
|
|
}`}
|
|
>
|
|
{/* Top Row: Drag Handle, Sequence Badge, Route Chain & Actions */}
|
|
<div className="flex items-center justify-between gap-3 pb-3 border-b border-border/50">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
{!isReturnLeg && (
|
|
<span
|
|
title={t('customTrip.dragToReorderHint') || 'جابجایی ترتیب با کشیدن'}
|
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground p-1 -ms-1 rounded-md hover:bg-muted/60 shrink-0"
|
|
>
|
|
<Ic name="gripVertical" className="size-4" />
|
|
</span>
|
|
)}
|
|
<span
|
|
className={`size-6.5 rounded-full font-bold flex items-center justify-center text-xs shrink-0 ${
|
|
isReturnLeg
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-primary/15 text-primary'
|
|
}`}
|
|
>
|
|
{toFa(index + 1)}
|
|
</span>
|
|
<div className="flex items-center gap-2 font-bold text-xs sm:text-sm truncate">
|
|
<span className="text-foreground">
|
|
{leg.fromCity || (isReturnLeg ? (destLegsCount > 0 ? editLegs[destLegsCount - 1]?.toCity || editOriginCity : editOriginCity) : editOriginCity)}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs">←</span>
|
|
<span className="text-primary truncate">
|
|
{isReturnLeg
|
|
? (leg.toCity && leg.toCity.trim() !== (leg.fromCity || '').trim()
|
|
? leg.toCity
|
|
: `[${t('customTrip.pendingDestinationChoice') || 'در انتظار تعیین مقصد'}]`)
|
|
: leg.toCity || `[${t('customTrip.pendingDestinationChoice') || 'در انتظار تعیین مقصد'}]`}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Concise non-wrapping summary badges */}
|
|
{isReturnLeg ? (
|
|
<div className="hidden sm:flex items-center gap-1.5 shrink-0">
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-primary/10 text-primary border border-primary/20">
|
|
<Ic name="repeat" className="size-3" />
|
|
<span>{t('customTrip.returnLegBadge') || 'مسیر بازگشت'}</span>
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-500/20">
|
|
<span>{leg.transportMethodCode === 2 ? '🚆 قطار' : leg.transportMethodCode === 3 ? '🚌 زمینی' : '✈️ پرواز'}</span>
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="hidden sm:flex items-center gap-1.5 shrink-0">
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-500/20">
|
|
<span>{leg.transportMethodCode === 2 ? '🚆 قطار' : leg.transportMethodCode === 3 ? '🚌 زمینی' : '✈️ پرواز'}</span>
|
|
</span>
|
|
{leg.stayNights ? (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs font-medium bg-muted text-muted-foreground border border-border/60">
|
|
<Ic name="moon" className="size-3 text-primary" />
|
|
<span>{toFa(leg.stayNights)} شب اقامت</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!isReturnLeg ? (
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
disabled={index === 0}
|
|
onClick={() => handleMoveLeg(index, 'up')}
|
|
title={t('customTrip.moveLegUp')}
|
|
className="size-7.5 rounded-lg text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer"
|
|
>
|
|
<Ic name="chevronUp" className="size-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
disabled={index >= destLegsCount - 1}
|
|
onClick={() => handleMoveLeg(index, 'down')}
|
|
title={t('customTrip.moveLegDown')}
|
|
className="size-7.5 rounded-lg text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer"
|
|
>
|
|
<Ic name="chevronDown" className="size-4" />
|
|
</Button>
|
|
{destLegsCount > 1 && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveLeg(index)}
|
|
title={t('customTrip.removeLeg')}
|
|
className="size-7.5 rounded-lg text-rose-500 hover:bg-rose-500/10 cursor-pointer"
|
|
>
|
|
<Ic name="trash" className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center shrink-0">
|
|
<span className="text-[11px] text-muted-foreground hidden md:inline">
|
|
پایان سفر و بازگشت
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Editable Fields Grid */}
|
|
{isReturnLeg ? (
|
|
/* Return Leg: Clean 2-Row + Notice Layout */
|
|
<div className="space-y-3.5">
|
|
{/* Row 1: Return Route & Transport Method (3 equal columns on md+) */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3.5 text-xs">
|
|
{/* 1. Return Origin City */}
|
|
<div>
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
مبدأ بازگشت:
|
|
</label>
|
|
<div className="h-10 px-3 rounded-xl bg-muted/40 border border-border/60 text-foreground text-xs font-semibold flex items-center justify-between">
|
|
<span className="truncate">
|
|
{leg.fromCity || (destLegsCount > 0 ? editLegs[destLegsCount - 1]?.toCity || editOriginCity : editOriginCity)}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border/40 shrink-0">
|
|
آخرین مقصد
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2. Return Destination City */}
|
|
<div>
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{t('customTrip.returnDestinationCityLabel') || 'مقصد بازگشت (شهر مسافر)'}:
|
|
</label>
|
|
{(() => {
|
|
const retLegFromCity = (leg.fromCity || (destLegsCount > 0 ? editLegs[destLegsCount - 1]?.toCity || editOriginCity : editOriginCity) || editOriginCity).trim()
|
|
const retSelectedValue = leg.toCity && leg.toCity.trim() !== retLegFromCity ? leg.toCity : ''
|
|
return (
|
|
<select
|
|
value={retSelectedValue}
|
|
onChange={(e) => handleUpdateLeg(index, { toCity: e.target.value })}
|
|
className={`w-full h-10 px-3 rounded-xl bg-background border text-foreground font-semibold text-xs focus:ring-1 focus:ring-primary focus:outline-none ${
|
|
!retSelectedValue ? 'border-amber-500/70 ring-1 ring-amber-500/30' : 'border-border/70'
|
|
}`}
|
|
>
|
|
<option value="" disabled>
|
|
{t('customTrip.selectDestinationPlaceholder') || 'انتخاب شهر مقصد بازگشت...'}
|
|
</option>
|
|
{retSelectedValue &&
|
|
!localOrigins.some((o) => o.name === retSelectedValue) &&
|
|
!localDestinations.some((d) => d.name === retSelectedValue) && (
|
|
<option value={retSelectedValue}>{retSelectedValue}</option>
|
|
)}
|
|
|
|
<optgroup label={t('customTrip.destinationCategoryOrigins') || 'شهرهای مبدأ و ترانزیت (Origins)'}>
|
|
{localOrigins.map((orig) => {
|
|
const isFromCity = orig.name.trim() === retLegFromCity
|
|
const isRouteAlreadyInLegs = destOnlyLegs.some((otherLeg, otherIdx) => {
|
|
const otherOrigin = (otherLeg.fromCity || (otherIdx > 0 ? destOnlyLegs[otherIdx - 1]?.toCity : editOriginCity) || editOriginCity).trim()
|
|
return otherOrigin === retLegFromCity && (otherLeg.toCity || '').trim() === orig.name.trim()
|
|
})
|
|
const isDisabled = isFromCity || isRouteAlreadyInLegs
|
|
return (
|
|
<option key={`ret-orig-${orig.id}`} value={orig.name} disabled={isDisabled}>
|
|
{orig.flag ? `${orig.flag} ` : ''}
|
|
{orig.name} ({orig.country})
|
|
{orig.name === editOriginCity ? ` (${t('customTrip.initialOriginBadge') || 'شهر مبدأ سفر'})` : ''}
|
|
{isFromCity ? ` (${t('customTrip.currentCityOptionLabel') || 'مبدأ این مسیر'})` : ''}
|
|
{isRouteAlreadyInLegs ? ` (${t('customTrip.routeAlreadyExistsLabel') || 'این مسیر قبلاً انتخاب شده'})` : ''}
|
|
</option>
|
|
)
|
|
})}
|
|
</optgroup>
|
|
|
|
<optgroup label={t('customTrip.destinationCategoryPilgrimage') || 'شهرهای زیارتی و مقاصد (Destinations)'}>
|
|
{localDestinations.map((dest) => {
|
|
const isFromCity = dest.name.trim() === retLegFromCity
|
|
const isRouteAlreadyInLegs = destOnlyLegs.some((otherLeg, otherIdx) => {
|
|
const otherOrigin = (otherLeg.fromCity || (otherIdx > 0 ? destOnlyLegs[otherIdx - 1]?.toCity : editOriginCity) || editOriginCity).trim()
|
|
return otherOrigin === retLegFromCity && (otherLeg.toCity || '').trim() === dest.name.trim()
|
|
})
|
|
const isDisabled = isFromCity || isRouteAlreadyInLegs
|
|
return (
|
|
<option key={`ret-dest-${dest.id}`} value={dest.name} disabled={isDisabled}>
|
|
{dest.flag ? `${dest.flag} ` : ''}
|
|
{dest.name} ({dest.country})
|
|
{isFromCity ? ` (${t('customTrip.currentCityOptionLabel') || 'مبدأ این مسیر'})` : ''}
|
|
{isRouteAlreadyInLegs ? ` (${t('customTrip.routeAlreadyExistsLabel') || 'این مسیر قبلاً انتخاب شده'})` : ''}
|
|
</option>
|
|
)
|
|
})}
|
|
</optgroup>
|
|
</select>
|
|
)
|
|
})()}
|
|
</div>
|
|
|
|
{/* 3. Return Transport Method */}
|
|
<div>
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{t('customTrip.transportMethod') || 'روش بازگشت'}:
|
|
</label>
|
|
<select
|
|
value={leg.transportMethodCode || 1}
|
|
onChange={(e) => {
|
|
const code = Number(e.target.value) as 1 | 2 | 3
|
|
const label =
|
|
code === 2
|
|
? 'قطار (ریلی)'
|
|
: code === 3
|
|
? 'زمینی (اتوبوس / ون VIP)'
|
|
: 'پرواز (هوایی)'
|
|
handleUpdateLeg(index, {
|
|
transportMethodCode: code,
|
|
transportMethod: label,
|
|
})
|
|
}}
|
|
className="w-full h-10 px-3 rounded-xl bg-background border border-border/70 text-foreground font-semibold text-xs focus:ring-1 focus:ring-primary focus:outline-none"
|
|
>
|
|
<option value={1}>✈️ پرواز (هوایی)</option>
|
|
<option value={2}>🚆 قطار (ریلی)</option>
|
|
<option value={3}>🚌 زمینی (اتوبوس / ون VIP)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: Return Departure Schedule (8 cols Date + 4 cols Time) */}
|
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-3.5 text-xs">
|
|
{/* Return Departure Date */}
|
|
<div className="col-span-1 md:col-span-8">
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{leg.transportMethodCode === 1
|
|
? '✈️ تاریخ پرواز بازگشت:'
|
|
: leg.transportMethodCode === 2
|
|
? '🚆 تاریخ قطار بازگشت:'
|
|
: '🚌 تاریخ حرکت بازگشت:'}
|
|
</label>
|
|
<UniversalDatePicker
|
|
value={(leg.departureDate || '').slice(0, 10)}
|
|
onChange={(iso) =>
|
|
handleUpdateLeg(index, { departureDate: iso })
|
|
}
|
|
allowedModes={['islamic', 'gregorian']}
|
|
defaultMode="islamic"
|
|
compact={false}
|
|
minDate={returnMinDate}
|
|
placeholder={t('customTrip.selectDepartureDatePlaceholder')}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* Return Departure Time */}
|
|
<div className="col-span-1 md:col-span-4">
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
ساعت حرکت بازگشت:
|
|
</label>
|
|
<Input
|
|
type="time"
|
|
value={leg.departureTime || '18:00'}
|
|
onChange={(e) =>
|
|
handleUpdateLeg(index, { departureTime: e.target.value })
|
|
}
|
|
className="h-10 text-xs font-mono text-center rounded-xl px-3 dir-ltr font-bold text-foreground"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3: Return Notice */}
|
|
<div className="flex items-center gap-2 p-2.5 px-3 rounded-xl bg-muted/30 border border-border/50 text-[11px] text-muted-foreground">
|
|
<Ic name="info" className="size-4 text-primary shrink-0" />
|
|
<span>مسیر پایانی بازگشت است و نیازی به رزرو هتل اقامتی ندارد.</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Destination Leg Fields: 3 Clean Rows (Route, Schedule, Hotel & Stay Range) */
|
|
<div className="space-y-3.5">
|
|
{/* Row 1: Journey Route & Transport (3 equal columns on md+) */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3.5 text-xs">
|
|
{/* 1. Origin City */}
|
|
<div>
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{t('customTrip.originCity') || 'شهر مبدأ'}:
|
|
</label>
|
|
{index === 0 ? (
|
|
<select
|
|
value={editOriginCity}
|
|
onChange={(e) => handleOriginChange(e.target.value)}
|
|
className="w-full h-10 px-3 rounded-xl bg-background border border-border/70 text-foreground font-semibold text-xs focus:ring-1 focus:ring-primary focus:outline-none"
|
|
>
|
|
{!localOrigins.some((o) => o.name === editOriginCity) && editOriginCity && (
|
|
<option value={editOriginCity}>{editOriginCity}</option>
|
|
)}
|
|
<optgroup label="شهرهای مبدأ (فرودگاهها)">
|
|
{localOrigins.map((orig) => (
|
|
<option key={`orig-opt-${orig.id}`} value={orig.name}>
|
|
{orig.flag ? `${orig.flag} ` : ''}
|
|
{orig.name} ({orig.country})
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
<optgroup label="سایر مقاصد">
|
|
{localDestinations
|
|
.filter((d) => !localOrigins.some((o) => o.name === d.name))
|
|
.map((dest) => (
|
|
<option key={`dest-orig-opt-${dest.id}`} value={dest.name}>
|
|
{dest.flag ? `${dest.flag} ` : ''}
|
|
{dest.name} ({dest.country})
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
</select>
|
|
) : (
|
|
<div
|
|
title="مبدأ این مرحله، مقصد مرحله قبل است"
|
|
className="h-10 px-3 rounded-xl bg-muted/40 border border-border/60 text-foreground text-xs font-semibold flex items-center justify-between"
|
|
>
|
|
<span className="truncate">{leg.fromCity || editLegs[index - 1]?.toCity || editOriginCity}</span>
|
|
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border/40 shrink-0">
|
|
مرحله قبل
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 2. Destination City */}
|
|
<div>
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{t('customTrip.destinationCityName') || 'شهر مقصد'}:
|
|
</label>
|
|
<select
|
|
value={leg.toCity || ''}
|
|
onChange={(e) => {
|
|
const newCity = e.target.value
|
|
const matchingHotels = localHotels.filter(
|
|
(h) =>
|
|
h.cityName === newCity ||
|
|
newCity.includes(h.cityName) ||
|
|
h.cityName.includes(newCity)
|
|
)
|
|
handleUpdateLeg(index, {
|
|
toCity: newCity,
|
|
hotelId: matchingHotels[0]?.id ?? null,
|
|
hotelName: matchingHotels[0]?.name ?? '',
|
|
})
|
|
}}
|
|
className={`w-full h-10 px-3 rounded-xl bg-background border text-foreground font-semibold text-xs focus:ring-1 focus:ring-primary focus:outline-none ${
|
|
!leg.toCity
|
|
? 'border-amber-500/70 ring-1 ring-amber-500/30'
|
|
: 'border-border/70'
|
|
}`}
|
|
>
|
|
<option value="" disabled={Boolean(leg.toCity)}>
|
|
{t('customTrip.selectDestinationPlaceholder') || 'انتخاب شهر مقصد...'}
|
|
</option>
|
|
|
|
{leg.toCity &&
|
|
!localDestinations.some((d) => d.name === leg.toCity) &&
|
|
!localOrigins.some((o) => o.name === leg.toCity) && (
|
|
<option value={leg.toCity}>{leg.toCity}</option>
|
|
)}
|
|
|
|
<optgroup label={t('customTrip.destinationCategoryPilgrimage') || 'شهرهای زیارتی و مقاصد (Destinations)'}>
|
|
{localDestinations.map((dest) => {
|
|
const currentLegOrigin = (leg.fromCity || (index > 0 ? editLegs[index - 1]?.toCity : editOriginCity) || editOriginCity).trim()
|
|
const isCurrentLegOrigin = Boolean(currentLegOrigin && dest.name.trim() === currentLegOrigin)
|
|
const isOriginCity = Boolean(editOriginCity && dest.name.trim() === editOriginCity.trim())
|
|
const isUsedInOtherLeg = destOnlyLegs.some(
|
|
(otherLeg, otherIdx) => otherIdx !== index && (otherLeg.toCity || '').trim() === dest.name.trim()
|
|
)
|
|
const isRouteAlreadyChosen = Boolean(
|
|
currentLegOrigin &&
|
|
destOnlyLegs.some((otherLeg, otherIdx) => {
|
|
if (otherIdx === index) return false
|
|
const otherOrigin = (otherLeg.fromCity || (otherIdx > 0 ? destOnlyLegs[otherIdx - 1]?.toCity : editOriginCity) || editOriginCity).trim()
|
|
return otherOrigin === currentLegOrigin && (otherLeg.toCity || '').trim() === dest.name.trim()
|
|
})
|
|
)
|
|
const isDisabled = isCurrentLegOrigin || isOriginCity || isUsedInOtherLeg || isRouteAlreadyChosen
|
|
return (
|
|
<option key={`dest-${dest.id}`} value={dest.name} disabled={isDisabled}>
|
|
{dest.flag ? `${dest.flag} ` : ''}
|
|
{dest.name} ({dest.country})
|
|
{isCurrentLegOrigin ? ` (${t('customTrip.currentCityOptionLabel') || 'مبدأ این مسیر'})` : ''}
|
|
{isOriginCity ? ` (${t('customTrip.originCityOptionLabel') || 'شهر مبدأ و بازگشت'})` : ''}
|
|
{isUsedInOtherLeg ? ` (${t('customTrip.alreadySelectedLabel') || 'قبلاً انتخاب شده'})` : ''}
|
|
{isRouteAlreadyChosen && !isCurrentLegOrigin && !isUsedInOtherLeg ? ` (${t('customTrip.routeAlreadyExistsLabel') || 'این مسیر قبلاً انتخاب شده'})` : ''}
|
|
</option>
|
|
)
|
|
})}
|
|
</optgroup>
|
|
|
|
<optgroup label={t('customTrip.destinationCategoryOrigins') || 'شهرهای مبدأ و ترانزیت (Origins)'}>
|
|
{localOrigins.map((orig) => {
|
|
const currentLegOrigin = (leg.fromCity || (index > 0 ? editLegs[index - 1]?.toCity : editOriginCity) || editOriginCity).trim()
|
|
const isCurrentLegOrigin = Boolean(currentLegOrigin && orig.name.trim() === currentLegOrigin)
|
|
const isOriginCity = Boolean(editOriginCity && orig.name.trim() === editOriginCity.trim())
|
|
const isUsedInOtherLeg = destOnlyLegs.some(
|
|
(otherLeg, otherIdx) => otherIdx !== index && (otherLeg.toCity || '').trim() === orig.name.trim()
|
|
)
|
|
const isRouteAlreadyChosen = Boolean(
|
|
currentLegOrigin &&
|
|
destOnlyLegs.some((otherLeg, otherIdx) => {
|
|
if (otherIdx === index) return false
|
|
const otherOrigin = (otherLeg.fromCity || (otherIdx > 0 ? destOnlyLegs[otherIdx - 1]?.toCity : editOriginCity) || editOriginCity).trim()
|
|
return otherOrigin === currentLegOrigin && (otherLeg.toCity || '').trim() === orig.name.trim()
|
|
})
|
|
)
|
|
const isDisabled = isCurrentLegOrigin || isOriginCity || isUsedInOtherLeg || isRouteAlreadyChosen
|
|
return (
|
|
<option key={`orig-${orig.id}`} value={orig.name} disabled={isDisabled}>
|
|
{orig.flag ? `${orig.flag} ` : ''}
|
|
{orig.name} ({orig.country})
|
|
{isCurrentLegOrigin ? ` (${t('customTrip.currentCityOptionLabel') || 'مبدأ این مسیر'})` : ''}
|
|
{isOriginCity ? ` (${t('customTrip.originCityOptionLabel') || 'شهر مبدأ و بازگشت'})` : ''}
|
|
{isUsedInOtherLeg ? ` (${t('customTrip.alreadySelectedLabel') || 'قبلاً انتخاب شده'})` : ''}
|
|
{isRouteAlreadyChosen && !isCurrentLegOrigin && !isUsedInOtherLeg ? ` (${t('customTrip.routeAlreadyExistsLabel') || 'این مسیر قبلاً انتخاب شده'})` : ''}
|
|
</option>
|
|
)
|
|
})}
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
|
|
{/* 3. Transport Method */}
|
|
<div>
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{t('customTrip.transportMethod') || 'روش سفر'}:
|
|
</label>
|
|
<select
|
|
value={leg.transportMethodCode || 1}
|
|
onChange={(e) => {
|
|
const code = Number(e.target.value) as 1 | 2 | 3
|
|
const label =
|
|
code === 2
|
|
? 'قطار (ریلی)'
|
|
: code === 3
|
|
? 'زمینی (اتوبوس / ون VIP)'
|
|
: 'پرواز (هوایی)'
|
|
handleUpdateLeg(index, {
|
|
transportMethodCode: code,
|
|
transportMethod: label,
|
|
})
|
|
}}
|
|
className="w-full h-10 px-3 rounded-xl bg-background border border-border/70 text-foreground font-semibold text-xs focus:ring-1 focus:ring-primary focus:outline-none"
|
|
>
|
|
<option value={1}>✈️ پرواز (هوایی)</option>
|
|
<option value={2}>🚆 قطار (ریلی)</option>
|
|
<option value={3}>🚌 زمینی (اتوبوس / ون VIP)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: Departure Schedule (8 cols Date + 4 cols Time) */}
|
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-3.5 text-xs">
|
|
{/* 4. Departure Date (8 cols on md+) */}
|
|
<div className="col-span-1 md:col-span-8">
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{leg.transportMethodCode === 1
|
|
? '✈️ تاریخ پرواز:'
|
|
: leg.transportMethodCode === 2
|
|
? '🚆 تاریخ قطار:'
|
|
: '🚌 تاریخ حرکت:'}
|
|
</label>
|
|
<UniversalDatePicker
|
|
value={(leg.departureDate || '').slice(0, 10)}
|
|
onChange={(iso) =>
|
|
handleUpdateLeg(index, { departureDate: iso })
|
|
}
|
|
allowedModes={['islamic', 'gregorian']}
|
|
defaultMode="islamic"
|
|
compact={false}
|
|
minDate={getLegMinDate(index)}
|
|
placeholder={t('customTrip.selectDepartureDatePlaceholder')}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* 5. Departure Time (4 cols on md+) */}
|
|
<div className="col-span-1 md:col-span-4">
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
ساعت حرکت:
|
|
</label>
|
|
<Input
|
|
type="time"
|
|
value={leg.departureTime || '09:00'}
|
|
onChange={(e) =>
|
|
handleUpdateLeg(index, { departureTime: e.target.value })
|
|
}
|
|
className="h-10 text-xs font-mono text-center rounded-xl px-3 dir-ltr font-bold text-foreground"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3: Hotel & Accommodation */}
|
|
<div className="space-y-3 pt-3.5 border-t border-border/50 text-xs">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-12 gap-3.5">
|
|
{/* 1. Hotel Selection (md: 6 cols) */}
|
|
<div className="col-span-1 sm:col-span-2 md:col-span-6">
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{t('customTrip.hotelName') || 'هتل اقامت'}:
|
|
</label>
|
|
{cityHotels.length > 0 ? (
|
|
<select
|
|
value={leg.hotelName || ''}
|
|
onChange={(e) => {
|
|
const selectedName = e.target.value
|
|
const found = cityHotels.find((h) => h.name === selectedName)
|
|
handleUpdateLeg(index, {
|
|
hotelId: found ? found.id : null,
|
|
hotelName: selectedName,
|
|
})
|
|
}}
|
|
className="w-full h-10 px-3 rounded-xl bg-background border border-border/70 text-foreground font-semibold text-xs focus:ring-1 focus:ring-primary focus:outline-none"
|
|
>
|
|
{!cityHotels.some((h) => h.name === leg.hotelName) && leg.hotelName && (
|
|
<option value={leg.hotelName}>{leg.hotelName}</option>
|
|
)}
|
|
{cityHotels.map((h) => (
|
|
<option key={h.id} value={h.name}>
|
|
🏨 {h.name} ({toFa(h.stars)}★ - ${formatAmount(h.pricePerDayAdult)})
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<Input
|
|
value={leg.hotelName || ''}
|
|
onChange={(e) => handleUpdateLeg(index, { hotelName: e.target.value })}
|
|
placeholder={t('customTrip.customHotelPlaceholder') || 'نام هتل...'}
|
|
className="h-10 text-xs rounded-xl"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 2. Stay Nights (md: 3 cols) */}
|
|
<div className="col-span-1 sm:col-span-1 md:col-span-3">
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{t('customTrip.nightsLabel') || 'مدت اقامت'}:
|
|
</label>
|
|
<div className="relative">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={60}
|
|
value={leg.stayNights || 1}
|
|
onChange={(e) =>
|
|
handleUpdateLeg(index, {
|
|
stayNights: Math.max(1, Number(e.target.value) || 1),
|
|
})
|
|
}
|
|
className="h-10 text-xs font-mono text-center rounded-xl px-2 pe-8 font-bold"
|
|
/>
|
|
<span className="absolute end-2.5 top-1/2 -translate-y-1/2 text-[11px] text-muted-foreground pointer-events-none">
|
|
شب
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3. Meal Plan (md: 3 cols) */}
|
|
<div className="col-span-1 sm:col-span-1 md:col-span-3">
|
|
<label className="text-[11px] text-muted-foreground font-medium block mb-1">
|
|
{t('customTrip.mealPlanLabel') || 'برنامه غذایی'}:
|
|
</label>
|
|
<select
|
|
value={leg.mealPlan || 'FULL_BOARD'}
|
|
onChange={(e) => handleUpdateLeg(index, { mealPlan: e.target.value })}
|
|
className="w-full h-10 px-3 rounded-xl bg-background border border-border/70 text-foreground text-xs font-semibold focus:ring-1 focus:ring-primary focus:outline-none"
|
|
>
|
|
<option value="FULL_BOARD">{t('customTrip.mealFullBoard') || 'فولبرد (صبحانه، ناهار، شام)'}</option>
|
|
<option value="BREAKFAST">{t('customTrip.mealBreakfast') || 'صبحانه'}</option>
|
|
<option value="ROOM_ONLY">{t('customTrip.mealRoomOnly') || 'بدون غذا'}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4. Dedicated Stay Date Range Bar (Full Width) */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 p-3 rounded-xl bg-muted/40 border border-border/60">
|
|
<div className="flex items-center gap-2 text-foreground font-medium">
|
|
<Ic name="calendar" className="size-4 text-primary shrink-0" />
|
|
<span className="text-xs">{t('customTrip.stayDateRange') || 'بازه اقامت در هتل'}:</span>
|
|
<Badge variant="outline" className="bg-background text-[11px] font-bold px-2 py-0.5 border-border/70">
|
|
{toFa(leg.stayNights || 1)} {t('customTrip.nightsCountLabel') || 'شب'}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2.5 text-xs font-mono select-none flex-wrap">
|
|
<div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs">
|
|
<span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkInLabel') || 'ورود'}:</span>
|
|
<span className="font-bold text-foreground text-xs dir-ltr">
|
|
{leg.checkIn ? leg.checkIn.slice(0, 10) : '—'}
|
|
</span>
|
|
</div>
|
|
<span className="text-primary font-bold shrink-0">←</span>
|
|
<div className="flex items-center gap-1.5 bg-background px-3 py-1.5 rounded-lg border border-border/70 shadow-2xs">
|
|
<span className="text-[11px] text-muted-foreground font-sans font-medium">{t('customTrip.checkOutLabel') || 'خروج'}:</span>
|
|
<span className="font-bold text-primary text-xs dir-ltr">
|
|
{leg.checkOut ? leg.checkOut.slice(0, 10) : '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Passengers & Passport Images */}
|
|
{order.passengers && order.passengers.length > 0 && (
|
|
<div className="space-y-2.5">
|
|
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
|
{t('customTrip.passengersList')}:
|
|
</h4>
|
|
<div className="space-y-2.5">
|
|
{order.passengers.map((pax, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="p-3.5 sm:px-4.5 rounded-xl border border-border/70 bg-card flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs sm:text-sm"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="size-6.5 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs shrink-0">
|
|
{toFa(idx + 1)}
|
|
</span>
|
|
<div>
|
|
<div className="font-bold text-foreground">
|
|
{`${pax.firstName} ${pax.lastName}`.trim() || '—'}
|
|
<span className="ms-2 text-[11px] font-medium px-2 py-0.5 rounded-md bg-muted text-muted-foreground">
|
|
{pax.passengerType === 'INFANT'
|
|
? t('customTrip.infants')
|
|
: pax.passengerType === 'CHILD'
|
|
? t('customTrip.children')
|
|
: t('customTrip.adults')}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
{pax.passportNumber && (
|
|
<span>
|
|
{t('customTrip.passportNumber')}:{' '}
|
|
<span className="font-mono text-foreground">{pax.passportNumber}</span>
|
|
</span>
|
|
)}
|
|
{pax.phoneNumber && (
|
|
<span className="font-mono dir-ltr inline-block">{pax.phoneNumber}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{pax.passportImage ? (
|
|
<a
|
|
href={pax.passportImage}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 text-xs font-semibold transition-colors"
|
|
>
|
|
<Ic name="eye" className="size-3.5" />
|
|
{t('customTrip.viewPassportImage')}
|
|
</a>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground bg-muted/50 px-2.5 py-1 rounded-lg">
|
|
{t('customTrip.noPassportImage')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* User Special Request */}
|
|
{order.detail && (
|
|
<div className="p-3.5 rounded-xl bg-blue-500/5 border border-blue-500/20 text-xs sm:text-sm">
|
|
<span className="font-bold text-blue-600 dark:text-blue-400 block mb-1">
|
|
{t('customTrip.specialRequest')}:
|
|
</span>
|
|
<p className="text-foreground/90">{order.detail}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Final Invoice Price & Admin Notes */}
|
|
<div className="border-t border-border/70 pt-4.5 space-y-3.5">
|
|
<div>
|
|
<label className="text-xs font-bold text-foreground flex items-center justify-between mb-1.5">
|
|
<span className="flex items-center gap-1.5">
|
|
<span>{t('customTrip.finalInvoicePrice')} ({t('customTrip.currencyUsd')}):</span>
|
|
<span className="text-rose-500 font-bold">*</span>
|
|
</span>
|
|
{!isPriceValid && (
|
|
<span className="text-[10px] font-semibold text-rose-500 bg-rose-500/10 px-2 py-0.5 rounded-full border border-rose-500/20">
|
|
{t('customTrip.requiredBadge')}
|
|
</span>
|
|
)}
|
|
</label>
|
|
<div className="max-w-md">
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
dir="ltr"
|
|
value={editPrice > 0 ? formatAmount(editPrice) : ''}
|
|
onChange={(e) => {
|
|
const raw = toEn(e.target.value).replace(/[^0-9]/g, '')
|
|
setEditPrice(raw ? Number(raw) : 0)
|
|
}}
|
|
className={`h-10 rounded-xl font-mono font-bold text-start text-sm transition-colors ${
|
|
!isPriceValid
|
|
? 'border-rose-500/60 focus:ring-rose-500/30 bg-rose-500/5'
|
|
: 'border-border/70 focus:ring-primary'
|
|
}`}
|
|
placeholder={t('customTrip.pricePlaceholder')}
|
|
/>
|
|
</div>
|
|
{!isPriceValid ? (
|
|
<p className="text-[11px] text-rose-500 font-medium mt-1.5 flex items-center gap-1">
|
|
<Ic name="alertCircle" className="size-3.5 shrink-0" />
|
|
<span>{t('customTrip.priceRequiredNotice')}</span>
|
|
</p>
|
|
) : (
|
|
<p className="text-[11px] text-muted-foreground mt-1.5">
|
|
{t('customTrip.priceAfterInvoiceNotice')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-semibold text-foreground block mb-1">
|
|
{t('customTrip.adminFeedback')}:
|
|
</label>
|
|
<Textarea
|
|
rows={2}
|
|
value={editNotes}
|
|
onChange={(e) => setEditNotes(e.target.value)}
|
|
placeholder={t('customTrip.adminFeedbackPlaceholder')}
|
|
style={{ unicodeBidi: 'plaintext', textAlign: 'start' }}
|
|
className="text-xs sm:text-sm rounded-xl bg-background border-border/70 focus:border-primary focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer - Two-Stage Actions */}
|
|
<DialogFooter className="p-4 sm:p-6 py-4 border-t border-border-soft shrink-0 bg-card/95 backdrop-blur-xs flex flex-wrap items-center justify-between gap-3 mt-0">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="rounded-xl text-xs h-9.5 px-4 border-border-soft hover:bg-muted cursor-pointer"
|
|
>
|
|
{t('customTrip.cancel')}
|
|
</Button>
|
|
{order.status !== 'REJECTED' && order.status !== 'APPROVED' && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleRejectOrder}
|
|
disabled={isRejecting || isSavingStage1 || isTransferringStage2}
|
|
className="rounded-xl text-xs h-9.5 px-4 border-rose-500/30 text-rose-600 dark:text-rose-400 hover:bg-rose-500/10 gap-1.5 cursor-pointer"
|
|
>
|
|
<Ic name="xCircle" className="size-3.5" />
|
|
<span>{t('customTrip.rejectOrderBtn')}</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
{!isFormValid && (
|
|
<span className="text-[11px] text-amber-600 dark:text-amber-400 font-medium flex items-center gap-1 px-2.5 py-1 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
|
<Ic name="alertCircle" className="size-3.5 shrink-0" />
|
|
<span>{t('customTrip.requiredFieldsAlert')}</span>
|
|
</span>
|
|
)}
|
|
|
|
{/* Stage 1 Action: Save Review Changes without creating invoice */}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleSaveStage1Review}
|
|
disabled={!isFormValid || isSavingStage1 || isTransferringStage2 || isRejecting}
|
|
className={`rounded-xl text-xs font-bold h-9.5 px-4.5 border-primary/40 text-primary hover:bg-primary/10 gap-1.5 ${
|
|
!isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer'
|
|
}`}
|
|
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : t('customTrip.saveStage1ReviewHint')}
|
|
>
|
|
<Ic name="save" className="size-3.5" />
|
|
<span>{isSavingStage1 ? t('customTrip.submitting') : t('customTrip.saveStage1Review')}</span>
|
|
</Button>
|
|
|
|
{/* Stage 2 Action: Final Approve & Transfer to Financial Invoices */}
|
|
<Button
|
|
type="button"
|
|
onClick={handleFinalizeAndTransferToBilling}
|
|
disabled={!isFormValid || isTransferringStage2 || isSavingStage1 || isRejecting}
|
|
className={`rounded-xl text-xs font-bold h-9.5 px-5.5 bg-emerald-600 text-white hover:bg-emerald-700 shadow-xs gap-1.5 ${
|
|
!isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer'
|
|
}`}
|
|
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : t('customTrip.approveAndTransferToBillingHint')}
|
|
>
|
|
<Ic name="receipt" className="size-4" />
|
|
<span>
|
|
{isTransferringStage2
|
|
? t('customTrip.submitting')
|
|
: isAlreadyInvoiced
|
|
? t('customTrip.updateInvoiceAndItems')
|
|
: t('customTrip.approveAndTransferToBilling')}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|