feat(patietn tabular view modal) - redirect to payment-view added
This commit is contained in:
@@ -160,15 +160,15 @@ export const getPatientFinancialRowsFn = async (
|
|||||||
offset = 0
|
offset = 0
|
||||||
): Promise<{ rows: FinancialRow[]; totalCount: number }> => {
|
): Promise<{ rows: FinancialRow[]; totalCount: number }> => {
|
||||||
try {
|
try {
|
||||||
// counts
|
// Count claims and orphan payments
|
||||||
const [[{ count_claims }], [{ count_payments_without_claim }]] =
|
const [[{ count_claims }], [{ count_orphan_payments }]] =
|
||||||
(await Promise.all([
|
(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_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;
|
])) as any;
|
||||||
|
|
||||||
const totalCount =
|
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`
|
const rawRows = (await db.$queryRaw`
|
||||||
WITH claim_rows AS (
|
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
|
SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = c."patientId" LIMIT 1
|
||||||
) AS patient_name,
|
) 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(
|
SELECT coalesce(json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
@@ -201,29 +207,13 @@ export const getPatientFinancialRowsFn = async (
|
|||||||
)
|
)
|
||||||
), '[]'::json)
|
), '[]'::json)
|
||||||
FROM "ServiceLine" sl2 WHERE sl2."claimId" = c.id
|
FROM "ServiceLine" sl2 WHERE sl2."claimId" = c.id
|
||||||
) AS service_lines,
|
) 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
|
|
||||||
FROM "Claim" c
|
FROM "Claim" c
|
||||||
LEFT JOIN "ServiceLine" sl ON sl."claimId" = c.id
|
LEFT JOIN "ServiceLine" sl ON sl."claimId" = c.id
|
||||||
WHERE c."patientId" = ${patientId}
|
WHERE c."patientId" = ${patientId}
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
),
|
),
|
||||||
payment_rows AS (
|
orphan_payment_rows AS (
|
||||||
SELECT
|
SELECT
|
||||||
'PAYMENT'::text AS type,
|
'PAYMENT'::text AS type,
|
||||||
p.id,
|
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
|
SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = p."patientId" LIMIT 1
|
||||||
) AS patient_name,
|
) 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(
|
SELECT coalesce(json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
@@ -254,28 +247,15 @@ export const getPatientFinancialRowsFn = async (
|
|||||||
)
|
)
|
||||||
), '[]'::json)
|
), '[]'::json)
|
||||||
FROM "ServiceLine" sl3 WHERE sl3."paymentId" = p.id
|
FROM "ServiceLine" sl3 WHERE sl3."paymentId" = p.id
|
||||||
) AS service_lines,
|
) 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
|
|
||||||
FROM "Payment" p
|
FROM "Payment" p
|
||||||
WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL
|
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 (
|
FROM (
|
||||||
SELECT * FROM claim_rows
|
SELECT * FROM claim_rows
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT * FROM payment_rows
|
SELECT * FROM orphan_payment_rows
|
||||||
) t
|
) t
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
@@ -294,7 +274,9 @@ export const getPatientFinancialRowsFn = async (
|
|||||||
total_due: Number(r.total_due ?? 0),
|
total_due: Number(r.total_due ?? 0),
|
||||||
patient_name: r.patient_name ?? null,
|
patient_name: r.patient_name ?? null,
|
||||||
service_lines: r.service_lines ?? [],
|
service_lines: r.service_lines ?? [],
|
||||||
payments: r.payments ?? [],
|
linked_payment_id: r.linked_payment_id
|
||||||
|
? Number(r.linked_payment_id)
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { rows, totalCount };
|
return { rows, totalCount };
|
||||||
|
|||||||
@@ -111,8 +111,20 @@ export function PatientFinancialsModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gotoRow(r: FinancialRow) {
|
function gotoRow(r: FinancialRow) {
|
||||||
if (r.type === "CLAIM") navigate(`/claims/${r.id}`);
|
// If there's an explicit linked payment id, navigate to that payment
|
||||||
else navigate(`/payments/${r.id}`);
|
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);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +137,14 @@ export function PatientFinancialsModal({
|
|||||||
setOffset((page - 1) * limit);
|
setOffset((page - 1) * limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
const startItem = useMemo(() => Math.min(offset + 1, totalCount || 0), [offset, totalCount]);
|
const startItem = useMemo(
|
||||||
const endItem = useMemo(() => Math.min(offset + limit, totalCount || 0), [offset, limit, totalCount]);
|
() => Math.min(offset + 1, totalCount || 0),
|
||||||
|
[offset, totalCount]
|
||||||
|
);
|
||||||
|
const endItem = useMemo(
|
||||||
|
() => Math.min(offset + limit, totalCount || 0),
|
||||||
|
[offset, limit, totalCount]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -139,7 +157,11 @@ export function PatientFinancialsModal({
|
|||||||
{patientName ? (
|
{patientName ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-medium">{patientName}</span>{" "}
|
<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."
|
"Claims, payments and balances for this patient."
|
||||||
@@ -148,7 +170,11 @@ export function PatientFinancialsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +207,10 @@ export function PatientFinancialsModal({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<TableRow>
|
<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.
|
No records found.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -194,9 +223,10 @@ export function PatientFinancialsModal({
|
|||||||
|
|
||||||
const procedureCodes =
|
const procedureCodes =
|
||||||
(r.service_lines || [])
|
(r.service_lines || [])
|
||||||
.map((sl: any) => sl.procedureCode ?? sl.procedureCode)
|
.map((sl: any) => sl.procedureCode)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ") || (r.payments?.length ? "No Codes Given" : "-");
|
.join(", ") ||
|
||||||
|
(r.linked_payment_id ? "No Codes Given" : "-");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -204,13 +234,29 @@ export function PatientFinancialsModal({
|
|||||||
className="cursor-pointer hover:bg-gray-50"
|
className="cursor-pointer hover:bg-gray-50"
|
||||||
onClick={() => gotoRow(r)}
|
onClick={() => gotoRow(r)}
|
||||||
>
|
>
|
||||||
<TableCell className="font-medium">{r.type}</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell>{r.date ? new Date(r.date).toLocaleDateString() : "-"}</TableCell>
|
{r.type}
|
||||||
<TableCell className="text-sm text-muted-foreground">{procedureCodes}</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">{billed.toFixed(2)}</TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-right">{paid.toFixed(2)}</TableCell>
|
{r.date
|
||||||
<TableCell className="text-right">{adjusted.toFixed(2)}</TableCell>
|
? new Date(r.date).toLocaleDateString()
|
||||||
<TableCell className={`text-right ${totalDue > 0 ? "text-red-600" : "text-green-600"}`}>
|
: "-"}
|
||||||
|
</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)}
|
{totalDue.toFixed(2)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{r.status ?? "-"}</TableCell>
|
<TableCell>{r.status ?? "-"}</TableCell>
|
||||||
@@ -243,7 +289,9 @@ export function PatientFinancialsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -257,7 +305,11 @@ export function PatientFinancialsModal({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentPage > 1) setPage(currentPage - 1);
|
if (currentPage > 1) setPage(currentPage - 1);
|
||||||
}}
|
}}
|
||||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
className={
|
||||||
|
currentPage === 1
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
@@ -287,7 +339,11 @@ export function PatientFinancialsModal({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentPage < totalPages) setPage(currentPage + 1);
|
if (currentPage < totalPages) setPage(currentPage + 1);
|
||||||
}}
|
}}
|
||||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
|
className={
|
||||||
|
currentPage === totalPages
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
formatLocalDate,
|
formatLocalDate,
|
||||||
parseLocalDate,
|
parseLocalDate,
|
||||||
} from "@/utils/dateUtils";
|
} from "@/utils/dateUtils";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
PaymentStatus,
|
PaymentStatus,
|
||||||
paymentStatusOptions,
|
paymentStatusOptions,
|
||||||
@@ -31,34 +31,59 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { DateInput } from "@/components/ui/dateInput";
|
import { DateInput } from "@/components/ui/dateInput";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
type PaymentEditModalProps = {
|
type PaymentEditModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEditServiceLine: (payload: NewTransactionPayload) => void;
|
|
||||||
|
// Keeping callbacks optional — if provided parent handlers will be used
|
||||||
|
onEditServiceLine?: (payload: NewTransactionPayload) => void;
|
||||||
isUpdatingServiceLine?: boolean;
|
isUpdatingServiceLine?: boolean;
|
||||||
onUpdateStatus: (paymentId: number, status: PaymentStatus) => void;
|
|
||||||
|
onUpdateStatus?: (paymentId: number, status: PaymentStatus) => void;
|
||||||
isUpdatingStatus?: boolean;
|
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({
|
export default function PaymentEditModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onClose,
|
onClose,
|
||||||
payment,
|
|
||||||
onEditServiceLine,
|
onEditServiceLine,
|
||||||
isUpdatingServiceLine,
|
isUpdatingServiceLine: propUpdatingServiceLine,
|
||||||
onUpdateStatus,
|
onUpdateStatus,
|
||||||
isUpdatingStatus,
|
isUpdatingStatus: propUpdatingStatus,
|
||||||
|
payment: paymentProp,
|
||||||
|
paymentId: paymentIdProp,
|
||||||
}: PaymentEditModalProps) {
|
}: PaymentEditModalProps) {
|
||||||
if (!payment) return null;
|
// Local payment state: prefer prop but fetch if paymentId is provided
|
||||||
|
const [payment, setPayment] = useState<PaymentWithExtras | null>(
|
||||||
const [expandedLineId, setExpandedLineId] = useState<number | null>(null);
|
paymentProp ?? null
|
||||||
const [paymentStatus, setPaymentStatus] = React.useState<PaymentStatus>(
|
|
||||||
payment.status
|
|
||||||
);
|
);
|
||||||
|
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(() => {
|
const [formState, setFormState] = useState(() => {
|
||||||
return {
|
return {
|
||||||
serviceLineId: 0,
|
serviceLineId: 0,
|
||||||
@@ -72,10 +97,191 @@ export default function PaymentEditModal({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const serviceLines =
|
// Sync when parent passes a payment object or paymentId changes
|
||||||
payment.claim?.serviceLines ?? payment.serviceLines ?? [];
|
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) {
|
if (expandedLineId === lineId) {
|
||||||
// Closing current line
|
// Closing current line
|
||||||
setExpandedLineId(null);
|
setExpandedLineId(null);
|
||||||
@@ -109,6 +315,14 @@ export default function PaymentEditModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePayment = async () => {
|
const handleSavePayment = async () => {
|
||||||
|
if (!payment) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Payment not loaded.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!formState.serviceLineId) {
|
if (!formState.serviceLineId) {
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -178,7 +392,7 @@ export default function PaymentEditModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onEditServiceLine(payload);
|
await handleEditServiceLine(payload);
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: "Payment Transaction added successfully.",
|
description: "Payment Transaction added successfully.",
|
||||||
@@ -224,7 +438,7 @@ export default function PaymentEditModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onEditServiceLine(payload);
|
await handleEditServiceLine(payload);
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: `Full due amount ($${dueAmount.toFixed(
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||||
@@ -352,7 +578,7 @@ export default function PaymentEditModal({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={isUpdatingStatus}
|
disabled={isUpdatingStatus}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
payment && onUpdateStatus(payment.id, paymentStatus)
|
payment && handleUpdateStatus(payment.id, paymentStatus)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isUpdatingStatus ? "Updating..." : "Update Status"}
|
{isUpdatingStatus ? "Updating..." : "Update Status"}
|
||||||
@@ -432,7 +658,7 @@ export default function PaymentEditModal({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleEditServiceLine(line.id)}
|
onClick={() => handleEditServiceLineClick(line.id)}
|
||||||
>
|
>
|
||||||
{isExpanded ? "Cancel" : "Pay Partially"}
|
{isExpanded ? "Cancel" : "Pay Partially"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import PaymentsRecentTable from "@/components/payments/payments-recent-table";
|
|||||||
import PaymentsOfPatientModal from "@/components/payments/payments-of-patient-table";
|
import PaymentsOfPatientModal from "@/components/payments/payments-of-patient-table";
|
||||||
import PaymentOCRBlock from "@/components/payments/payment-ocr-block";
|
import PaymentOCRBlock from "@/components/payments/payment-ocr-block";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { Patient } from "@repo/db/types";
|
import { Patient, PaymentWithExtras } from "@repo/db/types";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import PaymentEditModal from "@/components/payments/payment-edit-modal";
|
||||||
|
|
||||||
export default function PaymentsPage() {
|
export default function PaymentsPage() {
|
||||||
const [paymentPeriod, setPaymentPeriod] = useState<string>("all-time");
|
const [paymentPeriod, setPaymentPeriod] = useState<string>("all-time");
|
||||||
@@ -32,6 +33,10 @@ export default function PaymentsPage() {
|
|||||||
const [openPatientModalFromAppointment, setOpenPatientModalFromAppointment] =
|
const [openPatientModalFromAppointment, setOpenPatientModalFromAppointment] =
|
||||||
useState(false);
|
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
|
// small helper: remove query params silently
|
||||||
const clearUrlParams = (params: string[]) => {
|
const clearUrlParams = (params: string[]) => {
|
||||||
try {
|
try {
|
||||||
@@ -105,6 +110,20 @@ export default function PaymentsPage() {
|
|||||||
};
|
};
|
||||||
}, [location]);
|
}, [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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -215,6 +234,17 @@ export default function PaymentsPage() {
|
|||||||
setInitialPatientForModal(null);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,5 +68,5 @@ export type FinancialRow = {
|
|||||||
total_due: number;
|
total_due: number;
|
||||||
patient_name: string | null;
|
patient_name: string | null;
|
||||||
service_lines: any[];
|
service_lines: any[];
|
||||||
payments: any[];
|
linked_payment_id?: number | null;
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user