PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
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.3, at resources/js/pages/Bookings.tsx

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