feat(report page) - export button added

This commit is contained in:
2025-10-24 23:39:39 +05:30
parent 54596be39f
commit 3af71cc5b8
11 changed files with 373 additions and 78 deletions

View 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;

View File

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

View 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;
}

View File

@@ -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,
}; };

View File

@@ -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,13 +514,11 @@ 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 && typeof decoded.staffId === "undefined"
? decoded ? decoded
: null; : 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),
}); });
} }

View File

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

View 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>
);
}

View File

@@ -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]">

View File

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

View File

@@ -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">

View File

@@ -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 {