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>
This commit is contained in:
2026-06-26 00:23:43 -04:00
co-authored by Claude Sonnet 4.6
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions
@@ -0,0 +1,811 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { formatDateToHumanReadable, formatLocalDate, parseLocalDate, } from "@/utils/dateUtils";
import React, { useEffect, useState } from "react";
import { paymentMethodOptions, paymentStatusArray, paymentMethodArray, } from "@repo/db/types";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { toast } from "@/hooks/use-toast";
import { X } from "lucide-react";
import { DateInput } from "@/components/ui/dateInput";
import { apiRequest } from "@/lib/queryClient";
import { useMutation, useQuery } from "@tanstack/react-query";
export default function PaymentEditModal({ isOpen, onOpenChange, onClose, onEditServiceLine, isUpdatingServiceLine: propUpdatingServiceLine, onUpdateStatus, isUpdatingStatus: propUpdatingStatus, payment: paymentProp, paymentId: paymentIdProp, }) {
// Local payment state: prefer prop but fetch if paymentId is provided
const [payment, setPayment] = useState(paymentProp ?? null);
const [loadingPayment, setLoadingPayment] = useState(false);
// Local update states (used if parent didn't provide flags)
const [localUpdatingServiceLine, setLocalUpdatingServiceLine] = useState(false);
const [localUpdatingStatus, setLocalUpdatingStatus] = useState(false);
// derived flags - prefer parent's flags if provided
const isUpdatingServiceLine = propUpdatingServiceLine ?? localUpdatingServiceLine;
const isUpdatingStatus = propUpdatingStatus ?? localUpdatingStatus;
// UI state (kept from your original)
const [expandedLineId, setExpandedLineId] = useState(null);
const [paymentStatus, setPaymentStatus] = useState((paymentProp ?? null)?.status ?? "PENDING");
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState((paymentProp ?? null)?.npiProviderId ?? null);
const [isUpdatingProvider, setIsUpdatingProvider] = useState(false);
// Fetch all NPI providers
const { data: npiProviders = [] } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
return res.json();
},
});
// Default to Mary Scannell (or first provider) if no provider set yet
useEffect(() => {
if (!npiProviders.length)
return;
if (selectedNpiProviderId !== null)
return;
const mary = npiProviders.find((p) => p.providerName.toLowerCase().includes("mary scannell"));
const fallback = mary ?? npiProviders[0];
if (fallback)
setSelectedNpiProviderId(fallback.id);
}, [npiProviders]);
// Sync provider when payment changes (e.g. fetched from API)
useEffect(() => {
if (payment?.npiProviderId !== undefined) {
setSelectedNpiProviderId(payment.npiProviderId ?? null);
}
}, [payment?.id]);
const handleUpdateProvider = async () => {
if (!payment)
return;
setIsUpdatingProvider(true);
try {
const res = await apiRequest("PATCH", `/api/payments/${payment.id}/provider`, {
npiProviderId: selectedNpiProviderId,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || "Failed to update provider");
}
toast({ title: "Success", description: "Provider updated successfully." });
await refetchPayment(payment.id);
}
catch (err) {
toast({ title: "Error", description: err?.message ?? "Failed to update provider.", variant: "destructive" });
}
finally {
setIsUpdatingProvider(false);
}
};
const [formState, setFormState] = useState(() => {
return {
serviceLineId: 0,
transactionId: "",
paidAmount: 0,
adjustedAmount: 0,
method: paymentMethodOptions.CHECK,
receivedDate: formatLocalDate(new Date()),
payerName: "",
notes: "",
};
});
// Sync when parent passes a payment object or paymentId changes
useEffect(() => {
// if parent gave a full payment object, use it immediately
if (paymentProp) {
setPayment(paymentProp);
setPaymentStatus(paymentProp.status);
}
}, [paymentProp]);
// Fetch payment when modal opens and we only have paymentId (or payment prop not supplied)
useEffect(() => {
if (!isOpen)
return;
// if payment prop is already available, no need to fetch
if (paymentProp)
return;
const id = paymentIdProp ?? payment?.id;
if (!id)
return;
let cancelled = false;
(async () => {
setLoadingPayment(true);
try {
const res = await apiRequest("GET", `/api/payments/${id}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message ?? `Failed to fetch payment ${id}`);
}
const data = await res.json();
if (!cancelled) {
setPayment(data);
setPaymentStatus(data.status);
}
}
catch (err) {
console.error("Failed to fetch payment:", err);
toast({
title: "Error",
description: err?.message ?? "Failed to load payment.",
variant: "destructive",
});
}
finally {
if (!cancelled)
setLoadingPayment(false);
}
})();
return () => {
cancelled = true;
};
}, [isOpen, paymentIdProp]);
// convenience: get service lines from claim or payment
const serviceLines = payment?.claim?.serviceLines ?? payment?.serviceLines ?? [];
// small helper to refresh current payment (used after internal writes)
async function refetchPayment(id) {
const pid = id ?? payment?.id ?? paymentIdProp;
if (!pid)
return;
setLoadingPayment(true);
try {
const res = await apiRequest("GET", `/api/payments/${pid}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message ?? `Failed to fetch payment ${pid}`);
}
const data = await res.json();
setPayment(data);
setPaymentStatus(data.status);
}
catch (err) {
console.error("Failed to refetch payment:", err);
toast({
title: "Error",
description: err?.message ?? "Failed to refresh payment.",
variant: "destructive",
});
}
finally {
setLoadingPayment(false);
}
}
// Internal save (fallback) — used only when parent didn't provide onEditServiceLine
const internalUpdatePaymentMutation = 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!",
});
await refetchPayment(paymentId);
},
onError: (error) => {
toast({
title: "Error",
description: `Update failed: ${error.message}`,
variant: "destructive",
});
},
});
const internalUpdatePaymentStatusMutation = 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!",
});
// Fetch updated payment and set into local state
await refetchPayment(paymentId);
},
onError: (error) => {
toast({
title: "Error",
description: `Status update failed: ${error.message}`,
variant: "destructive",
});
},
});
// Keep your existing handlers but route to either parent callback or internal functions
const handleEditServiceLine = async (payload) => {
if (onEditServiceLine) {
await onEditServiceLine(payload);
}
else {
// fallback to internal API call
setLocalUpdatingServiceLine(true);
await internalUpdatePaymentMutation.mutateAsync(payload);
setLocalUpdatingServiceLine(false);
}
};
const handleUpdateStatus = async (paymentId, status) => {
if (onUpdateStatus) {
await onUpdateStatus(paymentId, status);
}
else {
setLocalUpdatingStatus(true);
await internalUpdatePaymentStatusMutation.mutateAsync({
paymentId,
status,
});
setLocalUpdatingStatus(false);
}
};
const handleEditServiceLineClick = (lineId) => {
if (expandedLineId === lineId) {
// Closing current line
setExpandedLineId(null);
return;
}
// Find line data
const line = serviceLines.find((sl) => sl.id === lineId);
if (!line)
return;
// updating form to show its data, while expanding.
setFormState({
serviceLineId: line.id,
transactionId: "",
paidAmount: Number(line.totalDue) > 0 ? Number(line.totalDue) : 0,
adjustedAmount: 0,
method: paymentMethodOptions.CHECK,
receivedDate: formatLocalDate(new Date()),
payerName: "",
notes: "",
});
setExpandedLineId(lineId);
};
const updateField = (field, value) => {
setFormState((prev) => ({
...prev,
[field]: value,
}));
};
const handleSavePayment = async () => {
if (!payment) {
toast({
title: "Error",
description: "Payment not loaded.",
variant: "destructive",
});
return;
}
if (!formState.serviceLineId) {
toast({
title: "Error",
description: "No service line selected.",
variant: "destructive",
});
return;
}
const paidAmount = Number(formState.paidAmount) || 0;
const adjustedAmount = Number(formState.adjustedAmount) || 0;
if (paidAmount < 0 || adjustedAmount < 0) {
toast({
title: "Invalid Amount",
description: "Amounts cannot be negative.",
variant: "destructive",
});
return;
}
if (paidAmount === 0 && adjustedAmount === 0) {
toast({
title: "Invalid Amount",
description: "Either paid or adjusted amount must be greater than zero.",
variant: "destructive",
});
return;
}
const line = serviceLines.find((sl) => sl.id === formState.serviceLineId);
if (!line) {
toast({
title: "Error",
description: "Selected service line not found.",
variant: "destructive",
});
return;
}
const dueAmount = Number(line.totalDue);
if (paidAmount > dueAmount) {
toast({
title: "Invalid Payment",
description: `Paid amount ($${paidAmount.toFixed(2)}) cannot exceed due amount ($${dueAmount.toFixed(2)}).`,
variant: "destructive",
});
return;
}
const payload = {
paymentId: payment.id,
serviceLineTransactions: [
{
serviceLineId: formState.serviceLineId,
transactionId: formState.transactionId || undefined,
paidAmount: Number(formState.paidAmount),
adjustedAmount: Number(formState.adjustedAmount) || 0,
method: formState.method,
receivedDate: parseLocalDate(formState.receivedDate),
payerName: formState.payerName?.trim() || undefined,
notes: formState.notes?.trim() || undefined,
},
],
};
try {
await handleEditServiceLine(payload);
toast({
title: "Success",
description: "Payment Transaction added successfully.",
});
setExpandedLineId(null);
}
catch (err) {
console.error(err);
toast({ title: "Error", description: "Failed to save payment." });
}
};
const handlePayFullDue = async (line) => {
if (!line || !payment) {
toast({
title: "Error",
description: "Service line or payment data missing.",
variant: "destructive",
});
return;
}
const dueAmount = Number(line.totalDue);
if (isNaN(dueAmount) || dueAmount <= 0) {
toast({
title: "No Due",
description: "This service line has no outstanding balance.",
variant: "destructive",
});
return;
}
const payload = {
paymentId: payment.id,
serviceLineTransactions: [
{
serviceLineId: line.id,
paidAmount: dueAmount,
adjustedAmount: 0,
method: paymentMethodOptions.CHECK, // Maybe make dynamic later
receivedDate: new Date(),
},
],
};
try {
await handleEditServiceLine(payload);
toast({
title: "Success",
description: `Full due amount ($${dueAmount.toFixed(2)}) paid for ${line.procedureCode}`,
});
}
catch (err) {
console.error(err);
toast({
title: "Error",
description: "Failed to update payment.",
variant: "destructive",
});
}
};
if (!payment) {
return (<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<div className="p-8 text-center">
{loadingPayment ? "Loading…" : "No payment selected"}
</div>
</DialogContent>
</Dialog>);
}
return (<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<div className="relative">
<DialogHeader>
<DialogTitle>Edit Payment</DialogTitle>
<DialogDescription>
View and manage payments applied to service lines.
</DialogDescription>
</DialogHeader>
{/* Close button in top-right */}
<Button variant="ghost" size="icon" onClick={onClose} className="absolute right-0 top-0">
<X className="h-4 w-4"/>
</Button>
</div>
<div className="space-y-6">
{/* Claim + Patient Info */}
<div className="space-y-2 border-b border-gray-200 pb-4">
<h3 className="text-2xl font-bold text-gray-900">
{payment.claim?.patientName ??
(`${payment.patient?.firstName ?? ""} ${payment.patient?.lastName ?? ""}`.trim() ||
"Unknown Patient")}
</h3>
<div className="flex flex-wrap items-center gap-3 text-sm">
{payment.claimId ? (<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
Claim #{payment.claimId.toString().padStart(4, "0")}
</span>) : payment.notes?.startsWith("PDF import") ? (<span className="bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full font-medium">
PDF Import
</span>) : (<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
OCR Imported Payment
</span>)}
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
Service Date:{" "}
{payment.claim?.serviceDate
? formatDateToHumanReadable(payment.claim.serviceDate)
: serviceLines.length > 0
? formatDateToHumanReadable(serviceLines[0]?.procedureDate)
: formatDateToHumanReadable(payment.createdAt)}
</span>
{payment.icn ? (<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
ICN : {payment.icn}
</span>) : null}
</div>
</div>
{/* Payment Summary + Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Payment Info */}
<div>
<h4 className="font-semibold text-gray-900">Payment Info</h4>
<div className="mt-2 space-y-1 text-sm">
<p>
<span className="text-gray-500">Total Billed:</span>{" "}
<span className="font-medium">
${Number(payment.totalBilled || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Total Paid:</span>{" "}
<span className="font-medium text-green-600">
${Number(payment.totalPaid || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Total Due:</span>{" "}
<span className="font-medium text-red-600">
${Number(payment.totalDue || 0).toFixed(2)}
</span>
</p>
{/* Status Selector */}
<div className="pt-3">
<label className="block text-sm text-gray-600 mb-1">
Payment Status
</label>
<Select value={paymentStatus} onValueChange={(value) => setPaymentStatus(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{paymentStatusArray.map((status) => (<SelectItem key={status} value={status}>
{status}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<Button size="sm" disabled={isUpdatingStatus} onClick={() => payment && handleUpdateStatus(payment.id, paymentStatus)}>
{isUpdatingStatus ? "Updating..." : "Update Status"}
</Button>
{/* Provider Selector */}
<div className="pt-3">
<label className="block text-sm text-gray-600 mb-1">
Rendering Provider
</label>
<Select value={selectedNpiProviderId?.toString() ?? ""} onValueChange={(val) => setSelectedNpiProviderId(Number(val))}>
<SelectTrigger>
<SelectValue placeholder="Select Provider"/>
</SelectTrigger>
<SelectContent>
{npiProviders.map((p) => (<SelectItem key={p.id} value={p.id.toString()}>
{p.npiNumber} — {p.providerName}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<Button size="sm" disabled={isUpdatingProvider} onClick={handleUpdateProvider}>
{isUpdatingProvider ? "Updating..." : "Update Provider"}
</Button>
</div>
</div>
{/* Metadata */}
<div>
<h4 className="font-semibold text-gray-900">Metadata</h4>
<div className="mt-2 space-y-1 text-sm">
<p>
<span className="text-gray-500">Created At:</span>{" "}
{payment.createdAt
? formatDateToHumanReadable(payment.createdAt)
: "N/A"}
</p>
<p>
<span className="text-gray-500">Last Updated At:</span>{" "}
{payment.updatedAt
? formatDateToHumanReadable(payment.updatedAt)
: "N/A"}
</p>
{payment.commissionBatchItems?.length > 0 ? (<div className="pt-2">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800">
✓ Commissioned
</span>
<p className="mt-1 text-xs text-gray-500">
Paid as commission on{" "}
{formatDateToHumanReadable(payment.commissionBatchItems[0].commissionBatch.createdAt)}
</p>
</div>) : (<p className="pt-1">
<span className="text-gray-400 text-xs">Not yet commissioned</span>
</p>)}
</div>
</div>
</div>
{/* Service Lines Payments */}
<div>
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
<div className="mt-3 space-y-4">
{serviceLines.length > 0 ? (serviceLines.map((line) => {
const isExpanded = expandedLineId === line.id;
return (<div key={line.id} className="border border-gray-200 p-4 rounded-xl bg-white shadow-sm hover:shadow-md transition-shadow duration-200">
{/* Top Info */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<p>
<span className="text-gray-500">Procedure Code:</span>{" "}
<span className="font-medium">
{line.procedureCode}
</span>
</p>
{line.icn && (<p>
<span className="text-gray-500">ICN:</span>{" "}
<span className="font-medium font-mono text-xs">
{line.icn}
</span>
</p>)}
{line.paidCode && line.paidCode !== line.procedureCode && (<p>
<span className="text-gray-500">Paid Code:</span>{" "}
<span className="font-medium">{line.paidCode}</span>
</p>)}
{line.allowedAmount != null && (<p>
<span className="text-gray-500">Allowed:</span>{" "}
<span className="font-medium text-blue-600">
${Number(line.allowedAmount).toFixed(2)}
</span>
</p>)}
{line.quad && (<p>
<span className="text-gray-500">Quad:</span>{" "}
<span className="font-medium">
{line.quad}
</span>
</p>)}
{line.arch && (<p>
<span className="text-gray-500">Arch:</span>{" "}
<span className="font-medium">
{line.arch}
</span>
</p>)}
{line.toothNumber && (<p>
<span className="text-gray-500">Tooth Number:</span>{" "}
<span className="font-medium">
{line.toothNumber}
</span>
</p>)}
{line.toothSurface && (<p>
<span className="text-gray-500">Surface:</span>{" "}
<span className="font-medium">
{line.toothSurface}
</span>
</p>)}
<p>
<span className="text-gray-500">Billed:</span>{" "}
<span className="font-semibold">
${Number(line.totalBilled || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Paid:</span>{" "}
<span className="font-semibold text-green-600">
${Number(line.totalPaid || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Adjusted:</span>{" "}
<span className="font-semibold text-yellow-600">
${Number(line.totalAdjusted || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Due:</span>{" "}
<span className="font-semibold text-red-600">
${Number(line.totalDue || 0).toFixed(2)}
</span>
</p>
</div>
{/* Action Buttons */}
<div className="pt-4 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleEditServiceLineClick(line.id)}>
{isExpanded ? "Cancel" : "Pay Partially"}
</Button>
<Button variant="default" size="sm" onClick={() => handlePayFullDue(line)}>
Pay Full Due
</Button>
</div>
{/* Expanded Partial Payment Form */}
{isExpanded && (<div className="mt-4 p-4 border-t border-gray-200 bg-gray-50 rounded-lg space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">
Paid Amount
</label>
<Input type="number" step="0.01" placeholder="Paid Amount" defaultValue={formState.paidAmount} onChange={(e) => updateField("paidAmount", parseFloat(e.target.value))}/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">
Adjusted Amount
</label>
<Input type="number" step="0.01" placeholder="Adjusted Amount" defaultValue={formState.adjustedAmount} onChange={(e) => updateField("adjustedAmount", parseFloat(e.target.value))}/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">
Payment Method
</label>
<Select value={formState.method} onValueChange={(value) => setFormState((prev) => ({
...prev,
method: value,
}))}>
<SelectTrigger>
<SelectValue placeholder="Select a payment method"/>
</SelectTrigger>
<SelectContent>
{paymentMethodArray.map((methodOption) => (<SelectItem key={methodOption} value={methodOption}>
{methodOption}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<DateInput label="Received Date" value={formState.receivedDate
? parseLocalDate(formState.receivedDate)
: null} onChange={(date) => {
if (date) {
const localDate = formatLocalDate(date);
updateField("receivedDate", localDate);
}
else {
updateField("receivedDate", null);
}
}} disableFuture/>
<div className="space-y-1">
<label className="text-sm font-medium">
Payer Name
</label>
<Input type="text" placeholder="Payer Name" value={formState.payerName} onChange={(e) => updateField("payerName", e.target.value)}/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Notes</label>
<Input type="text" placeholder="Notes" onChange={(e) => updateField("notes", e.target.value)}/>
</div>
<Button size="sm" disabled={isUpdatingServiceLine} onClick={() => handleSavePayment()}>
{isUpdatingStatus ? "Updating..." : "Update"}
</Button>
</div>)}
</div>);
})) : (<p className="text-gray-500">No service lines available.</p>)}
</div>
</div>
{/* Transactions Overview */}
<div>
<h4 className="font-medium text-gray-900 pt-6">All Transactions</h4>
<div className="mt-4 space-y-3">
{payment.serviceLineTransactions.length > 0 ? (payment.serviceLineTransactions.map((tx) => (<div key={tx.id} className="border border-gray-200 p-4 rounded-xl bg-white shadow-sm hover:shadow-md transition-shadow duration-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
{/* Transaction ID */}
{tx.id && (<p>
<span className="text-gray-500">Transaction ID:</span>{" "}
<span className="font-medium">{tx.id}</span>
</p>)}
{/* Procedure Code */}
{tx.serviceLine?.procedureCode && (<p>
<span className="text-gray-500">Procedure Code:</span>{" "}
<span className="font-medium">
{tx.serviceLine.procedureCode}
</span>
</p>)}
{/* Tooth Number */}
{tx.serviceLine?.toothNumber && (<p>
<span className="text-gray-500">Tooth Number:</span>{" "}
<span className="font-medium">
{tx.serviceLine.toothNumber}
</span>
</p>)}
{/* Tooth Surface */}
{tx.serviceLine?.toothSurface && (<p>
<span className="text-gray-500">Surface:</span>{" "}
<span className="font-medium">
{tx.serviceLine.toothSurface}
</span>
</p>)}
{/* Paid Amount */}
<p>
<span className="text-gray-500">Paid Amount:</span>{" "}
<span className="font-semibold text-green-600">
${Number(tx.paidAmount).toFixed(2)}
</span>
</p>
{/* Adjusted Amount */}
{Number(tx.adjustedAmount) > 0 && (<p>
<span className="text-gray-500">
Adjusted Amount:
</span>{" "}
<span className="font-semibold text-yellow-600">
${Number(tx.adjustedAmount).toFixed(2)}
</span>
</p>)}
{/* Date */}
<p>
<span className="text-gray-500">Date:</span>{" "}
<span>
{formatDateToHumanReadable(tx.receivedDate)}
</span>
</p>
{/* Method */}
<p>
<span className="text-gray-500">Method:</span>{" "}
<span className="capitalize">{tx.method}</span>
</p>
{/* Payer Name */}
{tx.payerName && tx.payerName.trim() !== "" && (<p className="md:col-span-2">
<span className="text-gray-500">Payer Name:</span>{" "}
<span>{tx.payerName}</span>
</p>)}
{/* Notes */}
{tx.notes && tx.notes.trim() !== "" && (<p className="md:col-span-2">
<span className="text-gray-500">Notes:</span>{" "}
<span>{tx.notes}</span>
</p>)}
</div>
</div>))) : (<p className="text-gray-500">No transactions recorded.</p>)}
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-2 pt-6">
<Button variant="default" onClick={onClose}>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>);
}
@@ -0,0 +1,318 @@
// PaymentOCRBlock.tsx
import * as React from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Image as ImageIcon, X, Plus } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { useReactTable, getCoreRowModel, flexRender, } from "@tanstack/react-table";
import { QK_PAYMENTS_RECENT_BASE } from "@/components/payments/payments-recent-table";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import { MultipleFileUploadZone, } from "../file-upload/multiple-file-upload-zone";
export default function PaymentOCRBlock() {
//Config
const MAX_FILES = 10;
const ACCEPTED_FILE_TYPES = "image/jpeg,image/jpg,image/png,image/webp";
const TITLE = "Payment Document OCR";
const DESCRIPTION = "You can upload up to 10 files. Allowed types: JPG, PNG, WEBP.";
// FILE/ZONE state
const uploadZoneRef = React.useRef(null);
const [filesForUI, setFilesForUI] = React.useState([]); // reactive UI only
const [isUploading, setIsUploading] = React.useState(false); // forwarded to zone
const [isExtracting, setIsExtracting] = React.useState(false);
// extracted rows shown only inside modal
const [rows, setRows] = React.useState([]);
const [modalColumns, setModalColumns] = React.useState([]);
const [showModal, setShowModal] = React.useState(false);
const [error, setError] = React.useState(null);
//Mutation
const extractPaymentOCR = useMutation({
mutationFn: async (files) => {
const formData = new FormData();
files.forEach((file) => formData.append("files", file, file.name));
const res = await apiRequest("POST", "/api/payment-ocr/extract", formData);
if (!res.ok)
throw new Error("Failed to extract payment data");
const data = (await res.json());
return Array.isArray(data) ? data : data.rows;
},
onSuccess: (data) => {
// Remove unwanted keys before using the data
const cleaned = data.map((row) => {
const { ["Extraction Success"]: _, ["Source File"]: __, ...rest } = row;
return rest;
});
const withIds = cleaned.map((r, i) => ({ ...r, __id: i }));
setRows(withIds);
const allKeys = Array.from(cleaned.reduce((acc, row) => {
Object.keys(row).forEach((k) => acc.add(k));
return acc;
}, new Set()));
setModalColumns(allKeys);
setIsExtracting(false);
setShowModal(true);
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to extract payment data: ${error.message}`,
variant: "destructive",
});
setIsExtracting(false);
},
});
// ---- handlers (all in this file) -----------------------------------------
// Called by zone when its internal list changes (keeps parent UI reactive)
const handleZoneFilesChange = React.useCallback((files) => {
setFilesForUI(files);
setError(null);
}, []);
// Remove a single file by asking the zone to remove it (zone exposes removeFile)
const removeUploadedFile = React.useCallback((index) => {
uploadZoneRef.current?.removeFile(index);
// zone will call onFilesChange and update filesForUI automatically
}, []);
// Extract: read files from zone via ref and call mutation
const handleExtract = () => {
const files = uploadZoneRef.current?.getFiles() ?? [];
if (!files.length) {
setError("Please upload at least one file to extract.");
return;
}
setIsExtracting(true);
extractPaymentOCR.mutate(files);
};
const handleSave = async () => {
try {
const skipped = [];
const payload = rows
.map((row, idx) => {
const patientName = row["Patient Name"];
const patientId = row["Patient ID"];
const procedureCode = row["CDT Code"];
if (!patientName || !patientId || !procedureCode) {
skipped.push(`Row ${idx + 1} (missing name/id/procedureCode)`);
return null;
}
return {
patientName,
insuranceId: patientId,
icn: row["ICN"] ?? null,
procedureCode: row["CDT Code"],
toothNumber: row["Tooth"] ?? null,
toothSurface: row["Surface"] ?? null,
procedureDate: row["Date SVC"] ?? null,
totalBilled: Number(row["Billed Amount"] ?? 0),
totalAllowed: Number(row["Allowed Amount"] ?? 0),
totalPaid: Number(row["Paid Amount"] ?? 0),
sourceFile: row["Source File"] ?? null,
};
})
.filter((r) => r !== null);
if (skipped.length > 0) {
toast({
title: "Some rows skipped, because of either no patient Name or MemberId given.",
description: skipped.join(", "),
variant: "destructive",
});
}
if (payload.length === 0) {
toast({
title: "Error",
description: "No valid rows to save",
variant: "destructive",
});
return;
}
const res = await apiRequest("POST", "/api/payments/full-ocr-import", {
rows: payload,
});
if (!res.ok)
throw new Error("Failed to save OCR payments");
toast({ title: "Saved", description: "OCR rows saved successfully" });
// 🔄 REFRESH both tables (all pages/filters)
await Promise.all([
queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE }), // all recent payments
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }), // recent patients list
]);
// ✅ CLEAR UI: reset zone + modal + rows
uploadZoneRef.current?.reset();
setFilesForUI([]);
setRows([]);
setModalColumns([]);
setError(null);
setIsExtracting(false);
setShowModal(false);
}
catch (err) {
toast({
title: "Error",
description: err.message,
variant: "destructive",
});
}
};
return (<div className="mb-8">
<Card>
<CardHeader>
<CardTitle>{TITLE}</CardTitle>
<CardDescription>{DESCRIPTION}</CardDescription>
</CardHeader>
<CardContent>
{/* Upload block */}
<div className="bg-gray-100 p-4 rounded-md space-y-4">
<MultipleFileUploadZone ref={uploadZoneRef} isUploading={isUploading} acceptedFileTypes={ACCEPTED_FILE_TYPES} maxFiles={MAX_FILES} onFilesChange={handleZoneFilesChange} // reactive UI only
/>
{/* Show list of files received from the upload zone (UI only) */}
{filesForUI.length > 0 && (<div>
<p className="text-sm text-gray-600 mb-2">
Uploaded ({filesForUI.length}/{MAX_FILES})
</p>
<ul className="space-y-2 max-h-48 overflow-auto">
{filesForUI.map((file, idx) => (<li key={`${file.name}-${file.size}-${idx}`} className="flex items-center justify-between border rounded-md p-2 bg-white">
<div className="flex items-center gap-3">
<ImageIcon className="h-6 w-6 text-green-500"/>
<div className="text-left">
<p className="font-medium text-green-700 truncate">
{file.name}
</p>
<p className="text-sm text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => removeUploadedFile(idx)}>
<X className="h-4 w-4"/>
</Button>
</li>))}
</ul>
</div>)}
</div>
{/* Extract */}
<div className="mt-4 flex justify-end gap-4">
<Button className="w-full h-12 gap-2" type="button" onClick={handleExtract} disabled={isExtracting || !filesForUI.length}>
{extractPaymentOCR.isPending
? "Extracting..."
: "Extract Payment Data"}
</Button>
</div>
{/* show extraction error if any */}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
</CardContent>
</Card>
<OCRDetailsModal open={showModal} onClose={() => setShowModal(false)} onSave={handleSave} rows={rows} setRows={setRows} columnKeys={modalColumns}/>
</div>);
}
// ---------------- Simple Modal (in-app popup) ----------------
export function OCRDetailsModal({ open, onClose, onSave, rows, setRows, columnKeys, }) {
if (!open)
return null;
//rows helper
const handleDeleteRow = (index) => {
setRows((prev) => prev.filter((_, i) => i !== index));
};
const handleAddRow = React.useCallback(() => {
setRows((prev) => {
const newRow = { __id: prev.length };
columnKeys.forEach((k) => {
newRow[k] = "";
});
return [...prev, newRow];
});
}, [setRows, columnKeys]);
const modalColumns = React.useMemo(() => {
// ensure ICN (if present) is moved to the end of the data columns
const reorderedKeys = [
...columnKeys.filter((k) => k !== "ICN"),
...(columnKeys.includes("ICN") ? ["ICN"] : []),
];
return reorderedKeys.map((key) => ({
id: key,
header: key,
cell: ({ row }) => {
const value = (row.original[key] ?? "");
return (<input className="w-full border rounded p-1" value={String(value)} onChange={(e) => {
const v = e.target.value;
setRows((prev) => {
const next = [...prev];
next[row.index] = {
...next[row.index],
__id: next[row.index].__id,
[key]: v,
};
return next;
});
}}/>);
},
}));
}, [columnKeys, setRows]);
const table = useReactTable({
data: rows,
columns: modalColumns,
getCoreRowModel: getCoreRowModel(),
});
return (<div className="fixed inset-0 z-50 flex items-start justify-center p-6">
<div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden/>
{/* larger modal, column layout so footer sticks to bottom */}
<div className="relative z-10 w-full max-w-[1600px] h-[92vh] bg-white rounded-lg shadow-2xl overflow-hidden flex flex-col">
{/* header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleAddRow}>
<Plus className="h-4 w-4 mr-2"/> Add Row
</Button>
<h3 className="text-lg font-medium ml-2">OCR Payment Details</h3>
</div>
<div>
<Button size="sm" variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
{/* body (scrollable) */}
<div className="p-4 overflow-auto flex-1">
<div className="min-w-max">
<table className="border-collapse border border-gray-300 w-full">
<thead>
{table.getHeaderGroups().map((hg) => (<tr key={hg.id} className="bg-gray-100">
{hg.headers.map((header) => (<th key={header.id} className="border p-2 text-left whitespace-nowrap">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>))}
<th className="border p-2">Actions</th>
</tr>))}
</thead>
<tbody>
{table.getRowModel().rows.map((r) => (<tr key={r.id}>
{r.getVisibleCells().map((cell) => (<td key={cell.id} className="border p-2 whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>))}
<td className="border p-2">
<Button size="sm" variant="destructive" onClick={() => handleDeleteRow(r.index)}>
Delete
</Button>
</td>
</tr>))}
</tbody>
</table>
</div>
</div>
{/* footer (always visible) */}
<div className="p-4 border-t flex justify-end">
<Button type="button" className="h-12" onClick={onSave}>
Save Edited Data
</Button>
</div>
</div>
</div>);
}
@@ -0,0 +1,216 @@
import * as React from "react";
import * as XLSX from "xlsx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { FileText, X, Download, DatabaseIcon } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { QK_PAYMENTS_RECENT_BASE } from "@/components/payments/payments-recent-table";
import { MultipleFileUploadZone, } from "../file-upload/multiple-file-upload-zone";
const COLUMNS = [
{ key: "Patient Name", label: "Patient Name" },
{ key: "Member #", label: "Member #" },
{ key: "ICN", label: "ICN" },
{ key: "Submitted Code", label: "Submitted Code" },
{ key: "Paid Code", label: "Paid Code" },
{ key: "Tooth", label: "Tooth" },
{ key: "Date of Service", label: "Date of Service" },
{ key: "Submitted Amount", label: "Submitted ($)" },
{ key: "Allowed Amount", label: "Allowed ($)" },
{ key: "Paid Amount", label: "Paid ($)" },
];
const SUMMARY_FIELDS = [
"Source File",
"Payee ID",
"Business NPI",
"Run #",
"RA #",
"RA Date",
"Claim Detail Amount",
"Claim Adjustment Amount",
"Misc. Adjustment Amount",
"Payment Amount",
];
// Convert a string value to a number if it looks numeric, otherwise keep as string.
// Handles: "36.00" → 36, "$14,369.00" → 14369, "($3,107.39)" → -3107.39
function toExcelValue(val) {
if (!val)
return "";
const stripped = val
.replace(/\$/g, "")
.replace(/,/g, "")
.replace(/^\((.+)\)$/, "-$1") // (3,107.39) → -3107.39
.trim();
const num = Number(stripped);
return stripped !== "" && !isNaN(num) ? num : val;
}
function downloadExcel(rows, headers, sourceFileName) {
const wb = XLSX.utils.book_new();
// Sheet 1 — RA Summary (one row per uploaded PDF)
if (headers.length > 0) {
const summaryData = headers.map((h) => {
const out = {};
SUMMARY_FIELDS.forEach((f) => { out[f] = toExcelValue(h[f] ?? ""); });
return out;
});
const wsSummary = XLSX.utils.json_to_sheet(summaryData, {
header: SUMMARY_FIELDS,
});
XLSX.utils.book_append_sheet(wb, wsSummary, "RA Summary");
}
// Sheet 2 — Payment Data (one row per ICN)
const data = rows.map((row) => {
const out = {};
COLUMNS.forEach(({ key, label }) => { out[label] = toExcelValue(row[key] ?? ""); });
return out;
});
const wsData = XLSX.utils.json_to_sheet(data, {
header: COLUMNS.map((c) => c.label),
});
XLSX.utils.book_append_sheet(wb, wsData, "Payment Data");
const name = sourceFileName.replace(/\.pdf$/i, "") || "payment_extract";
XLSX.writeFile(wb, `${name}.xlsx`);
}
export default function PaymentUploadDocumentsBlock() {
const MAX_FILES = 10;
const ACCEPTED_FILE_TYPES = "application/pdf";
const uploadZoneRef = React.useRef(null);
const [filesForUI, setFilesForUI] = React.useState([]);
const [isUploading] = React.useState(false);
const [error, setError] = React.useState(null);
// ── shared extract helper ────────────────────────────────────────────────
const extractData = async (files) => {
const formData = new FormData();
files.forEach((file) => formData.append("files", file, file.name));
const res = await apiRequest("POST", "/api/payment-pdf/extract", formData);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || "Failed to extract PDF data");
}
const data = (await res.json());
return { rows: data.rows ?? [], headers: data.headers ?? [] };
};
const getFiles = () => {
const files = uploadZoneRef.current?.getFiles() ?? [];
if (!files.length) {
setError("Please upload at least one PDF file.");
return null;
}
setError(null);
return files;
};
// ── Extract & Download ───────────────────────────────────────────────────
const downloadMutation = useMutation({
mutationFn: async (files) => {
const { rows, headers } = await extractData(files);
return { rows, headers, sourceName: files[0]?.name ?? "payment_extract" };
},
onSuccess: ({ rows, headers, sourceName }) => {
if (rows.length === 0) {
toast({ title: "No data", description: "No rows were extracted from the PDF.", variant: "destructive" });
return;
}
downloadExcel(rows, headers, sourceName);
toast({ title: "Downloaded", description: `${rows.length} rows exported to Excel.` });
},
onError: (err) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
// ── Extract & Import ─────────────────────────────────────────────────────
const importMutation = useMutation({
mutationFn: async (files) => {
const { rows } = await extractData(files);
if (rows.length === 0)
throw new Error("No rows extracted from the PDF.");
const res = await apiRequest("POST", "/api/payment-pdf/import", { rows });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || "Import failed");
}
return res.json();
},
onSuccess: async (data) => {
toast({
title: "Imported",
description: `${data.paymentIds?.length ?? 0} payment(s) created successfully.`,
});
await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE });
},
onError: (err) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
const handleZoneFilesChange = React.useCallback((files) => {
setFilesForUI(files);
setError(null);
}, []);
const removeUploadedFile = React.useCallback((index) => {
uploadZoneRef.current?.removeFile(index);
}, []);
const busy = downloadMutation.isPending || importMutation.isPending;
return (<div className="mb-8">
<Card>
<CardHeader>
<CardTitle>Upload Payment Documents</CardTitle>
<CardDescription>
Upload up to 10 MassHealth remittance PDFs. Extract and download as
Excel, or import directly into the database.
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-gray-100 p-4 rounded-md space-y-4">
<MultipleFileUploadZone ref={uploadZoneRef} isUploading={isUploading} acceptedFileTypes={ACCEPTED_FILE_TYPES} maxFiles={MAX_FILES} onFilesChange={handleZoneFilesChange}/>
{filesForUI.length > 0 && (<div>
<p className="text-sm text-gray-600 mb-2">
Uploaded ({filesForUI.length}/{MAX_FILES})
</p>
<ul className="space-y-2 max-h-48 overflow-auto">
{filesForUI.map((file, idx) => (<li key={`${file.name}-${file.size}-${idx}`} className="flex items-center justify-between border rounded-md p-2 bg-white">
<div className="flex items-center gap-3">
<FileText className="h-6 w-6 text-blue-500"/>
<div className="text-left">
<p className="font-medium text-blue-700 truncate">{file.name}</p>
<p className="text-sm text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => removeUploadedFile(idx)}>
<X className="h-4 w-4"/>
</Button>
</li>))}
</ul>
</div>)}
</div>
<div className="mt-4 flex gap-3">
{/* Extract & Download */}
<Button className="flex-1 h-12 gap-2 bg-blue-600 hover:bg-blue-700 text-white" type="button" disabled={busy || !filesForUI.length} onClick={() => {
const files = getFiles();
if (files)
downloadMutation.mutate(files);
}}>
<Download className="h-4 w-4"/>
{downloadMutation.isPending ? "Extracting…" : "Extract & Download"}
</Button>
{/* Extract & Import */}
<Button className="flex-1 h-12 gap-2 bg-teal-600 hover:bg-teal-700 text-white" type="button" disabled={busy || !filesForUI.length} onClick={() => {
const files = getFiles();
if (files)
importMutation.mutate(files);
}}>
<DatabaseIcon className="h-4 w-4"/>
{importMutation.isPending ? "Importing…" : "Extract & Import"}
</Button>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</CardContent>
</Card>
</div>);
}
@@ -0,0 +1,90 @@
import { forwardRef, useEffect, useState } from "react";
import { PatientTable } from "../patients/patient-table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import PaymentsRecentTable from "./payments-recent-table";
const PaymentsOfPatientModal = forwardRef(({ initialPatient = null, openInitially = false, onClose }, ref) => {
const [selectedPatient, setSelectedPatient] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [paymentsPage, setPaymentsPage] = useState(1);
// minimal, local scroll + cleanup — put inside PaymentsOfPatientModal
useEffect(() => {
if (!selectedPatient)
return;
const raf = requestAnimationFrame(() => {
const card = document.getElementById("payments-for-patient-card");
const main = document.querySelector("main"); // your app's scroll container
if (card && main instanceof HTMLElement) {
const parentRect = main.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const relativeTop = cardRect.top - parentRect.top + main.scrollTop;
const offset = 8;
main.scrollTo({
top: Math.max(0, relativeTop - offset),
behavior: "smooth",
});
}
});
// cleanup: when selectedPatient changes (ddmodal closes) or component unmounts,
// reset the main scroll to top so other pages are not left scrolled.
return () => {
cancelAnimationFrame(raf);
const main = document.querySelector("main");
if (main instanceof HTMLElement) {
// immediate reset (no animation) so navigation to other pages starts at top
main.scrollTo({ top: 0, behavior: "auto" });
}
};
}, [selectedPatient]);
// when parent provides an initialPatient and openInitially flag, apply it
useEffect(() => {
if (initialPatient) {
setSelectedPatient(initialPatient);
setPaymentsPage(1);
}
if (openInitially) {
setIsModalOpen(true);
}
}, [initialPatient, openInitially]);
const handleSelectPatient = (patient) => {
if (patient) {
setSelectedPatient(patient);
setPaymentsPage(1);
setIsModalOpen(true);
}
else {
setSelectedPatient(null);
setIsModalOpen(false);
}
};
return (<div className="space-y-8 py-8">
{/* Payments Section */}
{selectedPatient && (<Card id="payments-for-patient-card">
<CardHeader>
<CardTitle>
Payments for {selectedPatient.firstName}{" "}
{selectedPatient.lastName}
</CardTitle>
<CardDescription>
Displaying recent payments for the selected patient.
</CardDescription>
</CardHeader>
<CardContent>
<PaymentsRecentTable patientId={selectedPatient.id} allowEdit allowDelete onPageChange={setPaymentsPage}/>
</CardContent>
</Card>)}
{/* Patients Section */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select any patient and View all their recent payments.
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable allowView allowCheckbox onSelectPatient={handleSelectPatient}/>
</CardContent>
</Card>
</div>);
});
export default PaymentsOfPatientModal;
@@ -0,0 +1,776 @@
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>);
}