Files
DentalManagementMH06/apps/Frontend/src/components/payments/payments-recent-table.tsx
Gitead b7e06adf2f feat: MassHealth PDF import auto-pays full balance + patient name fix
- PDF import now marks payments as PAID when MassHealth patient's
  mhPaidAmount >= totalBilled (no patient balance)
- Newly created patients from MH vouchers get insuranceProvider = 'MassHealth'
- Existing patients with blank insuranceProvider get it filled on import
- Fix: update patient name from PDF if existing record has empty name
- Various frontend/selenium/route updates

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

1158 lines
42 KiB
TypeScript
Executable File
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,
Eye,
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 {
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<number | null>(null);
const [checkedPaymentIds, setCheckedPaymentIds] = useState<Set<number>>(new Set());
const [isMhChecking, setIsMhChecking] = useState(false);
const [editingMhPaidId, setEditingMhPaidId] = useState<number | null>(null);
const [editingMhPaidValue, setEditingMhPaidValue] = useState<string>("");
const [editingCopaymentId, setEditingCopaymentId] = useState<number | null>(null);
const [editingCopaymentValue, setEditingCopaymentValue] = useState<string>("");
const [isRevertOpen, setIsRevertOpen] = useState(false);
const [revertPaymentId, setRevertPaymentId] = useState<number | null>(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 handleToggleCheck = (paymentId: number) => {
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<PaymentApiResponse>({
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: 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<number | null>(null);
const [isUnvoidOpen, setIsUnvoidOpen] = useState(false);
const [unvoidPaymentId, setUnvoidPaymentId] = useState<number | null>(null);
const [isPaidInFullOpen, setIsPaidInFullOpen] = useState(false);
const [paidInFullPaymentId, setPaidInFullPaymentId] = useState<number | null>(null);
const [isRevertPaidOpen, setIsRevertPaidOpen] = useState(false);
const [revertPaidPaymentId, setRevertPaidPaymentId] = useState<number | null>(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: 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-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 as string).charAt(0).toUpperCase() +
(status as string).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 as any).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 as any).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: { id?: number; filename: string }) => (
<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 as any).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 as number);
}}
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>
);
}