payment table - rows 1st checkpoint
This commit is contained in:
@@ -7,7 +7,8 @@ import multer from "multer";
|
|||||||
import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
|
import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import axios from "axios";
|
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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -284,8 +285,36 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
|||||||
userId: req.user!.id,
|
userId: req.user!.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newClaim = await storage.createClaim(parsedClaim);
|
// Step 1: Calculate total billed from service lines
|
||||||
res.status(201).json(newClaim);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|||||||
@@ -38,13 +38,7 @@ const updateAppointmentSchema = (
|
|||||||
type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
|
type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
|
||||||
|
|
||||||
//patient types
|
//patient types
|
||||||
const PatientSchema = (
|
|
||||||
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
|
||||||
).omit({
|
|
||||||
appointments: true,
|
|
||||||
});
|
|
||||||
type Patient = z.infer<typeof PatientUncheckedCreateInputObjectSchema>;
|
type Patient = z.infer<typeof PatientUncheckedCreateInputObjectSchema>;
|
||||||
type Patient2 = z.infer<typeof PatientSchema>;
|
|
||||||
|
|
||||||
const insertPatientSchema = (
|
const insertPatientSchema = (
|
||||||
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
@@ -196,9 +190,17 @@ type PaymentWithExtras = Prisma.PaymentGetPayload<{
|
|||||||
include: {
|
include: {
|
||||||
transactions: true;
|
transactions: true;
|
||||||
servicePayments: true;
|
servicePayments: true;
|
||||||
claim: true;
|
claim: {
|
||||||
|
include: {
|
||||||
|
serviceLines: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}>;
|
}> & {
|
||||||
|
patientName: string;
|
||||||
|
paymentDate: Date;
|
||||||
|
paymentMethod: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
// User methods
|
// User methods
|
||||||
@@ -942,45 +944,79 @@ export const storage: IStorage = {
|
|||||||
id: number,
|
id: number,
|
||||||
userId: number
|
userId: number
|
||||||
): Promise<PaymentWithExtras | null> {
|
): Promise<PaymentWithExtras | null> {
|
||||||
return db.payment.findFirst({
|
const payment = await db.payment.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
include: {
|
include: {
|
||||||
claim: true,
|
claim: {
|
||||||
|
include: {
|
||||||
|
serviceLines: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
transactions: true,
|
transactions: true,
|
||||||
servicePayments: true,
|
servicePayments: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!payment) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payment,
|
||||||
|
patientName: payment.claim?.patientName ?? "",
|
||||||
|
paymentDate: payment.createdAt,
|
||||||
|
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPaymentsByClaimId(
|
async getPaymentsByClaimId(
|
||||||
claimId: number,
|
claimId: number,
|
||||||
userId: number
|
userId: number
|
||||||
): Promise<PaymentWithExtras | null> {
|
): Promise<PaymentWithExtras | null> {
|
||||||
return db.payment.findFirst({
|
const payment = await db.payment.findFirst({
|
||||||
where: { claimId, userId },
|
where: { claimId, userId },
|
||||||
include: {
|
include: {
|
||||||
claim: true,
|
claim: {
|
||||||
|
include: {
|
||||||
|
serviceLines: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
transactions: true,
|
transactions: true,
|
||||||
servicePayments: true,
|
servicePayments: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!payment) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payment,
|
||||||
|
patientName: payment.claim?.patientName ?? "",
|
||||||
|
paymentDate: payment.createdAt,
|
||||||
|
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPaymentsByPatientId(
|
async getPaymentsByPatientId(
|
||||||
patientId: number,
|
patientId: number,
|
||||||
userId: number
|
userId: number
|
||||||
): Promise<PaymentWithExtras[]> {
|
): Promise<PaymentWithExtras[]> {
|
||||||
return db.payment.findMany({
|
const payments = await db.payment.findMany({
|
||||||
where: {
|
where: { patientId, userId },
|
||||||
patientId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
claim: true,
|
claim: {
|
||||||
|
include: {
|
||||||
|
serviceLines: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
transactions: true,
|
transactions: true,
|
||||||
servicePayments: true,
|
servicePayments: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return payments.map((payment) => ({
|
||||||
|
...payment,
|
||||||
|
patientName: payment.claim?.patientName ?? "",
|
||||||
|
paymentDate: payment.createdAt,
|
||||||
|
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
async getRecentPaymentsByUser(
|
async getRecentPaymentsByUser(
|
||||||
@@ -988,17 +1024,28 @@ export const storage: IStorage = {
|
|||||||
limit: number,
|
limit: number,
|
||||||
offset: number
|
offset: number
|
||||||
): Promise<PaymentWithExtras[]> {
|
): Promise<PaymentWithExtras[]> {
|
||||||
return db.payment.findMany({
|
const payments = await db.payment.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
skip: offset,
|
skip: offset,
|
||||||
take: limit,
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
claim: true,
|
claim: {
|
||||||
|
include: {
|
||||||
|
serviceLines: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
transactions: true,
|
transactions: true,
|
||||||
servicePayments: true,
|
servicePayments: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return payments.map((payment) => ({
|
||||||
|
...payment,
|
||||||
|
patientName: payment.claim?.patientName ?? "",
|
||||||
|
paymentDate: payment.createdAt,
|
||||||
|
paymentMethod: payment.transactions[0]?.method ?? "OTHER",
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPaymentsByDateRange(
|
async getPaymentsByDateRange(
|
||||||
@@ -1006,7 +1053,7 @@ export const storage: IStorage = {
|
|||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
): Promise<PaymentWithExtras[]> {
|
): Promise<PaymentWithExtras[]> {
|
||||||
return db.payment.findMany({
|
const payments = await db.payment.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
createdAt: {
|
createdAt: {
|
||||||
@@ -1016,11 +1063,22 @@ export const storage: IStorage = {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
claim: true,
|
claim: {
|
||||||
|
include: {
|
||||||
|
serviceLines: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
transactions: true,
|
transactions: true,
|
||||||
servicePayments: 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> {
|
async getTotalPaymentCountByUser(userId: number): Promise<number> {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
StaffUncheckedCreateInputObjectSchema,
|
StaffUncheckedCreateInputObjectSchema,
|
||||||
} from "@repo/db/usedSchemas";
|
} from "@repo/db/usedSchemas";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import LoadingScreen from "@/components/ui/LoadingScreen";
|
import LoadingScreen from "@/components/ui/LoadingScreen";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
@@ -118,6 +117,10 @@ export default function ClaimsRecentTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [patientId]);
|
||||||
|
|
||||||
const getClaimsQueryKey = () =>
|
const getClaimsQueryKey = () =>
|
||||||
patientId
|
patientId
|
||||||
? ["claims-recent", "patient", patientId, currentPage]
|
? ["claims-recent", "patient", patientId, currentPage]
|
||||||
@@ -243,8 +246,9 @@ export default function ClaimsRecentTable({
|
|||||||
|
|
||||||
const totalPages = useMemo(
|
const totalPages = useMemo(
|
||||||
() => Math.ceil((claimsData?.totalCount || 0) / claimsPerPage),
|
() => Math.ceil((claimsData?.totalCount || 0) / claimsPerPage),
|
||||||
[claimsData]
|
[claimsData?.totalCount, claimsPerPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const startItem = offset + 1;
|
const startItem = offset + 1;
|
||||||
const endItem = Math.min(offset + claimsPerPage, claimsData?.totalCount || 0);
|
const endItem = Math.min(offset + claimsPerPage, claimsData?.totalCount || 0);
|
||||||
|
|
||||||
@@ -315,264 +319,286 @@ export default function ClaimsRecentTable({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
function getPageNumbers(current: number, total: number): (number | "...")[] {
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
const delta = 2;
|
||||||
<div className="overflow-x-auto">
|
const range: (number | "...")[] = [];
|
||||||
<Table>
|
const left = Math.max(2, current - delta);
|
||||||
<TableHeader>
|
const right = Math.min(total - 1, current + delta);
|
||||||
<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">
|
range.push(1);
|
||||||
<div className="text-sm font-medium text-gray-900">
|
if (left > 2) range.push("...");
|
||||||
{claim.patientName}
|
|
||||||
</div>
|
for (let i = left; i <= right; i++) {
|
||||||
<div className="text-sm text-gray-500">
|
range.push(i);
|
||||||
DOB: {formatDateToHumanReadable(claim.dateOfBirth)}
|
}
|
||||||
</div>
|
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<div className="text-sm text-gray-900">
|
<TableCell>
|
||||||
{formatDateToHumanReadable(claim.createdAt!)}
|
<div className="text-sm text-gray-900">
|
||||||
</div>
|
{formatDateToHumanReadable(claim.createdAt!)}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<div className="text-sm text-gray-900">
|
<TableCell>
|
||||||
{claim.insuranceProvider ?? "Not specified"}
|
<div className="text-sm text-gray-900">
|
||||||
</div>
|
{claim.insuranceProvider ?? "Not specified"}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<div className="text-sm text-gray-900">
|
<TableCell>
|
||||||
{claim.memberId ?? "Not specified"}
|
<div className="text-sm text-gray-900">
|
||||||
</div>
|
{claim.memberId ?? "Not specified"}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<div className="text-sm text-gray-900">
|
<TableCell>
|
||||||
${getTotalBilled(claim).toFixed(2)}
|
<div className="text-sm text-gray-900">
|
||||||
</div>
|
${getTotalBilled(claim).toFixed(2)}
|
||||||
</TableCell>
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const { label, color, icon } = getStatusInfo(
|
const { label, color, icon } = getStatusInfo(
|
||||||
claim.status
|
claim.status
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}
|
className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
</span>
|
||||||
})()}
|
);
|
||||||
</div>
|
})()}
|
||||||
</TableCell>
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
{allowDelete && (
|
{allowDelete && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDeleteClaim(claim);
|
handleDeleteClaim(claim);
|
||||||
}}
|
}}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
aria-label="Delete Staff"
|
aria-label="Delete Staff"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
>
|
>
|
||||||
<Delete />
|
<Delete />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{allowEdit && (
|
{allowEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleEditClaim(claim);
|
handleEditClaim(claim);
|
||||||
}}
|
}}
|
||||||
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{allowView && (
|
{allowView && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleViewClaim(claim);
|
handleViewClaim(claim);
|
||||||
}}
|
}}
|
||||||
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
|
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeleteConfirmationDialog
|
<DeleteConfirmationDialog
|
||||||
isOpen={isDeleteClaimOpen}
|
isOpen={isDeleteClaimOpen}
|
||||||
onConfirm={handleConfirmDeleteClaim}
|
onConfirm={handleConfirmDeleteClaim}
|
||||||
onCancel={() => setIsDeleteClaimOpen(false)}
|
onCancel={() => setIsDeleteClaimOpen(false)}
|
||||||
entityName={currentClaim?.patientName}
|
entityName={currentClaim?.patientName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isViewClaimOpen && currentClaim && (
|
||||||
|
<ClaimViewModal
|
||||||
|
isOpen={isViewClaimOpen}
|
||||||
|
onClose={() => setIsViewClaimOpen(false)}
|
||||||
|
onOpenChange={(open) => setIsViewClaimOpen(open)}
|
||||||
|
onEditClaim={(claim) => handleEditClaim(claim)}
|
||||||
|
claim={currentClaim}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isViewClaimOpen && currentClaim && (
|
{isEditClaimOpen && currentClaim && (
|
||||||
<ClaimViewModal
|
<ClaimEditModal
|
||||||
isOpen={isViewClaimOpen}
|
isOpen={isEditClaimOpen}
|
||||||
onClose={() => setIsViewClaimOpen(false)}
|
onClose={() => setIsEditClaimOpen(false)}
|
||||||
onOpenChange={(open) => setIsViewClaimOpen(open)}
|
onOpenChange={(open) => setIsEditClaimOpen(open)}
|
||||||
onEditClaim={(claim) => handleEditClaim(claim)}
|
claim={currentClaim}
|
||||||
claim={currentClaim}
|
onSave={(updatedClaim) => {
|
||||||
/>
|
updateClaimMutation.mutate(updatedClaim);
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isEditClaimOpen && currentClaim && (
|
{/* Pagination */}
|
||||||
<ClaimEditModal
|
{totalPages > 1 && (
|
||||||
isOpen={isEditClaimOpen}
|
<div className="bg-white px-4 py-3 border-t border-gray-200">
|
||||||
onClose={() => setIsEditClaimOpen(false)}
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
onOpenChange={(open) => setIsEditClaimOpen(open)}
|
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
|
||||||
claim={currentClaim}
|
Showing {startItem}–{endItem} of {claimsData?.totalCount || 0}{" "}
|
||||||
onSave={(updatedClaim) => {
|
results
|
||||||
updateClaimMutation.mutate(updatedClaim);
|
</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 */}
|
{getPageNumbers(currentPage, totalPages).map((page, idx) => (
|
||||||
{totalPages > 1 && (
|
<PaginationItem key={idx}>
|
||||||
<div className="bg-white px-4 py-3 border-t border-gray-200">
|
{page === "..." ? (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<span className="px-2 text-gray-500">...</span>
|
||||||
<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}>
|
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCurrentPage(i + 1);
|
setCurrentPage(page as number);
|
||||||
}}
|
}}
|
||||||
isActive={currentPage === i + 1}
|
isActive={currentPage === page}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{page}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
)}
|
||||||
))}
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationNext
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (currentPage < totalPages)
|
|
||||||
setCurrentPage(currentPage + 1);
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
currentPage === totalPages
|
|
||||||
? "pointer-events-none opacity-50"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</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>
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,6 +334,25 @@ export function PatientTable({
|
|||||||
return colorClasses[id % colorClasses.length];
|
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 (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -713,20 +732,25 @@ export function PatientTable({
|
|||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
{Array.from({ length: totalPages }).map((_, i) => (
|
{getPageNumbers(currentPage, totalPages).map((page, idx) => (
|
||||||
<PaginationItem key={i}>
|
<PaginationItem key={idx}>
|
||||||
<PaginationLink
|
{page === "..." ? (
|
||||||
href="#"
|
<span className="px-2 text-gray-500">...</span>
|
||||||
onClick={(e) => {
|
) : (
|
||||||
e.preventDefault();
|
<PaginationLink
|
||||||
setCurrentPage(i + 1);
|
href="#"
|
||||||
}}
|
onClick={(e) => {
|
||||||
isActive={currentPage === i + 1}
|
e.preventDefault();
|
||||||
>
|
setCurrentPage(page as number);
|
||||||
{i + 1}
|
}}
|
||||||
</PaginationLink>
|
isActive={currentPage === page}
|
||||||
</PaginationItem>
|
>
|
||||||
))}
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
)}
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext
|
<PaginationNext
|
||||||
href="#"
|
href="#"
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
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 { 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 { z } from "zod";
|
||||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||||
import PaymentViewModal from "./payment-view-modal";
|
import PaymentViewModal from "./payment-view-modal";
|
||||||
import PaymentEditModal from "./payment-edit-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 Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
|
||||||
type PaymentTransaction = z.infer<
|
type PaymentTransaction = z.infer<
|
||||||
@@ -45,24 +73,46 @@ type UpdatePayment = z.infer<typeof updatePaymentSchema>;
|
|||||||
|
|
||||||
type PaymentWithExtras = Prisma.PaymentGetPayload<{
|
type PaymentWithExtras = Prisma.PaymentGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
transactions: true;
|
claim: { include: { serviceLines: true } };
|
||||||
servicePayments: true;
|
servicePayments: true;
|
||||||
claim: true;
|
transactions: true;
|
||||||
};
|
};
|
||||||
}>;
|
}> & {
|
||||||
|
patientName: string;
|
||||||
|
paymentDate: Date;
|
||||||
|
paymentMethod: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface PaymentApiResponse {
|
interface PaymentApiResponse {
|
||||||
payments: Payment[];
|
payments: PaymentWithExtras[];
|
||||||
totalCount: number;
|
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 {
|
interface PaymentsRecentTableProps {
|
||||||
allowEdit?: boolean;
|
allowEdit?: boolean;
|
||||||
allowView?: boolean;
|
allowView?: boolean;
|
||||||
allowDelete?: boolean;
|
allowDelete?: boolean;
|
||||||
allowCheckbox?: boolean;
|
allowCheckbox?: boolean;
|
||||||
onSelectPayment?: (payment: Payment | null) => void;
|
onSelectPayment?: (payment: PaymentWithExtras | null) => void;
|
||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
claimId?: number;
|
claimId?: number;
|
||||||
}
|
}
|
||||||
@@ -77,219 +127,494 @@ export default function PaymentsRecentTable({
|
|||||||
claimId,
|
claimId,
|
||||||
}: PaymentsRecentTableProps) {
|
}: PaymentsRecentTableProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isViewPaymentOpen, setIsViewPaymentOpen] = useState(false);
|
||||||
|
const [isEditPaymentOpen, setIsEditPaymentOpen] = useState(false);
|
||||||
|
const [isDeletePaymentOpen, setIsDeletePaymentOpen] = useState(false);
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const paymentsPerPage = 5;
|
const paymentsPerPage = 5;
|
||||||
const offset = (currentPage - 1) * paymentsPerPage;
|
const offset = (currentPage - 1) * paymentsPerPage;
|
||||||
|
|
||||||
const [selectedPaymentId, setSelectedPaymentId] = useState<number | null>(null);
|
const [currentPayment, setCurrentPayment] = useState<
|
||||||
const [currentPayment, setCurrentPayment] = useState<Payment | null>(null);
|
PaymentWithExtras | undefined
|
||||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
>(undefined);
|
||||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
const [selectedPaymentId, setSelectedPaymentId] = useState<number | null>(
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
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
|
claimId
|
||||||
? ["payments", "claim", claimId, currentPage]
|
? ["payments-recent", "claim", claimId, currentPage]
|
||||||
: ["payments", "recent", currentPage];
|
: ["payments-recent", "global", currentPage];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: paymentsData,
|
data: paymentsData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useQuery<PaymentApiResponse>({
|
} = useQuery<PaymentApiResponse>({
|
||||||
queryKey: getQueryKey(),
|
queryKey: getPaymentsQueryKey(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const endpoint = claimId
|
const endpoint = claimId
|
||||||
? `/api/payments/claim/${claimId}?limit=${paymentsPerPage}&offset=${offset}`
|
? `/api/payments/claim/${claimId}?limit=${paymentsPerPage}&offset=${offset}`
|
||||||
: `/api/payments/recent?limit=${paymentsPerPage}&offset=${offset}`;
|
: `/api/payments/recent?limit=${paymentsPerPage}&offset=${offset}`;
|
||||||
|
|
||||||
const res = await apiRequest("GET", endpoint);
|
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();
|
return res.json();
|
||||||
},
|
},
|
||||||
placeholderData: { payments: [], totalCount: 0 },
|
placeholderData: { payments: [], totalCount: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const updatePaymentMutation = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (payment: Payment) => {
|
||||||
const res = await apiRequest("DELETE", `/api/payments/${id}`);
|
const response = await apiRequest("PUT", `/api/claims/${payment.id}`, {
|
||||||
if (!res.ok) throw new Error("Failed to delete");
|
data: payment,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || "Failed to update Payment");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "Deleted", description: "Payment deleted successfully" });
|
setIsEditPaymentOpen(false);
|
||||||
setIsDeleteOpen(false);
|
toast({
|
||||||
queryClient.invalidateQueries({ queryKey: getQueryKey() });
|
title: "Success",
|
||||||
|
description: "Payment updated successfully!",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getPaymentsQueryKey(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
toast({ title: "Error", description: "Delete failed", variant: "destructive" });
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `Update failed: ${error.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSelectPayment = (payment: Payment) => {
|
const deletePaymentMutation = useMutation({
|
||||||
const isSelected = selectedPaymentId === payment.id;
|
mutationFn: async (id: number) => {
|
||||||
const newSelectedId = isSelected ? null : payment.id;
|
const res = await apiRequest("DELETE", `/api/payments/${id}`);
|
||||||
setSelectedPaymentId(newSelectedId);
|
return;
|
||||||
onSelectPayment?.(isSelected ? null : payment);
|
},
|
||||||
|
|
||||||
|
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 = () => {
|
const handleViewPayment = (payment: PaymentWithExtras) => {
|
||||||
if (currentPayment?.id) {
|
setCurrentPayment(payment);
|
||||||
deleteMutation.mutate(currentPayment.id);
|
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(() => {
|
useEffect(() => {
|
||||||
onPageChange?.(currentPage);
|
if (onPageChange) onPageChange(currentPage);
|
||||||
}, [currentPage]);
|
}, [currentPage, onPageChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [claimId]);
|
||||||
|
|
||||||
const totalPages = useMemo(
|
const totalPages = useMemo(
|
||||||
() => Math.ceil((paymentsData?.totalCount || 0) / paymentsPerPage),
|
() => 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 (
|
return (
|
||||||
<div className="bg-white rounded shadow">
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{allowCheckbox && <TableHead>Select</TableHead>}
|
{allowCheckbox && <TableHead>Select</TableHead>}
|
||||||
<TableHead>Payment ID</TableHead>
|
<TableHead>Payment ID</TableHead>
|
||||||
<TableHead>Payer</TableHead>
|
<TableHead>Patient Name</TableHead>
|
||||||
<TableHead>Amount</TableHead>
|
<TableHead>Amount</TableHead>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>Date</TableHead>
|
||||||
<TableHead>Method</TableHead>
|
<TableHead>Method</TableHead>
|
||||||
<TableHead>Note</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableRow>
|
<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>
|
</TableRow>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<TableRow>
|
<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>
|
</TableRow>
|
||||||
) : paymentsData?.payments.length === 0 ? (
|
) : paymentsData?.payments.length === 0 ? (
|
||||||
<TableRow>
|
<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>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
paymentsData?.payments.map((payment) => (
|
paymentsData?.payments.map((payment) => {
|
||||||
<TableRow key={payment.id}>
|
const claim = (payment as PaymentWithExtras)
|
||||||
{allowCheckbox && (
|
.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>
|
<TableCell>
|
||||||
<Checkbox
|
{typeof payment.id === "number"
|
||||||
checked={selectedPaymentId === payment.id}
|
? `PAY-${payment.id.toString().padStart(4, "0")}`
|
||||||
onCheckedChange={() => handleSelectPayment(payment)}
|
: "N/A"}
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
<TableCell>{payment.patientName}</TableCell>
|
||||||
<TableCell>{`PAY-${payment.id.toString().padStart(4, "0")}`}</TableCell>
|
{/* 💰 Billed / Paid / Due breakdown */}
|
||||||
<TableCell>{payment.payerName}</TableCell>
|
<TableCell>
|
||||||
<TableCell>${payment.amountPaid.toFixed(2)}</TableCell>
|
<div className="flex flex-col gap-1">
|
||||||
<TableCell>{formatDateToHumanReadable(payment.paymentDate)}</TableCell>
|
<span>
|
||||||
<TableCell>{payment.paymentMethod}</TableCell>
|
<strong>Total Billed:</strong> $
|
||||||
<TableCell>{payment.note || "—"}</TableCell>
|
{totalBilled.toFixed(2)}
|
||||||
<TableCell className="text-right space-x-2">
|
</span>
|
||||||
{allowDelete && (
|
<span>
|
||||||
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsDeleteOpen(true); }}>
|
<strong>Total Paid:</strong> ${totalPaid.toFixed(2)}
|
||||||
<Delete className="text-red-600" />
|
</span>
|
||||||
</Button>
|
<span>
|
||||||
)}
|
<strong>Total Due:</strong>{" "}
|
||||||
{allowEdit && (
|
{outstanding > 0 ? (
|
||||||
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsEditOpen(true); }}>
|
<span className="text-yellow-600">
|
||||||
<Edit />
|
${outstanding.toFixed(2)}
|
||||||
</Button>
|
</span>
|
||||||
)}
|
) : (
|
||||||
{allowView && (
|
<span className="text-green-600">Settled</span>
|
||||||
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsViewOpen(true); }}>
|
)}
|
||||||
<Eye />
|
</span>
|
||||||
</Button>
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
</TableRow>
|
{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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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 */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="px-4 py-2 border-t flex items-center justify-between">
|
<div className="bg-white px-4 py-3 border-t border-gray-200">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
Showing {(offset + 1)}–{Math.min(offset + paymentsPerPage, paymentsData?.totalCount || 0)} of {paymentsData?.totalCount || 0}
|
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
|
||||||
</div>
|
Showing {startItem}–{endItem} of {paymentsData?.totalCount || 0}{" "}
|
||||||
<Pagination>
|
results
|
||||||
<PaginationContent>
|
</div>
|
||||||
<PaginationItem>
|
<Pagination>
|
||||||
<PaginationPrevious
|
<PaginationContent>
|
||||||
href="#"
|
<PaginationItem>
|
||||||
onClick={(e) => {
|
<PaginationPrevious
|
||||||
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
|
|
||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCurrentPage(i + 1);
|
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||||
}}
|
}}
|
||||||
isActive={currentPage === i + 1}
|
className={
|
||||||
>
|
currentPage === 1 ? "pointer-events-none opacity-50" : ""
|
||||||
{i + 1}
|
}
|
||||||
</PaginationLink>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
|
||||||
<PaginationItem>
|
{getPageNumbers(currentPage, totalPages).map((page, idx) => (
|
||||||
<PaginationNext
|
<PaginationItem key={idx}>
|
||||||
href="#"
|
{page === "..." ? (
|
||||||
onClick={(e) => {
|
<span className="px-2 text-gray-500">...</span>
|
||||||
e.preventDefault();
|
) : (
|
||||||
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
|
<PaginationLink
|
||||||
}}
|
href="#"
|
||||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
|
onClick={(e) => {
|
||||||
/>
|
e.preventDefault();
|
||||||
</PaginationItem>
|
setCurrentPage(page as number);
|
||||||
</PaginationContent>
|
}}
|
||||||
</Pagination>
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user