feat(report page) - ui comp added
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
71
apps/Frontend/src/components/reports/pagination-controls.tsx
Normal file
71
apps/Frontend/src/components/reports/pagination-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
apps/Frontend/src/components/reports/patients-balances-list.tsx
Normal file
130
apps/Frontend/src/components/reports/patients-balances-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
137
apps/Frontend/src/components/reports/report-config.tsx
Normal file
137
apps/Frontend/src/components/reports/report-config.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
apps/Frontend/src/components/reports/summary-cards.tsx
Normal file
116
apps/Frontend/src/components/reports/summary-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user