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)
|
||||
*/
|
||||
router.get(
|
||||
"/patient-balances",
|
||||
"/patients-with-balances",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const limit = Math.max(
|
||||
@@ -60,7 +60,12 @@ router.get(
|
||||
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 }
|
||||
res.json(data);
|
||||
} 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;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// apps/Backend/src/storage/payments-reports-storage.ts
|
||||
import { prisma } from "@repo/db/client";
|
||||
|
||||
/**
|
||||
* Row returned to the client
|
||||
*/
|
||||
export interface PatientBalanceRow {
|
||||
patientId: number;
|
||||
firstName: string | null;
|
||||
@@ -24,20 +20,20 @@ export interface GetPatientBalancesResult {
|
||||
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,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<GetPatientBalancesResult>;
|
||||
export interface DoctorBalancesAndSummary {
|
||||
balances: PatientBalanceRow[];
|
||||
totalCount: number;
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
summary: {
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
totalCollected: number;
|
||||
patientsWithBalance: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IPaymentsReportsStorage {
|
||||
// summary now returns an extra field patientsWithBalance
|
||||
getSummary(
|
||||
from?: Date | null,
|
||||
@@ -48,6 +44,35 @@ export interface IPaymentsReportsStorage {
|
||||
totalCollected: 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 */
|
||||
@@ -59,6 +84,7 @@ function fmtDateLiteral(d?: Date | null): string | null {
|
||||
|
||||
/** Cursor helpers — base64(JSON) */
|
||||
function encodeCursor(obj: {
|
||||
staffId?: number;
|
||||
lastPaymentDate: string | null;
|
||||
lastPatientCreatedAt: string; // ISO string
|
||||
lastPatientId: number;
|
||||
@@ -67,6 +93,7 @@ function encodeCursor(obj: {
|
||||
}
|
||||
|
||||
function decodeCursor(token?: string | null): {
|
||||
staffId?: number; // optional because older cursors might not include it
|
||||
lastPaymentDate: string | null;
|
||||
lastPatientCreatedAt: string;
|
||||
lastPatientId: number;
|
||||
@@ -81,12 +108,14 @@ function decodeCursor(token?: string | null): {
|
||||
"lastPatientId" in parsed
|
||||
) {
|
||||
return {
|
||||
staffId:
|
||||
"staffId" in parsed ? Number((parsed as any).staffId) : undefined,
|
||||
lastPaymentDate:
|
||||
parsed.lastPaymentDate === null
|
||||
(parsed as any).lastPaymentDate === null
|
||||
? null
|
||||
: String(parsed.lastPaymentDate),
|
||||
lastPatientCreatedAt: String(parsed.lastPatientCreatedAt),
|
||||
lastPatientId: Number(parsed.lastPatientId),
|
||||
: String((parsed as any).lastPaymentDate),
|
||||
lastPatientCreatedAt: String((parsed as any).lastPatientCreatedAt),
|
||||
lastPatientId: Number((parsed as any).lastPatientId),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -96,14 +125,212 @@ function decodeCursor(token?: string | null): {
|
||||
}
|
||||
|
||||
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)
|
||||
* Optionally filtered by date range.
|
||||
*/
|
||||
/**
|
||||
* Cursor-based getPatientBalances
|
||||
* Cursor-based getPatientsWithBalances
|
||||
*/
|
||||
async getPatientBalances(
|
||||
async getPatientsWithBalances(
|
||||
limit = 25,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
@@ -309,201 +536,244 @@ 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;
|
||||
async getBalancesAndSummaryByDoctor(
|
||||
staffId: number,
|
||||
limit = 25,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<DoctorBalancesAndSummary> {
|
||||
if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) {
|
||||
throw new Error("Invalid staffId");
|
||||
}
|
||||
|
||||
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 { apiRequest } from "@/lib/queryClient";
|
||||
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 DoctorCollectionRow = {
|
||||
doctorId: string;
|
||||
doctorName: string;
|
||||
totalCollected?: number;
|
||||
totalCharges?: number;
|
||||
totalPayments?: number;
|
||||
currentBalance?: number;
|
||||
type StaffOption = { id: number; name: string };
|
||||
|
||||
type BalanceRow = {
|
||||
patientId: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
totalCharges: number | string;
|
||||
totalPaid: number | string;
|
||||
totalAdjusted: number | string;
|
||||
currentBalance: number | string;
|
||||
lastPaymentDate: string | null;
|
||||
lastAppointmentDate: string | null;
|
||||
patientCreatedAt?: string | null;
|
||||
};
|
||||
|
||||
type CollectionsResp = {
|
||||
rows: DoctorCollectionRow[];
|
||||
balances: BalanceRow[];
|
||||
totalCount?: number;
|
||||
nextCursor?: string | null;
|
||||
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({
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -27,41 +54,41 @@ export default function CollectionsByDoctorReport({
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}) {
|
||||
const [doctorId, setDoctorId] = useState<string | "">("");
|
||||
const [staffId, setStaffId] = 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
|
||||
const pageIndex = cursorIndex + 1;
|
||||
|
||||
// load doctors for selector
|
||||
const { data: doctors } = useQuery<DoctorOption[], Error>({
|
||||
queryKey: ["doctors"],
|
||||
// load staffs list for selector
|
||||
const { data: staffs } = useQuery<StaffOption[], Error>({
|
||||
queryKey: ["staffs"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/doctors");
|
||||
const res = await apiRequest("GET", "/api/staffs");
|
||||
if (!res.ok) {
|
||||
const b = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to load doctors" }));
|
||||
throw new Error(b.message || "Failed to load doctors");
|
||||
.catch(() => ({ message: "Failed to load staffs" }));
|
||||
throw new Error(b.message || "Failed to load staffs");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// rows (collections by doctor) - cursor-based request
|
||||
// query balances+summary by doctor
|
||||
const {
|
||||
data: collectionData,
|
||||
isLoading: isLoadingRows,
|
||||
isError: isErrorRows,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery<CollectionsResp, Error>({
|
||||
queryKey: [
|
||||
"collections-by-doctor-rows",
|
||||
doctorId,
|
||||
staffId,
|
||||
currentCursor,
|
||||
perPage,
|
||||
startDate,
|
||||
@@ -71,13 +98,13 @@ export default function CollectionsByDoctorReport({
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(perPage));
|
||||
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 (endDate) params.set("to", endDate);
|
||||
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/payments-reports/collections-by-doctor?${params.toString()}`
|
||||
`/api/payments-reports/by-doctor?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const b = await res
|
||||
@@ -87,95 +114,177 @@ export default function CollectionsByDoctorReport({
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
enabled: true,
|
||||
enabled: Boolean(staffId), // only load when a doctor is selected
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// derived pagination info
|
||||
const rows = collectionData?.rows ?? [];
|
||||
const balances = collectionData?.balances ?? [];
|
||||
const totalCount = collectionData?.totalCount ?? undefined;
|
||||
const nextCursor = collectionData?.nextCursor ?? null;
|
||||
const serverNextCursor = collectionData?.nextCursor ?? null;
|
||||
const hasMore = collectionData?.hasMore ?? false;
|
||||
const summary = collectionData?.summary ?? null;
|
||||
|
||||
// reset cursor when filters change (doctor/date)
|
||||
// Reset pagination when filters change
|
||||
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]);
|
||||
}, [staffId, startDate, endDate]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
setCursorIndex((i) => Math.max(0, i - 1));
|
||||
}, []);
|
||||
|
||||
// Map doctor rows into GenericRow (consistent)
|
||||
const mapDoctorToGeneric = (r: DoctorCollectionRow): GenericRow => {
|
||||
const handleNext = useCallback(() => {
|
||||
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 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 {
|
||||
id: r.doctorId,
|
||||
name: r.doctorName,
|
||||
currentBalance: 0,
|
||||
id: String(r.patientId),
|
||||
name,
|
||||
currentBalance,
|
||||
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"
|
||||
<Select
|
||||
value={staffId || undefined}
|
||||
onValueChange={(v) => setStaffId(v)}
|
||||
>
|
||||
<option value="">All doctors</option>
|
||||
{doctors?.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a doctor" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{staffs?.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</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}
|
||||
/>
|
||||
{/* Summary card (time-window based) */}
|
||||
{staffId && (
|
||||
<div className="mb-4">
|
||||
<Card className="pt-4 pb-4">
|
||||
<CardContent>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-800">
|
||||
Doctor summary
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Data covers the selected time frame
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function PatientsBalancesList({
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
<h4 className="font-medium text-gray-900">{r.name}</h4>
|
||||
@@ -98,7 +98,11 @@ export default function PatientsBalancesList({
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function PatientsWithBalanceReport({
|
||||
if (endDate) params.set("to", endDate);
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/payments-reports/patient-balances?${params.toString()}`
|
||||
`/api/payments-reports/patients-with-balances?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res
|
||||
|
||||
Reference in New Issue
Block a user