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.
 
 
 

673 lines
24 KiB

import { apiFetch } from '@/services/http'
import type {
CustomTripOrderItem,
CustomTripOriginItem,
CustomTripDestinationItem,
CustomTripTransportItem,
CustomTripTransportRelationItem,
CustomTripHotelItem,
CustomTripOrderStatus,
} from '../types'
// Clear any legacy localStorage keys so panel strictly uses PostgreSQL database
if (typeof window !== 'undefined' && window.localStorage) {
const legacyKeys = [
'aqila_custom_destinations_v5',
'aqila_custom_hotels_v5',
'aqila_custom_relations_v5',
'aqila_custom_origins_v4',
'aqila_custom_destinations_v4',
'aqila_custom_hotels_v4',
'aqila_custom_relations_v4',
]
for (const key of legacyKeys) {
try {
window.localStorage.removeItem(key)
} catch {}
}
}
function cleanLegacyDetail(rawDetail: any): string {
if (!rawDetail || typeof rawDetail !== 'string') return ''
const s = rawDetail.trim()
if (s.startsWith('{') && (s.includes('beginTrip') || s.includes('number_passenger'))) {
return ''
}
return s
}
function addDaysIso(dateStr: string, days: number): string {
const d = new Date(`${dateStr.slice(0, 10)}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 getDefaultStartDateIso(createdStr?: string): string {
const d = createdStr ? new Date(createdStr) : new Date()
if (Number.isNaN(d.getTime())) {
return new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)
}
d.setUTCDate(d.getUTCDate() + 7)
return d.toISOString().slice(0, 10)
}
function mapBackendOrderToPanel(raw: any): CustomTripOrderItem {
const stays = Array.isArray(raw.stays) ? raw.stays : []
const rawLegs = Array.isArray(raw.legs) ? raw.legs : []
const passengers = Array.isArray(raw.passengers) ? raw.passengers : []
const rawServices = Array.isArray(raw.services) ? raw.services : []
const originCity = raw.origin_city || raw.originCity || ''
const destinations: string[] = Array.isArray(raw.destinations) ? raw.destinations : []
let legs = rawLegs.map((leg: any, idx: number) => {
const isReturn = Boolean(
leg.is_return ||
leg.isReturn ||
(idx === rawLegs.length - 1 &&
rawLegs.length > stays.length &&
stays.length > 0 &&
leg.to_city === originCity)
)
const matchingStay = isReturn
? null
: stays[idx] || stays.find((s: any) => s.city === leg.to_city)
const methodCode = (Number(leg.transport_method) || 1) as 1 | 2 | 3
return {
fromCity: leg.from_city || '',
toCity: isReturn ? leg.to_city || originCity || '' : leg.to_city || '',
transportMethod:
leg.transport_method_display ||
leg.transport_name_snapshot ||
(methodCode === 2 ? 'قطار (ریلی)' : methodCode === 3 ? 'زمینی (اتوبوس / ون VIP)' : 'پرواز (هوایی)'),
transportMethodCode: methodCode,
transportId: leg.transport_id ?? null,
hotelId: isReturn ? null : matchingStay?.hotel_id ?? null,
hotelName: isReturn ? '' : matchingStay?.hotel_name_snapshot || '',
stayNights: isReturn ? 0 : matchingStay?.nights || 3,
mealPlan: isReturn ? '' : matchingStay?.meal_plan || 'FULL_BOARD',
departureDate: leg.departure_date || matchingStay?.check_in || null,
departureTime: leg.departure_time || (isReturn ? '18:00' : '09:00'),
checkIn: isReturn ? null : matchingStay?.check_in || null,
checkOut: isReturn ? null : matchingStay?.check_out || null,
isReturn,
}
})
// If legacy order had destinations/stays but no legs, synthesize editable legs
if (legs.length === 0 && destinations.length > 0) {
legs = destinations.map((destCity, idx) => {
const matchingStay = stays.find((s: any) => s.city === destCity) || stays[idx]
return {
fromCity: idx === 0 ? originCity : destinations[idx - 1],
toCity: destCity,
transportMethod: 'پرواز (هوایی)',
transportMethodCode: 1 as const,
transportId: null,
hotelId: matchingStay?.hotel_id ?? null,
hotelName: matchingStay?.hotel_name_snapshot || '',
stayNights: matchingStay?.nights || 3,
mealPlan: matchingStay?.meal_plan || 'FULL_BOARD',
departureDate: matchingStay?.check_in || null,
departureTime: '09:00',
checkIn: matchingStay?.check_in || null,
checkOut: matchingStay?.check_out || null,
isReturn: false,
}
})
}
// Ensure last leg is Return to Origin City (has transport, no hotel) & dates are sequential
if (legs.length > 0 && originCity) {
const hasReturn = legs.some((l) => l.isReturn)
if (!hasReturn) {
const lastDest = legs[legs.length - 1].toCity || originCity
legs.push({
fromCity: lastDest,
toCity: originCity,
transportMethod: 'پرواز (هوایی)',
transportMethodCode: 1 as const,
transportId: null,
hotelId: null,
hotelName: '',
stayNights: 0,
mealPlan: '',
departureDate: null,
departureTime: '18:00',
checkIn: null,
checkOut: null,
isReturn: true,
})
}
let curDate = legs[0].departureDate || getDefaultStartDateIso(raw.created)
legs = legs.map((l, idx) => {
const fromCity = idx === 0 ? originCity || l.fromCity : legs[idx - 1].toCity || l.fromCity
if (l.isReturn) {
return {
...l,
fromCity,
toCity: originCity,
hotelId: null,
hotelName: '',
stayNights: 0,
mealPlan: '',
departureDate: curDate,
departureTime: l.departureTime || '18:00',
checkIn: null,
checkOut: null,
isReturn: true,
}
}
const nights = Math.max(1, Number(l.stayNights) || 3)
const depDate = idx === 0 && l.departureDate ? l.departureDate : curDate
const checkOut = addDaysIso(depDate, nights)
curDate = checkOut
return {
...l,
fromCity,
stayNights: nights,
departureDate: depDate,
departureTime: l.departureTime || '09:00',
checkIn: depDate,
checkOut,
isReturn: false,
}
})
}
const adultsCount = passengers.filter((p: any) => p.passenger_type === 'ADULT').length || raw.passenger_count || 1
const childrenCount = passengers.filter((p: any) => p.passenger_type === 'CHILD').length || 0
const infantsCount = passengers.filter((p: any) => p.passenger_type === 'INFANT').length || 0
const mappedPassengers = passengers.map((p: any) => ({
firstName: p.first_name || p.firstName || '',
lastName: p.last_name || p.lastName || '',
passportNumber: p.passport_number || p.passportNumber || '',
phoneNumber: p.phone_number || p.phoneNumber || '',
birthDate: p.birth_date || p.birthDate || '',
passengerType: (p.passenger_type || p.passengerType || 'ADULT') as 'ADULT' | 'CHILD' | 'INFANT',
passportImage: p.passport_image || p.passportImage || null,
}))
const mappedServices = rawServices.map((s: any) => ({
serviceType: s.service_type || s.serviceType || '',
quantity: Number(s.quantity) || 1,
totalPrice: Number(s.total_price ?? s.totalPrice ?? 0),
}))
return {
id: Number(raw.id),
trackingCode: raw.tracking_code || raw.trackingCode || '',
user: {
id: raw.user?.id || raw.id,
username: raw.customer_name || raw.user?.username || `user_${raw.id}`,
fullName: raw.customer_name || raw.user?.fullName || raw.user?.fullname || 'زائر گرامی',
phoneNumber: raw.contact_phone || raw.whatsapp_number || raw.user?.phoneNumber || '',
email: raw.user?.email || '',
},
originCity,
destinations,
legs,
passengers: mappedPassengers,
services: mappedServices,
adultsCount,
childrenCount,
infantsCount,
priceEstimate: Number(raw.final_price ?? raw.price_estimate ?? raw.priceEstimate ?? 0),
finalPrice: raw.final_price !== null && raw.final_price !== undefined ? Number(raw.final_price) : null,
factorId: raw.factor_id ?? null,
factorNumber: raw.factor_number ?? null,
status: (raw.status || 'PENDING') as CustomTripOrderStatus,
created: raw.created || new Date().toISOString(),
detail: cleanLegacyDetail(raw.detail),
adminNotes: raw.admin_notes || raw.adminNotes || '',
}
}
export async function fetchCustomTripOrders(): Promise<CustomTripOrderItem[]> {
try {
const res = await apiFetch<any>('trip/custom/v2/admin/orders/?page_size=100')
const list = Array.isArray(res) ? res : Array.isArray(res?.results) ? res.results : []
return list.map(mapBackendOrderToPanel)
} catch (err) {
console.warn('[CustomTrip API] Could not fetch admin orders from v2 endpoint:', err)
return []
}
}
export async function fetchCustomTripOrderDetail(orderId: number): Promise<CustomTripOrderItem | null> {
try {
const res = await apiFetch<any>(`trip/custom/v2/admin/orders/${orderId}/`)
if (res && res.id) {
return mapBackendOrderToPanel(res)
}
return null
} catch (err) {
console.warn(`[CustomTrip API] Could not fetch order ${orderId} detail:`, err)
// Fallback: search in orders list
try {
const orders = await fetchCustomTripOrders()
return orders.find((o) => o.id === orderId) || null
} catch {
return null
}
}
}
export async function updateCustomTripOrder(
orderId: number,
data: {
status?: CustomTripOrderStatus
priceEstimate?: number
adminNotes?: string
originCity?: string
legs?: {
fromCity: string
toCity: string
transportMethodCode?: 1 | 2 | 3
transportMethod?: string
hotelId?: number | string | null
hotelName?: string
stayNights?: number
mealPlan?: string
departureDate?: string | null
departureTime?: string | null
checkIn?: string | null
checkOut?: string | null
isReturn?: boolean
}[]
transferToBilling?: boolean
}
): Promise<CustomTripOrderItem> {
const itineraryPayload: Record<string, any> = {}
if (data.originCity) {
itineraryPayload.origin_city = data.originCity
}
if (Array.isArray(data.legs) && data.legs.length > 0) {
itineraryPayload.legs = data.legs.map((leg, idx) => ({
sequence: idx + 1,
from_city: leg.fromCity || (idx === 0 && data.originCity ? data.originCity : ''),
to_city: leg.toCity || (leg.isReturn && data.originCity ? data.originCity : leg.toCity),
transport_method: leg.transportMethodCode || 1,
departure_date: leg.departureDate || null,
departure_time: leg.departureTime || (leg.isReturn ? '18:00' : '09:00'),
is_return: Boolean(leg.isReturn),
}))
itineraryPayload.stays = data.legs
.filter((leg) => !leg.isReturn)
.map((leg, idx) => ({
sequence: idx + 1,
city: leg.toCity,
hotel_id: leg.hotelId ?? null,
hotel_name: leg.hotelName || '',
nights: Number(leg.stayNights) || 3,
meal_plan: leg.mealPlan || 'FULL_BOARD',
check_in: leg.checkIn || leg.departureDate || null,
check_out: leg.checkOut || null,
}))
}
// Stage 2: Finalize order and transfer to Financial Invoices (Billing)
if (
(data.transferToBilling || data.status === 'AWAITING_PAYMENT') &&
data.priceEstimate !== undefined &&
data.priceEstimate > 0
) {
const res = await apiFetch<any>(`trip/custom/v2/admin/orders/${orderId}/quote/`, {
method: 'POST',
body: {
final_price: data.priceEstimate,
admin_notes: data.adminNotes || '',
...itineraryPayload,
},
})
return mapBackendOrderToPanel(res)
}
if (data.status === 'REJECTED') {
const noteText = data.adminNotes || 'رد شده توسط مدیریت'
const res = await apiFetch<any>(`trip/custom/v2/admin/orders/${orderId}/reject/`, {
method: 'POST',
body: {
note: noteText,
reason: noteText,
},
})
return mapBackendOrderToPanel(res)
}
// Stage 1: Save changes/edits while keeping order in review (or updating status/items)
const res = await apiFetch<any>(`trip/custom/v2/admin/orders/${orderId}/`, {
method: 'PATCH',
body: {
status: data.status,
price_estimate: data.priceEstimate,
final_price: data.status === 'PENDING' ? undefined : data.priceEstimate,
admin_notes: data.adminNotes,
...itineraryPayload,
},
})
return mapBackendOrderToPanel(res)
}
export async function fetchCustomTripOrigins(): Promise<CustomTripOriginItem[]> {
const remote = await apiFetch<any[]>('trip/custom/v2/catalog/cities/')
if (!Array.isArray(remote)) return []
return remote.map((c: any) => ({
id: c.id ?? c.slug,
name: c.name || c.nameEn || '',
nameEn: c.nameEn || c.name || '',
country: c.country || '',
countryCode: c.countryCode || c.country_code || '',
flag: c.flag || '📍',
isInternational: Boolean(c.isInternational),
isActive: c.isActive !== undefined ? Boolean(c.isActive) : true,
translations: c.translations,
}))
}
export async function saveCustomTripOrigin(item: CustomTripOriginItem): Promise<CustomTripOriginItem> {
const payload = {
name: item.nameEn || item.name,
country: item.country,
countryCode: item.countryCode,
has_airport: true,
has_road_access: true,
}
const isNumericId = typeof item.id === 'number' || /^\d+$/.test(String(item.id))
const res = isNumericId
? await apiFetch<any>(`trip/custom/v2/catalog/cities/${item.id}/`, {
method: 'PUT',
body: JSON.stringify(payload),
}).catch(() =>
apiFetch<any>('trip/custom/v2/catalog/cities/', {
method: 'POST',
body: JSON.stringify(payload),
})
)
: await apiFetch<any>('trip/custom/v2/catalog/cities/', {
method: 'POST',
body: JSON.stringify(payload),
})
return {
id: res.id ?? item.id,
name: res.name || item.name,
nameEn: res.nameEn || item.nameEn,
country: res.country || item.country,
countryCode: res.countryCode || item.countryCode,
flag: res.flag || item.flag || '📍',
isInternational: res.isInternational !== undefined ? Boolean(res.isInternational) : item.isInternational,
isActive: res.isActive !== undefined ? Boolean(res.isActive) : item.isActive,
translations: res.translations || item.translations,
}
}
export async function deleteCustomTripOrigin(id: string | number): Promise<void> {
await apiFetch(`trip/custom/v2/catalog/cities/${id}/`, {
method: 'DELETE',
})
}
export async function fetchCustomTripDestinations(): Promise<CustomTripDestinationItem[]> {
const remote = await apiFetch<CustomTripDestinationItem[]>(
'trip/custom/v2/catalog/destinations/?include_inactive=true'
)
return Array.isArray(remote) ? remote : []
}
export async function fetchCustomTripDestinationById(
id: string | number
): Promise<CustomTripDestinationItem | null> {
try {
const detail = await apiFetch<CustomTripDestinationItem>(
`trip/custom/v2/catalog/destinations/${id}/`
)
if (detail && detail.id) return detail
} catch {}
const list = await fetchCustomTripDestinations()
const found = list.find(
(d) =>
String(d.id) === String(id) ||
d.name === String(id) ||
(d.nameEn && d.nameEn.toLowerCase() === String(id).toLowerCase())
)
return found || null
}
export async function saveCustomTripDestination(
item: CustomTripDestinationItem
): Promise<CustomTripDestinationItem> {
const payload = {
id: String(item.id),
city_key: String(item.id),
name: item.name,
nameEn: item.nameEn,
name_en: item.nameEn,
country: item.country,
flag: item.flag,
image: item.image,
blog_slug: item.blogSlug || item.blog_slug,
blogSlug: item.blogSlug || item.blog_slug,
journeys_count: item.journeysCount ?? item.journeys_count,
journeysCount: item.journeysCount ?? item.journeys_count,
short_description: item.shortDescription || item.short_description,
shortDescription: item.shortDescription || item.short_description,
description: item.description,
holyPlaces: item.holyPlaces,
holy_places: item.holyPlaces,
hotelsCount: item.hotelsCount,
hotels_count: item.hotelsCount,
transportsCount: item.transportsCount,
transports_count: item.transportsCount,
isActive: item.isActive,
is_active: item.isActive,
translations: item.translations,
}
const res = await apiFetch<CustomTripDestinationItem>(
`trip/custom/v2/catalog/destinations/${item.id}/`,
{
method: 'PUT',
body: JSON.stringify(payload),
}
).catch(async () => {
return await apiFetch<CustomTripDestinationItem>('trip/custom/v2/catalog/destinations/', {
method: 'POST',
body: JSON.stringify(payload),
})
})
return { ...item, ...res }
}
export async function deleteCustomTripDestination(id: string | number): Promise<void> {
await apiFetch(`trip/custom/v2/catalog/destinations/${id}/`, {
method: 'DELETE',
})
}
export async function fetchCustomTripTransports(): Promise<CustomTripTransportItem[]> {
const remote = await apiFetch<any[]>('trip/custom/v2/catalog/transports/')
if (!Array.isArray(remote)) return []
return remote.map((t: any) => ({
id: Number(t.id),
fromCity: t.from_city_display || t.from_city_name || '',
toCity: t.to_city_display || t.to_city_name || '',
method: (Number(t.method) || 1) as 1 | 2 | 3,
methodName: t.method_name || (t.method === 1 ? 'پرواز (هوایی)' : t.method === 2 ? 'قطار (ریلی)' : 'زمینی (اتوبوس / ون VIP)'),
priceAdult: Number(t.price) || 0,
priceChild: Number(t.price_child) || 0,
priceInfant: Number(t.price_infant) || 0,
isActive: true,
}))
}
export async function fetchCustomTripTransportRelations(): Promise<CustomTripTransportRelationItem[]> {
const remote = await apiFetch<any[]>('trip/custom/v2/catalog/transports/')
if (!Array.isArray(remote)) return []
const grouped = new Map<string, CustomTripTransportRelationItem>()
for (const t of remote) {
const fromCity = t.from_city_display || t.from_city_name || ''
const toCity = t.to_city_display || t.to_city_name || ''
const key = `${t.from_city_id}_${t.to_city_id}`
const existing = grouped.get(key)
const method = Number(t.method)
if (!existing) {
grouped.set(key, {
id: `rel_${t.from_city_id}_${t.to_city_id}`,
fromCity,
fromCountry: t.from_country || '',
toCity,
toCountry: t.to_country || '',
hasAirplane: method === 1,
hasTrain: method === 2,
hasRoad: method === 3,
airplaneDetails: method === 1 ? `نرخ پایه بزرگسال: $${Number(t.price || 0)}` : undefined,
trainDetails: method === 2 ? `نرخ پایه بزرگسال: $${Number(t.price || 0)}` : undefined,
roadDetails: method === 3 ? `نرخ پایه بزرگسال: $${Number(t.price || 0)}` : undefined,
isActive: true,
})
} else {
if (method === 1) {
existing.hasAirplane = true
existing.airplaneDetails = `نرخ پایه بزرگسال: $${Number(t.price || 0)}`
} else if (method === 2) {
existing.hasTrain = true
existing.trainDetails = `نرخ پایه بزرگسال: $${Number(t.price || 0)}`
} else if (method === 3) {
existing.hasRoad = true
existing.roadDetails = `نرخ پایه بزرگسال: $${Number(t.price || 0)}`
}
}
}
return Array.from(grouped.values())
}
export async function saveCustomTripTransportRelation(
item: CustomTripTransportRelationItem
): Promise<CustomTripTransportRelationItem> {
const methods: number[] = []
if (item.hasAirplane) methods.push(1)
if (item.hasTrain) methods.push(2)
if (item.hasRoad) methods.push(3)
if (methods.length === 0) methods.push(1)
for (const method of methods) {
await apiFetch('trip/custom/v2/catalog/transports/', {
method: 'POST',
body: JSON.stringify({
fromCity: item.fromCity,
fromCountry: item.fromCountry,
toCity: item.toCity,
toCountry: item.toCountry,
method,
}),
})
}
return item
}
export async function deleteCustomTripTransportRelation(id: string | number): Promise<void> {
const strId = String(id)
if (strId.startsWith('rel_')) {
const parts = strId.split('_')
if (parts.length >= 3) {
const fromId = Number(parts[1])
const toId = Number(parts[2])
const all = await apiFetch<any[]>('trip/custom/v2/catalog/transports/')
if (Array.isArray(all)) {
const matches = all.filter(
(t) => Number(t.from_city_id) === fromId && Number(t.to_city_id) === toId
)
for (const m of matches) {
await apiFetch(`trip/custom/v2/catalog/transports/${m.id}/`, {
method: 'DELETE',
})
}
return
}
}
}
await apiFetch(`trip/custom/v2/catalog/transports/${id}/`, {
method: 'DELETE',
})
}
export async function fetchCustomTripHotels(): Promise<CustomTripHotelItem[]> {
const remote = await apiFetch<any[]>('trip/custom/v2/catalog/hotels/')
if (!Array.isArray(remote)) return []
return remote.map((h: any) => ({
id: h.id,
name: h.name || '',
cityName: h.cityName || h.city_name || '',
stars: Number(h.stars) || 4,
score: Number(h.score) || 4.8,
distanceToShrine:
h.distanceToShrine ||
(h.distance_to_city_km ? `${h.distance_to_city_km} کیلومتر تا حرم مطهر` : 'نزدیک به حرم مطهر'),
distanceToShrineKm: Number(h.distanceToShrineKm ?? h.distance_to_city_km ?? 0.3),
amenities: Array.isArray(h.amenities)
? h.amenities
: ['اینترنت رایگان', 'رستوران و بوفه', 'نزدیک به حرم مطهر'],
description: h.description || 'هتل درجه یک و مجهز جهت اقامت زائرین گرامی.',
guideNote: h.guideNote || undefined,
pricePerDayAdult: Number(h.pricePerDayAdult ?? h.price_per_day ?? 80),
pricePerDayChild: Number(h.pricePerDayChild ?? h.price_per_day_child ?? 40),
image: '',
isActive: h.isActive !== undefined ? Boolean(h.isActive) : true,
translations: h.translations,
}))
}
export async function saveCustomTripHotel(item: CustomTripHotelItem): Promise<CustomTripHotelItem> {
const payload = {
name: item.name,
cityName: item.cityName,
stars: item.stars,
score: item.score,
distanceToShrine: item.distanceToShrine,
distanceToShrineKm: item.distanceToShrineKm ?? 0.3,
amenities: item.amenities,
description: item.description,
guideNote: item.guideNote,
pricePerDayAdult: item.pricePerDayAdult,
pricePerDayChild: item.pricePerDayChild,
isActive: item.isActive,
translations: item.translations,
}
const isNumericId = typeof item.id === 'number' || /^\d+$/.test(String(item.id))
const res = isNumericId
? await apiFetch<any>(`trip/custom/v2/catalog/hotels/${item.id}/`, {
method: 'PUT',
body: JSON.stringify(payload),
}).catch(() =>
apiFetch<any>('trip/custom/v2/catalog/hotels/', {
method: 'POST',
body: JSON.stringify(payload),
})
)
: await apiFetch<any>('trip/custom/v2/catalog/hotels/', {
method: 'POST',
body: JSON.stringify(payload),
})
return {
...item,
id: res.id ?? item.id,
name: res.name || item.name,
cityName: res.cityName || item.cityName,
}
}
export async function deleteCustomTripHotel(id: string | number): Promise<void> {
await apiFetch(`trip/custom/v2/catalog/hotels/${id}/`, {
method: 'DELETE',
})
}