PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / ViewBooking.tsx

ViewBooking.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/pages/ViewBooking.tsx

1,740 lines 74.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * View Booking Page
3 * Display booking details in a clean, minimal SaaS-style design
4 */
5
6 import React, { useMemo, useState } from "react";
7 import { useQuery } from "@tanstack/react-query";
8 import {
9 Download,
10 ArrowLeft,
11 Mail,
12 Phone,
13 Calendar,
14 Users,
15 DollarSign,
16 CreditCard,
17 FileText,
18 AlertCircle,
19 FileSignature,
20 CheckCircle,
21 Clock,
22 Send,
23 } from "lucide-react";
24 import { apiClient, apiService } from "../lib/api-client";
25 import { __ } from "../lib/i18n";
26 import { formatDate as formatDateUtil } from "../lib/dateFormat";
27 import PhoneDisplay from "../components/common/PhoneDisplay";
28 import { usePermissions } from "../hooks/usePermissions";
29 import { getCountryName } from "../lib/countries";
30 import { Button } from "../components/ui/button";
31 import { PageHeader } from "../components/common/PageHeader";
32 import {
33 Card,
34 CardContent,
35 CardHeader,
36 CardTitle,
37 } from "../components/ui/card";
38 import { ConditionalRender } from "../components/ui/conditional-render";
39 import { ResendEmailMenu } from "../components/bookings/ResendEmailMenu";
40 import { downloadAdminBookingInvoice } from "../lib/invoice-download";
41 import { Skeleton } from "../components/ui/skeleton";
42 import { formatYatraMoney } from "../lib/currency-display";
43
44 interface GoogleCalendarSyncInfo {
45 synced: boolean;
46 calendar_id: string | null;
47 event_id: string | null;
48 event_type: string | null;
49 sync_status: string | null;
50 error_message: string | null;
51 last_synced_at: string | null;
52 }
53
54 interface FormFieldConfig {
55 id: string;
56 type: string;
57 label: string;
58 enabled: boolean;
59 order: number;
60 section?: string;
61 options?: { value: string; label: string }[];
62 }
63
64 /**
65 * Dropdown answers are stored as the option VALUE (e.g. "spouse"); show the
66 * option's label from the form the trip used ("Spouse/Partner") when we have
67 * it, otherwise the raw value as before.
68 */
69 const optionLabel = (
70 field: FormFieldConfig | undefined,
71 value: unknown,
72 ): string => {
73 const raw = String(value ?? "");
74 if (field?.type === "select" && Array.isArray(field.options)) {
75 const match = field.options.find((o) => String(o.value) === raw);
76 if (match?.label) return match.label;
77 }
78 return raw;
79 };
80
81 interface FormSectionConfig {
82 title: string;
83 enabled: boolean;
84 fields: FormFieldConfig[];
85 }
86
87 interface BookingFormConfig {
88 contact_form: FormSectionConfig;
89 emergency_contact_form: FormSectionConfig;
90 traveler_form: FormSectionConfig;
91 }
92
93 const ViewBooking: React.FC = () => {
94 const { can } = usePermissions();
95
96 // Get booking id from URL
97 const bookingId = useMemo(() => {
98 const params = new URLSearchParams(window.location.search);
99 return params.get("id") ? parseInt(params.get("id") || "0") : null;
100 }, []);
101
102 // Helper to get field label by ID
103 const getFieldLabel = (
104 fieldId: string,
105 fields: FormFieldConfig[],
106 ): string => {
107 const field = fields.find((f) => f.id === fieldId);
108 return (
109 field?.label ||
110 fieldId.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())
111 );
112 };
113
114 // Fetch booking data from API
115 const {
116 data: booking,
117 isLoading,
118 error,
119 } = useQuery({
120 queryKey: ["booking", bookingId],
121 queryFn: async () => {
122 if (!bookingId) return null;
123
124 const result = await apiService.getBooking(bookingId);
125
126 if (!result) {
127 throw new Error("Failed to fetch booking");
128 }
129
130 // Handle both wrapped { success, data } and direct data response formats
131 const data = (result as any)?.data ?? result;
132
133 if (data && data.id) {
134 const contact = (data as any).contact || {};
135 return {
136 id: data.id,
137 booking_number: data.reference,
138 customer_name:
139 data.customer_name ||
140 (data.contact?.first_name && data.contact?.last_name
141 ? `${data.contact.first_name} ${data.contact.last_name}`.trim()
142 : `${data.contact_first_name || ""} ${data.contact_last_name || ""}`.trim()) ||
143 "N/A",
144 customer_email:
145 data.customer_email || data.contact_email || contact.email || "",
146 customer_phone:
147 data.customer_phone || data.contact_phone || contact.phone || "",
148 customer_country: data.contact_country || contact.country || "",
149 trip_id: data.trip_id,
150 trip_title: data.trip_title || `Trip #${data.trip_id}`,
151 trip_image: data.trip_image,
152 trip_price:
153 (parseFloat(data.subtotal) || data.total_amount || 0) /
154 (data.travelers_count || 1),
155 booking_date: data.created_at,
156 travel_date: data.travel_date,
157 travelers: data.travelers_count,
158 travelers_data: data.travelers || [],
159 total_amount: data.total_amount,
160 amount_paid: data.amount_paid || 0,
161 amount_due: data.amount_due || 0,
162 discount_amount: data.discount_amount || 0,
163 discount_code: data.discount_code || null,
164 currency: data.currency || "USD",
165 // Tax fields
166 subtotal: data.subtotal,
167 tax_amount: data.tax_amount,
168 tax_rate: data.tax_rate,
169 tax_inclusive: data.tax_inclusive,
170 tax_details: data.tax_details,
171 tax_breakdown: data.tax_breakdown,
172 taxable_amount: data.taxable_amount,
173 // Itinerary costs
174 itinerary_costs: data.itinerary_costs,
175 itinerary_costs_total: data.itinerary_costs_total,
176 // Additional services
177 additional_services: data.additional_services,
178 services_total: data.services_total,
179 // End tax fields
180 payment_status: data.payment_status,
181 booking_status: data.status,
182 payment_method: data.payment_gateway,
183 payment_date: data.payment_date,
184 notes: data.special_requests,
185 internal_notes: data.internal_notes,
186 emergency_contact: data.emergency_contact,
187 contact_data: data.contact_data,
188 payments: data.payments || [],
189 created_at: data.created_at,
190 updated_at: data.updated_at,
191 confirmed_at: data.confirmed_at,
192 completed_at: data.completed_at,
193 cancelled_at: data.cancelled_at,
194 cancellation_reason: data.cancellation_reason,
195 trip_details: data.trip_details,
196 google_calendar: data.google_calendar as
197 | GoogleCalendarSyncInfo
198 | undefined,
199 };
200 }
201
202 return null;
203 },
204 enabled: !!bookingId && can("yatra_view_bookings"),
205 });
206
207 // Fetch booking form configuration for dynamic field labels, resolved for
208 // this booking's trip so per-trip form versions (Pro conditions) label the
209 // fields the customer actually saw. Waits for the booking to know its trip.
210 const bookingTripId = booking?.trip_id ? Number(booking.trip_id) : null;
211 const { data: formConfig } = useQuery<BookingFormConfig>({
212 queryKey: ["booking-form-config", bookingTripId],
213 queryFn: async () => {
214 const response = await apiService.getBookingFormConfig(bookingTripId);
215 return (
216 response?.data?.booking_form_config ||
217 response?.booking_form_config ||
218 null
219 );
220 },
221 enabled: !!booking,
222 });
223
224 // Get enabled traveler fields from config
225 const travelerFields = useMemo(() => {
226 if (!formConfig?.traveler_form?.fields) return [];
227 return formConfig.traveler_form.fields
228 .filter((field) => field.enabled)
229 .sort((a, b) => a.order - b.order);
230 }, [formConfig]);
231
232 // Get enabled emergency contact fields
233 const emergencyFields = useMemo(() => {
234 if (!formConfig?.emergency_contact_form?.fields) return [];
235 return formConfig.emergency_contact_form.fields
236 .filter((field) => field.enabled)
237 .sort((a, b) => a.order - b.order);
238 }, [formConfig]);
239
240 // Get enabled contact (lead traveler) fields — used to label and format the
241 // country / nationality / address / custom contact fields in the summary.
242 const contactFields = useMemo(() => {
243 if (!formConfig?.contact_form?.fields) return [];
244 return formConfig.contact_form.fields
245 .filter((field) => field.enabled)
246 .sort((a, b) => a.order - b.order);
247 }, [formConfig]);
248
249 // Fetch consent status for this booking (only if Pro is active)
250 const isPro = !!(window as any).yatraAdmin?.isPro;
251 const { data: consentStatus } = useQuery({
252 queryKey: ["booking-consent-status", bookingId],
253 queryFn: async () => {
254 if (!bookingId) return null;
255 const response = await apiClient.get(
256 `/bookings/${bookingId}/consent-status`,
257 );
258 return response?.data || null;
259 },
260 enabled: !!bookingId && isPro,
261 });
262
263 const formatDate = (dateString: string) => {
264 return formatDateUtil(dateString);
265 };
266
267 const formatPrice = (price: number, currencyCode: string = "USD") =>
268 formatYatraMoney(Number(price) || 0, currencyCode, {
269 zeroAsUnknown: false,
270 });
271
272 const getBookingStatusBadge = (status: string) => {
273 const statusMap: Record<string, { className: string; label: string }> = {
274 confirmed: {
275 className:
276 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
277 label: __("Confirmed", "yatra"),
278 },
279 pending: {
280 className:
281 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
282 label: __("Pending", "yatra"),
283 },
284 cancelled: {
285 className:
286 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
287 label: __("Cancelled", "yatra"),
288 },
289 completed: {
290 className:
291 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
292 label: __("Completed", "yatra"),
293 },
294 };
295
296 const statusInfo = statusMap[status] || {
297 className:
298 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
299 label: status,
300 };
301
302 return (
303 <span
304 className={`inline-flex items-center px-3 py-1 rounded-md text-sm font-medium ${statusInfo.className}`}
305 >
306 {statusInfo.label}
307 </span>
308 );
309 };
310
311 const getPaymentStatusBadge = (status: string) => {
312 const statusMap: Record<string, { className: string; label: string }> = {
313 paid: {
314 className:
315 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
316 label: __("Paid", "yatra"),
317 },
318 pending: {
319 className:
320 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
321 label: __("Pending", "yatra"),
322 },
323 partial: {
324 className:
325 "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400",
326 label: __("Partial", "yatra"),
327 },
328 refunded: {
329 className:
330 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
331 label: __("Refunded", "yatra"),
332 },
333 };
334
335 const statusInfo = statusMap[status] || {
336 className:
337 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
338 label: status,
339 };
340
341 return (
342 <span
343 className={`inline-flex items-center px-3 py-1 rounded-md text-sm font-medium ${statusInfo.className}`}
344 >
345 {statusInfo.label}
346 </span>
347 );
348 };
349
350 const handleBack = () => {
351 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings`;
352 };
353
354 const handleEdit = () => {
355 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`;
356 };
357
358 // The booking invoice works for unpaid bookings too — it renders as a
359 // pro-forma invoice with the gateway's payment instructions. This page has
360 // no toast provider, so a failure is rendered as an inline banner under the
361 // header; a tooltip alone would leave a failed download looking like nothing
362 // happened.
363 const [invoiceError, setInvoiceError] = useState<string>("");
364 const handleDownloadInvoice = async () => {
365 const id = Number(booking?.id ?? 0);
366 if (!id) {
367 return;
368 }
369 setInvoiceError("");
370 try {
371 await downloadAdminBookingInvoice(id);
372 } catch (error: any) {
373 setInvoiceError(
374 error?.message || __("Failed to download invoice", "yatra"),
375 );
376 }
377 };
378
379 if (isLoading) {
380 return (
381 <div className="space-y-3">
382 {/* Header Skeleton */}
383 <div className="flex items-center justify-between">
384 <div className="space-y-2">
385 <Skeleton className="h-8 w-48" />
386 <Skeleton className="h-4 w-64" />
387 </div>
388 <div className="flex gap-2">
389 <Skeleton className="h-10 w-28" />
390 <Skeleton className="h-10 w-20" />
391 </div>
392 </div>
393
394 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
395 {/* Main Content Skeleton */}
396 <div className="lg:col-span-2 space-y-3">
397 {/* Booking Overview Card */}
398 <Card>
399 <CardHeader className="pb-2">
400 <div className="flex items-center justify-between">
401 <Skeleton className="h-5 w-36" />
402 <div className="flex items-center gap-2">
403 <Skeleton className="h-6 w-20 rounded-md" />
404 <Skeleton className="h-6 w-16 rounded-md" />
405 </div>
406 </div>
407 </CardHeader>
408 <CardContent className="space-y-4">
409 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
410 {[...Array(6)].map((_, i) => (
411 <div key={i} className="space-y-1">
412 <Skeleton className="h-3 w-24" />
413 <Skeleton className="h-5 w-32" />
414 </div>
415 ))}
416 </div>
417 </CardContent>
418 </Card>
419
420 {/* Customer Information Card */}
421 <Card>
422 <CardHeader className="pb-2">
423 <Skeleton className="h-5 w-40" />
424 </CardHeader>
425 <CardContent className="space-y-3">
426 <Skeleton className="h-5 w-36" />
427 <div className="flex items-center gap-4">
428 <Skeleton className="h-4 w-48" />
429 <Skeleton className="h-4 w-32" />
430 </div>
431 </CardContent>
432 </Card>
433
434 {/* Notes Card */}
435 <Card>
436 <CardHeader className="pb-2">
437 <Skeleton className="h-5 w-16" />
438 </CardHeader>
439 <CardContent>
440 <Skeleton className="h-20 w-full" />
441 </CardContent>
442 </Card>
443 </div>
444
445 {/* Sidebar Skeleton */}
446 <div className="space-y-3">
447 {/* Payment Information Card */}
448 <Card>
449 <CardHeader className="pb-2">
450 <Skeleton className="h-5 w-36" />
451 </CardHeader>
452 <CardContent className="space-y-3">
453 <div className="space-y-1">
454 <Skeleton className="h-3 w-24" />
455 <Skeleton className="h-6 w-16 rounded-md" />
456 </div>
457 <div className="space-y-1">
458 <Skeleton className="h-3 w-28" />
459 <Skeleton className="h-4 w-24" />
460 </div>
461 <div className="space-y-1">
462 <Skeleton className="h-3 w-32" />
463 <Skeleton className="h-4 w-20" />
464 </div>
465 <div className="space-y-1">
466 <Skeleton className="h-3 w-24" />
467 <Skeleton className="h-6 w-28" />
468 </div>
469 </CardContent>
470 </Card>
471
472 {/* Timeline Card */}
473 <Card>
474 <CardHeader className="pb-2">
475 <Skeleton className="h-5 w-20" />
476 </CardHeader>
477 <CardContent className="space-y-3">
478 <div className="space-y-1">
479 <Skeleton className="h-3 w-16" />
480 <Skeleton className="h-4 w-40" />
481 </div>
482 <div className="space-y-1">
483 <Skeleton className="h-3 w-24" />
484 <Skeleton className="h-4 w-40" />
485 </div>
486 </CardContent>
487 </Card>
488 </div>
489 </div>
490 </div>
491 );
492 }
493
494 if (error || !booking) {
495 return (
496 <div className="space-y-3">
497 <PageHeader
498 title={__("Booking Not Found", "yatra")}
499 description={__(
500 "The booking you are looking for does not exist",
501 "yatra",
502 )}
503 actions={
504 <Button
505 variant="outline"
506 onClick={handleBack}
507 className="flex items-center gap-2"
508 >
509 <ArrowLeft className="w-4 h-4" />
510 {__("Back to Bookings", "yatra")}
511 </Button>
512 }
513 />
514 <Card>
515 <CardContent className="p-8 text-center text-red-500">
516 {__("Error loading booking or booking not found", "yatra")}
517 </CardContent>
518 </Card>
519 </div>
520 );
521 }
522
523 return (
524 <div className="space-y-3">
525 <PageHeader
526 title={__("Booking Details", "yatra")}
527 description={__("View complete booking information", "yatra")}
528 actions={
529 <div className="flex gap-2">
530 <ConditionalRender capability="yatra_view_bookings">
531 <Button
532 variant="outline"
533 onClick={handleDownloadInvoice}
534 className="flex items-center gap-2"
535 >
536 <Download className="w-4 h-4" />
537 {__("Download invoice", "yatra")}
538 </Button>
539 </ConditionalRender>
540 <ConditionalRender capability="yatra_edit_bookings">
541 <ResendEmailMenu
542 bookingId={Number(booking.id)}
543 status={booking.booking_status}
544 amountPaid={Number(booking.amount_paid) || 0}
545 />
546 </ConditionalRender>
547 <ConditionalRender capability="yatra_edit_bookings">
548 <Button onClick={handleEdit} className="flex items-center gap-2">
549 {__("Edit Booking", "yatra")}
550 </Button>
551 </ConditionalRender>
552 <Button
553 variant="outline"
554 onClick={handleBack}
555 className="flex items-center gap-2"
556 >
557 <ArrowLeft className="w-4 h-4" />
558 {__("Back", "yatra")}
559 </Button>
560 </div>
561 }
562 />
563
564 {invoiceError && (
565 <div
566 role="alert"
567 className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
568 >
569 <AlertCircle className="w-4 h-4 shrink-0" />
570 {invoiceError}
571 </div>
572 )}
573
574 <ConditionalRender capability="yatra_view_bookings">
575 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
576 {/* Main Content */}
577 <div className="lg:col-span-2 space-y-3">
578 {/* Booking Overview */}
579 <Card>
580 <CardHeader className="pb-2">
581 <div className="flex items-center justify-between">
582 <CardTitle className="text-base">
583 {__("Booking Overview", "yatra")}
584 </CardTitle>
585 <div className="flex items-center gap-2">
586 {getBookingStatusBadge(booking.booking_status)}
587 {getPaymentStatusBadge(booking.payment_status)}
588 </div>
589 </div>
590 </CardHeader>
591 <CardContent className="space-y-4">
592 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
593 <div>
594 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
595 {__("Booking Number", "yatra")}
596 </div>
597 <div className="text-lg font-semibold text-gray-900 dark:text-white">
598 {booking.booking_number}
599 </div>
600 </div>
601 <div>
602 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
603 {__("Trip", "yatra")}
604 </div>
605 <div className="text-lg font-semibold text-gray-900 dark:text-white">
606 {booking.trip_title}
607 </div>
608 <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
609 {__("Trip ID", "yatra")}: #{booking.trip_id}
610 </div>
611 </div>
612 <div>
613 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
614 <Calendar className="w-3 h-3" />
615 {__("Booking Date", "yatra")}
616 </div>
617 <div className="text-sm text-gray-900 dark:text-white">
618 {formatDate(booking.booking_date)}
619 </div>
620 </div>
621 <div>
622 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
623 <Calendar className="w-3 h-3" />
624 {__("Travel Date", "yatra")}
625 </div>
626 <div className="text-sm text-gray-900 dark:text-white">
627 {formatDate(booking.travel_date)}
628 </div>
629 </div>
630 <div>
631 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
632 <Users className="w-3 h-3" />
633 {__("Number of Travelers", "yatra")}
634 </div>
635 <div className="text-sm text-gray-900 dark:text-white">
636 {booking.travelers}{" "}
637 {booking.travelers === 1
638 ? __("Traveler", "yatra")
639 : __("Travelers", "yatra")}
640 </div>
641 </div>
642 </div>
643
644 {/* Payment Summary */}
645 <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
646 <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
647 {__("Payment Summary", "yatra")}
648 </h4>
649 {(() => {
650 // ── Raw figures ──────────────────────────────────────────
651 const dbSubtotal =
652 parseFloat((booking as any).subtotal) ||
653 booking.total_amount ||
654 0;
655 const discountAmount = booking.discount_amount || 0;
656 const itineraryCostsTotal =
657 parseFloat((booking as any).itinerary_costs_total) || 0;
658 const taxAmount =
659 parseFloat((booking as any).tax_amount) || 0;
660 // Handle both PHP bool (true/false) and int/string (1/0/"1"/"0")
661 const taxInclusiveRaw = (booking as any).tax_inclusive;
662 const taxInclusive =
663 taxInclusiveRaw === true ||
664 parseInt(String(taxInclusiveRaw), 10) === 1;
665 const totalAmount = booking.total_amount || 0;
666 const amountPaid = booking.amount_paid || 0;
667 const amountDue = booking.amount_due || 0;
668
669 // ── Tax breakdown ────────────────────────────────────────
670 let taxLines: {
671 name: string;
672 rate: number;
673 amount: number;
674 }[] = [];
675 const taxBreakdownRaw = (booking as any).tax_breakdown;
676 if (
677 Array.isArray(taxBreakdownRaw) &&
678 taxBreakdownRaw.length > 0
679 ) {
680 taxLines = taxBreakdownRaw;
681 } else {
682 const taxDetailsRaw = (booking as any).tax_details;
683 if (taxDetailsRaw) {
684 try {
685 const parsed =
686 typeof taxDetailsRaw === "string"
687 ? JSON.parse(taxDetailsRaw)
688 : taxDetailsRaw;
689 if (Array.isArray(parsed) && parsed.length > 0)
690 taxLines = parsed;
691 } catch (_) {}
692 }
693 if (taxLines.length === 0 && taxAmount > 0) {
694 taxLines = [
695 {
696 name: __("Tax", "yatra"),
697 rate: parseFloat((booking as any).tax_rate) || 0,
698 amount: taxAmount,
699 },
700 ];
701 }
702 }
703
704 // ── Itinerary costs ──────────────────────────────────────
705 let itineraryCosts: any[] = [];
706 const itineraryCostsRaw = (booking as any).itinerary_costs;
707 if (typeof itineraryCostsRaw === "string") {
708 try {
709 itineraryCosts = JSON.parse(itineraryCostsRaw);
710 } catch (_) {}
711 } else if (Array.isArray(itineraryCostsRaw)) {
712 itineraryCosts = itineraryCostsRaw;
713 }
714
715 // ── Services ─────────────────────────────────────────────
716 const rawServices = (booking as any).additional_services;
717 const selectedServices: any[] = Array.isArray(rawServices)
718 ? rawServices.filter((s: any) => s?.selected !== false)
719 : [];
720 const getServiceAmt = (s: any) =>
721 parseFloat(
722 s?.total_price ??
723 s?.calculated_price ??
724 s?.total_cost ??
725 s?.amount ??
726 s?.unit_price ??
727 s?.price ??
728 0,
729 );
730 const servicesTotal = selectedServices.reduce(
731 (sum, s) => sum + getServiceAmt(s),
732 0,
733 );
734 const baseTripCost = dbSubtotal - servicesTotal;
735
736 // ── Derived amounts ──────────────────────────────────────
737 const taxableAmount =
738 Math.max(0, dbSubtotal - discountAmount) +
739 itineraryCostsTotal;
740
741 const hasServices = selectedServices.length > 0;
742 const hasDiscount = discountAmount > 0;
743 const hasItinerary =
744 itineraryCosts.length > 0 && itineraryCostsTotal > 0;
745 const hasTax = taxLines.length > 0;
746 const showTaxableRow =
747 hasTax && (hasDiscount || hasItinerary);
748
749 const Row = ({
750 label,
751 value,
752 sub = false,
753 green = false,
754 bold = false,
755 }: any) => (
756 <div
757 className={`flex justify-between text-sm${sub ? " ml-4" : ""}`}
758 >
759 <span
760 className={
761 bold
762 ? "font-semibold text-gray-900 dark:text-white"
763 : "text-gray-600 dark:text-gray-400"
764 }
765 >
766 {label}
767 </span>
768 <span
769 className={
770 bold
771 ? "font-semibold text-gray-900 dark:text-white"
772 : green
773 ? "text-green-600 dark:text-green-400"
774 : "text-gray-900 dark:text-white"
775 }
776 >
777 {value}
778 </span>
779 </div>
780 );
781
782 return (
783 <div className="space-y-1.5">
784 {/* Trip Base Price — always the first line */}
785 <div className="flex justify-between text-sm">
786 <span className="text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
787 {__("Trip Base Price", "yatra")}
788 {taxInclusive && (
789 <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
790 {__("Tax Incl.", "yatra")}
791 </span>
792 )}
793 </span>
794 <span className="text-gray-900 dark:text-white">
795 {formatPrice(baseTripCost, booking.currency)}
796 </span>
797 </div>
798
799 {/* Additional Services section — only when services exist */}
800 {hasServices && (
801 <>
802 <div className="flex justify-between text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mt-1">
803 <span>
804 + {__("Additional Services", "yatra")}
805 </span>
806 <span></span>
807 </div>
808 {selectedServices.map((s: any, i: number) => (
809 <Row
810 key={i}
811 sub
812 label={
813 s?.service_name ||
814 s?.name ||
815 s?.title ||
816 s?.label ||
817 __("Service", "yatra")
818 }
819 value={formatPrice(
820 getServiceAmt(s),
821 booking.currency,
822 )}
823 />
824 ))}
825 {/* Gross Total = Trip Base Price + Services */}
826 <div className="flex justify-between text-sm font-semibold border-t border-gray-200 dark:border-gray-600 pt-1.5 mt-0.5">
827 <span className="text-gray-900 dark:text-white">
828 {__("Gross Total", "yatra")}
829 </span>
830 <span className="text-gray-900 dark:text-white">
831 {formatPrice(dbSubtotal, booking.currency)}
832 </span>
833 </div>
834 </>
835 )}
836
837 {/* Discount */}
838 {hasDiscount && (
839 <Row
840 label={__("Discount", "yatra")}
841 value={`-${formatPrice(discountAmount, booking.currency)}`}
842 green
843 />
844 )}
845
846 {/* Itinerary Costs section */}
847 {hasItinerary && (
848 <>
849 <div className="flex justify-between text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mt-1">
850 <span>+ {__("Itinerary Costs", "yatra")}</span>
851 <span></span>
852 </div>
853 {itineraryCosts.map((cost: any, i: number) => (
854 <Row
855 key={i}
856 sub
857 label={`${cost.name} ${cost.price_per === "person" ? __("(per person)", "yatra") : cost.price_per === "group" ? __("(per booking)", "yatra") : __("(flat rate)", "yatra")}`}
858 value={formatPrice(
859 cost.total_cost ?? cost.price,
860 booking.currency,
861 )}
862 />
863 ))}
864 </>
865 )}
866
867 {/* Taxable Amount — only for exclusive tax when discount/itinerary exist above it */}
868 {!taxInclusive && showTaxableRow && (
869 <div className="flex justify-between text-sm font-semibold border-t border-dashed border-gray-300 dark:border-gray-600 pt-1.5 mt-0.5">
870 <span className="text-gray-700 dark:text-gray-300">
871 {__("Taxable Amount", "yatra")}
872 </span>
873 <span className="text-gray-900 dark:text-white">
874 {formatPrice(taxableAmount, booking.currency)}
875 </span>
876 </div>
877 )}
878
879 {/* Exclusive tax — shown as an addition before Net Amount */}
880 {hasTax && !taxInclusive && (
881 <>
882 <div className="flex justify-between text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mt-1">
883 <span>+ {__("Tax", "yatra")}</span>
884 <span></span>
885 </div>
886 {taxLines.map((tax: any, i: number) => (
887 <div
888 key={i}
889 className="flex justify-between text-sm ml-4"
890 >
891 <span className="text-gray-600 dark:text-gray-400">
892 {tax.name}
893 {tax.rate > 0 ? ` (${tax.rate}%)` : ""}
894 </span>
895 <span className="text-gray-900 dark:text-white">
896 +
897 {formatPrice(
898 parseFloat(tax.amount) || 0,
899 booking.currency,
900 )}
901 </span>
902 </div>
903 ))}
904 </>
905 )}
906
907 {/* Net Amount */}
908 <div className="flex justify-between text-base font-bold border-t-2 border-gray-800 dark:border-gray-300 pt-2 mt-1">
909 <span className="text-gray-900 dark:text-white">
910 {__("Net Amount", "yatra")}
911 </span>
912 <span className="text-gray-900 dark:text-white">
913 {formatPrice(totalAmount, booking.currency)}
914 </span>
915 </div>
916
917 {/* Inclusive tax — informational footnote after Net Amount */}
918 {hasTax && taxInclusive && (
919 <div className="mt-1 space-y-0.5">
920 {taxLines.map((tax: any, i: number) => (
921 <div
922 key={i}
923 className="text-xs text-gray-400 dark:text-gray-500 italic"
924 >
925 {__("Incl.", "yatra")} {tax.name}
926 {tax.rate > 0 ? ` (${tax.rate}%)` : ""}:{" "}
927 {formatPrice(
928 parseFloat(tax.amount) || 0,
929 booking.currency,
930 )}
931 </div>
932 ))}
933 </div>
934 )}
935
936 {/* Amount Paid */}
937 {amountPaid > 0 && (
938 <Row
939 label={__("Amount Paid", "yatra")}
940 value={formatPrice(amountPaid, booking.currency)}
941 green
942 />
943 )}
944
945 {/* Due Now */}
946 {amountDue > 0 && (
947 <div className="flex justify-between text-sm font-semibold">
948 <span className="text-orange-600 dark:text-orange-400">
949 {__("Due Now", "yatra")}
950 </span>
951 <span className="text-orange-600 dark:text-orange-400">
952 {formatPrice(amountDue, booking.currency)}
953 </span>
954 </div>
955 )}
956 </div>
957 );
958 })()}
959 </div>
960 </CardContent>
961 </Card>
962
963 {/* Payments recorded against this booking (gateway captures and
964 manual entries). Only completed rows count towards Amount
965 Paid — a pending row is money expected, not received. */}
966 {Array.isArray(booking.payments) && booking.payments.length > 0 && (
967 <Card data-testid="booking-payments-card">
968 <CardHeader className="pb-2">
969 <CardTitle className="text-base">
970 {__("Payments", "yatra")}
971 </CardTitle>
972 </CardHeader>
973 <CardContent>
974 <div className="divide-y divide-gray-100 dark:divide-gray-800">
975 {booking.payments.map((payment: any) => {
976 const status = String(
977 payment.status || payment.payment_status || "pending",
978 );
979 const statusClass =
980 status === "completed"
981 ? "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400"
982 : status === "pending"
983 ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400"
984 : status === "refunded"
985 ? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400"
986 : "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400";
987 const statusLabel: Record<string, string> = {
988 completed: __("Completed", "yatra"),
989 pending: __("Pending", "yatra"),
990 failed: __("Failed", "yatra"),
991 refunded: __("Refunded", "yatra"),
992 cancelled: __("Cancelled", "yatra"),
993 };
994 return (
995 <div
996 key={payment.id}
997 className="py-2 flex items-start justify-between gap-3 text-sm"
998 >
999 <div className="min-w-0">
1000 <div className="font-medium text-gray-900 dark:text-white">
1001 {formatPrice(
1002 Number(payment.amount || 0),
1003 payment.currency || booking.currency,
1004 )}
1005 <span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
1006 {payment.payment_method_label ||
1007 payment.gateway ||
1008 ""}
1009 </span>
1010 </div>
1011 <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
1012 {payment.payment_date
1013 ? formatDate(payment.payment_date)
1014 : ""}
1015 {payment.transaction_id
1016 ? ` · ${payment.transaction_id}`
1017 : ""}
1018 {payment.payment_number
1019 ? ` · ${payment.payment_number}`
1020 : ""}
1021 </div>
1022 </div>
1023 <span
1024 className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium whitespace-nowrap ${statusClass}`}
1025 >
1026 {statusLabel[status] || status}
1027 </span>
1028 </div>
1029 );
1030 })}
1031 </div>
1032 </CardContent>
1033 </Card>
1034 )}
1035
1036 {/* Customer Information */}
1037 <Card>
1038 <CardHeader className="pb-2">
1039 <CardTitle className="text-base">
1040 {__("Customer Information", "yatra")}
1041 </CardTitle>
1042 </CardHeader>
1043 <CardContent className="space-y-3">
1044 <div>
1045 <div className="text-sm font-medium text-gray-900 dark:text-white mb-1">
1046 {booking.customer_name}
1047 </div>
1048 <div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
1049 <div className="flex items-center gap-1.5">
1050 <Mail className="w-4 h-4" />
1051 {booking.customer_email}
1052 </div>
1053 {booking.customer_phone && (
1054 <div className="flex items-center gap-1.5">
1055 <Phone className="w-4 h-4" />
1056 <PhoneDisplay value={booking.customer_phone} />
1057 </div>
1058 )}
1059 </div>
1060 </div>
1061
1062 {/* Country + dynamic/custom contact fields (nationality,
1063 address, and any custom contact field). Mirrors the
1064 Emergency/Traveler dynamic-field display. */}
1065 {(() => {
1066 const cd =
1067 booking.contact_data &&
1068 typeof booking.contact_data === "object"
1069 ? (booking.contact_data as Record<string, unknown>)
1070 : {};
1071 const CORE = ["first_name", "last_name", "email", "phone"];
1072 const extras = Object.entries(cd).filter(
1073 ([k, v]) =>
1074 !CORE.includes(k) &&
1075 k !== "country" &&
1076 v != null &&
1077 String(v).trim() !== "",
1078 );
1079 const countryCode =
1080 booking.customer_country ||
1081 (cd.country ? String(cd.country) : "");
1082 if (!countryCode && extras.length === 0) return null;
1083 return (
1084 <div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-1 border-t border-gray-100 dark:border-gray-700">
1085 {countryCode && (
1086 <div>
1087 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1088 {getFieldLabel("country", contactFields)}
1089 </div>
1090 <div className="text-sm font-medium text-gray-900 dark:text-white">
1091 {getCountryName(countryCode)}
1092 </div>
1093 </div>
1094 )}
1095 {extras.map(([fieldId, value]) => {
1096 const field = contactFields.find(
1097 (f) => f.id === fieldId,
1098 );
1099 const display =
1100 field?.type === "country"
1101 ? getCountryName(String(value))
1102 : optionLabel(field, value);
1103 return (
1104 <div key={fieldId}>
1105 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1106 {getFieldLabel(fieldId, contactFields)}
1107 </div>
1108 <div className="text-sm font-medium text-gray-900 dark:text-white">
1109 {display}
1110 </div>
1111 </div>
1112 );
1113 })}
1114 </div>
1115 );
1116 })()}
1117 </CardContent>
1118 </Card>
1119
1120 {/* Travelers Information - Dynamic Fields */}
1121 {booking.travelers_data && booking.travelers_data.length > 0 && (
1122 <Card>
1123 <CardHeader className="pb-2">
1124 <CardTitle className="text-base flex items-center gap-2">
1125 <Users className="w-4 h-4" />
1126 {formConfig?.traveler_form?.title ||
1127 __("Travelers Information", "yatra")}
1128 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1129 ({booking.travelers_data.length}{" "}
1130 {booking.travelers_data.length === 1
1131 ? __("traveler", "yatra")
1132 : __("travelers", "yatra")}
1133 )
1134 </span>
1135 </CardTitle>
1136 </CardHeader>
1137 <CardContent className="space-y-4">
1138 {booking.travelers_data.map(
1139 (traveler: any, index: number) => {
1140 // Extract fields from traveler - handle both flat structure and nested fields property
1141 const travelerFieldsData = traveler.fields || traveler;
1142 const systemFields = [
1143 "id",
1144 "booking_id",
1145 "traveller_index",
1146 "is_lead",
1147 "created_at",
1148 "updated_at",
1149 "fields",
1150 ];
1151
1152 // Get all non-empty fields from the traveler data, excluding system fields
1153 const travelerEntries = Object.entries(
1154 travelerFieldsData,
1155 ).filter(([key, value]) => {
1156 // Exclude system fields
1157 if (systemFields.includes(key)) return false;
1158 // Exclude empty values
1159 if (
1160 !value ||
1161 (typeof value === "string" && value.trim() === "")
1162 )
1163 return false;
1164 // Exclude objects (they should be in fields)
1165 if (typeof value === "object" && !Array.isArray(value))
1166 return false;
1167 return true;
1168 });
1169
1170 // Get name from fields or direct properties
1171 const firstName =
1172 travelerFieldsData.first_name ||
1173 traveler.first_name ||
1174 "";
1175 const lastName =
1176 travelerFieldsData.last_name ||
1177 traveler.last_name ||
1178 "";
1179
1180 return (
1181 <div
1182 key={index}
1183 className={`p-4 rounded-lg ${index === 0 ? "bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800" : "bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700"}`}
1184 >
1185 <div className="flex items-center justify-between mb-3">
1186 <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
1187 {index === 0
1188 ? __("Lead Traveler", "yatra")
1189 : `${__("Traveler", "yatra")} ${index + 1}`}
1190 {/* Show name if available */}
1191 {(firstName || lastName) && (
1192 <span className="font-normal text-gray-500 dark:text-gray-400 ml-2">
1193 -{" "}
1194 {[firstName, lastName]
1195 .filter(Boolean)
1196 .join(" ")}
1197 </span>
1198 )}
1199 </h4>
1200 {index === 0 && (
1201 <span className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 px-2 py-0.5 rounded">
1202 {__("Primary Contact", "yatra")}
1203 </span>
1204 )}
1205 </div>
1206
1207 {/* Dynamic Fields Display */}
1208 <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
1209 {travelerEntries.map(([fieldId, fieldValue]) => {
1210 // Skip first_name and last_name as they're shown in header
1211 if (
1212 fieldId === "first_name" ||
1213 fieldId === "last_name"
1214 )
1215 return null;
1216
1217 const fieldConfig = travelerFields.find(
1218 (f) => f.id === fieldId,
1219 );
1220 const label =
1221 fieldConfig?.label ||
1222 getFieldLabel(fieldId, travelerFields);
1223 const isLongField =
1224 fieldConfig?.type === "textarea" ||
1225 String(fieldValue).length > 50;
1226
1227 // Format date fields (e.g. Date of Birth) with the
1228 // global WP date-format setting via the shared
1229 // formatter — not the browser locale.
1230 let displayValue = String(fieldValue);
1231 if (
1232 fieldConfig?.type === "date" ||
1233 fieldId.includes("date") ||
1234 fieldId.includes("birth") ||
1235 fieldId.includes("dob") ||
1236 fieldId.includes("expiry")
1237 ) {
1238 displayValue = formatDate(fieldValue as string);
1239 }
1240
1241 // Format country/nationality fields - convert code to full name
1242 if (
1243 (fieldId === "nationality" ||
1244 fieldId === "country") &&
1245 fieldValue &&
1246 typeof fieldValue === "string" &&
1247 fieldValue.length === 2
1248 ) {
1249 displayValue = getCountryName(fieldValue);
1250 }
1251
1252 // Dropdown answers: option label instead of value.
1253 // The label is the operator's own text, so it is
1254 // shown as written (no title-casing).
1255 let asWritten = false;
1256 if (fieldConfig?.type === "select") {
1257 displayValue = optionLabel(
1258 fieldConfig,
1259 fieldValue,
1260 );
1261 asWritten = displayValue !== String(fieldValue);
1262 }
1263
1264 return (
1265 <div
1266 key={fieldId}
1267 className={
1268 isLongField
1269 ? "col-span-2 md:col-span-3"
1270 : ""
1271 }
1272 >
1273 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1274 {label}
1275 </div>
1276 <div
1277 className={`text-sm text-gray-900 dark:text-white ${fieldId === "passport" ? "font-mono" : ""} ${asWritten ? "" : "capitalize"}`}
1278 >
1279 {displayValue}
1280 </div>
1281 </div>
1282 );
1283 })}
1284 </div>
1285
1286 {travelerEntries.length <= 2 && (
1287 <div className="text-sm text-gray-400 dark:text-gray-500 italic mt-2">
1288 {__(
1289 "Limited traveler information provided",
1290 "yatra",
1291 )}
1292 </div>
1293 )}
1294 </div>
1295 );
1296 },
1297 )}
1298 </CardContent>
1299 </Card>
1300 )}
1301
1302 {/* Emergency Contact - Dynamic Fields */}
1303 {booking.emergency_contact &&
1304 Object.values(booking.emergency_contact).some(
1305 (v) => v && String(v).trim() !== "",
1306 ) && (
1307 <Card>
1308 <CardHeader className="pb-2">
1309 <CardTitle className="text-base flex items-center gap-2">
1310 <AlertCircle className="w-4 h-4" />
1311 {formConfig?.emergency_contact_form?.title ||
1312 __("Emergency Contact", "yatra")}
1313 </CardTitle>
1314 </CardHeader>
1315 <CardContent>
1316 <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
1317 {Object.entries(booking.emergency_contact)
1318 .filter(
1319 ([_, value]) => value && String(value).trim() !== "",
1320 )
1321 .map(([fieldId, fieldValue]) => {
1322 const label = getFieldLabel(fieldId, emergencyFields);
1323 const emergencyField = emergencyFields.find(
1324 (f) => f.id === fieldId,
1325 );
1326 const emergencyDisplay = optionLabel(
1327 emergencyField,
1328 fieldValue,
1329 );
1330 return (
1331 <div key={fieldId}>
1332 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1333 {label}
1334 </div>
1335 <div
1336 className={`text-sm font-medium text-gray-900 dark:text-white ${emergencyDisplay !== String(fieldValue) ? "" : "capitalize"}`}
1337 >
1338 {emergencyDisplay}
1339 </div>
1340 </div>
1341 );
1342 })}
1343 </div>
1344 </CardContent>
1345 </Card>
1346 )}
1347
1348 {/* Notes */}
1349 {booking.notes && (
1350 <Card>
1351 <CardHeader className="pb-2">
1352 <CardTitle className="text-base flex items-center gap-2">
1353 <FileText className="w-4 h-4" />
1354 {__("Special Requests", "yatra")}
1355 </CardTitle>
1356 </CardHeader>
1357 <CardContent>
1358 <p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
1359 {booking.notes}
1360 </p>
1361 </CardContent>
1362 </Card>
1363 )}
1364 </div>
1365
1366 {/* Sidebar */}
1367 <div className="space-y-3">
1368 {/* Google Calendar Sync */}
1369 {(window as any).yatraAdmin?.isPro &&
1370 !!(window as any).yatraAdmin?.googleCalendar?.enabled &&
1371 (booking as any).google_calendar && (
1372 <Card>
1373 <CardHeader className="pb-2">
1374 <CardTitle className="text-base">
1375 {__("Google Calendar Sync", "yatra")}
1376 </CardTitle>
1377 </CardHeader>
1378 <CardContent className="space-y-3">
1379 {(() => {
1380 const gc = (booking as any)
1381 .google_calendar as GoogleCalendarSyncInfo;
1382 const status =
1383 gc.sync_status || (gc.synced ? "synced" : "not_synced");
1384 const statusClass =
1385 status === "synced"
1386 ? "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400"
1387 : status === "failed"
1388 ? "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400"
1389 : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400";
1390
1391 return (
1392 <>
1393 <div>
1394 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1395 {__("Status", "yatra")}
1396 </div>
1397 <span
1398 className={`inline-flex items-center px-3 py-1 rounded-md text-sm font-medium ${statusClass}`}
1399 >
1400 {status}
1401 </span>
1402 </div>
1403
1404 <div>
1405 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1406 {__("Last Synced", "yatra")}
1407 </div>
1408 <div className="text-sm text-gray-900 dark:text-white">
1409 {gc.last_synced_at
1410 ? formatDate(gc.last_synced_at)
1411 : __("—", "yatra")}
1412 </div>
1413 </div>
1414
1415 <div>
1416 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1417 {__("Calendar", "yatra")}
1418 </div>
1419 <div className="text-sm text-gray-900 dark:text-white break-all">
1420 {gc.calendar_id || __("—", "yatra")}
1421 </div>
1422 </div>
1423
1424 <div>
1425 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1426 {__("Event ID", "yatra")}
1427 </div>
1428 <div className="text-sm text-gray-900 dark:text-white font-mono break-all">
1429 {gc.event_id || __("—", "yatra")}
1430 </div>
1431 </div>
1432
1433 {gc.error_message && (
1434 <div>
1435 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1436 {__("Error", "yatra")}
1437 </div>
1438 <div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap">
1439 {gc.error_message}
1440 </div>
1441 </div>
1442 )}
1443 </>
1444 );
1445 })()}
1446 </CardContent>
1447 </Card>
1448 )}
1449
1450 {/* Payment Information */}
1451 <Card>
1452 <CardHeader className="pb-2">
1453 <CardTitle className="text-base">
1454 {__("Payment Information", "yatra")}
1455 </CardTitle>
1456 </CardHeader>
1457 <CardContent className="space-y-3">
1458 <div>
1459 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1460 {__("Payment Status", "yatra")}
1461 </div>
1462 <div className="mt-1">
1463 {getPaymentStatusBadge(booking.payment_status)}
1464 </div>
1465 </div>
1466 {booking.payment_method && (
1467 <div>
1468 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
1469 <CreditCard className="w-3 h-3" />
1470 {__("Payment Method", "yatra")}
1471 </div>
1472 <div className="text-sm text-gray-900 dark:text-white">
1473 {booking.payment_method}
1474 </div>
1475 </div>
1476 )}
1477 <div>
1478 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
1479 {__("Trip Price per Person", "yatra")}
1480 {((v) => v === true || parseInt(String(v), 10) === 1)(
1481 (booking as any).tax_inclusive,
1482 ) && (
1483 <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 normal-case tracking-normal">
1484 {__("Tax Incl.", "yatra")}
1485 </span>
1486 )}
1487 </div>
1488 <div className="text-sm text-gray-900 dark:text-white">
1489 {(() => {
1490 // subtotal (DB) = base trip cost + services when Pro is active.
1491 // Subtract services total to get the pure trip base cost.
1492 const subtotal =
1493 parseFloat((booking as any).subtotal) ||
1494 booking.total_amount ||
1495 0;
1496 const travelers = booking.travelers || 1;
1497
1498 const rawServices = (booking as any).additional_services;
1499 const servicesTotal = Array.isArray(rawServices)
1500 ? rawServices
1501 .filter((s: any) => s?.selected !== false)
1502 .reduce(
1503 (sum: number, s: any) =>
1504 sum +
1505 parseFloat(
1506 s?.total_price ??
1507 s?.calculated_price ??
1508 s?.total_cost ??
1509 s?.amount ??
1510 s?.unit_price ??
1511 s?.price ??
1512 0,
1513 ),
1514 0,
1515 )
1516 : 0;
1517
1518 const baseTripCost = subtotal - servicesTotal;
1519 return formatPrice(
1520 baseTripCost / travelers,
1521 booking.currency,
1522 );
1523 })()}
1524 </div>
1525 </div>
1526 <div>
1527 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
1528 {__("Total Amount", "yatra")}
1529 {((v) => v === true || parseInt(String(v), 10) === 1)(
1530 (booking as any).tax_inclusive,
1531 ) && (
1532 <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 normal-case tracking-normal">
1533 {__("Tax Incl.", "yatra")}
1534 </span>
1535 )}
1536 </div>
1537 <div className="text-lg font-semibold text-gray-900 dark:text-white">
1538 {formatPrice(booking.total_amount || 0, booking.currency)}
1539 </div>
1540 </div>
1541 </CardContent>
1542 </Card>
1543
1544 {/* Discount Applied Card */}
1545 {booking.discount_amount > 0 && (
1546 <Card className="border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20">
1547 <CardHeader className="pb-2">
1548 <CardTitle className="text-base flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
1549 <svg
1550 width="16"
1551 height="16"
1552 viewBox="0 0 24 24"
1553 fill="none"
1554 stroke="currentColor"
1555 strokeWidth="2"
1556 className="w-4 h-4"
1557 >
1558 <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
1559 <path d="M2 17l10 5 10-5"></path>
1560 <path d="M2 12l10 5 10-5"></path>
1561 </svg>
1562 {__("Discount Applied", "yatra")}
1563 </CardTitle>
1564 </CardHeader>
1565 <CardContent className="space-y-3">
1566 <div>
1567 <div className="text-xs text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-1">
1568 {__("Discount Type", "yatra")}
1569 </div>
1570 <div className="text-sm font-medium text-emerald-700 dark:text-emerald-300">
1571 {booking.discount_code?.toLowerCase().includes("group") ||
1572 booking.discount_code?.includes("GROUP")
1573 ? __("Group Discount", "yatra")
1574 : __("Coupon Discount", "yatra")}
1575 </div>
1576 </div>
1577 {booking.discount_code && (
1578 <div>
1579 <div className="text-xs text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-1">
1580 {__("Code", "yatra")}
1581 </div>
1582 <div className="text-sm font-mono font-medium text-emerald-700 dark:text-emerald-300 bg-emerald-100 dark:bg-emerald-900/40 px-2 py-1 rounded inline-block">
1583 {booking.discount_code}
1584 </div>
1585 </div>
1586 )}
1587 <div>
1588 <div className="text-xs text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-1">
1589 {__("Savings", "yatra")}
1590 </div>
1591 <div className="text-xl font-bold text-emerald-700 dark:text-emerald-300">
1592 -{formatPrice(booking.discount_amount, booking.currency)}
1593 </div>
1594 </div>
1595 </CardContent>
1596 </Card>
1597 )}
1598
1599 {/* Consent Status Card - Only show if Pro is active and there are consent forms */}
1600 {isPro && consentStatus && consentStatus.total_required > 0 && (
1601 <Card
1602 className={
1603 consentStatus.all_signed
1604 ? "border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20"
1605 : "border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20"
1606 }
1607 >
1608 <CardHeader className="pb-2">
1609 <CardTitle
1610 className={`text-base flex items-center gap-2 ${
1611 consentStatus.all_signed
1612 ? "text-green-700 dark:text-green-400"
1613 : "text-amber-700 dark:text-amber-400"
1614 }`}
1615 >
1616 <FileSignature className="w-4 h-4" />
1617 {__("Consent Status", "yatra")}
1618 </CardTitle>
1619 </CardHeader>
1620 <CardContent className="space-y-3">
1621 <div className="flex items-center justify-between">
1622 <span
1623 className={`text-sm ${consentStatus.all_signed ? "text-green-600 dark:text-green-400" : "text-amber-600 dark:text-amber-400"}`}
1624 >
1625 {consentStatus.all_signed ? (
1626 <span className="flex items-center gap-1">
1627 <CheckCircle className="w-4 h-4" />
1628 {__("All Consents Signed", "yatra")}
1629 </span>
1630 ) : (
1631 <span className="flex items-center gap-1">
1632 <Clock className="w-4 h-4" />
1633 {__("Pending Signatures", "yatra")}
1634 </span>
1635 )}
1636 </span>
1637 </div>
1638
1639 <div className="flex items-center justify-between text-sm">
1640 <span className="text-gray-600 dark:text-gray-400">
1641 {__("Signed", "yatra")}
1642 </span>
1643 <span className="font-medium text-gray-900 dark:text-white">
1644 {consentStatus.total_signed} /{" "}
1645 {consentStatus.total_required}
1646 </span>
1647 </div>
1648
1649 {/* Progress bar */}
1650 <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
1651 <div
1652 className={`h-2 rounded-full ${consentStatus.all_signed ? "bg-green-500" : "bg-amber-500"}`}
1653 style={{
1654 width: `${(consentStatus.total_signed / consentStatus.total_required) * 100}%`,
1655 }}
1656 />
1657 </div>
1658
1659 {/* Pending requests */}
1660 {consentStatus.pending_requests &&
1661 consentStatus.pending_requests.length > 0 && (
1662 <div className="pt-2 border-t border-amber-200 dark:border-amber-700">
1663 <div className="text-xs text-amber-600 dark:text-amber-400 uppercase tracking-wide mb-2">
1664 {__("Pending", "yatra")}
1665 </div>
1666 <div className="space-y-1">
1667 {consentStatus.pending_requests
1668 .slice(0, 3)
1669 .map((req: any, idx: number) => (
1670 <div
1671 key={idx}
1672 className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1"
1673 >
1674 <Send className="w-3 h-3" />
1675 {req.recipient_name || req.recipient_email}
1676 </div>
1677 ))}
1678 {consentStatus.pending_requests.length > 3 && (
1679 <div className="text-xs text-gray-500">
1680 +{consentStatus.pending_requests.length - 3}{" "}
1681 {__("more", "yatra")}
1682 </div>
1683 )}
1684 </div>
1685 </div>
1686 )}
1687
1688 {/* Link to consent management */}
1689 <Button
1690 variant="outline"
1691 size="sm"
1692 className="w-full mt-2"
1693 onClick={() => {
1694 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=trip-consent`;
1695 }}
1696 >
1697 {__("Manage Consents", "yatra")}
1698 </Button>
1699 </CardContent>
1700 </Card>
1701 )}
1702
1703 {/* Booking Timeline */}
1704 <Card>
1705 <CardHeader className="pb-2">
1706 <CardTitle className="text-base">
1707 {__("Timeline", "yatra")}
1708 </CardTitle>
1709 </CardHeader>
1710 <CardContent className="space-y-3">
1711 <div>
1712 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1713 {__("Created", "yatra")}
1714 </div>
1715 <div className="text-sm text-gray-900 dark:text-white">
1716 {formatDate(booking.created_at)}
1717 </div>
1718 </div>
1719 {booking.updated_at &&
1720 booking.updated_at !== booking.created_at && (
1721 <div>
1722 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1723 {__("Last Updated", "yatra")}
1724 </div>
1725 <div className="text-sm text-gray-900 dark:text-white">
1726 {formatDate(booking.updated_at)}
1727 </div>
1728 </div>
1729 )}
1730 </CardContent>
1731 </Card>
1732 </div>
1733 </div>
1734 </ConditionalRender>
1735 </div>
1736 );
1737 };
1738
1739 export default ViewBooking;
1740