payment table - rows 1st checkpoint
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,6 +319,25 @@ export default function ClaimsRecentTable({
|
||||
);
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -532,27 +555,30 @@ export default function ClaimsRecentTable({
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === 1
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
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="#"
|
||||
|
||||
@@ -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">
|
||||
@@ -713,20 +732,25 @@ export function PatientTable({
|
||||
/>
|
||||
</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="#"
|
||||
|
||||
@@ -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,107 +127,312 @@ 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) => (
|
||||
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>
|
||||
@@ -187,41 +442,124 @@ export default function PaymentsRecentTable({
|
||||
/>
|
||||
</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>
|
||||
{typeof payment.id === "number"
|
||||
? `PAY-${payment.id.toString().padStart(4, "0")}`
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<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>{payment.note || "—"}</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{allowDelete && (
|
||||
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsDeleteOpen(true); }}>
|
||||
<Delete className="text-red-600" />
|
||||
<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={() => { setCurrentPayment(payment); setIsEditOpen(true); }}>
|
||||
<Edit />
|
||||
<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={() => { setCurrentPayment(payment); setIsViewOpen(true); }}>
|
||||
<Eye />
|
||||
<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 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>
|
||||
@@ -232,64 +570,51 @@ export default function PaymentsRecentTable({
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
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);
|
||||
if (currentPage < totalPages)
|
||||
setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
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
Reference in New Issue
Block a user