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" }); 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)) .filter((line) => line.totalDue.gt(0))
.map((line) => ({ .map((line) => ({
serviceLineId: line.id, serviceLineId: line.id,
@@ -273,8 +284,19 @@ router.put(
if (!paymentRecord) { if (!paymentRecord) {
return res.status(404).json({ message: "Payment not found" }); 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) // 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)) .filter((line) => line.totalPaid.gt(0) || line.totalAdjusted.gt(0))
.map((line) => ({ .map((line) => ({
serviceLineId: line.id, serviceLineId: line.id,

View File

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

View File

@@ -14,6 +14,7 @@ import {
flexRender, flexRender,
ColumnDef, ColumnDef,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { convertOCRDate } from "@/utils/dateUtils";
// ---------------- Types ---------------- // ---------------- Types ----------------
@@ -45,11 +46,17 @@ export default function PaymentOCRBlock() {
return Array.isArray(data) ? data : data.rows; return Array.isArray(data) ? data : data.rows;
}, },
onSuccess: (data) => { 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); setRows(withIds);
const allKeys = Array.from( const allKeys = Array.from(
data.reduce<Set<string>>((acc, row) => { cleaned.reduce<Set<string>>((acc, row) => {
Object.keys(row).forEach((k) => acc.add(k)); Object.keys(row).forEach((k) => acc.add(k));
return acc; return acc;
}, new Set()) }, new Set())
@@ -147,17 +154,57 @@ export default function PaymentOCRBlock() {
extractPaymentOCR.mutate(uploadedImages); extractPaymentOCR.mutate(uploadedImages);
}; };
const handleSave = () => { const handleSave = async () => {
console.log("Saving edited rows:", rows); try {
toast({ const payload = rows.map((row) => {
title: "Saved", const billed = Number(row["Billed Amount"] ?? 0);
description: "Edited OCR results are ready to be sent to the database.", 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(),
},
};
}); });
// Here can POST `rows` to your backend API for DB save
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 //rows helper
const handleAddRow = () => { const handleAddRow = () => {
const newRow: Row = { __id: rows.length }; const newRow: Row = { __id: rows.length };
columns.forEach((c) => { columns.forEach((c) => {
@@ -367,128 +414,3 @@ export default function PaymentOCRBlock() {
</div> </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" year: "numeric", // e.g., "2023", "2025"
}).format(date); }).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);
}

View File

@@ -133,7 +133,8 @@ enum ClaimStatus {
model ServiceLine { model ServiceLine {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
claimId Int claimId Int?
paymentId Int?
procedureCode String procedureCode String
procedureDate DateTime @db.Date procedureDate DateTime @db.Date
oralCavityArea String? oralCavityArea String?
@@ -145,7 +146,9 @@ model ServiceLine {
totalDue Decimal @default(0.00) @db.Decimal(10, 2) totalDue Decimal @default(0.00) @db.Decimal(10, 2)
status ServiceLineStatus @default(UNPAID) status ServiceLineStatus @default(UNPAID)
claim Claim @relation(fields: [claimId], references: [id], onDelete: Cascade) claim Claim? @relation(fields: [claimId], references: [id], onDelete: Cascade)
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Cascade)
serviceLineTransactions ServiceLineTransaction[] serviceLineTransactions ServiceLineTransaction[]
} }
@@ -204,7 +207,7 @@ enum PdfCategory {
model Payment { model Payment {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
claimId Int @unique claimId Int? @unique
patientId Int patientId Int
userId Int userId Int
updatedById Int? updatedById Int?
@@ -217,12 +220,12 @@ model Payment {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
claim Claim @relation(fields: [claimId], references: [id], onDelete: Cascade) claim Claim? @relation(fields: [claimId], references: [id], onDelete: Cascade)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
updatedBy User? @relation("PaymentUpdatedBy", fields: [updatedById], references: [id]) updatedBy User? @relation("PaymentUpdatedBy", fields: [updatedById], references: [id])
serviceLineTransactions ServiceLineTransaction[] serviceLineTransactions ServiceLineTransaction[]
serviceLines ServiceLine[]
@@index([id])
@@index([claimId]) @@index([claimId])
@@index([patientId]) @@index([patientId])
} }

View File

@@ -67,12 +67,14 @@ export type PaymentWithExtras = Prisma.PaymentGetPayload<{
serviceLines: true; serviceLines: true;
}; };
}; };
serviceLines: true; // ✅ OCR-only service lines directly under Payment
serviceLineTransactions: { serviceLineTransactions: {
include: { include: {
serviceLine: true; serviceLine: true;
}; };
}; };
updatedBy: true; updatedBy: true;
patient: true;
}; };
}> & { }> & {
patientName: string; patientName: string;