PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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 / Payments.tsx

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

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