feat(report page) - collection by doctor - v1 -base
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user