initial commit
This commit is contained in:
953
apps/Frontend/src/components/payments/payment-edit-modal.tsx
Executable file
953
apps/Frontend/src/components/payments/payment-edit-modal.tsx
Executable file
@@ -0,0 +1,953 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
formatDateToHumanReadable,
|
||||
formatLocalDate,
|
||||
parseLocalDate,
|
||||
} from "@/utils/dateUtils";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
PaymentStatus,
|
||||
PaymentMethod,
|
||||
paymentMethodOptions,
|
||||
PaymentWithExtras,
|
||||
NewTransactionPayload,
|
||||
paymentStatusArray,
|
||||
paymentMethodArray,
|
||||
} from "@repo/db/types";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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;
|
||||
|
||||
// Keeping callbacks optional — if provided parent handlers will be used
|
||||
onEditServiceLine?: (payload: NewTransactionPayload) => void;
|
||||
isUpdatingServiceLine?: boolean;
|
||||
|
||||
onUpdateStatus?: (paymentId: number, status: PaymentStatus) => void;
|
||||
isUpdatingStatus?: boolean;
|
||||
|
||||
// Either pass a full payment object OR a paymentId (or both)
|
||||
payment?: PaymentWithExtras | null;
|
||||
paymentId?: number | null;
|
||||
};
|
||||
|
||||
export default function PaymentEditModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
onEditServiceLine,
|
||||
isUpdatingServiceLine: propUpdatingServiceLine,
|
||||
onUpdateStatus,
|
||||
isUpdatingStatus: propUpdatingStatus,
|
||||
payment: paymentProp,
|
||||
paymentId: paymentIdProp,
|
||||
}: PaymentEditModalProps) {
|
||||
// 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,
|
||||
transactionId: "",
|
||||
paidAmount: 0,
|
||||
adjustedAmount: 0,
|
||||
method: paymentMethodOptions.CHECK as PaymentMethod,
|
||||
receivedDate: formatLocalDate(new Date()),
|
||||
payerName: "",
|
||||
notes: "",
|
||||
};
|
||||
});
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find line data
|
||||
const line = serviceLines.find((sl) => sl.id === lineId);
|
||||
if (!line) return;
|
||||
|
||||
// updating form to show its data, while expanding.
|
||||
setFormState({
|
||||
serviceLineId: line.id,
|
||||
transactionId: "",
|
||||
paidAmount: Number(line.totalDue) > 0 ? Number(line.totalDue) : 0,
|
||||
adjustedAmount: 0,
|
||||
method: paymentMethodOptions.CHECK as PaymentMethod,
|
||||
receivedDate: formatLocalDate(new Date()),
|
||||
payerName: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
setExpandedLineId(lineId);
|
||||
};
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSavePayment = async () => {
|
||||
if (!payment) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Payment not loaded.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!formState.serviceLineId) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "No service line selected.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const paidAmount = Number(formState.paidAmount) || 0;
|
||||
const adjustedAmount = Number(formState.adjustedAmount) || 0;
|
||||
|
||||
if (paidAmount < 0 || adjustedAmount < 0) {
|
||||
toast({
|
||||
title: "Invalid Amount",
|
||||
description: "Amounts cannot be negative.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (paidAmount === 0 && adjustedAmount === 0) {
|
||||
toast({
|
||||
title: "Invalid Amount",
|
||||
description:
|
||||
"Either paid or adjusted amount must be greater than zero.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const line = serviceLines.find((sl) => sl.id === formState.serviceLineId);
|
||||
if (!line) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Selected service line not found.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dueAmount = Number(line.totalDue);
|
||||
if (paidAmount > dueAmount) {
|
||||
toast({
|
||||
title: "Invalid Payment",
|
||||
description: `Paid amount ($${paidAmount.toFixed(
|
||||
2
|
||||
)}) cannot exceed due amount ($${dueAmount.toFixed(2)}).`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: NewTransactionPayload = {
|
||||
paymentId: payment.id,
|
||||
serviceLineTransactions: [
|
||||
{
|
||||
serviceLineId: formState.serviceLineId,
|
||||
transactionId: formState.transactionId || undefined,
|
||||
paidAmount: Number(formState.paidAmount),
|
||||
adjustedAmount: Number(formState.adjustedAmount) || 0,
|
||||
method: formState.method,
|
||||
receivedDate: parseLocalDate(formState.receivedDate),
|
||||
payerName: formState.payerName?.trim() || undefined,
|
||||
notes: formState.notes?.trim() || undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
await handleEditServiceLine(payload);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Payment Transaction added successfully.",
|
||||
});
|
||||
setExpandedLineId(null);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({ title: "Error", description: "Failed to save payment." });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayFullDue = async (line: (typeof serviceLines)[0]) => {
|
||||
if (!line || !payment) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Service line or payment data missing.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dueAmount = Number(line.totalDue);
|
||||
if (isNaN(dueAmount) || dueAmount <= 0) {
|
||||
toast({
|
||||
title: "No Due",
|
||||
description: "This service line has no outstanding balance.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: NewTransactionPayload = {
|
||||
paymentId: payment.id,
|
||||
serviceLineTransactions: [
|
||||
{
|
||||
serviceLineId: line.id,
|
||||
paidAmount: dueAmount,
|
||||
adjustedAmount: 0,
|
||||
method: paymentMethodOptions.CHECK as PaymentMethod, // Maybe make dynamic later
|
||||
receivedDate: new Date(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
await handleEditServiceLine(payload);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Full due amount ($${dueAmount.toFixed(
|
||||
2
|
||||
)}) paid for ${line.procedureCode}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update payment.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Payment</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and manage payments applied to service lines.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Close button in top-right */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="absolute right-0 top-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Claim + Patient Info */}
|
||||
<div className="space-y-2 border-b border-gray-200 pb-4">
|
||||
<h3 className="text-2xl font-bold text-gray-900">
|
||||
{payment.claim?.patientName ??
|
||||
(`${payment.patient?.firstName ?? ""} ${payment.patient?.lastName ?? ""}`.trim() ||
|
||||
"Unknown Patient")}
|
||||
</h3>
|
||||
|
||||
<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">
|
||||
Claim #{payment.claimId.toString().padStart(4, "0")}
|
||||
</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">
|
||||
Service Date:{" "}
|
||||
{payment.claim?.serviceDate
|
||||
? formatDateToHumanReadable(payment.claim.serviceDate)
|
||||
: serviceLines.length > 0
|
||||
? formatDateToHumanReadable(serviceLines[0]?.procedureDate)
|
||||
: formatDateToHumanReadable(payment.createdAt)}
|
||||
</span>
|
||||
|
||||
{payment.icn ? (
|
||||
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
|
||||
ICN : {payment.icn}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Summary + Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Payment Info */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">Payment Info</h4>
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="text-gray-500">Total Billed:</span>{" "}
|
||||
<span className="font-medium">
|
||||
${Number(payment.totalBilled || 0).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Total Paid:</span>{" "}
|
||||
<span className="font-medium text-green-600">
|
||||
${Number(payment.totalPaid || 0).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Total Due:</span>{" "}
|
||||
<span className="font-medium text-red-600">
|
||||
${Number(payment.totalDue || 0).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Status Selector */}
|
||||
<div className="pt-3">
|
||||
<label className="block text-sm text-gray-600 mb-1">
|
||||
Payment Status
|
||||
</label>
|
||||
<Select
|
||||
value={paymentStatus}
|
||||
onValueChange={(value: PaymentStatus) =>
|
||||
setPaymentStatus(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentStatusArray.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isUpdatingStatus}
|
||||
onClick={() =>
|
||||
payment && handleUpdateStatus(payment.id, paymentStatus)
|
||||
}
|
||||
>
|
||||
{isUpdatingStatus ? "Updating..." : "Update Status"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">Metadata</h4>
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="text-gray-500">Created At:</span>{" "}
|
||||
{payment.createdAt
|
||||
? formatDateToHumanReadable(payment.createdAt)
|
||||
: "N/A"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Last Updated At:</span>{" "}
|
||||
{payment.updatedAt
|
||||
? formatDateToHumanReadable(payment.updatedAt)
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Lines Payments */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
|
||||
<div className="mt-3 space-y-4">
|
||||
{serviceLines.length > 0 ? (
|
||||
serviceLines.map((line) => {
|
||||
const isExpanded = expandedLineId === line.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={line.id}
|
||||
className="border border-gray-200 p-4 rounded-xl bg-white shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
{/* Top Info */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<p>
|
||||
<span className="text-gray-500">Procedure Code:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{line.procedureCode}
|
||||
</span>
|
||||
</p>
|
||||
{(line as any).quad && (
|
||||
<p>
|
||||
<span className="text-gray-500">Quad:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{(line as any).quad}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{(line as any).arch && (
|
||||
<p>
|
||||
<span className="text-gray-500">Arch:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{(line as any).arch}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{line.toothNumber && (
|
||||
<p>
|
||||
<span className="text-gray-500">Tooth Number:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{line.toothNumber}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{line.toothSurface && (
|
||||
<p>
|
||||
<span className="text-gray-500">Surface:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{line.toothSurface}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-gray-500">Billed:</span>{" "}
|
||||
<span className="font-semibold">
|
||||
${Number(line.totalBilled || 0).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Paid:</span>{" "}
|
||||
<span className="font-semibold text-green-600">
|
||||
${Number(line.totalPaid || 0).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Adjusted:</span>{" "}
|
||||
<span className="font-semibold text-yellow-600">
|
||||
${Number(line.totalAdjusted || 0).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Due:</span>{" "}
|
||||
<span className="font-semibold text-red-600">
|
||||
${Number(line.totalDue || 0).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditServiceLineClick(line.id)}
|
||||
>
|
||||
{isExpanded ? "Cancel" : "Pay Partially"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handlePayFullDue(line)}
|
||||
>
|
||||
Pay Full Due
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expanded Partial Payment Form */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 p-4 border-t border-gray-200 bg-gray-50 rounded-lg space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
Paid Amount
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Paid Amount"
|
||||
defaultValue={formState.paidAmount}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
"paidAmount",
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
Adjusted Amount
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Adjusted Amount"
|
||||
defaultValue={formState.adjustedAmount}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
"adjustedAmount",
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
Payment Method
|
||||
</label>
|
||||
<Select
|
||||
value={formState.method}
|
||||
onValueChange={(value: PaymentMethod) =>
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
method: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a payment method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentMethodArray.map((methodOption) => (
|
||||
<SelectItem
|
||||
key={methodOption}
|
||||
value={methodOption}
|
||||
>
|
||||
{methodOption}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DateInput
|
||||
label="Received Date"
|
||||
value={
|
||||
formState.receivedDate
|
||||
? parseLocalDate(formState.receivedDate)
|
||||
: null
|
||||
}
|
||||
onChange={(date) => {
|
||||
if (date) {
|
||||
const localDate = formatLocalDate(date);
|
||||
updateField("receivedDate", localDate);
|
||||
} else {
|
||||
updateField("receivedDate", null);
|
||||
}
|
||||
}}
|
||||
disableFuture
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
Payer Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Payer Name"
|
||||
value={formState.payerName}
|
||||
onChange={(e) =>
|
||||
updateField("payerName", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Notes</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Notes"
|
||||
onChange={(e) =>
|
||||
updateField("notes", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isUpdatingServiceLine}
|
||||
onClick={() => handleSavePayment()}
|
||||
>
|
||||
{isUpdatingStatus ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-gray-500">No service lines available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transactions Overview */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 pt-6">All Transactions</h4>
|
||||
<div className="mt-4 space-y-3">
|
||||
{payment.serviceLineTransactions.length > 0 ? (
|
||||
payment.serviceLineTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="border border-gray-200 p-4 rounded-xl bg-white shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
|
||||
{/* Transaction ID */}
|
||||
{tx.id && (
|
||||
<p>
|
||||
<span className="text-gray-500">Transaction ID:</span>{" "}
|
||||
<span className="font-medium">{tx.id}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Procedure Code */}
|
||||
{tx.serviceLine?.procedureCode && (
|
||||
<p>
|
||||
<span className="text-gray-500">Procedure Code:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{tx.serviceLine.procedureCode}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tooth Number */}
|
||||
{tx.serviceLine?.toothNumber && (
|
||||
<p>
|
||||
<span className="text-gray-500">Tooth Number:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{tx.serviceLine.toothNumber}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tooth Surface */}
|
||||
{tx.serviceLine?.toothSurface && (
|
||||
<p>
|
||||
<span className="text-gray-500">Surface:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{tx.serviceLine.toothSurface}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Paid Amount */}
|
||||
<p>
|
||||
<span className="text-gray-500">Paid Amount:</span>{" "}
|
||||
<span className="font-semibold text-green-600">
|
||||
${Number(tx.paidAmount).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Adjusted Amount */}
|
||||
{Number(tx.adjustedAmount) > 0 && (
|
||||
<p>
|
||||
<span className="text-gray-500">
|
||||
Adjusted Amount:
|
||||
</span>{" "}
|
||||
<span className="font-semibold text-yellow-600">
|
||||
${Number(tx.adjustedAmount).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<p>
|
||||
<span className="text-gray-500">Date:</span>{" "}
|
||||
<span>
|
||||
{formatDateToHumanReadable(tx.receivedDate)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Method */}
|
||||
<p>
|
||||
<span className="text-gray-500">Method:</span>{" "}
|
||||
<span className="capitalize">{tx.method}</span>
|
||||
</p>
|
||||
|
||||
{/* Payer Name */}
|
||||
{tx.payerName && tx.payerName.trim() !== "" && (
|
||||
<p className="md:col-span-2">
|
||||
<span className="text-gray-500">Payer Name:</span>{" "}
|
||||
<span>{tx.payerName}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{tx.notes && tx.notes.trim() !== "" && (
|
||||
<p className="md:col-span-2">
|
||||
<span className="text-gray-500">Notes:</span>{" "}
|
||||
<span>{tx.notes}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No transactions recorded.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-2 pt-6">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
458
apps/Frontend/src/components/payments/payment-ocr-block.tsx
Executable file
458
apps/Frontend/src/components/payments/payment-ocr-block.tsx
Executable file
@@ -0,0 +1,458 @@
|
||||
// PaymentOCRBlock.tsx
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Image as ImageIcon, X, Plus } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { QK_PAYMENTS_RECENT_BASE } from "@/components/payments/payments-recent-table";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
import {
|
||||
MultipleFileUploadZone,
|
||||
MultipleFileUploadZoneHandle,
|
||||
} from "../file-upload/multiple-file-upload-zone";
|
||||
|
||||
// ---------------- Types ----------------
|
||||
|
||||
type Row = { __id: number } & Record<string, string | number | null>;
|
||||
|
||||
export default function PaymentOCRBlock() {
|
||||
//Config
|
||||
const MAX_FILES = 10;
|
||||
const ACCEPTED_FILE_TYPES = "image/jpeg,image/jpg,image/png,image/webp";
|
||||
const TITLE = "Payment Document OCR";
|
||||
const DESCRIPTION =
|
||||
"You can upload up to 10 files. Allowed types: JPG, PNG, WEBP.";
|
||||
|
||||
// FILE/ZONE state
|
||||
const uploadZoneRef = React.useRef<MultipleFileUploadZoneHandle | null>(null);
|
||||
const [filesForUI, setFilesForUI] = React.useState<File[]>([]); // reactive UI only
|
||||
const [isUploading, setIsUploading] = React.useState(false); // forwarded to zone
|
||||
const [isExtracting, setIsExtracting] = React.useState(false);
|
||||
|
||||
// extracted rows shown only inside modal
|
||||
const [rows, setRows] = React.useState<Row[]>([]);
|
||||
const [modalColumns, setModalColumns] = React.useState<string[]>([]);
|
||||
const [showModal, setShowModal] = React.useState(false);
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
//Mutation
|
||||
const extractPaymentOCR = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file, file.name));
|
||||
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/payment-ocr/extract",
|
||||
formData
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error("Failed to extract payment data");
|
||||
const data = (await res.json()) as { rows: Row[] } | Row[];
|
||||
return Array.isArray(data) ? data : data.rows;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// 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(
|
||||
cleaned.reduce<Set<string>>((acc, row) => {
|
||||
Object.keys(row).forEach((k) => acc.add(k));
|
||||
return acc;
|
||||
}, new Set())
|
||||
);
|
||||
|
||||
setModalColumns(allKeys);
|
||||
|
||||
setIsExtracting(false);
|
||||
setShowModal(true);
|
||||
},
|
||||
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to extract payment data: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsExtracting(false);
|
||||
},
|
||||
});
|
||||
|
||||
// ---- handlers (all in this file) -----------------------------------------
|
||||
|
||||
// Called by zone when its internal list changes (keeps parent UI reactive)
|
||||
const handleZoneFilesChange = React.useCallback((files: File[]) => {
|
||||
setFilesForUI(files);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Remove a single file by asking the zone to remove it (zone exposes removeFile)
|
||||
const removeUploadedFile = React.useCallback((index: number) => {
|
||||
uploadZoneRef.current?.removeFile(index);
|
||||
// zone will call onFilesChange and update filesForUI automatically
|
||||
}, []);
|
||||
|
||||
// Extract: read files from zone via ref and call mutation
|
||||
const handleExtract = () => {
|
||||
const files = uploadZoneRef.current?.getFiles() ?? [];
|
||||
if (!files.length) {
|
||||
setError("Please upload at least one file to extract.");
|
||||
return;
|
||||
}
|
||||
setIsExtracting(true);
|
||||
extractPaymentOCR.mutate(files);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const skipped: string[] = [];
|
||||
|
||||
const payload = rows
|
||||
.map((row, idx) => {
|
||||
const patientName = row["Patient Name"];
|
||||
const patientId = row["Patient ID"];
|
||||
const procedureCode = row["CDT Code"];
|
||||
|
||||
if (!patientName || !patientId || !procedureCode) {
|
||||
skipped.push(`Row ${idx + 1} (missing name/id/procedureCode)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
patientName,
|
||||
insuranceId: patientId,
|
||||
icn: row["ICN"] ?? null,
|
||||
procedureCode: row["CDT Code"],
|
||||
toothNumber: row["Tooth"] ?? null,
|
||||
toothSurface: row["Surface"] ?? null,
|
||||
procedureDate: row["Date SVC"] ?? null,
|
||||
totalBilled: Number(row["Billed Amount"] ?? 0),
|
||||
totalAllowed: Number(row["Allowed Amount"] ?? 0),
|
||||
totalPaid: Number(row["Paid Amount"] ?? 0),
|
||||
sourceFile: row["Source File"] ?? null,
|
||||
};
|
||||
})
|
||||
.filter((r) => r !== null);
|
||||
|
||||
if (skipped.length > 0) {
|
||||
toast({
|
||||
title:
|
||||
"Some rows skipped, because of either no patient Name or MemberId given.",
|
||||
description: skipped.join(", "),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.length === 0) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "No valid rows to save",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await apiRequest("POST", "/api/payments/full-ocr-import", {
|
||||
rows: payload,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save OCR payments");
|
||||
|
||||
toast({ title: "Saved", description: "OCR rows saved successfully" });
|
||||
|
||||
// 🔄 REFRESH both tables (all pages/filters)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE }), // all recent payments
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }), // recent patients list
|
||||
]);
|
||||
|
||||
// ✅ CLEAR UI: reset zone + modal + rows
|
||||
uploadZoneRef.current?.reset();
|
||||
setFilesForUI([]);
|
||||
setRows([]);
|
||||
setModalColumns([]);
|
||||
setError(null);
|
||||
setIsExtracting(false);
|
||||
setShowModal(false);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{TITLE}</CardTitle>
|
||||
<CardDescription>{DESCRIPTION}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Upload block */}
|
||||
<div className="bg-gray-100 p-4 rounded-md space-y-4">
|
||||
<MultipleFileUploadZone
|
||||
ref={uploadZoneRef}
|
||||
isUploading={isUploading}
|
||||
acceptedFileTypes={ACCEPTED_FILE_TYPES}
|
||||
maxFiles={MAX_FILES}
|
||||
onFilesChange={handleZoneFilesChange} // reactive UI only
|
||||
/>
|
||||
|
||||
{/* Show list of files received from the upload zone (UI only) */}
|
||||
{filesForUI.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Uploaded ({filesForUI.length}/{MAX_FILES})
|
||||
</p>
|
||||
<ul className="space-y-2 max-h-48 overflow-auto">
|
||||
{filesForUI.map((file, idx) => (
|
||||
<li
|
||||
key={`${file.name}-${file.size}-${idx}`}
|
||||
className="flex items-center justify-between border rounded-md p-2 bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageIcon className="h-6 w-6 text-green-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-green-700 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeUploadedFile(idx)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extract */}
|
||||
<div className="mt-4 flex justify-end gap-4">
|
||||
<Button
|
||||
className="w-full h-12 gap-2"
|
||||
type="button"
|
||||
onClick={handleExtract}
|
||||
disabled={isExtracting || !filesForUI.length}
|
||||
>
|
||||
{extractPaymentOCR.isPending
|
||||
? "Extracting..."
|
||||
: "Extract Payment Data"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* show extraction error if any */}
|
||||
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<OCRDetailsModal
|
||||
open={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={handleSave}
|
||||
rows={rows}
|
||||
setRows={setRows}
|
||||
columnKeys={modalColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Simple Modal (in-app popup) ----------------
|
||||
|
||||
export function OCRDetailsModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
rows,
|
||||
setRows,
|
||||
columnKeys,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
rows: Row[];
|
||||
setRows: React.Dispatch<React.SetStateAction<Row[]>>;
|
||||
columnKeys: string[];
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
//rows helper
|
||||
const handleDeleteRow = (index: number) => {
|
||||
setRows((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleAddRow = React.useCallback(() => {
|
||||
setRows((prev) => {
|
||||
const newRow: Row = { __id: prev.length };
|
||||
columnKeys.forEach((k) => {
|
||||
newRow[k] = "";
|
||||
});
|
||||
return [...prev, newRow];
|
||||
});
|
||||
}, [setRows, columnKeys]);
|
||||
|
||||
const modalColumns = React.useMemo<ColumnDef<Row>[]>(() => {
|
||||
// ensure ICN (if present) is moved to the end of the data columns
|
||||
const reorderedKeys = [
|
||||
...columnKeys.filter((k) => k !== "ICN"),
|
||||
...(columnKeys.includes("ICN") ? ["ICN"] : []),
|
||||
];
|
||||
|
||||
return reorderedKeys.map((key) => ({
|
||||
id: key,
|
||||
header: key,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original[key] ?? "") as string;
|
||||
return (
|
||||
<input
|
||||
className="w-full border rounded p-1"
|
||||
value={String(value)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setRows((prev) => {
|
||||
const next = [...prev];
|
||||
next[row.index] = {
|
||||
...next[row.index],
|
||||
__id: next[row.index]!.__id,
|
||||
[key]: v,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
}, [columnKeys, setRows]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns: modalColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-6">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* larger modal, column layout so footer sticks to bottom */}
|
||||
<div className="relative z-10 w-full max-w-[1600px] h-[92vh] bg-white rounded-lg shadow-2xl overflow-hidden flex flex-col">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleAddRow}>
|
||||
<Plus className="h-4 w-4 mr-2" /> Add Row
|
||||
</Button>
|
||||
<h3 className="text-lg font-medium ml-2">OCR Payment Details</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* body (scrollable) */}
|
||||
<div className="p-4 overflow-auto flex-1">
|
||||
<div className="min-w-max">
|
||||
<table className="border-collapse border border-gray-300 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<tr key={hg.id} className="bg-gray-100">
|
||||
{hg.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="border p-2 text-left whitespace-nowrap"
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th className="border p-2">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
{r.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className="border p-2 whitespace-nowrap"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className="border p-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteRow(r.index)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer (always visible) */}
|
||||
<div className="p-4 border-t flex justify-end">
|
||||
<Button type="button" className="h-12" onClick={onSave}>
|
||||
Save Edited Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
apps/Frontend/src/components/payments/payments-of-patient-table.tsx
Executable file
127
apps/Frontend/src/components/payments/payments-of-patient-table.tsx
Executable file
@@ -0,0 +1,127 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { PatientTable } from "../patients/patient-table";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Patient } from "@repo/db/types";
|
||||
import PaymentsRecentTable from "./payments-recent-table";
|
||||
|
||||
type Props = {
|
||||
initialPatient?: Patient | null;
|
||||
openInitially?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const PaymentsOfPatientModal = forwardRef<HTMLDivElement, Props>(
|
||||
({ initialPatient = null, openInitially = false, onClose }: Props, ref) => {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(
|
||||
null
|
||||
);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [paymentsPage, setPaymentsPage] = useState(1);
|
||||
|
||||
// minimal, local scroll + cleanup — put inside PaymentsOfPatientModal
|
||||
useEffect(() => {
|
||||
if (!selectedPatient) return;
|
||||
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const card = document.getElementById("payments-for-patient-card");
|
||||
const main = document.querySelector("main"); // your app's scroll container
|
||||
if (card && main instanceof HTMLElement) {
|
||||
const parentRect = main.getBoundingClientRect();
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const relativeTop = cardRect.top - parentRect.top + main.scrollTop;
|
||||
const offset = 8;
|
||||
main.scrollTo({
|
||||
top: Math.max(0, relativeTop - offset),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// cleanup: when selectedPatient changes (ddmodal closes) or component unmounts,
|
||||
// reset the main scroll to top so other pages are not left scrolled.
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
const main = document.querySelector("main");
|
||||
if (main instanceof HTMLElement) {
|
||||
// immediate reset (no animation) so navigation to other pages starts at top
|
||||
main.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
};
|
||||
}, [selectedPatient]);
|
||||
|
||||
// when parent provides an initialPatient and openInitially flag, apply it
|
||||
useEffect(() => {
|
||||
if (initialPatient) {
|
||||
setSelectedPatient(initialPatient);
|
||||
setPaymentsPage(1);
|
||||
}
|
||||
|
||||
if (openInitially) {
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
}, [initialPatient, openInitially]);
|
||||
|
||||
const handleSelectPatient = (patient: Patient | null) => {
|
||||
if (patient) {
|
||||
setSelectedPatient(patient);
|
||||
setPaymentsPage(1);
|
||||
setIsModalOpen(true);
|
||||
} else {
|
||||
setSelectedPatient(null);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 py-8">
|
||||
{/* Payments Section */}
|
||||
{selectedPatient && (
|
||||
<Card id="payments-for-patient-card">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Payments for {selectedPatient.firstName}{" "}
|
||||
{selectedPatient.lastName}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Displaying recent payments for the selected patient.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PaymentsRecentTable
|
||||
patientId={selectedPatient.id}
|
||||
allowEdit
|
||||
allowDelete
|
||||
onPageChange={setPaymentsPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Patients Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Patient Records</CardTitle>
|
||||
<CardDescription>
|
||||
Select any patient and View all their recent payments.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PatientTable
|
||||
allowView
|
||||
allowCheckbox
|
||||
onSelectPatient={handleSelectPatient}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default PaymentsOfPatientModal;
|
||||
839
apps/Frontend/src/components/payments/payments-recent-table.tsx
Executable file
839
apps/Frontend/src/components/payments/payments-recent-table.tsx
Executable file
@@ -0,0 +1,839 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Edit,
|
||||
Eye,
|
||||
Delete,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
ThumbsDown,
|
||||
DollarSign,
|
||||
Ban,
|
||||
} from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import LoadingScreen from "../ui/LoadingScreen";
|
||||
import {
|
||||
NewTransactionPayload,
|
||||
PaymentStatus,
|
||||
PaymentWithExtras,
|
||||
} from "@repo/db/types";
|
||||
import EditPaymentModal from "./payment-edit-modal";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ConfirmationDialog } from "../ui/confirmationDialog";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
|
||||
interface PaymentApiResponse {
|
||||
payments: PaymentWithExtras[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface PaymentsRecentTableProps {
|
||||
allowEdit?: boolean;
|
||||
allowDelete?: boolean;
|
||||
allowCheckbox?: boolean;
|
||||
onSelectPayment?: (payment: PaymentWithExtras | null) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
patientId?: number;
|
||||
}
|
||||
|
||||
// 🔑 exported base key (so others can invalidate all pages/filters)
|
||||
export const QK_PAYMENTS_RECENT_BASE = ["payments-recent"] as const;
|
||||
// 🔑 exported helper for specific pages/scopes
|
||||
export const qkPaymentsRecent = (opts: {
|
||||
patientId?: number | null;
|
||||
page: number;
|
||||
}) =>
|
||||
opts.patientId
|
||||
? ([
|
||||
...QK_PAYMENTS_RECENT_BASE,
|
||||
"patient",
|
||||
opts.patientId,
|
||||
opts.page,
|
||||
] as const)
|
||||
: ([...QK_PAYMENTS_RECENT_BASE, "global", opts.page] as const);
|
||||
|
||||
export default function PaymentsRecentTable({
|
||||
allowEdit,
|
||||
allowDelete,
|
||||
allowCheckbox,
|
||||
onSelectPayment,
|
||||
onPageChange,
|
||||
patientId,
|
||||
}: PaymentsRecentTableProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isEditPaymentOpen, setIsEditPaymentOpen] = useState(false);
|
||||
const [isDeletePaymentOpen, setIsDeletePaymentOpen] = useState(false);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const paymentsPerPage = 5;
|
||||
const offset = (currentPage - 1) * paymentsPerPage;
|
||||
|
||||
const [currentPayment, setCurrentPayment] = useState<
|
||||
PaymentWithExtras | undefined
|
||||
>(undefined);
|
||||
const [selectedPaymentId, setSelectedPaymentId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [isRevertOpen, setIsRevertOpen] = useState(false);
|
||||
const [revertPaymentId, setRevertPaymentId] = useState<number | null>(null);
|
||||
|
||||
const handleSelectPayment = (payment: PaymentWithExtras) => {
|
||||
const isSelected = selectedPaymentId === payment.id;
|
||||
const newSelectedId = isSelected ? null : payment.id;
|
||||
setSelectedPaymentId(Number(newSelectedId));
|
||||
if (onSelectPayment) {
|
||||
onSelectPayment(isSelected ? null : payment);
|
||||
}
|
||||
};
|
||||
|
||||
const queryKey = qkPaymentsRecent({
|
||||
patientId: patientId ?? undefined,
|
||||
page: currentPage,
|
||||
});
|
||||
|
||||
const {
|
||||
data: paymentsData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<PaymentApiResponse>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const endpoint = patientId
|
||||
? `/api/payments/patient/${patientId}?limit=${paymentsPerPage}&offset=${offset}`
|
||||
: `/api/payments/recent?limit=${paymentsPerPage}&offset=${offset}`;
|
||||
|
||||
const res = await apiRequest("GET", endpoint);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.message || "Failed to fetch payments");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
placeholderData: { payments: [], totalCount: 0 },
|
||||
});
|
||||
|
||||
const updatePaymentMutation = 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!",
|
||||
});
|
||||
|
||||
// 🔄 refresh this table page
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: QK_PAYMENTS_RECENT_BASE,
|
||||
});
|
||||
|
||||
// Fetch updated payment and set into local state
|
||||
const refreshedPayment = await apiRequest(
|
||||
"GET",
|
||||
`/api/payments/${paymentId}`
|
||||
).then((res) => res.json());
|
||||
|
||||
setCurrentPayment(refreshedPayment); // <-- keep modal in sync
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Update failed: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updatePaymentStatusMutation = 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!",
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: QK_PAYMENTS_RECENT_BASE,
|
||||
});
|
||||
|
||||
// Fetch updated payment and set into local state
|
||||
const refreshedPayment = await apiRequest(
|
||||
"GET",
|
||||
`/api/payments/${paymentId}`
|
||||
).then((res) => res.json());
|
||||
|
||||
setCurrentPayment(refreshedPayment); // <-- keep modal in sync
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Status update failed: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const fullPaymentMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
paymentId,
|
||||
type,
|
||||
}: {
|
||||
paymentId: number;
|
||||
type: "pay" | "revert";
|
||||
}) => {
|
||||
const endpoint =
|
||||
type === "pay"
|
||||
? `/api/payments/${paymentId}/pay-absolute-full-claim`
|
||||
: `/api/payments/${paymentId}/revert-full-claim`;
|
||||
const response = await apiRequest("PUT", endpoint);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || "Failed to update Payment");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Payment updated successfully!",
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: QK_PAYMENTS_RECENT_BASE,
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Operation failed: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handlePayAbsoluteFullDue = (paymentId: number) => {
|
||||
fullPaymentMutation.mutate({ paymentId, type: "pay" });
|
||||
};
|
||||
|
||||
const handleRevert = () => {
|
||||
if (!revertPaymentId) return;
|
||||
|
||||
fullPaymentMutation.mutate({
|
||||
paymentId: revertPaymentId,
|
||||
type: "revert",
|
||||
});
|
||||
|
||||
setRevertPaymentId(null);
|
||||
setIsRevertOpen(false);
|
||||
};
|
||||
|
||||
const deletePaymentMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await apiRequest("DELETE", `/api/payments/${id}`);
|
||||
return;
|
||||
},
|
||||
|
||||
onSuccess: async () => {
|
||||
setIsDeletePaymentOpen(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: QK_PAYMENTS_RECENT_BASE,
|
||||
});
|
||||
toast({
|
||||
title: "Deleted",
|
||||
description: "Payment deleted successfully",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to delete payment: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditPayment = (payment: PaymentWithExtras) => {
|
||||
setCurrentPayment(payment);
|
||||
setIsEditPaymentOpen(true);
|
||||
};
|
||||
|
||||
const handleDeletePayment = (payment: PaymentWithExtras) => {
|
||||
setCurrentPayment(payment);
|
||||
setIsDeletePaymentOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDeletePayment = async () => {
|
||||
if (currentPayment) {
|
||||
if (typeof currentPayment.id === "number") {
|
||||
deletePaymentMutation.mutate(currentPayment.id);
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Selected Payment is missing an ID for deletion.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "No Payment selected for deletion.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//VOID and UNVOID Feature
|
||||
const handleVoid = (paymentId: number) => {
|
||||
updatePaymentStatusMutation.mutate({ paymentId, status: "VOID" });
|
||||
};
|
||||
|
||||
const handleUnvoid = (paymentId: number) => {
|
||||
updatePaymentStatusMutation.mutate({ paymentId, status: "PENDING" });
|
||||
};
|
||||
|
||||
const [isVoidOpen, setIsVoidOpen] = useState(false);
|
||||
const [voidPaymentId, setVoidPaymentId] = useState<number | null>(null);
|
||||
|
||||
const [isUnvoidOpen, setIsUnvoidOpen] = useState(false);
|
||||
const [unvoidPaymentId, setUnvoidPaymentId] = useState<number | null>(null);
|
||||
|
||||
const handleConfirmVoid = () => {
|
||||
if (!voidPaymentId) return;
|
||||
handleVoid(voidPaymentId);
|
||||
setVoidPaymentId(null);
|
||||
setIsVoidOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirmUnvoid = () => {
|
||||
if (!unvoidPaymentId) return;
|
||||
handleUnvoid(unvoidPaymentId);
|
||||
setUnvoidPaymentId(null);
|
||||
setIsUnvoidOpen(false);
|
||||
};
|
||||
|
||||
// Pagination
|
||||
useEffect(() => {
|
||||
if (onPageChange) onPageChange(currentPage);
|
||||
}, [currentPage, onPageChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [patientId]);
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.ceil((paymentsData?.totalCount || 0) / paymentsPerPage),
|
||||
[paymentsData?.totalCount, paymentsPerPage]
|
||||
);
|
||||
|
||||
const startItem = offset + 1;
|
||||
const endItem = Math.min(
|
||||
offset + paymentsPerPage,
|
||||
paymentsData?.totalCount || 0
|
||||
);
|
||||
|
||||
const getName = (p: PaymentWithExtras) =>
|
||||
p.patient
|
||||
? `${p.patient.firstName} ${p.patient.lastName}`.trim()
|
||||
: (p.patientName ?? "Unknown");
|
||||
|
||||
const getInitials = (fullName: string) => {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
const filteredParts = parts.filter((part) => part.length > 0);
|
||||
if (filteredParts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const firstInitial = filteredParts[0]!.charAt(0).toUpperCase();
|
||||
if (filteredParts.length === 1) {
|
||||
return firstInitial;
|
||||
} else {
|
||||
const lastInitial =
|
||||
filteredParts[filteredParts.length - 1]!.charAt(0).toUpperCase();
|
||||
return firstInitial + lastInitial;
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarColor = (id: number) => {
|
||||
const colorClasses = [
|
||||
"bg-blue-500",
|
||||
"bg-teal-500",
|
||||
"bg-amber-500",
|
||||
"bg-rose-500",
|
||||
"bg-indigo-500",
|
||||
"bg-green-500",
|
||||
"bg-purple-500",
|
||||
];
|
||||
return colorClasses[id % colorClasses.length];
|
||||
};
|
||||
|
||||
const getStatusInfo = (status?: PaymentStatus) => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return {
|
||||
label: "Pending",
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
icon: <Clock className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
case "PARTIALLY_PAID":
|
||||
return {
|
||||
label: "Partially Paid",
|
||||
color: "bg-blue-100 text-blue-800",
|
||||
icon: <DollarSign className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
case "PAID":
|
||||
return {
|
||||
label: "Paid",
|
||||
color: "bg-green-100 text-green-800",
|
||||
icon: <CheckCircle className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
case "OVERPAID":
|
||||
return {
|
||||
label: "Overpaid",
|
||||
color: "bg-purple-100 text-purple-800",
|
||||
icon: <TrendingUp className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
case "DENIED":
|
||||
return {
|
||||
label: "Denied",
|
||||
color: "bg-red-100 text-red-800",
|
||||
icon: <ThumbsDown className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
case "VOID":
|
||||
return {
|
||||
label: "Void",
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
icon: <Ban className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
label: status
|
||||
? (status as string).charAt(0).toUpperCase() +
|
||||
(status as string).slice(1).toLowerCase()
|
||||
: "Unknown",
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
icon: <AlertCircle className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{allowCheckbox && <TableHead>Select</TableHead>}
|
||||
<TableHead>Payment ID</TableHead>
|
||||
<TableHead>Claim ID</TableHead>
|
||||
<TableHead>Patient Name</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Service Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
<LoadingScreen />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : isError ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center py-8 text-red-500"
|
||||
>
|
||||
Error loading payments.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (paymentsData?.payments?.length ?? 0) === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No payments found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paymentsData?.payments.map((payment) => {
|
||||
const totalBilled = Number(payment.totalBilled || 0);
|
||||
const totalPaid = Number(payment.totalPaid || 0);
|
||||
const totalDue = Number(payment.totalDue || 0);
|
||||
|
||||
const displayName = getName(payment);
|
||||
const submittedOn =
|
||||
payment.serviceLines?.[0]?.procedureDate ??
|
||||
payment.claim?.createdAt ??
|
||||
payment.createdAt ??
|
||||
payment.serviceLineTransactions?.[0]?.receivedDate ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<TableRow key={payment.id}>
|
||||
{allowCheckbox && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedPaymentId === payment.id}
|
||||
onCheckedChange={() => handleSelectPayment(payment)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{typeof payment.id === "number"
|
||||
? `PAY-${payment.id.toString().padStart(4, "0")}`
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{typeof payment.claimId === "number"
|
||||
? `CLM-${payment.claimId.toString().padStart(4, "0")}`
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
className={`h-10 w-10 ${getAvatarColor(Number(payment.id))}`}
|
||||
>
|
||||
<AvatarFallback className="text-white">
|
||||
{getInitials(displayName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
PID-{payment.patientId?.toString().padStart(4, "0")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 💰 Billed / Paid / Due breakdown */}
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
<strong>Total Billed:</strong> $
|
||||
{Number(totalBilled).toFixed(2)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Total Paid:</strong> ${totalPaid.toFixed(2)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Total Due:</strong>{" "}
|
||||
{totalDue > 0 ? (
|
||||
<span className="text-yellow-600">
|
||||
${totalDue.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-600">Settled</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDateToHumanReadable(submittedOn)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const { label, color, icon } = getStatusInfo(
|
||||
payment.status
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{allowDelete && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleDeletePayment(payment);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
aria-label="Delete Payment"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Delete />
|
||||
</Button>
|
||||
)}
|
||||
{allowEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
handleEditPayment(payment);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* When NOT PAID and NOT VOID → Pay in Full + Void */}
|
||||
{payment.status !== "PAID" &&
|
||||
payment.status !== "VOID" && (
|
||||
<>
|
||||
<Button
|
||||
variant="warning"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handlePayAbsoluteFullDue(payment.id)
|
||||
}
|
||||
>
|
||||
Pay in Full
|
||||
</Button>
|
||||
|
||||
{/* NEW: Void */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setVoidPaymentId(payment.id);
|
||||
setIsVoidOpen(true);
|
||||
}}
|
||||
>
|
||||
Void
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* When PAID → Revert */}
|
||||
{payment.status === "PAID" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRevertPaymentId(payment.id);
|
||||
setIsRevertOpen(true);
|
||||
}}
|
||||
>
|
||||
Revert Full Due
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* When VOID → Unvoid */}
|
||||
{payment.status === "VOID" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUnvoidPaymentId(payment.id);
|
||||
setIsUnvoidOpen(true);
|
||||
}}
|
||||
>
|
||||
Unvoid
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Revert Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={isRevertOpen}
|
||||
title="Confirm Revert"
|
||||
message={`Do you want to revert all Service Line payments for Payment ID: ${revertPaymentId}?`}
|
||||
confirmLabel="Revert"
|
||||
confirmColor="bg-yellow-600 hover:bg-yellow-700"
|
||||
onConfirm={handleRevert}
|
||||
onCancel={() => setIsRevertOpen(false)}
|
||||
/>
|
||||
|
||||
{/* NEW: Void Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={isVoidOpen}
|
||||
title="Confirm Void"
|
||||
message={`Mark this payment as VOID? It will be excluded from balances and Calculations.`}
|
||||
confirmLabel="Void"
|
||||
confirmColor="bg-gray-700 hover:bg-gray-800"
|
||||
onConfirm={handleConfirmVoid}
|
||||
onCancel={() => setIsVoidOpen(false)}
|
||||
/>
|
||||
|
||||
{/* NEW: Unvoid Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={isUnvoidOpen}
|
||||
title="Confirm Unvoid"
|
||||
message={`Restore this payment to a normal state (PENDING)?`}
|
||||
confirmLabel="Unvoid"
|
||||
confirmColor="bg-blue-600 hover:bg-blue-700"
|
||||
onConfirm={handleConfirmUnvoid}
|
||||
onCancel={() => setIsUnvoidOpen(false)}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeletePaymentOpen}
|
||||
onConfirm={handleConfirmDeletePayment}
|
||||
onCancel={() => setIsDeletePaymentOpen(false)}
|
||||
entityName={`PaymentID : ${currentPayment?.id}`}
|
||||
/>
|
||||
|
||||
{isEditPaymentOpen && currentPayment && (
|
||||
<EditPaymentModal
|
||||
isOpen={isEditPaymentOpen}
|
||||
onOpenChange={(open) => setIsEditPaymentOpen(open)}
|
||||
onClose={() => setIsEditPaymentOpen(false)}
|
||||
payment={currentPayment}
|
||||
onEditServiceLine={(updatedPayment) => {
|
||||
updatePaymentMutation.mutate(updatedPayment);
|
||||
}}
|
||||
isUpdatingServiceLine={updatePaymentMutation.isPending}
|
||||
onUpdateStatus={(paymentId, status) => {
|
||||
updatePaymentStatusMutation.mutate({ paymentId, status });
|
||||
}}
|
||||
isUpdatingStatus={updatePaymentStatusMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-white px-4 py-3 border-t border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
|
||||
Showing {startItem}–{endItem} of {paymentsData?.totalCount || 0}{" "}
|
||||
results
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === 1 ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPageNumbers(currentPage, totalPages).map((page, idx) => (
|
||||
<PaginationItem key={idx}>
|
||||
{page === "..." ? (
|
||||
<span className="px-2 text-gray-500">...</span>
|
||||
) : (
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(page as number);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages)
|
||||
setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user