feat(collectionbydoctor query) - v1 done
This commit is contained in:
@@ -80,69 +80,134 @@ router.get(
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/payments-reports/by-doctor
|
||||
* GET /api/payments-reports/by-doctor/balances
|
||||
* Query params:
|
||||
* - staffId (required)
|
||||
* - limit (optional, default 25)
|
||||
* - cursor (optional)
|
||||
* - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
|
||||
*
|
||||
* Response: { balances, totalCount, nextCursor, hasMore }
|
||||
*/
|
||||
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" });
|
||||
}
|
||||
router.get(
|
||||
"/by-doctor/balances",
|
||||
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 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 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;
|
||||
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" });
|
||||
}
|
||||
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",
|
||||
// use the new storage method that returns only the paged balances
|
||||
const balancesResult = await storage.getPatientsBalancesByDoctor(
|
||||
staffId,
|
||||
limit,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
res.json({
|
||||
balances: balancesResult?.balances ?? [],
|
||||
totalCount: Number(balancesResult?.totalCount ?? 0),
|
||||
nextCursor: balancesResult?.nextCursor ?? null,
|
||||
hasMore: Boolean(balancesResult?.hasMore ?? false),
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"GET /api/payments-reports/by-doctor/balances error:",
|
||||
err?.message ?? err,
|
||||
err?.stack
|
||||
);
|
||||
res.status(500).json({
|
||||
message: "Failed to fetch doctor balances",
|
||||
detail: err?.message ?? String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/payments-reports/by-doctor/summary
|
||||
* Query params:
|
||||
* - staffId (required)
|
||||
* - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
|
||||
*
|
||||
* Response: { totalPatients, totalOutstanding, totalCollected, patientsWithBalance }
|
||||
*/
|
||||
router.get(
|
||||
"/by-doctor/summary",
|
||||
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 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" });
|
||||
}
|
||||
|
||||
// use the new storage method that returns only the summary for the staff
|
||||
const summary = await storage.getSummaryByDoctor(staffId, from, to);
|
||||
|
||||
res.json(summary);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"GET /api/payments-reports/by-doctor/summary error:",
|
||||
err?.message ?? err,
|
||||
err?.stack
|
||||
);
|
||||
res.status(500).json({
|
||||
message: "Failed to fetch doctor summary",
|
||||
detail: err?.message ?? String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { storage } from "../storage";
|
||||
import { getPatientFinancialRowsFn } from "./patients-storage";
|
||||
import { GetPatientBalancesResult } from "@repo/db/types";
|
||||
|
||||
type PatientSummaryRow = {
|
||||
patientId: number;
|
||||
@@ -20,12 +21,8 @@ export async function fetchAllPatientsWithBalances(
|
||||
const all: PatientSummaryRow[] = [];
|
||||
let cursor: string | null = null;
|
||||
while (true) {
|
||||
const page = await storage.getPatientsWithBalances(
|
||||
pageSize,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
);
|
||||
const page: GetPatientBalancesResult =
|
||||
await storage.getPatientsWithBalances(pageSize, cursor, from, to);
|
||||
if (!page) break;
|
||||
if (Array.isArray(page.balances) && page.balances.length) {
|
||||
for (const b of page.balances) {
|
||||
@@ -44,7 +41,7 @@ export async function fetchAllPatientsWithBalances(
|
||||
}
|
||||
|
||||
/**
|
||||
* Page through storage.getBalancesAndSummaryByDoctor to return full patient list for the staff.
|
||||
* Page through storage.getPatientsBalancesByDoctor to return full patient list for the staff.
|
||||
*/
|
||||
export async function fetchAllPatientsForDoctor(
|
||||
staffId: number,
|
||||
@@ -55,13 +52,14 @@ export async function fetchAllPatientsForDoctor(
|
||||
const all: PatientSummaryRow[] = [];
|
||||
let cursor: string | null = null;
|
||||
while (true) {
|
||||
const page = await storage.getBalancesAndSummaryByDoctor(
|
||||
staffId,
|
||||
pageSize,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
);
|
||||
const page: GetPatientBalancesResult =
|
||||
await storage.getPatientsBalancesByDoctor(
|
||||
staffId,
|
||||
pageSize,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
);
|
||||
if (!page) break;
|
||||
if (Array.isArray(page.balances) && page.balances.length) {
|
||||
for (const b of page.balances) {
|
||||
|
||||
@@ -31,20 +31,36 @@ export interface IPaymentsReportsStorage {
|
||||
): Promise<GetPatientBalancesResult>;
|
||||
|
||||
/**
|
||||
* One-query approach: returns both page of patient balances for the staff and a summary
|
||||
* Returns the paginated patient balances for a specific staff (doctor).
|
||||
* Same semantics / columns / ordering / cursor behavior as the previous combined function.
|
||||
*
|
||||
* - 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(
|
||||
getPatientsBalancesByDoctor(
|
||||
staffId: number,
|
||||
limit: number,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<DoctorBalancesAndSummary>;
|
||||
): Promise<GetPatientBalancesResult>;
|
||||
|
||||
/**
|
||||
* Returns only the summary object for the given staff (doctor).
|
||||
* Same summary shape as getSummary(), but scoped to claims/payments associated with the given staffId.
|
||||
*/
|
||||
getSummaryByDoctor(
|
||||
staffId: number,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
totalCollected: number;
|
||||
patientsWithBalance: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Return ISO literal for inclusive start-of-day (UTC midnight) */
|
||||
@@ -500,13 +516,21 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
}
|
||||
},
|
||||
|
||||
async getBalancesAndSummaryByDoctor(
|
||||
/**
|
||||
* Return just the paged balances for a doctor (same logic/filters as previous single-query approach)
|
||||
*/
|
||||
async getPatientsBalancesByDoctor(
|
||||
staffId: number,
|
||||
limit = 25,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<DoctorBalancesAndSummary> {
|
||||
): Promise<{
|
||||
balances: PatientBalanceRow[];
|
||||
totalCount: number;
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) {
|
||||
throw new Error("Invalid staffId");
|
||||
}
|
||||
@@ -526,8 +550,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
const hasTo = to !== undefined && to !== null;
|
||||
|
||||
// Use inclusive start-of-day for 'from' and exclusive start-of-next-day for 'to'
|
||||
const fromStart = isoStartOfDayLiteral(from); // 'YYYY-MM-DDT00:00:00.000Z'
|
||||
const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
|
||||
const fromStart = isoStartOfDayLiteral(from);
|
||||
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||
|
||||
// Filter payments by createdAt (time window) when provided
|
||||
const paymentTimeFilter =
|
||||
@@ -539,9 +563,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
? `AND pay."createdAt" <= ${toNextStart}`
|
||||
: "";
|
||||
|
||||
// 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
|
||||
|
||||
// Keyset predicate for paging (same semantics as before)
|
||||
let pageKeysetPredicate = "";
|
||||
if (effectiveCursor) {
|
||||
const lp = effectiveCursor.lastPaymentDate
|
||||
@@ -550,90 +572,77 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
const id = Number(effectiveCursor.lastPatientId);
|
||||
|
||||
pageKeysetPredicate = `AND (
|
||||
( ${lp} IS NOT NULL AND (
|
||||
(p.last_payment_date IS NOT NULL AND p.last_payment_date < ${lp})
|
||||
OR (p.last_payment_date IS NOT NULL AND p.last_payment_date = ${lp} AND p.id < ${id})
|
||||
)
|
||||
)
|
||||
OR
|
||||
( ${lp} IS NULL AND (
|
||||
p.last_payment_date IS NOT NULL
|
||||
OR (p.last_payment_date IS NULL AND p.id < ${id})
|
||||
)
|
||||
)
|
||||
)`;
|
||||
( ${lp} IS NOT NULL AND (
|
||||
(p.last_payment_date IS NOT NULL AND p.last_payment_date < ${lp})
|
||||
OR (p.last_payment_date IS NOT NULL AND p.last_payment_date = ${lp} AND p.id < ${id})
|
||||
)
|
||||
)
|
||||
OR
|
||||
( ${lp} IS NULL AND (
|
||||
p.last_payment_date IS NOT NULL
|
||||
OR (p.last_payment_date IS NULL AND p.id < ${id})
|
||||
)
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
"[getBalancesAndSummaryByDoctor] decodedCursor:",
|
||||
effectiveCursor
|
||||
);
|
||||
console.debug(
|
||||
"[getBalancesAndSummaryByDoctor] pageKeysetPredicate:",
|
||||
pageKeysetPredicate
|
||||
);
|
||||
|
||||
// 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)}
|
||||
),
|
||||
// Common CTEs (identical to previous single-query approach)
|
||||
const commonCtes = `
|
||||
WITH
|
||||
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"
|
||||
),
|
||||
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"
|
||||
),
|
||||
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
|
||||
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
|
||||
)
|
||||
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
|
||||
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 (
|
||||
// Query A: fetch the page of patient rows as JSON array
|
||||
const balancesQuery = `
|
||||
${commonCtes}
|
||||
|
||||
SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) AS balances_json FROM (
|
||||
SELECT
|
||||
p.id AS "patientId",
|
||||
p.first_name AS "firstName",
|
||||
@@ -649,57 +658,31 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
${pageKeysetPredicate}
|
||||
ORDER BY p.last_payment_date DESC NULLS LAST, p.id DESC
|
||||
LIMIT ${safeLimit}
|
||||
) t) AS balances_json,
|
||||
) t;
|
||||
`;
|
||||
|
||||
-- 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,
|
||||
// Query Count: total_count (same logic as previous combined query's CASE)
|
||||
const countQuery = `
|
||||
${commonCtes}
|
||||
|
||||
-- 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
|
||||
;
|
||||
`;
|
||||
SELECT
|
||||
(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;
|
||||
`;
|
||||
|
||||
const rawRows = (await prisma.$queryRawUnsafe(sql)) as Array<{
|
||||
balances_json?: any;
|
||||
total_count?: number;
|
||||
summary_json?: any;
|
||||
}>;
|
||||
// Execute balancesQuery
|
||||
const balancesRawRows = (await prisma.$queryRawUnsafe(
|
||||
balancesQuery
|
||||
)) as Array<{ balances_json?: any }>;
|
||||
|
||||
const firstRow =
|
||||
Array.isArray(rawRows) && rawRows.length > 0 ? rawRows[0] : undefined;
|
||||
const balancesJson = (balancesRawRows?.[0]?.balances_json as any) ?? [];
|
||||
|
||||
if (!firstRow) {
|
||||
return {
|
||||
balances: [],
|
||||
totalCount: 0,
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
summary: {
|
||||
totalPatients: 0,
|
||||
totalOutstanding: 0,
|
||||
totalCollected: 0,
|
||||
patientsWithBalance: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
const balancesArr = Array.isArray(balancesJson) ? balancesJson : [];
|
||||
|
||||
const balancesRaw = Array.isArray(firstRow.balances_json)
|
||||
? firstRow.balances_json
|
||||
: [];
|
||||
const balances: PatientBalanceRow[] = balancesRaw.map((r: any) => ({
|
||||
const balances: PatientBalanceRow[] = balancesArr.map((r: any) => ({
|
||||
patientId: Number(r.patientId),
|
||||
firstName: r.firstName ?? null,
|
||||
lastName: r.lastName ?? null,
|
||||
@@ -715,6 +698,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
: null,
|
||||
}));
|
||||
|
||||
// Determine hasMore and nextCursor
|
||||
const hasMore = balances.length === safeLimit;
|
||||
let nextCursor: string | null = null;
|
||||
if (hasMore) {
|
||||
@@ -728,20 +712,86 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
}
|
||||
}
|
||||
|
||||
const summaryRaw = firstRow.summary_json ?? {};
|
||||
const summary = {
|
||||
// Execute countQuery
|
||||
const countRows = (await prisma.$queryRawUnsafe(countQuery)) as Array<{
|
||||
total_count?: number;
|
||||
}>;
|
||||
const totalCount = Number(countRows?.[0]?.total_count ?? 0);
|
||||
|
||||
return {
|
||||
balances,
|
||||
totalCount,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Return only the summary data for a doctor (same logic/filters as previous single-query approach)
|
||||
*/
|
||||
async getSummaryByDoctor(
|
||||
staffId: number,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
totalCollected: number;
|
||||
patientsWithBalance: number;
|
||||
}> {
|
||||
if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) {
|
||||
throw new Error("Invalid staffId");
|
||||
}
|
||||
|
||||
const hasFrom = from !== undefined && from !== null;
|
||||
const hasTo = to !== undefined && to !== null;
|
||||
|
||||
const fromStart = isoStartOfDayLiteral(from);
|
||||
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||
|
||||
const paymentTimeFilter =
|
||||
hasFrom && hasTo
|
||||
? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
|
||||
: hasFrom
|
||||
? `AND pay."createdAt" >= ${fromStart}`
|
||||
: hasTo
|
||||
? `AND pay."createdAt" <= ${toNextStart}`
|
||||
: "";
|
||||
|
||||
const summaryQuery = `
|
||||
WITH
|
||||
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
|
||||
FROM "Payment" pay
|
||||
JOIN "Claim" c ON pay."claimId" = c.id
|
||||
WHERE c."staffId" = ${Number(staffId)}
|
||||
${paymentTimeFilter}
|
||||
GROUP BY pay."patientId"
|
||||
)
|
||||
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)
|
||||
) AS summary_json
|
||||
FROM payments_agg pa;
|
||||
`;
|
||||
|
||||
const rows = (await prisma.$queryRawUnsafe(summaryQuery)) as Array<{
|
||||
summary_json?: any;
|
||||
}>;
|
||||
|
||||
const summaryRaw = (rows?.[0]?.summary_json as any) ?? {};
|
||||
|
||||
return {
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DoctorBalancesAndSummary } from "@repo/db/types";
|
||||
import ExportReportButton from "./export-button";
|
||||
|
||||
type StaffOption = { id: number; name: string };
|
||||
@@ -54,16 +53,24 @@ export default function CollectionsByDoctorReport({
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// query balances+summary by doctor
|
||||
// --- balances query (paged rows) ---
|
||||
const {
|
||||
data: collectionData,
|
||||
isLoading: isLoadingRows,
|
||||
isError: isErrorRows,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery<DoctorBalancesAndSummary, Error>({
|
||||
data: balancesResult,
|
||||
isLoading: isLoadingBalances,
|
||||
isError: isErrorBalances,
|
||||
refetch: refetchBalances,
|
||||
isFetching: isFetchingBalances,
|
||||
} = useQuery<
|
||||
{
|
||||
balances: any[];
|
||||
totalCount: number;
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
},
|
||||
Error
|
||||
>({
|
||||
queryKey: [
|
||||
"collections-by-doctor-rows",
|
||||
"collections-by-doctor-balances",
|
||||
staffId,
|
||||
currentCursor,
|
||||
perPage,
|
||||
@@ -80,24 +87,66 @@ export default function CollectionsByDoctorReport({
|
||||
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/payments-reports/by-doctor?${params.toString()}`
|
||||
`/api/payments-reports/by-doctor/balances?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const b = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to load collections" }));
|
||||
throw new Error(b.message || "Failed to load collections");
|
||||
.catch(() => ({ message: "Failed to load collections balances" }));
|
||||
throw new Error(b.message || "Failed to load collections balances");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
enabled: Boolean(staffId), // only load when a doctor is selected
|
||||
enabled: Boolean(staffId),
|
||||
});
|
||||
|
||||
const balances = collectionData?.balances ?? [];
|
||||
const totalCount = collectionData?.totalCount ?? undefined;
|
||||
const serverNextCursor = collectionData?.nextCursor ?? null;
|
||||
const hasMore = collectionData?.hasMore ?? false;
|
||||
const summary = collectionData?.summary ?? null;
|
||||
// --- summary query (staff summary) ---
|
||||
const {
|
||||
data: summaryData,
|
||||
isLoading: isLoadingSummary,
|
||||
isError: isErrorSummary,
|
||||
refetch: refetchSummary,
|
||||
isFetching: isFetchingSummary,
|
||||
} = useQuery<
|
||||
{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number | string;
|
||||
totalCollected: number | string;
|
||||
patientsWithBalance: number;
|
||||
},
|
||||
Error
|
||||
>({
|
||||
queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
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/by-doctor/summary?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const b = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to load collections summary" }));
|
||||
throw new Error(b.message || "Failed to load collections summary");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
enabled: Boolean(staffId),
|
||||
});
|
||||
|
||||
const balances = balancesResult?.balances ?? [];
|
||||
const totalCount = balancesResult?.totalCount ?? undefined;
|
||||
const serverNextCursor = balancesResult?.nextCursor ?? null;
|
||||
const hasMore = Boolean(balancesResult?.hasMore ?? false);
|
||||
const summary = summaryData ?? null;
|
||||
|
||||
const isLoadingRows = isLoadingBalances;
|
||||
const isErrorRows = isErrorBalances;
|
||||
const isFetching = isFetchingBalances || isFetchingSummary;
|
||||
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => {
|
||||
@@ -117,7 +166,7 @@ export default function CollectionsByDoctorReport({
|
||||
if (serverNextCursor) {
|
||||
setCursorStack((s) => [...s, serverNextCursor]);
|
||||
setCursorIndex((i) => i + 1);
|
||||
// No manual refetch — the queryKey depends on currentCursor and React Query will fetch automatically.
|
||||
// React Query will fetch automatically because queryKey includes currentCursor
|
||||
}
|
||||
} else {
|
||||
setCursorIndex((i) => i + 1);
|
||||
|
||||
Reference in New Issue
Block a user