feat(report page) - collection by doctor - v1 -base

This commit is contained in:
2025-10-22 23:33:03 +05:30
parent 7b9e14b6b4
commit 4bac4f94e0
5 changed files with 759 additions and 305 deletions

View File

@@ -2,24 +2,51 @@ 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";
type DoctorOption = { id: string; name: string };
type DoctorCollectionRow = {
doctorId: string;
doctorName: string;
totalCollected?: number;
totalCharges?: number;
totalPayments?: number;
currentBalance?: number;
type StaffOption = { id: number; name: string };
type BalanceRow = {
patientId: number;
firstName: string | null;
lastName: string | null;
totalCharges: number | string;
totalPaid: number | string;
totalAdjusted: number | string;
currentBalance: number | string;
lastPaymentDate: string | null;
lastAppointmentDate: string | null;
patientCreatedAt?: string | null;
};
type CollectionsResp = {
rows: DoctorCollectionRow[];
balances: BalanceRow[];
totalCount?: number;
nextCursor?: string | null;
hasMore?: boolean;
summary?: {
totalPatients?: number;
totalOutstanding?: number;
totalCollected?: number;
patientsWithBalance?: number;
};
};
function fmtCurrency(v: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(v);
}
export default function CollectionsByDoctorReport({
startDate,
endDate,
@@ -27,41 +54,41 @@ export default function CollectionsByDoctorReport({
startDate: string;
endDate: string;
}) {
const [doctorId, setDoctorId] = useState<string | "">("");
const [staffId, setStaffId] = 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
const pageIndex = cursorIndex + 1;
// load doctors for selector
const { data: doctors } = useQuery<DoctorOption[], Error>({
queryKey: ["doctors"],
// load staffs list for selector
const { data: staffs } = useQuery<StaffOption[], Error>({
queryKey: ["staffs"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/doctors");
const res = await apiRequest("GET", "/api/staffs");
if (!res.ok) {
const b = await res
.json()
.catch(() => ({ message: "Failed to load doctors" }));
throw new Error(b.message || "Failed to load doctors");
.catch(() => ({ message: "Failed to load staffs" }));
throw new Error(b.message || "Failed to load staffs");
}
return res.json();
},
staleTime: 60_000,
});
// rows (collections by doctor) - cursor-based request
// query balances+summary by doctor
const {
data: collectionData,
isLoading: isLoadingRows,
isError: isErrorRows,
refetch,
isFetching,
} = useQuery<CollectionsResp, Error>({
queryKey: [
"collections-by-doctor-rows",
doctorId,
staffId,
currentCursor,
perPage,
startDate,
@@ -71,13 +98,13 @@ export default function CollectionsByDoctorReport({
const params = new URLSearchParams();
params.set("limit", String(perPage));
if (currentCursor) params.set("cursor", currentCursor);
if (doctorId) params.set("doctorId", doctorId);
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/collections-by-doctor?${params.toString()}`
`/api/payments-reports/by-doctor?${params.toString()}`
);
if (!res.ok) {
const b = await res
@@ -87,95 +114,177 @@ export default function CollectionsByDoctorReport({
}
return res.json();
},
enabled: true,
enabled: Boolean(staffId), // only load when a doctor is selected
staleTime: 30_000,
});
// derived pagination info
const rows = collectionData?.rows ?? [];
const balances = collectionData?.balances ?? [];
const totalCount = collectionData?.totalCount ?? undefined;
const nextCursor = collectionData?.nextCursor ?? null;
const serverNextCursor = collectionData?.nextCursor ?? null;
const hasMore = collectionData?.hasMore ?? false;
const summary = collectionData?.summary ?? null;
// reset cursor when filters change (doctor/date)
// Reset pagination when filters change
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]);
}, [staffId, startDate, endDate]);
const handlePrev = useCallback(() => {
setCursorIndex((i) => Math.max(0, i - 1));
}, []);
// Map doctor rows into GenericRow (consistent)
const mapDoctorToGeneric = (r: DoctorCollectionRow): GenericRow => {
const handleNext = useCallback(() => {
const idx = cursorIndex;
const isLastKnown = idx === cursorStack.length - 1;
if (isLastKnown) {
if (serverNextCursor) {
setCursorStack((s) => [...s, serverNextCursor]);
setCursorIndex((i) => i + 1);
// No manual refetch — the queryKey depends on currentCursor and React Query will fetch automatically.
}
} else {
setCursorIndex((i) => i + 1);
}
}, [cursorIndex, cursorStack.length, serverNextCursor]);
// Map server rows to GenericRow
const genericRows: GenericRow[] = balances.map((r) => {
const totalCharges = Number(r.totalCharges ?? 0);
const totalPayments = Number(r.totalCollected ?? r.totalPayments ?? 0);
const totalPayments = Number(r.totalPaid ?? 0);
const currentBalance = Number(r.currentBalance ?? 0);
const name = `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || "Unknown";
return {
id: r.doctorId,
name: r.doctorName,
currentBalance: 0,
id: String(r.patientId),
name,
currentBalance,
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"
<Select
value={staffId || undefined}
onValueChange={(v) => setStaffId(v)}
>
<option value="">All doctors</option>
{doctors?.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<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>
<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}
/>
{/* 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}
/>
)}
</div>
);
}

View File

@@ -90,7 +90,7 @@ export default function PatientsBalancesList({
</div>
) : (
rows.map((r) => (
<div key={r.id} className="p-4 hover:bg-gray-50">
<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>
@@ -98,7 +98,11 @@ export default function PatientsBalancesList({
</div>
<div className="text-right">
<div className="text-lg font-semibold text-red-600">
<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">

View File

@@ -40,7 +40,7 @@ export default function PatientsWithBalanceReport({
if (endDate) params.set("to", endDate);
const res = await apiRequest(
"GET",
`/api/payments-reports/patient-balances?${params.toString()}`
`/api/payments-reports/patients-with-balances?${params.toString()}`
);
if (!res.ok) {
const body = await res