Files
DentalManagementMH07/apps/Frontend/src/components/payments/payments-recent-table.jsx
Gitead 1edf73fdc8 feat: add new frontend components, MH batch worker, and gitignore rules
- Add all new Frontend source files (pages, components, hooks, utils)
- Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py
- Add install-steps-5-13.sh setup script
- Update .gitignore to exclude runtime/sensitive data (backups, uploads,
  chat-history, keys, downloads, generated .d.ts files) while keeping folders
- Add .gitkeep to preserve empty runtime folders in git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:23:43 -04:00

777 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: <Clock className="h-3 w-3 mr-1"/>,
};
case "PARTIALLY_PAID":
return {
label: "Partially Paid",
color: "bg-blue-100 text-blue-800",
icon: <DollarSign className="h-3 w-3 mr-1"/>,
};
case "PAID":
return {
label: "Paid in Full",
color: "bg-teal-100 text-teal-800",
icon: <CheckCircle className="h-3 w-3 mr-1"/>,
};
case "OVERPAID":
return {
label: "Overpaid",
color: "bg-purple-100 text-purple-800",
icon: <TrendingUp className="h-3 w-3 mr-1"/>,
};
case "DENIED":
return {
label: "Denied",
color: "bg-red-100 text-red-800",
icon: <ThumbsDown className="h-3 w-3 mr-1"/>,
};
case "VOID":
return {
label: "Void",
color: "bg-gray-100 text-gray-800",
icon: <Ban className="h-3 w-3 mr-1"/>,
};
default:
return {
label: status
? status.charAt(0).toUpperCase() +
status.slice(1).toLowerCase()
: "Unknown",
color: "bg-gray-100 text-gray-800",
icon: <AlertCircle className="h-3 w-3 mr-1"/>,
};
}
};
return (<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Check MH Payment action bar */}
{allowCheckbox && checkedPaymentIds.size > 0 && (<div className="flex items-center gap-3 px-4 py-2 bg-blue-50 border-b border-blue-200">
<span className="text-sm text-blue-700 font-medium">
{checkedPaymentIds.size} record{checkedPaymentIds.size > 1 ? "s" : ""} selected
</span>
<Button size="sm" variant="default" disabled={isMhChecking} onClick={async () => {
setIsMhChecking(true);
let successCount = 0;
let failCount = 0;
for (const paymentId of checkedPaymentIds) {
try {
const res = await apiRequest("PATCH", `/api/payments/${paymentId}/mh-payment-check`);
if (res.ok) {
successCount++;
}
else {
const err = await res.json();
console.error(`MH check failed for payment ${paymentId}:`, err.message);
failCount++;
}
}
catch (e) {
console.error(`MH check error for payment ${paymentId}:`, e);
failCount++;
}
}
setIsMhChecking(false);
setCheckedPaymentIds(new Set());
await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE });
if (failCount === 0) {
toast({ title: "MH Payment Check Complete", description: `${successCount} record(s) updated.` });
}
else {
toast({
title: "MH Payment Check Done",
description: `${successCount} succeeded, ${failCount} failed. Check credentials or claim numbers.`,
variant: "destructive",
});
}
}}>
{isMhChecking ? "Checking..." : "Check Single MH Payment"}
</Button>
<Button size="sm" variant="ghost" className="text-blue-600" onClick={() => setCheckedPaymentIds(new Set())}>
Clear
</Button>
</div>)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{allowCheckbox && (<TableHead className="w-10">
<Checkbox checked={allOnPageChecked} data-state={someOnPageChecked ? "indeterminate" : undefined} onCheckedChange={handleToggleAll} aria-label="Select all on page"/>
</TableHead>)}
<TableHead>Claim No.</TableHead>
<TableHead>Patient Name</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Service Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Attachments</TableHead>
<TableHead>Provider</TableHead>
<TableHead>MH Paid</TableHead>
<TableHead>Copayment</TableHead>
<TableHead>Adjustment</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>Payment ID</TableHead>
<TableHead>Claim ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<LoadingScreen />
</TableCell>
</TableRow>) : isError ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-red-500">
Error loading payments.
</TableCell>
</TableRow>) : (paymentsData?.payments?.length ?? 0) === 0 ? (<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
No payments found
</TableCell>
</TableRow>) : (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 (<TableRow key={payment.id}>
{allowCheckbox && (<TableCell className="w-10">
<Checkbox checked={checkedPaymentIds.has(payment.id)} onCheckedChange={() => handleToggleCheck(payment.id)} aria-label={`Select payment ${payment.id}`}/>
</TableCell>)}
<TableCell>
{payment.claim?.claimNumber ? (<span className="text-sm font-mono">{payment.claim.claimNumber}</span>) : payment.notes?.startsWith("PDF import") ? (<span className="text-xs font-medium bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">PDF Import</span>) : (<span className="text-gray-400"></span>)}
</TableCell>
<TableCell>
<div className="flex items-center">
<Avatar className={`h-10 w-10 ${getAvatarColor(Number(payment.id))}`}>
<AvatarFallback className="text-white">
{getInitials(displayName)}
</AvatarFallback>
</Avatar>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{displayName}
</div>
<div className="text-sm text-gray-500">
PID-{payment.patientId?.toString().padStart(4, "0")}
</div>
</div>
</div>
</TableCell>
{/* 💰 Billed / Collected / Due breakdown */}
<TableCell>
<div className="flex flex-col gap-1">
<span>
<strong>Total Billed:</strong> ${totalBilled.toFixed(2)}
</span>
<span>
<strong>Collected:</strong> ${totalCollected.toFixed(2)}
</span>
{adjustment > 0 && (<span>
<strong>Adjustment:</strong>{" "}
<span className="text-orange-600">-${adjustment.toFixed(2)}</span>
</span>)}
<span>
<strong>Balance:</strong>{" "}
{totalDue > 0 ? (<span className="text-yellow-600">${totalDue.toFixed(2)}</span>) : (<span className="text-green-600">Settled</span>)}
</span>
</div>
</TableCell>
<TableCell>
{formatDateToHumanReadable(submittedOn)}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{payment.status === "VOID" ? (<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 flex items-center w-fit">
<Ban className="h-3 w-3 mr-1"/>Void
</span>) : payment.status === "PAID" ? (<span className="px-2 py-1 text-xs font-medium rounded-full bg-teal-100 text-teal-800 flex items-center w-fit">
<CheckCircle className="h-3 w-3 mr-1"/>Paid in Full
</span>) : (<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 flex items-center w-fit">
<Clock className="h-3 w-3 mr-1"/>Balance
</span>)}
{payment.commissionBatchItems?.length > 0 && (<span className="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 text-purple-800 w-fit" title={`Commissioned on ${new Date(payment.commissionBatchItems[0].commissionBatch.createdAt).toLocaleDateString()}`}>
Commissioned
</span>)}
</div>
</TableCell>
<TableCell>
{payment.claim?.claimFiles && payment.claim.claimFiles.length > 0 ? (<ul className="space-y-1">
{payment.claim.claimFiles.map((f) => (<li key={f.id ?? f.filename} className="flex items-center gap-1 text-xs text-gray-700">
<Paperclip className="h-3 w-3 text-gray-400 shrink-0"/>
<span className="truncate max-w-[140px]" title={f.filename}>
{f.filename}
</span>
</li>))}
</ul>) : (<span className="text-xs text-gray-400"></span>)}
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{payment.npiProvider?.providerName ?? "—"}
</div>
</TableCell>
<TableCell>
{editingMhPaidId === payment.id ? (<input type="number" min="0" step="0.01" autoFocus className="w-24 border border-blue-400 rounded px-1 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" value={editingMhPaidValue} onChange={(e) => 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);
}}/>) : (<span className="text-sm font-medium text-green-700 cursor-pointer hover:underline hover:text-green-900" title="Click to edit" onClick={() => {
setEditingMhPaidId(payment.id);
setEditingMhPaidValue(payment.mhPaidAmount != null
? Number(payment.mhPaidAmount).toFixed(2)
: "0.00");
}}>
{payment.mhPaidAmount != null
? `$${Number(payment.mhPaidAmount).toFixed(2)}`
: <span className="text-gray-400 font-normal"></span>}
</span>)}
</TableCell>
{/* Copayment */}
<TableCell>
{editingCopaymentId === payment.id ? (<input type="number" min="0" step="0.01" autoFocus className="w-24 border border-blue-400 rounded px-1 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" value={editingCopaymentValue} onChange={(e) => 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);
}}/>) : (<span className="text-sm font-medium text-blue-700 cursor-pointer hover:underline hover:text-blue-900" title="Click to edit" onClick={() => {
setEditingCopaymentId(payment.id);
setEditingCopaymentValue(Number(payment.copayment ?? 0).toFixed(2));
}}>
${Number(payment.copayment ?? 0).toFixed(2)}
</span>)}
</TableCell>
{/* Adjustment — auto-computed: totalBilled - mhPaid - copayment */}
<TableCell>
<span className="text-sm font-medium text-orange-700">
${adjustment.toFixed(2)}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{allowDelete && (<Button onClick={() => {
handleDeletePayment(payment);
}} className="text-red-600 hover:text-red-900" aria-label="Delete Payment" variant="ghost" size="icon">
<Delete />
</Button>)}
{allowEdit && (<Button variant="ghost" size="icon" onClick={() => {
handleEditPayment(payment);
}} className="text-blue-600 hover:text-blue-800 hover:bg-blue-50">
<Edit className="h-4 w-4"/>
</Button>)}
{/* Paid in Full — only when not already paid or voided */}
{payment.status !== "PAID" &&
payment.status !== "VOID" &&
payment.status !== "DENIED" && (<Button variant="default" size="sm" className="bg-teal-600 hover:bg-teal-700 text-white" onClick={() => {
setPaidInFullPaymentId(payment.id);
setIsPaidInFullOpen(true);
}}>
Paid in Full
</Button>)}
{/* Revert — only when already Paid in Full */}
{payment.status === "PAID" && (<Button variant="outline" size="sm" className="border-teal-400 text-teal-700 hover:bg-teal-50" onClick={() => {
setRevertPaidPaymentId(payment.id);
setIsRevertPaidOpen(true);
}}>
Revert
</Button>)}
{/* Show Void unless already voided or denied */}
{payment.status !== "VOID" &&
payment.status !== "DENIED" && (<Button variant="outline" size="sm" onClick={() => {
setVoidPaymentId(payment.id);
setIsVoidOpen(true);
}}>
Void
</Button>)}
{/* When VOID → Unvoid */}
{payment.status === "VOID" && (<Button variant="outline" size="sm" onClick={() => {
setUnvoidPaymentId(payment.id);
setIsUnvoidOpen(true);
}}>
Unvoid
</Button>)}
</div>
</TableCell>
<TableCell className="text-xs text-gray-500">
{typeof payment.id === "number"
? `PAY-${payment.id.toString().padStart(4, "0")}`
: "N/A"}
</TableCell>
<TableCell className="text-xs text-gray-500">
{typeof payment.claimId === "number"
? `CLM-${payment.claimId.toString().padStart(4, "0")}`
: "N/A"}
</TableCell>
</TableRow>);
}))}
</TableBody>
</Table>
</div>
{/* Revert Confirmation Dialog */}
<ConfirmationDialog isOpen={isRevertOpen} title="Confirm Revert" message={`Do you want to revert all Service Line payments for Payment ID: ${revertPaymentId}?`} confirmLabel="Revert" confirmColor="bg-yellow-600 hover:bg-yellow-700" onConfirm={handleRevert} onCancel={() => setIsRevertOpen(false)}/>
{/* Revert Paid in Full Confirmation Dialog */}
<ConfirmationDialog isOpen={isRevertPaidOpen} title="Revert Paid in Full?" message="This will revert the status back to Balance. The amounts stay unchanged. Continue?" confirmLabel="Revert" confirmColor="bg-yellow-600 hover:bg-yellow-700" onConfirm={handleConfirmRevertPaid} onCancel={() => setIsRevertPaidOpen(false)}/>
{/* Paid in Full Confirmation Dialog */}
<ConfirmationDialog isOpen={isPaidInFullOpen} title="Mark as Paid in Full?" message="This will set the status to Paid in Full and close the balance for this payment. Continue?" confirmLabel="Paid in Full" confirmColor="bg-teal-600 hover:bg-teal-700" onConfirm={handleConfirmPaidInFull} onCancel={() => setIsPaidInFullOpen(false)}/>
{/* NEW: Void Confirmation Dialog */}
<ConfirmationDialog isOpen={isVoidOpen} title="Confirm Void" message={`Mark this payment as VOID? It will be excluded from balances and Calculations.`} confirmLabel="Void" confirmColor="bg-gray-700 hover:bg-gray-800" onConfirm={handleConfirmVoid} onCancel={() => setIsVoidOpen(false)}/>
{/* NEW: Unvoid Confirmation Dialog */}
<ConfirmationDialog isOpen={isUnvoidOpen} title="Confirm Unvoid" message={`Restore this payment to a normal state (PENDING)?`} confirmLabel="Unvoid" confirmColor="bg-blue-600 hover:bg-blue-700" onConfirm={handleConfirmUnvoid} onCancel={() => setIsUnvoidOpen(false)}/>
<DeleteConfirmationDialog isOpen={isDeletePaymentOpen} onConfirm={handleConfirmDeletePayment} onCancel={() => setIsDeletePaymentOpen(false)} entityName={`PaymentID : ${currentPayment?.id}`}/>
{isEditPaymentOpen && currentPayment && (<EditPaymentModal isOpen={isEditPaymentOpen} onOpenChange={(open) => 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 && (<div className="bg-white px-4 py-3 border-t border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
Showing {startItem}{endItem} of {paymentsData?.totalCount || 0}{" "}
results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (<PaginationItem key={idx}>
{page === "..." ? (<span className="px-2 text-gray-500">...</span>) : (<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page}>
{page}
</PaginationLink>)}
</PaginationItem>))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>)}
</div>);
}