From 399c47dcfdb19836af6a6fd8af55d7754bb151fc Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 3 Sep 2025 23:05:23 +0530 Subject: [PATCH] feat(ocr) - schema updated, allowed payment model to allow both - claim and ocr data --- apps/Backend/src/routes/payments.ts | 26 ++- apps/Backend/src/services/paymentService.ts | 13 +- .../payments/payment-edit-modal.tsx | 40 ++-- .../components/payments/payment-ocr-block.tsx | 192 ++++++------------ apps/Frontend/src/utils/dateUtils.ts | 38 ++++ packages/db/prisma/schema.prisma | 15 +- packages/db/types/payment-types.ts | 2 + 7 files changed, 166 insertions(+), 160 deletions(-) diff --git a/apps/Backend/src/routes/payments.ts b/apps/Backend/src/routes/payments.ts index 6a56955..f0c9b20 100644 --- a/apps/Backend/src/routes/payments.ts +++ b/apps/Backend/src/routes/payments.ts @@ -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 what’s 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, diff --git a/apps/Backend/src/services/paymentService.ts b/apps/Backend/src/services/paymentService.ts index 19c5163..462b1bb 100644 --- a/apps/Backend/src/services/paymentService.ts +++ b/apps/Backend/src/services/paymentService.ts @@ -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}`); diff --git a/apps/Frontend/src/components/payments/payment-edit-modal.tsx b/apps/Frontend/src/components/payments/payment-edit-modal.tsx index 55b1024..28d4297 100644 --- a/apps/Frontend/src/components/payments/payment-edit-modal.tsx +++ b/apps/Frontend/src/components/payments/payment-edit-modal.tsx @@ -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 */}

- {payment.claim.patientName} + {payment.claim?.patientName ?? + (`${payment.patient?.firstName ?? ""} ${payment.patient?.lastName ?? ""}`.trim() || + "Unknown Patient")}

+
- - Claim #{payment.claimId.toString().padStart(4, "0")} - + {payment.claimId ? ( + + Claim #{payment.claimId.toString().padStart(4, "0")} + + ) : ( + + OCR Imported Payment + + )} Service Date:{" "} - {formatDateToHumanReadable(payment.claim.serviceDate)} + {payment.claim?.serviceDate + ? formatDateToHumanReadable(payment.claim.serviceDate) + : serviceLines.length > 0 + ? formatDateToHumanReadable(serviceLines[0]?.procedureDate) + : formatDateToHumanReadable(payment.createdAt)}
@@ -366,8 +378,8 @@ export default function PaymentEditModal({

Service Lines

- {payment.claim.serviceLines.length > 0 ? ( - payment.claim.serviceLines.map((line) => { + {serviceLines.length > 0 ? ( + serviceLines.map((line) => { const isExpanded = expandedLineId === line.id; return ( diff --git a/apps/Frontend/src/components/payments/payment-ocr-block.tsx b/apps/Frontend/src/components/payments/payment-ocr-block.tsx index 1376abc..ab6c1d2 100644 --- a/apps/Frontend/src/components/payments/payment-ocr-block.tsx +++ b/apps/Frontend/src/components/payments/payment-ocr-block.tsx @@ -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>((acc, row) => { + cleaned.reduce>((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"]), // you’ll 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 doesn’t 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() {
); } - -// --------------------- Editable OCRTable ---------------------------- - -// function OCRTable({ -// rows, -// setRows, -// }: { -// rows: Row[]; -// setRows: React.Dispatch>; -// }) { -// const [columns, setColumns] = React.useState([]); - -// // Initialize columns once when rows come in -// React.useEffect(() => { -// if (rows.length && columns.length === 0) { -// const dynamicCols = Array.from( -// rows.reduce>((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 ( -//
-// -// -// -// {columns.map((c, idx) => ( -// -// ))} -// -// -// -// -// {rows.map((r, i) => ( -// -// {columns.map((c) => ( -// -// ))} -// -// ))} -// -// -// -// -//
-// handleColumnNameChange(idx, e.target.value)} -// onBlur={() => commitColumnRename(idx)} -// onKeyDown={(e) => { -// if (e.key === "Enter") { -// e.currentTarget.blur(); // commit rename -// } -// }} -// /> -// -// -//
-// handleCellChange(i, c, e.target.value)} -// /> -//
-// -//
-//
-// ); -// } diff --git a/apps/Frontend/src/utils/dateUtils.ts b/apps/Frontend/src/utils/dateUtils.ts index f98ee67..8eb6bfa 100644 --- a/apps/Frontend/src/utils/dateUtils.ts +++ b/apps/Frontend/src/utils/dateUtils.ts @@ -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); +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index aa08cd4..ef5d6ed 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -94,7 +94,7 @@ model Staff { role String // e.g., "Dentist", "Hygienist", "Assistant" phone String? createdAt DateTime @default(now()) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) appointments Appointment[] claims Claim[] @relation("ClaimStaff") } @@ -133,7 +133,8 @@ enum ClaimStatus { model ServiceLine { id Int @id @default(autoincrement()) - claimId Int + claimId Int? + paymentId Int? procedureCode String procedureDate DateTime @db.Date oralCavityArea String? @@ -145,7 +146,9 @@ model ServiceLine { totalDue Decimal @default(0.00) @db.Decimal(10, 2) 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[] } @@ -204,7 +207,7 @@ enum PdfCategory { model Payment { id Int @id @default(autoincrement()) - claimId Int @unique + claimId Int? @unique patientId Int userId Int updatedById Int? @@ -217,12 +220,12 @@ model Payment { createdAt DateTime @default(now()) 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) updatedBy User? @relation("PaymentUpdatedBy", fields: [updatedById], references: [id]) serviceLineTransactions ServiceLineTransaction[] + serviceLines ServiceLine[] - @@index([id]) @@index([claimId]) @@index([patientId]) } diff --git a/packages/db/types/payment-types.ts b/packages/db/types/payment-types.ts index c6739c8..5283e53 100644 --- a/packages/db/types/payment-types.ts +++ b/packages/db/types/payment-types.ts @@ -67,12 +67,14 @@ export type PaymentWithExtras = Prisma.PaymentGetPayload<{ serviceLines: true; }; }; + serviceLines: true; // ✅ OCR-only service lines directly under Payment serviceLineTransactions: { include: { serviceLine: true; }; }; updatedBy: true; + patient: true; }; }> & { patientName: string;