initial commit
This commit is contained in:
325
apps/Frontend/src/components/reports/collections-by-doctor-report.tsx
Executable file
325
apps/Frontend/src/components/reports/collections-by-doctor-report.tsx
Executable file
@@ -0,0 +1,325 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import PatientsBalancesList, { GenericRow } from "./patients-balances-list";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import ExportReportButton from "./export-button";
|
||||
|
||||
type StaffOption = { id: number; name: string };
|
||||
|
||||
function fmtCurrency(v: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
}
|
||||
|
||||
export default function CollectionsByDoctorReport({
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}) {
|
||||
const [staffId, setStaffId] = useState<string>("");
|
||||
|
||||
const perPage = 10;
|
||||
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
|
||||
const [cursorIndex, setCursorIndex] = useState<number>(0);
|
||||
const currentCursor = cursorStack[cursorIndex] ?? null;
|
||||
const pageIndex = cursorIndex + 1;
|
||||
|
||||
// load staffs list for selector
|
||||
const { data: staffs } = useQuery<StaffOption[], Error>({
|
||||
queryKey: ["staffs"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/staffs");
|
||||
if (!res.ok) {
|
||||
const b = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to load staffs" }));
|
||||
throw new Error(b.message || "Failed to load staffs");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// --- balances query (paged rows) ---
|
||||
const {
|
||||
data: balancesResult,
|
||||
isLoading: isLoadingBalances,
|
||||
isError: isErrorBalances,
|
||||
refetch: refetchBalances,
|
||||
isFetching: isFetchingBalances,
|
||||
} = useQuery<
|
||||
{
|
||||
balances: any[];
|
||||
totalCount: number;
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
},
|
||||
Error
|
||||
>({
|
||||
queryKey: [
|
||||
"collections-by-doctor-balances",
|
||||
staffId,
|
||||
currentCursor,
|
||||
perPage,
|
||||
startDate,
|
||||
endDate,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(perPage));
|
||||
if (currentCursor) params.set("cursor", currentCursor);
|
||||
if (staffId) params.set("staffId", staffId);
|
||||
if (startDate) params.set("from", startDate);
|
||||
if (endDate) params.set("to", endDate);
|
||||
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/payments-reports/by-doctor/balances?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const b = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to load collections balances" }));
|
||||
throw new Error(b.message || "Failed to load collections balances");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
enabled: Boolean(staffId),
|
||||
});
|
||||
|
||||
// --- summary query (staff summary) ---
|
||||
const {
|
||||
data: summaryData,
|
||||
isLoading: isLoadingSummary,
|
||||
isError: isErrorSummary,
|
||||
refetch: refetchSummary,
|
||||
isFetching: isFetchingSummary,
|
||||
} = useQuery<
|
||||
{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number | string;
|
||||
totalCollected: number | string;
|
||||
patientsWithBalance: number;
|
||||
},
|
||||
Error
|
||||
>({
|
||||
queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (staffId) params.set("staffId", staffId);
|
||||
if (startDate) params.set("from", startDate);
|
||||
if (endDate) params.set("to", endDate);
|
||||
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/payments-reports/by-doctor/summary?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const b = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to load collections summary" }));
|
||||
throw new Error(b.message || "Failed to load collections summary");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
enabled: Boolean(staffId),
|
||||
});
|
||||
|
||||
const balances = balancesResult?.balances ?? [];
|
||||
const totalCount = balancesResult?.totalCount ?? undefined;
|
||||
const serverNextCursor = balancesResult?.nextCursor ?? null;
|
||||
const hasMore = Boolean(balancesResult?.hasMore ?? false);
|
||||
const summary = summaryData ?? null;
|
||||
|
||||
const isLoadingRows = isLoadingBalances;
|
||||
const isErrorRows = isErrorBalances;
|
||||
const isFetching = isFetchingBalances || isFetchingSummary;
|
||||
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => {
|
||||
setCursorStack([null]);
|
||||
setCursorIndex(0);
|
||||
}, [staffId, startDate, endDate]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
setCursorIndex((i) => Math.max(0, i - 1));
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const idx = cursorIndex;
|
||||
const isLastKnown = idx === cursorStack.length - 1;
|
||||
|
||||
if (isLastKnown) {
|
||||
if (serverNextCursor && serverNextCursor !== currentCursor && balances.length > 0) {
|
||||
setCursorStack((s) => [...s, serverNextCursor]);
|
||||
setCursorIndex((i) => i + 1);
|
||||
// React Query will fetch automatically because queryKey includes currentCursor
|
||||
}
|
||||
} else {
|
||||
setCursorIndex((i) => i + 1);
|
||||
}
|
||||
}, [cursorIndex, cursorStack.length, serverNextCursor, balances, currentCursor]);
|
||||
|
||||
// Map server rows to GenericRow
|
||||
const genericRows: GenericRow[] = balances.map((r) => {
|
||||
const totalCharges = Number(r.totalCharges ?? 0);
|
||||
const totalPayments = Number(r.totalPayments ?? 0);
|
||||
const currentBalance = Number(r.currentBalance ?? 0);
|
||||
const name = `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || "Unknown";
|
||||
|
||||
return {
|
||||
id: String(r.patientId),
|
||||
name,
|
||||
currentBalance,
|
||||
totalCharges,
|
||||
totalPayments,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-700 block mb-1 ml-2">
|
||||
Select Doctor
|
||||
</label>
|
||||
<Select
|
||||
value={staffId || undefined}
|
||||
onValueChange={(v) => setStaffId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a doctor" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{staffs?.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary card (time-window based) */}
|
||||
{staffId && (
|
||||
<div className="mb-4">
|
||||
<Card className="pt-4 pb-4">
|
||||
<CardContent>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-800">
|
||||
Doctor summary
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Data covers the selected time frame
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-blue-600">
|
||||
{summary ? Number(summary.totalPatients ?? 0) : "—"}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Total Patients (in window)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-red-600">
|
||||
{summary ? Number(summary.patientsWithBalance ?? 0) : "—"}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">With Balance</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-green-600">
|
||||
{summary
|
||||
? Math.max(
|
||||
0,
|
||||
Number(summary.totalPatients ?? 0) -
|
||||
Number(summary.patientsWithBalance ?? 0)
|
||||
)
|
||||
: "—"}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Zero Balance</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-orange-600">
|
||||
{summary
|
||||
? fmtCurrency(Number(summary.totalOutstanding ?? 0))
|
||||
: "—"}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Outstanding</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-purple-600">
|
||||
{summary
|
||||
? fmtCurrency(Number(summary.totalCollected ?? 0))
|
||||
: "—"}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Collected</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List (shows all patients under doctor but per-row totals are time-filtered) */}
|
||||
{!staffId ? (
|
||||
<div className="text-sm text-gray-600">
|
||||
Please select a doctor to load collections.
|
||||
</div>
|
||||
) : (
|
||||
<PatientsBalancesList
|
||||
rows={genericRows}
|
||||
reportType="collections_by_doctor"
|
||||
loading={isLoadingRows || isFetching}
|
||||
error={
|
||||
isErrorRows
|
||||
? "Failed to load collections for the selected doctor/date range."
|
||||
: false
|
||||
}
|
||||
emptyMessage="No collection data for the selected doctor/date range."
|
||||
pageIndex={pageIndex}
|
||||
perPage={perPage}
|
||||
total={totalCount}
|
||||
onPrev={handlePrev}
|
||||
onNext={handleNext}
|
||||
hasPrev={cursorIndex > 0}
|
||||
hasNext={hasMore}
|
||||
headerRight={
|
||||
<ExportReportButton
|
||||
reportType="collections_by_doctor"
|
||||
from={startDate}
|
||||
to={endDate}
|
||||
staffId={Number(staffId)}
|
||||
className="mr-2"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
apps/Frontend/src/components/reports/export-button.tsx
Executable file
71
apps/Frontend/src/components/reports/export-button.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
71
apps/Frontend/src/components/reports/pagination-controls.tsx
Executable file
71
apps/Frontend/src/components/reports/pagination-controls.tsx
Executable file
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
export default function PaginationControls({
|
||||
pageIndex,
|
||||
perPage,
|
||||
total,
|
||||
onPrev,
|
||||
onNext,
|
||||
hasPrev,
|
||||
hasNext,
|
||||
}: {
|
||||
/** 1-based page index (for display). Pass cursorIndex + 1 from parent. */
|
||||
pageIndex: number;
|
||||
perPage: number;
|
||||
/** optional totalCount from backend (if provided) */
|
||||
total?: number | undefined;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}) {
|
||||
const startItem = total === 0 ? 0 : (pageIndex - 1) * perPage + 1;
|
||||
const endItem = Math.min(pageIndex * perPage, total ?? pageIndex * perPage);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{typeof total === "number"
|
||||
? `Showing ${startItem}-${endItem} of ${total}`
|
||||
: `Page ${pageIndex}`}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
if (hasPrev) onPrev();
|
||||
}}
|
||||
className={hasPrev ? "" : "pointer-events-none opacity-50"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
<div className="px-2" />
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
if (hasNext) onNext();
|
||||
}}
|
||||
className={hasNext ? "" : "pointer-events-none opacity-50"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/Frontend/src/components/reports/patients-balances-list.tsx
Executable file
139
apps/Frontend/src/components/reports/patients-balances-list.tsx
Executable file
@@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import { DollarSign } from "lucide-react";
|
||||
import PaginationControls from "./pagination-controls";
|
||||
|
||||
export type GenericRow = {
|
||||
id: string | number;
|
||||
name: string;
|
||||
currentBalance: number;
|
||||
totalCharges: number;
|
||||
totalPayments: number;
|
||||
};
|
||||
|
||||
export default function PatientsBalancesList({
|
||||
rows,
|
||||
reportType,
|
||||
loading,
|
||||
error,
|
||||
emptyMessage,
|
||||
pageIndex = 1, // 1-based
|
||||
perPage = 10,
|
||||
total, // optional totalCount from backend
|
||||
onPrev,
|
||||
onNext,
|
||||
hasPrev,
|
||||
hasNext,
|
||||
headerRight, // optional UI node to render in header
|
||||
}: {
|
||||
rows: GenericRow[];
|
||||
reportType?: string | null;
|
||||
loading?: boolean;
|
||||
error?: string | boolean;
|
||||
emptyMessage?: string;
|
||||
// cursor props (required)
|
||||
pageIndex?: number;
|
||||
perPage?: number;
|
||||
total?: number | undefined;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
headerRight?: React.ReactNode;
|
||||
}) {
|
||||
const fmt = (v: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
|
||||
const reportTypeTitle = (rt?: string | null) => {
|
||||
switch (rt) {
|
||||
case "patients_with_balance":
|
||||
return "Patients with Outstanding Balances";
|
||||
case "patients_no_balance":
|
||||
return "Patients with Zero Balance";
|
||||
case "monthly_collections":
|
||||
return "Monthly Collections";
|
||||
case "collections_by_doctor":
|
||||
return "Collections by Doctor";
|
||||
default:
|
||||
return "Balances";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-lg border">
|
||||
<div className="px-4 py-3 border-b bg-gray-50 flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{reportTypeTitle(reportType)}
|
||||
</h3>
|
||||
|
||||
{/* headerRight rendered here (if provided) */}
|
||||
<div>{headerRight ?? null}</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y min-h-[120px]">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-600">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<div>Loading {reportType ?? "data"}…</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-600">
|
||||
<div className="mb-2 font-semibold">Could not fetch data</div>
|
||||
<div className="text-sm text-red-500">
|
||||
{typeof error === "string"
|
||||
? error
|
||||
: "An error occurred while loading the report."}
|
||||
</div>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<DollarSign className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>{emptyMessage ?? "No rows for this report."}</p>
|
||||
</div>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<div key={String(r.id)} className="p-4 hover:bg-gray-50">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{r.name}</h4>
|
||||
<p className="text-sm text-gray-500">ID: {r.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`text-lg font-semibold ${
|
||||
r.currentBalance > 0 ? "text-red-600" : "text-green-600"
|
||||
}`}
|
||||
>
|
||||
{fmt(r.currentBalance)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Charges: {fmt(r.totalCharges)} · Collected:{" "}
|
||||
{fmt(r.totalPayments)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cursor pagination footer (cursor-only) */}
|
||||
<div className="bg-white px-4 py-3 border-t border-gray-200">
|
||||
<PaginationControls
|
||||
pageIndex={pageIndex}
|
||||
perPage={perPage}
|
||||
total={total}
|
||||
onPrev={onPrev}
|
||||
onNext={onNext}
|
||||
hasPrev={hasPrev}
|
||||
hasNext={hasNext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
apps/Frontend/src/components/reports/patients-with-balance-report.tsx
Executable file
133
apps/Frontend/src/components/reports/patients-with-balance-report.tsx
Executable file
@@ -0,0 +1,133 @@
|
||||
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; // optional
|
||||
nextCursor?: string | null;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
export default function PatientsWithBalanceReport({
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}) {
|
||||
const balancesPerPage = 10;
|
||||
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const currentCursor = cursorStack[cursorIndex] ?? null;
|
||||
const pageIndex = cursorIndex + 1; // 1-based for UI
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery<Resp, Error>({
|
||||
queryKey: [
|
||||
"/api/payments-reports/patient-balances",
|
||||
currentCursor,
|
||||
balancesPerPage,
|
||||
startDate,
|
||||
endDate,
|
||||
],
|
||||
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);
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/payments-reports/patients-with-balances?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to load patient balances" }));
|
||||
throw new Error(body.message || "Failed to load patient balances");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const balances = data?.balances ?? [];
|
||||
const totalCount = data?.totalCount ?? undefined;
|
||||
const nextCursor = data?.nextCursor ?? null;
|
||||
const hasMore = data?.hasMore ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
setCursorStack([null]);
|
||||
setCursorIndex(0);
|
||||
refetch();
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const idx = cursorIndex;
|
||||
const isLastKnown = idx === 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 totalCharges = Number(b.totalCharges ?? 0);
|
||||
const totalPayments =
|
||||
b.totalPayments != null
|
||||
? Number(b.totalPayments)
|
||||
: Number(totalCharges - currentBalance);
|
||||
return {
|
||||
id: b.patientId,
|
||||
name: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(),
|
||||
currentBalance,
|
||||
totalCharges,
|
||||
totalPayments,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<PatientsBalancesList
|
||||
rows={normalized}
|
||||
reportType="patients_with_balance"
|
||||
loading={isLoading}
|
||||
error={
|
||||
isError
|
||||
? "Failed to load patient balances for the selected date range."
|
||||
: false
|
||||
}
|
||||
emptyMessage="No patient balances for the selected date range."
|
||||
pageIndex={pageIndex}
|
||||
perPage={balancesPerPage}
|
||||
total={totalCount}
|
||||
onPrev={handlePrev}
|
||||
onNext={handleNext}
|
||||
hasPrev={cursorIndex > 0}
|
||||
hasNext={hasMore}
|
||||
headerRight={
|
||||
<ExportReportButton
|
||||
reportType="patients_with_balance"
|
||||
from={startDate}
|
||||
to={endDate}
|
||||
className="mr-2"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
apps/Frontend/src/components/reports/report-config.tsx
Executable file
137
apps/Frontend/src/components/reports/report-config.tsx
Executable file
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||
import { DateInput } from "@/components/ui/dateInput";
|
||||
|
||||
type ReportType =
|
||||
| "patients_with_balance"
|
||||
| "patients_no_balance"
|
||||
| "monthly_collections"
|
||||
| "collections_by_doctor"
|
||||
| "procedure_codes_by_doctor"
|
||||
| "payment_methods"
|
||||
| "insurance_vs_patient_payments"
|
||||
| "aging_report";
|
||||
|
||||
export default function ReportConfig({
|
||||
startDate,
|
||||
endDate,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
selectedReportType,
|
||||
setSelectedReportType,
|
||||
}: {
|
||||
startDate: string; // "" or "YYYY-MM-DD"
|
||||
endDate: string;
|
||||
setStartDate: (s: string) => void;
|
||||
setEndDate: (s: string) => void;
|
||||
selectedReportType: ReportType;
|
||||
setSelectedReportType: (r: ReportType) => void;
|
||||
}) {
|
||||
// Convert incoming string -> Date | null using your parseLocalDate utility.
|
||||
// parseLocalDate can throw for invalid strings, so guard with try/catch.
|
||||
let startDateObj: Date | null = null;
|
||||
if (startDate) {
|
||||
try {
|
||||
startDateObj = parseLocalDate(startDate);
|
||||
} catch {
|
||||
startDateObj = null;
|
||||
}
|
||||
}
|
||||
|
||||
let endDateObj: Date | null = null;
|
||||
if (endDate) {
|
||||
try {
|
||||
endDateObj = parseLocalDate(endDate);
|
||||
} catch {
|
||||
endDateObj = null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" /> Report Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
Choose the report type and date range.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<DateInput
|
||||
label="Start Date"
|
||||
value={startDateObj}
|
||||
onChange={(d) => {
|
||||
setStartDate(d ? formatLocalDate(d) : "");
|
||||
}}
|
||||
disableFuture
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DateInput
|
||||
label="End Date"
|
||||
value={endDateObj}
|
||||
onChange={(d) => {
|
||||
setEndDate(d ? formatLocalDate(d) : "");
|
||||
}}
|
||||
disableFuture
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="report-type">Report Type</Label>
|
||||
<Select
|
||||
value={selectedReportType}
|
||||
onValueChange={(v) => setSelectedReportType(v as ReportType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select report type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="patients_with_balance">
|
||||
Patients with Outstanding Balance
|
||||
</SelectItem>
|
||||
<SelectItem value="collections_by_doctor">
|
||||
Collections by Doctor
|
||||
</SelectItem>
|
||||
<SelectItem value="patients_no_balance">
|
||||
Patients with Zero Balance
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly_collections">
|
||||
Monthly Collections Summary
|
||||
</SelectItem>
|
||||
<SelectItem value="procedure_codes_by_doctor">
|
||||
Procedure Codes by Doctor
|
||||
</SelectItem>
|
||||
<SelectItem value="payment_methods">
|
||||
Payment Methods Breakdown
|
||||
</SelectItem>
|
||||
<SelectItem value="insurance_vs_patient_payments">
|
||||
Insurance vs Patient Payments
|
||||
</SelectItem>
|
||||
<SelectItem value="aging_report">
|
||||
Accounts Receivable Aging
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
115
apps/Frontend/src/components/reports/summary-cards.tsx
Executable file
115
apps/Frontend/src/components/reports/summary-cards.tsx
Executable file
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
|
||||
type SummaryResp = {
|
||||
totalPatients?: number;
|
||||
patientsWithBalance?: number;
|
||||
totalOutstanding?: number;
|
||||
totalCollected?: number;
|
||||
};
|
||||
|
||||
function fmtCurrency(v: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
}
|
||||
|
||||
export default function SummaryCards({
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}) {
|
||||
// Query the server summary for the given date range
|
||||
const { data, isLoading, isError } = useQuery<SummaryResp, Error>({
|
||||
queryKey: ["/api/payments-reports/summary", startDate, endDate],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.set("from", startDate);
|
||||
if (endDate) params.set("to", endDate);
|
||||
const endpoint = `/api/payments-reports/summary?${params.toString()}`;
|
||||
const res = await apiRequest("GET", endpoint);
|
||||
if (!res.ok) {
|
||||
const body = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to load dashboard summary" }));
|
||||
throw new Error(body?.message ?? "Failed to load dashboard summary");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
enabled: Boolean(startDate && endDate),
|
||||
});
|
||||
|
||||
const totalPatients = data?.totalPatients ?? 0;
|
||||
const patientsWithBalance = data?.patientsWithBalance ?? 0;
|
||||
const patientsNoBalance = Math.max(
|
||||
0,
|
||||
(data?.totalPatients ?? 0) - (data?.patientsWithBalance ?? 0)
|
||||
);
|
||||
const totalOutstanding = data?.totalOutstanding ?? 0;
|
||||
const totalCollected = data?.totalCollected ?? 0;
|
||||
|
||||
return (
|
||||
<Card className="pt-4 pb-4">
|
||||
<CardContent>
|
||||
{/* Heading */}
|
||||
<div className="mb-3">
|
||||
<h2 className="text-base font-semibold text-gray-800">
|
||||
Report summary
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Data covers the selected time frame
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-blue-600">
|
||||
{isLoading ? "—" : totalPatients}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Total Patients</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-red-600">
|
||||
{isLoading ? "—" : patientsWithBalance}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">With Balance</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-green-600">
|
||||
{isLoading ? "—" : patientsNoBalance}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Zero Balance</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-orange-600">
|
||||
{isLoading ? "—" : fmtCurrency(totalOutstanding)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Outstanding</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-purple-600">
|
||||
{isLoading ? "—" : fmtCurrency(totalCollected)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Collected</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="mt-3 text-sm text-red-600">
|
||||
Failed to load summary. Check server or network.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user