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

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