feat(collectionbydoctor query) - v1 done
This commit is contained in:
@@ -80,14 +80,18 @@ router.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/payments-reports/by-doctor
|
* GET /api/payments-reports/by-doctor/balances
|
||||||
* Query params:
|
* Query params:
|
||||||
* - staffId (required)
|
* - staffId (required)
|
||||||
* - limit (optional, default 25)
|
* - limit (optional, default 25)
|
||||||
* - cursor (optional)
|
* - cursor (optional)
|
||||||
* - 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)
|
||||||
|
*
|
||||||
|
* Response: { balances, totalCount, nextCursor, hasMore }
|
||||||
*/
|
*/
|
||||||
router.get("/by-doctor", async (req: Request, res: Response): Promise<any> => {
|
router.get(
|
||||||
|
"/by-doctor/balances",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const staffIdRaw = req.query.staffId;
|
const staffIdRaw = req.query.staffId;
|
||||||
if (!staffIdRaw) {
|
if (!staffIdRaw) {
|
||||||
@@ -110,7 +114,9 @@ router.get("/by-doctor", async (req: Request, res: Response): Promise<any> => {
|
|||||||
const cursor =
|
const cursor =
|
||||||
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
|
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
|
||||||
|
|
||||||
const from = req.query.from ? new Date(String(req.query.from)) : 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;
|
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
|
||||||
|
|
||||||
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
|
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
|
||||||
@@ -120,29 +126,88 @@ router.get("/by-doctor", async (req: Request, res: Response): Promise<any> => {
|
|||||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await storage.getBalancesAndSummaryByDoctor(
|
// use the new storage method that returns only the paged balances
|
||||||
|
const balancesResult = await storage.getPatientsBalancesByDoctor(
|
||||||
staffId,
|
staffId,
|
||||||
limit,
|
limit,
|
||||||
cursor,
|
cursor,
|
||||||
from,
|
from,
|
||||||
to
|
to
|
||||||
);
|
);
|
||||||
// data expected: { balances, totalCount, nextCursor, hasMore, summary }
|
|
||||||
res.json(data);
|
res.json({
|
||||||
|
balances: balancesResult?.balances ?? [],
|
||||||
|
totalCount: Number(balancesResult?.totalCount ?? 0),
|
||||||
|
nextCursor: balancesResult?.nextCursor ?? null,
|
||||||
|
hasMore: Boolean(balancesResult?.hasMore ?? false),
|
||||||
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(
|
console.error(
|
||||||
"GET /api/payments-reports/by-doctor error:",
|
"GET /api/payments-reports/by-doctor/balances error:",
|
||||||
err?.message ?? err,
|
err?.message ?? err,
|
||||||
err?.stack
|
err?.stack
|
||||||
);
|
);
|
||||||
// If prisma errors, return 500 with message for debugging (strip sensitive info in prod)
|
res.status(500).json({
|
||||||
res
|
message: "Failed to fetch doctor balances",
|
||||||
.status(500)
|
|
||||||
.json({
|
|
||||||
message: "Failed to fetch doctor balances and summary",
|
|
||||||
detail: err?.message ?? String(err),
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { getPatientFinancialRowsFn } from "./patients-storage";
|
import { getPatientFinancialRowsFn } from "./patients-storage";
|
||||||
|
import { GetPatientBalancesResult } from "@repo/db/types";
|
||||||
|
|
||||||
type PatientSummaryRow = {
|
type PatientSummaryRow = {
|
||||||
patientId: number;
|
patientId: number;
|
||||||
@@ -20,12 +21,8 @@ export async function fetchAllPatientsWithBalances(
|
|||||||
const all: PatientSummaryRow[] = [];
|
const all: PatientSummaryRow[] = [];
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
while (true) {
|
while (true) {
|
||||||
const page = await storage.getPatientsWithBalances(
|
const page: GetPatientBalancesResult =
|
||||||
pageSize,
|
await storage.getPatientsWithBalances(pageSize, cursor, from, to);
|
||||||
cursor,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
if (!page) break;
|
if (!page) break;
|
||||||
if (Array.isArray(page.balances) && page.balances.length) {
|
if (Array.isArray(page.balances) && page.balances.length) {
|
||||||
for (const b of page.balances) {
|
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(
|
export async function fetchAllPatientsForDoctor(
|
||||||
staffId: number,
|
staffId: number,
|
||||||
@@ -55,7 +52,8 @@ export async function fetchAllPatientsForDoctor(
|
|||||||
const all: PatientSummaryRow[] = [];
|
const all: PatientSummaryRow[] = [];
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
while (true) {
|
while (true) {
|
||||||
const page = await storage.getBalancesAndSummaryByDoctor(
|
const page: GetPatientBalancesResult =
|
||||||
|
await storage.getPatientsBalancesByDoctor(
|
||||||
staffId,
|
staffId,
|
||||||
pageSize,
|
pageSize,
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
@@ -31,20 +31,36 @@ export interface IPaymentsReportsStorage {
|
|||||||
): Promise<GetPatientBalancesResult>;
|
): 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
|
* - staffId required
|
||||||
* - limit: page size
|
* - limit: page size
|
||||||
* - cursorToken: optional base64 cursor (must have been produced for same staffId)
|
* - cursorToken: optional base64 cursor (must have been produced for same staffId)
|
||||||
* - from/to: optional date range applied to Payment."createdAt"
|
* - from/to: optional date range applied to Payment."createdAt"
|
||||||
*/
|
*/
|
||||||
getBalancesAndSummaryByDoctor(
|
getPatientsBalancesByDoctor(
|
||||||
staffId: number,
|
staffId: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
cursorToken?: string | null,
|
cursorToken?: string | null,
|
||||||
from?: Date | null,
|
from?: Date | null,
|
||||||
to?: 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) */
|
/** 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,
|
staffId: number,
|
||||||
limit = 25,
|
limit = 25,
|
||||||
cursorToken?: string | null,
|
cursorToken?: string | null,
|
||||||
from?: Date | null,
|
from?: Date | null,
|
||||||
to?: 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) {
|
if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) {
|
||||||
throw new Error("Invalid staffId");
|
throw new Error("Invalid staffId");
|
||||||
}
|
}
|
||||||
@@ -526,8 +550,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
const hasTo = to !== undefined && to !== null;
|
const hasTo = to !== undefined && to !== null;
|
||||||
|
|
||||||
// Use inclusive start-of-day for 'from' and exclusive start-of-next-day for 'to'
|
// 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 fromStart = isoStartOfDayLiteral(from);
|
||||||
const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
|
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||||
|
|
||||||
// Filter payments by createdAt (time window) when provided
|
// Filter payments by createdAt (time window) when provided
|
||||||
const paymentTimeFilter =
|
const paymentTimeFilter =
|
||||||
@@ -539,9 +563,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
? `AND pay."createdAt" <= ${toNextStart}`
|
? `AND pay."createdAt" <= ${toNextStart}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Keyset predicate must use columns present in the 'patients' CTE rows (alias p).
|
// Keyset predicate for paging (same semantics as before)
|
||||||
// We'll compare p.last_payment_date, p.patient_created_at and p.id
|
|
||||||
|
|
||||||
let pageKeysetPredicate = "";
|
let pageKeysetPredicate = "";
|
||||||
if (effectiveCursor) {
|
if (effectiveCursor) {
|
||||||
const lp = effectiveCursor.lastPaymentDate
|
const lp = effectiveCursor.lastPaymentDate
|
||||||
@@ -564,35 +586,20 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
)`;
|
)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =
|
const paymentsJoinForPatients =
|
||||||
hasFrom || hasTo
|
hasFrom || hasTo
|
||||||
? "INNER JOIN payments_agg pa ON pa.patient_id = p.id"
|
? "INNER JOIN payments_agg pa ON pa.patient_id = p.id"
|
||||||
: "LEFT JOIN payments_agg pa ON pa.patient_id = p.id";
|
: "LEFT JOIN payments_agg pa ON pa.patient_id = p.id";
|
||||||
|
|
||||||
const sql = `
|
// Common CTEs (identical to previous single-query approach)
|
||||||
|
const commonCtes = `
|
||||||
WITH
|
WITH
|
||||||
-- patients that have at least one appointment with this staff (roster)
|
|
||||||
staff_patients AS (
|
staff_patients AS (
|
||||||
SELECT DISTINCT "patientId" AS patient_id
|
SELECT DISTINCT "patientId" AS patient_id
|
||||||
FROM "Appointment"
|
FROM "Appointment"
|
||||||
WHERE "staffId" = ${Number(staffId)}
|
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 (
|
payments_agg AS (
|
||||||
SELECT
|
SELECT
|
||||||
pay."patientId" AS patient_id,
|
pay."patientId" AS patient_id,
|
||||||
@@ -613,7 +620,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
GROUP BY "patientId"
|
GROUP BY "patientId"
|
||||||
),
|
),
|
||||||
|
|
||||||
-- Build the patient rows. If window provided we INNER JOIN payments_agg (so only patients with payments in window)
|
|
||||||
patients AS (
|
patients AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
@@ -630,10 +636,13 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
${paymentsJoinForPatients}
|
${paymentsJoinForPatients}
|
||||||
LEFT JOIN last_appointments la ON la.patient_id = p.id
|
LEFT JOIN last_appointments la ON la.patient_id = p.id
|
||||||
)
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
SELECT
|
// Query A: fetch the page of patient rows as JSON array
|
||||||
-- page rows as JSON array
|
const balancesQuery = `
|
||||||
(SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM (
|
${commonCtes}
|
||||||
|
|
||||||
|
SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) AS balances_json FROM (
|
||||||
SELECT
|
SELECT
|
||||||
p.id AS "patientId",
|
p.id AS "patientId",
|
||||||
p.first_name AS "firstName",
|
p.first_name AS "firstName",
|
||||||
@@ -649,57 +658,31 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
${pageKeysetPredicate}
|
${pageKeysetPredicate}
|
||||||
ORDER BY p.last_payment_date DESC NULLS LAST, p.id DESC
|
ORDER BY p.last_payment_date DESC NULLS LAST, p.id DESC
|
||||||
LIMIT ${safeLimit}
|
LIMIT ${safeLimit}
|
||||||
) t) AS balances_json,
|
) t;
|
||||||
|
`;
|
||||||
|
|
||||||
-- total_count: when window provided, count distinct patients that have payments in the window (payments_agg),
|
// Query Count: total_count (same logic as previous combined query's CASE)
|
||||||
-- otherwise count all staff_patients
|
const countQuery = `
|
||||||
|
${commonCtes}
|
||||||
|
|
||||||
|
SELECT
|
||||||
(CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN
|
(CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN
|
||||||
(SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa)
|
(SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa)
|
||||||
ELSE
|
ELSE
|
||||||
(SELECT COUNT(*)::int FROM staff_patients)
|
(SELECT COUNT(*)::int FROM staff_patients)
|
||||||
END) AS total_count,
|
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<{
|
// Execute balancesQuery
|
||||||
balances_json?: any;
|
const balancesRawRows = (await prisma.$queryRawUnsafe(
|
||||||
total_count?: number;
|
balancesQuery
|
||||||
summary_json?: any;
|
)) as Array<{ balances_json?: any }>;
|
||||||
}>;
|
|
||||||
|
|
||||||
const firstRow =
|
const balancesJson = (balancesRawRows?.[0]?.balances_json as any) ?? [];
|
||||||
Array.isArray(rawRows) && rawRows.length > 0 ? rawRows[0] : undefined;
|
|
||||||
|
|
||||||
if (!firstRow) {
|
const balancesArr = Array.isArray(balancesJson) ? balancesJson : [];
|
||||||
return {
|
|
||||||
balances: [],
|
|
||||||
totalCount: 0,
|
|
||||||
nextCursor: null,
|
|
||||||
hasMore: false,
|
|
||||||
summary: {
|
|
||||||
totalPatients: 0,
|
|
||||||
totalOutstanding: 0,
|
|
||||||
totalCollected: 0,
|
|
||||||
patientsWithBalance: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const balancesRaw = Array.isArray(firstRow.balances_json)
|
const balances: PatientBalanceRow[] = balancesArr.map((r: any) => ({
|
||||||
? firstRow.balances_json
|
|
||||||
: [];
|
|
||||||
const balances: PatientBalanceRow[] = balancesRaw.map((r: any) => ({
|
|
||||||
patientId: Number(r.patientId),
|
patientId: Number(r.patientId),
|
||||||
firstName: r.firstName ?? null,
|
firstName: r.firstName ?? null,
|
||||||
lastName: r.lastName ?? null,
|
lastName: r.lastName ?? null,
|
||||||
@@ -715,6 +698,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Determine hasMore and nextCursor
|
||||||
const hasMore = balances.length === safeLimit;
|
const hasMore = balances.length === safeLimit;
|
||||||
let nextCursor: string | null = null;
|
let nextCursor: string | null = null;
|
||||||
if (hasMore) {
|
if (hasMore) {
|
||||||
@@ -728,20 +712,86 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryRaw = firstRow.summary_json ?? {};
|
// Execute countQuery
|
||||||
const summary = {
|
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),
|
totalPatients: Number(summaryRaw.totalPatients ?? 0),
|
||||||
totalOutstanding: Number(summaryRaw.totalOutstanding ?? 0),
|
totalOutstanding: Number(summaryRaw.totalOutstanding ?? 0),
|
||||||
totalCollected: Number(summaryRaw.totalCollected ?? 0),
|
totalCollected: Number(summaryRaw.totalCollected ?? 0),
|
||||||
patientsWithBalance: Number(summaryRaw.patientsWithBalance ?? 0),
|
patientsWithBalance: Number(summaryRaw.patientsWithBalance ?? 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
|
||||||
balances,
|
|
||||||
totalCount: Number(firstRow.total_count ?? 0),
|
|
||||||
nextCursor,
|
|
||||||
hasMore,
|
|
||||||
summary,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DoctorBalancesAndSummary } from "@repo/db/types";
|
|
||||||
import ExportReportButton from "./export-button";
|
import ExportReportButton from "./export-button";
|
||||||
|
|
||||||
type StaffOption = { id: number; name: string };
|
type StaffOption = { id: number; name: string };
|
||||||
@@ -54,16 +53,24 @@ export default function CollectionsByDoctorReport({
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// query balances+summary by doctor
|
// --- balances query (paged rows) ---
|
||||||
const {
|
const {
|
||||||
data: collectionData,
|
data: balancesResult,
|
||||||
isLoading: isLoadingRows,
|
isLoading: isLoadingBalances,
|
||||||
isError: isErrorRows,
|
isError: isErrorBalances,
|
||||||
refetch,
|
refetch: refetchBalances,
|
||||||
isFetching,
|
isFetching: isFetchingBalances,
|
||||||
} = useQuery<DoctorBalancesAndSummary, Error>({
|
} = useQuery<
|
||||||
|
{
|
||||||
|
balances: any[];
|
||||||
|
totalCount: number;
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
},
|
||||||
|
Error
|
||||||
|
>({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"collections-by-doctor-rows",
|
"collections-by-doctor-balances",
|
||||||
staffId,
|
staffId,
|
||||||
currentCursor,
|
currentCursor,
|
||||||
perPage,
|
perPage,
|
||||||
@@ -80,24 +87,66 @@ export default function CollectionsByDoctorReport({
|
|||||||
|
|
||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"GET",
|
"GET",
|
||||||
`/api/payments-reports/by-doctor?${params.toString()}`
|
`/api/payments-reports/by-doctor/balances?${params.toString()}`
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const b = await res
|
const b = await res
|
||||||
.json()
|
.json()
|
||||||
.catch(() => ({ message: "Failed to load collections" }));
|
.catch(() => ({ message: "Failed to load collections balances" }));
|
||||||
throw new Error(b.message || "Failed to load collections");
|
throw new Error(b.message || "Failed to load collections balances");
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
enabled: Boolean(staffId), // only load when a doctor is selected
|
enabled: Boolean(staffId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const balances = collectionData?.balances ?? [];
|
// --- summary query (staff summary) ---
|
||||||
const totalCount = collectionData?.totalCount ?? undefined;
|
const {
|
||||||
const serverNextCursor = collectionData?.nextCursor ?? null;
|
data: summaryData,
|
||||||
const hasMore = collectionData?.hasMore ?? false;
|
isLoading: isLoadingSummary,
|
||||||
const summary = collectionData?.summary ?? null;
|
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
|
// Reset pagination when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,7 +166,7 @@ export default function CollectionsByDoctorReport({
|
|||||||
if (serverNextCursor) {
|
if (serverNextCursor) {
|
||||||
setCursorStack((s) => [...s, serverNextCursor]);
|
setCursorStack((s) => [...s, serverNextCursor]);
|
||||||
setCursorIndex((i) => i + 1);
|
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 {
|
} else {
|
||||||
setCursorIndex((i) => i + 1);
|
setCursorIndex((i) => i + 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user