import { useState, useEffect, useMemo } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; import { Edit, Delete, Clock, CheckCircle, AlertCircle, TrendingUp, ThumbsDown, DollarSign, Ban, Paperclip, } from "lucide-react"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; import { formatDateToHumanReadable } from "@/utils/dateUtils"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import { Checkbox } from "@/components/ui/checkbox"; import { DeleteConfirmationDialog } from "../ui/deleteDialog"; import LoadingScreen from "../ui/LoadingScreen"; import EditPaymentModal from "./payment-edit-modal"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { ConfirmationDialog } from "../ui/confirmationDialog"; import { getPageNumbers } from "@/utils/pageNumberGenerator"; // 🔑 exported base key (so others can invalidate all pages/filters) export const QK_PAYMENTS_RECENT_BASE = ["payments-recent"]; // 🔑 exported helper for specific pages/scopes export const qkPaymentsRecent = (opts) => opts.patientId ? [ ...QK_PAYMENTS_RECENT_BASE, "patient", opts.patientId, opts.page, ] : [...QK_PAYMENTS_RECENT_BASE, "global", opts.page]; export default function PaymentsRecentTable({ allowEdit, allowDelete, allowCheckbox, onSelectPayment, onPageChange, patientId, }) { const { toast } = useToast(); const [isEditPaymentOpen, setIsEditPaymentOpen] = useState(false); const [isDeletePaymentOpen, setIsDeletePaymentOpen] = useState(false); const [currentPage, setCurrentPage] = useState(1); const paymentsPerPage = 5; const offset = (currentPage - 1) * paymentsPerPage; const [currentPayment, setCurrentPayment] = useState(undefined); const [selectedPaymentId, setSelectedPaymentId] = useState(null); const [checkedPaymentIds, setCheckedPaymentIds] = useState(new Set()); const [isMhChecking, setIsMhChecking] = useState(false); const [editingMhPaidId, setEditingMhPaidId] = useState(null); const [editingMhPaidValue, setEditingMhPaidValue] = useState(""); const [editingCopaymentId, setEditingCopaymentId] = useState(null); const [editingCopaymentValue, setEditingCopaymentValue] = useState(""); const [isRevertOpen, setIsRevertOpen] = useState(false); const [revertPaymentId, setRevertPaymentId] = useState(null); const handleSelectPayment = (payment) => { const isSelected = selectedPaymentId === payment.id; const newSelectedId = isSelected ? null : payment.id; setSelectedPaymentId(Number(newSelectedId)); if (onSelectPayment) { onSelectPayment(isSelected ? null : payment); } }; const handleToggleCheck = (paymentId) => { setCheckedPaymentIds((prev) => { const next = new Set(prev); if (next.has(paymentId)) { next.delete(paymentId); } else { next.add(paymentId); } return next; }); }; const queryKey = qkPaymentsRecent({ patientId: patientId ?? undefined, page: currentPage, }); const { data: paymentsData, isLoading, isError, } = useQuery({ queryKey, queryFn: async () => { const endpoint = patientId ? `/api/payments/patient/${patientId}?limit=${paymentsPerPage}&offset=${offset}` : `/api/payments/recent?limit=${paymentsPerPage}&offset=${offset}`; const res = await apiRequest("GET", endpoint); if (!res.ok) { const errorData = await res.json(); throw new Error(errorData.message || "Failed to fetch payments"); } return res.json(); }, placeholderData: { payments: [], totalCount: 0 }, }); const currentPageIds = (paymentsData?.payments ?? []).map((p) => p.id); const allOnPageChecked = currentPageIds.length > 0 && currentPageIds.every((id) => checkedPaymentIds.has(id)); const someOnPageChecked = !allOnPageChecked && currentPageIds.some((id) => checkedPaymentIds.has(id)); const handleToggleAll = () => { setCheckedPaymentIds((prev) => { const next = new Set(prev); if (allOnPageChecked) { currentPageIds.forEach((id) => next.delete(id)); } else { currentPageIds.forEach((id) => next.add(id)); } return next; }); }; const updatePaymentMutation = useMutation({ mutationFn: async (data) => { const response = await apiRequest("PUT", `/api/payments/${data.paymentId}`, { data: data, }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Failed to update Payment"); } return response.json(); }, onSuccess: async (updated, { paymentId }) => { toast({ title: "Success", description: "Payment updated successfully!", }); // 🔄 refresh this table page await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE, }); // Fetch updated payment and set into local state const refreshedPayment = await apiRequest("GET", `/api/payments/${paymentId}`).then((res) => res.json()); setCurrentPayment(refreshedPayment); // <-- keep modal in sync }, onError: (error) => { toast({ title: "Error", description: `Update failed: ${error.message}`, variant: "destructive", }); }, }); const updatePaymentStatusMutation = useMutation({ mutationFn: async ({ paymentId, status, }) => { const response = await apiRequest("PATCH", `/api/payments/${paymentId}/status`, { data: { status }, }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Failed to update payment status"); } return response.json(); }, onSuccess: async (updated, { paymentId }) => { toast({ title: "Success", description: "Payment Status updated successfully!", }); await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE, }); // Fetch updated payment and set into local state const refreshedPayment = await apiRequest("GET", `/api/payments/${paymentId}`).then((res) => res.json()); setCurrentPayment(refreshedPayment); // <-- keep modal in sync }, onError: (error) => { toast({ title: "Error", description: `Status update failed: ${error.message}`, variant: "destructive", }); }, }); const fullPaymentMutation = useMutation({ mutationFn: async ({ paymentId, type, }) => { const endpoint = type === "pay" ? `/api/payments/${paymentId}/pay-absolute-full-claim` : `/api/payments/${paymentId}/revert-full-claim`; const response = await apiRequest("PUT", endpoint); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Failed to update Payment"); } return response.json(); }, onSuccess: async () => { toast({ title: "Success", description: "Payment updated successfully!", }); await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE, }); }, onError: (error) => { toast({ title: "Error", description: `Operation failed: ${error.message}`, variant: "destructive", }); }, }); const handlePayAbsoluteFullDue = (paymentId) => { fullPaymentMutation.mutate({ paymentId, type: "pay" }); }; const handleRevert = () => { if (!revertPaymentId) return; fullPaymentMutation.mutate({ paymentId: revertPaymentId, type: "revert", }); setRevertPaymentId(null); setIsRevertOpen(false); }; const deletePaymentMutation = useMutation({ mutationFn: async (id) => { const res = await apiRequest("DELETE", `/api/payments/${id}`); return; }, onSuccess: async () => { setIsDeletePaymentOpen(false); await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE, }); toast({ title: "Deleted", description: "Payment deleted successfully", variant: "default", }); }, onError: (error) => { toast({ title: "Error", description: `Failed to delete payment: ${error.message}`, variant: "destructive", }); }, }); const handleEditPayment = (payment) => { setCurrentPayment(payment); setIsEditPaymentOpen(true); }; const handleDeletePayment = (payment) => { setCurrentPayment(payment); setIsDeletePaymentOpen(true); }; const handleConfirmDeletePayment = async () => { if (currentPayment) { if (typeof currentPayment.id === "number") { deletePaymentMutation.mutate(currentPayment.id); } else { toast({ title: "Error", description: "Selected Payment is missing an ID for deletion.", variant: "destructive", }); } } else { toast({ title: "Error", description: "No Payment selected for deletion.", variant: "destructive", }); } }; //VOID and UNVOID Feature const handleVoid = (paymentId) => { updatePaymentStatusMutation.mutate({ paymentId, status: "VOID" }); }; const handleUnvoid = (paymentId) => { updatePaymentStatusMutation.mutate({ paymentId, status: "PENDING" }); }; const [isVoidOpen, setIsVoidOpen] = useState(false); const [voidPaymentId, setVoidPaymentId] = useState(null); const [isUnvoidOpen, setIsUnvoidOpen] = useState(false); const [unvoidPaymentId, setUnvoidPaymentId] = useState(null); const [isPaidInFullOpen, setIsPaidInFullOpen] = useState(false); const [paidInFullPaymentId, setPaidInFullPaymentId] = useState(null); const [isRevertPaidOpen, setIsRevertPaidOpen] = useState(false); const [revertPaidPaymentId, setRevertPaidPaymentId] = useState(null); const handleConfirmVoid = () => { if (!voidPaymentId) return; handleVoid(voidPaymentId); setVoidPaymentId(null); setIsVoidOpen(false); }; const handleConfirmUnvoid = () => { if (!unvoidPaymentId) return; handleUnvoid(unvoidPaymentId); setUnvoidPaymentId(null); setIsUnvoidOpen(false); }; const handleConfirmPaidInFull = () => { if (!paidInFullPaymentId) return; updatePaymentStatusMutation.mutate({ paymentId: paidInFullPaymentId, status: "PAID" }); setPaidInFullPaymentId(null); setIsPaidInFullOpen(false); }; const handleConfirmRevertPaid = () => { if (!revertPaidPaymentId) return; updatePaymentStatusMutation.mutate({ paymentId: revertPaidPaymentId, status: "PENDING" }); setRevertPaidPaymentId(null); setIsRevertPaidOpen(false); }; // Pagination useEffect(() => { if (onPageChange) onPageChange(currentPage); }, [currentPage, onPageChange]); useEffect(() => { setCurrentPage(1); }, [patientId]); const totalPages = useMemo(() => Math.ceil((paymentsData?.totalCount || 0) / paymentsPerPage), [paymentsData?.totalCount, paymentsPerPage]); const startItem = offset + 1; const endItem = Math.min(offset + paymentsPerPage, paymentsData?.totalCount || 0); const getName = (p) => p.patient ? `${p.patient.firstName} ${p.patient.lastName}`.trim() : (p.patientName ?? "Unknown"); const getInitials = (fullName) => { const parts = fullName.trim().split(/\s+/); const filteredParts = parts.filter((part) => part.length > 0); if (filteredParts.length === 0) { return ""; } const firstInitial = filteredParts[0].charAt(0).toUpperCase(); if (filteredParts.length === 1) { return firstInitial; } else { const lastInitial = filteredParts[filteredParts.length - 1].charAt(0).toUpperCase(); return firstInitial + lastInitial; } }; const getAvatarColor = (id) => { const colorClasses = [ "bg-blue-500", "bg-teal-500", "bg-amber-500", "bg-rose-500", "bg-indigo-500", "bg-green-500", "bg-purple-500", ]; return colorClasses[id % colorClasses.length]; }; const getStatusInfo = (status) => { switch (status) { case "PENDING": return { label: "Pending", color: "bg-red-100 text-red-800", icon: , }; case "PARTIALLY_PAID": return { label: "Partially Paid", color: "bg-blue-100 text-blue-800", icon: , }; case "PAID": return { label: "Paid in Full", color: "bg-teal-100 text-teal-800", icon: , }; case "OVERPAID": return { label: "Overpaid", color: "bg-purple-100 text-purple-800", icon: , }; case "DENIED": return { label: "Denied", color: "bg-red-100 text-red-800", icon: , }; case "VOID": return { label: "Void", color: "bg-gray-100 text-gray-800", icon: , }; default: return { label: status ? status.charAt(0).toUpperCase() + status.slice(1).toLowerCase() : "Unknown", color: "bg-gray-100 text-gray-800", icon: , }; } }; return (
{/* Check MH Payment action bar */} {allowCheckbox && checkedPaymentIds.size > 0 && (
{checkedPaymentIds.size} record{checkedPaymentIds.size > 1 ? "s" : ""} selected
)}
{allowCheckbox && ( )} Claim No. Patient Name Amount Service Date Status Attachments Provider MH Paid Copayment Adjustment Actions Payment ID Claim ID {isLoading ? ( ) : isError ? ( Error loading payments. ) : (paymentsData?.payments?.length ?? 0) === 0 ? ( No payments found ) : (paymentsData?.payments.map((payment) => { const totalBilled = Number(payment.totalBilled || 0); const totalPaid = Number(payment.totalPaid || 0); const mhPaid = Number(payment.mhPaidAmount || 0); const copayment = Number(payment.copayment || 0); const adjustment = Number(payment.adjustment || 0); const totalDue = Math.max(0, totalBilled - mhPaid - copayment - adjustment); const totalCollected = mhPaid + copayment; const displayName = getName(payment); const submittedOn = payment.serviceLines?.[0]?.procedureDate ?? payment.claim?.createdAt ?? payment.createdAt ?? payment.serviceLineTransactions?.[0]?.receivedDate ?? null; return ( {allowCheckbox && ( handleToggleCheck(payment.id)} aria-label={`Select payment ${payment.id}`}/> )} {payment.claim?.claimNumber ? ({payment.claim.claimNumber}) : payment.notes?.startsWith("PDF import") ? (PDF Import) : (—)}
{getInitials(displayName)}
{displayName}
PID-{payment.patientId?.toString().padStart(4, "0")}
{/* 💰 Billed / Collected / Due breakdown */}
Total Billed: ${totalBilled.toFixed(2)} Collected: ${totalCollected.toFixed(2)} {adjustment > 0 && ( Adjustment:{" "} -${adjustment.toFixed(2)} )} Balance:{" "} {totalDue > 0 ? (${totalDue.toFixed(2)}) : (Settled)}
{formatDateToHumanReadable(submittedOn)}
{payment.status === "VOID" ? ( Void ) : payment.status === "PAID" ? ( Paid in Full ) : ( Balance )} {payment.commissionBatchItems?.length > 0 && ( ✓ Commissioned )}
{payment.claim?.claimFiles && payment.claim.claimFiles.length > 0 ? (
    {payment.claim.claimFiles.map((f) => (
  • {f.filename}
  • ))}
) : (—)}
{payment.npiProvider?.providerName ?? "—"}
{editingMhPaidId === payment.id ? ( setEditingMhPaidValue(e.target.value)} onKeyDown={async (e) => { if (e.key === "Enter") { e.currentTarget.blur(); } else if (e.key === "Escape") { setEditingMhPaidId(null); } }} onBlur={async () => { const val = parseFloat(editingMhPaidValue); if (!isNaN(val) && val >= 0) { try { const res = await apiRequest("PATCH", `/api/payments/${payment.id}/mh-paid-amount`, { mhPaidAmount: val }); if (res.ok) { await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE }); } else { toast({ title: "Error", description: "Failed to save MH paid amount.", variant: "destructive" }); } } catch { toast({ title: "Error", description: "Failed to save MH paid amount.", variant: "destructive" }); } } setEditingMhPaidId(null); }}/>) : ( { setEditingMhPaidId(payment.id); setEditingMhPaidValue(payment.mhPaidAmount != null ? Number(payment.mhPaidAmount).toFixed(2) : "0.00"); }}> {payment.mhPaidAmount != null ? `$${Number(payment.mhPaidAmount).toFixed(2)}` : —} )} {/* Copayment */} {editingCopaymentId === payment.id ? ( setEditingCopaymentValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); else if (e.key === "Escape") setEditingCopaymentId(null); }} onBlur={async () => { const val = parseFloat(editingCopaymentValue); if (!isNaN(val) && val >= 0) { try { const res = await apiRequest("PATCH", `/api/payments/${payment.id}/copayment`, { copayment: val }); if (res.ok) { await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE }); } else { toast({ title: "Error", description: "Failed to save copayment.", variant: "destructive" }); } } catch { toast({ title: "Error", description: "Failed to save copayment.", variant: "destructive" }); } } setEditingCopaymentId(null); }}/>) : ( { setEditingCopaymentId(payment.id); setEditingCopaymentValue(Number(payment.copayment ?? 0).toFixed(2)); }}> ${Number(payment.copayment ?? 0).toFixed(2)} )} {/* Adjustment — auto-computed: totalBilled - mhPaid - copayment */} ${adjustment.toFixed(2)}
{allowDelete && ()} {allowEdit && ()} {/* Paid in Full — only when not already paid or voided */} {payment.status !== "PAID" && payment.status !== "VOID" && payment.status !== "DENIED" && ()} {/* Revert — only when already Paid in Full */} {payment.status === "PAID" && ()} {/* Show Void unless already voided or denied */} {payment.status !== "VOID" && payment.status !== "DENIED" && ()} {/* When VOID → Unvoid */} {payment.status === "VOID" && ()}
{typeof payment.id === "number" ? `PAY-${payment.id.toString().padStart(4, "0")}` : "N/A"} {typeof payment.claimId === "number" ? `CLM-${payment.claimId.toString().padStart(4, "0")}` : "N/A"}
); }))}
{/* Revert Confirmation Dialog */} setIsRevertOpen(false)}/> {/* Revert Paid in Full Confirmation Dialog */} setIsRevertPaidOpen(false)}/> {/* Paid in Full Confirmation Dialog */} setIsPaidInFullOpen(false)}/> {/* NEW: Void Confirmation Dialog */} setIsVoidOpen(false)}/> {/* NEW: Unvoid Confirmation Dialog */} setIsUnvoidOpen(false)}/> setIsDeletePaymentOpen(false)} entityName={`PaymentID : ${currentPayment?.id}`}/> {isEditPaymentOpen && currentPayment && ( setIsEditPaymentOpen(open)} onClose={() => setIsEditPaymentOpen(false)} payment={currentPayment} onEditServiceLine={(updatedPayment) => { updatePaymentMutation.mutate(updatedPayment); }} isUpdatingServiceLine={updatePaymentMutation.isPending} onUpdateStatus={(paymentId, status) => { updatePaymentStatusMutation.mutate({ paymentId, status }); }} isUpdatingStatus={updatePaymentStatusMutation.isPending}/>)} {/* Pagination */} {totalPages > 1 && (
Showing {startItem}–{endItem} of {paymentsData?.totalCount || 0}{" "} results
{ e.preventDefault(); if (currentPage > 1) setCurrentPage(currentPage - 1); }} className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}/> {getPageNumbers(currentPage, totalPages).map((page, idx) => ( {page === "..." ? (...) : ( { e.preventDefault(); setCurrentPage(page); }} isActive={currentPage === page}> {page} )} ))} { e.preventDefault(); if (currentPage < totalPages) setCurrentPage(currentPage + 1); }} className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}/>
)}
); }