feat: teach AI classifier to recognize "comp" and tooth surface notation
Add "comp" as alias for composite in CDT lookup. Update classifier prompt with explicit examples for "claim comp #8 ml for lisa today" and dental surface letter definitions (M/D/L/F/B/O/V/I) so the LLM correctly treats #tooth+surfaces as composite fillings, not insurance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,7 @@ const ALIAS_MAP: Record<string, string> = {
|
|||||||
// Fillings / restorations
|
// Fillings / restorations
|
||||||
"filling": "resin",
|
"filling": "resin",
|
||||||
"composite": "resin",
|
"composite": "resin",
|
||||||
|
"comp": "resin",
|
||||||
"amalgam": "amalgam",
|
"amalgam": "amalgam",
|
||||||
"core buildup": "core buildup",
|
"core buildup": "core buildup",
|
||||||
"core bu": "core buildup",
|
"core bu": "core buildup",
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ Intents:
|
|||||||
e.g. "claim D0120 and D1110 for John Smith today"
|
e.g. "claim D0120 and D1110 for John Smith today"
|
||||||
e.g. "bill adult cleaning for Maria on 05/15/2026"
|
e.g. "bill adult cleaning for Maria on 05/15/2026"
|
||||||
e.g. "claim perio exam, 2BW for John Smith"
|
e.g. "claim perio exam, 2BW for John Smith"
|
||||||
|
e.g. "claim #8 ml for lisa" → procedureNames: ["#8 ml"], patientName: "lisa" (tooth+surface = composite filling, NOT insurance)
|
||||||
|
e.g. "claim comp #8 ml for lisa today" → procedureNames: ["comp #8 ml"], patientName: "lisa", appointmentDate: today
|
||||||
|
"comp" is short for "composite" — when followed by #tooth+surfaces, it is a composite filling
|
||||||
Use this when no eligibility check is requested — just billing/claiming services
|
Use this when no eligibility check is requested — just billing/claiming services
|
||||||
Always extract appointmentDate when a date or "today" is mentioned
|
Always extract appointmentDate when a date or "today" is mentioned
|
||||||
- preauth : submit a pre-authorization request for procedures
|
- preauth : submit a pre-authorization request for procedures
|
||||||
@@ -137,8 +140,14 @@ Rules:
|
|||||||
do NOT include it in procedureNames. It refers to a file attachment, not a billable procedure.
|
do NOT include it in procedureNames. It refers to a file attachment, not a billable procedure.
|
||||||
Only include actual clinical procedures in procedureNames.
|
Only include actual clinical procedures in procedureNames.
|
||||||
- For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces:
|
- For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces:
|
||||||
e.g. "composite #29 O", "#8 MO", "composite #11 MOD", "# 10 DL", "# 11 ML" — keep the #number and surface letters together as one entry
|
e.g. "composite #29 O", "#8 MO", "composite #11 MOD", "# 10 DL", "# 11 ML", "#8 ml", "comp #8 ml" — keep the #number and surface letters together as one entry
|
||||||
Note: "# 10 DL" and "composite on # 10 DL" are the same — preserve the space-after-# as-is
|
Note: "comp" is short for "composite". "# 10 DL", "composite on # 10 DL", and "comp # 10 DL" are all the same — preserve the space-after-# as-is
|
||||||
|
IMPORTANT: Any letter(s) immediately after a tooth number are DENTAL SURFACE abbreviations indicating a composite filling:
|
||||||
|
M = Mesial, D = Distal, L = Lingual, F = Facial, B = Buccal, O = Occlusal, V = Vestibular, I = Incisal
|
||||||
|
e.g. "#8 ML" → composite on tooth 8, mesial+lingual surfaces (NOT an insurance abbreviation)
|
||||||
|
e.g. "#14 MOD" → composite on tooth 14, mesial+occlusal+distal surfaces
|
||||||
|
e.g. "#5 F" → composite on tooth 5, facial surface
|
||||||
|
Whenever you see #[tooth number] followed by any combination of M/D/L/F/B/O/V/I, it is ALWAYS a composite filling surface notation
|
||||||
- #number always means a TOOTH number (never a case or pre-auth reference). When a single #number appears before a comma-separated list of procedures, apply it to EVERY procedure in the list.
|
- #number always means a TOOTH number (never a case or pre-auth reference). When a single #number appears before a comma-separated list of procedures, apply it to EVERY procedure in the list.
|
||||||
e.g. "#20 rct, post, crown" → ["#20 rct", "#20 post", "#20 crown"]
|
e.g. "#20 rct, post, crown" → ["#20 rct", "#20 post", "#20 crown"]
|
||||||
e.g. "preauth #20 rct, pos, crown" → ["#20 rct", "#20 pos", "#20 crown"]
|
e.g. "preauth #20 rct, pos, crown" → ["#20 rct", "#20 pos", "#20 crown"]
|
||||||
|
|||||||
@@ -87,6 +87,51 @@ router.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/payments-reports/patients-with-zero-balances
|
||||||
|
* Returns patients fully paid (balance <= 0) within the date/provider filter.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/patients-with-zero-balances",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
try {
|
||||||
|
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 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" });
|
||||||
|
|
||||||
|
const npiProviderId = req.query.npiProviderId
|
||||||
|
? Number(req.query.npiProviderId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const data = await storage.getPatientsWithZeroBalance(
|
||||||
|
limit,
|
||||||
|
cursor,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
npiProviderId
|
||||||
|
);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(
|
||||||
|
"GET /api/payments-reports/patients-with-zero-balances error:",
|
||||||
|
err?.message ?? err,
|
||||||
|
err?.stack
|
||||||
|
);
|
||||||
|
res.status(500).json({ message: "Failed to fetch zero-balance patients" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/payments-reports/by-doctor/balances
|
* GET /api/payments-reports/by-doctor/balances
|
||||||
* Query params:
|
* Query params:
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ export interface IPaymentsReportsStorage {
|
|||||||
npiProviderId?: number | null
|
npiProviderId?: number | null
|
||||||
): Promise<GetPatientBalancesResult>;
|
): Promise<GetPatientBalancesResult>;
|
||||||
|
|
||||||
|
getPatientsWithZeroBalance(
|
||||||
|
limit: number,
|
||||||
|
cursorToken?: string | null,
|
||||||
|
from?: Date | null,
|
||||||
|
to?: Date | null,
|
||||||
|
npiProviderId?: number | null
|
||||||
|
): Promise<GetPatientBalancesResult & { totalCharges: number; totalCollected: number }>;
|
||||||
|
|
||||||
getPatientsBalancesByDoctor(
|
getPatientsBalancesByDoctor(
|
||||||
staffId: number,
|
staffId: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
@@ -136,47 +144,35 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
const patientsCntRows = (await prisma.$queryRawUnsafe(patientsCountSql)) as { cnt: number }[];
|
const patientsCntRows = (await prisma.$queryRawUnsafe(patientsCountSql)) as { cnt: number }[];
|
||||||
const totalPatients = patientsCntRows?.[0]?.cnt ?? 0;
|
const totalPatients = patientsCntRows?.[0]?.cnt ?? 0;
|
||||||
|
|
||||||
const outstandingSql = `
|
// effective_balance per payment:
|
||||||
SELECT COALESCE(SUM(
|
// 0 if status='PAID' (user confirmed Pay In Full)
|
||||||
GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0)
|
// billed-collected if status!='PAID' and collected < billed
|
||||||
),0)::numeric(14,2) AS outstanding
|
// A patient has zero balance only when sum(effective_balance) = 0.
|
||||||
|
const combinedSql = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(pm.effective_balance),0)::numeric(14,2) AS outstanding,
|
||||||
|
COALESCE(SUM(pm.total_collected),0)::numeric(14,2) AS collected,
|
||||||
|
COUNT(CASE WHEN pm.effective_balance > 0 THEN 1 END)::int AS patients_with_balance
|
||||||
FROM (
|
FROM (
|
||||||
SELECT pay."patientId" AS patient_id,
|
SELECT
|
||||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
pay."patientId" AS patient_id,
|
||||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_collected,
|
||||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
SUM(
|
||||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
CASE WHEN pay.status = 'PAID' THEN 0
|
||||||
|
ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0)
|
||||||
|
END
|
||||||
|
)::numeric(14,2) AS effective_balance
|
||||||
FROM "Payment" pay
|
FROM "Payment" pay
|
||||||
${payWhereClause}
|
${payWhereClause}
|
||||||
GROUP BY pay."patientId"
|
GROUP BY pay."patientId"
|
||||||
) pm
|
) pm
|
||||||
`;
|
`;
|
||||||
const outstandingRows = (await prisma.$queryRawUnsafe(outstandingSql)) as { outstanding: string }[];
|
const combinedRows = (await prisma.$queryRawUnsafe(combinedSql)) as {
|
||||||
const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0);
|
outstanding: string; collected: string; patients_with_balance: number;
|
||||||
|
}[];
|
||||||
const collSql = `
|
const totalOutstanding = Number(combinedRows?.[0]?.outstanding ?? 0);
|
||||||
SELECT COALESCE(SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment"),0)::numeric(14,2) AS collected
|
const totalCollected = Number(combinedRows?.[0]?.collected ?? 0);
|
||||||
FROM "Payment" pay
|
const patientsWithBalance = combinedRows?.[0]?.patients_with_balance ?? 0;
|
||||||
${payWhereClause}
|
|
||||||
`;
|
|
||||||
const collRows = (await prisma.$queryRawUnsafe(collSql)) as { collected: string }[];
|
|
||||||
const totalCollected = Number(collRows?.[0]?.collected ?? 0);
|
|
||||||
|
|
||||||
const patientsWithBalanceSql = `
|
|
||||||
SELECT COUNT(*)::int AS cnt FROM (
|
|
||||||
SELECT pay."patientId" AS patient_id,
|
|
||||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
|
||||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
|
||||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
|
||||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
|
||||||
FROM "Payment" pay
|
|
||||||
${payWhereClause}
|
|
||||||
GROUP BY pay."patientId"
|
|
||||||
) t
|
|
||||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0
|
|
||||||
`;
|
|
||||||
const pwbRows = (await prisma.$queryRawUnsafe(patientsWithBalanceSql)) as { cnt: number }[];
|
|
||||||
const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0;
|
|
||||||
|
|
||||||
return { totalPatients, totalOutstanding, totalCollected, patientsWithBalance };
|
return { totalPatients, totalOutstanding, totalCollected, patientsWithBalance };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -236,6 +232,11 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
||||||
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(12,2) AS total_paid,
|
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(12,2) AS total_paid,
|
||||||
SUM(pay."adjustment")::numeric(12,2) AS total_adjusted,
|
SUM(pay."adjustment")::numeric(12,2) AS total_adjusted,
|
||||||
|
SUM(
|
||||||
|
CASE WHEN pay.status = 'PAID' THEN 0
|
||||||
|
ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0)
|
||||||
|
END
|
||||||
|
)::numeric(12,2) AS effective_balance,
|
||||||
MAX(pay."createdAt") AS last_payment_date
|
MAX(pay."createdAt") AS last_payment_date
|
||||||
FROM "Payment" pay
|
FROM "Payment" pay
|
||||||
${paymentWhereClause}
|
${paymentWhereClause}
|
||||||
@@ -243,9 +244,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
) pm
|
) pm
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Build keyset predicate if cursor provided.
|
|
||||||
// Ordering used: pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC, p.id DESC
|
|
||||||
// For keyset, we need to fetch rows strictly "less than" the cursor in this ordering.
|
|
||||||
let keysetPredicate = "";
|
let keysetPredicate = "";
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
const lp = cursor.lastPaymentDate
|
const lp = cursor.lastPaymentDate
|
||||||
@@ -253,10 +251,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
: "NULL";
|
: "NULL";
|
||||||
const id = Number(cursor.lastPatientId);
|
const id = Number(cursor.lastPatientId);
|
||||||
|
|
||||||
// We handle NULL last_payment_date ordering: since we use "NULLS LAST" in ORDER BY,
|
|
||||||
// rows with last_payment_date = NULL are considered *after* any non-null dates.
|
|
||||||
// To page correctly when cursor's lastPaymentDate is null, we compare accordingly.
|
|
||||||
// This predicate tries to cover both cases.
|
|
||||||
keysetPredicate = `
|
keysetPredicate = `
|
||||||
AND (
|
AND (
|
||||||
(pm.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND (
|
(pm.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND (
|
||||||
@@ -277,7 +271,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
COALESCE(pm.total_charges,0)::numeric(12,2) AS total_charges,
|
COALESCE(pm.total_charges,0)::numeric(12,2) AS total_charges,
|
||||||
COALESCE(pm.total_paid,0)::numeric(12,2) AS total_paid,
|
COALESCE(pm.total_paid,0)::numeric(12,2) AS total_paid,
|
||||||
COALESCE(pm.total_adjusted,0)::numeric(12,2) AS total_adjusted,
|
COALESCE(pm.total_adjusted,0)::numeric(12,2) AS total_adjusted,
|
||||||
(COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0))::numeric(12,2) AS current_balance,
|
COALESCE(pm.effective_balance,0)::numeric(12,2) AS current_balance,
|
||||||
pm.last_payment_date,
|
pm.last_payment_date,
|
||||||
apt.last_appointment_date
|
apt.last_appointment_date
|
||||||
FROM "Patient" p
|
FROM "Patient" p
|
||||||
@@ -287,7 +281,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
FROM "Appointment"
|
FROM "Appointment"
|
||||||
GROUP BY "patientId"
|
GROUP BY "patientId"
|
||||||
) apt ON apt.patient_id = p.id
|
) apt ON apt.patient_id = p.id
|
||||||
WHERE (COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0
|
WHERE COALESCE(pm.effective_balance,0) > 0
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p.id DESC`;
|
const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p.id DESC`;
|
||||||
@@ -350,18 +344,19 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// totalCount: count of patients with positive balance within same payment date filter
|
|
||||||
const countSql = `
|
const countSql = `
|
||||||
SELECT COUNT(*)::int AS cnt FROM (
|
SELECT COUNT(*)::int AS cnt FROM (
|
||||||
SELECT pay."patientId" AS patient_id,
|
SELECT
|
||||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
SUM(
|
||||||
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_paid,
|
CASE WHEN pay.status = 'PAID' THEN 0
|
||||||
SUM(pay."adjustment")::numeric(14,2) AS total_adjusted
|
ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0)
|
||||||
|
END
|
||||||
|
)::numeric(14,2) AS effective_balance
|
||||||
FROM "Payment" pay
|
FROM "Payment" pay
|
||||||
${paymentWhereClause}
|
${paymentWhereClause}
|
||||||
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 t.effective_balance > 0;
|
||||||
`;
|
`;
|
||||||
const cntRows = (await prisma.$queryRawUnsafe(countSql)) as {
|
const cntRows = (await prisma.$queryRawUnsafe(countSql)) as {
|
||||||
cnt: number;
|
cnt: number;
|
||||||
@@ -380,6 +375,169 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns patients whose current balance is zero or negative (fully paid/over-paid).
|
||||||
|
*/
|
||||||
|
async getPatientsWithZeroBalance(
|
||||||
|
limit = 25,
|
||||||
|
cursorToken?: string | null,
|
||||||
|
from?: Date | null,
|
||||||
|
to?: Date | null,
|
||||||
|
npiProviderId?: number | null
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
type RawRow = {
|
||||||
|
patient_id: number;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
total_charges: string;
|
||||||
|
total_paid: string;
|
||||||
|
total_adjusted: string;
|
||||||
|
current_balance: string;
|
||||||
|
last_payment_date: Date | null;
|
||||||
|
last_appointment_date: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
||||||
|
const cursor = decodeCursor(cursorToken);
|
||||||
|
|
||||||
|
const hasFrom = from !== undefined && from !== null;
|
||||||
|
const hasTo = to !== undefined && to !== null;
|
||||||
|
|
||||||
|
const fromStart = isoStartOfDayLiteral(from);
|
||||||
|
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||||
|
|
||||||
|
const payConditions: string[] = [];
|
||||||
|
if (hasFrom) payConditions.push(`pay."createdAt" >= ${fromStart}`);
|
||||||
|
if (hasTo) payConditions.push(`pay."createdAt" <= ${toNextStart}`);
|
||||||
|
if (npiProviderId) payConditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`);
|
||||||
|
const paymentWhereClause = payConditions.length ? `WHERE ${payConditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const pmSubquery = `
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
pay."patientId" AS patient_id,
|
||||||
|
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
||||||
|
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(12,2) AS total_paid,
|
||||||
|
SUM(pay."adjustment")::numeric(12,2) AS total_adjusted,
|
||||||
|
SUM(
|
||||||
|
CASE WHEN pay.status = 'PAID' THEN 0
|
||||||
|
ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0)
|
||||||
|
END
|
||||||
|
)::numeric(12,2) AS effective_balance,
|
||||||
|
MAX(pay."createdAt") AS last_payment_date
|
||||||
|
FROM "Payment" pay
|
||||||
|
${paymentWhereClause}
|
||||||
|
GROUP BY pay."patientId"
|
||||||
|
) pm
|
||||||
|
`;
|
||||||
|
|
||||||
|
let keysetPredicate = "";
|
||||||
|
if (cursor) {
|
||||||
|
const lp = cursor.lastPaymentDate ? `'${cursor.lastPaymentDate}'` : "NULL";
|
||||||
|
const id = Number(cursor.lastPatientId);
|
||||||
|
keysetPredicate = `
|
||||||
|
AND (
|
||||||
|
(pm.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND (
|
||||||
|
pm.last_payment_date < ${lp}
|
||||||
|
OR (pm.last_payment_date = ${lp} AND p.id < ${id})
|
||||||
|
))
|
||||||
|
OR (pm.last_payment_date IS NULL AND ${lp} IS NOT NULL)
|
||||||
|
OR (pm.last_payment_date IS NULL AND ${lp} IS NULL AND p.id < ${id})
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSelect = `
|
||||||
|
SELECT
|
||||||
|
p.id AS patient_id,
|
||||||
|
p."firstName" AS first_name,
|
||||||
|
p."lastName" AS last_name,
|
||||||
|
COALESCE(pm.total_charges,0)::numeric(12,2) AS total_charges,
|
||||||
|
COALESCE(pm.total_paid,0)::numeric(12,2) AS total_paid,
|
||||||
|
COALESCE(pm.total_adjusted,0)::numeric(12,2) AS total_adjusted,
|
||||||
|
0::numeric(12,2) AS current_balance,
|
||||||
|
pm.last_payment_date,
|
||||||
|
apt.last_appointment_date
|
||||||
|
FROM "Patient" p
|
||||||
|
INNER JOIN ${pmSubquery} ON pm.patient_id = p.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date
|
||||||
|
FROM "Appointment"
|
||||||
|
GROUP BY "patientId"
|
||||||
|
) apt ON apt.patient_id = p.id
|
||||||
|
WHERE COALESCE(pm.effective_balance, 0) = 0
|
||||||
|
`;
|
||||||
|
|
||||||
|
const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p.id DESC`;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
${baseSelect}
|
||||||
|
${cursor ? keysetPredicate : ""}
|
||||||
|
${orderBy}
|
||||||
|
LIMIT ${safeLimit};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = (await prisma.$queryRawUnsafe(query)) as RawRow[];
|
||||||
|
|
||||||
|
let nextCursor: string | null = null;
|
||||||
|
if (rows.length === safeLimit) {
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
if (last) {
|
||||||
|
nextCursor = encodeCursor({
|
||||||
|
lastPaymentDate: last.last_payment_date ? new Date(last.last_payment_date).toISOString() : null,
|
||||||
|
lastPatientId: Number(last.patient_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = rows.length === safeLimit;
|
||||||
|
|
||||||
|
const balances: PatientBalanceRow[] = rows.map((r) => ({
|
||||||
|
patientId: Number(r.patient_id),
|
||||||
|
firstName: r.first_name,
|
||||||
|
lastName: r.last_name,
|
||||||
|
totalCharges: Number(r.total_charges ?? 0),
|
||||||
|
totalPayments: Number(r.total_paid ?? 0),
|
||||||
|
totalAdjusted: Number(r.total_adjusted ?? 0),
|
||||||
|
currentBalance: Number(r.current_balance ?? 0),
|
||||||
|
lastPaymentDate: r.last_payment_date ? new Date(r.last_payment_date).toISOString() : null,
|
||||||
|
lastAppointmentDate: r.last_appointment_date ? new Date(r.last_appointment_date).toISOString() : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const countAndTotalsSql = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS cnt,
|
||||||
|
COALESCE(SUM(t.total_charges),0)::numeric(14,2) AS total_charges,
|
||||||
|
COALESCE(SUM(t.total_paid),0)::numeric(14,2) AS total_collected
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
pay."patientId" AS patient_id,
|
||||||
|
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||||
|
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_paid,
|
||||||
|
SUM(
|
||||||
|
CASE WHEN pay.status = 'PAID' THEN 0
|
||||||
|
ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0)
|
||||||
|
END
|
||||||
|
)::numeric(14,2) AS effective_balance
|
||||||
|
FROM "Payment" pay
|
||||||
|
${paymentWhereClause}
|
||||||
|
GROUP BY pay."patientId"
|
||||||
|
) t
|
||||||
|
WHERE COALESCE(t.effective_balance, 0) = 0;
|
||||||
|
`;
|
||||||
|
const cntRows = (await prisma.$queryRawUnsafe(countAndTotalsSql)) as { cnt: number; total_charges: string; total_collected: string }[];
|
||||||
|
const totalCount = cntRows?.[0]?.cnt ?? 0;
|
||||||
|
const totalCharges = Number(cntRows?.[0]?.total_charges ?? 0);
|
||||||
|
const totalCollected = Number(cntRows?.[0]?.total_collected ?? 0);
|
||||||
|
|
||||||
|
return { balances, totalCount, nextCursor, hasMore, totalCharges, totalCollected };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[paymentsReportsStorage.getPatientsWithZeroBalance] error:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return just the paged balances for a doctor (same logic/filters as previous single-query approach)
|
* Return just the paged balances for a doctor (same logic/filters as previous single-query approach)
|
||||||
*/
|
*/
|
||||||
@@ -488,6 +646,11 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||||
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_paid,
|
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_paid,
|
||||||
SUM(pay."adjustment")::numeric(14,2) AS total_adjusted,
|
SUM(pay."adjustment")::numeric(14,2) AS total_adjusted,
|
||||||
|
SUM(
|
||||||
|
CASE WHEN pay.status = 'PAID' THEN 0
|
||||||
|
ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0)
|
||||||
|
END
|
||||||
|
)::numeric(14,2) AS effective_balance,
|
||||||
MAX(pay."createdAt") AS last_payment_date
|
MAX(pay."createdAt") AS last_payment_date
|
||||||
FROM "Payment" pay
|
FROM "Payment" pay
|
||||||
JOIN "Claim" c ON pay."claimId" = c.id
|
JOIN "Claim" c ON pay."claimId" = c.id
|
||||||
@@ -510,7 +673,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
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.effective_balance, 0)::numeric(14,2) AS current_balance,
|
||||||
pa.last_payment_date,
|
pa.last_payment_date,
|
||||||
-- epoch milliseconds for last payment date (NULL when last_payment_date is NULL)
|
-- epoch milliseconds for last payment date (NULL when last_payment_date is NULL)
|
||||||
(CASE WHEN pa.last_payment_date IS NULL THEN NULL
|
(CASE WHEN pa.last_payment_date IS NULL THEN NULL
|
||||||
@@ -678,10 +841,12 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
payments_agg AS (
|
payments_agg AS (
|
||||||
SELECT
|
SELECT
|
||||||
pay."patientId" AS patient_id,
|
pay."patientId" AS patient_id,
|
||||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_collected,
|
||||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
SUM(
|
||||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
CASE WHEN pay.status = 'PAID' THEN 0
|
||||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0)
|
||||||
|
END
|
||||||
|
)::numeric(14,2) AS effective_balance
|
||||||
FROM "Payment" pay
|
FROM "Payment" pay
|
||||||
JOIN "Claim" c ON pay."claimId" = c.id
|
JOIN "Claim" c ON pay."claimId" = c.id
|
||||||
WHERE c."staffId" = ${Number(staffId)}
|
WHERE c."staffId" = ${Number(staffId)}
|
||||||
@@ -689,10 +854,10 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
GROUP BY pay."patientId"
|
GROUP BY pay."patientId"
|
||||||
)
|
)
|
||||||
SELECT json_build_object(
|
SELECT json_build_object(
|
||||||
'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0),
|
'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0),
|
||||||
'totalOutstanding', COALESCE(SUM(GREATEST(COALESCE(pa.total_charges,0) - COALESCE(pa.mh_paid,0) - COALESCE(pa.copayment,0) - COALESCE(pa.adjustment,0), 0)),0)::text,
|
'totalOutstanding', COALESCE(SUM(pa.effective_balance),0)::text,
|
||||||
'totalCollected', COALESCE(SUM(COALESCE(pa.mh_paid,0) + COALESCE(pa.copayment,0)),0)::text,
|
'totalCollected', COALESCE(SUM(pa.total_collected),0)::text,
|
||||||
'patientsWithBalance', COALESCE(SUM(CASE WHEN (COALESCE(pa.total_charges,0) - COALESCE(pa.mh_paid,0) - COALESCE(pa.copayment,0) - COALESCE(pa.adjustment,0)) > 0 THEN 1 ELSE 0 END),0)
|
'patientsWithBalance',COALESCE(SUM(CASE WHEN pa.effective_balance > 0 THEN 1 ELSE 0 END),0)
|
||||||
) AS summary_json
|
) AS summary_json
|
||||||
FROM payments_agg pa;
|
FROM payments_agg pa;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import type { PatientBalanceRow } from "@repo/db/types";
|
||||||
|
import PatientsBalancesList from "./patients-balances-list";
|
||||||
|
import ExportReportButton from "./export-button";
|
||||||
|
|
||||||
|
type Resp = {
|
||||||
|
balances: PatientBalanceRow[];
|
||||||
|
totalCount?: number;
|
||||||
|
nextCursor?: string | null;
|
||||||
|
hasMore?: boolean;
|
||||||
|
totalCharges?: number;
|
||||||
|
totalCollected?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtCurrency(v: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PatientsNoBalanceReport({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
npiProviderId,
|
||||||
|
}: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
npiProviderId?: number | null;
|
||||||
|
}) {
|
||||||
|
const balancesPerPage = 10;
|
||||||
|
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
|
||||||
|
const [cursorIndex, setCursorIndex] = useState(0);
|
||||||
|
const currentCursor = cursorStack[cursorIndex] ?? null;
|
||||||
|
const pageIndex = cursorIndex + 1;
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery<Resp, Error>({
|
||||||
|
queryKey: [
|
||||||
|
"/api/payments-reports/patients-with-zero-balances",
|
||||||
|
currentCursor,
|
||||||
|
balancesPerPage,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
npiProviderId ?? "all",
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", String(balancesPerPage));
|
||||||
|
if (currentCursor) params.set("cursor", currentCursor);
|
||||||
|
if (startDate) params.set("from", startDate);
|
||||||
|
if (endDate) params.set("to", endDate);
|
||||||
|
if (npiProviderId) params.set("npiProviderId", String(npiProviderId));
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/payments-reports/patients-with-zero-balances?${params.toString()}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ message: "Failed to load" }));
|
||||||
|
throw new Error(body.message || "Failed to load zero-balance patients");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = data?.balances ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? undefined;
|
||||||
|
const nextCursor = data?.nextCursor ?? null;
|
||||||
|
const hasMore = data?.hasMore ?? false;
|
||||||
|
const totalCharges = data?.totalCharges ?? 0;
|
||||||
|
const totalCollected = data?.totalCollected ?? 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCursorStack([null]);
|
||||||
|
setCursorIndex(0);
|
||||||
|
refetch();
|
||||||
|
}, [startDate, endDate, npiProviderId]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
const isLastKnown = cursorIndex === cursorStack.length - 1;
|
||||||
|
if (isLastKnown) {
|
||||||
|
if (nextCursor) {
|
||||||
|
setCursorStack((s) => [...s, nextCursor]);
|
||||||
|
setCursorIndex((i) => i + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCursorIndex((i) => i + 1);
|
||||||
|
}
|
||||||
|
}, [cursorIndex, cursorStack.length, nextCursor]);
|
||||||
|
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
setCursorIndex((i) => Math.max(0, i - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalized = balances.map((b) => {
|
||||||
|
const currentBalance = Number(b.currentBalance ?? 0);
|
||||||
|
const totalChargesRow = Number(b.totalCharges ?? 0);
|
||||||
|
const totalPayments =
|
||||||
|
b.totalPayments != null
|
||||||
|
? Number(b.totalPayments)
|
||||||
|
: Number(totalChargesRow - currentBalance);
|
||||||
|
return {
|
||||||
|
id: b.patientId,
|
||||||
|
name: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(),
|
||||||
|
currentBalance,
|
||||||
|
totalCharges: totalChargesRow,
|
||||||
|
totalPayments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<PatientsBalancesList
|
||||||
|
rows={normalized}
|
||||||
|
reportType="patients_no_balance"
|
||||||
|
loading={isLoading}
|
||||||
|
error={isError ? "Failed to load zero-balance patients." : false}
|
||||||
|
emptyMessage="No patients with zero balance for the selected date range."
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
perPage={balancesPerPage}
|
||||||
|
total={totalCount}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
onNext={handleNext}
|
||||||
|
hasPrev={cursorIndex > 0}
|
||||||
|
hasNext={hasMore}
|
||||||
|
headerRight={
|
||||||
|
<ExportReportButton
|
||||||
|
reportType="patients_no_balance"
|
||||||
|
from={startDate}
|
||||||
|
to={endDate}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aggregate summary for the full zero-balance set */}
|
||||||
|
{!isLoading && !isError && (totalCount ?? 0) > 0 && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg px-5 py-3 flex flex-wrap gap-6 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Total patients paid in full: </span>
|
||||||
|
<span className="font-semibold text-green-700">{totalCount}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Total charges billed: </span>
|
||||||
|
<span className="font-semibold text-gray-800">{fmtCurrency(totalCharges)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Total collected: </span>
|
||||||
|
<span className="font-semibold text-green-700">{fmtCurrency(totalCollected)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,21 +10,28 @@ type SummaryResp = {
|
|||||||
totalCollected?: number;
|
totalCollected?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ZeroBalResp = {
|
||||||
|
totalCount?: number;
|
||||||
|
totalCharges?: number;
|
||||||
|
totalCollected?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReportType = string;
|
||||||
|
|
||||||
function fmtCurrency(v: number) {
|
function fmtCurrency(v: number) {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(v);
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SummaryCards({
|
export default function SummaryCards({
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
npiProviderId,
|
npiProviderId,
|
||||||
|
selectedReportType,
|
||||||
}: {
|
}: {
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
npiProviderId?: number | null;
|
npiProviderId?: number | null;
|
||||||
|
selectedReportType?: ReportType;
|
||||||
}) {
|
}) {
|
||||||
const { data, isLoading, isError } = useQuery<SummaryResp, Error>({
|
const { data, isLoading, isError } = useQuery<SummaryResp, Error>({
|
||||||
queryKey: ["/api/payments-reports/summary", startDate, endDate, npiProviderId ?? "all"],
|
queryKey: ["/api/payments-reports/summary", startDate, endDate, npiProviderId ?? "all"],
|
||||||
@@ -33,12 +40,9 @@ export default function SummaryCards({
|
|||||||
if (startDate) params.set("from", startDate);
|
if (startDate) params.set("from", startDate);
|
||||||
if (endDate) params.set("to", endDate);
|
if (endDate) params.set("to", endDate);
|
||||||
if (npiProviderId) params.set("npiProviderId", String(npiProviderId));
|
if (npiProviderId) params.set("npiProviderId", String(npiProviderId));
|
||||||
const endpoint = `/api/payments-reports/summary?${params.toString()}`;
|
const res = await apiRequest("GET", `/api/payments-reports/summary?${params.toString()}`);
|
||||||
const res = await apiRequest("GET", endpoint);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res
|
const body = await res.json().catch(() => ({ message: "Failed to load dashboard summary" }));
|
||||||
.json()
|
|
||||||
.catch(() => ({ message: "Failed to load dashboard summary" }));
|
|
||||||
throw new Error(body?.message ?? "Failed to load dashboard summary");
|
throw new Error(body?.message ?? "Failed to load dashboard summary");
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -46,29 +50,44 @@ export default function SummaryCards({
|
|||||||
enabled: Boolean(startDate && endDate),
|
enabled: Boolean(startDate && endDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPatients = data?.totalPatients ?? 0;
|
// Fetch zero-balance aggregates only when that report is active
|
||||||
|
const showZeroBalSummary = selectedReportType === "patients_no_balance";
|
||||||
|
const { data: zeroBalData, isLoading: zeroBalLoading } = useQuery<ZeroBalResp, Error>({
|
||||||
|
queryKey: ["/api/payments-reports/patients-with-zero-balances/summary", startDate, endDate, npiProviderId ?? "all"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", "1");
|
||||||
|
if (startDate) params.set("from", startDate);
|
||||||
|
if (endDate) params.set("to", endDate);
|
||||||
|
if (npiProviderId) params.set("npiProviderId", String(npiProviderId));
|
||||||
|
const res = await apiRequest("GET", `/api/payments-reports/patients-with-zero-balances?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to load zero balance summary");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: Boolean(startDate && endDate && showZeroBalSummary),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPatients = data?.totalPatients ?? 0;
|
||||||
const patientsWithBalance = data?.patientsWithBalance ?? 0;
|
const patientsWithBalance = data?.patientsWithBalance ?? 0;
|
||||||
const patientsNoBalance = Math.max(
|
const patientsNoBalance = Math.max(0, totalPatients - patientsWithBalance);
|
||||||
0,
|
const totalOutstanding = data?.totalOutstanding ?? 0;
|
||||||
(data?.totalPatients ?? 0) - (data?.patientsWithBalance ?? 0)
|
const totalCollected = data?.totalCollected ?? 0;
|
||||||
);
|
|
||||||
const totalOutstanding = data?.totalOutstanding ?? 0;
|
const zeroBalCount = zeroBalData?.totalCount ?? 0;
|
||||||
const totalCollected = data?.totalCollected ?? 0;
|
const zeroBalBilled = zeroBalData?.totalCharges ?? 0;
|
||||||
|
const zeroBalCollected = zeroBalData?.totalCollected ?? 0;
|
||||||
|
|
||||||
|
const isWithBalance = selectedReportType === "patients_with_balance";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="pt-4 pb-4">
|
<Card className="pt-4 pb-4">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Heading */}
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h2 className="text-base font-semibold text-gray-800">
|
<h2 className="text-base font-semibold text-gray-800">Report summary</h2>
|
||||||
Report summary
|
<p className="text-sm text-gray-500">Data covers the selected time frame</p>
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Data covers the selected time frame
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats grid */}
|
{/* Global stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg font-semibold text-blue-600">
|
<div className="text-lg font-semibold text-blue-600">
|
||||||
@@ -77,21 +96,21 @@ export default function SummaryCards({
|
|||||||
<p className="text-sm text-gray-600">Total Patients</p>
|
<p className="text-sm text-gray-600">Total Patients</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className={`text-center rounded-lg py-1 ${isWithBalance ? "bg-red-50 ring-1 ring-red-300" : ""}`}>
|
||||||
<div className="text-lg font-semibold text-red-600">
|
<div className="text-lg font-semibold text-red-600">
|
||||||
{isLoading ? "—" : patientsWithBalance}
|
{isLoading ? "—" : patientsWithBalance}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">With Balance</p>
|
<p className="text-sm text-gray-600">With Balance</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className={`text-center rounded-lg py-1 ${showZeroBalSummary ? "bg-green-50 ring-1 ring-green-300" : ""}`}>
|
||||||
<div className="text-lg font-semibold text-green-600">
|
<div className="text-lg font-semibold text-green-600">
|
||||||
{isLoading ? "—" : patientsNoBalance}
|
{isLoading ? "—" : patientsNoBalance}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">Zero Balance</p>
|
<p className="text-sm text-gray-600">Zero Balance</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className={`text-center rounded-lg py-1 ${isWithBalance ? "bg-orange-50 ring-1 ring-orange-300" : ""}`}>
|
||||||
<div className="text-lg font-semibold text-orange-600">
|
<div className="text-lg font-semibold text-orange-600">
|
||||||
{isLoading ? "—" : fmtCurrency(totalOutstanding)}
|
{isLoading ? "—" : fmtCurrency(totalOutstanding)}
|
||||||
</div>
|
</div>
|
||||||
@@ -106,6 +125,35 @@ export default function SummaryCards({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zero-balance detail row */}
|
||||||
|
{showZeroBalSummary && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-green-100">
|
||||||
|
<p className="text-xs font-semibold text-green-700 uppercase tracking-wide mb-2">
|
||||||
|
Zero Balance — Paid in Full Summary
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center bg-green-50 rounded-lg py-2">
|
||||||
|
<div className="text-lg font-semibold text-green-700">
|
||||||
|
{zeroBalLoading ? "—" : zeroBalCount}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Patients Paid in Full</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center bg-green-50 rounded-lg py-2">
|
||||||
|
<div className="text-lg font-semibold text-gray-700">
|
||||||
|
{zeroBalLoading ? "—" : fmtCurrency(zeroBalBilled)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Total Billed</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center bg-green-50 rounded-lg py-2">
|
||||||
|
<div className="text-lg font-semibold text-green-700">
|
||||||
|
{zeroBalLoading ? "—" : fmtCurrency(zeroBalCollected)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Total Collected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isError && (
|
{isError && (
|
||||||
<div className="mt-3 text-sm text-red-600">
|
<div className="mt-3 text-sm text-red-600">
|
||||||
Failed to load summary. Check server or network.
|
Failed to load summary. Check server or network.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import ReportConfig from "@/components/reports/report-config";
|
import ReportConfig from "@/components/reports/report-config";
|
||||||
import PatientsWithBalanceReport from "@/components/reports/patients-with-balance-report";
|
import PatientsWithBalanceReport from "@/components/reports/patients-with-balance-report";
|
||||||
|
import PatientsNoBalanceReport from "@/components/reports/patients-no-balance-report";
|
||||||
import SummaryCards from "@/components/reports/summary-cards";
|
import SummaryCards from "@/components/reports/summary-cards";
|
||||||
import CommissionSection from "@/components/reports/commission-section";
|
import CommissionSection from "@/components/reports/commission-section";
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export default function ReportPage() {
|
|||||||
|
|
||||||
{/* SINGLE authoritative SummaryCards instance for the page */}
|
{/* SINGLE authoritative SummaryCards instance for the page */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<SummaryCards startDate={startDate} endDate={endDate} npiProviderId={npiProviderId} />
|
<SummaryCards startDate={startDate} endDate={endDate} npiProviderId={npiProviderId} selectedReportType={selectedReportType} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -74,7 +75,9 @@ export default function ReportPage() {
|
|||||||
<PatientsWithBalanceReport startDate={startDate} endDate={endDate} npiProviderId={npiProviderId} />
|
<PatientsWithBalanceReport startDate={startDate} endDate={endDate} npiProviderId={npiProviderId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add other report components here as needed */}
|
{selectedReportType === "patients_no_balance" && (
|
||||||
|
<PatientsNoBalanceReport startDate={startDate} endDate={endDate} npiProviderId={npiProviderId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Commission section */}
|
{/* Commission section */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"generatorVersion": "1.0.0",
|
"generatorVersion": "1.0.0",
|
||||||
"generatedAt": "2026-06-27T02:57:45.959Z",
|
"generatedAt": "2026-06-27T03:13:10.416Z",
|
||||||
"outputPath": "/home/gg/Desktop/DentalManagementMH06/packages/db/shared",
|
"outputPath": "/home/gg/Desktop/DentalManagementMH06/packages/db/shared",
|
||||||
"files": [
|
"files": [
|
||||||
"schemas/enums/TransactionIsolationLevel.schema.ts",
|
"schemas/enums/TransactionIsolationLevel.schema.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user