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";
export interface PatientBalanceRow {
patientId: number;
firstName: string | null;
lastName: string | null;
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;
};
}
import {
DoctorBalancesAndSummary,
GetPatientBalancesResult,
PatientBalanceRow,
} from "../../../../packages/db/types/payments-reports-types";
export interface IPaymentsReportsStorage {
// summary now returns an extra field patientsWithBalance
@@ -75,11 +47,21 @@ export interface IPaymentsReportsStorage {
): Promise<DoctorBalancesAndSummary>;
}
/** Helper: format Date -> SQL literal 'YYYY-MM-DDTHH:mm:ss.sssZ' or null */
function fmtDateLiteral(d?: Date | null): string | null {
/** Return ISO literal for inclusive start-of-day (UTC midnight) */
function isoStartOfDayLiteral(d?: Date | null): string | null {
if (!d) return null;
const iso = new Date(d).toISOString();
return `'${iso}'`;
const dt = new Date(d);
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) */
@@ -129,8 +111,10 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
try {
const hasFrom = from !== undefined && from !== 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
let patientsCountSql = "";
@@ -139,7 +123,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId"
) t
`;
@@ -148,7 +132,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit}
WHERE pay."createdAt" >= ${fromStart}
GROUP BY pay."patientId"
) t
`;
@@ -157,7 +141,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SELECT COUNT(*)::int AS cnt FROM (
SELECT pay."patientId" AS patient_id
FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit}
WHERE pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId"
) t
`;
@@ -182,7 +166,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId"
) pm
`;
@@ -197,7 +181,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit}
WHERE pay."createdAt" >= ${fromStart}
GROUP BY pay."patientId"
) pm
`;
@@ -212,7 +196,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit}
WHERE pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId"
) pm
`;
@@ -239,11 +223,11 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
// totalCollected: sum(totalPaid) in the range
let collSql = "";
if (hasFrom && hasTo) {
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit} AND "createdAt" <= ${toLit}`;
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart} AND "createdAt" <= ${toNextStart}`;
} 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) {
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 {
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."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId"
) t
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."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" >= ${fromLit}
WHERE pay."createdAt" >= ${fromStart}
GROUP BY pay."patientId"
) t
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."totalAdjusted")::numeric(14,2) AS total_adjusted
FROM "Payment" pay
WHERE pay."createdAt" <= ${toLit}
WHERE pay."createdAt" <= ${toNextStart}
GROUP BY pay."patientId"
) t
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 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)
const paymentWhereClause =
hasFrom && hasTo
? `WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}`
? `WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
: hasFrom
? `WHERE pay."createdAt" >= ${fromLit}`
? `WHERE pay."createdAt" >= ${fromStart}`
: hasTo
? `WHERE pay."createdAt" <= ${toLit}`
? `WHERE pay."createdAt" <= ${toNextStart}`
: "";
const pmSubquery = `
@@ -562,17 +548,19 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
const hasFrom = from !== undefined && from !== 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
const paymentTimeFilter =
hasFrom && hasTo
? `AND pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}`
? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
: hasFrom
? `AND pay."createdAt" >= ${fromLit}`
? `AND pay."createdAt" >= ${fromStart}`
: hasTo
? `AND pay."createdAt" <= ${toLit}`
? `AND pay."createdAt" <= ${toNextStart}`
: "";
// Keyset predicate must use columns present in the 'patients' CTE rows (alias p).

View File

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

View File

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

View File

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

View File

@@ -8,4 +8,25 @@ export interface PatientBalanceRow {
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;
};
}