feat(collectionbydoctor query) - v1 done

This commit is contained in:
2025-10-25 20:00:09 +05:30
parent 3af71cc5b8
commit 827560cdfd
4 changed files with 383 additions and 221 deletions

View File

@@ -80,69 +80,134 @@ 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(
try { "/by-doctor/balances",
const staffIdRaw = req.query.staffId; async (req: Request, res: Response): Promise<any> => {
if (!staffIdRaw) { try {
return res const staffIdRaw = req.query.staffId;
.status(400) if (!staffIdRaw) {
.json({ message: "Missing required 'staffId' query parameter" }); return res
} .status(400)
const staffId = Number(staffIdRaw); .json({ message: "Missing required 'staffId' query parameter" });
if (!Number.isFinite(staffId) || staffId <= 0) { }
return res const staffId = Number(staffIdRaw);
.status(400) if (!Number.isFinite(staffId) || staffId <= 0) {
.json({ message: "Invalid 'staffId' query parameter" }); return res
} .status(400)
.json({ message: "Invalid 'staffId' query parameter" });
}
const limit = Math.max( const limit = Math.max(
1, 1,
Math.min(200, parseInt(String(req.query.limit || "25"), 10)) Math.min(200, parseInt(String(req.query.limit || "25"), 10))
); );
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
const to = req.query.to ? new Date(String(req.query.to)) : undefined; ? 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)) { if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
return res.status(400).json({ message: "Invalid 'from' date" }); return res.status(400).json({ message: "Invalid 'from' date" });
} }
if (req.query.to && isNaN(to?.getTime() ?? NaN)) { if (req.query.to && isNaN(to?.getTime() ?? NaN)) {
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
staffId, const balancesResult = await storage.getPatientsBalancesByDoctor(
limit, staffId,
cursor, limit,
from, cursor,
to from,
); to
// data expected: { balances, totalCount, nextCursor, hasMore, summary } );
res.json(data);
} catch (err: any) { res.json({
console.error( balances: balancesResult?.balances ?? [],
"GET /api/payments-reports/by-doctor error:", totalCount: Number(balancesResult?.totalCount ?? 0),
err?.message ?? err, nextCursor: balancesResult?.nextCursor ?? null,
err?.stack hasMore: Boolean(balancesResult?.hasMore ?? false),
); });
// If prisma errors, return 500 with message for debugging (strip sensitive info in prod) } catch (err: any) {
res console.error(
.status(500) "GET /api/payments-reports/by-doctor/balances error:",
.json({ err?.message ?? err,
message: "Failed to fetch doctor balances and summary", err?.stack
);
res.status(500).json({
message: "Failed to fetch doctor balances",
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;

View File

@@ -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,13 +52,14 @@ 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 =
staffId, await storage.getPatientsBalancesByDoctor(
pageSize, staffId,
cursor, pageSize,
from, cursor,
to 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) {

View File

@@ -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
@@ -550,90 +572,77 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
const id = Number(effectiveCursor.lastPatientId); const id = Number(effectiveCursor.lastPatientId);
pageKeysetPredicate = `AND ( pageKeysetPredicate = `AND (
( ${lp} IS NOT NULL AND ( ( ${lp} IS NOT NULL AND (
(p.last_payment_date IS NOT NULL AND p.last_payment_date < ${lp}) (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 (p.last_payment_date IS NOT NULL AND p.last_payment_date = ${lp} AND p.id < ${id})
) )
) )
OR OR
( ${lp} IS NULL AND ( ( ${lp} IS NULL AND (
p.last_payment_date IS NOT NULL p.last_payment_date IS NOT NULL
OR (p.last_payment_date IS NULL AND p.id < ${id}) 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 = 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)
WITH const commonCtes = `
-- patients that have at least one appointment with this staff (roster) WITH
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, payments_agg AS (
-- and additionally restricted by the optional time window (paymentTimeFilter) SELECT
payments_agg AS ( pay."patientId" AS patient_id,
SELECT SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
pay."patientId" AS patient_id, SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalBilled")::numeric(14,2) AS total_charges, SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted,
SUM(pay."totalPaid")::numeric(14,2) AS total_paid, MAX(pay."createdAt") AS last_payment_date
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted, FROM "Payment" pay
MAX(pay."createdAt") AS last_payment_date JOIN "Claim" c ON pay."claimId" = c.id
FROM "Payment" pay WHERE c."staffId" = ${Number(staffId)}
JOIN "Claim" c ON pay."claimId" = c.id ${paymentTimeFilter}
WHERE c."staffId" = ${Number(staffId)} GROUP BY pay."patientId"
${paymentTimeFilter} ),
GROUP BY pay."patientId"
),
last_appointments AS ( last_appointments AS (
SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date
FROM "Appointment" FROM "Appointment"
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, p."firstName" AS first_name,
p."firstName" AS first_name, p."lastName" AS last_name,
p."lastName" AS last_name, COALESCE(pa.total_charges, 0)::numeric(14,2) AS total_charges,
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_paid, 0)::numeric(14,2) AS total_paid, COALESCE(pa.total_adjusted, 0)::numeric(14,2) AS total_adjusted,
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,
(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,
pa.last_payment_date, la.last_appointment_date
la.last_appointment_date FROM "Patient" p
FROM "Patient" p INNER JOIN staff_patients sp ON sp.patient_id = p.id
INNER JOIN staff_patients sp ON sp.patient_id = p.id ${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 = `
(CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN ${commonCtes}
(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
( (CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN
SELECT json_build_object( (SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa)
'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0), ELSE
'totalOutstanding', COALESCE(SUM(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)),0)::text, (SELECT COUNT(*)::int FROM staff_patients)
'totalCollected', COALESCE(SUM(COALESCE(pa.total_paid,0)),0)::text, END) AS total_count;
'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,
};
}, },
}; };

View File

@@ -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);