- 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>
319 lines
14 KiB
React
319 lines
14 KiB
React
// 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>);
|
|
}
|