feat(report page) - export button added
This commit is contained in:
99
apps/Backend/src/routes/export-payments-reports.ts
Normal file
99
apps/Backend/src/routes/export-payments-reports.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/reports/export
|
||||||
|
* query:
|
||||||
|
* - type = patients_with_balance | collections_by_doctor
|
||||||
|
* - from, to = optional ISO date strings (YYYY-MM-DD)
|
||||||
|
* - staffId = required for collections_by_doctor
|
||||||
|
* - format = csv (we expect csv; if missing default to csv)
|
||||||
|
*/
|
||||||
|
function escapeCsvCell(v: any) {
|
||||||
|
if (v === null || v === undefined) return "";
|
||||||
|
const s = String(v).replace(/\r?\n/g, " ");
|
||||||
|
if (s.includes('"') || s.includes(",") || s.includes("\n")) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/export", async (req: Request, res: Response): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const type = String(req.query.type || "");
|
||||||
|
const from = req.query.from ? new Date(String(req.query.from)) : undefined;
|
||||||
|
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
|
||||||
|
const staffId = req.query.staffId ? Number(req.query.staffId) : undefined;
|
||||||
|
const format = String(req.query.format || "csv").toLowerCase();
|
||||||
|
|
||||||
|
if (format !== "csv") {
|
||||||
|
return res.status(400).json({ message: "Only CSV export is supported" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let patientsSummary: any[] = [];
|
||||||
|
|
||||||
|
if (type === "patients_with_balance") {
|
||||||
|
patientsSummary = await storage.fetchAllPatientsWithBalances(from, to);
|
||||||
|
} else if (type === "collections_by_doctor") {
|
||||||
|
if (!staffId || !Number.isFinite(staffId) || staffId <= 0) {
|
||||||
|
return res.status(400).json({ message: "Missing or invalid staffId for collections_by_doctor" });
|
||||||
|
}
|
||||||
|
patientsSummary = await storage.fetchAllPatientsForDoctor(staffId, from, to);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ message: "Unsupported report type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const patientsWithFinancials = await storage.buildExportRowsForPatients(patientsSummary, 5000);
|
||||||
|
|
||||||
|
// Build CSV - flattened rows
|
||||||
|
// columns: patientId, patientName, currentBalance, type, date, procedureCode, billed, paid, adjusted, totalDue, status
|
||||||
|
const header = [
|
||||||
|
"patientId",
|
||||||
|
"patientName",
|
||||||
|
"currentBalance",
|
||||||
|
"type",
|
||||||
|
"date",
|
||||||
|
"procedureCode",
|
||||||
|
"billed",
|
||||||
|
"paid",
|
||||||
|
"adjusted",
|
||||||
|
"totalDue",
|
||||||
|
"status",
|
||||||
|
];
|
||||||
|
|
||||||
|
const lines = [header.join(",")];
|
||||||
|
|
||||||
|
for (const p of patientsWithFinancials) {
|
||||||
|
const name = `${p.firstName ?? ""} ${p.lastName ?? ""}`.trim();
|
||||||
|
for (const fr of p.financialRows) {
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
escapeCsvCell(p.patientId),
|
||||||
|
escapeCsvCell(name),
|
||||||
|
(Number(p.currentBalance ?? 0)).toFixed(2),
|
||||||
|
escapeCsvCell(fr.type),
|
||||||
|
escapeCsvCell(fr.date),
|
||||||
|
escapeCsvCell(fr.procedureCode),
|
||||||
|
(Number(fr.billed ?? 0)).toFixed(2),
|
||||||
|
(Number(fr.paid ?? 0)).toFixed(2),
|
||||||
|
(Number(fr.adjusted ?? 0)).toFixed(2),
|
||||||
|
(Number(fr.totalDue ?? 0)).toFixed(2),
|
||||||
|
escapeCsvCell(fr.status),
|
||||||
|
].join(",")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fname = `report-${type}-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${fname}"`);
|
||||||
|
return res.send(lines.join("\n"));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[/api/reports/export] error:", err?.message ?? err, err?.stack);
|
||||||
|
return res.status(500).json({ message: "Export error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -14,6 +14,7 @@ import notificationsRoutes from "./notifications";
|
|||||||
import paymentOcrRoutes from "./paymentOcrExtraction";
|
import paymentOcrRoutes from "./paymentOcrExtraction";
|
||||||
import cloudStorageRoutes from "./cloud-storage";
|
import cloudStorageRoutes from "./cloud-storage";
|
||||||
import paymentsReportsRoutes from "./payments-reports";
|
import paymentsReportsRoutes from "./payments-reports";
|
||||||
|
import exportPaymentsReportsRoutes from "./export-payments-reports";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -32,5 +33,6 @@ router.use("/notifications", notificationsRoutes);
|
|||||||
router.use("/payment-ocr", paymentOcrRoutes);
|
router.use("/payment-ocr", paymentOcrRoutes);
|
||||||
router.use("/cloud-storage", cloudStorageRoutes);
|
router.use("/cloud-storage", cloudStorageRoutes);
|
||||||
router.use("/payments-reports", paymentsReportsRoutes);
|
router.use("/payments-reports", paymentsReportsRoutes);
|
||||||
|
router.use("/export-payments-reports", exportPaymentsReportsRoutes);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
142
apps/Backend/src/storage/export-payments-reports-storage.ts
Normal file
142
apps/Backend/src/storage/export-payments-reports-storage.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { storage } from "../storage";
|
||||||
|
import { getPatientFinancialRowsFn } from "./patients-storage";
|
||||||
|
|
||||||
|
type PatientSummaryRow = {
|
||||||
|
patientId: number;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
currentBalance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page through storage.getPatientsWithBalances to return the full list (not paginated).
|
||||||
|
* Uses the same filters (from/to) as the existing queries.
|
||||||
|
*/
|
||||||
|
export async function fetchAllPatientsWithBalances(
|
||||||
|
from?: Date | null,
|
||||||
|
to?: Date | null,
|
||||||
|
pageSize = 500
|
||||||
|
): Promise<PatientSummaryRow[]> {
|
||||||
|
const all: PatientSummaryRow[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
while (true) {
|
||||||
|
const page = await storage.getPatientsWithBalances(
|
||||||
|
pageSize,
|
||||||
|
cursor,
|
||||||
|
from,
|
||||||
|
to
|
||||||
|
);
|
||||||
|
if (!page) break;
|
||||||
|
if (Array.isArray(page.balances) && page.balances.length) {
|
||||||
|
for (const b of page.balances) {
|
||||||
|
all.push({
|
||||||
|
patientId: Number(b.patientId),
|
||||||
|
firstName: b.firstName ?? null,
|
||||||
|
lastName: b.lastName ?? null,
|
||||||
|
currentBalance: Number(b.currentBalance ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!page.hasMore || !page.nextCursor) break;
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page through storage.getBalancesAndSummaryByDoctor to return full patient list for the staff.
|
||||||
|
*/
|
||||||
|
export async function fetchAllPatientsForDoctor(
|
||||||
|
staffId: number,
|
||||||
|
from?: Date | null,
|
||||||
|
to?: Date | null,
|
||||||
|
pageSize = 500
|
||||||
|
): Promise<PatientSummaryRow[]> {
|
||||||
|
const all: PatientSummaryRow[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
while (true) {
|
||||||
|
const page = await storage.getBalancesAndSummaryByDoctor(
|
||||||
|
staffId,
|
||||||
|
pageSize,
|
||||||
|
cursor,
|
||||||
|
from,
|
||||||
|
to
|
||||||
|
);
|
||||||
|
if (!page) break;
|
||||||
|
if (Array.isArray(page.balances) && page.balances.length) {
|
||||||
|
for (const b of page.balances) {
|
||||||
|
all.push({
|
||||||
|
patientId: Number(b.patientId),
|
||||||
|
firstName: b.firstName ?? null,
|
||||||
|
lastName: b.lastName ?? null,
|
||||||
|
currentBalance: Number(b.currentBalance ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!page.hasMore || !page.nextCursor) break;
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each patient, call the existing function to fetch full financial rows.
|
||||||
|
* This uses your existing getPatientFinancialRowsFn which returns { rows, totalCount }.
|
||||||
|
*
|
||||||
|
* The function returns an array of:
|
||||||
|
* { patientId, firstName, lastName, currentBalance, financialRows: Array<{ type, date, procedureCode, billed, paid, adjusted, totalDue, status }> }
|
||||||
|
*/
|
||||||
|
export async function buildExportRowsForPatients(
|
||||||
|
patients: PatientSummaryRow[],
|
||||||
|
perPatientLimit = 5000
|
||||||
|
) {
|
||||||
|
const out: Array<any> = [];
|
||||||
|
|
||||||
|
for (const p of patients) {
|
||||||
|
const patientId = Number(p.patientId);
|
||||||
|
const { rows } = await getPatientFinancialRowsFn(
|
||||||
|
patientId,
|
||||||
|
perPatientLimit,
|
||||||
|
0
|
||||||
|
); // returns rows array similarly to your earlier code
|
||||||
|
|
||||||
|
const frs = rows.flatMap((r: any) => {
|
||||||
|
const svc = r.service_lines ?? [];
|
||||||
|
if (svc.length > 0) {
|
||||||
|
return svc.map((sl: any) => ({
|
||||||
|
type: r.type,
|
||||||
|
date: r.date ? new Date(r.date).toLocaleDateString() : "",
|
||||||
|
procedureCode: String(sl.procedureCode ?? "-"),
|
||||||
|
billed: Number(sl.totalBilled ?? 0),
|
||||||
|
paid: Number(sl.totalPaid ?? 0),
|
||||||
|
adjusted: Number(sl.totalAdjusted ?? 0),
|
||||||
|
totalDue: Number(sl.totalDue ?? 0),
|
||||||
|
status: sl.status ?? r.status ?? "",
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: r.type,
|
||||||
|
date: r.date ? new Date(r.date).toLocaleDateString() : "",
|
||||||
|
procedureCode: "-",
|
||||||
|
billed: Number(r.total_billed ?? r.totalBilled ?? 0),
|
||||||
|
paid: Number(r.total_paid ?? r.totalPaid ?? 0),
|
||||||
|
adjusted: Number(r.total_adjusted ?? r.totalAdjusted ?? 0),
|
||||||
|
totalDue: Number(r.total_due ?? r.totalDue ?? 0),
|
||||||
|
status: r.status ?? "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
patientId,
|
||||||
|
firstName: p.firstName,
|
||||||
|
lastName: p.lastName,
|
||||||
|
currentBalance: Number(p.currentBalance ?? 0),
|
||||||
|
financialRows: frs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { databaseBackupStorage } from './database-backup-storage';
|
|||||||
import { notificationsStorage } from './notifications-storage';
|
import { notificationsStorage } from './notifications-storage';
|
||||||
import { cloudStorageStorage } from './cloudStorage-storage';
|
import { cloudStorageStorage } from './cloudStorage-storage';
|
||||||
import { paymentsReportsStorage } from './payments-reports-storage';
|
import { paymentsReportsStorage } from './payments-reports-storage';
|
||||||
|
import * as exportPaymentsReportsStorage from "./export-payments-reports-storage";
|
||||||
|
|
||||||
|
|
||||||
export const storage = {
|
export const storage = {
|
||||||
@@ -28,6 +28,7 @@ export const storage = {
|
|||||||
...notificationsStorage,
|
...notificationsStorage,
|
||||||
...cloudStorageStorage,
|
...cloudStorageStorage,
|
||||||
...paymentsReportsStorage,
|
...paymentsReportsStorage,
|
||||||
|
...exportPaymentsReportsStorage,
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ function isoStartOfNextDayLiteral(d?: Date | null): string | null {
|
|||||||
function encodeCursor(obj: {
|
function encodeCursor(obj: {
|
||||||
staffId?: number;
|
staffId?: number;
|
||||||
lastPaymentDate: string | null;
|
lastPaymentDate: string | null;
|
||||||
lastPatientCreatedAt: string; // ISO string
|
|
||||||
lastPatientId: number;
|
lastPatientId: number;
|
||||||
}) {
|
}) {
|
||||||
return Buffer.from(JSON.stringify(obj)).toString("base64");
|
return Buffer.from(JSON.stringify(obj)).toString("base64");
|
||||||
@@ -77,7 +76,6 @@ function encodeCursor(obj: {
|
|||||||
function decodeCursor(token?: string | null): {
|
function decodeCursor(token?: string | null): {
|
||||||
staffId?: number; // optional because older cursors might not include it
|
staffId?: number; // optional because older cursors might not include it
|
||||||
lastPaymentDate: string | null;
|
lastPaymentDate: string | null;
|
||||||
lastPatientCreatedAt: string;
|
|
||||||
lastPatientId: number;
|
lastPatientId: number;
|
||||||
} | null {
|
} | null {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
@@ -86,7 +84,6 @@ function decodeCursor(token?: string | null): {
|
|||||||
if (
|
if (
|
||||||
typeof parsed === "object" &&
|
typeof parsed === "object" &&
|
||||||
"lastPaymentDate" in parsed &&
|
"lastPaymentDate" in parsed &&
|
||||||
"lastPatientCreatedAt" in parsed &&
|
|
||||||
"lastPatientId" in parsed
|
"lastPatientId" in parsed
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@@ -96,7 +93,6 @@ function decodeCursor(token?: string | null): {
|
|||||||
(parsed as any).lastPaymentDate === null
|
(parsed as any).lastPaymentDate === null
|
||||||
? null
|
? null
|
||||||
: String((parsed as any).lastPaymentDate),
|
: String((parsed as any).lastPaymentDate),
|
||||||
lastPatientCreatedAt: String((parsed as any).lastPatientCreatedAt),
|
|
||||||
lastPatientId: Number((parsed as any).lastPatientId),
|
lastPatientId: Number((parsed as any).lastPatientId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -331,7 +327,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
current_balance: string;
|
current_balance: string;
|
||||||
last_payment_date: Date | null;
|
last_payment_date: Date | null;
|
||||||
last_appointment_date: Date | null;
|
last_appointment_date: Date | null;
|
||||||
patient_created_at: Date | null; // we select patient.createdAt for cursor tie-breaker
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
||||||
@@ -376,7 +371,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
const lp = cursor.lastPaymentDate
|
const lp = cursor.lastPaymentDate
|
||||||
? `'${cursor.lastPaymentDate}'`
|
? `'${cursor.lastPaymentDate}'`
|
||||||
: "NULL";
|
: "NULL";
|
||||||
const lc = `'${cursor.lastPatientCreatedAt}'`;
|
|
||||||
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,
|
// We handle NULL last_payment_date ordering: since we use "NULLS LAST" in ORDER BY,
|
||||||
@@ -385,19 +379,12 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
// This predicate tries to cover both cases.
|
// This predicate tries to cover both cases.
|
||||||
keysetPredicate = `
|
keysetPredicate = `
|
||||||
AND (
|
AND (
|
||||||
-- case: both sides have non-null last_payment_date
|
|
||||||
(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 (
|
||||||
pm.last_payment_date < ${lp}
|
pm.last_payment_date < ${lp}
|
||||||
OR (pm.last_payment_date = ${lp} AND p."createdAt" < ${lc})
|
OR (pm.last_payment_date = ${lp} AND p.id < ${id})
|
||||||
OR (pm.last_payment_date = ${lp} AND p."createdAt" = ${lc} AND p.id < ${id})
|
|
||||||
))
|
))
|
||||||
-- case: cursor lastPaymentDate IS NULL -> we need rows with last_payment_date IS NULL but earlier createdAt/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 (
|
OR (pm.last_payment_date IS NULL AND ${lp} IS NULL AND p.id < ${id})
|
||||||
p."createdAt" < ${lc}
|
|
||||||
OR (p."createdAt" = ${lc} AND p.id < ${id})
|
|
||||||
))
|
|
||||||
-- case: cursor had non-null lastPaymentDate but pm.last_payment_date IS NULL:
|
|
||||||
-- since NULLS LAST, pm.last_payment_date IS NULL are after non-null dates, so they are NOT < cursor -> excluded
|
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -412,8 +399,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
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.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0))::numeric(12,2) AS current_balance,
|
||||||
pm.last_payment_date,
|
pm.last_payment_date,
|
||||||
apt.last_appointment_date,
|
apt.last_appointment_date
|
||||||
p."createdAt" AS patient_created_at
|
|
||||||
FROM "Patient" p
|
FROM "Patient" p
|
||||||
LEFT JOIN ${pmSubquery} ON pm.patient_id = p.id
|
LEFT JOIN ${pmSubquery} ON pm.patient_id = p.id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@@ -424,7 +410,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
WHERE (COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0
|
WHERE (COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC, p.id DESC`;
|
const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p.id DESC`;
|
||||||
const limitClause = `LIMIT ${safeLimit}`;
|
const limitClause = `LIMIT ${safeLimit}`;
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
@@ -453,14 +439,9 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
? new Date(last.last_payment_date).toISOString()
|
? new Date(last.last_payment_date).toISOString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const lastPatientCreatedAtIso = last.patient_created_at
|
|
||||||
? new Date(last.patient_created_at).toISOString()
|
|
||||||
: new Date().toISOString();
|
|
||||||
|
|
||||||
if (rows.length === safeLimit) {
|
if (rows.length === safeLimit) {
|
||||||
nextCursor = encodeCursor({
|
nextCursor = encodeCursor({
|
||||||
lastPaymentDate: lastPaymentDateIso,
|
lastPaymentDate: lastPaymentDateIso,
|
||||||
lastPatientCreatedAt: lastPatientCreatedAtIso,
|
|
||||||
lastPatientId: Number(last.patient_id),
|
lastPatientId: Number(last.patient_id),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -487,9 +468,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
lastAppointmentDate: r.last_appointment_date
|
lastAppointmentDate: r.last_appointment_date
|
||||||
? new Date(r.last_appointment_date).toISOString()
|
? new Date(r.last_appointment_date).toISOString()
|
||||||
: null,
|
: null,
|
||||||
patientCreatedAt: r.patient_created_at
|
|
||||||
? new Date(r.patient_created_at).toISOString()
|
|
||||||
: null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// totalCount: count of patients with positive balance within same payment date filter
|
// totalCount: count of patients with positive balance within same payment date filter
|
||||||
@@ -536,15 +514,13 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
||||||
const decoded = decodeCursor(cursorToken);
|
const decoded = decodeCursor(cursorToken);
|
||||||
|
|
||||||
// Accept older cursors that didn't include staffId; if cursor has staffId and it doesn't match, ignore.
|
// Do NOT accept cursors without staffId — they may belong to another listing.
|
||||||
const effectiveCursor =
|
const effectiveCursor =
|
||||||
decoded &&
|
decoded &&
|
||||||
typeof decoded.staffId === "number" &&
|
typeof decoded.staffId === "number" &&
|
||||||
decoded.staffId === Number(staffId)
|
decoded.staffId === Number(staffId)
|
||||||
? decoded
|
? decoded
|
||||||
: decoded && typeof decoded.staffId === "undefined"
|
: null;
|
||||||
? decoded
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const hasFrom = from !== undefined && from !== null;
|
const hasFrom = from !== undefined && from !== null;
|
||||||
const hasTo = to !== undefined && to !== null;
|
const hasTo = to !== undefined && to !== null;
|
||||||
@@ -565,27 +541,38 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
|
|
||||||
// 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).
|
||||||
// We'll compare p.last_payment_date, p.patient_created_at and p.id
|
// 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
|
||||||
? `'${effectiveCursor.lastPaymentDate}'`
|
? `'${effectiveCursor.lastPaymentDate}'`
|
||||||
: "NULL";
|
: "NULL";
|
||||||
const lc = `'${effectiveCursor.lastPatientCreatedAt}'`;
|
|
||||||
const id = Number(effectiveCursor.lastPatientId);
|
const id = Number(effectiveCursor.lastPatientId);
|
||||||
|
|
||||||
pageKeysetPredicate = `AND (
|
pageKeysetPredicate = `AND (
|
||||||
(p.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND (
|
( ${lp} 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 = ${lp} AND p.patient_created_at < ${lc})
|
OR (p.last_payment_date IS NOT NULL AND p.last_payment_date = ${lp} AND p.id < ${id})
|
||||||
OR (p.last_payment_date = ${lp} AND p.patient_created_at = ${lc} AND p.id < ${id})
|
)
|
||||||
))
|
)
|
||||||
OR (p.last_payment_date IS NULL AND ${lp} IS NULL AND (
|
OR
|
||||||
p.patient_created_at < ${lc}
|
( ${lp} IS NULL AND (
|
||||||
OR (p.patient_created_at = ${lc} AND p.id < ${id})
|
p.last_payment_date IS NOT NULL
|
||||||
))
|
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
|
// 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).
|
// 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.
|
// When no time-window provided, we want all patients who have appointments with this staff.
|
||||||
@@ -637,8 +624,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
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
|
||||||
p."createdAt" AS patient_created_at
|
|
||||||
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}
|
||||||
@@ -657,12 +643,11 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
p.total_adjusted::text AS "totalAdjusted",
|
p.total_adjusted::text AS "totalAdjusted",
|
||||||
p.current_balance::text AS "currentBalance",
|
p.current_balance::text AS "currentBalance",
|
||||||
p.last_payment_date AS "lastPaymentDate",
|
p.last_payment_date AS "lastPaymentDate",
|
||||||
p.last_appointment_date AS "lastAppointmentDate",
|
p.last_appointment_date AS "lastAppointmentDate"
|
||||||
p.patient_created_at AS "patientCreatedAt"
|
|
||||||
FROM patients p
|
FROM patients p
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
${pageKeysetPredicate}
|
${pageKeysetPredicate}
|
||||||
ORDER BY p.last_payment_date DESC NULLS LAST, p.patient_created_at DESC, p.id DESC
|
ORDER BY p.last_payment_date DESC NULLS LAST, p.id DESC
|
||||||
LIMIT ${safeLimit}
|
LIMIT ${safeLimit}
|
||||||
) t) AS balances_json,
|
) t) AS balances_json,
|
||||||
|
|
||||||
@@ -728,9 +713,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
lastAppointmentDate: r.lastAppointmentDate
|
lastAppointmentDate: r.lastAppointmentDate
|
||||||
? new Date(r.lastAppointmentDate).toISOString()
|
? new Date(r.lastAppointmentDate).toISOString()
|
||||||
: null,
|
: null,
|
||||||
patientCreatedAt: r.patientCreatedAt
|
|
||||||
? new Date(r.patientCreatedAt).toISOString()
|
|
||||||
: null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const hasMore = balances.length === safeLimit;
|
const hasMore = balances.length === safeLimit;
|
||||||
@@ -741,8 +723,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
|||||||
nextCursor = encodeCursor({
|
nextCursor = encodeCursor({
|
||||||
staffId: Number(staffId),
|
staffId: Number(staffId),
|
||||||
lastPaymentDate: last.lastPaymentDate,
|
lastPaymentDate: last.lastPaymentDate,
|
||||||
lastPatientCreatedAt:
|
|
||||||
last.patientCreatedAt ?? new Date().toISOString(),
|
|
||||||
lastPatientId: Number(last.patientId),
|
lastPatientId: Number(last.patientId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DoctorBalancesAndSummary } from "@repo/db/types";
|
import { DoctorBalancesAndSummary } from "@repo/db/types";
|
||||||
|
import ExportReportButton from "./export-button";
|
||||||
|
|
||||||
type StaffOption = { id: number; name: string };
|
type StaffOption = { id: number; name: string };
|
||||||
|
|
||||||
@@ -259,6 +260,15 @@ export default function CollectionsByDoctorReport({
|
|||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
hasPrev={cursorIndex > 0}
|
hasPrev={cursorIndex > 0}
|
||||||
hasNext={hasMore}
|
hasNext={hasMore}
|
||||||
|
headerRight={
|
||||||
|
<ExportReportButton
|
||||||
|
reportType="collections_by_doctor"
|
||||||
|
from={startDate}
|
||||||
|
to={endDate}
|
||||||
|
staffId={Number(staffId)}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
71
apps/Frontend/src/components/reports/export-button.tsx
Normal file
71
apps/Frontend/src/components/reports/export-button.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
|
||||||
|
export default function ExportReportButton({
|
||||||
|
reportType,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
staffId,
|
||||||
|
className,
|
||||||
|
labelCsv = "Download CSV",
|
||||||
|
}: {
|
||||||
|
reportType: string; // e.g. "collections_by_doctor" or "patients_with_balance"
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
staffId?: number | string | null;
|
||||||
|
className?: string;
|
||||||
|
labelCsv?: string;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function downloadCsv() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("type", reportType);
|
||||||
|
if (from) params.set("from", from);
|
||||||
|
if (to) params.set("to", to);
|
||||||
|
if (staffId) params.set("staffId", String(staffId));
|
||||||
|
params.set("format", "csv"); // server expects format=csv
|
||||||
|
|
||||||
|
const url = `/api/export-payments-reports/export?${params.toString()}`;
|
||||||
|
|
||||||
|
// Use apiRequest for consistent auth headers/cookies
|
||||||
|
const res = await apiRequest("GET", url);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "Export failed");
|
||||||
|
throw new Error(body || "Export failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const href = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = href;
|
||||||
|
const safeFrom = from || "all";
|
||||||
|
const safeTo = to || "all";
|
||||||
|
a.download = `${reportType}_${safeFrom}_${safeTo}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Export CSV failed", err);
|
||||||
|
alert("Export failed: " + (err?.message ?? "unknown error"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ?? "flex items-center gap-2"}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={downloadCsv}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center px-3 py-2 rounded border text-sm"
|
||||||
|
>
|
||||||
|
{loading ? "Preparing..." : labelCsv}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export default function PatientsBalancesList({
|
|||||||
onNext,
|
onNext,
|
||||||
hasPrev,
|
hasPrev,
|
||||||
hasNext,
|
hasNext,
|
||||||
|
headerRight, // optional UI node to render in header
|
||||||
}: {
|
}: {
|
||||||
rows: GenericRow[];
|
rows: GenericRow[];
|
||||||
reportType?: string | null;
|
reportType?: string | null;
|
||||||
@@ -37,6 +38,7 @@ export default function PatientsBalancesList({
|
|||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
hasPrev: boolean;
|
hasPrev: boolean;
|
||||||
hasNext: boolean;
|
hasNext: boolean;
|
||||||
|
headerRight?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const fmt = (v: number) =>
|
const fmt = (v: number) =>
|
||||||
new Intl.NumberFormat("en-US", {
|
new Intl.NumberFormat("en-US", {
|
||||||
@@ -66,6 +68,9 @@ export default function PatientsBalancesList({
|
|||||||
<h3 className="font-medium text-gray-900">
|
<h3 className="font-medium text-gray-900">
|
||||||
{reportTypeTitle(reportType)}
|
{reportTypeTitle(reportType)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{/* headerRight rendered here (if provided) */}
|
||||||
|
<div>{headerRight ?? null}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y min-h-[120px]">
|
<div className="divide-y min-h-[120px]">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import type { PatientBalanceRow } from "@repo/db/types";
|
import type { PatientBalanceRow } from "@repo/db/types";
|
||||||
import PatientsBalancesList from "./patients-balances-list";
|
import PatientsBalancesList from "./patients-balances-list";
|
||||||
|
import ExportReportButton from "./export-button";
|
||||||
|
|
||||||
type Resp = {
|
type Resp = {
|
||||||
balances: PatientBalanceRow[];
|
balances: PatientBalanceRow[];
|
||||||
@@ -117,6 +118,14 @@ export default function PatientsWithBalanceReport({
|
|||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
hasPrev={cursorIndex > 0}
|
hasPrev={cursorIndex > 0}
|
||||||
hasNext={hasMore}
|
hasNext={hasMore}
|
||||||
|
headerRight={
|
||||||
|
<ExportReportButton
|
||||||
|
reportType="patients_with_balance"
|
||||||
|
from={startDate}
|
||||||
|
to={endDate}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Download } from "lucide-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 CollectionsByDoctorReport from "@/components/reports/collections-by-doctor-report";
|
import CollectionsByDoctorReport from "@/components/reports/collections-by-doctor-report";
|
||||||
import SummaryCards from "@/components/reports/summary-cards";
|
import SummaryCards from "@/components/reports/summary-cards";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
type ReportType =
|
type ReportType =
|
||||||
| "patients_with_balance"
|
| "patients_with_balance"
|
||||||
@@ -32,17 +30,6 @@ export default function ReportPage() {
|
|||||||
const [selectedReportType, setSelectedReportType] = useState<ReportType>(
|
const [selectedReportType, setSelectedReportType] = useState<ReportType>(
|
||||||
"patients_with_balance"
|
"patients_with_balance"
|
||||||
);
|
);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
|
|
||||||
const generateReport = async () => {
|
|
||||||
setIsGenerating(true);
|
|
||||||
try {
|
|
||||||
// placeholder: implement export per-report endpoint
|
|
||||||
await new Promise((r) => setTimeout(r, 900));
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
@@ -62,16 +49,6 @@ export default function ReportPage() {
|
|||||||
Generate comprehensive financial reports for your practice
|
Generate comprehensive financial reports for your practice
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Export Button (Top Right) */}
|
|
||||||
<Button
|
|
||||||
onClick={generateReport}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="default"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
{isGenerating ? "Generating..." : "Export Report"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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 {
|
export interface GetPatientBalancesResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user