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

1,010 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 = (price: number, currencyCode: string = defaultCurrency) =>
235 formatYatraMoney(Number(price) || 0, currencyCode, {
236 zeroAsUnknown: false,
237 });
238
239 const getBookingStatusBadge = (status: string) => {
240 const statusMap: Record<string, { className: string; label: string }> = {
241 confirmed: {
242 className:
243 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
244 label: __("Confirmed"),
245 },
246 pending: {
247 className:
248 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
249 label: __("Pending"),
250 },
251 cancelled: {
252 className:
253 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
254 label: __("Cancelled"),
255 },
256 completed: {
257 className:
258 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
259 label: __("Completed"),
260 },
261 };
262
263 const statusInfo = statusMap[status] || {
264 className:
265 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
266 label: status,
267 };
268
269 return (
270 <span
271 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
272 >
273 {statusInfo.label}
274 </span>
275 );
276 };
277
278 const getPaymentStatusBadge = (status: string) => {
279 const statusMap: Record<string, { className: string; label: string }> = {
280 paid: {
281 className:
282 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
283 label: __("Paid"),
284 },
285 pending: {
286 className:
287 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
288 label: __("Pending"),
289 },
290 partial: {
291 className:
292 "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400",
293 label: __("Partial"),
294 },
295 refunded: {
296 className:
297 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
298 label: __("Refunded"),
299 },
300 };
301
302 const statusInfo = statusMap[status] || {
303 className:
304 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
305 label: status,
306 };
307
308 return (
309 <span
310 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
311 >
312 {statusInfo.label}
313 </span>
314 );
315 };
316
317 const handleEdit = (booking: Booking) => {
318 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${booking.id}`;
319 };
320
321 const handleDelete = (booking: Booking) => {
322 setBookingToDelete(booking);
323 setDeleteDialogOpen(true);
324 };
325
326 const confirmDelete = () => {
327 if (bookingToDelete) {
328 deleteMutation.mutate(bookingToDelete.id);
329 setDeleteDialogOpen(false);
330 setBookingToDelete(null);
331 }
332 };
333
334 const cancelDelete = () => {
335 setDeleteDialogOpen(false);
336 setBookingToDelete(null);
337 };
338
339 const handleView = (booking: Booking) => {
340 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=view&id=${booking.id}`;
341 };
342
343 const handleCreateBooking = () => {
344 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=create`;
345 };
346
347 const handleResetFilters = () => {
348 setSearchTerm("");
349 setStatusFilter("all");
350 setPaymentFilter("all");
351 setSortBy("booking_date");
352 setSortOrder("desc");
353 setPage(1);
354 };
355
356 const handleSort = (field: string) => {
357 if (sortBy === field) {
358 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
359 } else {
360 setSortBy(field);
361 setSortOrder("desc");
362 }
363 };
364
365 const getSortIcon = (field: string) => {
366 if (sortBy !== field) {
367 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
368 }
369 return sortOrder === "asc" ? (
370 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
371 ) : (
372 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
373 );
374 };
375
376 const hasFilters =
377 searchTerm ||
378 statusFilter !== "all" ||
379 paymentFilter !== "all" ||
380 sortBy !== "booking_date" ||
381 sortOrder !== "desc";
382
383 const toggleColumn = (columnKey: string) => {
384 const newVisibleColumns = {
385 ...visibleColumns,
386 [columnKey]: !visibleColumns[columnKey as keyof typeof visibleColumns],
387 };
388 setVisibleColumns(newVisibleColumns);
389 if (typeof window !== "undefined") {
390 window.localStorage.setItem(
391 "yatra-bookings-visible-columns",
392 JSON.stringify(newVisibleColumns),
393 );
394 }
395 };
396
397 const handleSelectItem = (id: string | number, checked: boolean) => {
398 setSelectedIds((prev) =>
399 checked ? [...prev, id] : prev.filter((selectedId) => selectedId !== id),
400 );
401 };
402
403 const handleSelectAll = (checked: boolean) => {
404 if (checked) {
405 setSelectedIds(bookings.map((booking: Booking) => booking.id));
406 } else {
407 setSelectedIds([]);
408 }
409 };
410
411 const isAllSelected =
412 bookings.length > 0 && selectedIds.length === bookings.length;
413
414 const updateStatusForIds = async (
415 ids: (string | number)[],
416 newStatus: string,
417 ) => {
418 await apiService.bulkUpdateStatus("bookings", ids, newStatus);
419 };
420
421 const handleBulkApply = async () => {
422 if (!bulkAction || selectedIds.length === 0) {
423 return;
424 }
425
426 try {
427 setIsBulkPending(true);
428
429 if (bulkAction === "delete") {
430 await apiService.bulkDelete("bookings", selectedIds);
431 showToast(__("Selected bookings deleted successfully"), "success");
432 } else {
433 await updateStatusForIds(selectedIds, bulkAction);
434 showToast(__("Bulk booking status updated successfully"), "success");
435 }
436
437 queryClient.invalidateQueries({ queryKey: ["bookings"] });
438 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
439 setSelectedIds([]);
440 setBulkAction("");
441 } catch (error: any) {
442 showToast(
443 __("Error: ") +
444 (error?.message || __("Failed to perform bulk action on bookings")),
445 "error",
446 );
447 } finally {
448 setIsBulkPending(false);
449 }
450 };
451
452 const allBulkActionOptions = [
453 { value: "confirmed", label: __("Mark as Confirmed") },
454 { value: "pending", label: __("Mark as Pending") },
455 { value: "cancelled", label: __("Mark as Cancelled") },
456 { value: "completed", label: __("Mark as Completed") },
457 { value: "delete", label: __("Delete permanently") },
458 ];
459
460 const getBulkActionOptionsForStatus = (view: string) => {
461 switch (view) {
462 case "confirmed":
463 return allBulkActionOptions.filter((opt) =>
464 ["pending", "cancelled", "completed", "delete"].includes(opt.value),
465 );
466 case "pending":
467 return allBulkActionOptions.filter((opt) =>
468 ["confirmed", "cancelled", "completed", "delete"].includes(opt.value),
469 );
470 case "cancelled":
471 return allBulkActionOptions.filter((opt) =>
472 ["pending", "confirmed", "delete"].includes(opt.value),
473 );
474 case "completed":
475 return allBulkActionOptions.filter((opt) =>
476 ["confirmed", "cancelled", "delete"].includes(opt.value),
477 );
478 default:
479 return allBulkActionOptions;
480 }
481 };
482
483 const bulkActionOptions = getBulkActionOptionsForStatus(statusFilter);
484
485 const statusOptions = [
486 {
487 value: "all",
488 label: __("All Status"),
489 count: statsData?.all ?? totalBookings,
490 },
491 { value: "confirmed", label: __("Confirmed"), count: confirmedCount },
492 { value: "pending", label: __("Pending"), count: pendingCount },
493 { value: "cancelled", label: __("Cancelled"), count: cancelledCount },
494 { value: "completed", label: __("Completed"), count: completedCount },
495 ];
496
497 const sortOptions = [
498 { value: "booking_date", label: __("Booking Date") },
499 { value: "travel_date", label: __("Travel Date") },
500 { value: "booking_number", label: __("Booking Number") },
501 { value: "customer_name", label: __("Customer") },
502 { value: "trip_title", label: __("Trip") },
503 { value: "total_amount", label: __("Amount") },
504 { value: "booking_status", label: __("Status") },
505 ];
506
507 const columnOptions = [
508 {
509 key: "booking_number",
510 label: __("Booking #"),
511 visible: visibleColumns.booking_number,
512 },
513 {
514 key: "customer",
515 label: __("Customer"),
516 visible: visibleColumns.customer,
517 },
518 { key: "trip", label: __("Trip"), visible: visibleColumns.trip },
519 {
520 key: "travelers",
521 label: __("Travelers"),
522 visible: visibleColumns.travelers,
523 },
524 {
525 key: "booking_date",
526 label: __("Booking Date"),
527 visible: visibleColumns.booking_date,
528 },
529 {
530 key: "travel_date",
531 label: __("Travel Date"),
532 visible: visibleColumns.travel_date,
533 },
534 { key: "amount", label: __("Amount"), visible: visibleColumns.amount },
535 {
536 key: "payment_status",
537 label: __("Payment"),
538 visible: visibleColumns.payment_status,
539 },
540 {
541 key: "booking_status",
542 label: __("Status"),
543 visible: visibleColumns.booking_status,
544 },
545 ];
546
547 const columns = [
548 {
549 key: "booking_number",
550 label: __("Booking #"),
551 sortable: true,
552 visible: visibleColumns.booking_number,
553 width: "w-[140px]",
554 render: (booking: Booking) => (
555 <button
556 type="button"
557 onClick={() => handleView(booking)}
558 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"
559 >
560 {booking.booking_number}
561 </button>
562 ),
563 },
564 {
565 key: "customer",
566 label: __("Customer"),
567 sortable: true,
568 visible: visibleColumns.customer,
569 render: (booking: Booking) => (
570 <div className="flex flex-col">
571 <span className="font-medium">
572 {booking.customer_name || __("Guest")}
573 </span>
574 <span className="text-xs text-muted-foreground">
575 {booking.customer_email}
576 </span>
577 </div>
578 ),
579 },
580 {
581 key: "trip",
582 label: __("Trip"),
583 sortable: true,
584 visible: visibleColumns.trip,
585 render: (booking: Booking) => (
586 <div className="flex flex-col">
587 <a
588 href={`${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${booking.trip_id}`}
589 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
590 >
591 {booking.trip_title}
592 </a>
593 <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
594 {booking.trip_id ? `(ID: ${booking.trip_id})` : ""}
595 </span>
596 </div>
597 ),
598 },
599 {
600 key: "travelers",
601 label: __("Travelers"),
602 sortable: true,
603 visible: visibleColumns.travelers,
604 render: (booking: Booking) => (
605 <span className="font-medium">
606 {booking.travelers ?? booking.travelers_count ?? 0}
607 </span>
608 ),
609 },
610 {
611 key: "booking_date",
612 label: __("Booking Date"),
613 sortable: true,
614 visible: visibleColumns.booking_date,
615 render: (booking: Booking) => (
616 <span>{formatDate(booking.booking_date)}</span>
617 ),
618 },
619 {
620 key: "travel_date",
621 label: __("Travel Date"),
622 sortable: true,
623 visible: visibleColumns.travel_date,
624 render: (booking: Booking) => (
625 <span>{formatDate(booking.travel_date)}</span>
626 ),
627 },
628 {
629 key: "amount",
630 label: __("Amount"),
631 sortable: true,
632 visible: visibleColumns.amount,
633 render: (booking: Booking) => (
634 <div className="flex flex-col">
635 <span className="font-semibold">
636 {formatPrice(booking.total_amount, booking.currency)}
637 </span>
638 {booking.amount_paid ? (
639 <span className="text-xs text-muted-foreground">
640 {__("Paid: ")}
641 {formatPrice(booking.amount_paid, booking.currency)}
642 </span>
643 ) : null}
644 </div>
645 ),
646 },
647 {
648 key: "payment_status",
649 label: __("Payment"),
650 sortable: true,
651 visible: visibleColumns.payment_status,
652 render: (booking: Booking) =>
653 getPaymentStatusBadge(booking.payment_status),
654 },
655 {
656 key: "booking_status",
657 label: __("Status"),
658 sortable: true,
659 visible: visibleColumns.booking_status,
660 render: (booking: Booking) =>
661 getBookingStatusBadge(booking.booking_status),
662 },
663 ];
664
665 const actions = [
666 {
667 key: "view",
668 label: __("View"),
669 icon: <Eye className="w-4 h-4" />,
670 onClick: (booking: Booking) => handleView(booking),
671 condition: () => can("yatra_view_bookings"),
672 },
673 {
674 key: "edit",
675 label: __("Edit"),
676 icon: <Edit className="w-4 h-4" />,
677 onClick: (booking: Booking) => handleEdit(booking),
678 condition: () => can("yatra_edit_bookings"),
679 },
680 {
681 key: "mark_confirmed",
682 label: __("Mark as Confirmed"),
683 icon: <ArrowDown className="w-4 h-4" />,
684 onClick: async (booking: Booking) => {
685 setIsBulkPending(true);
686 try {
687 await updateStatusForIds([booking.id], "confirmed");
688 queryClient.invalidateQueries({ queryKey: ["bookings"] });
689 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
690 showToast(__("Booking status updated"), "success");
691 } catch (error: any) {
692 showToast(
693 error?.message || __("Failed to update booking status"),
694 "error",
695 );
696 } finally {
697 setIsBulkPending(false);
698 }
699 },
700 condition: (booking: Booking) =>
701 can("yatra_edit_bookings") && booking.booking_status !== "confirmed",
702 },
703 {
704 key: "mark_pending",
705 label: __("Mark as Pending"),
706 icon: <ArrowUp className="w-4 h-4" />,
707 onClick: async (booking: Booking) => {
708 setIsBulkPending(true);
709 try {
710 await updateStatusForIds([booking.id], "pending");
711 queryClient.invalidateQueries({ queryKey: ["bookings"] });
712 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
713 showToast(__("Booking status updated"), "success");
714 } catch (error: any) {
715 showToast(
716 error?.message || __("Failed to update booking status"),
717 "error",
718 );
719 } finally {
720 setIsBulkPending(false);
721 }
722 },
723 condition: (booking: Booking) =>
724 can("yatra_edit_bookings") && booking.booking_status !== "pending",
725 },
726 {
727 key: "mark_cancelled",
728 label: __("Mark as Cancelled"),
729 icon: <ArrowDown className="w-4 h-4" />,
730 onClick: async (booking: Booking) => {
731 setIsBulkPending(true);
732 try {
733 await updateStatusForIds([booking.id], "cancelled");
734 queryClient.invalidateQueries({ queryKey: ["bookings"] });
735 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
736 showToast(__("Booking status updated"), "success");
737 } catch (error: any) {
738 showToast(
739 error?.message || __("Failed to update booking status"),
740 "error",
741 );
742 } finally {
743 setIsBulkPending(false);
744 }
745 },
746 condition: (booking: Booking) =>
747 can("yatra_edit_bookings") && booking.booking_status !== "cancelled",
748 },
749 {
750 key: "mark_completed",
751 label: __("Mark as Completed"),
752 icon: <ArrowDown className="w-4 h-4" />,
753 onClick: async (booking: Booking) => {
754 setIsBulkPending(true);
755 try {
756 await updateStatusForIds([booking.id], "completed");
757 queryClient.invalidateQueries({ queryKey: ["bookings"] });
758 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
759 showToast(__("Booking status updated"), "success");
760 } catch (error: any) {
761 showToast(
762 error?.message || __("Failed to update booking status"),
763 "error",
764 );
765 } finally {
766 setIsBulkPending(false);
767 }
768 },
769 condition: (booking: Booking) =>
770 can("yatra_edit_bookings") && booking.booking_status !== "completed",
771 },
772 {
773 key: "delete",
774 label: __("Delete"),
775 icon: <Trash2 className="w-4 h-4" />,
776 onClick: (booking: Booking) => handleDelete(booking),
777 variant: "destructive" as const,
778 condition: () => can("yatra_delete_bookings"),
779 },
780 ];
781
782 return (
783 <div className="space-y-3">
784 <PageHeader
785 title={__("Bookings")}
786 description={__("Manage customer bookings and reservations")}
787 actions={
788 <div className="flex items-center gap-2">
789 <Button
790 onClick={handleCreateBooking}
791 className="flex items-center gap-2"
792 >
793 <Plus className="w-4 h-4" />
794 {__("Create Booking")}
795 </Button>
796 </div>
797 }
798 />
799
800 {/* Stats summary */}
801 {statsData && (
802 <div className="grid grid-cols-1 md:grid-cols-4 gap-3">
803 <Card>
804 <CardContent className="p-4">
805 <div className="text-sm text-muted-foreground">
806 {__("Total Bookings")}
807 </div>
808 <div className="text-2xl font-semibold">{totalBookings}</div>
809 </CardContent>
810 </Card>
811 <Card>
812 <CardContent className="p-4">
813 <div className="text-sm text-muted-foreground">
814 {__("Confirmed")}
815 </div>
816 <div className="text-2xl font-semibold">{confirmedCount}</div>
817 </CardContent>
818 </Card>
819 <Card>
820 <CardContent className="p-4">
821 <div className="text-sm text-muted-foreground">
822 {__("Pending")}
823 </div>
824 <div className="text-2xl font-semibold">{pendingCount}</div>
825 </CardContent>
826 </Card>
827 <Card>
828 <CardContent className="p-4">
829 <div className="text-sm text-muted-foreground">
830 {__("Cancelled")}
831 </div>
832 <div className="text-2xl font-semibold">{cancelledCount}</div>
833 </CardContent>
834 </Card>
835 </div>
836 )}
837
838 <Card>
839 <CardContent className="p-4">
840 <div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center w-full">
841 <div className="flex-1 min-w-0">
842 <SearchFilterToolbar
843 searchTerm={searchTerm}
844 onSearchChange={setSearchTerm}
845 statusFilter={statusFilter}
846 onStatusChange={(value) => {
847 setStatusFilter(value);
848 setPage(1);
849 }}
850 statusOptions={statusOptions}
851 sortBy={sortBy}
852 onSortByChange={(field) => {
853 setSortBy(field);
854 setPage(1);
855 }}
856 sortOrder={sortOrder}
857 onSortOrderChange={(order) => {
858 setSortOrder(order);
859 setPage(1);
860 }}
861 sortOptions={sortOptions}
862 onResetFilters={handleResetFilters}
863 hasFilters={!!hasFilters}
864 placeholder={__("Search bookings...")}
865 />
866 </div>
867
868 {/* Payment Status Filter */}
869 <div className="w-full lg:w-auto lg:min-w-[160px]">
870 <Select
871 value={paymentFilter}
872 onChange={(e) => {
873 setPaymentFilter(e.target.value);
874 setPage(1);
875 }}
876 className="w-full"
877 >
878 <option value="all">{__("All Payments")}</option>
879 <option value="paid">{__("Paid")}</option>
880 <option value="pending">{__("Pending")}</option>
881 <option value="partial">{__("Partial")}</option>
882 <option value="refunded">{__("Refunded")}</option>
883 </Select>
884 </div>
885 </div>
886 </CardContent>
887 </Card>
888
889 <ConditionalRender capability="yatra_view_bookings">
890 <>
891 <BulkActionToolbar
892 selectedIds={selectedIds}
893 bulkAction={bulkAction}
894 setBulkAction={setBulkAction}
895 onApply={handleBulkApply}
896 onClearSelection={() => setSelectedIds([])}
897 statusFilter={statusFilter}
898 setStatusFilter={(value) => {
899 setStatusFilter(value);
900 setPage(1);
901 }}
902 statusOptions={[
903 {
904 key: "all",
905 label: __("All"),
906 count: statsData?.all ?? totalBookings,
907 },
908 {
909 key: "confirmed",
910 label: __("Confirmed"),
911 count: confirmedCount,
912 },
913 { key: "pending", label: __("Pending"), count: pendingCount },
914 {
915 key: "cancelled",
916 label: __("Cancelled"),
917 count: cancelledCount,
918 },
919 {
920 key: "completed",
921 label: __("Completed"),
922 count: completedCount,
923 },
924 ]}
925 showColumnsDropdown={showColumnsDropdown}
926 setShowColumnsDropdown={setShowColumnsDropdown}
927 columnOptions={columnOptions}
928 onToggleColumn={toggleColumn}
929 bulkMutationPending={isBulkPending}
930 totalItems={total}
931 bulkActionOptions={bulkActionOptions}
932 />
933
934 <Card>
935 <CardContent className="p-0">
936 <SharedTable
937 data={bookings}
938 columns={columns}
939 actions={actions}
940 isLoading={isLoading}
941 isError={isBookingsError}
942 errorText={__("Error loading bookings")}
943 errorDescription={__(
944 "We couldn't connect to the bookings service. Please refresh or try again shortly.",
945 )}
946 errorDetails={derivedErrorDetails}
947 errorRequestInfo={errorContext.requestInfo}
948 onRetry={() => refetch()}
949 emptyText={__("No bookings found")}
950 emptyDescription={
951 hasFilters
952 ? __("Try adjusting your filters to see more results.")
953 : __("Get started by creating your first booking.")
954 }
955 onCreateClick={
956 can("yatra_edit_bookings") ? handleCreateBooking : undefined
957 }
958 onSort={handleSort}
959 getSortIcon={getSortIcon}
960 selectedItemIds={selectedIds}
961 onSelectItem={handleSelectItem}
962 onSelectAll={handleSelectAll}
963 isAllSelected={isAllSelected}
964 getItemId={(booking: Booking) => booking.id}
965 skeletonRows={5}
966 capability="yatra_view_bookings"
967 />
968 </CardContent>
969 </Card>
970
971 {total > 0 && (
972 <div className="mt-4">
973 <Pagination
974 currentPage={page}
975 totalPages={totalPages}
976 totalItems={total}
977 itemsPerPage={perPage}
978 onPageChange={(newPage) => setPage(newPage)}
979 itemName={__("bookings")}
980 />
981 </div>
982 )}
983 </>
984 </ConditionalRender>
985
986 {/* Delete Confirmation Dialog */}
987 <ConfirmationDialog
988 isOpen={deleteDialogOpen}
989 onClose={cancelDelete}
990 onConfirm={confirmDelete}
991 title={__("Delete Booking")}
992 description={
993 bookingToDelete
994 ? __(
995 `Are you sure you want to delete booking "${bookingToDelete.booking_number}"? This action cannot be undone.`,
996 )
997 : __("Are you sure you want to delete this booking?")
998 }
999 confirmText={__("Delete")}
1000 cancelText={__("Cancel")}
1001 variant="danger"
1002 isLoading={deleteMutation.isPending}
1003 icon={<AlertTriangle className="w-6 h-6 text-red-500" />}
1004 />
1005 </div>
1006 );
1007 };
1008
1009 export default Bookings;
1010