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

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