payment checkpoint 2

This commit is contained in:
2025-07-31 23:11:59 +05:30
parent 23e4eb6b7c
commit 5810328711
9 changed files with 1292 additions and 200 deletions

View File

@@ -1,25 +1,26 @@
import { Router } from 'express';
import patientRoutes from './patients';
import appointmentRoutes from './appointments'
import userRoutes from './users'
import staffRoutes from './staffs'
import patientsRoutes from './patients';
import appointmentsRoutes from './appointments'
import usersRoutes from './users'
import staffsRoutes from './staffs'
import pdfExtractionRoutes from './pdfExtraction';
import claimsRoutes from './claims';
import insuranceCredsRoutes from './insuranceCreds';
import documentRoutes from './documents';
import documentsRoutes from './documents';
import insuranceEligibilityRoutes from './insuranceEligibility'
import paymentsRoutes from './payments'
const router = Router();
router.use('/patients', patientRoutes);
router.use('/appointments', appointmentRoutes);
router.use('/users', userRoutes);
router.use('/staffs', staffRoutes);
router.use('/patients', patientsRoutes);
router.use('/appointments', appointmentsRoutes);
router.use('/users', usersRoutes);
router.use('/staffs', staffsRoutes);
router.use('/pdfExtraction', pdfExtractionRoutes);
router.use('/claims', claimsRoutes);
router.use('/insuranceCreds', insuranceCredsRoutes);
router.use('/documents', documentRoutes);
router.use('/documents', documentsRoutes);
router.use('/insuranceEligibility', insuranceEligibilityRoutes);
router.use('/payments', paymentsRoutes);
export default router;

View File

@@ -2,11 +2,52 @@ import { Router } from "express";
import { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import { ClaimUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
import {
ClaimUncheckedCreateInputObjectSchema,
PaymentUncheckedCreateInputObjectSchema,
PaymentTransactionCreateInputObjectSchema,
ServiceLinePaymentCreateInputObjectSchema,
} from "@repo/db/usedSchemas";
import { Prisma } from "@repo/db/generated/prisma";
import { ZodError } from "zod";
const router = Router();
// Base Payment type
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
type PaymentTransaction = z.infer<
typeof PaymentTransactionCreateInputObjectSchema
>;
type ServiceLinePayment = z.infer<
typeof ServiceLinePaymentCreateInputObjectSchema
>;
// Define Zod schemas
const insertPaymentSchema = (
PaymentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
).omit({
id: true,
createdAt: true,
updatedAt: true,
});
type InsertPayment = z.infer<typeof insertPaymentSchema>;
const updatePaymentSchema = (
PaymentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
)
.omit({
id: true,
createdAt: true,
})
.partial();
type UpdatePayment = z.infer<typeof updatePaymentSchema>;
type PaymentWithExtras = Prisma.PaymentGetPayload<{
include: {
transactions: true;
servicePayments: true;
claim: true;
};
}>;
// Claim schema
const ClaimSchema = (
ClaimUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
).omit({
@@ -29,11 +70,42 @@ const updateClaimSchema = (
type UpdateClaim = z.infer<typeof updateClaimSchema>;
const paymentFilterSchema = z.object({
from: z.string().datetime(),
to: z.string().datetime(),
});
function parseIntOrError(input: string | undefined, name: string) {
if (!input) throw new Error(`${name} is required`);
const value = parseInt(input, 10);
if (isNaN(value)) throw new Error(`${name} must be a valid number`);
return value;
}
export function handleRouteError(
res: Response,
error: unknown,
defaultMsg: string
) {
if (error instanceof ZodError) {
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
const msg = error instanceof Error ? error.message : defaultMsg;
return res.status(500).json({ message: msg });
}
const router = Router();
// GET /api/payments/recent
router.get('/recent', async (req, res) => {
router.get("/recent", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const limit = parseInt(req.query.limit as string) || 10;
const offset = parseInt(req.query.offset as string) || 0;
@@ -42,102 +114,164 @@ router.get('/recent', async (req, res) => {
storage.getTotalPaymentCountByUser(userId),
]);
res.json({ payments, totalCount });
res.status(200).json({ payments, totalCount });
} catch (err) {
console.error('Failed to fetch payments:', err);
res.status(500).json({ message: 'Failed to fetch recent payments' });
console.error("Failed to fetch payments:", err);
res.status(500).json({ message: "Failed to fetch recent payments" });
}
});
// GET /api/payments/claim/:claimId
router.get('/claim/:claimId', async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user.id;
const claimId = parseInt(req.params.claimId);
router.get(
"/claim/:claimId",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const payment = await storage.getPaymentByClaimId(userId, claimId);
if (!payment) return res.status(404).json({ message: 'Payment not found' });
const parsedClaimId = parseIntOrError(req.params.claimId, "Claim ID");
res.json(payment);
} catch (err) {
console.error('Failed to fetch payment by claim:', err);
res.status(500).json({ message: 'Failed to fetch payment' });
const payments = await storage.getPaymentsByClaimId(
userId,
parsedClaimId
);
if (!payments)
return res.status(404).json({ message: "No payments found for claim" });
res.status(200).json(payments);
} catch (error) {
console.error("Error fetching payments:", error);
res.status(500).json({ message: "Failed to retrieve payments" });
}
}
});
);
// GET /api/payments/patient/:patientId
router.get('/patient/:patientId', async (req, res) => {
try {
const userId = req.user.id;
const patientId = parseInt(req.params.patientId);
router.get(
"/patient/:patientId",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const payments = await storage.getPaymentsByPatientId(userId, patientId);
res.json(payments);
} catch (err) {
console.error('Failed to fetch patient payments:', err);
res.status(500).json({ message: 'Failed to fetch patient payments' });
const parsedPatientId = parseIntOrError(
req.params.patientId,
"Patient ID"
);
const payments = await storage.getPaymentsByPatientId(
userId,
parsedPatientId
);
if (!payments)
return res.status(404).json({ message: "No payments found for claim" });
res.status(200).json(payments);
} catch (err) {
console.error("Failed to fetch patient payments:", err);
res.status(500).json({ message: "Failed to fetch patient payments" });
}
}
});
);
// GET /api/payments/filter
router.get('/filter', async (req, res) => {
router.get("/filter", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user.id;
const { from, to } = req.query;
const fromDate = new Date(from as string);
const toDate = new Date(to as string);
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const payments = await storage.getPaymentsByDateRange(userId, fromDate, toDate);
res.json(payments);
const validated = paymentFilterSchema.safeParse(req.query);
if (!validated.success) {
return res.status(400).json({
message: "Invalid date format",
errors: validated.error.errors,
});
}
const { from, to } = validated.data;
const payments = await storage.getPaymentsByDateRange(
userId,
new Date(from),
new Date(to)
);
res.status(200).json(payments);
} catch (err) {
console.error('Failed to filter payments:', err);
res.status(500).json({ message: 'Failed to filter payments' });
console.error("Failed to filter payments:", err);
res.status(500).json({ message: "Server error" });
}
});
// POST /api/payments/:claimId
router.post('/:claimId', body('totalBilled').isDecimal(),(req: Request, res: Response): Promise<any> => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user.id;
const claimId = parseInt(req.params.claimId);
const { totalBilled } = req.body;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const payment = await storage.createPayment({ userId, claimId, totalBilled });
const claimId = parseIntOrError(req.params.claimId, "Claim ID");
const validated = insertPaymentSchema.safeParse({
...req.body,
claimId,
userId,
});
if (!validated.success) {
return res.status(400).json({
message: "Validation failed",
errors: validated.error.flatten(),
});
}
const payment = await storage.createPayment(validated.data);
res.status(201).json(payment);
} catch (err) {
console.error('Failed to create payment:', err);
res.status(500).json({ message: 'Failed to create payment' });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to create payment";
res.status(500).json({ message });
}
});
// PUT /api/payments/:id
router.put('/:id', async (req, res) => {
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user.id;
const id = parseInt(req.params.id);
const updates = req.body;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const updated = await storage.updatePayment(userId, id, updates);
res.json(updated);
} catch (err) {
console.error('Failed to update payment:', err);
res.status(500).json({ message: 'Failed to update payment' });
const id = parseIntOrError(req.params.id, "Payment ID");
const validated = updatePaymentSchema.safeParse(req.body);
if (!validated.success) {
return res.status(400).json({
message: "Validation failed",
errors: validated.error.flatten(),
});
}
const updated = await storage.updatePayment(id, validated.data, userId);
res.status(200).json(updated);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to update payment";
res.status(500).json({ message });
}
});
// DELETE /api/payments/:id
router.delete('/:id', async (req, res) => {
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user.id;
const id = parseInt(req.params.id);
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const id = parseIntOrError(req.params.id, "Payment ID");
await storage.deletePayment(userId, id);
res.json({ message: 'Payment deleted' });
} catch (err) {
console.error('Failed to delete payment:', err);
res.status(500).json({ message: 'Failed to delete payment' });
res.status(200).json({ message: "Payment deleted successfully" });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to delete payment";
res.status(500).json({ message });
}
});

View File

@@ -16,7 +16,6 @@ import {
import { z } from "zod";
import { Prisma } from "@repo/db/generated/prisma";
//creating types out of schema auto generated.
type Appointment = z.infer<typeof AppointmentUncheckedCreateInputObjectSchema>;
@@ -167,9 +166,12 @@ export interface ClaimPdfMetadata {
// Base Payment type
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
type PaymentTransaction = z.infer<typeof PaymentTransactionCreateInputObjectSchema>;
type ServiceLinePayment = z.infer<typeof ServiceLinePaymentCreateInputObjectSchema>
type PaymentTransaction = z.infer<
typeof PaymentTransactionCreateInputObjectSchema
>;
type ServiceLinePayment = z.infer<
typeof ServiceLinePaymentCreateInputObjectSchema
>;
const insertPaymentSchema = (
PaymentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
@@ -180,7 +182,6 @@ const insertPaymentSchema = (
});
type InsertPayment = z.infer<typeof insertPaymentSchema>;
const updatePaymentSchema = (
PaymentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
)
@@ -191,7 +192,6 @@ const updatePaymentSchema = (
.partial();
type UpdatePayment = z.infer<typeof updatePaymentSchema>;
type PaymentWithExtras = Prisma.PaymentGetPayload<{
include: {
transactions: true;
@@ -350,15 +350,33 @@ export interface IStorage {
): Promise<PdfGroup | undefined>;
deletePdfGroup(id: number): Promise<boolean>;
// Payment methods:
// Payment methods:
createPayment(data: InsertPayment): Promise<Payment>;
updatePayment(id: number, updates: UpdatePayment): Promise<Payment>;
deletePayment(id: number): Promise<void>;
getPaymentById(id: number): Promise<PaymentWithExtras | null>;
getPaymentByClaimId(claimId: number): Promise<PaymentWithExtras | null>;
getPaymentsByPatientId(patientId: number, userId: number): Promise<PaymentWithExtras[]>;
getRecentPaymentsByUser(userId: number, limit: number, offset: number): Promise<PaymentWithExtras[]>;
getPaymentsByDateRange(userId: number, from: Date, to: Date): Promise<PaymentWithExtras[]>;
updatePayment(
id: number,
updates: UpdatePayment,
userId: number
): Promise<Payment>;
deletePayment(id: number, userId: number): Promise<void>;
getPaymentById(id: number, userId: number): Promise<PaymentWithExtras | null>;
getPaymentsByClaimId(
claimId: number,
userId: number
): Promise<PaymentWithExtras | null>;
getPaymentsByPatientId(
patientId: number,
userId: number
): Promise<PaymentWithExtras[]>;
getRecentPaymentsByUser(
userId: number,
limit: number,
offset: number
): Promise<PaymentWithExtras[]>;
getPaymentsByDateRange(
userId: number,
from: Date,
to: Date
): Promise<PaymentWithExtras[]>;
getTotalPaymentCountByUser(userId: number): Promise<number>;
}
@@ -557,18 +575,20 @@ export const storage: IStorage = {
}
},
async getPatientAppointmentByDateTime(
async getPatientAppointmentByDateTime(
patientId: number,
date: Date,
startTime: string
): Promise<Appointment | undefined> {
return await db.appointment.findFirst({
where: {
patientId,
date,
startTime,
},
}) ?? undefined;
return (
(await db.appointment.findFirst({
where: {
patientId,
date,
startTime,
},
})) ?? undefined
);
},
async getStaffAppointmentByDateTime(
@@ -577,14 +597,16 @@ export const storage: IStorage = {
startTime: string,
excludeId?: number
): Promise<Appointment | undefined> {
return await db.appointment.findFirst({
where: {
staffId,
date,
startTime,
NOT: excludeId ? { id: excludeId } : undefined,
},
}) ?? undefined;
return (
(await db.appointment.findFirst({
where: {
staffId,
date,
startTime,
NOT: excludeId ? { id: excludeId } : undefined,
},
})) ?? undefined
);
},
async getPatientConflictAppointment(
@@ -593,14 +615,16 @@ export const storage: IStorage = {
startTime: string,
excludeId: number
): Promise<Appointment | undefined> {
return await db.appointment.findFirst({
where: {
patientId,
date,
startTime,
NOT: { id: excludeId },
},
}) ?? undefined;
return (
(await db.appointment.findFirst({
where: {
patientId,
date,
startTime,
NOT: { id: excludeId },
},
})) ?? undefined
);
},
async getStaffConflictAppointment(
@@ -609,17 +633,18 @@ export const storage: IStorage = {
startTime: string,
excludeId: number
): Promise<Appointment | undefined> {
return await db.appointment.findFirst({
where: {
staffId,
date,
startTime,
NOT: { id: excludeId },
},
}) ?? undefined;
return (
(await db.appointment.findFirst({
where: {
staffId,
date,
startTime,
NOT: { id: excludeId },
},
})) ?? undefined
);
},
// Staff methods
async getStaff(id: number): Promise<Staff | undefined> {
const staff = await db.staff.findUnique({ where: { id } });
@@ -884,33 +909,41 @@ export const storage: IStorage = {
},
// Payment Methods
async createPayment(payment: InsertPayment): Promise<Payment> {
return db.payment.create({ data: payment as Payment });
},
async updatePayment(id: number, updates: UpdatePayment): Promise<Payment> {
return db.payment.update({ where: { id }, data: updates });
return db.payment.create({ data: payment as Payment });
},
async deletePayment(id: number): Promise<void> {
async updatePayment(
id: number,
updates: UpdatePayment,
userId: number
): Promise<Payment> {
const existing = await db.payment.findFirst({ where: { id, userId } });
if (!existing) {
throw new Error("Not authorized or payment not found");
}
return db.payment.update({
where: { id },
data: updates,
});
},
async deletePayment(id: number, userId: number): Promise<void> {
const existing = await db.payment.findFirst({ where: { id, userId } });
if (!existing) {
throw new Error("Not authorized or payment not found");
}
await db.payment.delete({ where: { id } });
},
async getPaymentById(id: number): Promise<PaymentWithExtras | null> {
return db.payment.findUnique({
where: { id },
include: {
claim: true,
transactions: true,
servicePayments: true,
},
});
},
async getPaymentByClaimId(claimId: number): Promise<PaymentWithExtras | null> {
async getPaymentById(
id: number,
userId: number
): Promise<PaymentWithExtras | null> {
return db.payment.findFirst({
where: { claimId },
where: { id, userId },
include: {
claim: true,
transactions: true,
@@ -919,8 +952,24 @@ export const storage: IStorage = {
});
},
async getPaymentsByClaimId(
claimId: number,
userId: number
): Promise<PaymentWithExtras | null> {
return db.payment.findFirst({
where: { claimId, userId },
include: {
claim: true,
transactions: true,
servicePayments: true,
},
});
},
async getPaymentsByPatientId(patientId: number, userId: number): Promise<PaymentWithExtras[]> {
async getPaymentsByPatientId(
patientId: number,
userId: number
): Promise<PaymentWithExtras[]> {
return db.payment.findMany({
where: {
patientId,
@@ -934,7 +983,11 @@ export const storage: IStorage = {
});
},
async getRecentPaymentsByUser(userId: number, limit: number, offset: number): Promise<PaymentWithExtras[]> {
async getRecentPaymentsByUser(
userId: number,
limit: number,
offset: number
): Promise<PaymentWithExtras[]> {
return db.payment.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
@@ -948,7 +1001,11 @@ export const storage: IStorage = {
});
},
async getPaymentsByDateRange(userId: number, from: Date, to: Date): Promise<PaymentWithExtras[]> {
async getPaymentsByDateRange(
userId: number,
from: Date,
to: Date
): Promise<PaymentWithExtras[]> {
return db.payment.findMany({
where: {
userId,

View File

@@ -0,0 +1,141 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
import { format } from "date-fns";
import { PaymentUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
interface PaymentEditModalProps {
isOpen: boolean;
onClose: () => void;
payment: Payment;
onSave: () => void;
}
export default function PaymentEditModal({
isOpen,
onClose,
payment,
onSave,
}: PaymentEditModalProps) {
const { toast } = useToast();
const [form, setForm] = useState({
payerName: payment.payerName,
amountPaid: payment.amountPaid.toString(),
paymentDate: format(new Date(payment.paymentDate), "yyyy-MM-dd"),
paymentMethod: payment.paymentMethod,
note: payment.note || "",
});
const [loading, setLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setForm({ ...form, [name]: value });
};
const handleSubmit = async () => {
setLoading(true);
try {
const res = await apiRequest("PUT", `/api/payments/${payment.id}`, {
...form,
amountPaid: parseFloat(form.amountPaid),
paymentDate: new Date(form.paymentDate),
});
if (!res.ok) throw new Error("Failed to update payment");
toast({ title: "Success", description: "Payment updated successfully" });
queryClient.invalidateQueries();
onClose();
onSave();
} catch (error) {
toast({
title: "Error",
description: "Failed to update payment",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Payment</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="payerName">Payer Name</Label>
<Input
id="payerName"
name="payerName"
value={form.payerName}
onChange={handleChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="amountPaid">Amount Paid</Label>
<Input
id="amountPaid"
name="amountPaid"
type="number"
step="0.01"
value={form.amountPaid}
onChange={handleChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="paymentDate">Payment Date</Label>
<Input
id="paymentDate"
name="paymentDate"
type="date"
value={form.paymentDate}
onChange={handleChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="paymentMethod">Payment Method</Label>
<Input
id="paymentMethod"
name="paymentMethod"
value={form.paymentMethod}
onChange={handleChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="note">Note</Label>
<Textarea
id="note"
name="note"
value={form.note}
onChange={handleChange}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,35 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { z } from "zod";
import { PaymentUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
interface PaymentViewModalProps {
isOpen: boolean;
onClose: () => void;
payment: Payment;
}
export default function PaymentViewModal({ isOpen, onClose, payment }: PaymentViewModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Payment Details</DialogTitle>
</DialogHeader>
<div className="space-y-4 text-sm">
<div><strong>Payment ID:</strong> PAY-{payment.id.toString().padStart(4, "0")}</div>
<div><strong>Claim ID:</strong> {payment.claimId}</div>
<div><strong>Payer Name:</strong> {payment.payerName}</div>
<div><strong>Amount Paid:</strong> ${payment.amountPaid.toFixed(2)}</div>
<div><strong>Payment Date:</strong> {formatDateToHumanReadable(payment.paymentDate)}</div>
<div><strong>Payment Method:</strong> {payment.paymentMethod}</div>
<div><strong>Note:</strong> {payment.note || "—"}</div>
<div><strong>Created At:</strong> {formatDateToHumanReadable(payment.createdAt)}</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,295 @@
import { useState, useEffect, useMemo } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Edit, Eye, Delete } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Checkbox } from "@/components/ui/checkbox";
import { PaymentUncheckedCreateInputObjectSchema, PaymentTransactionCreateInputObjectSchema, ServiceLinePaymentCreateInputObjectSchema } 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";
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
type PaymentTransaction = z.infer<
typeof PaymentTransactionCreateInputObjectSchema
>;
type ServiceLinePayment = z.infer<
typeof ServiceLinePaymentCreateInputObjectSchema
>;
const insertPaymentSchema = (
PaymentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
).omit({
id: true,
createdAt: true,
updatedAt: true,
});
type InsertPayment = z.infer<typeof insertPaymentSchema>;
const updatePaymentSchema = (
PaymentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
)
.omit({
id: true,
createdAt: true,
})
.partial();
type UpdatePayment = z.infer<typeof updatePaymentSchema>;
type PaymentWithExtras = Prisma.PaymentGetPayload<{
include: {
transactions: true;
servicePayments: true;
claim: true;
};
}>;
interface PaymentApiResponse {
payments: Payment[];
totalCount: number;
}
interface PaymentsRecentTableProps {
allowEdit?: boolean;
allowView?: boolean;
allowDelete?: boolean;
allowCheckbox?: boolean;
onSelectPayment?: (payment: Payment | null) => void;
onPageChange?: (page: number) => void;
claimId?: number;
}
export default function PaymentsRecentTable({
allowEdit,
allowView,
allowDelete,
allowCheckbox,
onSelectPayment,
onPageChange,
claimId,
}: PaymentsRecentTableProps) {
const { toast } = useToast();
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 getQueryKey = () =>
claimId
? ["payments", "claim", claimId, currentPage]
: ["payments", "recent", currentPage];
const {
data: paymentsData,
isLoading,
isError,
} = useQuery<PaymentApiResponse>({
queryKey: getQueryKey(),
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");
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");
},
onSuccess: () => {
toast({ title: "Deleted", description: "Payment deleted successfully" });
setIsDeleteOpen(false);
queryClient.invalidateQueries({ queryKey: getQueryKey() });
},
onError: () => {
toast({ title: "Error", description: "Delete failed", variant: "destructive" });
},
});
const handleSelectPayment = (payment: Payment) => {
const isSelected = selectedPaymentId === payment.id;
const newSelectedId = isSelected ? null : payment.id;
setSelectedPaymentId(newSelectedId);
onSelectPayment?.(isSelected ? null : payment);
};
const handleDelete = () => {
if (currentPayment?.id) {
deleteMutation.mutate(currentPayment.id);
}
};
useEffect(() => {
onPageChange?.(currentPage);
}, [currentPage]);
const totalPages = useMemo(
() => Math.ceil((paymentsData?.totalCount || 0) / paymentsPerPage),
[paymentsData]
);
return (
<div className="bg-white rounded shadow">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{allowCheckbox && <TableHead>Select</TableHead>}
<TableHead>Payment ID</TableHead>
<TableHead>Payer</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>
</TableRow>
) : isError ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-red-500 py-6">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>
</TableRow>
) : (
paymentsData?.payments.map((payment) => (
<TableRow key={payment.id}>
{allowCheckbox && (
<TableCell>
<Checkbox
checked={selectedPaymentId === payment.id}
onCheckedChange={() => handleSelectPayment(payment)}
/>
</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>
))
)}
</TableBody>
</Table>
</div>
{/* 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
href="#"
onClick={(e) => {
e.preventDefault();
setCurrentPage(i + 1);
}}
isActive={currentPage === i + 1}
>
{i + 1}
</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>
)}
{/* 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>
);
}