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";
|
import { storage } from "../storage";
|
||||||
const router = Router();
|
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
|
* GET /api/payments-reports/summary
|
||||||
* optional query: from=YYYY-MM-DD&to=YYYY-MM-DD (ISO date strings)
|
* 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
|
* GET /api/payments-reports/patient-balances
|
||||||
* query:
|
* query:
|
||||||
* - limit (default 25)
|
* - limit (default 25)
|
||||||
* - offset (default 0)
|
* - cursor (optional base64 cursor token)
|
||||||
* - minBalance (true|false)
|
|
||||||
* - from / to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
|
* - from / to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
@@ -65,9 +44,9 @@ router.get(
|
|||||||
1,
|
1,
|
||||||
Math.min(200, parseInt(String(req.query.limit || "25"), 10))
|
Math.min(200, parseInt(String(req.query.limit || "25"), 10))
|
||||||
);
|
);
|
||||||
const offset = Math.max(0, parseInt(String(req.query.offset || "0"), 10));
|
|
||||||
const minBalance =
|
const cursor =
|
||||||
String(req.query.minBalance || "false").toLowerCase() === "true";
|
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
|
||||||
|
|
||||||
const from = req.query.from
|
const from = req.query.from
|
||||||
? new Date(String(req.query.from))
|
? new Date(String(req.query.from))
|
||||||
@@ -81,13 +60,8 @@ router.get(
|
|||||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await storage.getPatientBalances(
|
const data = await storage.getPatientBalances(limit, cursor, from, to);
|
||||||
limit,
|
// returns { balances, totalCount, nextCursor, hasMore }
|
||||||
offset,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
minBalance
|
|
||||||
);
|
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -14,16 +14,29 @@ export interface PatientBalanceRow {
|
|||||||
currentBalance: number;
|
currentBalance: number;
|
||||||
lastPaymentDate: string | null;
|
lastPaymentDate: string | null;
|
||||||
lastAppointmentDate: string | null;
|
lastAppointmentDate: string | null;
|
||||||
|
patientCreatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetPatientBalancesResult {
|
||||||
|
balances: PatientBalanceRow[];
|
||||||
|
totalCount: number;
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPaymentsReportsStorage {
|
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(
|
getPatientBalances(
|
||||||
limit: number,
|
limit: number,
|
||||||
offset: number,
|
cursorToken?: string | null,
|
||||||
from?: Date | null,
|
from?: Date | null,
|
||||||
to?: Date | null,
|
to?: Date | null
|
||||||
minBalanceOnly?: boolean
|
): Promise<GetPatientBalancesResult>;
|
||||||
): Promise<{ balances: PatientBalanceRow[]; totalCount: number }>;
|
|
||||||
|
|
||||||
// summary now returns an extra field patientsWithBalance
|
// summary now returns an extra field patientsWithBalance
|
||||||
getSummary(
|
getSummary(
|
||||||
@@ -44,13 +57,57 @@ function fmtDateLiteral(d?: Date | null): string | null {
|
|||||||
return `'${iso}'`;
|
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 = {
|
export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||||
|
/**
|
||||||
|
* Returns all patients that currently have an outstanding balance (>0)
|
||||||
|
* Optionally filtered by date range.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Cursor-based getPatientBalances
|
||||||
|
*/
|
||||||
async getPatientBalances(
|
async getPatientBalances(
|
||||||
limit = 25,
|
limit = 25,
|
||||||
offset = 0,
|
cursorToken?: string | null,
|
||||||
from?: Date | null,
|
from?: Date | null,
|
||||||
to?: Date | null,
|
to?: Date | null
|
||||||
minBalanceOnly = false
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
type RawRow = {
|
type RawRow = {
|
||||||
@@ -58,91 +115,92 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
total_charges: string;
|
total_charges: string;
|
||||||
total_payments: string;
|
total_paid: string;
|
||||||
total_adjusted: string;
|
total_adjusted: string;
|
||||||
current_balance: string;
|
current_balance: string;
|
||||||
last_payment_date: Date | null;
|
last_payment_date: Date | null;
|
||||||
last_appointment_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 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 hasFrom = from !== undefined && from !== null;
|
||||||
const hasTo = to !== undefined && to !== null;
|
const hasTo = to !== undefined && to !== null;
|
||||||
const fromLit = fmtDateLiteral(from);
|
const fromLit = fmtDateLiteral(from);
|
||||||
const toLit = fmtDateLiteral(to);
|
const toLit = fmtDateLiteral(to);
|
||||||
|
|
||||||
// Build pm subquery (aggregated payments in the date window) — only Payment table used
|
// Build payment subquery (aggregated payments by patient, filtered by createdAt if provided)
|
||||||
let pmSubquery = "";
|
const paymentWhereClause =
|
||||||
if (hasFrom && hasTo) {
|
hasFrom && hasTo
|
||||||
pmSubquery = `
|
? `WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}`
|
||||||
(
|
: hasFrom
|
||||||
SELECT
|
? `WHERE pay."createdAt" >= ${fromLit}`
|
||||||
pay."patientId" AS patient_id,
|
: hasTo
|
||||||
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
? `WHERE pay."createdAt" <= ${toLit}`
|
||||||
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
|
const pmSubquery = `
|
||||||
FROM "Payment" pay
|
(
|
||||||
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}
|
SELECT
|
||||||
GROUP BY pay."patientId"
|
pay."patientId" AS patient_id,
|
||||||
) pm
|
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
||||||
`;
|
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
|
||||||
} else if (hasFrom) {
|
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
|
||||||
pmSubquery = `
|
MAX(pay."createdAt") AS last_payment_date
|
||||||
(
|
FROM "Payment" pay
|
||||||
SELECT
|
${paymentWhereClause}
|
||||||
pay."patientId" AS patient_id,
|
GROUP BY pay."patientId"
|
||||||
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
) pm
|
||||||
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
|
// Build keyset predicate if cursor provided.
|
||||||
FROM "Payment" pay
|
// Ordering used: pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC, p.id DESC
|
||||||
WHERE pay."createdAt" >= ${fromLit}
|
// For keyset, we need to fetch rows strictly "less than" the cursor in this ordering.
|
||||||
GROUP BY pay."patientId"
|
let keysetPredicate = "";
|
||||||
) pm
|
if (cursor) {
|
||||||
`;
|
const lp = cursor.lastPaymentDate
|
||||||
} else if (hasTo) {
|
? `'${cursor.lastPaymentDate}'`
|
||||||
pmSubquery = `
|
: "NULL";
|
||||||
(
|
const lc = `'${cursor.lastPatientCreatedAt}'`;
|
||||||
SELECT
|
const id = Number(cursor.lastPatientId);
|
||||||
pay."patientId" AS patient_id,
|
|
||||||
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
// We handle NULL last_payment_date ordering: since we use "NULLS LAST" in ORDER BY,
|
||||||
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
|
// rows with last_payment_date = NULL are considered *after* any non-null dates.
|
||||||
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
|
// To page correctly when cursor's lastPaymentDate is null, we compare accordingly.
|
||||||
MAX(pay."createdAt") AS last_payment_date
|
// This predicate tries to cover both cases.
|
||||||
FROM "Payment" pay
|
keysetPredicate = `
|
||||||
WHERE pay."createdAt" <= ${toLit}
|
AND (
|
||||||
GROUP BY pay."patientId"
|
-- case: both sides have non-null last_payment_date
|
||||||
) pm
|
(pm.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND (
|
||||||
`;
|
pm.last_payment_date < ${lp}
|
||||||
} else {
|
OR (pm.last_payment_date = ${lp} AND p."createdAt" < ${lc})
|
||||||
pmSubquery = `
|
OR (pm.last_payment_date = ${lp} AND p."createdAt" = ${lc} AND p.id < ${id})
|
||||||
(
|
))
|
||||||
SELECT
|
-- case: cursor lastPaymentDate IS NULL -> we need rows with last_payment_date IS NULL but earlier createdAt/id
|
||||||
pay."patientId" AS patient_id,
|
OR (pm.last_payment_date IS NULL AND ${lp} IS NULL AND (
|
||||||
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
p."createdAt" < ${lc}
|
||||||
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
|
OR (p."createdAt" = ${lc} AND p.id < ${id})
|
||||||
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
|
))
|
||||||
MAX(pay."createdAt") AS last_payment_date
|
-- case: cursor had non-null lastPaymentDate but pm.last_payment_date IS NULL:
|
||||||
FROM "Payment" pay
|
-- since NULLS LAST, pm.last_payment_date IS NULL are after non-null dates, so they are NOT < cursor -> excluded
|
||||||
GROUP BY pay."patientId"
|
)
|
||||||
) pm
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = `
|
const baseSelect = `
|
||||||
SELECT
|
SELECT
|
||||||
p.id AS patient_id,
|
p.id AS patient_id,
|
||||||
p."firstName" AS first_name,
|
p."firstName" AS first_name,
|
||||||
p."lastName" AS last_name,
|
p."lastName" AS last_name,
|
||||||
COALESCE(pm.total_charges,0)::numeric(12,2) AS total_charges,
|
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_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,
|
(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,
|
pm.last_payment_date,
|
||||||
apt.last_appointment_date
|
apt.last_appointment_date,
|
||||||
|
p."createdAt" AS patient_created_at
|
||||||
FROM "Patient" p
|
FROM "Patient" p
|
||||||
LEFT JOIN ${pmSubquery} ON pm.patient_id = p.id
|
LEFT JOIN ${pmSubquery} ON pm.patient_id = p.id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@@ -150,120 +208,64 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
FROM "Appointment"
|
FROM "Appointment"
|
||||||
GROUP BY "patientId"
|
GROUP BY "patientId"
|
||||||
) apt ON apt.patient_id = p.id
|
) 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
|
const query = `
|
||||||
? `${baseQuery} WHERE ${balanceWhere} ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC LIMIT ${safeLimit} OFFSET ${safeOffset}`
|
${baseSelect}
|
||||||
: `${baseQuery} ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC LIMIT ${safeLimit} OFFSET ${safeOffset}`;
|
${cursor ? keysetPredicate : ""}
|
||||||
|
${orderBy}
|
||||||
|
${limitClause};
|
||||||
|
`;
|
||||||
|
|
||||||
// Execute query
|
const rows = (await prisma.$queryRawUnsafe(query)) as RawRow[];
|
||||||
const rows = (await prisma.$queryRawUnsafe(finalQuery)) as RawRow[];
|
|
||||||
|
|
||||||
// totalCount — count distinct patients that have payments in the date window (and if minBalanceOnly, only those with positive balance)
|
// Build nextCursor from last returned row (if any)
|
||||||
let countSql = "";
|
let nextCursor: string | null = null;
|
||||||
if (hasFrom && hasTo) {
|
|
||||||
if (minBalanceOnly) {
|
// Explicitly handle empty result set
|
||||||
countSql = `
|
if (rows.length === 0) {
|
||||||
SELECT COUNT(*)::int AS cnt FROM (
|
nextCursor = null;
|
||||||
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
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (minBalanceOnly) {
|
// rows.length > 0 here, but do an explicit last-check to make TS happy
|
||||||
countSql = `
|
const last = rows[rows.length - 1];
|
||||||
SELECT COUNT(*)::int AS cnt FROM (
|
if (!last) {
|
||||||
SELECT pay."patientId" AS patient_id,
|
// defensive — should not happen, but satisfies strict checks
|
||||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
nextCursor = null;
|
||||||
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
|
|
||||||
`;
|
|
||||||
} else {
|
} 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 {
|
// Determine hasMore: if we returned exactly limit, there *may* be more.
|
||||||
cnt: number;
|
const hasMore = rows.length === safeLimit;
|
||||||
}[];
|
|
||||||
const totalCount = cntRows?.[0]?.cnt ?? 0;
|
|
||||||
|
|
||||||
|
// Convert rows to PatientBalanceRow
|
||||||
const balances: PatientBalanceRow[] = rows.map((r) => ({
|
const balances: PatientBalanceRow[] = rows.map((r) => ({
|
||||||
patientId: Number(r.patient_id),
|
patientId: Number(r.patient_id),
|
||||||
firstName: r.first_name,
|
firstName: r.first_name,
|
||||||
lastName: r.last_name,
|
lastName: r.last_name,
|
||||||
totalCharges: Number(r.total_charges ?? 0),
|
totalCharges: Number(r.total_charges ?? 0),
|
||||||
totalPayments: Number(r.total_payments ?? 0),
|
totalPayments: Number(r.total_paid ?? 0),
|
||||||
totalAdjusted: Number(r.total_adjusted ?? 0),
|
totalAdjusted: Number(r.total_adjusted ?? 0),
|
||||||
currentBalance: Number(r.current_balance ?? 0),
|
currentBalance: Number(r.current_balance ?? 0),
|
||||||
lastPaymentDate: r.last_payment_date
|
lastPaymentDate: r.last_payment_date
|
||||||
@@ -272,9 +274,35 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
lastAppointmentDate: r.last_appointment_date
|
lastAppointmentDate: r.last_appointment_date
|
||||||
? new Date(r.last_appointment_date).toISOString()
|
? new Date(r.last_appointment_date).toISOString()
|
||||||
: null,
|
: 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) {
|
} catch (err) {
|
||||||
console.error("[paymentsReportsStorage.getPatientBalances] error:", err);
|
console.error("[paymentsReportsStorage.getPatientBalances] error:", err);
|
||||||
throw 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 React, { useState } from "react";
|
||||||
import { useState } from "react";
|
import { Download } from "lucide-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 { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { apiRequest } from "@/lib/queryClient"; // <<-- your helper
|
import ReportConfig from "@/components/reports/report-config";
|
||||||
import type { PatientBalanceRow } from "@repo/db/types";
|
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 =
|
type ReportType =
|
||||||
| "patients_with_balance"
|
| "patients_with_balance"
|
||||||
@@ -37,26 +17,16 @@ type ReportType =
|
|||||||
| "insurance_vs_patient_payments"
|
| "insurance_vs_patient_payments"
|
||||||
| "aging_report";
|
| "aging_report";
|
||||||
|
|
||||||
interface PatientBalancesResponse {
|
export default function ReportPage() {
|
||||||
balances: PatientBalanceRow[];
|
|
||||||
totalCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReportsPage() {
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// pagination state for patient balances
|
const [startDate, setStartDate] = useState<string>(() => {
|
||||||
const [balancesPage, setBalancesPage] = useState<number>(1);
|
|
||||||
const balancesPerPage = 10;
|
|
||||||
|
|
||||||
// date range state (for dashboard summary)
|
|
||||||
const [startDate, setStartDate] = useState(() => {
|
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setMonth(d.getMonth() - 1);
|
d.setMonth(d.getMonth() - 1);
|
||||||
return d.toISOString().split("T")[0];
|
return d.toISOString().split("T")[0] ?? "";
|
||||||
});
|
});
|
||||||
const [endDate, setEndDate] = useState(
|
const [endDate, setEndDate] = useState<string>(
|
||||||
() => new Date().toISOString().split("T")[0]
|
() => new Date().toISOString().split("T")[0] ?? ""
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectedReportType, setSelectedReportType] = useState<ReportType>(
|
const [selectedReportType, setSelectedReportType] = useState<ReportType>(
|
||||||
@@ -64,413 +34,73 @@ export default function ReportsPage() {
|
|||||||
);
|
);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
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 () => {
|
const generateReport = async () => {
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
await new Promise((r) => setTimeout(r, 900));
|
try {
|
||||||
setIsGenerating(false);
|
// placeholder: implement export per-report endpoint
|
||||||
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// -------------------- report rendering (only patients_with_balance wired) --------------------
|
if (!user) {
|
||||||
// -------------------- 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="text-center py-8">Please sign in to view reports.</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div>
|
<div className="container mx-auto space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
|
{/* Header Section */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
Financial Reports
|
Financial Reports
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
Generate comprehensive financial reports for your practice
|
Generate comprehensive financial reports for your practice
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Export Button (Top Right) */}
|
||||||
<Button
|
<Button
|
||||||
onClick={generateReport}
|
onClick={generateReport}
|
||||||
disabled={isGenerating}
|
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"}
|
{isGenerating ? "Generating..." : "Export Report"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="mb-4">
|
||||||
<CardHeader>
|
<ReportConfig
|
||||||
<CardTitle className="flex items-center gap-2">
|
startDate={startDate}
|
||||||
<Calendar className="h-5 w-5" />
|
endDate={endDate}
|
||||||
Report Configuration
|
setStartDate={setStartDate}
|
||||||
</CardTitle>
|
setEndDate={setEndDate}
|
||||||
</CardHeader>
|
selectedReportType={selectedReportType}
|
||||||
|
setSelectedReportType={setSelectedReportType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
{/* SINGLE authoritative SummaryCards instance for the page */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="mb-4">
|
||||||
<div className="space-y-2">
|
<SummaryCards startDate={startDate} endDate={endDate} />
|
||||||
<Label htmlFor="start-date">Start Date</Label>
|
</div>
|
||||||
<Input
|
|
||||||
id="start-date"
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<Label htmlFor="end-date">End Date</Label>
|
{selectedReportType === "patients_with_balance" && (
|
||||||
<Input
|
<PatientsWithBalanceReport startDate={startDate} endDate={endDate} />
|
||||||
id="end-date"
|
)}
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
{selectedReportType === "collections_by_doctor" && (
|
||||||
<Label htmlFor="report-type">Report Type</Label>
|
<CollectionsByDoctorReport startDate={startDate} endDate={endDate} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Separator />
|
{/* Add other report components here as needed */}
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ model Payment {
|
|||||||
|
|
||||||
@@index([claimId])
|
@@index([claimId])
|
||||||
@@index([patientId])
|
@@index([patientId])
|
||||||
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ServiceLineTransaction {
|
model ServiceLineTransaction {
|
||||||
|
|||||||
Reference in New Issue
Block a user