PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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.16, at resources/js/pages/Payments.tsx

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