- 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>
217 lines
9.4 KiB
JavaScript
217 lines
9.4 KiB
JavaScript
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>);
|
|
}
|