PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
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 / Bookings.tsx

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

1,169 lines 37.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Bookings Page
3 * Clean, minimal SaaS-style bookings management page
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 Plus,
10 ArrowUpDown,
11 ArrowUp,
12 ArrowDown,
13 Eye,
14 Edit,
15 Trash2,
16 AlertTriangle,
17 CreditCard,
18 CircleDollarSign,
19 Undo2,
20 Mail,
21 } from "lucide-react";
22 import {
23 Pagination,
24 SearchFilterToolbar,
25 BulkActionToolbar,
26 Table as SharedTable,
27 } from "../components/shared";
28 import { __ } from "../lib/i18n";
29 import {
30 buildYatraSinglePublicUrls,
31 isWordPressPlainPermalink,
32 } from "../lib/frontend-permalink-urls";
33 import { useToast } from "../components/ui/toast";
34 import { Button } from "../components/ui/button";
35 import { Select } from "../components/ui/select";
36 import { PageHeader } from "../components/common/PageHeader";
37 import { Card, CardContent } from "../components/ui/card";
38 import { ConditionalRender } from "../components/ui/conditional-render";
39 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
40 import { formatYatraMoney } from "../lib/currency-display";
41 import { apiService, apiClient } from "../lib/api-client";
42 import { formatDate as formatDateUtil } from "../lib/dateFormat";
43 import { getErrorContext } from "../lib/errors";
44 import { usePermissions } from "../hooks/usePermissions";
45
46 interface Booking {
47 id: number;
48 booking_number: string;
49 customer_name: string;
50 customer_email: string;
51 trip_title: string;
52 trip_id: number;
53 trip_slug?: string;
54 booking_date: string;
55 travel_date: string;
56 travelers?: number;
57 travelers_count?: number;
58 total_amount: number;
59 amount_paid?: number;
60 currency?: string;
61 payment_status: string;
62 booking_status: string;
63 payment_method?: string;
64 payment_gateway?: string;
65 created_at: string;
66 }
67
68
69 /**
70 * Payment statuses an administrator can set straight from the booking list.
71 *
72 * Labels are prefixed with "Payment:" because the same menu already carries
73 * booking-status actions ("Mark as Pending", "Mark as Cancelled") — without the
74 * prefix two entries would read identically and set different fields.
75 *
76 * `failed` is deliberately omitted: it describes a gateway outcome rather than
77 * something an operator records by hand, and it is still settable from the
78 * booking edit screen.
79 */
80 const PAYMENT_STATUS_ACTIONS: Array<{
81 value: string;
82 label: () => string;
83 icon: React.ReactNode;
84 }> = [
85 {
86 value: "paid",
87 label: () => __("Payment: Mark as Paid"),
88 icon: <CreditCard className="w-4 h-4" />,
89 },
90 {
91 value: "partial",
92 label: () => __("Payment: Mark as Partially Paid"),
93 icon: <CircleDollarSign className="w-4 h-4" />,
94 },
95 {
96 value: "pending",
97 label: () => __("Payment: Mark as Pending"),
98 icon: <CircleDollarSign className="w-4 h-4" />,
99 },
100 {
101 value: "refunded",
102 label: () => __("Payment: Mark as Refunded"),
103 icon: <Undo2 className="w-4 h-4" />,
104 },
105 ];
106
107 const Bookings: React.FC = () => {
108 const [searchTerm, setSearchTerm] = useState("");
109 const [statusFilter, setStatusFilter] = useState("all");
110 const [paymentFilter, setPaymentFilter] = useState("all");
111 const [sortBy, setSortBy] = useState("booking_date");
112 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
113 const [page, setPage] = useState(1);
114 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
115 const [bulkAction, setBulkAction] = useState("");
116 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
117 const [isBulkPending, setIsBulkPending] = useState(false);
118 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
119 const [bookingToDelete, setBookingToDelete] = useState<Booking | null>(null);
120 const [visibleColumns, setVisibleColumns] = useState(() => {
121 if (typeof window === "undefined") {
122 return {
123 booking_number: true,
124 customer: true,
125 trip: true,
126 travelers: true,
127 booking_date: true,
128 travel_date: true,
129 amount: true,
130 payment_status: true,
131 booking_status: true,
132 };
133 }
134 const saved = window.localStorage.getItem("yatra-bookings-visible-columns");
135 return saved
136 ? JSON.parse(saved)
137 : {
138 booking_number: true,
139 customer: true,
140 trip: true,
141 travelers: true,
142 booking_date: true,
143 travel_date: true,
144 amount: true,
145 payment_status: true,
146 booking_status: true,
147 };
148 });
149 const queryClient = useQueryClient();
150 const { can } = usePermissions();
151 const { showToast } = useToast();
152 const defaultCurrency =
153 (window as any)?.yatraAdmin?.currency ||
154 (window as any)?.yatraBookingData?.currency ||
155 "USD";
156 const translations = (window as any)?.yatraAdmin?.translations || {};
157 const statsEnabled = can("yatra_view_bookings") !== false;
158
159 // Helper function for translations
160 const __ = (text: string) => translations[text] || text;
161
162 // Build query params
163 const BOOKINGS_PER_PAGE = 10;
164
165 const queryParams = useMemo(() => {
166 const params: Record<string, any> = {
167 page,
168 per_page: BOOKINGS_PER_PAGE,
169 orderby: sortBy,
170 order: sortOrder,
171 };
172
173 if (searchTerm) {
174 params.search = searchTerm;
175 }
176
177 if (statusFilter !== "all") {
178 params.status = statusFilter;
179 }
180
181 if (paymentFilter !== "all") {
182 params.payment_status = paymentFilter;
183 }
184
185 return params;
186 }, [searchTerm, statusFilter, paymentFilter, sortBy, sortOrder, page]);
187
188 // Fetch bookings from API
189 const canViewBookings = can("yatra_view_bookings") !== false; // default true when capability data missing
190
191 const { data, isLoading, error, refetch } = useQuery({
192 queryKey: ["bookings", queryParams],
193 queryFn: async () => {
194 // Check URL parameter for error simulation (for testing)
195 const urlParams = new URLSearchParams(window.location.search);
196 if (urlParams.get("simulate_error") === "true") {
197 throw new Error(
198 "Simulated API error for testing error UI functionality",
199 );
200 }
201
202 const params = new URLSearchParams();
203 params.append("page", String(queryParams.page));
204 params.append("per_page", String(queryParams.per_page));
205
206 if (queryParams.search) {
207 params.append("search", queryParams.search);
208 }
209 if (queryParams.status) {
210 params.append("status", queryParams.status);
211 }
212 if (queryParams.payment_status) {
213 params.append("payment_status", queryParams.payment_status);
214 }
215
216 return await apiService.getBookings({
217 page: queryParams.page,
218 per_page: queryParams.per_page,
219 search: queryParams.search,
220 status: queryParams.status,
221 payment_status: queryParams.payment_status,
222 // Forward sort params so column-header sorting reaches the backend.
223 orderby: queryParams.orderby,
224 order: queryParams.order,
225 });
226 },
227 enabled: canViewBookings,
228 });
229
230 // Fetch booking stats (counts)
231 const { data: statsRaw } = useQuery({
232 queryKey: ["bookings-stats"],
233 queryFn: async () => {
234 return await apiService.getBookingsStats();
235 },
236 enabled: statsEnabled,
237 });
238 const statsData: any = statsRaw ?? {};
239 const totalBookings = statsData?.all ?? 0;
240 const confirmedCount = statsData?.confirmed ?? 0;
241 const pendingCount = statsData?.pending ?? 0;
242 const cancelledCount = statsData?.cancelled ?? 0;
243 const completedCount = statsData?.completed ?? 0;
244
245 // Delete mutation
246 const deleteMutation = useMutation({
247 mutationFn: async (id: number) => {
248 await apiService.deleteBooking(id);
249 },
250 onSuccess: () => {
251 queryClient.invalidateQueries({ queryKey: ["bookings"] });
252 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
253 showToast(__("Booking deleted successfully"), "success");
254 },
255 onError: (error: any) => {
256 showToast(error?.message || __("Failed to delete booking"), "error");
257 },
258 });
259
260 const bookings: Booking[] = data?.data || [];
261 const listMeta = data?.meta ?? {};
262 const total = Number(listMeta.total ?? data?.total ?? 0);
263 const perPage =
264 Number(listMeta.per_page ?? data?.per_page ?? BOOKINGS_PER_PAGE) ||
265 BOOKINGS_PER_PAGE;
266 const totalPages = Math.max(
267 1,
268 Number(listMeta.total_pages) ||
269 (total > 0 ? Math.ceil(total / perPage) : 1),
270 );
271
272 // Enhanced error handling
273 const errorContext = getErrorContext(error);
274 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
275 const derivedErrorDetails =
276 errorContext.details ||
277 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
278 (error ? String(error?.message || error) : undefined);
279 const isBookingsError = !!error || !!apiErrorMessage;
280
281 const formatDate = (dateString: string) => {
282 return formatDateUtil(dateString);
283 };
284
285 const formatPrice = (price: number, currencyCode: string = defaultCurrency) =>
286 formatYatraMoney(Number(price) || 0, currencyCode, {
287 zeroAsUnknown: false,
288 });
289
290 const getBookingStatusBadge = (status: string) => {
291 const statusMap: Record<string, { className: string; label: string }> = {
292 confirmed: {
293 className:
294 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
295 label: __("Confirmed"),
296 },
297 pending: {
298 className:
299 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
300 label: __("Pending"),
301 },
302 // Guest-checkout holding state: row exists but the customer hasn't
303 // clicked the email-verification link yet, so the booking-created
304 // fan-out (admin notify + customer confirmation) is deferred.
305 // Visually amber-on-amber so it reads as "waiting on something
306 // outside our control" — distinct from the regular yellow "pending"
307 // (which means waiting on the operator).
308 pending_verification: {
309 className:
310 "bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-300 border border-amber-200 dark:border-amber-800",
311 label: __("Awaiting email verification"),
312 },
313 cancelled: {
314 className:
315 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
316 label: __("Cancelled"),
317 },
318 completed: {
319 className:
320 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
321 label: __("Completed"),
322 },
323 };
324
325 const statusInfo = statusMap[status] || {
326 className:
327 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
328 label: status,
329 };
330
331 return (
332 <span
333 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
334 >
335 {statusInfo.label}
336 </span>
337 );
338 };
339
340 const getPaymentStatusBadge = (status: string) => {
341 const statusMap: Record<string, { className: string; label: string }> = {
342 paid: {
343 className:
344 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
345 label: __("Paid"),
346 },
347 pending: {
348 className:
349 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
350 label: __("Pending"),
351 },
352 partial: {
353 className:
354 "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400",
355 label: __("Partial"),
356 },
357 refunded: {
358 className:
359 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
360 label: __("Refunded"),
361 },
362 };
363
364 const statusInfo = statusMap[status] || {
365 className:
366 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
367 label: status,
368 };
369
370 return (
371 <span
372 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
373 >
374 {statusInfo.label}
375 </span>
376 );
377 };
378
379 const handleEdit = (booking: Booking) => {
380 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${booking.id}`;
381 };
382
383 const handleDelete = (booking: Booking) => {
384 setBookingToDelete(booking);
385 setDeleteDialogOpen(true);
386 };
387
388 const confirmDelete = () => {
389 if (bookingToDelete) {
390 deleteMutation.mutate(bookingToDelete.id);
391 setDeleteDialogOpen(false);
392 setBookingToDelete(null);
393 }
394 };
395
396 const cancelDelete = () => {
397 setDeleteDialogOpen(false);
398 setBookingToDelete(null);
399 };
400
401 const handleView = (booking: Booking) => {
402 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=view&id=${booking.id}`;
403 };
404
405 const handleCreateBooking = () => {
406 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=create`;
407 };
408
409 const handleResetFilters = () => {
410 setSearchTerm("");
411 setStatusFilter("all");
412 setPaymentFilter("all");
413 setSortBy("booking_date");
414 setSortOrder("desc");
415 setPage(1);
416 };
417
418 const handleSort = (field: string) => {
419 if (sortBy === field) {
420 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
421 } else {
422 setSortBy(field);
423 setSortOrder("desc");
424 }
425 };
426
427 const getSortIcon = (field: string) => {
428 if (sortBy !== field) {
429 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
430 }
431 return sortOrder === "asc" ? (
432 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
433 ) : (
434 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
435 );
436 };
437
438 const hasFilters =
439 searchTerm ||
440 statusFilter !== "all" ||
441 paymentFilter !== "all" ||
442 sortBy !== "booking_date" ||
443 sortOrder !== "desc";
444
445 const toggleColumn = (columnKey: string) => {
446 const newVisibleColumns = {
447 ...visibleColumns,
448 [columnKey]: !visibleColumns[columnKey as keyof typeof visibleColumns],
449 };
450 setVisibleColumns(newVisibleColumns);
451 if (typeof window !== "undefined") {
452 window.localStorage.setItem(
453 "yatra-bookings-visible-columns",
454 JSON.stringify(newVisibleColumns),
455 );
456 }
457 };
458
459 const handleSelectItem = (id: string | number, checked: boolean) => {
460 setSelectedIds((prev) =>
461 checked ? [...prev, id] : prev.filter((selectedId) => selectedId !== id),
462 );
463 };
464
465 const handleSelectAll = (checked: boolean) => {
466 if (checked) {
467 setSelectedIds(bookings.map((booking: Booking) => booking.id));
468 } else {
469 setSelectedIds([]);
470 }
471 };
472
473 const isAllSelected =
474 bookings.length > 0 && selectedIds.length === bookings.length;
475
476 const updateStatusForIds = async (
477 ids: (string | number)[],
478 newStatus: string,
479 ) => {
480 await apiService.bulkUpdateStatus("bookings", ids, newStatus);
481 };
482
483 /**
484 * Set a booking's payment status from the actions menu.
485 *
486 * Marking a booking paid also settles its money fields server-side
487 * (BookingService::handlePaymentStatusChange), so the invoice reports the
488 * correct Amount Paid / Amount Due rather than staying at "Payment Pending".
489 */
490 const updatePaymentStatus = async (booking: Booking, status: string) => {
491 setIsBulkPending(true);
492 try {
493 await apiService.updateBooking(booking.id, { payment_status: status });
494 queryClient.invalidateQueries({ queryKey: ["bookings"] });
495 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
496 showToast(__("Payment status updated"), "success");
497 } catch (error: any) {
498 showToast(
499 error?.message || __("Failed to update payment status"),
500 "error",
501 );
502 } finally {
503 setIsBulkPending(false);
504 }
505 };
506
507 const handleBulkApply = async () => {
508 if (!bulkAction || selectedIds.length === 0) {
509 return;
510 }
511
512 try {
513 setIsBulkPending(true);
514
515 if (bulkAction === "delete") {
516 await apiService.bulkDelete("bookings", selectedIds);
517 showToast(__("Selected bookings deleted successfully"), "success");
518 } else {
519 await updateStatusForIds(selectedIds, bulkAction);
520 showToast(__("Bulk booking status updated successfully"), "success");
521 }
522
523 queryClient.invalidateQueries({ queryKey: ["bookings"] });
524 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
525 setSelectedIds([]);
526 setBulkAction("");
527 } catch (error: any) {
528 showToast(
529 __("Error: ") +
530 (error?.message || __("Failed to perform bulk action on bookings")),
531 "error",
532 );
533 } finally {
534 setIsBulkPending(false);
535 }
536 };
537
538 const allBulkActionOptions = [
539 { value: "confirmed", label: __("Mark as Confirmed") },
540 { value: "pending", label: __("Mark as Pending") },
541 { value: "cancelled", label: __("Mark as Cancelled") },
542 { value: "completed", label: __("Mark as Completed") },
543 { value: "delete", label: __("Delete permanently") },
544 ];
545
546 const getBulkActionOptionsForStatus = (view: string) => {
547 switch (view) {
548 case "confirmed":
549 return allBulkActionOptions.filter((opt) =>
550 ["pending", "cancelled", "completed", "delete"].includes(opt.value),
551 );
552 case "pending":
553 return allBulkActionOptions.filter((opt) =>
554 ["confirmed", "cancelled", "completed", "delete"].includes(opt.value),
555 );
556 case "cancelled":
557 return allBulkActionOptions.filter((opt) =>
558 ["pending", "confirmed", "delete"].includes(opt.value),
559 );
560 case "completed":
561 return allBulkActionOptions.filter((opt) =>
562 ["confirmed", "cancelled", "delete"].includes(opt.value),
563 );
564 default:
565 return allBulkActionOptions;
566 }
567 };
568
569 const bulkActionOptions = getBulkActionOptionsForStatus(statusFilter);
570
571 const statusOptions = [
572 {
573 value: "all",
574 label: __("All Status"),
575 count: statsData?.all ?? totalBookings,
576 },
577 { value: "confirmed", label: __("Confirmed"), count: confirmedCount },
578 { value: "pending", label: __("Pending"), count: pendingCount },
579 { value: "cancelled", label: __("Cancelled"), count: cancelledCount },
580 { value: "completed", label: __("Completed"), count: completedCount },
581 ];
582
583 const sortOptions = [
584 { value: "booking_date", label: __("Booking Date") },
585 { value: "travel_date", label: __("Travel Date") },
586 { value: "booking_number", label: __("Booking Number") },
587 { value: "customer_name", label: __("Customer") },
588 { value: "trip_title", label: __("Trip") },
589 { value: "total_amount", label: __("Amount") },
590 { value: "booking_status", label: __("Status") },
591 ];
592
593 const columnOptions = [
594 {
595 key: "booking_number",
596 label: __("Booking #"),
597 visible: visibleColumns.booking_number,
598 },
599 {
600 key: "customer",
601 label: __("Customer"),
602 visible: visibleColumns.customer,
603 },
604 { key: "trip", label: __("Trip"), visible: visibleColumns.trip },
605 {
606 key: "travelers",
607 label: __("Travelers"),
608 visible: visibleColumns.travelers,
609 },
610 {
611 key: "booking_date",
612 label: __("Booking Date"),
613 visible: visibleColumns.booking_date,
614 },
615 {
616 key: "travel_date",
617 label: __("Travel Date"),
618 visible: visibleColumns.travel_date,
619 },
620 { key: "amount", label: __("Amount"), visible: visibleColumns.amount },
621 {
622 key: "payment_status",
623 label: __("Payment"),
624 visible: visibleColumns.payment_status,
625 },
626 {
627 key: "booking_status",
628 label: __("Status"),
629 visible: visibleColumns.booking_status,
630 },
631 ];
632
633 const columns = [
634 {
635 key: "booking_number",
636 label: __("Booking #"),
637 sortable: true,
638 visible: visibleColumns.booking_number,
639 width: "w-[140px]",
640 render: (booking: Booking) => (
641 <button
642 type="button"
643 onClick={() => handleView(booking)}
644 className="text-sm font-mono font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline"
645 >
646 {booking.booking_number}
647 </button>
648 ),
649 },
650 {
651 key: "customer",
652 label: __("Customer"),
653 sortable: true,
654 visible: visibleColumns.customer,
655 render: (booking: Booking) => (
656 <div className="flex flex-col">
657 <span className="font-medium">
658 {booking.customer_name || __("Guest")}
659 </span>
660 <span className="text-xs text-muted-foreground">
661 {booking.customer_email}
662 </span>
663 </div>
664 ),
665 },
666 {
667 key: "trip",
668 label: __("Trip"),
669 sortable: true,
670 visible: visibleColumns.trip,
671 render: (booking: Booking) => {
672 // Link to the tour's PUBLIC single page (opens in a new tab), not the
673 // tour editor — a booking overview should never be a one-click path to
674 // editing the underlying tour. Without a slug we can't build the public
675 // URL, so the name is shown as plain text rather than falling back to
676 // the editor.
677 const slug = booking.trip_slug || "";
678 let tourUrl = "";
679 if (slug) {
680 const { plainUrl, prettyUrl } = buildYatraSinglePublicUrls({
681 entity: "trip",
682 slug,
683 });
684 tourUrl = isWordPressPlainPermalink() ? plainUrl : prettyUrl;
685 }
686 return (
687 <div className="flex flex-col">
688 {tourUrl ? (
689 <a
690 href={tourUrl}
691 target="_blank"
692 rel="noopener noreferrer"
693 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
694 >
695 {booking.trip_title}
696 </a>
697 ) : (
698 <span className="font-medium text-gray-900 dark:text-white">
699 {booking.trip_title}
700 </span>
701 )}
702 <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
703 {booking.trip_id ? `(ID: ${booking.trip_id})` : ""}
704 </span>
705 </div>
706 );
707 },
708 },
709 {
710 key: "travelers",
711 label: __("Travelers"),
712 sortable: true,
713 visible: visibleColumns.travelers,
714 render: (booking: Booking) => (
715 <span className="font-medium">
716 {booking.travelers ?? booking.travelers_count ?? 0}
717 </span>
718 ),
719 },
720 {
721 key: "booking_date",
722 label: __("Booking Date"),
723 sortable: true,
724 visible: visibleColumns.booking_date,
725 render: (booking: Booking) => (
726 <span>{formatDate(booking.booking_date)}</span>
727 ),
728 },
729 {
730 key: "travel_date",
731 label: __("Travel Date"),
732 sortable: true,
733 visible: visibleColumns.travel_date,
734 render: (booking: Booking) => (
735 <span>{formatDate(booking.travel_date)}</span>
736 ),
737 },
738 {
739 key: "amount",
740 label: __("Amount"),
741 sortable: true,
742 visible: visibleColumns.amount,
743 render: (booking: Booking) => (
744 <div className="flex flex-col">
745 <span className="font-semibold">
746 {formatPrice(booking.total_amount, booking.currency)}
747 </span>
748 {booking.amount_paid ? (
749 <span className="text-xs text-muted-foreground">
750 {__("Paid: ")}
751 {formatPrice(booking.amount_paid, booking.currency)}
752 </span>
753 ) : null}
754 </div>
755 ),
756 },
757 {
758 key: "payment_status",
759 label: __("Payment"),
760 sortable: true,
761 visible: visibleColumns.payment_status,
762 render: (booking: Booking) =>
763 getPaymentStatusBadge(booking.payment_status),
764 },
765 {
766 key: "booking_status",
767 label: __("Status"),
768 sortable: true,
769 visible: visibleColumns.booking_status,
770 render: (booking: Booking) =>
771 getBookingStatusBadge(booking.booking_status),
772 },
773 ];
774
775 /**
776 * Manually (re)send a transactional email for a booking from the row actions.
777 * Errors and disabled-recipient cases surface via the endpoint's message.
778 */
779 const resendBookingEmail = async (bookingId: number, type: string) => {
780 try {
781 const res: any = await apiClient.post(
782 `/bookings/${bookingId}/send-email`,
783 { type },
784 );
785 if (res?.success === false) {
786 throw new Error(res.message || __("Failed to send email"));
787 }
788 showToast(res?.message || __("Email sent"), "success");
789 } catch (error: any) {
790 showToast(error?.message || __("Failed to send email"), "error");
791 }
792 };
793
794 const actions = [
795 {
796 key: "view",
797 label: __("View"),
798 icon: <Eye className="w-4 h-4" />,
799 onClick: (booking: Booking) => handleView(booking),
800 condition: () => can("yatra_view_bookings"),
801 },
802 {
803 key: "edit",
804 label: __("Edit"),
805 icon: <Edit className="w-4 h-4" />,
806 onClick: (booking: Booking) => handleEdit(booking),
807 condition: () => can("yatra_edit_bookings"),
808 },
809 {
810 key: "mark_confirmed",
811 label: __("Mark as Confirmed"),
812 icon: <ArrowDown className="w-4 h-4" />,
813 onClick: async (booking: Booking) => {
814 setIsBulkPending(true);
815 try {
816 await updateStatusForIds([booking.id], "confirmed");
817 queryClient.invalidateQueries({ queryKey: ["bookings"] });
818 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
819 showToast(__("Booking status updated"), "success");
820 } catch (error: any) {
821 showToast(
822 error?.message || __("Failed to update booking status"),
823 "error",
824 );
825 } finally {
826 setIsBulkPending(false);
827 }
828 },
829 condition: (booking: Booking) =>
830 can("yatra_edit_bookings") && booking.booking_status !== "confirmed",
831 },
832 {
833 key: "mark_pending",
834 label: __("Mark as Pending"),
835 icon: <ArrowUp className="w-4 h-4" />,
836 onClick: async (booking: Booking) => {
837 setIsBulkPending(true);
838 try {
839 await updateStatusForIds([booking.id], "pending");
840 queryClient.invalidateQueries({ queryKey: ["bookings"] });
841 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
842 showToast(__("Booking status updated"), "success");
843 } catch (error: any) {
844 showToast(
845 error?.message || __("Failed to update booking status"),
846 "error",
847 );
848 } finally {
849 setIsBulkPending(false);
850 }
851 },
852 condition: (booking: Booking) =>
853 can("yatra_edit_bookings") && booking.booking_status !== "pending",
854 },
855 {
856 key: "mark_cancelled",
857 label: __("Mark as Cancelled"),
858 icon: <ArrowDown className="w-4 h-4" />,
859 onClick: async (booking: Booking) => {
860 setIsBulkPending(true);
861 try {
862 await updateStatusForIds([booking.id], "cancelled");
863 queryClient.invalidateQueries({ queryKey: ["bookings"] });
864 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
865 showToast(__("Booking status updated"), "success");
866 } catch (error: any) {
867 showToast(
868 error?.message || __("Failed to update booking status"),
869 "error",
870 );
871 } finally {
872 setIsBulkPending(false);
873 }
874 },
875 condition: (booking: Booking) =>
876 can("yatra_edit_bookings") && booking.booking_status !== "cancelled",
877 },
878 {
879 key: "mark_completed",
880 label: __("Mark as Completed"),
881 icon: <ArrowDown className="w-4 h-4" />,
882 onClick: async (booking: Booking) => {
883 setIsBulkPending(true);
884 try {
885 await updateStatusForIds([booking.id], "completed");
886 queryClient.invalidateQueries({ queryKey: ["bookings"] });
887 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
888 showToast(__("Booking status updated"), "success");
889 } catch (error: any) {
890 showToast(
891 error?.message || __("Failed to update booking status"),
892 "error",
893 );
894 } finally {
895 setIsBulkPending(false);
896 }
897 },
898 condition: (booking: Booking) =>
899 can("yatra_edit_bookings") && booking.booking_status !== "completed",
900 },
901 ...PAYMENT_STATUS_ACTIONS.map((action) => ({
902 key: `payment_${action.value}`,
903 label: action.label(),
904 icon: action.icon,
905 onClick: (booking: Booking) => updatePaymentStatus(booking, action.value),
906 // Hidden when the booking is already in that payment state, matching how
907 // the booking-status actions above behave.
908 condition: (booking: Booking) =>
909 can("yatra_edit_bookings") && booking.payment_status !== action.value,
910 })),
911 {
912 key: "resend_confirmation",
913 label: __("Resend confirmation email"),
914 icon: <Mail className="w-4 h-4" />,
915 onClick: (booking: Booking) => resendBookingEmail(booking.id, "confirmation"),
916 condition: (booking: Booking) =>
917 can("yatra_edit_bookings") && !!booking.customer_email,
918 },
919 {
920 key: "resend_payment",
921 label: __("Resend payment email"),
922 icon: <Mail className="w-4 h-4" />,
923 onClick: (booking: Booking) =>
924 resendBookingEmail(booking.id, "payment_confirmation"),
925 // Only when a payment has actually been recorded.
926 condition: (booking: Booking) =>
927 can("yatra_edit_bookings") &&
928 !!booking.customer_email &&
929 Number(booking.amount_paid || 0) > 0,
930 },
931 {
932 key: "delete",
933 label: __("Delete"),
934 icon: <Trash2 className="w-4 h-4" />,
935 onClick: (booking: Booking) => handleDelete(booking),
936 variant: "destructive" as const,
937 condition: () => can("yatra_delete_bookings"),
938 },
939 ];
940
941 return (
942 <div className="space-y-3">
943 <PageHeader
944 title={__("Bookings")}
945 description={__("Manage customer bookings and reservations")}
946 actions={
947 <div className="flex items-center gap-2">
948 <Button
949 onClick={handleCreateBooking}
950 className="flex items-center gap-2"
951 >
952 <Plus className="w-4 h-4" />
953 {__("Create Booking")}
954 </Button>
955 </div>
956 }
957 />
958
959 {/* Stats summary */}
960 {statsData && (
961 <div className="grid grid-cols-1 md:grid-cols-4 gap-3">
962 <Card>
963 <CardContent className="p-4">
964 <div className="text-sm text-muted-foreground">
965 {__("Total Bookings")}
966 </div>
967 <div className="text-2xl font-semibold">{totalBookings}</div>
968 </CardContent>
969 </Card>
970 <Card>
971 <CardContent className="p-4">
972 <div className="text-sm text-muted-foreground">
973 {__("Confirmed")}
974 </div>
975 <div className="text-2xl font-semibold">{confirmedCount}</div>
976 </CardContent>
977 </Card>
978 <Card>
979 <CardContent className="p-4">
980 <div className="text-sm text-muted-foreground">
981 {__("Pending")}
982 </div>
983 <div className="text-2xl font-semibold">{pendingCount}</div>
984 </CardContent>
985 </Card>
986 <Card>
987 <CardContent className="p-4">
988 <div className="text-sm text-muted-foreground">
989 {__("Cancelled")}
990 </div>
991 <div className="text-2xl font-semibold">{cancelledCount}</div>
992 </CardContent>
993 </Card>
994 </div>
995 )}
996
997 <Card>
998 <CardContent className="p-4">
999 <div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center w-full">
1000 <div className="flex-1 min-w-0">
1001 <SearchFilterToolbar
1002 searchTerm={searchTerm}
1003 onSearchChange={setSearchTerm}
1004 statusFilter={statusFilter}
1005 onStatusChange={(value) => {
1006 setStatusFilter(value);
1007 setPage(1);
1008 }}
1009 statusOptions={statusOptions}
1010 sortBy={sortBy}
1011 onSortByChange={(field) => {
1012 setSortBy(field);
1013 setPage(1);
1014 }}
1015 sortOrder={sortOrder}
1016 onSortOrderChange={(order) => {
1017 setSortOrder(order);
1018 setPage(1);
1019 }}
1020 sortOptions={sortOptions}
1021 onResetFilters={handleResetFilters}
1022 hasFilters={!!hasFilters}
1023 placeholder={__("Search bookings...")}
1024 />
1025 </div>
1026
1027 {/* Payment Status Filter */}
1028 <div className="w-full lg:w-auto lg:min-w-[160px]">
1029 <Select
1030 value={paymentFilter}
1031 onChange={(e) => {
1032 setPaymentFilter(e.target.value);
1033 setPage(1);
1034 }}
1035 className="w-full"
1036 >
1037 <option value="all">{__("All Payments")}</option>
1038 <option value="paid">{__("Paid")}</option>
1039 <option value="pending">{__("Pending")}</option>
1040 <option value="partial">{__("Partial")}</option>
1041 <option value="refunded">{__("Refunded")}</option>
1042 </Select>
1043 </div>
1044 </div>
1045 </CardContent>
1046 </Card>
1047
1048 <ConditionalRender capability="yatra_view_bookings">
1049 <>
1050 <BulkActionToolbar
1051 selectedIds={selectedIds}
1052 bulkAction={bulkAction}
1053 setBulkAction={setBulkAction}
1054 onApply={handleBulkApply}
1055 onClearSelection={() => setSelectedIds([])}
1056 statusFilter={statusFilter}
1057 setStatusFilter={(value) => {
1058 setStatusFilter(value);
1059 setPage(1);
1060 }}
1061 statusOptions={[
1062 {
1063 key: "all",
1064 label: __("All"),
1065 count: statsData?.all ?? totalBookings,
1066 },
1067 {
1068 key: "confirmed",
1069 label: __("Confirmed"),
1070 count: confirmedCount,
1071 },
1072 { key: "pending", label: __("Pending"), count: pendingCount },
1073 {
1074 key: "cancelled",
1075 label: __("Cancelled"),
1076 count: cancelledCount,
1077 },
1078 {
1079 key: "completed",
1080 label: __("Completed"),
1081 count: completedCount,
1082 },
1083 ]}
1084 showColumnsDropdown={showColumnsDropdown}
1085 setShowColumnsDropdown={setShowColumnsDropdown}
1086 columnOptions={columnOptions}
1087 onToggleColumn={toggleColumn}
1088 bulkMutationPending={isBulkPending}
1089 totalItems={total}
1090 bulkActionOptions={bulkActionOptions}
1091 />
1092
1093 <Card>
1094 <CardContent className="p-0">
1095 <SharedTable
1096 data={bookings}
1097 columns={columns}
1098 actions={actions}
1099 isLoading={isLoading}
1100 isError={isBookingsError}
1101 errorText={__("Error loading bookings")}
1102 errorDescription={__(
1103 "We couldn't connect to the bookings service. Please refresh or try again shortly.",
1104 )}
1105 errorDetails={derivedErrorDetails}
1106 errorRequestInfo={errorContext.requestInfo}
1107 onRetry={() => refetch()}
1108 emptyText={__("No bookings found")}
1109 emptyDescription={
1110 hasFilters
1111 ? __("Try adjusting your filters to see more results.")
1112 : __("Get started by creating your first booking.")
1113 }
1114 onCreateClick={
1115 can("yatra_edit_bookings") ? handleCreateBooking : undefined
1116 }
1117 onSort={handleSort}
1118 getSortIcon={getSortIcon}
1119 selectedItemIds={selectedIds}
1120 onSelectItem={handleSelectItem}
1121 onSelectAll={handleSelectAll}
1122 isAllSelected={isAllSelected}
1123 getItemId={(booking: Booking) => booking.id}
1124 skeletonRows={5}
1125 capability="yatra_view_bookings"
1126 />
1127 </CardContent>
1128 </Card>
1129
1130 {total > 0 && (
1131 <div className="mt-4">
1132 <Pagination
1133 currentPage={page}
1134 totalPages={totalPages}
1135 totalItems={total}
1136 itemsPerPage={perPage}
1137 onPageChange={(newPage) => setPage(newPage)}
1138 itemName={__("bookings")}
1139 />
1140 </div>
1141 )}
1142 </>
1143 </ConditionalRender>
1144
1145 {/* Delete Confirmation Dialog */}
1146 <ConfirmationDialog
1147 isOpen={deleteDialogOpen}
1148 onClose={cancelDelete}
1149 onConfirm={confirmDelete}
1150 title={__("Delete Booking")}
1151 description={
1152 bookingToDelete
1153 ? __(
1154 `Are you sure you want to delete booking "${bookingToDelete.booking_number}"? This action cannot be undone.`,
1155 )
1156 : __("Are you sure you want to delete this booking?")
1157 }
1158 confirmText={__("Delete")}
1159 cancelText={__("Cancel")}
1160 variant="danger"
1161 isLoading={deleteMutation.isPending}
1162 icon={<AlertTriangle className="w-6 h-6 text-red-500" />}
1163 />
1164 </div>
1165 );
1166 };
1167
1168 export default Bookings;
1169