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;
|
||||
|
||||
Reference in New Issue
Block a user