// 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 (
{TITLE} {DESCRIPTION} {/* Upload block */}
{/* Show list of files received from the upload zone (UI only) */} {filesForUI.length > 0 && (

Uploaded ({filesForUI.length}/{MAX_FILES})

    {filesForUI.map((file, idx) => (
  • {file.name}

    {(file.size / 1024 / 1024).toFixed(2)} MB

  • ))}
)}
{/* Extract */}
{/* show extraction error if any */} {error &&

{error}

}
setShowModal(false)} onSave={handleSave} rows={rows} setRows={setRows} columnKeys={modalColumns}/>
); } // ---------------- 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 ( { 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 (
{/* larger modal, column layout so footer sticks to bottom */}
{/* header */}

OCR Payment Details

{/* body (scrollable) */}
{table.getHeaderGroups().map((hg) => ( {hg.headers.map((header) => ())} ))} {table.getRowModel().rows.map((r) => ( {r.getVisibleCells().map((cell) => ())} ))}
{flexRender(header.column.columnDef.header, header.getContext())} Actions
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{/* footer (always visible) */}
); }