Files
DentalManagementE/apps/Frontend/src/components/payments/payment-ocr-block.tsx

417 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PaymentOCRBlock.tsx
import * as React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Upload, Image as ImageIcon, X, Plus, Minus } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import {
useReactTable,
getCoreRowModel,
flexRender,
ColumnDef,
} from "@tanstack/react-table";
import { convertOCRDate } from "@/utils/dateUtils";
// ---------------- Types ----------------
type Row = { __id: number } & Record<string, string | number | null>;
export default function PaymentOCRBlock() {
// UI state
const [uploadedImages, setUploadedImages] = React.useState<File[]>([]);
const [isDragging, setIsDragging] = React.useState(false);
const [isExtracting, setIsExtracting] = React.useState(false);
const [rows, setRows] = React.useState<Row[]>([]);
const [columns, setColumns] = React.useState<ColumnDef<Row>[]>([]);
const [error, setError] = React.useState<string | null>(null);
//Mutation
const extractPaymentOCR = useMutation({
mutationFn: async (files: File[]) => {
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()) as { rows: Row[] } | Row[];
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: Row[] = cleaned.map((r, i) => ({ ...r, __id: i }));
setRows(withIds);
const allKeys = Array.from(
cleaned.reduce<Set<string>>((acc, row) => {
Object.keys(row).forEach((k) => acc.add(k));
return acc;
}, new Set())
);
setColumns(
allKeys.map((key) => ({
id: key, // ✅ unique identifier
header: key,
cell: ({ row }) => (
<input
className="w-full border rounded p-1"
value={(row.original[key] as string) ?? ""}
onChange={(e) => {
const newData = [...rows];
newData[row.index] = {
...newData[row.index],
__id: newData[row.index]!.__id,
[key]: e.target.value,
};
setRows(newData);
}}
/>
),
}))
);
setIsExtracting(false);
},
onError: (error: any) => {
toast({
title: "Error",
description: `Failed to extract payment data: ${error.message}`,
variant: "destructive",
});
setIsExtracting(false);
},
});
// ---- Table instance ----
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
});
// ---- handlers (all in this file) -----------------------------------------
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const list = Array.from(e.target.files || []);
if (!list.length) return;
if (list.length > 10) {
setError("You can only upload up to 10 images.");
return;
}
setUploadedImages(list);
setError(null);
};
const handleImageDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const list = Array.from(e.dataTransfer.files || []).filter((f) =>
f.type.startsWith("image/")
);
if (!list.length) {
setError("Please drop image files (JPG/PNG/TIFF/BMP).");
return;
}
if (list.length > 10) {
setError("You can only upload up to 10 images.");
return;
}
setUploadedImages(list);
setError(null);
};
const removeUploadedImage = (index: number) => {
setUploadedImages((prev) => {
const next = prev.filter((_, i) => i !== index);
if (next.length === 0) {
setRows([]);
setColumns([]);
setError(null);
}
return next;
});
};
const handleExtract = () => {
if (!uploadedImages.length) return;
setIsExtracting(true);
extractPaymentOCR.mutate(uploadedImages);
};
const handleSave = async () => {
try {
const payload = rows.map((row) => {
const billed = Number(row["Billed Amount"] ?? 0);
const allowed = Number(row["Allowed Amount"] ?? 0);
const paid = Number(row["Paid Amount"] ?? 0);
return {
patientId: parseInt(row["Patient ID"] as string, 10),
totalBilled: billed,
totalPaid: paid,
totalAdjusted: billed - allowed, // ❗ write-off
totalDue: allowed - paid, // ❗ patient responsibility
notes: `OCR import - CDT ${row["CDT Code"]}, Tooth ${row["Tooth"]}, Date ${row["Date SVC"]}`,
serviceLine: {
procedureCode: row["CDT Code"],
procedureDate: convertOCRDate(row["Date SVC"]), // youll parse "070825" → proper Date
toothNumber: row["Tooth"],
totalBilled: billed,
totalPaid: paid,
totalAdjusted: billed - allowed,
totalDue: allowed - paid,
},
transaction: {
paidAmount: paid,
adjustedAmount: billed - allowed, // same as totalAdjusted
method: "OTHER", // fallback, since OCR doesnt give this
receivedDate: new Date(),
},
};
});
const res = await apiRequest(
"POST",
"/api/payments/ocr",
JSON.stringify({ payments: payload })
);
if (!res.ok) throw new Error("Failed to save OCR payments");
toast({ title: "Saved", description: "OCR rows saved successfully" });
} catch (err: any) {
toast({
title: "Error",
description: err.message,
variant: "destructive",
});
}
};
//rows helper
const handleAddRow = () => {
const newRow: Row = { __id: rows.length };
columns.forEach((c) => {
if (c.id) newRow[c.id] = "";
});
setRows((prev) => [...prev, newRow]);
};
const handleDeleteRow = (index: number) => {
setRows((prev) => prev.filter((_, i) => i !== index));
};
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-medium text-gray-800">
Payment Document OCR
</h2>
</div>
<Card>
<CardContent className="p-6 space-y-6">
{/* Upload block */}
<div
className={`flex-1 border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? "border-blue-400 bg-blue-50"
: uploadedImages.length
? "border-green-400 bg-green-50"
: "border-gray-300 bg-gray-50 hover:border-gray-400"
}`}
onDrop={handleImageDrop}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onClick={() =>
document.getElementById("image-upload-input")?.click()
}
>
{uploadedImages.length ? (
<div className="space-y-4">
{uploadedImages.map((file, idx) => (
<div
key={idx}
className="flex items-center justify-between space-x-4 border rounded-md p-2 bg-white"
>
<div className="flex items-center space-x-3">
<ImageIcon className="h-6 w-6 text-green-500" />
<div className="text-left">
<p className="font-medium text-green-700">
{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={(e) => {
e.stopPropagation();
removeUploadedImage(idx);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="space-y-4">
<Upload className="h-12 w-12 text-gray-400 mx-auto" />
<div>
<p className="text-lg font-medium text-gray-700 mb-2">
Upload Payment Documents
</p>
<p className="text-sm text-gray-500 mb-4">
Drag and drop up to 10 images or click to browse
</p>
<Button variant="outline" type="button">
Choose Images
</Button>
</div>
<p className="text-xs text-gray-400">
Supported formats: JPG, PNG, TIFF, BMP Max size: 10MB each
</p>
</div>
)}
<input
id="image-upload-input"
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
multiple
/>
</div>
{/* Extract */}
<div className="flex justify-end gap-4">
<Button
className="w-full h-12 gap-2"
type="button"
onClick={handleExtract}
disabled={!uploadedImages.length || isExtracting}
>
{extractPaymentOCR.isPending
? "Extracting..."
: "Extract Payment Data"}
</Button>
</div>
{/* Results Table */}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
{rows.length > 0 && (
<div className="space-y-4">
{/* Row/Column control buttons */}
<div className="flex gap-2 flex-wrap">
<Button size="sm" onClick={handleAddRow}>
<Plus className="h-4 w-4 mr-1" /> Add Row
</Button>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="border-collapse border border-gray-300 w-full table-auto min-w-max">
<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((row, rowIndex) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => {
const colId = cell.column.id; // ✅ key for field
return (
<td
key={cell.id}
className="border p-2 whitespace-nowrap"
>
<input
className="w-full border rounded p-1"
value={
(rows[rowIndex]?.[colId] as string) ?? ""
}
onChange={(e) => {
const newData = [...rows];
newData[rowIndex] = {
...newData[rowIndex],
__id: newData[rowIndex]!.__id, // keep id
[colId]: e.target.value,
};
setRows(newData);
}}
/>
</td>
);
})}
<td className="border p-2">
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRow(rowIndex)}
>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button
className="w-full h-12 gap-2"
type="button"
variant="warning"
onClick={handleSave}
>
Save Edited Data
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}