initial commit

This commit is contained in:
2026-04-04 22:13:55 -04:00
commit 5d77e207c9
10181 changed files with 522212 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}