feat(patietn tabular view modal) - redirect to payment-view added

This commit is contained in:
2025-10-09 05:39:02 +05:30
parent 64e338ba60
commit d685376d80
5 changed files with 375 additions and 81 deletions

View File

@@ -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 };

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -139,7 +157,11 @@ export function PatientFinancialsModal({
{patientName ? (
<>
<span className="font-medium">{patientName}</span>{" "}
{patientPID && <span className="text-muted-foreground"> PID-{String(patientPID).padStart(4, "0")}</span>}
{patientPID && (
<span className="text-muted-foreground">
PID-{String(patientPID).padStart(4, "0")}
</span>
)}
</>
) : (
"Claims, payments and balances for this patient."
@@ -148,7 +170,11 @@ export function PatientFinancialsModal({
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
>
Close
</Button>
</div>
@@ -181,7 +207,10 @@ export function PatientFinancialsModal({
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={8}
className="text-center py-8 text-muted-foreground"
>
No records found.
</TableCell>
</TableRow>
@@ -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 (
<TableRow
@@ -204,13 +234,29 @@ export function PatientFinancialsModal({
className="cursor-pointer hover:bg-gray-50"
onClick={() => gotoRow(r)}
>
<TableCell className="font-medium">{r.type}</TableCell>
<TableCell>{r.date ? new Date(r.date).toLocaleDateString() : "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{procedureCodes}</TableCell>
<TableCell className="text-right">{billed.toFixed(2)}</TableCell>
<TableCell className="text-right">{paid.toFixed(2)}</TableCell>
<TableCell className="text-right">{adjusted.toFixed(2)}</TableCell>
<TableCell className={`text-right ${totalDue > 0 ? "text-red-600" : "text-green-600"}`}>
<TableCell className="font-medium">
{r.type}
</TableCell>
<TableCell>
{r.date
? new Date(r.date).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{procedureCodes}
</TableCell>
<TableCell className="text-right">
{billed.toFixed(2)}
</TableCell>
<TableCell className="text-right">
{paid.toFixed(2)}
</TableCell>
<TableCell className="text-right">
{adjusted.toFixed(2)}
</TableCell>
<TableCell
className={`text-right ${totalDue > 0 ? "text-red-600" : "text-green-600"}`}
>
{totalDue.toFixed(2)}
</TableCell>
<TableCell>{r.status ?? "-"}</TableCell>
@@ -243,7 +289,9 @@ export function PatientFinancialsModal({
</div>
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium">{startItem}</span><span className="font-medium">{endItem}</span> of <span className="font-medium">{totalCount}</span>
Showing <span className="font-medium">{startItem}</span>
<span className="font-medium">{endItem}</span> of{" "}
<span className="font-medium">{totalCount}</span>
</div>
</div>
@@ -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"
: ""
}
/>
</PaginationItem>
@@ -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"
: ""
}
/>
</PaginationItem>
</PaginationContent>

View File

@@ -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<number | null>(null);
const [paymentStatus, setPaymentStatus] = React.useState<PaymentStatus>(
payment.status
// Local payment state: prefer prop but fetch if paymentId is provided
const [payment, setPayment] = useState<PaymentWithExtras | null>(
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<number | null>(null);
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus>(
(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 (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<div className="p-8 text-center">
{loadingPayment ? "Loading…" : "No payment selected"}
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
@@ -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({
<Button
variant="outline"
size="sm"
onClick={() => handleEditServiceLine(line.id)}
onClick={() => handleEditServiceLineClick(line.id)}
>
{isExpanded ? "Cancel" : "Pay Partially"}
</Button>

View File

@@ -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<string>("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<number | null>(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 (
<div>
{/* 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 */}
<PaymentEditModal
isOpen={isEditModalOpen}
onOpenChange={(v) => setIsEditModalOpen(v)}
onClose={() => {
setIsEditModalOpen(false);
setPaymentIdToEdit(null);
}}
paymentId={paymentIdToEdit}
/>
</div>
);
}

View File

@@ -68,5 +68,5 @@ export type FinancialRow = {
total_due: number;
patient_name: string | null;
service_lines: any[];
payments: any[];
linked_payment_id?: number | null;
};