feat(report page) - collection by doctor - v1 -base
This commit is contained in:
@@ -37,7 +37,7 @@ router.get("/summary", async (req: Request, res: Response): Promise<any> => {
|
|||||||
* - 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(
|
||||||
"/patient-balances",
|
"/patients-with-balances",
|
||||||
async (req: Request, res: Response): Promise<any> => {
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const limit = Math.max(
|
const limit = Math.max(
|
||||||
@@ -60,7 +60,12 @@ 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(limit, cursor, from, to);
|
const data = await storage.getPatientsWithBalances(
|
||||||
|
limit,
|
||||||
|
cursor,
|
||||||
|
from,
|
||||||
|
to
|
||||||
|
);
|
||||||
// returns { balances, totalCount, nextCursor, hasMore }
|
// returns { balances, totalCount, nextCursor, hasMore }
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -74,4 +79,70 @@ router.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/payments-reports/by-doctor
|
||||||
|
* Query params:
|
||||||
|
* - staffId (required)
|
||||||
|
* - limit (optional, default 25)
|
||||||
|
* - cursor (optional)
|
||||||
|
* - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
|
||||||
|
*/
|
||||||
|
router.get("/by-doctor", async (req: Request, res: Response): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const staffIdRaw = req.query.staffId;
|
||||||
|
if (!staffIdRaw) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "Missing required 'staffId' query parameter" });
|
||||||
|
}
|
||||||
|
const staffId = Number(staffIdRaw);
|
||||||
|
if (!Number.isFinite(staffId) || staffId <= 0) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "Invalid 'staffId' query parameter" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(200, parseInt(String(req.query.limit || "25"), 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
const cursor =
|
||||||
|
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
|
||||||
|
|
||||||
|
const from = req.query.from ? new Date(String(req.query.from)) : undefined;
|
||||||
|
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
|
||||||
|
|
||||||
|
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
|
||||||
|
return res.status(400).json({ message: "Invalid 'from' date" });
|
||||||
|
}
|
||||||
|
if (req.query.to && isNaN(to?.getTime() ?? NaN)) {
|
||||||
|
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await storage.getBalancesAndSummaryByDoctor(
|
||||||
|
staffId,
|
||||||
|
limit,
|
||||||
|
cursor,
|
||||||
|
from,
|
||||||
|
to
|
||||||
|
);
|
||||||
|
// data expected: { balances, totalCount, nextCursor, hasMore, summary }
|
||||||
|
res.json(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(
|
||||||
|
"GET /api/payments-reports/by-doctor error:",
|
||||||
|
err?.message ?? err,
|
||||||
|
err?.stack
|
||||||
|
);
|
||||||
|
// If prisma errors, return 500 with message for debugging (strip sensitive info in prod)
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({
|
||||||
|
message: "Failed to fetch doctor balances and summary",
|
||||||
|
detail: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
// apps/Backend/src/storage/payments-reports-storage.ts
|
|
||||||
import { prisma } from "@repo/db/client";
|
import { prisma } from "@repo/db/client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Row returned to the client
|
|
||||||
*/
|
|
||||||
export interface PatientBalanceRow {
|
export interface PatientBalanceRow {
|
||||||
patientId: number;
|
patientId: number;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
@@ -24,20 +20,20 @@ export interface GetPatientBalancesResult {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPaymentsReportsStorage {
|
export interface DoctorBalancesAndSummary {
|
||||||
/**
|
balances: PatientBalanceRow[];
|
||||||
* Cursor-based pagination:
|
totalCount: number;
|
||||||
* - limit: page size
|
nextCursor: string | null;
|
||||||
* - cursorToken: base64(JSON) token for last-seen row (or null for first page)
|
hasMore: boolean;
|
||||||
* - from/to: optional date range filter applied to Payment."createdAt"
|
summary: {
|
||||||
*/
|
totalPatients: number;
|
||||||
getPatientBalances(
|
totalOutstanding: number;
|
||||||
limit: number,
|
totalCollected: number;
|
||||||
cursorToken?: string | null,
|
patientsWithBalance: number;
|
||||||
from?: Date | null,
|
};
|
||||||
to?: Date | null
|
}
|
||||||
): Promise<GetPatientBalancesResult>;
|
|
||||||
|
|
||||||
|
export interface IPaymentsReportsStorage {
|
||||||
// summary now returns an extra field patientsWithBalance
|
// summary now returns an extra field patientsWithBalance
|
||||||
getSummary(
|
getSummary(
|
||||||
from?: Date | null,
|
from?: Date | null,
|
||||||
@@ -48,6 +44,35 @@ export interface IPaymentsReportsStorage {
|
|||||||
totalCollected: number;
|
totalCollected: number;
|
||||||
patientsWithBalance: number;
|
patientsWithBalance: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
*/
|
||||||
|
getPatientsWithBalances(
|
||||||
|
limit: number,
|
||||||
|
cursorToken?: string | null,
|
||||||
|
from?: Date | null,
|
||||||
|
to?: Date | null
|
||||||
|
): Promise<GetPatientBalancesResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-query approach: returns both page of patient balances for the staff and a summary
|
||||||
|
*
|
||||||
|
* - staffId required
|
||||||
|
* - limit: page size
|
||||||
|
* - cursorToken: optional base64 cursor (must have been produced for same staffId)
|
||||||
|
* - from/to: optional date range applied to Payment."createdAt"
|
||||||
|
*/
|
||||||
|
getBalancesAndSummaryByDoctor(
|
||||||
|
staffId: number,
|
||||||
|
limit: number,
|
||||||
|
cursorToken?: string | null,
|
||||||
|
from?: Date | null,
|
||||||
|
to?: Date | null
|
||||||
|
): Promise<DoctorBalancesAndSummary>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper: format Date -> SQL literal 'YYYY-MM-DDTHH:mm:ss.sssZ' or null */
|
/** Helper: format Date -> SQL literal 'YYYY-MM-DDTHH:mm:ss.sssZ' or null */
|
||||||
@@ -59,6 +84,7 @@ function fmtDateLiteral(d?: Date | null): string | null {
|
|||||||
|
|
||||||
/** Cursor helpers — base64(JSON) */
|
/** Cursor helpers — base64(JSON) */
|
||||||
function encodeCursor(obj: {
|
function encodeCursor(obj: {
|
||||||
|
staffId?: number;
|
||||||
lastPaymentDate: string | null;
|
lastPaymentDate: string | null;
|
||||||
lastPatientCreatedAt: string; // ISO string
|
lastPatientCreatedAt: string; // ISO string
|
||||||
lastPatientId: number;
|
lastPatientId: number;
|
||||||
@@ -67,6 +93,7 @@ function encodeCursor(obj: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decodeCursor(token?: string | null): {
|
function decodeCursor(token?: string | null): {
|
||||||
|
staffId?: number; // optional because older cursors might not include it
|
||||||
lastPaymentDate: string | null;
|
lastPaymentDate: string | null;
|
||||||
lastPatientCreatedAt: string;
|
lastPatientCreatedAt: string;
|
||||||
lastPatientId: number;
|
lastPatientId: number;
|
||||||
@@ -81,12 +108,14 @@ function decodeCursor(token?: string | null): {
|
|||||||
"lastPatientId" in parsed
|
"lastPatientId" in parsed
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
staffId:
|
||||||
|
"staffId" in parsed ? Number((parsed as any).staffId) : undefined,
|
||||||
lastPaymentDate:
|
lastPaymentDate:
|
||||||
parsed.lastPaymentDate === null
|
(parsed as any).lastPaymentDate === null
|
||||||
? null
|
? null
|
||||||
: String(parsed.lastPaymentDate),
|
: String((parsed as any).lastPaymentDate),
|
||||||
lastPatientCreatedAt: String(parsed.lastPatientCreatedAt),
|
lastPatientCreatedAt: String((parsed as any).lastPatientCreatedAt),
|
||||||
lastPatientId: Number(parsed.lastPatientId),
|
lastPatientId: Number((parsed as any).lastPatientId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -96,14 +125,212 @@ function decodeCursor(token?: string | null): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||||
|
async getSummary(from?: Date | null, to?: Date | null) {
|
||||||
|
try {
|
||||||
|
const hasFrom = from !== undefined && from !== null;
|
||||||
|
const hasTo = to !== undefined && to !== null;
|
||||||
|
const fromLit = fmtDateLiteral(from);
|
||||||
|
const toLit = fmtDateLiteral(to);
|
||||||
|
|
||||||
|
// totalPatients: distinct patients who had payments in the date range
|
||||||
|
let patientsCountSql = "";
|
||||||
|
if (hasFrom && hasTo) {
|
||||||
|
patientsCountSql = `
|
||||||
|
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) {
|
||||||
|
patientsCountSql = `
|
||||||
|
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) {
|
||||||
|
patientsCountSql = `
|
||||||
|
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 {
|
||||||
|
patientsCountSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`;
|
||||||
|
}
|
||||||
|
const patientsCntRows = (await prisma.$queryRawUnsafe(
|
||||||
|
patientsCountSql
|
||||||
|
)) as { cnt: number }[];
|
||||||
|
const totalPatients = patientsCntRows?.[0]?.cnt ?? 0;
|
||||||
|
|
||||||
|
// totalOutstanding: sum of (charges - paid - adjusted) across patients, using payments in range
|
||||||
|
let outstandingSql = "";
|
||||||
|
if (hasFrom && hasTo) {
|
||||||
|
outstandingSql = `
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
||||||
|
),0)::numeric(14,2) AS outstanding
|
||||||
|
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"
|
||||||
|
) pm
|
||||||
|
`;
|
||||||
|
} else if (hasFrom) {
|
||||||
|
outstandingSql = `
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
||||||
|
),0)::numeric(14,2) AS outstanding
|
||||||
|
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"
|
||||||
|
) pm
|
||||||
|
`;
|
||||||
|
} else if (hasTo) {
|
||||||
|
outstandingSql = `
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
||||||
|
),0)::numeric(14,2) AS outstanding
|
||||||
|
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"
|
||||||
|
) pm
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
outstandingSql = `
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
||||||
|
),0)::numeric(14,2) AS outstanding
|
||||||
|
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"
|
||||||
|
) pm
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const outstandingRows = (await prisma.$queryRawUnsafe(
|
||||||
|
outstandingSql
|
||||||
|
)) as { outstanding: string }[];
|
||||||
|
const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0);
|
||||||
|
|
||||||
|
// totalCollected: sum(totalPaid) in the range
|
||||||
|
let collSql = "";
|
||||||
|
if (hasFrom && hasTo) {
|
||||||
|
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit} AND "createdAt" <= ${toLit}`;
|
||||||
|
} else if (hasFrom) {
|
||||||
|
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit}`;
|
||||||
|
} else if (hasTo) {
|
||||||
|
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toLit}`;
|
||||||
|
} else {
|
||||||
|
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment"`;
|
||||||
|
}
|
||||||
|
const collRows = (await prisma.$queryRawUnsafe(collSql)) as {
|
||||||
|
collected: string;
|
||||||
|
}[];
|
||||||
|
const totalCollected = Number(collRows?.[0]?.collected ?? 0);
|
||||||
|
|
||||||
|
// NEW: patientsWithBalance: number of patients whose (charges - paid - adjusted) > 0, within the date range
|
||||||
|
let patientsWithBalanceSql = "";
|
||||||
|
if (hasFrom && hasTo) {
|
||||||
|
patientsWithBalanceSql = `
|
||||||
|
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 if (hasFrom) {
|
||||||
|
patientsWithBalanceSql = `
|
||||||
|
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 if (hasTo) {
|
||||||
|
patientsWithBalanceSql = `
|
||||||
|
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 {
|
||||||
|
patientsWithBalanceSql = `
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const pwbRows = (await prisma.$queryRawUnsafe(
|
||||||
|
patientsWithBalanceSql
|
||||||
|
)) as { cnt: number }[];
|
||||||
|
const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPatients,
|
||||||
|
totalOutstanding,
|
||||||
|
totalCollected,
|
||||||
|
patientsWithBalance,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[paymentsReportsStorage.getSummary] error:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all patients that currently have an outstanding balance (>0)
|
* Returns all patients that currently have an outstanding balance (>0)
|
||||||
* Optionally filtered by date range.
|
* Optionally filtered by date range.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Cursor-based getPatientBalances
|
* Cursor-based getPatientsWithBalances
|
||||||
*/
|
*/
|
||||||
async getPatientBalances(
|
async getPatientsWithBalances(
|
||||||
limit = 25,
|
limit = 25,
|
||||||
cursorToken?: string | null,
|
cursorToken?: string | null,
|
||||||
from?: Date | null,
|
from?: Date | null,
|
||||||
@@ -309,201 +536,244 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSummary(from?: Date | null, to?: Date | null) {
|
async getBalancesAndSummaryByDoctor(
|
||||||
try {
|
staffId: number,
|
||||||
const hasFrom = from !== undefined && from !== null;
|
limit = 25,
|
||||||
const hasTo = to !== undefined && to !== null;
|
cursorToken?: string | null,
|
||||||
const fromLit = fmtDateLiteral(from);
|
from?: Date | null,
|
||||||
const toLit = fmtDateLiteral(to);
|
to?: Date | null
|
||||||
|
): Promise<DoctorBalancesAndSummary> {
|
||||||
// totalPatients: distinct patients who had payments in the date range
|
if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) {
|
||||||
let patientsCountSql = "";
|
throw new Error("Invalid staffId");
|
||||||
if (hasFrom && hasTo) {
|
|
||||||
patientsCountSql = `
|
|
||||||
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) {
|
|
||||||
patientsCountSql = `
|
|
||||||
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) {
|
|
||||||
patientsCountSql = `
|
|
||||||
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 {
|
|
||||||
patientsCountSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`;
|
|
||||||
}
|
|
||||||
const patientsCntRows = (await prisma.$queryRawUnsafe(
|
|
||||||
patientsCountSql
|
|
||||||
)) as { cnt: number }[];
|
|
||||||
const totalPatients = patientsCntRows?.[0]?.cnt ?? 0;
|
|
||||||
|
|
||||||
// totalOutstanding: sum of (charges - paid - adjusted) across patients, using payments in range
|
|
||||||
let outstandingSql = "";
|
|
||||||
if (hasFrom && hasTo) {
|
|
||||||
outstandingSql = `
|
|
||||||
SELECT COALESCE(SUM(
|
|
||||||
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
|
||||||
),0)::numeric(14,2) AS outstanding
|
|
||||||
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"
|
|
||||||
) pm
|
|
||||||
`;
|
|
||||||
} else if (hasFrom) {
|
|
||||||
outstandingSql = `
|
|
||||||
SELECT COALESCE(SUM(
|
|
||||||
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
|
||||||
),0)::numeric(14,2) AS outstanding
|
|
||||||
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"
|
|
||||||
) pm
|
|
||||||
`;
|
|
||||||
} else if (hasTo) {
|
|
||||||
outstandingSql = `
|
|
||||||
SELECT COALESCE(SUM(
|
|
||||||
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
|
||||||
),0)::numeric(14,2) AS outstanding
|
|
||||||
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"
|
|
||||||
) pm
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
outstandingSql = `
|
|
||||||
SELECT COALESCE(SUM(
|
|
||||||
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
|
||||||
),0)::numeric(14,2) AS outstanding
|
|
||||||
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"
|
|
||||||
) pm
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
const outstandingRows = (await prisma.$queryRawUnsafe(
|
|
||||||
outstandingSql
|
|
||||||
)) as { outstanding: string }[];
|
|
||||||
const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0);
|
|
||||||
|
|
||||||
// totalCollected: sum(totalPaid) in the range
|
|
||||||
let collSql = "";
|
|
||||||
if (hasFrom && hasTo) {
|
|
||||||
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit} AND "createdAt" <= ${toLit}`;
|
|
||||||
} else if (hasFrom) {
|
|
||||||
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit}`;
|
|
||||||
} else if (hasTo) {
|
|
||||||
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toLit}`;
|
|
||||||
} else {
|
|
||||||
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment"`;
|
|
||||||
}
|
|
||||||
const collRows = (await prisma.$queryRawUnsafe(collSql)) as {
|
|
||||||
collected: string;
|
|
||||||
}[];
|
|
||||||
const totalCollected = Number(collRows?.[0]?.collected ?? 0);
|
|
||||||
|
|
||||||
// NEW: patientsWithBalance: number of patients whose (charges - paid - adjusted) > 0, within the date range
|
|
||||||
let patientsWithBalanceSql = "";
|
|
||||||
if (hasFrom && hasTo) {
|
|
||||||
patientsWithBalanceSql = `
|
|
||||||
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 if (hasFrom) {
|
|
||||||
patientsWithBalanceSql = `
|
|
||||||
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 if (hasTo) {
|
|
||||||
patientsWithBalanceSql = `
|
|
||||||
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 {
|
|
||||||
patientsWithBalanceSql = `
|
|
||||||
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
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
const pwbRows = (await prisma.$queryRawUnsafe(
|
|
||||||
patientsWithBalanceSql
|
|
||||||
)) as { cnt: number }[];
|
|
||||||
const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalPatients,
|
|
||||||
totalOutstanding,
|
|
||||||
totalCollected,
|
|
||||||
patientsWithBalance,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[paymentsReportsStorage.getSummary] error:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
||||||
|
const decoded = decodeCursor(cursorToken);
|
||||||
|
|
||||||
|
// Accept older cursors that didn't include staffId; if cursor has staffId and it doesn't match, ignore.
|
||||||
|
const effectiveCursor =
|
||||||
|
decoded &&
|
||||||
|
typeof decoded.staffId === "number" &&
|
||||||
|
decoded.staffId === Number(staffId)
|
||||||
|
? decoded
|
||||||
|
: decoded && typeof decoded.staffId === "undefined"
|
||||||
|
? decoded
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const hasFrom = from !== undefined && from !== null;
|
||||||
|
const hasTo = to !== undefined && to !== null;
|
||||||
|
const fromLit = fmtDateLiteral(from);
|
||||||
|
const toLit = fmtDateLiteral(to);
|
||||||
|
|
||||||
|
// Filter payments by createdAt (time window) when provided
|
||||||
|
const paymentTimeFilter =
|
||||||
|
hasFrom && hasTo
|
||||||
|
? `AND pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}`
|
||||||
|
: hasFrom
|
||||||
|
? `AND pay."createdAt" >= ${fromLit}`
|
||||||
|
: hasTo
|
||||||
|
? `AND pay."createdAt" <= ${toLit}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Keyset predicate must use columns present in the 'patients' CTE rows (alias p).
|
||||||
|
// We'll compare p.last_payment_date, p.patient_created_at and p.id
|
||||||
|
let pageKeysetPredicate = "";
|
||||||
|
if (effectiveCursor) {
|
||||||
|
const lp = effectiveCursor.lastPaymentDate
|
||||||
|
? `'${effectiveCursor.lastPaymentDate}'`
|
||||||
|
: "NULL";
|
||||||
|
const lc = `'${effectiveCursor.lastPatientCreatedAt}'`;
|
||||||
|
const id = Number(effectiveCursor.lastPatientId);
|
||||||
|
|
||||||
|
pageKeysetPredicate = `AND (
|
||||||
|
(p.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND (
|
||||||
|
p.last_payment_date < ${lp}
|
||||||
|
OR (p.last_payment_date = ${lp} AND p.patient_created_at < ${lc})
|
||||||
|
OR (p.last_payment_date = ${lp} AND p.patient_created_at = ${lc} AND p.id < ${id})
|
||||||
|
))
|
||||||
|
OR (p.last_payment_date IS NULL AND ${lp} IS NULL AND (
|
||||||
|
p.patient_created_at < ${lc}
|
||||||
|
OR (p.patient_created_at = ${lc} AND p.id < ${id})
|
||||||
|
))
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a time window is provided, we want the patient rows to be restricted to patients who have
|
||||||
|
// payments in that window (and those payments must be linked to claims with this staffId).
|
||||||
|
// When no time-window provided, we want all patients who have appointments with this staff.
|
||||||
|
// We'll implement that by conditionally INNER JOINing payments_agg in the patients listing when window exists.
|
||||||
|
const paymentsJoinForPatients =
|
||||||
|
hasFrom || hasTo
|
||||||
|
? "INNER JOIN payments_agg pa ON pa.patient_id = p.id"
|
||||||
|
: "LEFT JOIN payments_agg pa ON pa.patient_id = p.id";
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
WITH
|
||||||
|
-- patients that have at least one appointment with this staff (roster)
|
||||||
|
staff_patients AS (
|
||||||
|
SELECT DISTINCT "patientId" AS patient_id
|
||||||
|
FROM "Appointment"
|
||||||
|
WHERE "staffId" = ${Number(staffId)}
|
||||||
|
),
|
||||||
|
|
||||||
|
-- aggregate payments, but only payments that link to Claims with this staffId,
|
||||||
|
-- and additionally restricted by the optional time window (paymentTimeFilter)
|
||||||
|
payments_agg AS (
|
||||||
|
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,
|
||||||
|
MAX(pay."createdAt") AS last_payment_date
|
||||||
|
FROM "Payment" pay
|
||||||
|
JOIN "Claim" c ON pay."claimId" = c.id
|
||||||
|
WHERE c."staffId" = ${Number(staffId)}
|
||||||
|
${paymentTimeFilter}
|
||||||
|
GROUP BY pay."patientId"
|
||||||
|
),
|
||||||
|
|
||||||
|
last_appointments AS (
|
||||||
|
SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date
|
||||||
|
FROM "Appointment"
|
||||||
|
GROUP BY "patientId"
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Build the patient rows. If window provided we INNER JOIN payments_agg (so only patients with payments in window)
|
||||||
|
patients AS (
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p."firstName" AS first_name,
|
||||||
|
p."lastName" AS last_name,
|
||||||
|
COALESCE(pa.total_charges, 0)::numeric(14,2) AS total_charges,
|
||||||
|
COALESCE(pa.total_paid, 0)::numeric(14,2) AS total_paid,
|
||||||
|
COALESCE(pa.total_adjusted, 0)::numeric(14,2) AS total_adjusted,
|
||||||
|
(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0))::numeric(14,2) AS current_balance,
|
||||||
|
pa.last_payment_date,
|
||||||
|
la.last_appointment_date,
|
||||||
|
p."createdAt" AS patient_created_at
|
||||||
|
FROM "Patient" p
|
||||||
|
INNER JOIN staff_patients sp ON sp.patient_id = p.id
|
||||||
|
${paymentsJoinForPatients}
|
||||||
|
LEFT JOIN last_appointments la ON la.patient_id = p.id
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
-- page rows as JSON array
|
||||||
|
(SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM (
|
||||||
|
SELECT
|
||||||
|
p.id AS "patientId",
|
||||||
|
p.first_name AS "firstName",
|
||||||
|
p.last_name AS "lastName",
|
||||||
|
p.total_charges::text AS "totalCharges",
|
||||||
|
p.total_paid::text AS "totalPaid",
|
||||||
|
p.total_adjusted::text AS "totalAdjusted",
|
||||||
|
p.current_balance::text AS "currentBalance",
|
||||||
|
p.last_payment_date AS "lastPaymentDate",
|
||||||
|
p.last_appointment_date AS "lastAppointmentDate",
|
||||||
|
p.patient_created_at AS "patientCreatedAt"
|
||||||
|
FROM patients p
|
||||||
|
WHERE 1=1
|
||||||
|
${pageKeysetPredicate}
|
||||||
|
ORDER BY p.last_payment_date DESC NULLS LAST, p.patient_created_at DESC, p.id DESC
|
||||||
|
LIMIT ${safeLimit}
|
||||||
|
) t) AS balances_json,
|
||||||
|
|
||||||
|
-- total_count: when window provided, count distinct patients that have payments in the window (payments_agg),
|
||||||
|
-- otherwise count all staff_patients
|
||||||
|
(CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN
|
||||||
|
(SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa)
|
||||||
|
ELSE
|
||||||
|
(SELECT COUNT(*)::int FROM staff_patients)
|
||||||
|
END) AS total_count,
|
||||||
|
|
||||||
|
-- summary: computed from payments_agg (already filtered by staffId and time window)
|
||||||
|
(
|
||||||
|
SELECT json_build_object(
|
||||||
|
'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0),
|
||||||
|
'totalOutstanding', COALESCE(SUM(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)),0)::text,
|
||||||
|
'totalCollected', COALESCE(SUM(COALESCE(pa.total_paid,0)),0)::text,
|
||||||
|
'patientsWithBalance', COALESCE(SUM(CASE WHEN (COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)) > 0 THEN 1 ELSE 0 END),0)
|
||||||
|
)
|
||||||
|
FROM payments_agg pa
|
||||||
|
) AS summary_json
|
||||||
|
;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rawRows = (await prisma.$queryRawUnsafe(sql)) as Array<{
|
||||||
|
balances_json?: any;
|
||||||
|
total_count?: number;
|
||||||
|
summary_json?: any;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const firstRow =
|
||||||
|
Array.isArray(rawRows) && rawRows.length > 0 ? rawRows[0] : undefined;
|
||||||
|
|
||||||
|
if (!firstRow) {
|
||||||
|
return {
|
||||||
|
balances: [],
|
||||||
|
totalCount: 0,
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
summary: {
|
||||||
|
totalPatients: 0,
|
||||||
|
totalOutstanding: 0,
|
||||||
|
totalCollected: 0,
|
||||||
|
patientsWithBalance: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const balancesRaw = Array.isArray(firstRow.balances_json)
|
||||||
|
? firstRow.balances_json
|
||||||
|
: [];
|
||||||
|
const balances: PatientBalanceRow[] = balancesRaw.map((r: any) => ({
|
||||||
|
patientId: Number(r.patientId),
|
||||||
|
firstName: r.firstName ?? null,
|
||||||
|
lastName: r.lastName ?? null,
|
||||||
|
totalCharges: Number(r.totalCharges ?? 0),
|
||||||
|
totalPayments: Number(r.totalPaid ?? 0),
|
||||||
|
totalAdjusted: Number(r.totalAdjusted ?? 0),
|
||||||
|
currentBalance: Number(r.currentBalance ?? 0),
|
||||||
|
lastPaymentDate: r.lastPaymentDate
|
||||||
|
? new Date(r.lastPaymentDate).toISOString()
|
||||||
|
: null,
|
||||||
|
lastAppointmentDate: r.lastAppointmentDate
|
||||||
|
? new Date(r.lastAppointmentDate).toISOString()
|
||||||
|
: null,
|
||||||
|
patientCreatedAt: r.patientCreatedAt
|
||||||
|
? new Date(r.patientCreatedAt).toISOString()
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasMore = balances.length === safeLimit;
|
||||||
|
let nextCursor: string | null = null;
|
||||||
|
if (hasMore) {
|
||||||
|
const last = balances[balances.length - 1];
|
||||||
|
if (last) {
|
||||||
|
nextCursor = encodeCursor({
|
||||||
|
staffId: Number(staffId),
|
||||||
|
lastPaymentDate: last.lastPaymentDate,
|
||||||
|
lastPatientCreatedAt:
|
||||||
|
last.patientCreatedAt ?? new Date().toISOString(),
|
||||||
|
lastPatientId: Number(last.patientId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryRaw = firstRow.summary_json ?? {};
|
||||||
|
const summary = {
|
||||||
|
totalPatients: Number(summaryRaw.totalPatients ?? 0),
|
||||||
|
totalOutstanding: Number(summaryRaw.totalOutstanding ?? 0),
|
||||||
|
totalCollected: Number(summaryRaw.totalCollected ?? 0),
|
||||||
|
patientsWithBalance: Number(summaryRaw.patientsWithBalance ?? 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
balances,
|
||||||
|
totalCount: Number(firstRow.total_count ?? 0),
|
||||||
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,24 +2,51 @@ import React, { useCallback, useEffect, useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import PatientsBalancesList, { GenericRow } from "./patients-balances-list";
|
import PatientsBalancesList, { GenericRow } from "./patients-balances-list";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
type DoctorOption = { id: string; name: string };
|
type StaffOption = { id: number; name: string };
|
||||||
type DoctorCollectionRow = {
|
|
||||||
doctorId: string;
|
type BalanceRow = {
|
||||||
doctorName: string;
|
patientId: number;
|
||||||
totalCollected?: number;
|
firstName: string | null;
|
||||||
totalCharges?: number;
|
lastName: string | null;
|
||||||
totalPayments?: number;
|
totalCharges: number | string;
|
||||||
currentBalance?: number;
|
totalPaid: number | string;
|
||||||
|
totalAdjusted: number | string;
|
||||||
|
currentBalance: number | string;
|
||||||
|
lastPaymentDate: string | null;
|
||||||
|
lastAppointmentDate: string | null;
|
||||||
|
patientCreatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CollectionsResp = {
|
type CollectionsResp = {
|
||||||
rows: DoctorCollectionRow[];
|
balances: BalanceRow[];
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
nextCursor?: string | null;
|
nextCursor?: string | null;
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
|
summary?: {
|
||||||
|
totalPatients?: number;
|
||||||
|
totalOutstanding?: number;
|
||||||
|
totalCollected?: number;
|
||||||
|
patientsWithBalance?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function fmtCurrency(v: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(v);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CollectionsByDoctorReport({
|
export default function CollectionsByDoctorReport({
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -27,41 +54,41 @@ export default function CollectionsByDoctorReport({
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
}) {
|
}) {
|
||||||
const [doctorId, setDoctorId] = useState<string | "">("");
|
const [staffId, setStaffId] = useState<string>("");
|
||||||
|
|
||||||
// pagination (cursor) state
|
|
||||||
const perPage = 10;
|
const perPage = 10;
|
||||||
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
|
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
|
||||||
const [cursorIndex, setCursorIndex] = useState<number>(0);
|
const [cursorIndex, setCursorIndex] = useState<number>(0);
|
||||||
const currentCursor = cursorStack[cursorIndex] ?? null;
|
const currentCursor = cursorStack[cursorIndex] ?? null;
|
||||||
const pageIndex = cursorIndex + 1; // 1-based for UI
|
const pageIndex = cursorIndex + 1;
|
||||||
|
|
||||||
// load doctors for selector
|
// load staffs list for selector
|
||||||
const { data: doctors } = useQuery<DoctorOption[], Error>({
|
const { data: staffs } = useQuery<StaffOption[], Error>({
|
||||||
queryKey: ["doctors"],
|
queryKey: ["staffs"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiRequest("GET", "/api/doctors");
|
const res = await apiRequest("GET", "/api/staffs");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const b = await res
|
const b = await res
|
||||||
.json()
|
.json()
|
||||||
.catch(() => ({ message: "Failed to load doctors" }));
|
.catch(() => ({ message: "Failed to load staffs" }));
|
||||||
throw new Error(b.message || "Failed to load doctors");
|
throw new Error(b.message || "Failed to load staffs");
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// rows (collections by doctor) - cursor-based request
|
// query balances+summary by doctor
|
||||||
const {
|
const {
|
||||||
data: collectionData,
|
data: collectionData,
|
||||||
isLoading: isLoadingRows,
|
isLoading: isLoadingRows,
|
||||||
isError: isErrorRows,
|
isError: isErrorRows,
|
||||||
refetch,
|
refetch,
|
||||||
|
isFetching,
|
||||||
} = useQuery<CollectionsResp, Error>({
|
} = useQuery<CollectionsResp, Error>({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"collections-by-doctor-rows",
|
"collections-by-doctor-rows",
|
||||||
doctorId,
|
staffId,
|
||||||
currentCursor,
|
currentCursor,
|
||||||
perPage,
|
perPage,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -71,13 +98,13 @@ export default function CollectionsByDoctorReport({
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("limit", String(perPage));
|
params.set("limit", String(perPage));
|
||||||
if (currentCursor) params.set("cursor", currentCursor);
|
if (currentCursor) params.set("cursor", currentCursor);
|
||||||
if (doctorId) params.set("doctorId", doctorId);
|
if (staffId) params.set("staffId", staffId);
|
||||||
if (startDate) params.set("from", startDate);
|
if (startDate) params.set("from", startDate);
|
||||||
if (endDate) params.set("to", endDate);
|
if (endDate) params.set("to", endDate);
|
||||||
|
|
||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"GET",
|
"GET",
|
||||||
`/api/payments-reports/collections-by-doctor?${params.toString()}`
|
`/api/payments-reports/by-doctor?${params.toString()}`
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const b = await res
|
const b = await res
|
||||||
@@ -87,95 +114,177 @@ export default function CollectionsByDoctorReport({
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
enabled: true,
|
enabled: Boolean(staffId), // only load when a doctor is selected
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// derived pagination info
|
const balances = collectionData?.balances ?? [];
|
||||||
const rows = collectionData?.rows ?? [];
|
|
||||||
const totalCount = collectionData?.totalCount ?? undefined;
|
const totalCount = collectionData?.totalCount ?? undefined;
|
||||||
const nextCursor = collectionData?.nextCursor ?? null;
|
const serverNextCursor = collectionData?.nextCursor ?? null;
|
||||||
const hasMore = collectionData?.hasMore ?? false;
|
const hasMore = collectionData?.hasMore ?? false;
|
||||||
|
const summary = collectionData?.summary ?? null;
|
||||||
|
|
||||||
// reset cursor when filters change (doctor/date)
|
// Reset pagination when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCursorStack([null]);
|
setCursorStack([null]);
|
||||||
setCursorIndex(0);
|
setCursorIndex(0);
|
||||||
refetch();
|
}, [staffId, startDate, endDate]);
|
||||||
// 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(() => {
|
const handlePrev = useCallback(() => {
|
||||||
setCursorIndex((i) => Math.max(0, i - 1));
|
setCursorIndex((i) => Math.max(0, i - 1));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Map doctor rows into GenericRow (consistent)
|
const handleNext = useCallback(() => {
|
||||||
const mapDoctorToGeneric = (r: DoctorCollectionRow): GenericRow => {
|
const idx = cursorIndex;
|
||||||
|
const isLastKnown = idx === cursorStack.length - 1;
|
||||||
|
|
||||||
|
if (isLastKnown) {
|
||||||
|
if (serverNextCursor) {
|
||||||
|
setCursorStack((s) => [...s, serverNextCursor]);
|
||||||
|
setCursorIndex((i) => i + 1);
|
||||||
|
// No manual refetch — the queryKey depends on currentCursor and React Query will fetch automatically.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCursorIndex((i) => i + 1);
|
||||||
|
}
|
||||||
|
}, [cursorIndex, cursorStack.length, serverNextCursor]);
|
||||||
|
|
||||||
|
// Map server rows to GenericRow
|
||||||
|
const genericRows: GenericRow[] = balances.map((r) => {
|
||||||
const totalCharges = Number(r.totalCharges ?? 0);
|
const totalCharges = Number(r.totalCharges ?? 0);
|
||||||
const totalPayments = Number(r.totalCollected ?? r.totalPayments ?? 0);
|
const totalPayments = Number(r.totalPaid ?? 0);
|
||||||
|
const currentBalance = Number(r.currentBalance ?? 0);
|
||||||
|
const name = `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || "Unknown";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: r.doctorId,
|
id: String(r.patientId),
|
||||||
name: r.doctorName,
|
name,
|
||||||
currentBalance: 0,
|
currentBalance,
|
||||||
totalCharges,
|
totalCharges,
|
||||||
totalPayments,
|
totalPayments,
|
||||||
};
|
};
|
||||||
};
|
});
|
||||||
|
|
||||||
const genericRows: GenericRow[] = rows.map(mapDoctorToGeneric);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-gray-700 block mb-1">Doctor</label>
|
<label className="text-sm text-gray-700 block mb-1">Doctor</label>
|
||||||
<select
|
<Select
|
||||||
value={doctorId}
|
value={staffId || undefined}
|
||||||
onChange={(e) => setDoctorId(e.target.value)}
|
onValueChange={(v) => setStaffId(v)}
|
||||||
className="w-full border rounded px-2 py-1"
|
|
||||||
>
|
>
|
||||||
<option value="">All doctors</option>
|
<SelectTrigger className="w-full">
|
||||||
{doctors?.map((d) => (
|
<SelectValue placeholder="Select a doctor" />
|
||||||
<option key={d.id} value={d.id}>
|
</SelectTrigger>
|
||||||
{d.name}
|
|
||||||
</option>
|
<SelectContent>
|
||||||
))}
|
<SelectGroup>
|
||||||
</select>
|
{staffs?.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PatientsBalancesList
|
{/* Summary card (time-window based) */}
|
||||||
rows={genericRows}
|
{staffId && (
|
||||||
reportType="collections_by_doctor"
|
<div className="mb-4">
|
||||||
loading={isLoadingRows}
|
<Card className="pt-4 pb-4">
|
||||||
error={
|
<CardContent>
|
||||||
isErrorRows
|
<div className="mb-3 flex items-center justify-between">
|
||||||
? "Failed to load collections for the selected doctor/date range."
|
<div>
|
||||||
: false
|
<h2 className="text-base font-semibold text-gray-800">
|
||||||
}
|
Doctor summary
|
||||||
emptyMessage="No collection data for the selected doctor/date range."
|
</h2>
|
||||||
// cursor props (cursor-only approach)
|
<p className="text-sm text-gray-500">
|
||||||
pageIndex={pageIndex}
|
Data covers the selected time frame
|
||||||
perPage={perPage}
|
</p>
|
||||||
total={totalCount}
|
</div>
|
||||||
onPrev={handlePrev}
|
</div>
|
||||||
onNext={handleNext}
|
|
||||||
hasPrev={cursorIndex > 0}
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
|
||||||
hasNext={hasMore}
|
<div className="text-center">
|
||||||
/>
|
<div className="text-lg font-semibold text-blue-600">
|
||||||
|
{summary ? Number(summary.totalPatients ?? 0) : "—"}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Total Patients (in window)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-semibold text-red-600">
|
||||||
|
{summary ? Number(summary.patientsWithBalance ?? 0) : "—"}
|
||||||
|
</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">
|
||||||
|
{summary
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Number(summary.totalPatients ?? 0) -
|
||||||
|
Number(summary.patientsWithBalance ?? 0)
|
||||||
|
)
|
||||||
|
: "—"}
|
||||||
|
</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">
|
||||||
|
{summary
|
||||||
|
? fmtCurrency(Number(summary.totalOutstanding ?? 0))
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">Outstanding</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-semibold text-purple-600">
|
||||||
|
{summary
|
||||||
|
? fmtCurrency(Number(summary.totalCollected ?? 0))
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">Collected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List (shows all patients under doctor but per-row totals are time-filtered) */}
|
||||||
|
{!staffId ? (
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Please select a doctor to load collections.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PatientsBalancesList
|
||||||
|
rows={genericRows}
|
||||||
|
reportType="collections_by_doctor"
|
||||||
|
loading={isLoadingRows || isFetching}
|
||||||
|
error={
|
||||||
|
isErrorRows
|
||||||
|
? "Failed to load collections for the selected doctor/date range."
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
emptyMessage="No collection data for the selected doctor/date range."
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
perPage={perPage}
|
||||||
|
total={totalCount}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
onNext={handleNext}
|
||||||
|
hasPrev={cursorIndex > 0}
|
||||||
|
hasNext={hasMore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function PatientsBalancesList({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
rows.map((r) => (
|
rows.map((r) => (
|
||||||
<div key={r.id} className="p-4 hover:bg-gray-50">
|
<div key={String(r.id)} className="p-4 hover:bg-gray-50">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-gray-900">{r.name}</h4>
|
<h4 className="font-medium text-gray-900">{r.name}</h4>
|
||||||
@@ -98,7 +98,11 @@ export default function PatientsBalancesList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-lg font-semibold text-red-600">
|
<div
|
||||||
|
className={`text-lg font-semibold ${
|
||||||
|
r.currentBalance > 0 ? "text-red-600" : "text-green-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{fmt(r.currentBalance)}
|
{fmt(r.currentBalance)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function PatientsWithBalanceReport({
|
|||||||
if (endDate) params.set("to", endDate);
|
if (endDate) params.set("to", endDate);
|
||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"GET",
|
"GET",
|
||||||
`/api/payments-reports/patient-balances?${params.toString()}`
|
`/api/payments-reports/patients-with-balances?${params.toString()}`
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res
|
const body = await res
|
||||||
|
|||||||
Reference in New Issue
Block a user