payment table - rows 1st checkpoint

This commit is contained in:
2025-08-02 14:24:44 +05:30
parent 5810328711
commit a9a93b1c00
6 changed files with 1762 additions and 632 deletions

View File

@@ -7,7 +7,8 @@ import multer from "multer";
import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
import path from "path";
import axios from "axios";
import fs from "fs";
import { Prisma } from "@repo/db/generated/prisma";
import { Decimal } from "@prisma/client/runtime/library";
const router = Router();
@@ -284,8 +285,36 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
userId: req.user!.id,
});
const newClaim = await storage.createClaim(parsedClaim);
res.status(201).json(newClaim);
// Step 1: Calculate total billed from service lines
const serviceLinesCreateInput = (
parsedClaim.serviceLines as Prisma.ServiceLineCreateNestedManyWithoutClaimInput
)?.create;
const lines = Array.isArray(serviceLinesCreateInput)
? (serviceLinesCreateInput as unknown as { amount: number }[])
: [];
const totalBilled = lines.reduce(
(sum, line) => sum + (line.amount ?? 0),
0
);
// Step 2: Create claim (with service lines)
const claim = await storage.createClaim(parsedClaim);
// Step 3: Create empty payment
await storage.createPayment({
patientId: claim.patientId,
userId: req.user!.id,
claimId: claim.id,
totalBilled: new Decimal(totalBilled),
totalPaid: new Decimal(0),
totalDue: new Decimal(totalBilled),
status: "PENDING",
notes: null,
paymentMethod: null,
receivedDate: null,
});
res.status(201).json(claim);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({

View File

@@ -38,13 +38,7 @@ const updateAppointmentSchema = (
type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
//patient types
const PatientSchema = (
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
).omit({
appointments: true,
});
type Patient = z.infer<typeof PatientUncheckedCreateInputObjectSchema>;
type Patient2 = z.infer<typeof PatientSchema>;
const insertPatientSchema = (
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
@@ -196,9 +190,17 @@ type PaymentWithExtras = Prisma.PaymentGetPayload<{
include: {
transactions: true;
servicePayments: true;
claim: true;
claim: {
include: {
serviceLines: true;
};
};
};
}>;
}> & {
patientName: string;
paymentDate: Date;
paymentMethod: string;
};
export interface IStorage {
// User methods
@@ -942,45 +944,79 @@ export const storage: IStorage = {
id: number,
userId: number
): Promise<PaymentWithExtras | null> {
return db.payment.findFirst({
const payment = await db.payment.findFirst({
where: { id, userId },
include: {
claim: true,
claim: {
include: {
serviceLines: true,
},
},
transactions: true,
servicePayments: true,
},
});
if (!payment) return null;
return {
...payment,
patientName: payment.claim?.patientName ?? "",
paymentDate: payment.createdAt,
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
};
},
async getPaymentsByClaimId(
claimId: number,
userId: number
): Promise<PaymentWithExtras | null> {
return db.payment.findFirst({
const payment = await db.payment.findFirst({
where: { claimId, userId },
include: {
claim: true,
claim: {
include: {
serviceLines: true,
},
},
transactions: true,
servicePayments: true,
},
});
if (!payment) return null;
return {
...payment,
patientName: payment.claim?.patientName ?? "",
paymentDate: payment.createdAt,
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
};
},
async getPaymentsByPatientId(
patientId: number,
userId: number
): Promise<PaymentWithExtras[]> {
return db.payment.findMany({
where: {
patientId,
userId,
},
const payments = await db.payment.findMany({
where: { patientId, userId },
include: {
claim: true,
claim: {
include: {
serviceLines: true,
},
},
transactions: true,
servicePayments: true,
},
});
return payments.map((payment) => ({
...payment,
patientName: payment.claim?.patientName ?? "",
paymentDate: payment.createdAt,
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
}));
},
async getRecentPaymentsByUser(
@@ -988,17 +1024,28 @@ export const storage: IStorage = {
limit: number,
offset: number
): Promise<PaymentWithExtras[]> {
return db.payment.findMany({
const payments = await db.payment.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
skip: offset,
take: limit,
include: {
claim: true,
claim: {
include: {
serviceLines: true,
},
},
transactions: true,
servicePayments: true,
},
});
return payments.map((payment) => ({
...payment,
patientName: payment.claim?.patientName ?? "",
paymentDate: payment.createdAt,
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
}));
},
async getPaymentsByDateRange(
@@ -1006,7 +1053,7 @@ export const storage: IStorage = {
from: Date,
to: Date
): Promise<PaymentWithExtras[]> {
return db.payment.findMany({
const payments = await db.payment.findMany({
where: {
userId,
createdAt: {
@@ -1016,11 +1063,22 @@ export const storage: IStorage = {
},
orderBy: { createdAt: "desc" },
include: {
claim: true,
claim: {
include: {
serviceLines: true,
},
},
transactions: true,
servicePayments: true,
},
});
return payments.map((payment) => ({
...payment,
patientName: payment.claim?.patientName ?? "",
paymentDate: payment.createdAt,
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
}));
},
async getTotalPaymentCountByUser(userId: number): Promise<number> {

View File

@@ -35,7 +35,6 @@ import {
StaffUncheckedCreateInputObjectSchema,
} from "@repo/db/usedSchemas";
import { z } from "zod";
import { useAuth } from "@/hooks/use-auth";
import LoadingScreen from "@/components/ui/LoadingScreen";
import { Checkbox } from "@/components/ui/checkbox";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -118,6 +117,10 @@ export default function ClaimsRecentTable({
}
};
useEffect(() => {
setCurrentPage(1);
}, [patientId]);
const getClaimsQueryKey = () =>
patientId
? ["claims-recent", "patient", patientId, currentPage]
@@ -243,8 +246,9 @@ export default function ClaimsRecentTable({
const totalPages = useMemo(
() => Math.ceil((claimsData?.totalCount || 0) / claimsPerPage),
[claimsData]
[claimsData?.totalCount, claimsPerPage]
);
const startItem = offset + 1;
const endItem = Math.min(offset + claimsPerPage, claimsData?.totalCount || 0);
@@ -315,264 +319,286 @@ export default function ClaimsRecentTable({
);
};
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{allowCheckbox && <TableHead>Select</TableHead>}
<TableHead>Claim ID</TableHead>
<TableHead>Patient Name</TableHead>
<TableHead>Submission Date</TableHead>
<TableHead>Insurance Provider</TableHead>
<TableHead>Member ID</TableHead>
<TableHead>Total Billed</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 claims.
</TableCell>
</TableRow>
) : (claimsData?.claims ?? []).length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
No claims found.
</TableCell>
</TableRow>
) : (
claimsData?.claims.map((claim) => (
<TableRow key={claim.id} className="hover:bg-gray-50">
{allowCheckbox && (
<TableCell>
<Checkbox
checked={selectedClaimId === claim.id}
onCheckedChange={() => handleSelectClaim(claim)}
/>
</TableCell>
)}
<TableCell>
<div className="text-sm font-medium text-gray-900">
CML-{claim.id!.toString().padStart(4, "0")}
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
<Avatar
className={`h-10 w-10 ${getAvatarColor(claim.patientId)}`}
>
<AvatarFallback className="text-white">
{getInitialsFromName(claim.patientName)}
</AvatarFallback>
</Avatar>
function getPageNumbers(current: number, total: number): (number | "...")[] {
const delta = 2;
const range: (number | "...")[] = [];
const left = Math.max(2, current - delta);
const right = Math.min(total - 1, current + delta);
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{claim.patientName}
</div>
<div className="text-sm text-gray-500">
DOB: {formatDateToHumanReadable(claim.dateOfBirth)}
</div>
range.push(1);
if (left > 2) range.push("...");
for (let i = left; i <= right; i++) {
range.push(i);
}
if (right < total - 1) range.push("...");
if (total > 1) range.push(total);
return range;
}
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{allowCheckbox && <TableHead>Select</TableHead>}
<TableHead>Claim ID</TableHead>
<TableHead>Patient Name</TableHead>
<TableHead>Submission Date</TableHead>
<TableHead>Insurance Provider</TableHead>
<TableHead>Member ID</TableHead>
<TableHead>Total Billed</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 claims.
</TableCell>
</TableRow>
) : (claimsData?.claims ?? []).length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
No claims found.
</TableCell>
</TableRow>
) : (
claimsData?.claims.map((claim) => (
<TableRow key={claim.id} className="hover:bg-gray-50">
{allowCheckbox && (
<TableCell>
<Checkbox
checked={selectedClaimId === claim.id}
onCheckedChange={() => handleSelectClaim(claim)}
/>
</TableCell>
)}
<TableCell>
<div className="text-sm font-medium text-gray-900">
CML-{claim.id!.toString().padStart(4, "0")}
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
<Avatar
className={`h-10 w-10 ${getAvatarColor(claim.patientId)}`}
>
<AvatarFallback className="text-white">
{getInitialsFromName(claim.patientName)}
</AvatarFallback>
</Avatar>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{claim.patientName}
</div>
<div className="text-sm text-gray-500">
DOB: {formatDateToHumanReadable(claim.dateOfBirth)}
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{formatDateToHumanReadable(claim.createdAt!)}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.insuranceProvider ?? "Not specified"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.memberId ?? "Not specified"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
${getTotalBilled(claim).toFixed(2)}
</div>
</TableCell>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{formatDateToHumanReadable(claim.createdAt!)}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.insuranceProvider ?? "Not specified"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.memberId ?? "Not specified"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
${getTotalBilled(claim).toFixed(2)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{(() => {
const { label, color, icon } = getStatusInfo(
claim.status
);
return (
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}
>
<span className="flex items-center">
{icon}
{label}
</span>
<TableCell>
<div className="flex items-center gap-2">
{(() => {
const { label, color, icon } = getStatusInfo(
claim.status
);
return (
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}
>
<span className="flex items-center">
{icon}
{label}
</span>
);
})()}
</div>
</TableCell>
</span>
);
})()}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{allowDelete && (
<Button
onClick={() => {
handleDeleteClaim(claim);
}}
className="text-red-600 hover:text-red-900"
aria-label="Delete Staff"
variant="ghost"
size="icon"
>
<Delete />
</Button>
)}
{allowEdit && (
<Button
variant="ghost"
size="icon"
onClick={() => {
handleEditClaim(claim);
}}
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
>
<Edit className="h-4 w-4" />
</Button>
)}
{allowView && (
<Button
variant="ghost"
size="icon"
onClick={() => {
handleViewClaim(claim);
}}
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
>
<Eye className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{allowDelete && (
<Button
onClick={() => {
handleDeleteClaim(claim);
}}
className="text-red-600 hover:text-red-900"
aria-label="Delete Staff"
variant="ghost"
size="icon"
>
<Delete />
</Button>
)}
{allowEdit && (
<Button
variant="ghost"
size="icon"
onClick={() => {
handleEditClaim(claim);
}}
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
>
<Edit className="h-4 w-4" />
</Button>
)}
{allowView && (
<Button
variant="ghost"
size="icon"
onClick={() => {
handleViewClaim(claim);
}}
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
>
<Eye className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<DeleteConfirmationDialog
isOpen={isDeleteClaimOpen}
onConfirm={handleConfirmDeleteClaim}
onCancel={() => setIsDeleteClaimOpen(false)}
entityName={currentClaim?.patientName}
<DeleteConfirmationDialog
isOpen={isDeleteClaimOpen}
onConfirm={handleConfirmDeleteClaim}
onCancel={() => setIsDeleteClaimOpen(false)}
entityName={currentClaim?.patientName}
/>
{isViewClaimOpen && currentClaim && (
<ClaimViewModal
isOpen={isViewClaimOpen}
onClose={() => setIsViewClaimOpen(false)}
onOpenChange={(open) => setIsViewClaimOpen(open)}
onEditClaim={(claim) => handleEditClaim(claim)}
claim={currentClaim}
/>
)}
{isViewClaimOpen && currentClaim && (
<ClaimViewModal
isOpen={isViewClaimOpen}
onClose={() => setIsViewClaimOpen(false)}
onOpenChange={(open) => setIsViewClaimOpen(open)}
onEditClaim={(claim) => handleEditClaim(claim)}
claim={currentClaim}
/>
)}
{isEditClaimOpen && currentClaim && (
<ClaimEditModal
isOpen={isEditClaimOpen}
onClose={() => setIsEditClaimOpen(false)}
onOpenChange={(open) => setIsEditClaimOpen(open)}
claim={currentClaim}
onSave={(updatedClaim) => {
updateClaimMutation.mutate(updatedClaim);
}}
/>
)}
{isEditClaimOpen && currentClaim && (
<ClaimEditModal
isOpen={isEditClaimOpen}
onClose={() => setIsEditClaimOpen(false)}
onOpenChange={(open) => setIsEditClaimOpen(open)}
claim={currentClaim}
onSave={(updatedClaim) => {
updateClaimMutation.mutate(updatedClaim);
}}
/>
)}
{/* 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 {claimsData?.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>
{/* 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 {claimsData?.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>
{Array.from({ length: totalPages }).map((_, i) => (
<PaginationItem key={i}>
{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(i + 1);
setCurrentPage(page as number);
}}
isActive={currentPage === i + 1}
isActive={currentPage === page}
>
{i + 1}
{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>
))}
<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>
)}
</div>
);
}

View File

@@ -334,6 +334,25 @@ export function PatientTable({
return colorClasses[id % colorClasses.length];
};
function getPageNumbers(current: number, total: number): (number | "...")[] {
const delta = 2;
const range: (number | "...")[] = [];
const left = Math.max(2, current - delta);
const right = Math.min(total - 1, current + delta);
range.push(1);
if (left > 2) range.push("...");
for (let i = left; i <= right; i++) {
range.push(i);
}
if (right < total - 1) range.push("...");
if (total > 1) range.push(total);
return range;
}
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="overflow-x-auto">
@@ -712,21 +731,26 @@ export function PatientTable({
}
/>
</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>
))}
{Array.from({ length: totalPages }).map((_, i) => (
<PaginationItem key={i}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setCurrentPage(i + 1);
}}
isActive={currentPage === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"

View File

@@ -1,20 +1,48 @@
import { useState, useEffect, useMemo } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Edit, Eye, Delete } from "lucide-react";
import {
Edit,
Eye,
Delete,
Clock,
CheckCircle,
AlertCircle,
} 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 {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Checkbox } from "@/components/ui/checkbox";
import { PaymentUncheckedCreateInputObjectSchema, PaymentTransactionCreateInputObjectSchema, ServiceLinePaymentCreateInputObjectSchema } from "@repo/db/usedSchemas";
import {
PaymentUncheckedCreateInputObjectSchema,
PaymentTransactionCreateInputObjectSchema,
ServiceLinePaymentCreateInputObjectSchema,
ClaimUncheckedCreateInputObjectSchema,
ClaimStatusSchema,
StaffUncheckedCreateInputObjectSchema,
} from "@repo/db/usedSchemas";
import { z } from "zod";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import PaymentViewModal from "./payment-view-modal";
import PaymentEditModal from "./payment-edit-modal";
import { Prisma } from "@repo/db/generated/prisma";
import LoadingScreen from "../ui/LoadingScreen";
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
type PaymentTransaction = z.infer<
@@ -45,24 +73,46 @@ type UpdatePayment = z.infer<typeof updatePaymentSchema>;
type PaymentWithExtras = Prisma.PaymentGetPayload<{
include: {
transactions: true;
claim: { include: { serviceLines: true } };
servicePayments: true;
claim: true;
transactions: true;
};
}>;
}> & {
patientName: string;
paymentDate: Date;
paymentMethod: string;
};
interface PaymentApiResponse {
payments: Payment[];
payments: PaymentWithExtras[];
totalCount: number;
}
//creating types out of schema auto generated.
type Claim = z.infer<typeof ClaimUncheckedCreateInputObjectSchema>;
export type ClaimStatus = z.infer<typeof ClaimStatusSchema>;
type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
type ClaimWithServiceLines = Claim & {
serviceLines: {
id: number;
claimId: number;
procedureCode: string;
procedureDate: Date;
oralCavityArea: string | null;
toothNumber: string | null;
toothSurface: string | null;
billedAmount: number;
}[];
staff: Staff | null;
};
interface PaymentsRecentTableProps {
allowEdit?: boolean;
allowView?: boolean;
allowDelete?: boolean;
allowCheckbox?: boolean;
onSelectPayment?: (payment: Payment | null) => void;
onSelectPayment?: (payment: PaymentWithExtras | null) => void;
onPageChange?: (page: number) => void;
claimId?: number;
}
@@ -77,219 +127,494 @@ export default function PaymentsRecentTable({
claimId,
}: PaymentsRecentTableProps) {
const { toast } = useToast();
const [isViewPaymentOpen, setIsViewPaymentOpen] = useState(false);
const [isEditPaymentOpen, setIsEditPaymentOpen] = useState(false);
const [isDeletePaymentOpen, setIsDeletePaymentOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const paymentsPerPage = 5;
const offset = (currentPage - 1) * paymentsPerPage;
const [selectedPaymentId, setSelectedPaymentId] = useState<number | null>(null);
const [currentPayment, setCurrentPayment] = useState<Payment | null>(null);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [currentPayment, setCurrentPayment] = useState<
PaymentWithExtras | undefined
>(undefined);
const [selectedPaymentId, setSelectedPaymentId] = useState<number | null>(
null
);
const getQueryKey = () =>
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 getPaymentsQueryKey = () =>
claimId
? ["payments", "claim", claimId, currentPage]
: ["payments", "recent", currentPage];
? ["payments-recent", "claim", claimId, currentPage]
: ["payments-recent", "global", currentPage];
const {
data: paymentsData,
isLoading,
isError,
} = useQuery<PaymentApiResponse>({
queryKey: getQueryKey(),
queryKey: getPaymentsQueryKey(),
queryFn: async () => {
const endpoint = claimId
? `/api/payments/claim/${claimId}?limit=${paymentsPerPage}&offset=${offset}`
: `/api/payments/recent?limit=${paymentsPerPage}&offset=${offset}`;
const res = await apiRequest("GET", endpoint);
if (!res.ok) throw new Error("Failed to fetch payments");
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 deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await apiRequest("DELETE", `/api/payments/${id}`);
if (!res.ok) throw new Error("Failed to delete");
const updatePaymentMutation = useMutation({
mutationFn: async (payment: Payment) => {
const response = await apiRequest("PUT", `/api/claims/${payment.id}`, {
data: payment,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update Payment");
}
return response.json();
},
onSuccess: () => {
toast({ title: "Deleted", description: "Payment deleted successfully" });
setIsDeleteOpen(false);
queryClient.invalidateQueries({ queryKey: getQueryKey() });
setIsEditPaymentOpen(false);
toast({
title: "Success",
description: "Payment updated successfully!",
variant: "default",
});
queryClient.invalidateQueries({
queryKey: getPaymentsQueryKey(),
});
},
onError: () => {
toast({ title: "Error", description: "Delete failed", variant: "destructive" });
onError: (error) => {
toast({
title: "Error",
description: `Update failed: ${error.message}`,
variant: "destructive",
});
},
});
const handleSelectPayment = (payment: Payment) => {
const isSelected = selectedPaymentId === payment.id;
const newSelectedId = isSelected ? null : payment.id;
setSelectedPaymentId(newSelectedId);
onSelectPayment?.(isSelected ? null : payment);
const deletePaymentMutation = useMutation({
mutationFn: async (id: number) => {
const res = await apiRequest("DELETE", `/api/payments/${id}`);
return;
},
onSuccess: () => {
setIsDeletePaymentOpen(false);
queryClient.invalidateQueries({
queryKey: getPaymentsQueryKey(),
});
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 handleDelete = () => {
if (currentPayment?.id) {
deleteMutation.mutate(currentPayment.id);
const handleViewPayment = (payment: PaymentWithExtras) => {
setCurrentPayment(payment);
setIsViewPaymentOpen(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",
});
}
};
useEffect(() => {
onPageChange?.(currentPage);
}, [currentPage]);
if (onPageChange) onPageChange(currentPage);
}, [currentPage, onPageChange]);
useEffect(() => {
setCurrentPage(1);
}, [claimId]);
const totalPages = useMemo(
() => Math.ceil((paymentsData?.totalCount || 0) / paymentsPerPage),
[paymentsData]
[paymentsData?.totalCount, paymentsPerPage]
);
const startItem = offset + 1;
const endItem = Math.min(
offset + paymentsPerPage,
paymentsData?.totalCount || 0
);
const getInitialsFromName = (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?: ClaimStatus) => {
switch (status) {
case "PENDING":
return {
label: "Pending",
color: "bg-yellow-100 text-yellow-800",
icon: <Clock className="h-3 w-3 mr-1" />,
};
case "APPROVED":
return {
label: "Approved",
color: "bg-green-100 text-green-800",
icon: <CheckCircle className="h-3 w-3 mr-1" />,
};
case "CANCELLED":
return {
label: "Cancelled",
color: "bg-red-100 text-red-800",
icon: <AlertCircle className="h-3 w-3 mr-1" />,
};
default:
return {
label: status
? status.charAt(0).toUpperCase() + status.slice(1)
: "Unknown",
color: "bg-gray-100 text-gray-800",
icon: <AlertCircle className="h-3 w-3 mr-1" />,
};
}
};
const getTotalBilled = (claim: ClaimWithServiceLines) => {
return claim.serviceLines.reduce(
(sum, line) => sum + (line.billedAmount || 0),
0
);
};
function getPageNumbers(current: number, total: number): (number | "...")[] {
const delta = 2;
const range: (number | "...")[] = [];
const left = Math.max(2, current - delta);
const right = Math.min(total - 1, current + delta);
range.push(1);
if (left > 2) range.push("...");
for (let i = left; i <= right; i++) {
range.push(i);
}
if (right < total - 1) range.push("...");
if (total > 1) range.push(total);
return range;
}
return (
<div className="bg-white rounded shadow">
<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>Payer</TableHead>
<TableHead>Patient Name</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Date</TableHead>
<TableHead>Method</TableHead>
<TableHead>Note</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-6">Loading...</TableCell>
<TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
<LoadingScreen />
</TableCell>
</TableRow>
) : isError ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-red-500 py-6">Error loading payments</TableCell>
<TableCell
colSpan={6}
className="text-center py-8 text-red-500"
>
Error loading payments.
</TableCell>
</TableRow>
) : paymentsData?.payments.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-6 text-muted-foreground">No payments found</TableCell>
<TableCell
colSpan={8}
className="text-center py-8 text-muted-foreground"
>
No payments found
</TableCell>
</TableRow>
) : (
paymentsData?.payments.map((payment) => (
<TableRow key={payment.id}>
{allowCheckbox && (
paymentsData?.payments.map((payment) => {
const claim = (payment as PaymentWithExtras)
.claim as ClaimWithServiceLines;
const totalBilled = getTotalBilled(claim);
const totalPaid = (
payment as PaymentWithExtras
).servicePayments.reduce(
(sum, sp) => sum + (sp.paidAmount?.toNumber?.() ?? 0),
0
);
const outstanding = totalBilled - totalPaid;
return (
<TableRow key={payment.id}>
{allowCheckbox && (
<TableCell>
<Checkbox
checked={selectedPaymentId === payment.id}
onCheckedChange={() => handleSelectPayment(payment)}
/>
</TableCell>
)}
<TableCell>
<Checkbox
checked={selectedPaymentId === payment.id}
onCheckedChange={() => handleSelectPayment(payment)}
/>
{typeof payment.id === "number"
? `PAY-${payment.id.toString().padStart(4, "0")}`
: "N/A"}
</TableCell>
)}
<TableCell>{`PAY-${payment.id.toString().padStart(4, "0")}`}</TableCell>
<TableCell>{payment.payerName}</TableCell>
<TableCell>${payment.amountPaid.toFixed(2)}</TableCell>
<TableCell>{formatDateToHumanReadable(payment.paymentDate)}</TableCell>
<TableCell>{payment.paymentMethod}</TableCell>
<TableCell>{payment.note || "—"}</TableCell>
<TableCell className="text-right space-x-2">
{allowDelete && (
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsDeleteOpen(true); }}>
<Delete className="text-red-600" />
</Button>
)}
{allowEdit && (
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsEditOpen(true); }}>
<Edit />
</Button>
)}
{allowView && (
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsViewOpen(true); }}>
<Eye />
</Button>
)}
</TableCell>
</TableRow>
))
<TableCell>{payment.patientName}</TableCell>
{/* 💰 Billed / Paid / Due breakdown */}
<TableCell>
<div className="flex flex-col gap-1">
<span>
<strong>Total Billed:</strong> $
{totalBilled.toFixed(2)}
</span>
<span>
<strong>Total Paid:</strong> ${totalPaid.toFixed(2)}
</span>
<span>
<strong>Total Due:</strong>{" "}
{outstanding > 0 ? (
<span className="text-yellow-600">
${outstanding.toFixed(2)}
</span>
) : (
<span className="text-green-600">Settled</span>
)}
</span>
</div>
</TableCell>
<TableCell>
{formatDateToHumanReadable(payment.paymentDate)}
</TableCell>
<TableCell>{payment.paymentMethod}</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 Staff"
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>
)}
{allowView && (
<Button
variant="ghost"
size="icon"
onClick={() => {
handleViewPayment(payment);
}}
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
>
<Eye className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<DeleteConfirmationDialog
isOpen={isDeletePaymentOpen}
onConfirm={handleConfirmDeletePayment}
onCancel={() => setIsDeletePaymentOpen(false)}
entityName={String(currentPayment?.claimId)}
/>
{/* /will hanlde both modal later */}
{/* {isViewPaymentOpen && currentPayment && (
<ClaimPaymentModal
isOpen={isViewPaymentOpen}
onClose={() => setIsViewPaymentOpen(false)}
onOpenChange={(open) => setIsViewPaymentOpen(open)}
onEditClaim={(payment) => handleEditPayment(payment)}
payment={currentPayment}
/>
)}
{isEditPaymentOpen && currentPayment && (
<ClaimPaymentModal
isOpen={isEditPaymentOpen}
onClose={() => setIsEditPaymentOpen(false)}
onOpenChange={(open) => setIsEditPaymentOpen(open)}
payment={currentPayment}
onSave={(updatedPayment) => {
updatePaymentMutation.mutate(updatedPayment);
}}
/>
)} */}
{/* Pagination */}
{totalPages > 1 && (
<div className="px-4 py-2 border-t flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {(offset + 1)}{Math.min(offset + paymentsPerPage, paymentsData?.totalCount || 0)} of {paymentsData?.totalCount || 0}
</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>
{Array.from({ length: totalPages }).map((_, i) => (
<PaginationItem key={i}>
<PaginationLink
<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();
setCurrentPage(i + 1);
if (currentPage > 1) setCurrentPage(currentPage - 1);
}}
isActive={currentPage === i + 1}
>
{i + 1}
</PaginationLink>
className={
currentPage === 1 ? "pointer-events-none opacity-50" : ""
}
/>
</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>
{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>
)}
{/* Modals */}
{isViewOpen && currentPayment && (
<PaymentViewModal
isOpen={isViewOpen}
onClose={() => setIsViewOpen(false)}
payment={currentPayment}
/>
)}
{isEditOpen && currentPayment && (
<PaymentEditModal
isOpen={isEditOpen}
onClose={() => setIsEditOpen(false)}
payment={currentPayment}
onSave={() => {
queryClient.invalidateQueries({ queryKey: getQueryKey() });
}}
/>
)}
<DeleteConfirmationDialog
isOpen={isDeleteOpen}
onCancel={() => setIsDeleteOpen(false)}
onConfirm={handleDelete}
entityName={`Payment ${currentPayment?.id}`}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff