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:
@@ -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>);
|
||||
}
|
||||
Reference in New Issue
Block a user