recent claim table, checkpoint
This commit is contained in:
@@ -90,9 +90,10 @@ router.post(
|
|||||||
claimData.insuranceSiteKey
|
claimData.insuranceSiteKey
|
||||||
);
|
);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
return res
|
return res.status(404).json({
|
||||||
.status(404)
|
error:
|
||||||
.json({ error: "No insurance credentials found for this provider. Kindly Update this at Settings Page." });
|
"No insurance credentials found for this provider. Kindly Update this at Settings Page.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const enrichedData = {
|
const enrichedData = {
|
||||||
@@ -183,35 +184,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /api/claims?page=1&limit=5
|
|
||||||
router.get("/", async (req: Request, res: Response) => {
|
|
||||||
const userId = req.user!.id;
|
|
||||||
const offset = parseInt(req.query.offset as string) || 0;
|
|
||||||
const limit = parseInt(req.query.limit as string) || 5;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [claims, total] = await Promise.all([
|
|
||||||
storage.getClaimsPaginated(userId, offset, limit),
|
|
||||||
storage.countClaimsByUserId(userId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
data: claims,
|
|
||||||
page: Math.floor(offset / limit) + 1,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ message: "Failed to retrieve paginated claims" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/claims/recent
|
// GET /api/claims/recent
|
||||||
router.get("/recent", async (req: Request, res: Response) => {
|
router.get("/recent", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const claims = await storage.getClaimsMetadataByUser(req.user!.id);
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
res.json(claims); // Just ID and createdAt
|
const offset = parseInt(req.query.offset as string) || 0;
|
||||||
|
|
||||||
|
const [claims, totalCount] = await Promise.all([
|
||||||
|
storage.getRecentClaimsByUser(req.user!.id, limit, offset),
|
||||||
|
storage.getTotalClaimCountByUser(req.user!.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ claims, totalCount });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve recent claims:", error);
|
||||||
res.status(500).json({ message: "Failed to retrieve recent claims" });
|
res.status(500).json({ message: "Failed to retrieve recent claims" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -219,7 +205,7 @@ router.get("/recent", async (req: Request, res: Response) => {
|
|||||||
// Get all claims for the logged-in user
|
// Get all claims for the logged-in user
|
||||||
router.get("/all", async (req: Request, res: Response) => {
|
router.get("/all", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const claims = await storage.getClaimsByUserId(req.user!.id);
|
const claims = await storage.getTotalClaimCountByUser(req.user!.id);
|
||||||
res.json(claims);
|
res.json(claims);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: "Failed to retrieve claims" });
|
res.status(500).json({ message: "Failed to retrieve claims" });
|
||||||
|
|||||||
@@ -218,18 +218,14 @@ export interface IStorage {
|
|||||||
|
|
||||||
// Claim methods
|
// Claim methods
|
||||||
getClaim(id: number): Promise<Claim | undefined>;
|
getClaim(id: number): Promise<Claim | undefined>;
|
||||||
getClaimsByUserId(userId: number): Promise<Claim[]>;
|
|
||||||
getClaimsByPatientId(patientId: number): Promise<Claim[]>;
|
getClaimsByPatientId(patientId: number): Promise<Claim[]>;
|
||||||
getClaimsByAppointmentId(appointmentId: number): Promise<Claim[]>;
|
getClaimsByAppointmentId(appointmentId: number): Promise<Claim[]>;
|
||||||
getClaimsPaginated(
|
getRecentClaimsByUser(
|
||||||
userId: number,
|
userId: number,
|
||||||
offset: number,
|
limit: number,
|
||||||
limit: number
|
offset: number
|
||||||
): Promise<Claim[]>;
|
): Promise<Claim[]>;
|
||||||
countClaimsByUserId(userId: number): Promise<number>;
|
getTotalClaimCountByUser(userId: number): Promise<number>;
|
||||||
getClaimsMetadataByUser(
|
|
||||||
userId: number
|
|
||||||
): Promise<{ id: number; createdAt: Date; status: string }[]>;
|
|
||||||
createClaim(claim: InsertClaim): Promise<Claim>;
|
createClaim(claim: InsertClaim): Promise<Claim>;
|
||||||
updateClaim(id: number, updates: UpdateClaim): Promise<Claim>;
|
updateClaim(id: number, updates: UpdateClaim): Promise<Claim>;
|
||||||
deleteClaim(id: number): Promise<void>;
|
deleteClaim(id: number): Promise<void>;
|
||||||
@@ -524,10 +520,6 @@ export const storage: IStorage = {
|
|||||||
return claim ?? undefined;
|
return claim ?? undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getClaimsByUserId(userId: number): Promise<Claim[]> {
|
|
||||||
return await db.claim.findMany({ where: { userId } });
|
|
||||||
},
|
|
||||||
|
|
||||||
async getClaimsByPatientId(patientId: number): Promise<Claim[]> {
|
async getClaimsByPatientId(patientId: number): Promise<Claim[]> {
|
||||||
return await db.claim.findMany({ where: { patientId } });
|
return await db.claim.findMany({ where: { patientId } });
|
||||||
},
|
},
|
||||||
@@ -536,6 +528,24 @@ export const storage: IStorage = {
|
|||||||
return await db.claim.findMany({ where: { appointmentId } });
|
return await db.claim.findMany({ where: { appointmentId } });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getRecentClaimsByUser(
|
||||||
|
userId: number,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): Promise<ClaimWithServiceLines[]> {
|
||||||
|
return db.claim.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
include: { serviceLines: true },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTotalClaimCountByUser(userId: number): Promise<number> {
|
||||||
|
return db.claim.count({ where: { userId } });
|
||||||
|
},
|
||||||
|
|
||||||
async createClaim(claim: InsertClaim): Promise<Claim> {
|
async createClaim(claim: InsertClaim): Promise<Claim> {
|
||||||
return await db.claim.create({ data: claim as Claim });
|
return await db.claim.create({ data: claim as Claim });
|
||||||
},
|
},
|
||||||
@@ -559,38 +569,6 @@ export const storage: IStorage = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getClaimsPaginated(
|
|
||||||
userId: number,
|
|
||||||
offset: number,
|
|
||||||
limit: number
|
|
||||||
): Promise<ClaimWithServiceLines[]> {
|
|
||||||
return db.claim.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
skip: offset,
|
|
||||||
take: limit,
|
|
||||||
include: { serviceLines: true },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async countClaimsByUserId(userId: number): Promise<number> {
|
|
||||||
return db.claim.count({ where: { userId } });
|
|
||||||
},
|
|
||||||
|
|
||||||
async getClaimsMetadataByUser(
|
|
||||||
userId: number
|
|
||||||
): Promise<{ id: number; createdAt: Date; status: string }[]> {
|
|
||||||
return db.claim.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
createdAt: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Insurance Creds
|
// Insurance Creds
|
||||||
async getInsuranceCredentialsByUser(userId: number) {
|
async getInsuranceCredentialsByUser(userId: number) {
|
||||||
return await db.insuranceCredential.findMany({ where: { userId } });
|
return await db.insuranceCredential.findMany({ where: { userId } });
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import procedureCodes from "../../assets/data/procedureCodes.json";
|
import procedureCodes from "../../assets/data/procedureCodes.json";
|
||||||
|
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||||
|
|
||||||
const PatientSchema = (
|
const PatientSchema = (
|
||||||
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
@@ -79,7 +80,6 @@ type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
|
|||||||
|
|
||||||
type Claim = z.infer<typeof ClaimUncheckedCreateInputObjectSchema>;
|
type Claim = z.infer<typeof ClaimUncheckedCreateInputObjectSchema>;
|
||||||
|
|
||||||
|
|
||||||
interface ServiceLine {
|
interface ServiceLine {
|
||||||
procedureCode: string;
|
procedureCode: string;
|
||||||
procedureDate: string; // YYYY-MM-DD
|
procedureDate: string; // YYYY-MM-DD
|
||||||
@@ -185,33 +185,15 @@ export function ClaimForm({
|
|||||||
}, [staffMembersRaw, staff]);
|
}, [staffMembersRaw, staff]);
|
||||||
|
|
||||||
// Service date state
|
// Service date state
|
||||||
function parseLocalDate(dateInput: Date | string): Date {
|
|
||||||
if (dateInput instanceof Date) return dateInput;
|
|
||||||
|
|
||||||
const parts = dateInput.split("-");
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
throw new Error(`Invalid date format: ${dateInput}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = Number(parts[0]);
|
|
||||||
const month = Number(parts[1]);
|
|
||||||
const day = Number(parts[2]);
|
|
||||||
|
|
||||||
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
|
|
||||||
throw new Error(`Invalid date parts in date string: ${dateInput}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(year, month - 1, day); // month is 0-indexed
|
|
||||||
}
|
|
||||||
|
|
||||||
const [serviceDateValue, setServiceDateValue] = useState<Date>(new Date());
|
const [serviceDateValue, setServiceDateValue] = useState<Date>(new Date());
|
||||||
const [serviceDate, setServiceDate] = useState<string>(
|
const [serviceDate, setServiceDate] = useState<string>(
|
||||||
new Date().toLocaleDateString("en-CA") // "YYYY-MM-DD"
|
formatLocalDate(new Date())
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (extractedData?.serviceDate) {
|
if (extractedData?.serviceDate) {
|
||||||
const parsed = parseLocalDate(extractedData.serviceDate);
|
const parsed = parseLocalDate(extractedData.serviceDate);
|
||||||
const isoFormatted = parsed.toLocaleDateString("en-CA");
|
const isoFormatted = formatLocalDate(parsed);
|
||||||
setServiceDateValue(parsed);
|
setServiceDateValue(parsed);
|
||||||
setServiceDate(isoFormatted);
|
setServiceDate(isoFormatted);
|
||||||
}
|
}
|
||||||
@@ -220,7 +202,7 @@ export function ClaimForm({
|
|||||||
// Update service date when calendar date changes
|
// Update service date when calendar date changes
|
||||||
const onServiceDateChange = (date: Date | undefined) => {
|
const onServiceDateChange = (date: Date | undefined) => {
|
||||||
if (date) {
|
if (date) {
|
||||||
const formattedDate = date.toLocaleDateString("en-CA"); // "YYYY-MM-DD"
|
const formattedDate = formatLocalDate(date);
|
||||||
setServiceDateValue(date);
|
setServiceDateValue(date);
|
||||||
setServiceDate(formattedDate);
|
setServiceDate(formattedDate);
|
||||||
setForm((prev) => ({ ...prev, serviceDate: formattedDate }));
|
setForm((prev) => ({ ...prev, serviceDate: formattedDate }));
|
||||||
@@ -253,7 +235,7 @@ export function ClaimForm({
|
|||||||
serviceDate: serviceDate,
|
serviceDate: serviceDate,
|
||||||
insuranceProvider: "",
|
insuranceProvider: "",
|
||||||
insuranceSiteKey: "",
|
insuranceSiteKey: "",
|
||||||
status: "pending",
|
status: "PENDING",
|
||||||
serviceLines: Array.from({ length: 10 }, () => ({
|
serviceLines: Array.from({ length: 10 }, () => ({
|
||||||
procedureCode: "",
|
procedureCode: "",
|
||||||
procedureDate: serviceDate,
|
procedureDate: serviceDate,
|
||||||
@@ -321,8 +303,7 @@ export function ClaimForm({
|
|||||||
|
|
||||||
const updateProcedureDate = (index: number, date: Date | undefined) => {
|
const updateProcedureDate = (index: number, date: Date | undefined) => {
|
||||||
if (!date) return;
|
if (!date) return;
|
||||||
|
const formattedDate = formatLocalDate(date);
|
||||||
const formattedDate = date.toLocaleDateString("en-CA");
|
|
||||||
const updatedLines = [...form.serviceLines];
|
const updatedLines = [...form.serviceLines];
|
||||||
|
|
||||||
if (updatedLines[index]) {
|
if (updatedLines[index]) {
|
||||||
@@ -417,13 +398,13 @@ export function ClaimForm({
|
|||||||
|
|
||||||
const { uploadedFiles, insuranceSiteKey, ...formToCreateClaim } = form;
|
const { uploadedFiles, insuranceSiteKey, ...formToCreateClaim } = form;
|
||||||
const createdClaim = await onSubmit({
|
const createdClaim = await onSubmit({
|
||||||
...formToCreateClaim,
|
...formToCreateClaim,
|
||||||
serviceLines: filteredServiceLines,
|
serviceLines: filteredServiceLines,
|
||||||
staffId: Number(staff?.id),
|
staffId: Number(staff?.id),
|
||||||
patientId: patientId,
|
patientId: patientId,
|
||||||
insuranceProvider: "MassHealth",
|
insuranceProvider: "MassHealth",
|
||||||
appointmentId: appointmentId!,
|
appointmentId: appointmentId!,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. sending form data to selenium service
|
// 4. sending form data to selenium service
|
||||||
onHandleForSelenium({
|
onHandleForSelenium({
|
||||||
|
|||||||
125
apps/Frontend/src/components/claims/claims-for-patient-table.tsx
Normal file
125
apps/Frontend/src/components/claims/claims-for-patient-table.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// components/claims/ClaimsForPatientTable.tsx
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } 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, Trash2 } from "lucide-react";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Pagination, PaginationContent, PaginationItem, PaginationLink } from "@/components/ui/pagination";
|
||||||
|
import { DeleteConfirmationDialog } from "@/components/modals/DeleteConfirmationDialog";
|
||||||
|
import ClaimViewModal from "@/components/modals/ClaimViewModal";
|
||||||
|
|
||||||
|
interface Claim {
|
||||||
|
id: number;
|
||||||
|
patientId: number;
|
||||||
|
patientName: string;
|
||||||
|
serviceDate: string;
|
||||||
|
insuranceProvider: string;
|
||||||
|
status: string;
|
||||||
|
remarks: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
patientId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaimsForPatientTable({ patientId }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [viewClaim, setViewClaim] = useState<Claim | null>(null);
|
||||||
|
const [deleteClaim, setDeleteClaim] = useState<Claim | null>(null);
|
||||||
|
|
||||||
|
const limit = 5;
|
||||||
|
const offset = (currentPage - 1) * limit;
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["claims-by-patient", patientId, currentPage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest("GET", `/api/claims/by-patient/${patientId}?limit=${limit}&offset=${offset}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to load claims for patient");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!patientId,
|
||||||
|
placeholderData: { data: [], total: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const res = await apiRequest("DELETE", `/api/claims/${id}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to delete claim");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Deleted", description: "Claim removed." });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["claims-by-patient"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (deleteClaim) deleteMutation.mutate(deleteClaim.id);
|
||||||
|
setDeleteClaim(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = useMemo(() => Math.ceil((data?.total || 0) / limit), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded shadow p-4 mt-4">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Claims for Selected Patient</h2>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Claim ID</TableHead>
|
||||||
|
<TableHead>Service Date</TableHead>
|
||||||
|
<TableHead>Insurance</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data?.map((claim: Claim) => (
|
||||||
|
<TableRow key={claim.id}>
|
||||||
|
<TableCell>CLM-{claim.id.toString().padStart(4, "0")}</TableCell>
|
||||||
|
<TableCell>{format(new Date(claim.serviceDate), "MMM dd, yyyy")}</TableCell>
|
||||||
|
<TableCell>{claim.insuranceProvider}</TableCell>
|
||||||
|
<TableCell>{claim.status}</TableCell>
|
||||||
|
<TableCell className="text-right space-x-2">
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setViewClaim(claim)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => {/* handle edit */}}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setDeleteClaim(claim)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
{Array.from({ length: totalPages }).map((_, i) => (
|
||||||
|
<PaginationItem key={i}>
|
||||||
|
<PaginationLink isActive={i + 1 === currentPage} onClick={() => setCurrentPage(i + 1)}>
|
||||||
|
{i + 1}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
|
||||||
|
<ClaimViewModal claim={viewClaim} onClose={() => setViewClaim(null)} />
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
isOpen={!!deleteClaim}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteClaim(null)}
|
||||||
|
entityName="claim"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// components/patients/PatientSearchTable.tsx
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface Patient {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
gender: string;
|
||||||
|
dob: string;
|
||||||
|
memberId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelectPatient: (patient: Patient) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PatientSearchTable({ onSelectPatient }: Props) {
|
||||||
|
const [term, setTerm] = useState("");
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["patients", term],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest("GET", `/api/patients/search?term=${term}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to load patients");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!term,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (term.length > 0) setVisible(true);
|
||||||
|
}, [term]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input placeholder="Search patients..." value={term} onChange={(e) => setTerm(e.target.value)} />
|
||||||
|
|
||||||
|
{visible && data?.length > 0 && (
|
||||||
|
<div className="border rounded overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Gender</TableHead>
|
||||||
|
<TableHead>DOB</TableHead>
|
||||||
|
<TableHead>Member ID</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((patient: Patient) => (
|
||||||
|
<TableRow key={patient.id} onClick={() => onSelectPatient(patient)} className="cursor-pointer hover:bg-muted">
|
||||||
|
<TableCell>{patient.name}</TableCell>
|
||||||
|
<TableCell>{patient.gender}</TableCell>
|
||||||
|
<TableCell>{patient.dob}</TableCell>
|
||||||
|
<TableCell>{patient.memberId}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
529
apps/Frontend/src/components/claims/claims-recent-table.tsx
Normal file
529
apps/Frontend/src/components/claims/claims-recent-table.tsx
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import { useEffect, useState, 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 {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Delete,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||||
|
import {
|
||||||
|
PatientUncheckedCreateInputObjectSchema,
|
||||||
|
ClaimUncheckedCreateInputObjectSchema,
|
||||||
|
ClaimStatusSchema,
|
||||||
|
} 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";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||||
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
//creating types out of schema auto generated.
|
||||||
|
type Claim = z.infer<typeof ClaimUncheckedCreateInputObjectSchema>;
|
||||||
|
export type ClaimStatus = z.infer<typeof ClaimStatusSchema>;
|
||||||
|
|
||||||
|
type ClaimWithServiceLines = Claim & {
|
||||||
|
serviceLines: {
|
||||||
|
id: number;
|
||||||
|
claimId: number;
|
||||||
|
procedureCode: string;
|
||||||
|
procedureDate: Date;
|
||||||
|
oralCavityArea: string | null;
|
||||||
|
toothNumber: string | null;
|
||||||
|
toothSurface: string | null;
|
||||||
|
billedAmount: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PatientSchema = (
|
||||||
|
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
).omit({
|
||||||
|
appointments: true,
|
||||||
|
});
|
||||||
|
type Patient = z.infer<typeof PatientSchema>;
|
||||||
|
|
||||||
|
interface ClaimApiResponse {
|
||||||
|
claims: ClaimWithServiceLines[];
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaimsRecentTableProps {
|
||||||
|
allowEdit?: boolean;
|
||||||
|
allowView?: boolean;
|
||||||
|
allowDelete?: boolean;
|
||||||
|
allowCheckbox?: boolean;
|
||||||
|
onSelectClaim?: (claim: Claim | null) => void;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaimsRecentTable({
|
||||||
|
allowEdit,
|
||||||
|
allowView,
|
||||||
|
allowDelete,
|
||||||
|
allowCheckbox,
|
||||||
|
onSelectClaim,
|
||||||
|
onPageChange,
|
||||||
|
}: ClaimsRecentTableProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [isViewClaimOpen, setIsViewClaimOpen] = useState(false);
|
||||||
|
const [isEditClaimOpen, setIsEditClaimOpen] = useState(false);
|
||||||
|
const [isDeleteClaimOpen, setIsDeleteClaimOpen] = useState(false);
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const claimsPerPage = 5;
|
||||||
|
const offset = (currentPage - 1) * claimsPerPage;
|
||||||
|
|
||||||
|
const [currentClaim, setCurrentClaim] = useState<Claim | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [selectedClaimId, setSelectedClaimId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleSelectClaim = (claim: Claim) => {
|
||||||
|
const isSelected = selectedClaimId === claim.id;
|
||||||
|
const newSelectedId = isSelected ? null : claim.id;
|
||||||
|
setSelectedClaimId(Number(newSelectedId));
|
||||||
|
|
||||||
|
if (onSelectClaim) {
|
||||||
|
onSelectClaim(isSelected ? null : claim);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: claimsData,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery<ClaimApiResponse, Error>({
|
||||||
|
queryKey: [
|
||||||
|
"claims-recent",
|
||||||
|
{
|
||||||
|
page: currentPage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/claims/recent?limit=${claimsPerPage}&offset=${offset}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
throw new Error(errorData.message || "Search failed");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
placeholderData: { claims: [], totalCount: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteClaimMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await apiRequest("DELETE", `/api/claims/${id}`);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsDeleteClaimOpen(false);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [
|
||||||
|
"claims-recent",
|
||||||
|
{
|
||||||
|
page: currentPage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Claim deleted successfully!",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.log(error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to delete claim: ${error.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEditClaim = (claim: Claim) => {
|
||||||
|
setCurrentClaim(claim);
|
||||||
|
setIsEditClaimOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewClaim = (claim: Claim) => {
|
||||||
|
setCurrentClaim(claim);
|
||||||
|
setIsViewClaimOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClaim = (claim: Claim) => {
|
||||||
|
setCurrentClaim(claim);
|
||||||
|
setIsDeleteClaimOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDeleteClaim = async () => {
|
||||||
|
if (currentClaim) {
|
||||||
|
if (typeof currentClaim.id === "number") {
|
||||||
|
deleteClaimMutation.mutate(currentClaim.id);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Selected claim is missing an ID for deletion.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "No patient selected for deletion.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onPageChange) onPageChange(currentPage);
|
||||||
|
}, [currentPage, onPageChange]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(
|
||||||
|
() => Math.ceil((claimsData?.totalCount || 0) / claimsPerPage),
|
||||||
|
[claimsData]
|
||||||
|
);
|
||||||
|
const startItem = offset + 1;
|
||||||
|
const endItem = Math.min(offset + claimsPerPage, claimsData?.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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardHeader className="pb-8">
|
||||||
|
<CardTitle>Recently Submitted Claims</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<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>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{formatDateToHumanReadable(claim.createdAt!)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{claim.insuranceProvider ?? "Not specified"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{claim.memberId ?? "Not specified"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
${getTotalBilled(claim).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(() => {
|
||||||
|
const { label, color, icon } = getStatusInfo(
|
||||||
|
claim.status
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
{allowDelete && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteClaim(claim);
|
||||||
|
}}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
aria-label="Delete Staff"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
handleEditClaim(claim);
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowView && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
handleViewClaim(claim);
|
||||||
|
}}
|
||||||
|
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
isOpen={isDeleteClaimOpen}
|
||||||
|
onConfirm={handleConfirmDeleteClaim}
|
||||||
|
onCancel={() => setIsDeleteClaimOpen(false)}
|
||||||
|
entityName={currentClaim?.patientName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="bg-white px-4 py-3 border-t border-gray-200">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
|
||||||
|
Showing {startItem}–{endItem} of {claimsData?.totalCount || 0}{" "}
|
||||||
|
results
|
||||||
|
</div>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
currentPage === 1
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }).map((_, i) => (
|
||||||
|
<PaginationItem key={i}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { AlertCircle, CheckCircle, Clock } from "lucide-react";
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
|
||||||
import { toast } from "@/hooks/use-toast";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
// Types for your data
|
|
||||||
interface ServiceLine {
|
|
||||||
billedAmount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Claim {
|
|
||||||
id: number;
|
|
||||||
patientName: string;
|
|
||||||
serviceDate: string;
|
|
||||||
insuranceProvider: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
serviceLines: ServiceLine[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClaimResponse {
|
|
||||||
data: Claim[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RecentClaims() {
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
const limit = 5;
|
|
||||||
|
|
||||||
const { data, isLoading, error, isFetching } = useQuery({
|
|
||||||
queryKey: ["/api/claims", offset, limit],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await apiRequest(
|
|
||||||
"GET",
|
|
||||||
`/api/claims?offset=${offset}&limit=${limit}`
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch claims");
|
|
||||||
return res.json() as Promise<ClaimResponse>;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const claims = data?.data ?? [];
|
|
||||||
const total = data?.total ?? 0;
|
|
||||||
|
|
||||||
const canGoBack = offset > 0;
|
|
||||||
const canGoNext = offset + limit < total;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <p className="text-sm text-gray-500">Loading claims...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-red-500">Failed to load recent claims.</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-8">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-medium text-gray-800">Recent Claims</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle>Submitted Claims</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{claims.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Clock className="h-12 w-12 mx-auto text-gray-400 mb-3" />
|
|
||||||
<h3 className="text-lg font-medium">No claims found</h3>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Any recent insurance claims will show up here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{claims.map((claim: Claim) => {
|
|
||||||
const totalBilled = claim.serviceLines.reduce(
|
|
||||||
(sum: number, line: ServiceLine) => sum + line.billedAmount,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`claim-${claim.id}`}
|
|
||||||
className="py-4 flex items-center justify-between cursor-pointer hover:bg-gray-50"
|
|
||||||
onClick={() =>
|
|
||||||
toast({
|
|
||||||
title: "Claim Details",
|
|
||||||
description: `Viewing details for claim #${claim.id}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">{claim.patientName}</h3>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
<span>Claim #: {claim.id}</span>
|
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<span>
|
|
||||||
Submitted:{" "}
|
|
||||||
{format(new Date(claim.createdAt), "MMM dd, yyyy")}
|
|
||||||
</span>
|
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<span>Provider: {claim.insuranceProvider}</span>
|
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<span>Amount: ${totalBilled.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
|
||||||
claim.status === "pending"
|
|
||||||
? "bg-yellow-100 text-yellow-800"
|
|
||||||
: claim.status === "completed"
|
|
||||||
? "bg-green-100 text-green-800"
|
|
||||||
: "bg-blue-100 text-blue-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{claim.status === "pending" ? (
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Clock className="h-3 w-3 mr-1" />
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
) : claim.status === "approved" ? (
|
|
||||||
<span className="flex items-center">
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
Approved
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center">
|
|
||||||
<AlertCircle className="h-3 w-3 mr-1" />
|
|
||||||
{claim.status}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{total > limit && (
|
|
||||||
<div className="flex items-center justify-between mt-6">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Showing {offset + 1}–{Math.min(offset + limit, total)} of{" "}
|
|
||||||
{total} claims
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!canGoBack || isFetching}
|
|
||||||
onClick={() => setOffset((prev) => Math.max(prev - limit, 0))}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!canGoNext || isFetching}
|
|
||||||
onClick={() => setOffset((prev) => prev + limit)}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -38,6 +38,7 @@ import { PatientSearch, SearchCriteria } from "./patient-search";
|
|||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Checkbox } from "../ui/checkbox";
|
import { Checkbox } from "../ui/checkbox";
|
||||||
|
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||||
|
|
||||||
const PatientSchema = (
|
const PatientSchema = (
|
||||||
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
@@ -329,15 +330,6 @@ export function PatientTable({
|
|||||||
return colorClasses[id % colorClasses.length];
|
return colorClasses[id % colorClasses.length];
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string | Date) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
||||||
@@ -428,7 +420,7 @@ export function PatientTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm text-gray-900">
|
||||||
{formatDate(patient.dateOfBirth)}
|
{formatDateToHumanReadable(patient.dateOfBirth)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 capitalize">
|
<div className="text-sm text-gray-500 capitalize">
|
||||||
{patient.gender}
|
{patient.gender}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { parse, format } from "date-fns";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import RecentClaims from "@/components/claims/recent-claims";
|
|
||||||
import { useAppDispatch, useAppSelector } from "@/redux/hooks";
|
import { useAppDispatch, useAppSelector } from "@/redux/hooks";
|
||||||
import {
|
import {
|
||||||
setTaskStatus,
|
setTaskStatus,
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
} from "@/redux/slices/seleniumClaimSubmitTaskSlice";
|
} from "@/redux/slices/seleniumClaimSubmitTaskSlice";
|
||||||
import { SeleniumTaskBanner } from "@/components/claims/selenium-task-banner";
|
import { SeleniumTaskBanner } from "@/components/claims/selenium-task-banner";
|
||||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||||
|
import ClaimsRecentTable from "@/components/claims/claims-recent-table";
|
||||||
|
|
||||||
//creating types out of schema auto generated.
|
//creating types out of schema auto generated.
|
||||||
type Appointment = z.infer<typeof AppointmentUncheckedCreateInputObjectSchema>;
|
type Appointment = z.infer<typeof AppointmentUncheckedCreateInputObjectSchema>;
|
||||||
@@ -703,7 +703,11 @@ export default function ClaimsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Claims Section */}
|
{/* Recent Claims Section */}
|
||||||
<RecentClaims />
|
<ClaimsRecentTable
|
||||||
|
allowEdit={true}
|
||||||
|
allowView={true}
|
||||||
|
allowDelete={true}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -72,3 +72,29 @@ export function normalizeToISOString(date: Date | string): string {
|
|||||||
const parsed = parseLocalDate(date);
|
const parsed = parseLocalDate(date);
|
||||||
return parsed.toISOString(); // ensures it always starts from local midnight
|
return parsed.toISOString(); // ensures it always starts from local midnight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date string or Date object into a human-readable "DD Mon YYYY" string.
|
||||||
|
* Examples: "22 Jul 2025"
|
||||||
|
*
|
||||||
|
* @param dateInput The date as a string (e.g., ISO, YYYY-MM-DD) or a Date object.
|
||||||
|
* @returns A formatted date string.
|
||||||
|
*/
|
||||||
|
export const formatDateToHumanReadable = (dateInput: string | Date): string => {
|
||||||
|
// Create a Date object from the input.
|
||||||
|
// The Date constructor is quite flexible with various string formats.
|
||||||
|
const date = new Date(dateInput);
|
||||||
|
|
||||||
|
// Check if the date is valid. If new Date() fails to parse, it returns "Invalid Date".
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
console.error("Invalid date input provided:", dateInput);
|
||||||
|
return "Invalid Date"; // Or handle this error in a way that suits your UI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Intl.DateTimeFormat for locale-aware, human-readable formatting.
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
day: "2-digit", // e.g., "01", "22"
|
||||||
|
month: "short", // e.g., "Jan", "Jul"
|
||||||
|
year: "numeric", // e.g., "2023", "2025"
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|||||||
@@ -93,20 +93,20 @@ model Staff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Claim {
|
model Claim {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
patientId Int
|
patientId Int
|
||||||
appointmentId Int
|
appointmentId Int
|
||||||
userId Int
|
userId Int
|
||||||
staffId Int
|
staffId Int
|
||||||
patientName String
|
patientName String
|
||||||
memberId String
|
memberId String
|
||||||
dateOfBirth DateTime @db.Date
|
dateOfBirth DateTime @db.Date
|
||||||
remarks String
|
remarks String
|
||||||
serviceDate DateTime
|
serviceDate DateTime
|
||||||
insuranceProvider String // e.g., "Delta MA"
|
insuranceProvider String // e.g., "Delta MA"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
status String @default("pending") // "pending", "approved", "cancelled", "review"
|
status ClaimStatus @default(PENDING)
|
||||||
|
|
||||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||||
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
||||||
@@ -116,6 +116,13 @@ model Claim {
|
|||||||
serviceLines ServiceLine[]
|
serviceLines ServiceLine[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ClaimStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
CANCELLED
|
||||||
|
REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
model ServiceLine {
|
model ServiceLine {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
claimId Int
|
claimId Int
|
||||||
@@ -163,7 +170,6 @@ model PdfFile {
|
|||||||
group PdfGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group PdfGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PdfCategory {
|
enum PdfCategory {
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from '../shared/schemas/objects/InsuranceCredentialUncheckedCreateInput
|
|||||||
export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/PdfGroupUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/PdfGroupUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/enums/PdfCategory.schema'
|
export * from '../shared/schemas/enums/PdfCategory.schema'
|
||||||
|
export * from '../shared/schemas/enums/ClaimStatus.schema'
|
||||||
Reference in New Issue
Block a user