feat(report page) - ui comp added

This commit is contained in:
2025-10-22 18:55:21 +05:30
parent c70c19aefa
commit 7b9e14b6b4
10 changed files with 1014 additions and 639 deletions

View File

@@ -3,26 +3,6 @@ import type { Request, Response } from "express";
import { storage } from "../storage";
const router = Router();
/**
* GET /api/payments-reports/summary
* optional query: from=YYYY-MM-DD&to=YYYY-MM-DD (ISO date strings)
*/
router.get("/summary", async (req, res) => {
try {
const from = req.query.from ? new Date(String(req.query.from)) : undefined;
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
const summary = await storage.getSummary(from, to);
res.json(summary);
} catch (err: any) {
console.error(
"GET /api/payments-reports/summary error:",
err?.message ?? err,
err?.stack
);
res.status(500).json({ message: "Failed to fetch dashboard summary" });
}
});
/**
* GET /api/payments-reports/summary
* optional query: from=YYYY-MM-DD&to=YYYY-MM-DD (ISO date strings)
@@ -53,8 +33,7 @@ router.get("/summary", async (req: Request, res: Response): Promise<any> => {
* GET /api/payments-reports/patient-balances
* query:
* - limit (default 25)
* - offset (default 0)
* - minBalance (true|false)
* - cursor (optional base64 cursor token)
* - from / to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
*/
router.get(
@@ -65,9 +44,9 @@ router.get(
1,
Math.min(200, parseInt(String(req.query.limit || "25"), 10))
);
const offset = Math.max(0, parseInt(String(req.query.offset || "0"), 10));
const minBalance =
String(req.query.minBalance || "false").toLowerCase() === "true";
const cursor =
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
const from = req.query.from
? new Date(String(req.query.from))
@@ -81,13 +60,8 @@ router.get(
return res.status(400).json({ message: "Invalid 'to' date" });
}
const data = await storage.getPatientBalances(
limit,
offset,
from,
to,
minBalance
);
const data = await storage.getPatientBalances(limit, cursor, from, to);
// returns { balances, totalCount, nextCursor, hasMore }
res.json(data);
} catch (err: any) {
console.error(
@@ -100,22 +74,4 @@ router.get(
}
);
// // GET /api/payments-by-date?from=ISO&to=ISO&limit=&offset=
// router.get("/payments-by-date", async (req: Request, res: Response): Promise<any> => {
// try {
// const from = req.query.from ? new Date(String(req.query.from)) : null;
// const to = req.query.to ? new Date(String(req.query.to)) : null;
// if (!from || !to) return res.status(400).json({ message: "from and to are required" });
// const limit = Math.min(parseInt(req.query.limit as string) || 1000, 5000);
// const offset = parseInt(req.query.offset as string) || 0;
// const { payments, totalCount } = await storage.getPaymentsByDateRange(from, to, limit, offset);
// res.json({ payments, totalCount });
// } catch (err) {
// console.error(err);
// res.status(500).json({ message: "Failed to fetch payments by date" });
// }
// });
export default router;

View File

@@ -14,16 +14,29 @@ export interface PatientBalanceRow {
currentBalance: number;
lastPaymentDate: string | null;
lastAppointmentDate: string | null;
patientCreatedAt?: string | null;
}
export interface GetPatientBalancesResult {
balances: PatientBalanceRow[];
totalCount: number;
nextCursor: string | null;
hasMore: boolean;
}
export interface IPaymentsReportsStorage {
/**
* Cursor-based pagination:
* - limit: page size
* - cursorToken: base64(JSON) token for last-seen row (or null for first page)
* - from/to: optional date range filter applied to Payment."createdAt"
*/
getPatientBalances(
limit: number,
offset: number,
cursorToken?: string | null,
from?: Date | null,
to?: Date | null,
minBalanceOnly?: boolean
): Promise<{ balances: PatientBalanceRow[]; totalCount: number }>;
to?: Date | null
): Promise<GetPatientBalancesResult>;
// summary now returns an extra field patientsWithBalance
getSummary(
@@ -44,13 +57,57 @@ function fmtDateLiteral(d?: Date | null): string | null {
return `'${iso}'`;
}
/** Cursor helpers — base64(JSON) */
function encodeCursor(obj: {
lastPaymentDate: string | null;
lastPatientCreatedAt: string; // ISO string
lastPatientId: number;
}) {
return Buffer.from(JSON.stringify(obj)).toString("base64");
}
function decodeCursor(token?: string | null): {
lastPaymentDate: string | null;
lastPatientCreatedAt: string;
lastPatientId: number;
} | null {
if (!token) return null;
try {
const parsed = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
if (
typeof parsed === "object" &&
"lastPaymentDate" in parsed &&
"lastPatientCreatedAt" in parsed &&
"lastPatientId" in parsed
) {
return {
lastPaymentDate:
parsed.lastPaymentDate === null
? null
: String(parsed.lastPaymentDate),
lastPatientCreatedAt: String(parsed.lastPatientCreatedAt),
lastPatientId: Number(parsed.lastPatientId),
};
}
return null;
} catch {
return null;
}
}
export const paymentsReportsStorage: IPaymentsReportsStorage = {
/**
* Returns all patients that currently have an outstanding balance (>0)
* Optionally filtered by date range.
*/
/**
* Cursor-based getPatientBalances
*/
async getPatientBalances(
limit = 25,
offset = 0,
cursorToken?: string | null,
from?: Date | null,
to?: Date | null,
minBalanceOnly = false
to?: Date | null
) {
try {
type RawRow = {
@@ -58,91 +115,92 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
first_name: string | null;
last_name: string | null;
total_charges: string;
total_payments: string;
total_paid: string;
total_adjusted: string;
current_balance: string;
last_payment_date: Date | null;
last_appointment_date: Date | null;
patient_created_at: Date | null; // we select patient.createdAt for cursor tie-breaker
};
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
const safeOffset = Math.max(0, Number(offset) || 0);
const cursor = decodeCursor(cursorToken);
const hasFrom = from !== undefined && from !== null;
const hasTo = to !== undefined && to !== null;
const fromLit = fmtDateLiteral(from);
const toLit = fmtDateLiteral(to);
// Build pm subquery (aggregated payments in the date window) — only Payment table used
let pmSubquery = "";
if (hasFrom && hasTo) {
pmSubquery = `
(
SELECT
pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
MAX(pay."createdAt") AS last_payment_date
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}
GROUP BY pay."patientId"
) pm
`;
} else if (hasFrom) {
pmSubquery = `
(
SELECT
pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
MAX(pay."createdAt") AS last_payment_date
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit}
GROUP BY pay."patientId"
) pm
`;
} else if (hasTo) {
pmSubquery = `
(
SELECT
pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
MAX(pay."createdAt") AS last_payment_date
FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit}
GROUP BY pay."patientId"
) pm
`;
} else {
pmSubquery = `
(
SELECT
pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
MAX(pay."createdAt") AS last_payment_date
FROM "Payment" pay
GROUP BY pay."patientId"
) pm
// Build payment subquery (aggregated payments by patient, filtered by createdAt if provided)
const paymentWhereClause =
hasFrom && hasTo
? `WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}`
: hasFrom
? `WHERE pay."createdAt" >= ${fromLit}`
: hasTo
? `WHERE pay."createdAt" <= ${toLit}`
: "";
const pmSubquery = `
(
SELECT
pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
MAX(pay."createdAt") AS last_payment_date
FROM "Payment" pay
${paymentWhereClause}
GROUP BY pay."patientId"
) pm
`;
// Build keyset predicate if cursor provided.
// Ordering used: pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC, p.id DESC
// For keyset, we need to fetch rows strictly "less than" the cursor in this ordering.
let keysetPredicate = "";
if (cursor) {
const lp = cursor.lastPaymentDate
? `'${cursor.lastPaymentDate}'`
: "NULL";
const lc = `'${cursor.lastPatientCreatedAt}'`;
const id = Number(cursor.lastPatientId);
// We handle NULL last_payment_date ordering: since we use "NULLS LAST" in ORDER BY,
// rows with last_payment_date = NULL are considered *after* any non-null dates.
// To page correctly when cursor's lastPaymentDate is null, we compare accordingly.
// This predicate tries to cover both cases.
keysetPredicate = `
AND (
-- case: both sides have non-null last_payment_date
(pm.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND (
pm.last_payment_date < ${lp}
OR (pm.last_payment_date = ${lp} AND p."createdAt" < ${lc})
OR (pm.last_payment_date = ${lp} AND p."createdAt" = ${lc} AND p.id < ${id})
))
-- case: cursor lastPaymentDate IS NULL -> we need rows with last_payment_date IS NULL but earlier createdAt/id
OR (pm.last_payment_date IS NULL AND ${lp} IS NULL AND (
p."createdAt" < ${lc}
OR (p."createdAt" = ${lc} AND p.id < ${id})
))
-- case: cursor had non-null lastPaymentDate but pm.last_payment_date IS NULL:
-- since NULLS LAST, pm.last_payment_date IS NULL are after non-null dates, so they are NOT < cursor -> excluded
)
`;
}
const baseQuery = `
const baseSelect = `
SELECT
p.id AS patient_id,
p."firstName" AS first_name,
p."lastName" AS last_name,
COALESCE(pm.total_charges,0)::numeric(12,2) AS total_charges,
COALESCE(pm.total_paid,0)::numeric(12,2) AS total_payments,
COALESCE(pm.total_paid,0)::numeric(12,2) AS total_paid,
COALESCE(pm.total_adjusted,0)::numeric(12,2) AS total_adjusted,
(COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0))::numeric(12,2) AS current_balance,
pm.last_payment_date,
apt.last_appointment_date
apt.last_appointment_date,
p."createdAt" AS patient_created_at
FROM "Patient" p
LEFT JOIN ${pmSubquery} ON pm.patient_id = p.id
LEFT JOIN (
@@ -150,120 +208,64 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
FROM "Appointment"
GROUP BY "patientId"
) apt ON apt.patient_id = p.id
WHERE (COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0
`;
const balanceWhere = `(COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0`;
const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC, p.id DESC`;
const limitClause = `LIMIT ${safeLimit}`;
const finalQuery = minBalanceOnly
? `${baseQuery} WHERE ${balanceWhere} ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC LIMIT ${safeLimit} OFFSET ${safeOffset}`
: `${baseQuery} ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC LIMIT ${safeLimit} OFFSET ${safeOffset}`;
const query = `
${baseSelect}
${cursor ? keysetPredicate : ""}
${orderBy}
${limitClause};
`;
// Execute query
const rows = (await prisma.$queryRawUnsafe(finalQuery)) as RawRow[];
const rows = (await prisma.$queryRawUnsafe(query)) as RawRow[];
// totalCount — count distinct patients that have payments in the date window (and if minBalanceOnly, only those with positive balance)
let countSql = "";
if (hasFrom && hasTo) {
if (minBalanceOnly) {
countSql = `
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}
GROUP BY pay."patientId"
) t
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
`;
} else {
countSql = `
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}
GROUP BY pay."patientId"
) t
`;
}
} else if (hasFrom) {
if (minBalanceOnly) {
countSql = `
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit}
GROUP BY pay."patientId"
) t
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
`;
} else {
countSql = `
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit}
GROUP BY pay."patientId"
) t
`;
}
} else if (hasTo) {
if (minBalanceOnly) {
countSql = `
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit}
GROUP BY pay."patientId"
) t
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
`;
} else {
countSql = `
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id
FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit}
GROUP BY pay."patientId"
) t
`;
}
// Build nextCursor from last returned row (if any)
let nextCursor: string | null = null;
// Explicitly handle empty result set
if (rows.length === 0) {
nextCursor = null;
} else {
if (minBalanceOnly) {
countSql = `
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
GROUP BY pay."patientId"
) t
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
`;
// rows.length > 0 here, but do an explicit last-check to make TS happy
const last = rows[rows.length - 1];
if (!last) {
// defensive — should not happen, but satisfies strict checks
nextCursor = null;
} else {
countSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`;
const lastPaymentDateIso = last.last_payment_date
? new Date(last.last_payment_date).toISOString()
: null;
const lastPatientCreatedAtIso = last.patient_created_at
? new Date(last.patient_created_at).toISOString()
: new Date().toISOString();
if (rows.length === safeLimit) {
nextCursor = encodeCursor({
lastPaymentDate: lastPaymentDateIso,
lastPatientCreatedAt: lastPatientCreatedAtIso,
lastPatientId: Number(last.patient_id),
});
} else {
nextCursor = null;
}
}
}
const cntRows = (await prisma.$queryRawUnsafe(countSql)) as {
cnt: number;
}[];
const totalCount = cntRows?.[0]?.cnt ?? 0;
// Determine hasMore: if we returned exactly limit, there *may* be more.
const hasMore = rows.length === safeLimit;
// Convert rows to PatientBalanceRow
const balances: PatientBalanceRow[] = rows.map((r) => ({
patientId: Number(r.patient_id),
firstName: r.first_name,
lastName: r.last_name,
totalCharges: Number(r.total_charges ?? 0),
totalPayments: Number(r.total_payments ?? 0),
totalPayments: Number(r.total_paid ?? 0),
totalAdjusted: Number(r.total_adjusted ?? 0),
currentBalance: Number(r.current_balance ?? 0),
lastPaymentDate: r.last_payment_date
@@ -272,9 +274,35 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
lastAppointmentDate: r.last_appointment_date
? new Date(r.last_appointment_date).toISOString()
: null,
patientCreatedAt: r.patient_created_at
? new Date(r.patient_created_at).toISOString()
: null,
}));
return { balances, totalCount };
// totalCount: count of patients with positive balance within same payment date filter
const countSql = `
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id,
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
${paymentWhereClause}
GROUP BY pay."patientId"
) t
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0;
`;
const cntRows = (await prisma.$queryRawUnsafe(countSql)) as {
cnt: number;
}[];
const totalCount = cntRows?.[0]?.cnt ?? 0;
return {
balances,
totalCount,
nextCursor,
hasMore,
};
} catch (err) {
console.error("[paymentsReportsStorage.getPatientBalances] error:", err);
throw err;

View File

@@ -0,0 +1,181 @@
import React, { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import PatientsBalancesList, { GenericRow } from "./patients-balances-list";
type DoctorOption = { id: string; name: string };
type DoctorCollectionRow = {
doctorId: string;
doctorName: string;
totalCollected?: number;
totalCharges?: number;
totalPayments?: number;
currentBalance?: number;
};
type CollectionsResp = {
rows: DoctorCollectionRow[];
totalCount?: number;
nextCursor?: string | null;
hasMore?: boolean;
};
export default function CollectionsByDoctorReport({
startDate,
endDate,
}: {
startDate: string;
endDate: string;
}) {
const [doctorId, setDoctorId] = useState<string | "">("");
// pagination (cursor) state
const perPage = 10;
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
const [cursorIndex, setCursorIndex] = useState<number>(0);
const currentCursor = cursorStack[cursorIndex] ?? null;
const pageIndex = cursorIndex + 1; // 1-based for UI
// load doctors for selector
const { data: doctors } = useQuery<DoctorOption[], Error>({
queryKey: ["doctors"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/doctors");
if (!res.ok) {
const b = await res
.json()
.catch(() => ({ message: "Failed to load doctors" }));
throw new Error(b.message || "Failed to load doctors");
}
return res.json();
},
staleTime: 60_000,
});
// rows (collections by doctor) - cursor-based request
const {
data: collectionData,
isLoading: isLoadingRows,
isError: isErrorRows,
refetch,
} = useQuery<CollectionsResp, Error>({
queryKey: [
"collections-by-doctor-rows",
doctorId,
currentCursor,
perPage,
startDate,
endDate,
],
queryFn: async () => {
const params = new URLSearchParams();
params.set("limit", String(perPage));
if (currentCursor) params.set("cursor", currentCursor);
if (doctorId) params.set("doctorId", doctorId);
if (startDate) params.set("from", startDate);
if (endDate) params.set("to", endDate);
const res = await apiRequest(
"GET",
`/api/payments-reports/collections-by-doctor?${params.toString()}`
);
if (!res.ok) {
const b = await res
.json()
.catch(() => ({ message: "Failed to load collections" }));
throw new Error(b.message || "Failed to load collections");
}
return res.json();
},
enabled: true,
staleTime: 30_000,
});
// derived pagination info
const rows = collectionData?.rows ?? [];
const totalCount = collectionData?.totalCount ?? undefined;
const nextCursor = collectionData?.nextCursor ?? null;
const hasMore = collectionData?.hasMore ?? false;
// reset cursor when filters change (doctor/date)
useEffect(() => {
setCursorStack([null]);
setCursorIndex(0);
refetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId, startDate, endDate]);
const handleNext = useCallback(() => {
const idx = cursorIndex;
const isLastKnown = idx === cursorStack.length - 1;
if (isLastKnown) {
if (nextCursor) {
setCursorStack((s) => [...s, nextCursor]);
setCursorIndex((i) => i + 1);
}
} else {
setCursorIndex((i) => i + 1);
}
}, [cursorIndex, cursorStack.length, nextCursor]);
const handlePrev = useCallback(() => {
setCursorIndex((i) => Math.max(0, i - 1));
}, []);
// Map doctor rows into GenericRow (consistent)
const mapDoctorToGeneric = (r: DoctorCollectionRow): GenericRow => {
const totalCharges = Number(r.totalCharges ?? 0);
const totalPayments = Number(r.totalCollected ?? r.totalPayments ?? 0);
return {
id: r.doctorId,
name: r.doctorName,
currentBalance: 0,
totalCharges,
totalPayments,
};
};
const genericRows: GenericRow[] = rows.map(mapDoctorToGeneric);
return (
<div>
<div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm text-gray-700 block mb-1">Doctor</label>
<select
value={doctorId}
onChange={(e) => setDoctorId(e.target.value)}
className="w-full border rounded px-2 py-1"
>
<option value="">All doctors</option>
{doctors?.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
</div>
<PatientsBalancesList
rows={genericRows}
reportType="collections_by_doctor"
loading={isLoadingRows}
error={
isErrorRows
? "Failed to load collections for the selected doctor/date range."
: false
}
emptyMessage="No collection data for the selected doctor/date range."
// cursor props (cursor-only approach)
pageIndex={pageIndex}
perPage={perPage}
total={totalCount}
onPrev={handlePrev}
onNext={handleNext}
hasPrev={cursorIndex > 0}
hasNext={hasMore}
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
export default function PaginationControls({
pageIndex,
perPage,
total,
onPrev,
onNext,
hasPrev,
hasNext,
}: {
/** 1-based page index (for display). Pass cursorIndex + 1 from parent. */
pageIndex: number;
perPage: number;
/** optional totalCount from backend (if provided) */
total?: number | undefined;
onPrev: () => void;
onNext: () => void;
hasPrev: boolean;
hasNext: boolean;
}) {
const startItem = total === 0 ? 0 : (pageIndex - 1) * perPage + 1;
const endItem = Math.min(pageIndex * perPage, total ?? pageIndex * perPage);
return (
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground whitespace-nowrap">
{typeof total === "number"
? `Showing ${startItem}-${endItem} of ${total}`
: `Page ${pageIndex}`}
</div>
<div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e: any) => {
e.preventDefault();
if (hasPrev) onPrev();
}}
className={hasPrev ? "" : "pointer-events-none opacity-50"}
/>
</PaginationItem>
<div className="px-2" />
<PaginationItem>
<PaginationNext
href="#"
onClick={(e: any) => {
e.preventDefault();
if (hasNext) onNext();
}}
className={hasNext ? "" : "pointer-events-none opacity-50"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import React from "react";
import { DollarSign } from "lucide-react";
import PaginationControls from "./pagination-controls";
export type GenericRow = {
id: string | number;
name: string;
currentBalance: number;
totalCharges: number;
totalPayments: number;
};
export default function PatientsBalancesList({
rows,
reportType,
loading,
error,
emptyMessage,
pageIndex = 1, // 1-based
perPage = 10,
total, // optional totalCount from backend
onPrev,
onNext,
hasPrev,
hasNext,
}: {
rows: GenericRow[];
reportType?: string | null;
loading?: boolean;
error?: string | boolean;
emptyMessage?: string;
// cursor props (required)
pageIndex?: number;
perPage?: number;
total?: number | undefined;
onPrev: () => void;
onNext: () => void;
hasPrev: boolean;
hasNext: boolean;
}) {
const fmt = (v: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(v);
const reportTypeTitle = (rt?: string | null) => {
switch (rt) {
case "patients_with_balance":
return "Patients with Outstanding Balances";
case "patients_no_balance":
return "Patients with Zero Balance";
case "monthly_collections":
return "Monthly Collections";
case "collections_by_doctor":
return "Collections by Doctor";
default:
return "Balances";
}
};
return (
<div className="space-y-4">
<div className="bg-white rounded-lg border">
<div className="px-4 py-3 border-b bg-gray-50 flex items-center justify-between">
<h3 className="font-medium text-gray-900">
{reportTypeTitle(reportType)}
</h3>
</div>
<div className="divide-y min-h-[120px]">
{loading ? (
<div className="p-8 text-center text-gray-600">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<div>Loading {reportType ?? "data"}</div>
</div>
) : error ? (
<div className="p-8 text-center text-red-600">
<div className="mb-2 font-semibold">Could not fetch data</div>
<div className="text-sm text-red-500">
{typeof error === "string"
? error
: "An error occurred while loading the report."}
</div>
</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<DollarSign className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p>{emptyMessage ?? "No rows for this report."}</p>
</div>
) : (
rows.map((r) => (
<div key={r.id} className="p-4 hover:bg-gray-50">
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium text-gray-900">{r.name}</h4>
<p className="text-sm text-gray-500">ID: {r.id}</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-red-600">
{fmt(r.currentBalance)}
</div>
<div className="text-sm text-gray-500">
Charges: {fmt(r.totalCharges)} · Collected:{" "}
{fmt(r.totalPayments)}
</div>
</div>
</div>
</div>
))
)}
</div>
{/* Cursor pagination footer (cursor-only) */}
<div className="bg-white px-4 py-3 border-t border-gray-200">
<PaginationControls
pageIndex={pageIndex}
perPage={perPage}
total={total}
onPrev={onPrev}
onNext={onNext}
hasPrev={hasPrev}
hasNext={hasNext}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import React, { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import type { PatientBalanceRow } from "@repo/db/types";
import PatientsBalancesList from "./patients-balances-list";
type Resp = {
balances: PatientBalanceRow[];
totalCount?: number; // optional
nextCursor?: string | null;
hasMore?: boolean;
};
export default function PatientsWithBalanceReport({
startDate,
endDate,
}: {
startDate: string;
endDate: string;
}) {
const balancesPerPage = 10;
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
const [cursorIndex, setCursorIndex] = useState(0);
const currentCursor = cursorStack[cursorIndex] ?? null;
const pageIndex = cursorIndex + 1; // 1-based for UI
const { data, isLoading, isError, refetch } = useQuery<Resp, Error>({
queryKey: [
"/api/payments-reports/patient-balances",
currentCursor,
balancesPerPage,
startDate,
endDate,
],
queryFn: async () => {
const params = new URLSearchParams();
params.set("limit", String(balancesPerPage));
if (currentCursor) params.set("cursor", currentCursor);
if (startDate) params.set("from", startDate);
if (endDate) params.set("to", endDate);
const res = await apiRequest(
"GET",
`/api/payments-reports/patient-balances?${params.toString()}`
);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ message: "Failed to load patient balances" }));
throw new Error(body.message || "Failed to load patient balances");
}
return res.json();
},
enabled: true,
staleTime: 30_000,
});
const balances = data?.balances ?? [];
const totalCount = data?.totalCount ?? undefined;
const nextCursor = data?.nextCursor ?? null;
const hasMore = data?.hasMore ?? false;
useEffect(() => {
setCursorStack([null]);
setCursorIndex(0);
refetch();
}, [startDate, endDate]);
const handleNext = useCallback(() => {
const idx = cursorIndex;
const isLastKnown = idx === cursorStack.length - 1;
if (isLastKnown) {
if (nextCursor) {
setCursorStack((s) => [...s, nextCursor]);
setCursorIndex((i) => i + 1);
}
} else {
setCursorIndex((i) => i + 1);
}
}, [cursorIndex, cursorStack.length, nextCursor]);
const handlePrev = useCallback(() => {
setCursorIndex((i) => Math.max(0, i - 1));
}, []);
const normalized = balances.map((b) => {
const currentBalance = Number(b.currentBalance ?? 0);
const totalCharges = Number(b.totalCharges ?? 0);
const totalPayments =
b.totalPayments != null
? Number(b.totalPayments)
: Number(totalCharges - currentBalance);
return {
id: b.patientId,
name: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(),
currentBalance,
totalCharges,
totalPayments,
};
});
return (
<div>
<div className="mb-4">
<PatientsBalancesList
rows={normalized}
reportType="patients_with_balance"
loading={isLoading}
error={
isError
? "Failed to load patient balances for the selected date range."
: false
}
emptyMessage="No patient balances for the selected date range."
pageIndex={pageIndex}
perPage={balancesPerPage}
total={totalCount}
onPrev={handlePrev}
onNext={handleNext}
hasPrev={cursorIndex > 0}
hasNext={hasMore}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Calendar } from "lucide-react";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { DateInput } from "@/components/ui/dateInput";
type ReportType =
| "patients_with_balance"
| "patients_no_balance"
| "monthly_collections"
| "collections_by_doctor"
| "procedure_codes_by_doctor"
| "payment_methods"
| "insurance_vs_patient_payments"
| "aging_report";
export default function ReportConfig({
startDate,
endDate,
setStartDate,
setEndDate,
selectedReportType,
setSelectedReportType,
}: {
startDate: string; // "" or "YYYY-MM-DD"
endDate: string;
setStartDate: (s: string) => void;
setEndDate: (s: string) => void;
selectedReportType: ReportType;
setSelectedReportType: (r: ReportType) => void;
}) {
// Convert incoming string -> Date | null using your parseLocalDate utility.
// parseLocalDate can throw for invalid strings, so guard with try/catch.
let startDateObj: Date | null = null;
if (startDate) {
try {
startDateObj = parseLocalDate(startDate);
} catch {
startDateObj = null;
}
}
let endDateObj: Date | null = null;
if (endDate) {
try {
endDateObj = parseLocalDate(endDate);
} catch {
endDateObj = null;
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" /> Report Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-gray-500">
Choose the report type and date range.
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<DateInput
label="Start Date"
value={startDateObj}
onChange={(d) => {
setStartDate(d ? formatLocalDate(d) : "");
}}
disableFuture
/>
</div>
<div>
<DateInput
label="End Date"
value={endDateObj}
onChange={(d) => {
setEndDate(d ? formatLocalDate(d) : "");
}}
disableFuture
/>
</div>
<div className="space-y-2">
<Label htmlFor="report-type">Report Type</Label>
<Select
value={selectedReportType}
onValueChange={(v) => setSelectedReportType(v as ReportType)}
>
<SelectTrigger>
<SelectValue placeholder="Select report type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="patients_with_balance">
Patients with Outstanding Balance
</SelectItem>
<SelectItem value="collections_by_doctor">
Collections by Doctor
</SelectItem>
<SelectItem value="patients_no_balance">
Patients with Zero Balance
</SelectItem>
<SelectItem value="monthly_collections">
Monthly Collections Summary
</SelectItem>
<SelectItem value="procedure_codes_by_doctor">
Procedure Codes by Doctor
</SelectItem>
<SelectItem value="payment_methods">
Payment Methods Breakdown
</SelectItem>
<SelectItem value="insurance_vs_patient_payments">
Insurance vs Patient Payments
</SelectItem>
<SelectItem value="aging_report">
Accounts Receivable Aging
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
type SummaryResp = {
totalPatients?: number;
patientsWithBalance?: number;
totalOutstanding?: number;
totalCollected?: number;
};
function fmtCurrency(v: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(v);
}
export default function SummaryCards({
startDate,
endDate,
}: {
startDate: string;
endDate: string;
}) {
// Query the server summary for the given date range
const { data, isLoading, isError } = useQuery<SummaryResp, Error>({
queryKey: ["/api/payments-reports/summary", startDate, endDate],
queryFn: async () => {
const params = new URLSearchParams();
if (startDate) params.set("from", startDate);
if (endDate) params.set("to", endDate);
const endpoint = `/api/payments-reports/summary?${params.toString()}`;
const res = await apiRequest("GET", endpoint);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ message: "Failed to load dashboard summary" }));
throw new Error(body?.message ?? "Failed to load dashboard summary");
}
return res.json();
},
enabled: Boolean(startDate && endDate),
staleTime: 30_000,
});
const totalPatients = data?.totalPatients ?? 0;
const patientsWithBalance = data?.patientsWithBalance ?? 0;
const patientsNoBalance = Math.max(
0,
(data?.totalPatients ?? 0) - (data?.patientsWithBalance ?? 0)
);
const totalOutstanding = data?.totalOutstanding ?? 0;
const totalCollected = data?.totalCollected ?? 0;
return (
<Card className="pt-4 pb-4">
<CardContent>
{/* Heading */}
<div className="mb-3">
<h2 className="text-base font-semibold text-gray-800">
Report summary
</h2>
<p className="text-sm text-gray-500">
Data covers the selected time frame
</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">
{isLoading ? "—" : totalPatients}
</div>
<p className="text-sm text-gray-600">Total Patients</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-red-600">
{isLoading ? "—" : patientsWithBalance}
</div>
<p className="text-sm text-gray-600">With Balance</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-green-600">
{isLoading ? "—" : patientsNoBalance}
</div>
<p className="text-sm text-gray-600">Zero Balance</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-orange-600">
{isLoading ? "—" : fmtCurrency(totalOutstanding)}
</div>
<p className="text-sm text-gray-600">Outstanding</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-purple-600">
{isLoading ? "—" : fmtCurrency(totalCollected)}
</div>
<p className="text-sm text-gray-600">Collected</p>
</div>
</div>
{isError && (
<div className="mt-3 text-sm text-red-600">
Failed to load summary. Check server or network.
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,31 +1,11 @@
// apps/Frontend/src/pages/reports-page.tsx
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
DollarSign,
FileText,
Download,
AlertCircle,
Calendar,
Users,
TrendingUp,
} from "lucide-react";
import React, { useState } from "react";
import { Download } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { apiRequest } from "@/lib/queryClient"; // <<-- your helper
import type { PatientBalanceRow } from "@repo/db/types";
import ReportConfig from "@/components/reports/report-config";
import PatientsWithBalanceReport from "@/components/reports/patients-with-balance-report";
import CollectionsByDoctorReport from "@/components/reports/collections-by-doctor-report";
import SummaryCards from "@/components/reports/summary-cards";
import { Button } from "@/components/ui/button";
type ReportType =
| "patients_with_balance"
@@ -37,26 +17,16 @@ type ReportType =
| "insurance_vs_patient_payments"
| "aging_report";
interface PatientBalancesResponse {
balances: PatientBalanceRow[];
totalCount: number;
}
export default function ReportsPage() {
export default function ReportPage() {
const { user } = useAuth();
// pagination state for patient balances
const [balancesPage, setBalancesPage] = useState<number>(1);
const balancesPerPage = 10;
// date range state (for dashboard summary)
const [startDate, setStartDate] = useState(() => {
const [startDate, setStartDate] = useState<string>(() => {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().split("T")[0];
return d.toISOString().split("T")[0] ?? "";
});
const [endDate, setEndDate] = useState(
() => new Date().toISOString().split("T")[0]
const [endDate, setEndDate] = useState<string>(
() => new Date().toISOString().split("T")[0] ?? ""
);
const [selectedReportType, setSelectedReportType] = useState<ReportType>(
@@ -64,413 +34,73 @@ export default function ReportsPage() {
);
const [isGenerating, setIsGenerating] = useState(false);
// --- 1) patient balances (paginated) using apiRequest ---
const {
data: patientBalancesResponse,
isLoading: isLoadingBalances,
isError: isErrorBalances,
} = useQuery<PatientBalancesResponse>({
queryKey: [
"/api/payments-reports/patient-balances",
balancesPage,
balancesPerPage,
startDate,
endDate,
selectedReportType,
],
queryFn: async () => {
const offset = (balancesPage - 1) * balancesPerPage;
const minBalanceFlag = selectedReportType === "patients_with_balance";
const endpoint = `/api/payments-reports/patient-balances?limit=${balancesPerPage}&offset=${offset}&minBalance=${minBalanceFlag}&from=${encodeURIComponent(
String(startDate)
)}&to=${encodeURIComponent(String(endDate))}`;
const res = await apiRequest("GET", endpoint);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ message: "Failed to load patient balances" }));
throw new Error(body.message || "Failed to load patient balances");
}
return res.json();
},
enabled: !!user,
});
const patientBalances: PatientBalanceRow[] =
patientBalancesResponse?.balances ?? [];
const patientBalancesTotal = patientBalancesResponse?.totalCount ?? 0;
// --- 2) dashboard summary (separate route/storage) using apiRequest ---
const { data: dashboardSummary, isLoading: isLoadingSummary } = useQuery({
queryKey: [
"/api/payments-reports/summary",
String(startDate),
String(endDate),
],
queryFn: async () => {
const endpoint = `/api/payments-reports/summary?from=${encodeURIComponent(
String(startDate)
)}&to=${encodeURIComponent(String(endDate))}`;
const res = await apiRequest("GET", endpoint);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ message: "Failed to load dashboard summary" }));
throw new Error(body.message || "Failed to load dashboard summary");
}
return res.json();
},
enabled: !!user,
});
// format currency for numbers in dollars (storage returns decimal numbers like 123.45)
const formatCurrency = (amountDollars: number | undefined | null) => {
const value = Number(amountDollars ?? 0);
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
};
// summary stats: use dashboardSummary for totals (server-driven) and derive other counts from paginated balances
const summaryStats = {
totalPatients: dashboardSummary?.totalPatients ?? 0,
// use the server-provided count of patients with balance inside range
patientsWithBalance: dashboardSummary?.patientsWithBalance ?? 0,
// patientsNoBalance: based on totalCount - patientsWithBalance (note: totalCount is number of patients with payments in range)
patientsNoBalance: Math.max(
0,
(dashboardSummary?.totalPatients ?? 0) -
(dashboardSummary?.patientsWithBalance ?? 0)
),
totalOutstanding:
dashboardSummary?.totalOutstanding ??
patientBalances.reduce((s, b) => s + (b.currentBalance ?? 0), 0),
totalCollected: dashboardSummary?.totalCollected ?? 0,
};
const generateReport = async () => {
setIsGenerating(true);
await new Promise((r) => setTimeout(r, 900));
setIsGenerating(false);
try {
// placeholder: implement export per-report endpoint
await new Promise((r) => setTimeout(r, 900));
} finally {
setIsGenerating(false);
}
};
// -------------------- report rendering (only patients_with_balance wired) --------------------
// -------------------- report rendering (only patients_with_balance wired) --------------------
const renderPatientsWithBalance = () => {
// Use patientBalances for the current page list (already minBalance filtered if selectedReportType === 'patients_with_balance')
const patientsWithBalance = patientBalances
.filter((b) => (b.currentBalance ?? 0) > 0)
.map((b) => ({
patientId: b.patientId,
patientName: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(),
currentBalance: b.currentBalance ?? 0,
totalCharges: b.totalCharges ?? 0,
totalPayments: b.totalPayments ?? 0,
}));
const totalOutstanding = patientsWithBalance.reduce(
(s, p) => s + p.currentBalance,
0
);
const avgBalance = patientsWithBalance.length
? totalOutstanding / patientsWithBalance.length
: 0;
if (!user) {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-red-600">
{summaryStats.patientsWithBalance}
</div>
<p className="text-sm text-gray-600">Patients with Balance</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-red-600">
{formatCurrency(summaryStats.totalOutstanding)}
</div>
<p className="text-sm text-gray-600">Total Outstanding</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-blue-600">
{formatCurrency(avgBalance)}
</div>
<p className="text-sm text-gray-600">
Average Balance (visible page)
</p>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-lg border">
<div className="px-4 py-3 border-b bg-gray-50">
<h3 className="font-medium text-gray-900">
Patients with Outstanding Balances
</h3>
</div>
<div className="divide-y">
{patientsWithBalance.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<DollarSign className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p>No patients have outstanding balances on this page</p>
</div>
) : (
patientsWithBalance.map((p) => (
<div key={p.patientId} className="p-4 hover:bg-gray-50">
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium text-gray-900">
{p.patientName}
</h4>
<p className="text-sm text-gray-500">
Patient ID: {p.patientId}
</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-red-600">
{formatCurrency(p.currentBalance)}
</div>
<div className="text-sm text-gray-500">
Charges: {formatCurrency(p.totalCharges)}
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* pagination controls for balances */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-600">
Showing {(balancesPage - 1) * balancesPerPage + 1} -{" "}
{Math.min(balancesPage * balancesPerPage, patientBalancesTotal)} of{" "}
{patientBalancesTotal}
</div>
<div className="space-x-2">
<Button
disabled={balancesPage <= 1}
onClick={() => setBalancesPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
disabled={balancesPage * balancesPerPage >= patientBalancesTotal}
onClick={() => setBalancesPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
</div>
<div className="text-center py-8">Please sign in to view reports.</div>
);
};
const renderReportContent = () => {
if (isLoadingBalances || isLoadingSummary) {
return (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-gray-600">Loading report data...</p>
</div>
);
}
if ((patientBalances?.length ?? 0) === 0) {
return (
<div className="text-center py-12">
<AlertCircle className="h-12 w-12 text-amber-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Financial Data Not Available
</h3>
<p className="text-gray-600 mb-4">
No patient balance data yet. Add payments/service lines to populate
reports.
</p>
<div className="text-sm text-gray-500">
<p>
Date range: {startDate} to {endDate}
</p>
</div>
</div>
);
}
switch (selectedReportType) {
case "patients_with_balance":
return renderPatientsWithBalance();
default:
return (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Report Type Not Implemented
</h3>
<p className="text-gray-600">
The "{selectedReportType}" report is being developed. For now use
"Patients with Outstanding Balance".
</p>
</div>
);
}
};
}
return (
<div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div className="container mx-auto space-y-6">
{/* Header Section */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-semibold text-gray-900">
<h1 className="text-3xl font-bold tracking-tight">
Financial Reports
</h1>
<p className="text-gray-600">
<p className="text-muted-foreground">
Generate comprehensive financial reports for your practice
</p>
</div>
{/* Export Button (Top Right) */}
<Button
onClick={generateReport}
disabled={isGenerating}
className="mt-4 md:mt-0"
className="default"
>
<Download className="h-4 w-4 mr-2" />{" "}
<Download className="h-4 w-4 mr-2" />
{isGenerating ? "Generating..." : "Export Report"}
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Report Configuration
</CardTitle>
</CardHeader>
<div className="mb-4">
<ReportConfig
startDate={startDate}
endDate={endDate}
setStartDate={setStartDate}
setEndDate={setEndDate}
selectedReportType={selectedReportType}
setSelectedReportType={setSelectedReportType}
/>
</div>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
{/* SINGLE authoritative SummaryCards instance for the page */}
<div className="mb-4">
<SummaryCards startDate={startDate} endDate={endDate} />
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div>
{selectedReportType === "patients_with_balance" && (
<PatientsWithBalanceReport startDate={startDate} endDate={endDate} />
)}
<div className="space-y-2">
<Label htmlFor="report-type">Report Type</Label>
<Select
value={selectedReportType}
onValueChange={(v) => setSelectedReportType(v as ReportType)}
>
<SelectTrigger>
<SelectValue placeholder="Select report type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="patients_with_balance">
Patients with Outstanding Balance
</SelectItem>
<SelectItem value="patients_no_balance">
Patients with Zero Balance
</SelectItem>
<SelectItem value="monthly_collections">
Monthly Collections Summary
</SelectItem>
<SelectItem value="collections_by_doctor">
Collections by Each Doctor
</SelectItem>
<SelectItem value="procedure_codes_by_doctor">
Procedure Codes Analysis by Doctors
</SelectItem>
<SelectItem value="payment_methods">
Payment Methods Breakdown
</SelectItem>
<SelectItem value="insurance_vs_patient_payments">
Insurance vs Patient Payments
</SelectItem>
<SelectItem value="aging_report">
Accounts Receivable Aging
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{selectedReportType === "collections_by_doctor" && (
<CollectionsByDoctorReport startDate={startDate} endDate={endDate} />
)}
<Separator />
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">
{summaryStats.totalPatients}
</div>
<p className="text-sm text-gray-600">Total Patients</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-red-600">
{summaryStats.patientsWithBalance}
</div>
<p className="text-sm text-gray-600">With Balance</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-green-600">
{summaryStats.patientsNoBalance}
</div>
<p className="text-sm text-gray-600">Zero Balance</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-orange-600">
{formatCurrency(summaryStats.totalOutstanding)}
</div>
<p className="text-sm text-gray-600">Outstanding</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-purple-600">
{formatCurrency(summaryStats.totalCollected)}
</div>
<p className="text-sm text-gray-600">Collected</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
{selectedReportType === "patients_with_balance"
? "Patients with Outstanding Balance"
: selectedReportType}
</CardTitle>
</CardHeader>
<CardContent>{renderReportContent()}</CardContent>
</Card>
{/* Add other report components here as needed */}
</div>
</div>
);
}

View File

@@ -243,6 +243,7 @@ model Payment {
@@index([claimId])
@@index([patientId])
@@index([createdAt])
}
model ServiceLineTransaction {