feat(report page) - ui comp added

This commit is contained in:
2025-10-22 18:55:21 +05:30
parent c70c19aefa
commit 7b9e14b6b4
10 changed files with 1014 additions and 639 deletions

View File

@@ -0,0 +1,181 @@
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";
type DoctorOption = { id: string; name: string };
type DoctorCollectionRow = {
doctorId: string;
doctorName: string;
totalCollected?: number;
totalCharges?: number;
totalPayments?: number;
currentBalance?: number;
};
type CollectionsResp = {
rows: DoctorCollectionRow[];
totalCount?: number;
nextCursor?: string | null;
hasMore?: boolean;
};
export default function CollectionsByDoctorReport({
startDate,
endDate,
}: {
startDate: string;
endDate: string;
}) {
const [doctorId, setDoctorId] = useState<string | "">("");
// pagination (cursor) state
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; // 1-based for UI
// load doctors for selector
const { data: doctors } = useQuery<DoctorOption[], Error>({
queryKey: ["doctors"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/doctors");
if (!res.ok) {
const b = await res
.json()
.catch(() => ({ message: "Failed to load doctors" }));
throw new Error(b.message || "Failed to load doctors");
}
return res.json();
},
staleTime: 60_000,
});
// rows (collections by doctor) - cursor-based request
const {
data: collectionData,
isLoading: isLoadingRows,
isError: isErrorRows,
refetch,
} = useQuery<CollectionsResp, Error>({
queryKey: [
"collections-by-doctor-rows",
doctorId,
currentCursor,
perPage,
startDate,
endDate,
],
queryFn: async () => {
const params = new URLSearchParams();
params.set("limit", String(perPage));
if (currentCursor) params.set("cursor", currentCursor);
if (doctorId) params.set("doctorId", doctorId);
if (startDate) params.set("from", startDate);
if (endDate) params.set("to", endDate);
const res = await apiRequest(
"GET",
`/api/payments-reports/collections-by-doctor?${params.toString()}`
);
if (!res.ok) {
const b = await res
.json()
.catch(() => ({ message: "Failed to load collections" }));
throw new Error(b.message || "Failed to load collections");
}
return res.json();
},
enabled: true,
staleTime: 30_000,
});
// derived pagination info
const rows = collectionData?.rows ?? [];
const totalCount = collectionData?.totalCount ?? undefined;
const nextCursor = collectionData?.nextCursor ?? null;
const hasMore = collectionData?.hasMore ?? false;
// reset cursor when filters change (doctor/date)
useEffect(() => {
setCursorStack([null]);
setCursorIndex(0);
refetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId, 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));
}, []);
// Map doctor rows into GenericRow (consistent)
const mapDoctorToGeneric = (r: DoctorCollectionRow): GenericRow => {
const totalCharges = Number(r.totalCharges ?? 0);
const totalPayments = Number(r.totalCollected ?? r.totalPayments ?? 0);
return {
id: r.doctorId,
name: r.doctorName,
currentBalance: 0,
totalCharges,
totalPayments,
};
};
const genericRows: GenericRow[] = rows.map(mapDoctorToGeneric);
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">Doctor</label>
<select
value={doctorId}
onChange={(e) => setDoctorId(e.target.value)}
className="w-full border rounded px-2 py-1"
>
<option value="">All doctors</option>
{doctors?.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
</div>
<PatientsBalancesList
rows={genericRows}
reportType="collections_by_doctor"
loading={isLoadingRows}
error={
isErrorRows
? "Failed to load collections for the selected doctor/date range."
: false
}
emptyMessage="No collection data for the selected doctor/date range."
// cursor props (cursor-only approach)
pageIndex={pageIndex}
perPage={perPage}
total={totalCount}
onPrev={handlePrev}
onNext={handleNext}
hasPrev={cursorIndex > 0}
hasNext={hasMore}
/>
</div>
);
}

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

View File

@@ -0,0 +1,130 @@
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,
}: {
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;
}) {
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>
</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={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 text-red-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>
);
}

View File

@@ -0,0 +1,125 @@
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";
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/patient-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,
staleTime: 30_000,
});
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}
/>
</div>
</div>
);
}

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

View File

@@ -0,0 +1,116 @@
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),
staleTime: 30_000,
});
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>
);
}

View File

@@ -1,31 +1,11 @@
// apps/Frontend/src/pages/reports-page.tsx
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
DollarSign,
FileText,
Download,
AlertCircle,
Calendar,
Users,
TrendingUp,
} from "lucide-react";
import React, { useState } from "react";
import { Download } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { apiRequest } from "@/lib/queryClient"; // <<-- your helper
import type { PatientBalanceRow } from "@repo/db/types";
import ReportConfig from "@/components/reports/report-config";
import PatientsWithBalanceReport from "@/components/reports/patients-with-balance-report";
import CollectionsByDoctorReport from "@/components/reports/collections-by-doctor-report";
import SummaryCards from "@/components/reports/summary-cards";
import { Button } from "@/components/ui/button";
type ReportType =
| "patients_with_balance"
@@ -37,26 +17,16 @@ type ReportType =
| "insurance_vs_patient_payments"
| "aging_report";
interface PatientBalancesResponse {
balances: PatientBalanceRow[];
totalCount: number;
}
export default function ReportsPage() {
export default function ReportPage() {
const { user } = useAuth();
// pagination state for patient balances
const [balancesPage, setBalancesPage] = useState<number>(1);
const balancesPerPage = 10;
// date range state (for dashboard summary)
const [startDate, setStartDate] = useState(() => {
const [startDate, setStartDate] = useState<string>(() => {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().split("T")[0];
return d.toISOString().split("T")[0] ?? "";
});
const [endDate, setEndDate] = useState(
() => new Date().toISOString().split("T")[0]
const [endDate, setEndDate] = useState<string>(
() => new Date().toISOString().split("T")[0] ?? ""
);
const [selectedReportType, setSelectedReportType] = useState<ReportType>(
@@ -64,413 +34,73 @@ export default function ReportsPage() {
);
const [isGenerating, setIsGenerating] = useState(false);
// --- 1) patient balances (paginated) using apiRequest ---
const {
data: patientBalancesResponse,
isLoading: isLoadingBalances,
isError: isErrorBalances,
} = useQuery<PatientBalancesResponse>({
queryKey: [
"/api/payments-reports/patient-balances",
balancesPage,
balancesPerPage,
startDate,
endDate,
selectedReportType,
],
queryFn: async () => {
const offset = (balancesPage - 1) * balancesPerPage;
const minBalanceFlag = selectedReportType === "patients_with_balance";
const endpoint = `/api/payments-reports/patient-balances?limit=${balancesPerPage}&offset=${offset}&minBalance=${minBalanceFlag}&from=${encodeURIComponent(
String(startDate)
)}&to=${encodeURIComponent(String(endDate))}`;
const res = await apiRequest("GET", endpoint);
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: !!user,
});
const patientBalances: PatientBalanceRow[] =
patientBalancesResponse?.balances ?? [];
const patientBalancesTotal = patientBalancesResponse?.totalCount ?? 0;
// --- 2) dashboard summary (separate route/storage) using apiRequest ---
const { data: dashboardSummary, isLoading: isLoadingSummary } = useQuery({
queryKey: [
"/api/payments-reports/summary",
String(startDate),
String(endDate),
],
queryFn: async () => {
const endpoint = `/api/payments-reports/summary?from=${encodeURIComponent(
String(startDate)
)}&to=${encodeURIComponent(String(endDate))}`;
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: !!user,
});
// format currency for numbers in dollars (storage returns decimal numbers like 123.45)
const formatCurrency = (amountDollars: number | undefined | null) => {
const value = Number(amountDollars ?? 0);
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
};
// summary stats: use dashboardSummary for totals (server-driven) and derive other counts from paginated balances
const summaryStats = {
totalPatients: dashboardSummary?.totalPatients ?? 0,
// use the server-provided count of patients with balance inside range
patientsWithBalance: dashboardSummary?.patientsWithBalance ?? 0,
// patientsNoBalance: based on totalCount - patientsWithBalance (note: totalCount is number of patients with payments in range)
patientsNoBalance: Math.max(
0,
(dashboardSummary?.totalPatients ?? 0) -
(dashboardSummary?.patientsWithBalance ?? 0)
),
totalOutstanding:
dashboardSummary?.totalOutstanding ??
patientBalances.reduce((s, b) => s + (b.currentBalance ?? 0), 0),
totalCollected: dashboardSummary?.totalCollected ?? 0,
};
const generateReport = async () => {
setIsGenerating(true);
await new Promise((r) => setTimeout(r, 900));
setIsGenerating(false);
try {
// placeholder: implement export per-report endpoint
await new Promise((r) => setTimeout(r, 900));
} finally {
setIsGenerating(false);
}
};
// -------------------- report rendering (only patients_with_balance wired) --------------------
// -------------------- report rendering (only patients_with_balance wired) --------------------
const renderPatientsWithBalance = () => {
// Use patientBalances for the current page list (already minBalance filtered if selectedReportType === 'patients_with_balance')
const patientsWithBalance = patientBalances
.filter((b) => (b.currentBalance ?? 0) > 0)
.map((b) => ({
patientId: b.patientId,
patientName: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(),
currentBalance: b.currentBalance ?? 0,
totalCharges: b.totalCharges ?? 0,
totalPayments: b.totalPayments ?? 0,
}));
const totalOutstanding = patientsWithBalance.reduce(
(s, p) => s + p.currentBalance,
0
);
const avgBalance = patientsWithBalance.length
? totalOutstanding / patientsWithBalance.length
: 0;
if (!user) {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-red-600">
{summaryStats.patientsWithBalance}
</div>
<p className="text-sm text-gray-600">Patients with Balance</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-red-600">
{formatCurrency(summaryStats.totalOutstanding)}
</div>
<p className="text-sm text-gray-600">Total Outstanding</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-blue-600">
{formatCurrency(avgBalance)}
</div>
<p className="text-sm text-gray-600">
Average Balance (visible page)
</p>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-lg border">
<div className="px-4 py-3 border-b bg-gray-50">
<h3 className="font-medium text-gray-900">
Patients with Outstanding Balances
</h3>
</div>
<div className="divide-y">
{patientsWithBalance.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>No patients have outstanding balances on this page</p>
</div>
) : (
patientsWithBalance.map((p) => (
<div key={p.patientId} className="p-4 hover:bg-gray-50">
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium text-gray-900">
{p.patientName}
</h4>
<p className="text-sm text-gray-500">
Patient ID: {p.patientId}
</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-red-600">
{formatCurrency(p.currentBalance)}
</div>
<div className="text-sm text-gray-500">
Charges: {formatCurrency(p.totalCharges)}
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* pagination controls for balances */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-600">
Showing {(balancesPage - 1) * balancesPerPage + 1} -{" "}
{Math.min(balancesPage * balancesPerPage, patientBalancesTotal)} of{" "}
{patientBalancesTotal}
</div>
<div className="space-x-2">
<Button
disabled={balancesPage <= 1}
onClick={() => setBalancesPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
disabled={balancesPage * balancesPerPage >= patientBalancesTotal}
onClick={() => setBalancesPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
</div>
<div className="text-center py-8">Please sign in to view reports.</div>
);
};
const renderReportContent = () => {
if (isLoadingBalances || isLoadingSummary) {
return (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-gray-600">Loading report data...</p>
</div>
);
}
if ((patientBalances?.length ?? 0) === 0) {
return (
<div className="text-center py-12">
<AlertCircle className="h-12 w-12 text-amber-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Financial Data Not Available
</h3>
<p className="text-gray-600 mb-4">
No patient balance data yet. Add payments/service lines to populate
reports.
</p>
<div className="text-sm text-gray-500">
<p>
Date range: {startDate} to {endDate}
</p>
</div>
</div>
);
}
switch (selectedReportType) {
case "patients_with_balance":
return renderPatientsWithBalance();
default:
return (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Report Type Not Implemented
</h3>
<p className="text-gray-600">
The "{selectedReportType}" report is being developed. For now use
"Patients with Outstanding Balance".
</p>
</div>
);
}
};
}
return (
<div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div className="container mx-auto space-y-6">
{/* Header Section */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-semibold text-gray-900">
<h1 className="text-3xl font-bold tracking-tight">
Financial Reports
</h1>
<p className="text-gray-600">
<p className="text-muted-foreground">
Generate comprehensive financial reports for your practice
</p>
</div>
{/* Export Button (Top Right) */}
<Button
onClick={generateReport}
disabled={isGenerating}
className="mt-4 md:mt-0"
className="default"
>
<Download className="h-4 w-4 mr-2" />{" "}
<Download className="h-4 w-4 mr-2" />
{isGenerating ? "Generating..." : "Export Report"}
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Report Configuration
</CardTitle>
</CardHeader>
<div className="mb-4">
<ReportConfig
startDate={startDate}
endDate={endDate}
setStartDate={setStartDate}
setEndDate={setEndDate}
selectedReportType={selectedReportType}
setSelectedReportType={setSelectedReportType}
/>
</div>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
{/* SINGLE authoritative SummaryCards instance for the page */}
<div className="mb-4">
<SummaryCards startDate={startDate} endDate={endDate} />
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div>
{selectedReportType === "patients_with_balance" && (
<PatientsWithBalanceReport startDate={startDate} endDate={endDate} />
)}
<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="patients_no_balance">
Patients with Zero Balance
</SelectItem>
<SelectItem value="monthly_collections">
Monthly Collections Summary
</SelectItem>
<SelectItem value="collections_by_doctor">
Collections by Each Doctor
</SelectItem>
<SelectItem value="procedure_codes_by_doctor">
Procedure Codes Analysis by Doctors
</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>
{selectedReportType === "collections_by_doctor" && (
<CollectionsByDoctorReport startDate={startDate} endDate={endDate} />
)}
<Separator />
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">
{summaryStats.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">
{summaryStats.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">
{summaryStats.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">
{formatCurrency(summaryStats.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">
{formatCurrency(summaryStats.totalCollected)}
</div>
<p className="text-sm text-gray-600">Collected</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
{selectedReportType === "patients_with_balance"
? "Patients with Outstanding Balance"
: selectedReportType}
</CardTitle>
</CardHeader>
<CardContent>{renderReportContent()}</CardContent>
</Card>
{/* Add other report components here as needed */}
</div>
</div>
);
}