PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / Payments.tsx

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

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