- Add Upload Payment Documents section with Extract & Download (Excel) and Extract & Import (database) buttons - PDF extractor (pdfplumber) parses MassHealth RA PDFs: two-pass strategy joins summary-page ICN/patient map with detail-page procedure data (CDT code, paid code, tooth, date, allowed amount) - RA cover-page summary (Payee ID, RA #, Payment Amount, etc.) included as separate Excel sheet; numeric values written as numbers - Backend PDF import route groups rows by Member #, finds/creates patient, creates Payment + ServiceLines with ICN per procedure - Add icn, paidCode, allowedAmount fields to ServiceLine schema - Payments table: status simplified to Paid in Full / Balance; adjustment auto-computed on mhPaidAmount/copayment change; Paid in Full and Revert buttons with confirmation dialogs - Edit Payment modal: shows ICN, Paid Code, Allowed Amount per line - PDF Import badge distinguishes from OCR imports in payments table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
353 lines
12 KiB
TypeScript
Executable File
353 lines
12 KiB
TypeScript
Executable File
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardContent,
|
|
CardDescription,
|
|
} from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { DollarSign, CalendarIcon } from "lucide-react";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import PaymentsRecentTable from "@/components/payments/payments-recent-table";
|
|
import PaymentsOfPatientModal from "@/components/payments/payments-of-patient-table";
|
|
import PaymentOCRBlock from "@/components/payments/payment-ocr-block";
|
|
import PaymentUploadDocumentsBlock from "@/components/payments/payment-upload-documents-block";
|
|
import { useLocation } from "wouter";
|
|
import { Patient, PaymentWithExtras } from "@repo/db/types";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import { toast } from "@/hooks/use-toast";
|
|
import PaymentEditModal from "@/components/payments/payment-edit-modal";
|
|
|
|
export default function PaymentsPage() {
|
|
const [paymentPeriod, setPaymentPeriod] = useState<string>("all-time");
|
|
|
|
// Check Payments Online date range
|
|
const [mhFromDate, setMhFromDate] = useState<Date | undefined>(undefined);
|
|
const [mhToDate, setMhToDate] = useState<Date | undefined>(undefined);
|
|
const [fromCalendarOpen, setFromCalendarOpen] = useState(false);
|
|
const [toCalendarOpen, setToCalendarOpen] = useState(false);
|
|
|
|
// for auto-open from appointment redirect
|
|
const [location] = useLocation();
|
|
const [initialPatientForModal, setInitialPatientForModal] =
|
|
useState<Patient | null>(null);
|
|
const [openPatientModalFromAppointment, setOpenPatientModalFromAppointment] =
|
|
useState(false);
|
|
|
|
// Payment edit modal state (opens directly when ?paymentId=)
|
|
const [paymentIdToEdit, setPaymentIdToEdit] = useState<number | null>(null);
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
|
|
// small helper: remove query params silently
|
|
const clearUrlParams = (params: string[]) => {
|
|
try {
|
|
const url = new URL(window.location.href);
|
|
let changed = false;
|
|
for (const p of params) {
|
|
if (url.searchParams.has(p)) {
|
|
url.searchParams.delete(p);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed)
|
|
window.history.replaceState({}, document.title, url.toString());
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
// case1: If payments page is opened via appointment-page, /payments?appointmentId=123 -> fetch patient and open modal
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const appointmentIdParam = params.get("appointmentId");
|
|
if (!appointmentIdParam) return;
|
|
const appointmentId = Number(appointmentIdParam);
|
|
if (!Number.isFinite(appointmentId) || appointmentId <= 0) return;
|
|
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
try {
|
|
const res = await apiRequest(
|
|
"GET",
|
|
`/api/appointments/${appointmentId}/patient`
|
|
);
|
|
if (!res.ok) {
|
|
let body: any = null;
|
|
try {
|
|
body = await res.json();
|
|
} catch {}
|
|
if (!cancelled) {
|
|
toast({
|
|
title: "Failed to load patient",
|
|
description:
|
|
body?.message ??
|
|
body?.error ??
|
|
`Could not fetch patient for appointment ${appointmentId}.`,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
const patient = data?.patient ?? data;
|
|
if (!cancelled && patient && patient.id) {
|
|
setInitialPatientForModal(patient as Patient);
|
|
setOpenPatientModalFromAppointment(true);
|
|
|
|
// remove query param so reload won't re-open
|
|
clearUrlParams(["appointmentId"]);
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
console.error("Error fetching patient for appointment:", err);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [location]);
|
|
|
|
// NEW: detect paymentId query param -> open edit modal (modal will fetch by id)
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const paymentIdParam = params.get("paymentId");
|
|
if (!paymentIdParam) return;
|
|
const paymentId = Number(paymentIdParam);
|
|
if (!Number.isFinite(paymentId) || paymentId <= 0) return;
|
|
|
|
// Open modal with paymentId and clear params
|
|
setPaymentIdToEdit(paymentId);
|
|
setIsEditModalOpen(true);
|
|
clearUrlParams(["paymentId", "patientId"]);
|
|
}, [location]);
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="container mx-auto space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Payments</h1>
|
|
<p className="text-muted-foreground">
|
|
Manage patient payments and outstanding balances
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between mt-4 mb-6">
|
|
<div className="mt-4 md:mt-0 flex items-center space-x-2">
|
|
<Select
|
|
defaultValue="all-time"
|
|
onValueChange={(value) => setPaymentPeriod(value)}
|
|
>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Select period" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all-time">All Time</SelectItem>
|
|
<SelectItem value="this-month">This Month</SelectItem>
|
|
<SelectItem value="last-month">Last Month</SelectItem>
|
|
<SelectItem value="last-90-days">Last 90 Days</SelectItem>
|
|
<SelectItem value="this-year">This Year</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payment Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-500">
|
|
Outstanding Balance
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center">
|
|
<DollarSign className="h-5 w-5 text-yellow-500 mr-2" />
|
|
<div className="text-2xl font-bold">$0</div>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
From 0 outstanding invoices
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-500">
|
|
Payments Collected
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center">
|
|
<DollarSign className="h-5 w-5 text-green-500 mr-2" />
|
|
<div className="text-2xl font-bold">${0}</div>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
From 0 completed payments
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-500">
|
|
Pending Payments
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center">
|
|
<DollarSign className="h-5 w-5 text-blue-500 mr-2" />
|
|
<div className="text-2xl font-bold">$0</div>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
From 0 pending transactions
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Check Payments Online */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Check Payments Online</CardTitle>
|
|
<CardDescription>
|
|
Select a date range and check MH payment status online
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
{/* From date picker */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-600 whitespace-nowrap">From:</span>
|
|
<Popover open={fromCalendarOpen} onOpenChange={setFromCalendarOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className="w-[160px] justify-start text-left font-normal"
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4 text-gray-400" />
|
|
{mhFromDate
|
|
? mhFromDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
: <span className="text-muted-foreground">Pick a date</span>}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={mhFromDate}
|
|
onSelect={(d) => {
|
|
setMhFromDate(d);
|
|
setFromCalendarOpen(false);
|
|
}}
|
|
onClose={() => setFromCalendarOpen(false)}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* To date picker */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-600 whitespace-nowrap">To:</span>
|
|
<Popover open={toCalendarOpen} onOpenChange={setToCalendarOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className="w-[160px] justify-start text-left font-normal"
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4 text-gray-400" />
|
|
{mhToDate
|
|
? mhToDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
: <span className="text-muted-foreground">Pick a date</span>}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={mhToDate}
|
|
onSelect={(d) => {
|
|
setMhToDate(d);
|
|
setToCalendarOpen(false);
|
|
}}
|
|
onClose={() => setToCalendarOpen(false)}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* Check All MH Payment button */}
|
|
<Button
|
|
variant="default"
|
|
onClick={() => {
|
|
// Logic to be defined later
|
|
}}
|
|
>
|
|
Check All MH Payment
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Recent Payments table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Payment's Records</CardTitle>
|
|
<CardDescription>
|
|
View and manage all recents patient's claims payments
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<PaymentsRecentTable allowEdit allowDelete allowCheckbox />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Recent Payments by Patients*/}
|
|
<PaymentsOfPatientModal
|
|
initialPatient={initialPatientForModal}
|
|
openInitially={openPatientModalFromAppointment}
|
|
onClose={() => {
|
|
// reset the local flags when modal is closed
|
|
setOpenPatientModalFromAppointment(false);
|
|
setInitialPatientForModal(null);
|
|
}}
|
|
/>
|
|
|
|
{/* Upload Payment Documents Section */}
|
|
<PaymentUploadDocumentsBlock />
|
|
|
|
{/* OCR Image Upload Section*/}
|
|
<PaymentOCRBlock />
|
|
|
|
{/* Payment Edit Modal — modal will fetch payment by id and handle its own save/update */}
|
|
<PaymentEditModal
|
|
isOpen={isEditModalOpen}
|
|
onOpenChange={(v) => setIsEditModalOpen(v)}
|
|
onClose={() => {
|
|
setIsEditModalOpen(false);
|
|
setPaymentIdToEdit(null);
|
|
}}
|
|
paymentId={paymentIdToEdit}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|