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

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>
)
}