feat(ocr) - schema updated, allowed payment model to allow both - claim and ocr data

This commit is contained in:
2025-09-03 23:05:23 +05:30
parent 155f338a15
commit 399c47dcfd
7 changed files with 166 additions and 160 deletions

View File

@@ -230,7 +230,18 @@ router.put(
return res.status(404).json({ message: "Payment not found" });
}
const serviceLineTransactions = paymentRecord.claim.serviceLines
// Collect service lines from either claim or direct payment(OCR based data)
const serviceLines = paymentRecord.claim
? paymentRecord.claim.serviceLines
: paymentRecord.serviceLines;
if (!serviceLines || serviceLines.length === 0) {
return res
.status(400)
.json({ message: "No service lines available for this payment" });
}
const serviceLineTransactions = serviceLines
.filter((line) => line.totalDue.gt(0))
.map((line) => ({
serviceLineId: line.id,
@@ -273,8 +284,19 @@ router.put(
if (!paymentRecord) {
return res.status(404).json({ message: "Payment not found" });
}
const serviceLines = paymentRecord.claim
? paymentRecord.claim.serviceLines
: paymentRecord.serviceLines;
if (!serviceLines || serviceLines.length === 0) {
return res
.status(400)
.json({ message: "No service lines available for this payment" });
}
// Build reversal transactions (negating whats already paid/adjusted)
const serviceLineTransactions = paymentRecord.claim.serviceLines
const serviceLineTransactions = serviceLines
.filter((line) => line.totalPaid.gt(0) || line.totalAdjusted.gt(0))
.map((line) => ({
serviceLineId: line.id,

View File

@@ -16,10 +16,17 @@ export async function validateTransactions(
throw new Error("Payment not found");
}
// Choose service lines from claim if present, otherwise direct payment service lines(OCR Based datas)
const serviceLines = paymentRecord.claim
? paymentRecord.claim.serviceLines
: paymentRecord.serviceLines;
if (!serviceLines || serviceLines.length === 0) {
throw new Error("No service lines available for this payment");
}
for (const txn of serviceLineTransactions) {
const line = paymentRecord.claim.serviceLines.find(
(sl) => sl.id === txn.serviceLineId
);
const line = serviceLines.find((sl) => sl.id === txn.serviceLineId);
if (!line) {
throw new Error(`Invalid service line: ${txn.serviceLineId}`);

View File

@@ -72,6 +72,9 @@ export default function PaymentEditModal({
};
});
const serviceLines =
payment.claim?.serviceLines ?? payment.serviceLines ?? [];
const handleEditServiceLine = (lineId: number) => {
if (expandedLineId === lineId) {
// Closing current line
@@ -80,7 +83,7 @@ export default function PaymentEditModal({
}
// Find line data
const line = payment.claim.serviceLines.find((sl) => sl.id === lineId);
const line = serviceLines.find((sl) => sl.id === lineId);
if (!line) return;
// updating form to show its data, while expanding.
@@ -136,9 +139,7 @@ export default function PaymentEditModal({
return;
}
const line = payment.claim.serviceLines.find(
(sl) => sl.id === formState.serviceLineId
);
const line = serviceLines.find((sl) => sl.id === formState.serviceLineId);
if (!line) {
toast({
title: "Error",
@@ -189,9 +190,7 @@ export default function PaymentEditModal({
}
};
const handlePayFullDue = async (
line: (typeof payment.claim.serviceLines)[0]
) => {
const handlePayFullDue = async (line: (typeof serviceLines)[0]) => {
if (!line || !payment) {
toast({
title: "Error",
@@ -268,15 +267,28 @@ export default function PaymentEditModal({
{/* Claim + Patient Info */}
<div className="space-y-2 border-b border-gray-200 pb-4">
<h3 className="text-2xl font-bold text-gray-900">
{payment.claim.patientName}
{payment.claim?.patientName ??
(`${payment.patient?.firstName ?? ""} ${payment.patient?.lastName ?? ""}`.trim() ||
"Unknown Patient")}
</h3>
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
Claim #{payment.claimId.toString().padStart(4, "0")}
</span>
{payment.claimId ? (
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
Claim #{payment.claimId.toString().padStart(4, "0")}
</span>
) : (
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
OCR Imported Payment
</span>
)}
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
Service Date:{" "}
{formatDateToHumanReadable(payment.claim.serviceDate)}
{payment.claim?.serviceDate
? formatDateToHumanReadable(payment.claim.serviceDate)
: serviceLines.length > 0
? formatDateToHumanReadable(serviceLines[0]?.procedureDate)
: formatDateToHumanReadable(payment.createdAt)}
</span>
</div>
</div>
@@ -366,8 +378,8 @@ export default function PaymentEditModal({
<div>
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
<div className="mt-3 space-y-4">
{payment.claim.serviceLines.length > 0 ? (
payment.claim.serviceLines.map((line) => {
{serviceLines.length > 0 ? (
serviceLines.map((line) => {
const isExpanded = expandedLineId === line.id;
return (

View File

@@ -14,6 +14,7 @@ import {
flexRender,
ColumnDef,
} from "@tanstack/react-table";
import { convertOCRDate } from "@/utils/dateUtils";
// ---------------- Types ----------------
@@ -45,11 +46,17 @@ export default function PaymentOCRBlock() {
return Array.isArray(data) ? data : data.rows;
},
onSuccess: (data) => {
const withIds: Row[] = data.map((r, i) => ({ ...r, __id: i }));
// 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(
data.reduce<Set<string>>((acc, row) => {
cleaned.reduce<Set<string>>((acc, row) => {
Object.keys(row).forEach((k) => acc.add(k));
return acc;
}, new Set())
@@ -147,17 +154,57 @@ export default function PaymentOCRBlock() {
extractPaymentOCR.mutate(uploadedImages);
};
const handleSave = () => {
console.log("Saving edited rows:", rows);
toast({
title: "Saved",
description: "Edited OCR results are ready to be sent to the database.",
});
// Here can POST `rows` to your backend API for DB save
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) => {
@@ -367,128 +414,3 @@ export default function PaymentOCRBlock() {
</div>
);
}
// --------------------- Editable OCRTable ----------------------------
// function OCRTable({
// rows,
// setRows,
// }: {
// rows: Row[];
// setRows: React.Dispatch<React.SetStateAction<Row[]>>;
// }) {
// const [columns, setColumns] = React.useState<string[]>([]);
// // Initialize columns once when rows come in
// React.useEffect(() => {
// if (rows.length && columns.length === 0) {
// const dynamicCols = Array.from(
// rows.reduce<Set<string>>((acc, r) => {
// Object.keys(r || {}).forEach((k) => acc.add(k));
// return acc;
// }, new Set())
// );
// setColumns(dynamicCols);
// }
// }, [rows]);
// if (!rows?.length) return null;
// // ---------------- column handling ----------------
// const handleColumnNameChange = (colIdx: number, value: string) => {
// // Just update local columns state for typing responsiveness
// setColumns((prev) => prev.map((c, i) => (i === colIdx ? value : c)));
// };
// const commitColumnRename = (colIdx: number) => {
// const oldName = Object.keys(rows[0] ?? {})[colIdx];
// const newName = columns[colIdx];
// if (!oldName || !newName || oldName === newName) return;
// const updated = rows.map((r) => {
// const { [oldName]: oldVal, ...rest } = r;
// return { ...rest, [newName]: oldVal };
// });
// setRows(updated);
// };
// // ---------------- row/col editing ----------------
// const addColumn = () => {
// const newColName = `New Column ${columns.length + 1}`;
// setColumns((prev) => [...prev, newColName]);
// const updated = rows.map((r) => ({ ...r, [newColName]: "" }));
// setRows(updated);
// };
// const addRow = () => {
// const newRow: Row = {};
// columns.forEach((c) => (newRow[c] = ""));
// setRows([...rows, newRow]);
// };
// const handleCellChange = (rowIdx: number, col: string, value: string) => {
// const updated = rows.map((r, i) =>
// i === rowIdx ? { ...r, [col]: value } : r
// );
// setRows(updated);
// };
// // ---------------- render ----------------
// return (
// <div className="mt-6 overflow-auto border rounded-lg">
// <table className="min-w-full text-sm">
// <thead className="bg-gray-50">
// <tr>
// {columns.map((c, idx) => (
// <th
// key={idx}
// className="px-3 py-2 text-left font-semibold text-gray-700 whitespace-nowrap"
// >
// <input
// type="text"
// className="border rounded px-2 py-1 text-sm font-semibold w-40"
// value={c}
// onChange={(e) => handleColumnNameChange(idx, e.target.value)}
// onBlur={() => commitColumnRename(idx)}
// onKeyDown={(e) => {
// if (e.key === "Enter") {
// e.currentTarget.blur(); // commit rename
// }
// }}
// />
// </th>
// ))}
// <th className="px-3 py-2">
// <Button size="sm" variant="outline" onClick={addColumn}>
// + Add Column
// </Button>
// </th>
// </tr>
// </thead>
// <tbody>
// {rows.map((r, i) => (
// <tr key={i} className="odd:bg-white even:bg-gray-50">
// {columns.map((c) => (
// <td key={c} className="px-3 py-2 whitespace-nowrap">
// <input
// type="text"
// className="w-full border rounded px-2 py-1 text-sm"
// value={r[c] != null ? String(r[c]) : ""}
// onChange={(e) => handleCellChange(i, c, e.target.value)}
// />
// </td>
// ))}
// </tr>
// ))}
// <tr>
// <td colSpan={columns.length + 1} className="px-3 py-2 text-center">
// <Button size="sm" variant="outline" onClick={addRow}>
// + Add Row
// </Button>
// </td>
// </tr>
// </tbody>
// </table>
// </div>
// );
// }

View File

@@ -101,3 +101,41 @@ export const formatDateToHumanReadable = (
year: "numeric", // e.g., "2023", "2025"
}).format(date);
};
/**
* Convert any OCR numeric-ish value into a number.
* Handles string | number | null | undefined gracefully.
*/
export function toNum(val: string | number | null | undefined): number {
if (val == null || val === "") return 0;
if (typeof val === "number") return val;
const parsed = Number(val);
return isNaN(parsed) ? 0 : parsed;
}
/**
* Convert any OCR string-like value into a safe string.
*/
export function toStr(val: string | number | null | undefined): string {
if (val == null) return "";
return String(val).trim();
}
/**
* Convert OCR date strings like "070825" (MMDDYY) into a JS Date object.
* Example: "070825" → 2025-08-07.
*/
export function convertOCRDate(input: string | number | null | undefined): Date {
const raw = toStr(input);
if (!/^\d{6}$/.test(raw)) {
throw new Error(`Invalid OCR date format: ${raw}`);
}
const month = parseInt(raw.slice(0, 2), 10) - 1;
const day = parseInt(raw.slice(2, 4), 10);
const year2 = parseInt(raw.slice(4, 6), 10);
const year = year2 < 50 ? 2000 + year2 : 1900 + year2;
return new Date(year, month, day);
}