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, Eye, Delete, Clock, CheckCircle, AlertCircle, TrendingUp, ThumbsDown, DollarSign, Ban, } 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 { NewTransactionPayload, PaymentStatus, PaymentWithExtras, } from "@repo/db/types"; import EditPaymentModal from "./payment-edit-modal"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { ConfirmationDialog } from "../ui/confirmationDialog"; import { getPageNumbers } from "@/utils/pageNumberGenerator"; interface PaymentApiResponse { payments: PaymentWithExtras[]; totalCount: number; } interface PaymentsRecentTableProps { allowEdit?: boolean; allowDelete?: boolean; allowCheckbox?: boolean; onSelectPayment?: (payment: PaymentWithExtras | null) => void; onPageChange?: (page: number) => void; patientId?: number; } // 🔑 exported base key (so others can invalidate all pages/filters) export const QK_PAYMENTS_RECENT_BASE = ["payments-recent"] as const; // 🔑 exported helper for specific pages/scopes export const qkPaymentsRecent = (opts: { patientId?: number | null; page: number; }) => opts.patientId ? ([ ...QK_PAYMENTS_RECENT_BASE, "patient", opts.patientId, opts.page, ] as const) : ([...QK_PAYMENTS_RECENT_BASE, "global", opts.page] as const); export default function PaymentsRecentTable({ allowEdit, allowDelete, allowCheckbox, onSelectPayment, onPageChange, patientId, }: PaymentsRecentTableProps) { 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< PaymentWithExtras | undefined >(undefined); const [selectedPaymentId, setSelectedPaymentId] = useState( null ); const [isRevertOpen, setIsRevertOpen] = useState(false); const [revertPaymentId, setRevertPaymentId] = useState(null); const handleSelectPayment = (payment: PaymentWithExtras) => { const isSelected = selectedPaymentId === payment.id; const newSelectedId = isSelected ? null : payment.id; setSelectedPaymentId(Number(newSelectedId)); if (onSelectPayment) { onSelectPayment(isSelected ? null : payment); } }; 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 updatePaymentMutation = useMutation({ mutationFn: async (data: NewTransactionPayload) => { 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, }: { paymentId: number; status: PaymentStatus; }) => { 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, }: { paymentId: number; type: "pay" | "revert"; }) => { 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: any) => { toast({ title: "Error", description: `Operation failed: ${error.message}`, variant: "destructive", }); }, }); const handlePayAbsoluteFullDue = (paymentId: number) => { 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: number) => { 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: PaymentWithExtras) => { setCurrentPayment(payment); setIsEditPaymentOpen(true); }; const handleDeletePayment = (payment: PaymentWithExtras) => { 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: number) => { updatePaymentStatusMutation.mutate({ paymentId, status: "VOID" }); }; const handleUnvoid = (paymentId: number) => { 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 handleConfirmVoid = () => { if (!voidPaymentId) return; handleVoid(voidPaymentId); setVoidPaymentId(null); setIsVoidOpen(false); }; const handleConfirmUnvoid = () => { if (!unvoidPaymentId) return; handleUnvoid(unvoidPaymentId); setUnvoidPaymentId(null); setIsUnvoidOpen(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: PaymentWithExtras) => p.patient ? `${p.patient.firstName} ${p.patient.lastName}`.trim() : (p.patientName ?? "Unknown"); const getInitials = (fullName: string) => { 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: number) => { 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?: PaymentStatus) => { switch (status) { case "PENDING": return { label: "Pending", color: "bg-yellow-100 text-yellow-800", icon: , }; case "PARTIALLY_PAID": return { label: "Partially Paid", color: "bg-blue-100 text-blue-800", icon: , }; case "PAID": return { label: "Paid", color: "bg-green-100 text-green-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 as string).charAt(0).toUpperCase() + (status as string).slice(1).toLowerCase() : "Unknown", color: "bg-gray-100 text-gray-800", icon: , }; } }; return (
{allowCheckbox && Select} Payment ID Claim ID Patient Name Amount Service Date Status Actions {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 totalDue = Number(payment.totalDue || 0); const displayName = getName(payment); const submittedOn = payment.serviceLines?.[0]?.procedureDate ?? payment.claim?.createdAt ?? payment.createdAt ?? payment.serviceLineTransactions?.[0]?.receivedDate ?? null; return ( {allowCheckbox && ( handleSelectPayment(payment)} /> )} {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"}
{getInitials(displayName)}
{displayName}
PID-{payment.patientId?.toString().padStart(4, "0")}
{/* 💰 Billed / Paid / Due breakdown */}
Total Billed: $ {Number(totalBilled).toFixed(2)} Total Paid: ${totalPaid.toFixed(2)} Total Due:{" "} {totalDue > 0 ? ( ${totalDue.toFixed(2)} ) : ( Settled )}
{formatDateToHumanReadable(submittedOn)}
{(() => { const { label, color, icon } = getStatusInfo( payment.status ); return ( {icon} {label} ); })()}
{allowDelete && ( )} {allowEdit && ( )} {/* When NOT PAID and NOT VOID → Pay in Full + Void */} {payment.status !== "PAID" && payment.status !== "VOID" && ( <> {/* NEW: Void */} )} {/* When PAID → Revert */} {payment.status === "PAID" && ( )} {/* When VOID → Unvoid */} {payment.status === "VOID" && ( )}
); }) )}
{/* Revert Confirmation Dialog */} setIsRevertOpen(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 as number); }} isActive={currentPage === page} > {page} )} ))} { e.preventDefault(); if (currentPage < totalPages) setCurrentPage(currentPage + 1); }} className={ currentPage === totalPages ? "pointer-events-none opacity-50" : "" } />
)}
); }