| 1 |
/** |
| 2 |
* Scheduled Payments Page |
| 3 |
* |
| 4 |
* Read-only view of the balance payments Yatra has scheduled for bookings, |
| 5 |
* plus the ability to cancel one that has not run yet. |
| 6 |
* |
| 7 |
* Premium module for collecting a booking's remaining balance automatically. |
| 8 |
* This page is the UI shell in the free plugin; every endpoint it calls is |
| 9 |
* registered by the Yatra Pro "Scheduled Payments" module and only exists |
| 10 |
* while that module is enabled — hence the module gate below. |
| 11 |
* |
| 12 |
* @package Yatra |
| 13 |
*/ |
| 14 |
|
| 15 |
import React, { useState, useMemo, useEffect } from "react"; |
| 16 |
import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 17 |
import { |
| 18 |
CalendarClock, |
| 19 |
Ban, |
| 20 |
Link as LinkIcon, |
| 21 |
CreditCard, |
| 22 |
AlertCircle, |
| 23 |
} from "lucide-react"; |
| 24 |
import { __, sprintf } from "../lib/i18n"; |
| 25 |
import { apiService } from "../lib/api-client"; |
| 26 |
import { formatDate, formatDateTime } from "../lib/dateFormat"; |
| 27 |
import { formatYatraMoney } from "../lib/currency-display"; |
| 28 |
import { usePermissions } from "../hooks/usePermissions"; |
| 29 |
import { useNavigate } from "../hooks/useNavigate"; |
| 30 |
import { useToast } from "../components/ui/toast"; |
| 31 |
import { Badge } from "../components/ui/badge"; |
| 32 |
import { Button } from "../components/ui/button"; |
| 33 |
import { PageHeader } from "../components/common/PageHeader"; |
| 34 |
import { Modal } from "../components/ui/modal"; |
| 35 |
import { |
| 36 |
Pagination, |
| 37 |
SearchFilterToolbar, |
| 38 |
Table as SharedTable, |
| 39 |
} from "../components/shared"; |
| 40 |
import { isModuleActive } from "../lib/plugin-utils"; |
| 41 |
import PremiumUpgradeCard from "./premium-pages/ScheduledPayments"; |
| 42 |
|
| 43 |
interface ScheduledPayment { |
| 44 |
id: number; |
| 45 |
booking_id: number; |
| 46 |
booking_reference: string; |
| 47 |
booking_status: string; |
| 48 |
booking_exists: boolean; |
| 49 |
customer_name: string; |
| 50 |
customer_email: string; |
| 51 |
amount: number; |
| 52 |
currency: string; |
| 53 |
scheduled_date: string; |
| 54 |
status: string; |
| 55 |
payment_type: string; |
| 56 |
collection_method: "auto" | "link"; |
| 57 |
gateway: string; |
| 58 |
attempt_count: number; |
| 59 |
max_attempts: number; |
| 60 |
last_attempt_at: string; |
| 61 |
last_error: string; |
| 62 |
reminder_sent: boolean; |
| 63 |
paid_at: string; |
| 64 |
installment_number: number | null; |
| 65 |
created_at: string; |
| 66 |
} |
| 67 |
|
| 68 |
/** A balance still owed on a live booking, with nothing scheduled to collect it. */ |
| 69 |
interface OutstandingBalance { |
| 70 |
booking_id: number; |
| 71 |
booking_reference: string; |
| 72 |
booking_status: string; |
| 73 |
customer_name: string; |
| 74 |
customer_email: string; |
| 75 |
currency: string; |
| 76 |
total_amount: number; |
| 77 |
amount_paid: number; |
| 78 |
amount_due: number; |
| 79 |
tour_start: string; |
| 80 |
days_to_tour: number | null; |
| 81 |
payment_gateway: string; |
| 82 |
payment_method: string; |
| 83 |
not_scheduled_reason: string; |
| 84 |
} |
| 85 |
|
| 86 |
const PER_PAGE = 20; |
| 87 |
|
| 88 |
const ScheduledPayments: React.FC = () => { |
| 89 |
const [searchTerm, setSearchTerm] = useState(""); |
| 90 |
const [statusFilter, setStatusFilter] = useState("all"); |
| 91 |
const [page, setPage] = useState(1); |
| 92 |
const [toCancel, setToCancel] = useState<ScheduledPayment | null>(null); |
| 93 |
const [cancelling, setCancelling] = useState(false); |
| 94 |
// "scheduled" = automated collections; "outstanding" = money still owed that |
| 95 |
// nothing is collecting. Operators reach for the second far more often. |
| 96 |
const [view, setView] = useState<"scheduled" | "outstanding">("scheduled"); |
| 97 |
const [sendingLinkFor, setSendingLinkFor] = useState<number | null>(null); |
| 98 |
|
| 99 |
const { can } = usePermissions(); |
| 100 |
const { navigate } = useNavigate(); |
| 101 |
const { showToast } = useToast(); |
| 102 |
const queryClient = useQueryClient(); |
| 103 |
|
| 104 |
// Held in state, not read inline, so switching the module on or off in |
| 105 |
// Yatra → Modules updates this page immediately. The Modules screen refreshes |
| 106 |
// `window.yatraAdmin.scheduledPaymentsEnabled` and fires these events; without |
| 107 |
// listening, the page would keep its previous shape until a reload. |
| 108 |
const [moduleAvailable, setModuleAvailable] = useState<boolean>(() => |
| 109 |
isModuleActive("scheduled_payments"), |
| 110 |
); |
| 111 |
|
| 112 |
useEffect(() => { |
| 113 |
const sync = () => setModuleAvailable(isModuleActive("scheduled_payments")); |
| 114 |
|
| 115 |
window.addEventListener("yatra-modules-updated", sync); |
| 116 |
window.addEventListener("yatra-force-nav-refresh", sync); |
| 117 |
|
| 118 |
return () => { |
| 119 |
window.removeEventListener("yatra-modules-updated", sync); |
| 120 |
window.removeEventListener("yatra-force-nav-refresh", sync); |
| 121 |
}; |
| 122 |
}, []); |
| 123 |
// The page sits under Payments in the menu, so a finance role that can see |
| 124 |
// Payments can open it — the endpoints accept the same pair of capabilities. |
| 125 |
const canView = |
| 126 |
can("yatra_view_bookings") || can("yatra_view_financial_reports"); |
| 127 |
|
| 128 |
const queryParams = useMemo( |
| 129 |
() => ({ |
| 130 |
page, |
| 131 |
per_page: PER_PAGE, |
| 132 |
...(searchTerm ? { search: searchTerm } : {}), |
| 133 |
...(statusFilter !== "all" ? { status: statusFilter } : {}), |
| 134 |
}), |
| 135 |
[page, searchTerm, statusFilter], |
| 136 |
); |
| 137 |
|
| 138 |
const { data, isLoading, error, refetch } = useQuery({ |
| 139 |
queryKey: ["scheduled-payments", queryParams], |
| 140 |
queryFn: async () => await apiService.getScheduledPayments(queryParams), |
| 141 |
enabled: moduleAvailable && canView && view === "scheduled", |
| 142 |
}); |
| 143 |
|
| 144 |
const outstandingParams = useMemo( |
| 145 |
() => ({ |
| 146 |
page, |
| 147 |
per_page: PER_PAGE, |
| 148 |
...(searchTerm ? { search: searchTerm } : {}), |
| 149 |
}), |
| 150 |
[page, searchTerm], |
| 151 |
); |
| 152 |
|
| 153 |
const { |
| 154 |
data: outstandingData, |
| 155 |
isLoading: outstandingLoading, |
| 156 |
error: outstandingError, |
| 157 |
refetch: refetchOutstanding, |
| 158 |
} = useQuery({ |
| 159 |
queryKey: ["scheduled-payments-outstanding", outstandingParams], |
| 160 |
queryFn: async () => |
| 161 |
await apiService.getOutstandingBalances(outstandingParams), |
| 162 |
enabled: moduleAvailable && canView && view === "outstanding", |
| 163 |
}); |
| 164 |
|
| 165 |
const { data: statsRaw } = useQuery({ |
| 166 |
queryKey: ["scheduled-payments-stats"], |
| 167 |
queryFn: async () => await apiService.getScheduledPaymentsStats(), |
| 168 |
enabled: moduleAvailable && canView, |
| 169 |
}); |
| 170 |
|
| 171 |
// Endpoints answer { success, data, meta } and the API client returns the raw |
| 172 |
// body, so read through `data` rather than the top level. |
| 173 |
const isOutstandingView = view === "outstanding"; |
| 174 |
const response: any = (isOutstandingView ? outstandingData : data) ?? {}; |
| 175 |
const payments: ScheduledPayment[] = isOutstandingView |
| 176 |
? [] |
| 177 |
: (response?.data ?? []); |
| 178 |
const outstanding: OutstandingBalance[] = isOutstandingView |
| 179 |
? (response?.data ?? []) |
| 180 |
: []; |
| 181 |
const total: number = Number(response?.meta?.total ?? 0) || 0; |
| 182 |
const totalPages: number = Number(response?.meta?.total_pages ?? 0) || 0; |
| 183 |
const listLoading = isOutstandingView ? outstandingLoading : isLoading; |
| 184 |
const listError = isOutstandingView ? outstandingError : error; |
| 185 |
const reload = isOutstandingView ? refetchOutstanding : refetch; |
| 186 |
|
| 187 |
const stats: Record<string, number> = (statsRaw as any)?.data ?? {}; |
| 188 |
const statusCount = (key: string) => Number(stats?.[key] ?? 0) || 0; |
| 189 |
// Counts bookings, not schedule rows, so the endpoint returns it beside the |
| 190 |
// status map rather than inside it (where it would break "all = sum"). |
| 191 |
const outstandingCount = Number((statsRaw as any)?.outstanding ?? 0) || 0; |
| 192 |
|
| 193 |
const formatMoney = (amount: number, currency: string) => |
| 194 |
formatYatraMoney( |
| 195 |
Number(amount) || 0, |
| 196 |
currency || (window as any)?.yatraAdmin?.currency || "USD", |
| 197 |
{ zeroAsUnknown: false }, |
| 198 |
); |
| 199 |
|
| 200 |
const getStatusBadge = (status: string) => { |
| 201 |
const map: Record<string, { className: string; label: string }> = { |
| 202 |
pending: { |
| 203 |
className: |
| 204 |
"bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400", |
| 205 |
label: __("Scheduled", "yatra"), |
| 206 |
}, |
| 207 |
processing: { |
| 208 |
className: |
| 209 |
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400", |
| 210 |
label: __("Processing", "yatra"), |
| 211 |
}, |
| 212 |
completed: { |
| 213 |
className: |
| 214 |
"bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400", |
| 215 |
label: __("Paid", "yatra"), |
| 216 |
}, |
| 217 |
failed: { |
| 218 |
className: |
| 219 |
"bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400", |
| 220 |
label: __("Failed", "yatra"), |
| 221 |
}, |
| 222 |
cancelled: { |
| 223 |
className: |
| 224 |
"bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", |
| 225 |
label: __("Cancelled", "yatra"), |
| 226 |
}, |
| 227 |
}; |
| 228 |
const info = map[status] || { |
| 229 |
className: |
| 230 |
"bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", |
| 231 |
label: status, |
| 232 |
}; |
| 233 |
|
| 234 |
return <Badge className={`text-xs ${info.className}`}>{info.label}</Badge>; |
| 235 |
}; |
| 236 |
|
| 237 |
const columns = [ |
| 238 |
{ |
| 239 |
key: "booking", |
| 240 |
label: __("Booking", "yatra"), |
| 241 |
visible: true, |
| 242 |
render: (item: ScheduledPayment) => ( |
| 243 |
<div className="flex flex-col"> |
| 244 |
<span className="font-medium text-gray-900 dark:text-white"> |
| 245 |
{item.booking_reference || |
| 246 |
/* translators: %d: booking ID. */ |
| 247 |
__("Booking #", "yatra") + item.booking_id} |
| 248 |
</span> |
| 249 |
{item.customer_name ? ( |
| 250 |
<span className="text-xs text-gray-500 dark:text-gray-400"> |
| 251 |
{item.customer_name} |
| 252 |
</span> |
| 253 |
) : null} |
| 254 |
{!item.booking_exists ? ( |
| 255 |
<span className="text-xs text-red-600 dark:text-red-400"> |
| 256 |
{__("Booking no longer exists", "yatra")} |
| 257 |
</span> |
| 258 |
) : null} |
| 259 |
</div> |
| 260 |
), |
| 261 |
}, |
| 262 |
{ |
| 263 |
key: "amount", |
| 264 |
label: __("Amount", "yatra"), |
| 265 |
visible: true, |
| 266 |
render: (item: ScheduledPayment) => ( |
| 267 |
<span className="font-medium"> |
| 268 |
{formatMoney(item.amount, item.currency)} |
| 269 |
</span> |
| 270 |
), |
| 271 |
}, |
| 272 |
{ |
| 273 |
key: "scheduled_date", |
| 274 |
label: __("Scheduled for", "yatra"), |
| 275 |
visible: true, |
| 276 |
render: (item: ScheduledPayment) => ( |
| 277 |
<span className="text-sm"> |
| 278 |
{item.scheduled_date ? formatDateTime(item.scheduled_date) : "—"} |
| 279 |
</span> |
| 280 |
), |
| 281 |
}, |
| 282 |
{ |
| 283 |
key: "collection_method", |
| 284 |
label: __("Collection", "yatra"), |
| 285 |
visible: true, |
| 286 |
render: (item: ScheduledPayment) => |
| 287 |
item.collection_method === "auto" ? ( |
| 288 |
<span className="inline-flex items-center gap-1.5 text-sm"> |
| 289 |
<CreditCard className="w-3.5 h-3.5" /> |
| 290 |
{__("Auto-charge", "yatra")} |
| 291 |
</span> |
| 292 |
) : ( |
| 293 |
<span className="inline-flex items-center gap-1.5 text-sm"> |
| 294 |
<LinkIcon className="w-3.5 h-3.5" /> |
| 295 |
{__("Payment link", "yatra")} |
| 296 |
</span> |
| 297 |
), |
| 298 |
}, |
| 299 |
{ |
| 300 |
key: "status", |
| 301 |
label: __("Status", "yatra"), |
| 302 |
visible: true, |
| 303 |
render: (item: ScheduledPayment) => ( |
| 304 |
<div className="flex flex-col gap-1"> |
| 305 |
{getStatusBadge(item.status)} |
| 306 |
{item.status === "failed" && item.last_error ? ( |
| 307 |
<span |
| 308 |
className="text-xs text-red-600 dark:text-red-400 truncate max-w-[220px]" |
| 309 |
title={item.last_error} |
| 310 |
> |
| 311 |
{item.last_error} |
| 312 |
</span> |
| 313 |
) : null} |
| 314 |
{item.attempt_count > 0 && item.status !== "completed" ? ( |
| 315 |
<span className="text-xs text-gray-500 dark:text-gray-400"> |
| 316 |
{__("Attempts:", "yatra")} {item.attempt_count}/ |
| 317 |
{item.max_attempts} |
| 318 |
</span> |
| 319 |
) : null} |
| 320 |
</div> |
| 321 |
), |
| 322 |
}, |
| 323 |
]; |
| 324 |
|
| 325 |
/** Why nothing is collecting this balance — plain wording for operators. */ |
| 326 |
const notScheduledLabel = (reason: string): string => { |
| 327 |
switch (reason) { |
| 328 |
case "no_payment_yet": |
| 329 |
return __("No payment received yet", "yatra"); |
| 330 |
case "anchored_to_booking": |
| 331 |
return __("Balance due is anchored to the booking date", "yatra"); |
| 332 |
case "no_tour_date": |
| 333 |
return __("Booking has no tour date", "yatra"); |
| 334 |
case "not_due_yet": |
| 335 |
return __("Reminder starts closer to the tour", "yatra"); |
| 336 |
case "due_next_run": |
| 337 |
return __("Reminder goes out on the next daily run", "yatra"); |
| 338 |
default: |
| 339 |
return reason; |
| 340 |
} |
| 341 |
}; |
| 342 |
|
| 343 |
const outstandingColumns = [ |
| 344 |
{ |
| 345 |
key: "booking", |
| 346 |
label: __("Booking", "yatra"), |
| 347 |
visible: true, |
| 348 |
render: (item: OutstandingBalance) => ( |
| 349 |
<div className="flex flex-col"> |
| 350 |
<span className="font-medium text-gray-900 dark:text-white"> |
| 351 |
{item.booking_reference || `#${item.booking_id}`} |
| 352 |
</span> |
| 353 |
{item.customer_name ? ( |
| 354 |
<span className="text-xs text-gray-500 dark:text-gray-400"> |
| 355 |
{item.customer_name} |
| 356 |
</span> |
| 357 |
) : null} |
| 358 |
</div> |
| 359 |
), |
| 360 |
}, |
| 361 |
{ |
| 362 |
key: "amount_due", |
| 363 |
label: __("Outstanding", "yatra"), |
| 364 |
visible: true, |
| 365 |
render: (item: OutstandingBalance) => ( |
| 366 |
<div className="flex flex-col"> |
| 367 |
<span className="font-medium"> |
| 368 |
{formatMoney(item.amount_due, item.currency)} |
| 369 |
</span> |
| 370 |
<span className="text-xs text-gray-500 dark:text-gray-400"> |
| 371 |
{sprintf( |
| 372 |
/* translators: 1: amount already paid, 2: booking total. */ |
| 373 |
__("%1$s of %2$s paid", "yatra"), |
| 374 |
formatMoney(item.amount_paid, item.currency), |
| 375 |
formatMoney(item.total_amount, item.currency), |
| 376 |
)} |
| 377 |
</span> |
| 378 |
</div> |
| 379 |
), |
| 380 |
}, |
| 381 |
{ |
| 382 |
key: "tour_start", |
| 383 |
label: __("Tour date", "yatra"), |
| 384 |
visible: true, |
| 385 |
render: (item: OutstandingBalance) => ( |
| 386 |
<div className="flex flex-col"> |
| 387 |
<span className="text-sm"> |
| 388 |
{item.tour_start ? formatDate(item.tour_start) : "—"} |
| 389 |
</span> |
| 390 |
{typeof item.days_to_tour === "number" && item.days_to_tour >= 0 ? ( |
| 391 |
<span className="text-xs text-gray-500 dark:text-gray-400"> |
| 392 |
{sprintf( |
| 393 |
/* translators: %d: number of days until the tour starts. */ |
| 394 |
__("in %d days", "yatra"), |
| 395 |
item.days_to_tour, |
| 396 |
)} |
| 397 |
</span> |
| 398 |
) : null} |
| 399 |
</div> |
| 400 |
), |
| 401 |
}, |
| 402 |
{ |
| 403 |
key: "reason", |
| 404 |
label: __("Not scheduled because", "yatra"), |
| 405 |
visible: true, |
| 406 |
render: (item: OutstandingBalance) => ( |
| 407 |
<span className="text-sm text-gray-600 dark:text-gray-300"> |
| 408 |
{notScheduledLabel(item.not_scheduled_reason)} |
| 409 |
</span> |
| 410 |
), |
| 411 |
}, |
| 412 |
]; |
| 413 |
|
| 414 |
const sendBalanceLink = async (item: OutstandingBalance) => { |
| 415 |
if (sendingLinkFor !== null) { |
| 416 |
return; |
| 417 |
} |
| 418 |
setSendingLinkFor(item.booking_id); |
| 419 |
try { |
| 420 |
await apiService.sendBalancePaymentLink(item.booking_id); |
| 421 |
queryClient.invalidateQueries({ |
| 422 |
queryKey: ["scheduled-payments-outstanding"], |
| 423 |
}); |
| 424 |
queryClient.invalidateQueries({ queryKey: ["scheduled-payments"] }); |
| 425 |
queryClient.invalidateQueries({ queryKey: ["scheduled-payments-stats"] }); |
| 426 |
showToast(__("Balance payment link sent.", "yatra"), "success"); |
| 427 |
} catch (err: any) { |
| 428 |
showToast( |
| 429 |
err?.message || __("Failed to send the payment link.", "yatra"), |
| 430 |
"error", |
| 431 |
); |
| 432 |
} finally { |
| 433 |
setSendingLinkFor(null); |
| 434 |
} |
| 435 |
}; |
| 436 |
|
| 437 |
const outstandingActions = [ |
| 438 |
{ |
| 439 |
key: "send_link", |
| 440 |
label: __("Send balance payment link", "yatra"), |
| 441 |
icon: <LinkIcon className="w-4 h-4" />, |
| 442 |
onClick: (item: OutstandingBalance) => sendBalanceLink(item), |
| 443 |
// Emails the customer a secure link; needs the same capability as |
| 444 |
// changing a booking, and only makes sense once money is owed. |
| 445 |
condition: (item: OutstandingBalance) => |
| 446 |
can("yatra_edit_bookings") && item.customer_email !== "", |
| 447 |
}, |
| 448 |
{ |
| 449 |
key: "view_booking", |
| 450 |
label: __("View booking", "yatra"), |
| 451 |
icon: <CalendarClock className="w-4 h-4" />, |
| 452 |
onClick: (item: OutstandingBalance) => |
| 453 |
navigate({ |
| 454 |
subpage: "bookings", |
| 455 |
action: "view", |
| 456 |
id: item.booking_id, |
| 457 |
}), |
| 458 |
condition: () => can("yatra_view_bookings"), |
| 459 |
}, |
| 460 |
]; |
| 461 |
|
| 462 |
const actions = [ |
| 463 |
{ |
| 464 |
key: "view_booking", |
| 465 |
label: __("View booking", "yatra"), |
| 466 |
icon: <CalendarClock className="w-4 h-4" />, |
| 467 |
onClick: (item: ScheduledPayment) => |
| 468 |
navigate({ |
| 469 |
subpage: "bookings", |
| 470 |
action: "view", |
| 471 |
id: item.booking_id, |
| 472 |
}), |
| 473 |
condition: (item: ScheduledPayment) => |
| 474 |
can("yatra_view_bookings") && item.booking_exists, |
| 475 |
}, |
| 476 |
{ |
| 477 |
key: "cancel", |
| 478 |
label: __("Cancel scheduled payment", "yatra"), |
| 479 |
icon: <Ban className="w-4 h-4" />, |
| 480 |
onClick: (item: ScheduledPayment) => setToCancel(item), |
| 481 |
variant: "destructive" as const, |
| 482 |
// Only a row that has not run yet can be cancelled — the endpoint |
| 483 |
// enforces the same rule. |
| 484 |
condition: (item: ScheduledPayment) => |
| 485 |
can("yatra_edit_bookings") && |
| 486 |
(item.status === "pending" || item.status === "processing"), |
| 487 |
}, |
| 488 |
]; |
| 489 |
|
| 490 |
const confirmCancel = async () => { |
| 491 |
if (!toCancel) { |
| 492 |
return; |
| 493 |
} |
| 494 |
setCancelling(true); |
| 495 |
try { |
| 496 |
await apiService.cancelScheduledPayment(toCancel.id); |
| 497 |
queryClient.invalidateQueries({ queryKey: ["scheduled-payments"] }); |
| 498 |
queryClient.invalidateQueries({ queryKey: ["scheduled-payments-stats"] }); |
| 499 |
showToast(__("Scheduled payment cancelled.", "yatra"), "success"); |
| 500 |
setToCancel(null); |
| 501 |
} catch (err: any) { |
| 502 |
showToast( |
| 503 |
err?.message || __("Failed to cancel scheduled payment.", "yatra"), |
| 504 |
"error", |
| 505 |
); |
| 506 |
} finally { |
| 507 |
setCancelling(false); |
| 508 |
} |
| 509 |
}; |
| 510 |
|
| 511 |
// Module gate lives after the hooks so every hook runs on every render. |
| 512 |
if (!moduleAvailable) { |
| 513 |
return <PremiumUpgradeCard />; |
| 514 |
} |
| 515 |
|
| 516 |
return ( |
| 517 |
<div className="space-y-6"> |
| 518 |
<PageHeader |
| 519 |
title={__("Scheduled Payments", "yatra")} |
| 520 |
description={ |
| 521 |
isOutstandingView |
| 522 |
? __( |
| 523 |
"Money still owed on live bookings that nothing is scheduled to collect.", |
| 524 |
"yatra", |
| 525 |
) |
| 526 |
: __( |
| 527 |
"Balance payments Yatra has scheduled for bookings that were paid with a deposit.", |
| 528 |
"yatra", |
| 529 |
) |
| 530 |
} |
| 531 |
/> |
| 532 |
|
| 533 |
{/* Two halves of the same question: what Yatra will collect on its own, |
| 534 |
and what is still owed with nothing collecting it. */} |
| 535 |
<div className="flex items-center gap-2"> |
| 536 |
{( |
| 537 |
[ |
| 538 |
["scheduled", __("Scheduled", "yatra"), statusCount("all")], |
| 539 |
["outstanding", __("Outstanding", "yatra"), outstandingCount], |
| 540 |
] as const |
| 541 |
).map(([key, label, count]) => ( |
| 542 |
<button |
| 543 |
key={key} |
| 544 |
type="button" |
| 545 |
onClick={() => { |
| 546 |
setView(key as "scheduled" | "outstanding"); |
| 547 |
setPage(1); |
| 548 |
}} |
| 549 |
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${ |
| 550 |
view === key |
| 551 |
? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium" |
| 552 |
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700" |
| 553 |
}`} |
| 554 |
> |
| 555 |
{label} |
| 556 |
<span className="ml-1.5 text-xs opacity-70">{count}</span> |
| 557 |
</button> |
| 558 |
))} |
| 559 |
</div> |
| 560 |
|
| 561 |
<SearchFilterToolbar |
| 562 |
searchTerm={searchTerm} |
| 563 |
onSearchChange={(term) => { |
| 564 |
setSearchTerm(term); |
| 565 |
setPage(1); |
| 566 |
}} |
| 567 |
statusFilter={statusFilter} |
| 568 |
onStatusChange={(value) => { |
| 569 |
setStatusFilter(value); |
| 570 |
setPage(1); |
| 571 |
}} |
| 572 |
// Status belongs to schedule rows; an outstanding balance has none, so |
| 573 |
// the filter collapses to a single option in that view. |
| 574 |
statusOptions={ |
| 575 |
isOutstandingView |
| 576 |
? [{ value: "all", label: __("All", "yatra") }] |
| 577 |
: [ |
| 578 |
{ |
| 579 |
value: "all", |
| 580 |
label: __("All", "yatra") + ` (${statusCount("all")})`, |
| 581 |
}, |
| 582 |
{ |
| 583 |
value: "pending", |
| 584 |
label: |
| 585 |
__("Scheduled", "yatra") + ` (${statusCount("pending")})`, |
| 586 |
}, |
| 587 |
{ |
| 588 |
value: "completed", |
| 589 |
label: __("Paid", "yatra") + ` (${statusCount("completed")})`, |
| 590 |
}, |
| 591 |
{ |
| 592 |
value: "failed", |
| 593 |
label: __("Failed", "yatra") + ` (${statusCount("failed")})`, |
| 594 |
}, |
| 595 |
{ |
| 596 |
value: "cancelled", |
| 597 |
label: |
| 598 |
__("Cancelled", "yatra") + ` (${statusCount("cancelled")})`, |
| 599 |
}, |
| 600 |
] |
| 601 |
} |
| 602 |
sortBy="scheduled_date" |
| 603 |
onSortByChange={() => undefined} |
| 604 |
sortOrder="desc" |
| 605 |
onSortOrderChange={() => undefined} |
| 606 |
sortOptions={[ |
| 607 |
{ value: "scheduled_date", label: __("Scheduled date", "yatra") }, |
| 608 |
]} |
| 609 |
onResetFilters={() => { |
| 610 |
setSearchTerm(""); |
| 611 |
setStatusFilter("all"); |
| 612 |
setPage(1); |
| 613 |
}} |
| 614 |
hasFilters={ |
| 615 |
searchTerm !== "" || (!isOutstandingView && statusFilter !== "all") |
| 616 |
} |
| 617 |
placeholder={__("Search by booking reference or customer…", "yatra")} |
| 618 |
/> |
| 619 |
|
| 620 |
<SharedTable |
| 621 |
data={isOutstandingView ? outstanding : payments} |
| 622 |
columns={isOutstandingView ? outstandingColumns : columns} |
| 623 |
actions={isOutstandingView ? outstandingActions : actions} |
| 624 |
isLoading={listLoading} |
| 625 |
isError={!!listError} |
| 626 |
errorText={ |
| 627 |
listError |
| 628 |
? __("Could not load scheduled payments.", "yatra") |
| 629 |
: undefined |
| 630 |
} |
| 631 |
onRetry={() => reload()} |
| 632 |
emptyText={ |
| 633 |
isOutstandingView |
| 634 |
? __("No outstanding balances", "yatra") |
| 635 |
: __("No scheduled payments yet", "yatra") |
| 636 |
} |
| 637 |
// Says *why* it is empty: operators reasonably expect every deposit |
| 638 |
// booking to appear here, and the two conditions below are what |
| 639 |
// actually create a row. |
| 640 |
emptyDescription={ |
| 641 |
isOutstandingView |
| 642 |
? __( |
| 643 |
"Every confirmed and pending booking is paid up, or already has a scheduled payment collecting the rest.", |
| 644 |
"yatra", |
| 645 |
) |
| 646 |
: __( |
| 647 |
"Yatra schedules a balance payment when a deposit is paid with a card it can charge again later, or — if Balance due is anchored to the tour date — when it emails a payment link. Deposits paid by bank transfer, Pay Later or recorded by hand don't create one: check the Outstanding tab for those.", |
| 648 |
"yatra", |
| 649 |
) |
| 650 |
} |
| 651 |
getItemId={(item: ScheduledPayment | OutstandingBalance) => |
| 652 |
isOutstandingView |
| 653 |
? (item as OutstandingBalance).booking_id |
| 654 |
: (item as ScheduledPayment).id |
| 655 |
} |
| 656 |
/> |
| 657 |
|
| 658 |
{totalPages > 1 ? ( |
| 659 |
<Pagination |
| 660 |
currentPage={page} |
| 661 |
totalPages={totalPages} |
| 662 |
totalItems={total} |
| 663 |
itemsPerPage={PER_PAGE} |
| 664 |
onPageChange={setPage} |
| 665 |
itemName={__("scheduled payments", "yatra")} |
| 666 |
/> |
| 667 |
) : null} |
| 668 |
|
| 669 |
<Modal |
| 670 |
isOpen={toCancel !== null} |
| 671 |
onClose={() => { |
| 672 |
if (!cancelling) { |
| 673 |
setToCancel(null); |
| 674 |
} |
| 675 |
}} |
| 676 |
title={__("Cancel scheduled payment?", "yatra")} |
| 677 |
description={__( |
| 678 |
"Yatra will not collect this balance automatically. The booking keeps its outstanding balance, which you can still collect manually.", |
| 679 |
"yatra", |
| 680 |
)} |
| 681 |
size="sm" |
| 682 |
panelClassName="yatra-model-ui" |
| 683 |
footer={ |
| 684 |
<div className="flex gap-2 justify-end"> |
| 685 |
<Button |
| 686 |
variant="outline" |
| 687 |
onClick={() => setToCancel(null)} |
| 688 |
disabled={cancelling} |
| 689 |
> |
| 690 |
{__("Keep it", "yatra")} |
| 691 |
</Button> |
| 692 |
<Button |
| 693 |
variant="destructive" |
| 694 |
onClick={confirmCancel} |
| 695 |
disabled={cancelling} |
| 696 |
> |
| 697 |
{cancelling |
| 698 |
? __("Cancelling…", "yatra") |
| 699 |
: __("Cancel payment", "yatra")} |
| 700 |
</Button> |
| 701 |
</div> |
| 702 |
} |
| 703 |
> |
| 704 |
{toCancel ? ( |
| 705 |
<div className="space-y-2"> |
| 706 |
<div className="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-300"> |
| 707 |
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> |
| 708 |
<span> |
| 709 |
{formatMoney(toCancel.amount, toCancel.currency)} |
| 710 |
{toCancel.booking_reference |
| 711 |
? ` — ${toCancel.booking_reference}` |
| 712 |
: ""} |
| 713 |
</span> |
| 714 |
</div> |
| 715 |
{toCancel.status === "processing" ? ( |
| 716 |
// Honest about the race: the charge was already handed to the |
| 717 |
// gateway, and if it succeeds the row returns to Paid. |
| 718 |
<p className="text-xs text-amber-700 dark:text-amber-400"> |
| 719 |
{__( |
| 720 |
"This charge has already started. If the gateway completes it, the payment will still go through and this row will show as Paid.", |
| 721 |
"yatra", |
| 722 |
)} |
| 723 |
</p> |
| 724 |
) : null} |
| 725 |
</div> |
| 726 |
) : null} |
| 727 |
</Modal> |
| 728 |
</div> |
| 729 |
); |
| 730 |
}; |
| 731 |
|
| 732 |
export default ScheduledPayments; |
| 733 |
|