diff --git a/apps/Backend/src/storage/patients-storage.ts b/apps/Backend/src/storage/patients-storage.ts index b837163..4310bb8 100644 --- a/apps/Backend/src/storage/patients-storage.ts +++ b/apps/Backend/src/storage/patients-storage.ts @@ -160,15 +160,15 @@ export const getPatientFinancialRowsFn = async ( offset = 0 ): Promise<{ rows: FinancialRow[]; totalCount: number }> => { try { - // counts - const [[{ count_claims }], [{ count_payments_without_claim }]] = + // Count claims and orphan payments + const [[{ count_claims }], [{ count_orphan_payments }]] = (await Promise.all([ db.$queryRaw`SELECT COUNT(1) AS count_claims FROM "Claim" c WHERE c."patientId" = ${patientId}`, - db.$queryRaw`SELECT COUNT(1) AS count_payments_without_claim FROM "Payment" p WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL`, + db.$queryRaw`SELECT COUNT(1) AS count_orphan_payments FROM "Payment" p WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL`, ])) as any; const totalCount = - Number(count_claims ?? 0) + Number(count_payments_without_claim ?? 0); + Number(count_claims ?? 0) + Number(count_orphan_payments ?? 0); const rawRows = (await db.$queryRaw` WITH claim_rows AS ( @@ -185,6 +185,12 @@ export const getPatientFinancialRowsFn = async ( ( SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = c."patientId" LIMIT 1 ) AS patient_name, + + -- linked_payment_id (NULL if none). Schema has unique Payment.claimId so LIMIT 1 is safe. + ( + SELECT p2.id FROM "Payment" p2 WHERE p2."claimId" = c.id LIMIT 1 + ) AS linked_payment_id, + ( SELECT coalesce(json_agg( json_build_object( @@ -201,29 +207,13 @@ export const getPatientFinancialRowsFn = async ( ) ), '[]'::json) FROM "ServiceLine" sl2 WHERE sl2."claimId" = c.id - ) AS service_lines, - ( - SELECT coalesce(json_agg( - json_build_object( - 'id', p2.id, - 'totalBilled', p2."totalBilled", - 'totalPaid', p2."totalPaid", - 'totalAdjusted', p2."totalAdjusted", - 'totalDue', p2."totalDue", - 'status', p2.status::text, - 'createdAt', p2."createdAt", - 'icn', p2.icn, - 'notes', p2.notes - ) - ), '[]'::json) - FROM "Payment" p2 WHERE p2."claimId" = c.id - ) AS payments + ) AS service_lines FROM "Claim" c LEFT JOIN "ServiceLine" sl ON sl."claimId" = c.id WHERE c."patientId" = ${patientId} GROUP BY c.id ), - payment_rows AS ( + orphan_payment_rows AS ( SELECT 'PAYMENT'::text AS type, p.id, @@ -237,7 +227,10 @@ export const getPatientFinancialRowsFn = async ( ( SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = p."patientId" LIMIT 1 ) AS patient_name, - -- aggregate service lines that belong to this payment (if any) + + -- this payment's id is the linked_payment_id + p.id AS linked_payment_id, + ( SELECT coalesce(json_agg( json_build_object( @@ -254,28 +247,15 @@ export const getPatientFinancialRowsFn = async ( ) ), '[]'::json) FROM "ServiceLine" sl3 WHERE sl3."paymentId" = p.id - ) AS service_lines, - json_build_array( - json_build_object( - 'id', p.id, - 'totalBilled', p."totalBilled", - 'totalPaid', p."totalPaid", - 'totalAdjusted', p."totalAdjusted", - 'totalDue', p."totalDue", - 'status', p.status::text, - 'createdAt', p."createdAt", - 'icn', p.icn, - 'notes', p.notes - ) - ) AS payments + ) AS service_lines FROM "Payment" p WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL ) - SELECT type, id, date, created_at, status, total_billed, total_paid, total_adjusted, total_due, patient_name, service_lines, payments + SELECT type, id, date, created_at, status, total_billed, total_paid, total_adjusted, total_due, patient_name, linked_payment_id, service_lines FROM ( SELECT * FROM claim_rows UNION ALL - SELECT * FROM payment_rows + SELECT * FROM orphan_payment_rows ) t ORDER BY t.created_at DESC LIMIT ${limit} OFFSET ${offset} @@ -294,7 +274,9 @@ export const getPatientFinancialRowsFn = async ( total_due: Number(r.total_due ?? 0), patient_name: r.patient_name ?? null, service_lines: r.service_lines ?? [], - payments: r.payments ?? [], + linked_payment_id: r.linked_payment_id + ? Number(r.linked_payment_id) + : null, })); return { rows, totalCount }; diff --git a/apps/Frontend/src/components/patients/patient-financial-modal.tsx b/apps/Frontend/src/components/patients/patient-financial-modal.tsx index 76e7a14..3f202a7 100644 --- a/apps/Frontend/src/components/patients/patient-financial-modal.tsx +++ b/apps/Frontend/src/components/patients/patient-financial-modal.tsx @@ -111,8 +111,20 @@ export function PatientFinancialsModal({ } function gotoRow(r: FinancialRow) { - if (r.type === "CLAIM") navigate(`/claims/${r.id}`); - else navigate(`/payments/${r.id}`); + // If there's an explicit linked payment id, navigate to that payment + if (r.linked_payment_id) { + navigate(`/payments?paymentId=${r.linked_payment_id}`); + onOpenChange(false); + return; + } + + // If this is a PAYMENT row but somehow has no linked id, fallback to its id + if (r.type === "PAYMENT") { + navigate(`/payments?paymentId=${r.id}`); + onOpenChange(false); + return; + } + onOpenChange(false); } @@ -125,8 +137,14 @@ export function PatientFinancialsModal({ setOffset((page - 1) * limit); } - const startItem = useMemo(() => Math.min(offset + 1, totalCount || 0), [offset, totalCount]); - const endItem = useMemo(() => Math.min(offset + limit, totalCount || 0), [offset, limit, totalCount]); + const startItem = useMemo( + () => Math.min(offset + 1, totalCount || 0), + [offset, totalCount] + ); + const endItem = useMemo( + () => Math.min(offset + limit, totalCount || 0), + [offset, limit, totalCount] + ); return ( @@ -139,7 +157,11 @@ export function PatientFinancialsModal({ {patientName ? ( <> {patientName}{" "} - {patientPID && • PID-{String(patientPID).padStart(4, "0")}} + {patientPID && ( + + • PID-{String(patientPID).padStart(4, "0")} + + )} ) : ( "Claims, payments and balances for this patient." @@ -148,7 +170,11 @@ export function PatientFinancialsModal({
-
@@ -181,7 +207,10 @@ export function PatientFinancialsModal({ ) : rows.length === 0 ? ( - + No records found. @@ -194,9 +223,10 @@ export function PatientFinancialsModal({ const procedureCodes = (r.service_lines || []) - .map((sl: any) => sl.procedureCode ?? sl.procedureCode) + .map((sl: any) => sl.procedureCode) .filter(Boolean) - .join(", ") || (r.payments?.length ? "No Codes Given" : "-"); + .join(", ") || + (r.linked_payment_id ? "No Codes Given" : "-"); return ( gotoRow(r)} > - {r.type} - {r.date ? new Date(r.date).toLocaleDateString() : "-"} - {procedureCodes} - {billed.toFixed(2)} - {paid.toFixed(2)} - {adjusted.toFixed(2)} - 0 ? "text-red-600" : "text-green-600"}`}> + + {r.type} + + + {r.date + ? new Date(r.date).toLocaleDateString() + : "-"} + + + {procedureCodes} + + + {billed.toFixed(2)} + + + {paid.toFixed(2)} + + + {adjusted.toFixed(2)} + + 0 ? "text-red-600" : "text-green-600"}`} + > {totalDue.toFixed(2)} {r.status ?? "-"} @@ -243,7 +289,9 @@ export function PatientFinancialsModal({
- Showing {startItem}{endItem} of {totalCount} + Showing {startItem}– + {endItem} of{" "} + {totalCount}
@@ -257,7 +305,11 @@ export function PatientFinancialsModal({ e.preventDefault(); if (currentPage > 1) setPage(currentPage - 1); }} - className={currentPage === 1 ? "pointer-events-none opacity-50" : ""} + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "" + } /> @@ -287,7 +339,11 @@ export function PatientFinancialsModal({ e.preventDefault(); if (currentPage < totalPages) setPage(currentPage + 1); }} - className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""} + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "" + } /> diff --git a/apps/Frontend/src/components/payments/payment-edit-modal.tsx b/apps/Frontend/src/components/payments/payment-edit-modal.tsx index cdaaaeb..da29e1f 100644 --- a/apps/Frontend/src/components/payments/payment-edit-modal.tsx +++ b/apps/Frontend/src/components/payments/payment-edit-modal.tsx @@ -11,7 +11,7 @@ import { formatLocalDate, parseLocalDate, } from "@/utils/dateUtils"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { PaymentStatus, paymentStatusOptions, @@ -31,34 +31,59 @@ import { Input } from "@/components/ui/input"; import { toast } from "@/hooks/use-toast"; import { X } from "lucide-react"; import { DateInput } from "@/components/ui/dateInput"; +import { apiRequest } from "@/lib/queryClient"; +import { useMutation } from "@tanstack/react-query"; type PaymentEditModalProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; onClose: () => void; - onEditServiceLine: (payload: NewTransactionPayload) => void; + + // Keeping callbacks optional — if provided parent handlers will be used + onEditServiceLine?: (payload: NewTransactionPayload) => void; isUpdatingServiceLine?: boolean; - onUpdateStatus: (paymentId: number, status: PaymentStatus) => void; + + onUpdateStatus?: (paymentId: number, status: PaymentStatus) => void; isUpdatingStatus?: boolean; - payment: PaymentWithExtras | null; + + // Either pass a full payment object OR a paymentId (or both) + payment?: PaymentWithExtras | null; + paymentId?: number | null; }; export default function PaymentEditModal({ isOpen, onOpenChange, onClose, - payment, onEditServiceLine, - isUpdatingServiceLine, + isUpdatingServiceLine: propUpdatingServiceLine, onUpdateStatus, - isUpdatingStatus, + isUpdatingStatus: propUpdatingStatus, + payment: paymentProp, + paymentId: paymentIdProp, }: PaymentEditModalProps) { - if (!payment) return null; - - const [expandedLineId, setExpandedLineId] = useState(null); - const [paymentStatus, setPaymentStatus] = React.useState( - payment.status + // Local payment state: prefer prop but fetch if paymentId is provided + const [payment, setPayment] = useState( + paymentProp ?? null ); + const [loadingPayment, setLoadingPayment] = useState(false); + + // Local update states (used if parent didn't provide flags) + const [localUpdatingServiceLine, setLocalUpdatingServiceLine] = + useState(false); + const [localUpdatingStatus, setLocalUpdatingStatus] = useState(false); + + // derived flags - prefer parent's flags if provided + const isUpdatingServiceLine = + propUpdatingServiceLine ?? localUpdatingServiceLine; + const isUpdatingStatus = propUpdatingStatus ?? localUpdatingStatus; + + // UI state (kept from your original) + const [expandedLineId, setExpandedLineId] = useState(null); + const [paymentStatus, setPaymentStatus] = useState( + (paymentProp ?? null)?.status ?? ("PENDING" as PaymentStatus) + ); + const [formState, setFormState] = useState(() => { return { serviceLineId: 0, @@ -72,10 +97,191 @@ export default function PaymentEditModal({ }; }); - const serviceLines = - payment.claim?.serviceLines ?? payment.serviceLines ?? []; + // Sync when parent passes a payment object or paymentId changes + useEffect(() => { + // if parent gave a full payment object, use it immediately + if (paymentProp) { + setPayment(paymentProp); + setPaymentStatus(paymentProp.status); + } + }, [paymentProp]); - const handleEditServiceLine = (lineId: number) => { + // Fetch payment when modal opens and we only have paymentId (or payment prop not supplied) + useEffect(() => { + if (!isOpen) return; + + // if payment prop is already available, no need to fetch + if (paymentProp) return; + + const id = paymentIdProp ?? payment?.id; + if (!id) return; + + let cancelled = false; + (async () => { + setLoadingPayment(true); + try { + const res = await apiRequest("GET", `/api/payments/${id}`); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.message ?? `Failed to fetch payment ${id}`); + } + const data = await res.json(); + if (!cancelled) { + setPayment(data as PaymentWithExtras); + setPaymentStatus((data as PaymentWithExtras).status); + } + } catch (err: any) { + console.error("Failed to fetch payment:", err); + toast({ + title: "Error", + description: err?.message ?? "Failed to load payment.", + variant: "destructive", + }); + } finally { + if (!cancelled) setLoadingPayment(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [isOpen, paymentIdProp]); + + // convenience: get service lines from claim or payment + const serviceLines = + payment?.claim?.serviceLines ?? payment?.serviceLines ?? []; + + // small helper to refresh current payment (used after internal writes) + async function refetchPayment(id?: number) { + const pid = id ?? payment?.id ?? paymentIdProp; + if (!pid) return; + setLoadingPayment(true); + try { + const res = await apiRequest("GET", `/api/payments/${pid}`); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.message ?? `Failed to fetch payment ${pid}`); + } + const data = await res.json(); + setPayment(data as PaymentWithExtras); + setPaymentStatus((data as PaymentWithExtras).status); + } catch (err: any) { + console.error("Failed to refetch payment:", err); + toast({ + title: "Error", + description: err?.message ?? "Failed to refresh payment.", + variant: "destructive", + }); + } finally { + setLoadingPayment(false); + } + } + + // Internal save (fallback) — used only when parent didn't provide onEditServiceLine + const internalUpdatePaymentMutation = useMutation({ + mutationFn: async (data: NewTransactionPayload) => { + const response = await apiRequest( + "PUT", + `/api/payments/${data.paymentId}`, + { + data: data, + } + ); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to update Payment"); + } + return response.json(); + }, + onSuccess: async (updated, { paymentId }) => { + toast({ + title: "Success", + description: "Payment updated successfully!", + }); + + await refetchPayment(paymentId); + }, + + onError: (error) => { + toast({ + title: "Error", + description: `Update failed: ${error.message}`, + variant: "destructive", + }); + }, + }); + + const internalUpdatePaymentStatusMutation = useMutation({ + mutationFn: async ({ + paymentId, + status, + }: { + paymentId: number; + status: PaymentStatus; + }) => { + const response = await apiRequest( + "PATCH", + `/api/payments/${paymentId}/status`, + { + data: { status }, + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to update payment status"); + } + + return response.json(); + }, + + onSuccess: async (updated, { paymentId }) => { + toast({ + title: "Success", + description: "Payment Status updated successfully!", + }); + + // Fetch updated payment and set into local state + await refetchPayment(paymentId); + }, + onError: (error) => { + toast({ + title: "Error", + description: `Status update failed: ${error.message}`, + variant: "destructive", + }); + }, + }); + + // Keep your existing handlers but route to either parent callback or internal functions + const handleEditServiceLine = async (payload: NewTransactionPayload) => { + if (onEditServiceLine) { + await onEditServiceLine(payload); + } else { + // fallback to internal API call + setLocalUpdatingServiceLine(true); + await internalUpdatePaymentMutation.mutateAsync(payload); + setLocalUpdatingServiceLine(false); + } + }; + + const handleUpdateStatus = async ( + paymentId: number, + status: PaymentStatus + ) => { + if (onUpdateStatus) { + await onUpdateStatus(paymentId, status); + } else { + setLocalUpdatingStatus(true); + await internalUpdatePaymentStatusMutation.mutateAsync({ + paymentId, + status, + }); + setLocalUpdatingStatus(false); + } + }; + + const handleEditServiceLineClick = (lineId: number) => { if (expandedLineId === lineId) { // Closing current line setExpandedLineId(null); @@ -109,6 +315,14 @@ export default function PaymentEditModal({ }; const handleSavePayment = async () => { + if (!payment) { + toast({ + title: "Error", + description: "Payment not loaded.", + variant: "destructive", + }); + return; + } if (!formState.serviceLineId) { toast({ title: "Error", @@ -178,7 +392,7 @@ export default function PaymentEditModal({ }; try { - await onEditServiceLine(payload); + await handleEditServiceLine(payload); toast({ title: "Success", description: "Payment Transaction added successfully.", @@ -224,7 +438,7 @@ export default function PaymentEditModal({ }; try { - await onEditServiceLine(payload); + await handleEditServiceLine(payload); toast({ title: "Success", description: `Full due amount ($${dueAmount.toFixed( @@ -241,6 +455,18 @@ export default function PaymentEditModal({ } }; + if (!payment) { + return ( + + +
+ {loadingPayment ? "Loading…" : "No payment selected"} +
+
+
+ ); + } + return ( @@ -352,7 +578,7 @@ export default function PaymentEditModal({ size="sm" disabled={isUpdatingStatus} onClick={() => - payment && onUpdateStatus(payment.id, paymentStatus) + payment && handleUpdateStatus(payment.id, paymentStatus) } > {isUpdatingStatus ? "Updating..." : "Update Status"} @@ -432,7 +658,7 @@ export default function PaymentEditModal({ diff --git a/apps/Frontend/src/pages/payments-page.tsx b/apps/Frontend/src/pages/payments-page.tsx index 9d14190..93052e1 100644 --- a/apps/Frontend/src/pages/payments-page.tsx +++ b/apps/Frontend/src/pages/payments-page.tsx @@ -18,9 +18,10 @@ import PaymentsRecentTable from "@/components/payments/payments-recent-table"; import PaymentsOfPatientModal from "@/components/payments/payments-of-patient-table"; import PaymentOCRBlock from "@/components/payments/payment-ocr-block"; import { useLocation } from "wouter"; -import { Patient } from "@repo/db/types"; +import { Patient, PaymentWithExtras } from "@repo/db/types"; import { apiRequest } from "@/lib/queryClient"; import { toast } from "@/hooks/use-toast"; +import PaymentEditModal from "@/components/payments/payment-edit-modal"; export default function PaymentsPage() { const [paymentPeriod, setPaymentPeriod] = useState("all-time"); @@ -32,6 +33,10 @@ export default function PaymentsPage() { const [openPatientModalFromAppointment, setOpenPatientModalFromAppointment] = useState(false); + // Payment edit modal state (opens directly when ?paymentId=) + const [paymentIdToEdit, setPaymentIdToEdit] = useState(null); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + // small helper: remove query params silently const clearUrlParams = (params: string[]) => { try { @@ -105,6 +110,20 @@ export default function PaymentsPage() { }; }, [location]); + // NEW: detect paymentId query param -> open edit modal (modal will fetch by id) + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const paymentIdParam = params.get("paymentId"); + if (!paymentIdParam) return; + const paymentId = Number(paymentIdParam); + if (!Number.isFinite(paymentId) || paymentId <= 0) return; + + // Open modal with paymentId and clear params + setPaymentIdToEdit(paymentId); + setIsEditModalOpen(true); + clearUrlParams(["paymentId", "patientId"]); + }, [location]); + return (
{/* Header */} @@ -215,6 +234,17 @@ export default function PaymentsPage() { setInitialPatientForModal(null); }} /> + + {/* Payment Edit Modal — modal will fetch payment by id and handle its own save/update */} + setIsEditModalOpen(v)} + onClose={() => { + setIsEditModalOpen(false); + setPaymentIdToEdit(null); + }} + paymentId={paymentIdToEdit} + />
); } diff --git a/packages/db/types/patient-types.ts b/packages/db/types/patient-types.ts index decf996..9111312 100644 --- a/packages/db/types/patient-types.ts +++ b/packages/db/types/patient-types.ts @@ -68,5 +68,5 @@ export type FinancialRow = { total_due: number; patient_name: string | null; service_lines: any[]; - payments: any[]; -}; \ No newline at end of file + linked_payment_id?: number | null; +};