feat(report page) - collection by doctors - done

This commit is contained in:
2025-10-23 19:33:41 +05:30
parent 4bac4f94e0
commit 54596be39f
5 changed files with 77 additions and 94 deletions

View File

@@ -1,37 +1,9 @@
import { prisma } from "@repo/db/client"; import { prisma } from "@repo/db/client";
import {
export interface PatientBalanceRow { DoctorBalancesAndSummary,
patientId: number; GetPatientBalancesResult,
firstName: string | null; PatientBalanceRow,
lastName: string | null; } from "../../../../packages/db/types/payments-reports-types";
totalCharges: number;
totalPayments: number;
totalAdjusted: number;
currentBalance: number;
lastPaymentDate: string | null;
lastAppointmentDate: string | null;
patientCreatedAt?: string | null;
}
export interface GetPatientBalancesResult {
balances: PatientBalanceRow[];
totalCount: number;
nextCursor: string | null;
hasMore: boolean;
}
export interface DoctorBalancesAndSummary {
balances: PatientBalanceRow[];
totalCount: number;
nextCursor: string | null;
hasMore: boolean;
summary: {
totalPatients: number;
totalOutstanding: number;
totalCollected: number;
patientsWithBalance: number;
};
}
export interface IPaymentsReportsStorage { export interface IPaymentsReportsStorage {
// summary now returns an extra field patientsWithBalance // summary now returns an extra field patientsWithBalance
@@ -75,11 +47,21 @@ export interface IPaymentsReportsStorage {
): Promise<DoctorBalancesAndSummary>; ): Promise<DoctorBalancesAndSummary>;
} }
/** Helper: format Date -> SQL literal 'YYYY-MM-DDTHH:mm:ss.sssZ' or null */ /** Return ISO literal for inclusive start-of-day (UTC midnight) */
function fmtDateLiteral(d?: Date | null): string | null { function isoStartOfDayLiteral(d?: Date | null): string | null {
if (!d) return null; if (!d) return null;
const iso = new Date(d).toISOString(); const dt = new Date(d);
return `'${iso}'`; dt.setUTCHours(0, 0, 0, 0);
return `'${dt.toISOString()}'`;
}
/** Return ISO literal for exclusive next-day start (UTC midnight of the next day) */
function isoStartOfNextDayLiteral(d?: Date | null): string | null {
if (!d) return null;
const dt = new Date(d);
dt.setUTCHours(0, 0, 0, 0);
dt.setUTCDate(dt.getUTCDate() + 1);
return `'${dt.toISOString()}'`;
} }
/** Cursor helpers — base64(JSON) */ /** Cursor helpers — base64(JSON) */
@@ -129,8 +111,10 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
try { try {
const hasFrom = from !== undefined && from !== null; const hasFrom = from !== undefined && from !== null;
const hasTo = to !== undefined && to !== null; const hasTo = to !== undefined && to !== null;
const fromLit = fmtDateLiteral(from);
const toLit = fmtDateLiteral(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 toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
// totalPatients: distinct patients who had payments in the date range // totalPatients: distinct patients who had payments in the date range
let patientsCountSql = ""; let patientsCountSql = "";
@@ -139,7 +123,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SELECT COUNT(*)::int AS cnt FROM ( SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id SELECT pay."patientId" AS patient_id
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) t ) t
`; `;
@@ -148,7 +132,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SELECT COUNT(*)::int AS cnt FROM ( SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id SELECT pay."patientId" AS patient_id
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} WHERE pay."createdAt" >= ${fromStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) t ) t
`; `;
@@ -157,7 +141,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SELECT COUNT(*)::int AS cnt FROM ( SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id SELECT pay."patientId" AS patient_id
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit} WHERE pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) t ) t
`; `;
@@ -182,7 +166,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid, SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) pm ) pm
`; `;
@@ -197,7 +181,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid, SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} WHERE pay."createdAt" >= ${fromStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) pm ) pm
`; `;
@@ -212,7 +196,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid, SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit} WHERE pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) pm ) pm
`; `;
@@ -239,11 +223,11 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
// totalCollected: sum(totalPaid) in the range // totalCollected: sum(totalPaid) in the range
let collSql = ""; let collSql = "";
if (hasFrom && hasTo) { if (hasFrom && hasTo) {
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit} AND "createdAt" <= ${toLit}`; collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart} AND "createdAt" <= ${toNextStart}`;
} else if (hasFrom) { } else if (hasFrom) {
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit}`; collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart}`;
} else if (hasTo) { } else if (hasTo) {
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toLit}`; collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toNextStart}`;
} else { } else {
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment"`; collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment"`;
} }
@@ -262,7 +246,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid, SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) t ) t
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
@@ -275,7 +259,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid, SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} WHERE pay."createdAt" >= ${fromStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) t ) t
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
@@ -288,7 +272,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid, SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit} WHERE pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId" GROUP BY pay."patientId"
) t ) t
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
@@ -355,17 +339,19 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
const hasFrom = from !== undefined && from !== null; const hasFrom = from !== undefined && from !== null;
const hasTo = to !== undefined && to !== null; const hasTo = to !== undefined && to !== null;
const fromLit = fmtDateLiteral(from);
const toLit = fmtDateLiteral(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 toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
// Build payment subquery (aggregated payments by patient, filtered by createdAt if provided) // Build payment subquery (aggregated payments by patient, filtered by createdAt if provided)
const paymentWhereClause = const paymentWhereClause =
hasFrom && hasTo hasFrom && hasTo
? `WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}` ? `WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
: hasFrom : hasFrom
? `WHERE pay."createdAt" >= ${fromLit}` ? `WHERE pay."createdAt" >= ${fromStart}`
: hasTo : hasTo
? `WHERE pay."createdAt" <= ${toLit}` ? `WHERE pay."createdAt" <= ${toNextStart}`
: ""; : "";
const pmSubquery = ` const pmSubquery = `
@@ -562,17 +548,19 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
const hasFrom = from !== undefined && from !== null; const hasFrom = from !== undefined && from !== null;
const hasTo = to !== undefined && to !== null; const hasTo = to !== undefined && to !== null;
const fromLit = fmtDateLiteral(from);
const toLit = fmtDateLiteral(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 toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
// Filter payments by createdAt (time window) when provided // Filter payments by createdAt (time window) when provided
const paymentTimeFilter = const paymentTimeFilter =
hasFrom && hasTo hasFrom && hasTo
? `AND pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}` ? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
: hasFrom : hasFrom
? `AND pay."createdAt" >= ${fromLit}` ? `AND pay."createdAt" >= ${fromStart}`
: hasTo : hasTo
? `AND pay."createdAt" <= ${toLit}` ? `AND pay."createdAt" <= ${toNextStart}`
: ""; : "";
// Keyset predicate must use columns present in the 'patients' CTE rows (alias p). // Keyset predicate must use columns present in the 'patients' CTE rows (alias p).

View File

@@ -11,35 +11,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { DoctorBalancesAndSummary } from "@repo/db/types";
type StaffOption = { id: number; name: string }; 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 = {
balances: BalanceRow[];
totalCount?: number;
nextCursor?: string | null;
hasMore?: boolean;
summary?: {
totalPatients?: number;
totalOutstanding?: number;
totalCollected?: number;
patientsWithBalance?: number;
};
};
function fmtCurrency(v: number) { function fmtCurrency(v: number) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
@@ -85,7 +60,7 @@ export default function CollectionsByDoctorReport({
isError: isErrorRows, isError: isErrorRows,
refetch, refetch,
isFetching, isFetching,
} = useQuery<CollectionsResp, Error>({ } = useQuery<DoctorBalancesAndSummary, Error>({
queryKey: [ queryKey: [
"collections-by-doctor-rows", "collections-by-doctor-rows",
staffId, staffId,
@@ -115,7 +90,6 @@ export default function CollectionsByDoctorReport({
return res.json(); return res.json();
}, },
enabled: Boolean(staffId), // only load when a doctor is selected enabled: Boolean(staffId), // only load when a doctor is selected
staleTime: 30_000,
}); });
const balances = collectionData?.balances ?? []; const balances = collectionData?.balances ?? [];
@@ -152,7 +126,7 @@ export default function CollectionsByDoctorReport({
// Map server rows to GenericRow // Map server rows to GenericRow
const genericRows: GenericRow[] = balances.map((r) => { const genericRows: GenericRow[] = balances.map((r) => {
const totalCharges = Number(r.totalCharges ?? 0); const totalCharges = Number(r.totalCharges ?? 0);
const totalPayments = Number(r.totalPaid ?? 0); const totalPayments = Number(r.totalPayments ?? 0);
const currentBalance = Number(r.currentBalance ?? 0); const currentBalance = Number(r.currentBalance ?? 0);
const name = `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || "Unknown"; const name = `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || "Unknown";
@@ -169,7 +143,9 @@ export default function CollectionsByDoctorReport({
<div> <div>
<div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="text-sm text-gray-700 block mb-1">Doctor</label> <label className="text-sm text-gray-700 block mb-1 ml-2">
Select Doctor
</label>
<Select <Select
value={staffId || undefined} value={staffId || undefined}
onValueChange={(v) => setStaffId(v)} onValueChange={(v) => setStaffId(v)}

View File

@@ -51,7 +51,6 @@ export default function PatientsWithBalanceReport({
return res.json(); return res.json();
}, },
enabled: true, enabled: true,
staleTime: 30_000,
}); });
const balances = data?.balances ?? []; const balances = data?.balances ?? [];

View File

@@ -42,7 +42,6 @@ export default function SummaryCards({
return res.json(); return res.json();
}, },
enabled: Boolean(startDate && endDate), enabled: Boolean(startDate && endDate),
staleTime: 30_000,
}); });
const totalPatients = data?.totalPatients ?? 0; const totalPatients = data?.totalPatients ?? 0;

View File

@@ -8,4 +8,25 @@ export interface PatientBalanceRow {
currentBalance: number; currentBalance: number;
lastPaymentDate: string | null; lastPaymentDate: string | null;
lastAppointmentDate: string | null; lastAppointmentDate: string | null;
} patientCreatedAt?: string | null;
}
export interface GetPatientBalancesResult {
balances: PatientBalanceRow[];
totalCount: number;
nextCursor: string | null;
hasMore: boolean;
}
export interface DoctorBalancesAndSummary {
balances: PatientBalanceRow[];
totalCount: number;
nextCursor: string | null;
hasMore: boolean;
summary: {
totalPatients: number;
totalOutstanding: number;
totalCollected: number;
patientsWithBalance: number;
};
}