feat: add new frontend components, MH batch worker, and gitignore rules
- Add all new Frontend source files (pages, components, hooks, utils) - Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py - Add install-steps-5-13.sh setup script - Update .gitignore to exclude runtime/sensitive data (backups, uploads, chat-history, keys, downloads, generated .d.ts files) while keeping folders - Add .gitkeep to preserve empty runtime folders in git Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import PatientsBalancesList 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";
|
||||
function fmtCurrency(v) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
}
|
||||
export default function CollectionsByDoctorReport({ startDate, endDate, npiProviderId, }) {
|
||||
const [staffId, setStaffId] = useState("");
|
||||
const perPage = 10;
|
||||
const [cursorStack, setCursorStack] = useState([null]);
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const currentCursor = cursorStack[cursorIndex] ?? null;
|
||||
const pageIndex = cursorIndex + 1;
|
||||
// load staffs list for selector
|
||||
const { data: staffs } = useQuery({
|
||||
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({
|
||||
queryKey: [
|
||||
"collections-by-doctor-balances",
|
||||
staffId,
|
||||
currentCursor,
|
||||
perPage,
|
||||
startDate,
|
||||
endDate,
|
||||
npiProviderId ?? "all",
|
||||
],
|
||||
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);
|
||||
if (npiProviderId)
|
||||
params.set("npiProviderId", String(npiProviderId));
|
||||
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({
|
||||
queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate, npiProviderId ?? "all"],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (staffId)
|
||||
params.set("staffId", staffId);
|
||||
if (startDate)
|
||||
params.set("from", startDate);
|
||||
if (endDate)
|
||||
params.set("to", endDate);
|
||||
if (npiProviderId)
|
||||
params.set("npiProviderId", String(npiProviderId));
|
||||
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, npiProviderId]);
|
||||
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 = 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>);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateInput } from "@/components/ui/dateInput";
|
||||
import { formatLocalDate, parseLocalDate, formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { DollarSign } from "lucide-react";
|
||||
function fmt(n) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
|
||||
}
|
||||
export default function CommissionSection() {
|
||||
const { toast } = useToast();
|
||||
const printRef = useRef(null);
|
||||
// Filters
|
||||
const [fromDate, setFromDate] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
});
|
||||
const [toDate, setToDate] = useState(() => new Date().toISOString().split("T")[0] ?? "");
|
||||
const [selectedProviderId, setSelectedProviderId] = useState(null);
|
||||
// Selection
|
||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||
// Pay modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [commissionAmount, setCommissionAmount] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
// Providers
|
||||
const { data: providers = [] } = useQuery({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
// Eligible payments
|
||||
const { data: payments = [], isLoading, isError, } = useQuery({
|
||||
queryKey: ["/api/commissions/eligible", selectedProviderId, fromDate, toDate],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("npiProviderId", String(selectedProviderId));
|
||||
if (fromDate)
|
||||
params.set("from", fromDate);
|
||||
if (toDate)
|
||||
params.set("to", toDate);
|
||||
const res = await apiRequest("GET", `/api/commissions/eligible?${params}`);
|
||||
if (!res.ok)
|
||||
throw new Error("Failed to fetch eligible payments");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!selectedProviderId,
|
||||
});
|
||||
// Past batches
|
||||
const { data: batches = [] } = useQuery({
|
||||
queryKey: ["/api/commissions/batches", selectedProviderId],
|
||||
queryFn: async () => {
|
||||
const params = selectedProviderId
|
||||
? `?npiProviderId=${selectedProviderId}`
|
||||
: "";
|
||||
const res = await apiRequest("GET", `/api/commissions/batches${params}`);
|
||||
if (!res.ok)
|
||||
throw new Error("Failed to fetch batches");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
// Reset selection when provider/dates change
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set());
|
||||
}, [selectedProviderId, fromDate, toDate]);
|
||||
// Create commission batch mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (payload) => {
|
||||
const res = await apiRequest("POST", "/api/commissions", payload);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.message ?? "Failed to save commission");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Commission saved", description: "Payments have been marked as commissioned." });
|
||||
setShowModal(false);
|
||||
setSelectedIds(new Set());
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/commissions/eligible"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/commissions/batches"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Error", description: err?.message ?? "Failed to save", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
const selectedPayments = payments.filter((p) => selectedIds.has(p.id));
|
||||
const totalCollection = selectedPayments.reduce((s, p) => s + p.collectionAmount, 0);
|
||||
// Sync commission amount when selection changes
|
||||
useEffect(() => {
|
||||
setCommissionAmount(totalCollection.toFixed(2));
|
||||
}, [totalCollection]);
|
||||
const allSelected = payments.length > 0 && selectedIds.size === payments.length;
|
||||
const someSelected = selectedIds.size > 0 && !allSelected;
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
else {
|
||||
setSelectedIds(new Set(payments.map((p) => p.id)));
|
||||
}
|
||||
};
|
||||
const toggleOne = (id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handlePrint = () => {
|
||||
const provider = providers.find((p) => p.id === selectedProviderId);
|
||||
const win = window.open("", "_blank");
|
||||
if (!win)
|
||||
return;
|
||||
win.document.write(`
|
||||
<html><head><title>Commission Summary</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 24px; font-size: 13px; }
|
||||
h1 { font-size: 18px; margin-bottom: 4px; }
|
||||
p { margin: 2px 0; color: #555; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
||||
th { background: #f3f4f6; text-align: left; padding: 8px; border: 1px solid #e5e7eb; }
|
||||
td { padding: 8px; border: 1px solid #e5e7eb; }
|
||||
.total { font-weight: bold; margin-top: 16px; font-size: 15px; }
|
||||
.footer { margin-top: 24px; color: #888; font-size: 11px; }
|
||||
</style></head><body>
|
||||
<h1>Commission Summary — ${provider?.providerName ?? "Provider"}</h1>
|
||||
<p>Date Range: ${fromDate || "—"} to ${toDate || "—"}</p>
|
||||
<p>Generated: ${new Date().toLocaleDateString()}</p>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>#</th><th>Claim / Source</th><th>Patient</th>
|
||||
<th>Service Date</th><th>MH Paid</th><th>Copayment</th><th>Collection</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${selectedPayments
|
||||
.map((p, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${p.claimNumber ?? (p.isOcr ? "PDF Import" : "—")}</td>
|
||||
<td>${p.patientName}</td>
|
||||
<td>${p.serviceDate ? new Date(p.serviceDate).toLocaleDateString() : "—"}</td>
|
||||
<td>${fmt(p.mhPaidAmount)}</td>
|
||||
<td>${fmt(p.copayment)}</td>
|
||||
<td>${fmt(p.collectionAmount)}</td>
|
||||
</tr>`)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="total">Total Collection: ${fmt(totalCollection)}</p>
|
||||
<p class="total">Commission Amount: ${fmt(Number(commissionAmount) || 0)}</p>
|
||||
${notes ? `<p style="margin-top:12px"><b>Notes:</b> ${notes}</p>` : ""}
|
||||
<p class="footer">Summit Dental Care — Commission Record</p>
|
||||
</body></html>
|
||||
`);
|
||||
win.document.close();
|
||||
win.print();
|
||||
};
|
||||
const handleSave = () => {
|
||||
if (!selectedProviderId || selectedPayments.length === 0)
|
||||
return;
|
||||
const amount = Number(commissionAmount);
|
||||
if (isNaN(amount) || amount < 0) {
|
||||
toast({ title: "Invalid amount", description: "Enter a valid commission amount.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({
|
||||
npiProviderId: selectedProviderId,
|
||||
paymentIds: selectedPayments.map((p) => p.id),
|
||||
totalCollection,
|
||||
commissionAmount: amount,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
};
|
||||
const fromDateObj = fromDate ? (() => { try {
|
||||
return parseLocalDate(fromDate);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
} })() : null;
|
||||
const toDateObj = toDate ? (() => { try {
|
||||
return parseLocalDate(toDate);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
} })() : null;
|
||||
return (<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5"/> Commission
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<DateInput label="Start Date" value={fromDateObj} onChange={(d) => setFromDate(d ? formatLocalDate(d) : "")} disableFuture/>
|
||||
<DateInput label="End Date" value={toDateObj} onChange={(d) => setToDate(d ? formatLocalDate(d) : "")} disableFuture/>
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Select value={selectedProviderId?.toString() ?? ""} onValueChange={(v) => setSelectedProviderId(Number(v))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (<SelectItem key={p.id} value={p.id.toString()}>
|
||||
{p.providerName}
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eligible payments table */}
|
||||
{selectedProviderId && (<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
{isLoading
|
||||
? "Loading…"
|
||||
: `${payments.length} eligible payment(s) — not yet commissioned`}
|
||||
</p>
|
||||
{selectedIds.size > 0 && (<Button size="sm" onClick={() => setShowModal(true)} className="bg-green-600 hover:bg-green-700 text-white">
|
||||
Pay Commission ({selectedIds.size} selected — {fmt(totalCollection)})
|
||||
</Button>)}
|
||||
</div>
|
||||
|
||||
{isError && (<p className="text-sm text-red-500">Failed to load payments.</p>)}
|
||||
|
||||
{!isLoading && !isError && payments.length === 0 && (<p className="text-sm text-gray-400 py-4 text-center">
|
||||
No uncommissioned payments found for this provider and date range.
|
||||
</p>)}
|
||||
|
||||
{payments.length > 0 && (<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left w-10">
|
||||
<Checkbox checked={allSelected} data-state={someSelected ? "indeterminate" : undefined} onCheckedChange={toggleAll}/>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left">Claim / Source</th>
|
||||
<th className="px-3 py-2 text-left">Patient</th>
|
||||
<th className="px-3 py-2 text-left">Service Date</th>
|
||||
<th className="px-3 py-2 text-right">MH Paid</th>
|
||||
<th className="px-3 py-2 text-right">Copayment</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Collection</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (<tr key={p.id} className={`border-t border-gray-100 hover:bg-gray-50 cursor-pointer ${selectedIds.has(p.id) ? "bg-green-50" : ""}`} onClick={() => toggleOne(p.id)}>
|
||||
<td className="px-3 py-2">
|
||||
<Checkbox checked={selectedIds.has(p.id)} onCheckedChange={() => toggleOne(p.id)} onClick={(e) => e.stopPropagation()}/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
{p.claimNumber ?? (p.isOcr ? (<span className="bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded text-xs">PDF Import</span>) : "—")}
|
||||
</td>
|
||||
<td className="px-3 py-2">{p.patientName}</td>
|
||||
<td className="px-3 py-2 text-gray-500">
|
||||
{p.serviceDate
|
||||
? new Date(p.serviceDate).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-green-700">{fmt(p.mhPaidAmount)}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-700">{fmt(p.copayment)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold">{fmt(p.collectionAmount)}</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
{selectedIds.size > 0 && (<tfoot className="bg-gray-50 border-t-2 border-gray-200">
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-2 text-right font-semibold text-gray-700">
|
||||
Selected Total ({selectedIds.size} items):
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-green-700 text-base">
|
||||
{fmt(totalCollection)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>)}
|
||||
</table>
|
||||
</div>)}
|
||||
</div>)}
|
||||
|
||||
{/* Past commissions */}
|
||||
{batches.length > 0 && (<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Past Commission Batches</h4>
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Date Paid</th>
|
||||
<th className="px-3 py-2 text-left">Provider</th>
|
||||
<th className="px-3 py-2 text-right">Claims</th>
|
||||
<th className="px-3 py-2 text-right">Total Collection</th>
|
||||
<th className="px-3 py-2 text-right">Commission Paid</th>
|
||||
<th className="px-3 py-2 text-left">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => (<tr key={b.id} className="border-t border-gray-100">
|
||||
<td className="px-3 py-2">{formatDateToHumanReadable(b.createdAt)}</td>
|
||||
<td className="px-3 py-2">{b.providerName}</td>
|
||||
<td className="px-3 py-2 text-right">{b.itemCount}</td>
|
||||
<td className="px-3 py-2 text-right">{fmt(b.totalCollection)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-green-700">{fmt(b.commissionAmount)}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{b.notes ?? "—"}</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
|
||||
{/* Pay Commission Modal */}
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="sm:max-w-[680px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay Commission</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the selected payments and confirm the commission amount.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Selected rows summary */}
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 max-h-64">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Claim / Source</th>
|
||||
<th className="px-3 py-2 text-left">Patient</th>
|
||||
<th className="px-3 py-2 text-right">MH Paid</th>
|
||||
<th className="px-3 py-2 text-right">Copayment</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Collection</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedPayments.map((p) => (<tr key={p.id} className="border-t border-gray-100">
|
||||
<td className="px-3 py-1.5 font-mono text-xs">
|
||||
{p.claimNumber ?? (p.isOcr ? "PDF Import" : "—")}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">{p.patientName}</td>
|
||||
<td className="px-3 py-1.5 text-right">{fmt(p.mhPaidAmount)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{fmt(p.copayment)}</td>
|
||||
<td className="px-3 py-1.5 text-right font-medium">{fmt(p.collectionAmount)}</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 border-t-2 border-gray-200">
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-2 text-right font-semibold">Total Collection:</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-green-700">{fmt(totalCollection)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Commission amount input */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Commission Amount ($)</Label>
|
||||
<Input type="number" min="0" step="0.01" value={commissionAmount} onChange={(e) => setCommissionAmount(e.target.value)} placeholder="Enter commission amount"/>
|
||||
<p className="text-xs text-gray-400">Defaults to total collection. Adjust if using a rate.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="e.g. May 2026 commission @ 20%"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={handlePrint}>
|
||||
Print
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={createMutation.isPending} className="bg-green-600 hover:bg-green-700 text-white">
|
||||
{createMutation.isPending ? "Saving…" : "Save & Mark as Commissioned"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useState } from "react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
export default function ExportReportButton({ reportType, from, to, staffId, className, labelCsv = "Download CSV", }) {
|
||||
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) {
|
||||
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>);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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, }) {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (hasPrev)
|
||||
onPrev();
|
||||
}} className={hasPrev ? "" : "pointer-events-none opacity-50"}/>
|
||||
</PaginationItem>
|
||||
|
||||
<div className="px-2"/>
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (hasNext)
|
||||
onNext();
|
||||
}} className={hasNext ? "" : "pointer-events-none opacity-50"}/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
import { DollarSign } from "lucide-react";
|
||||
import PaginationControls from "./pagination-controls";
|
||||
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
|
||||
}) {
|
||||
const fmt = (v) => new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
const reportTypeTitle = (rt) => {
|
||||
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>);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import PatientsBalancesList from "./patients-balances-list";
|
||||
import ExportReportButton from "./export-button";
|
||||
export default function PatientsWithBalanceReport({ startDate, endDate, npiProviderId, }) {
|
||||
const balancesPerPage = 10;
|
||||
const [cursorStack, setCursorStack] = useState([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({
|
||||
queryKey: [
|
||||
"/api/payments-reports/patient-balances",
|
||||
currentCursor,
|
||||
balancesPerPage,
|
||||
startDate,
|
||||
endDate,
|
||||
npiProviderId ?? "all",
|
||||
],
|
||||
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);
|
||||
if (npiProviderId)
|
||||
params.set("npiProviderId", String(npiProviderId));
|
||||
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, npiProviderId]);
|
||||
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>);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
export default function ReportConfig({ startDate, endDate, setStartDate, setEndDate, selectedReportType, setSelectedReportType, npiProviderId, setNpiProviderId, }) {
|
||||
let startDateObj = null;
|
||||
if (startDate) {
|
||||
try {
|
||||
startDateObj = parseLocalDate(startDate);
|
||||
}
|
||||
catch {
|
||||
startDateObj = null;
|
||||
}
|
||||
}
|
||||
let endDateObj = null;
|
||||
if (endDate) {
|
||||
try {
|
||||
endDateObj = parseLocalDate(endDate);
|
||||
}
|
||||
catch {
|
||||
endDateObj = null;
|
||||
}
|
||||
}
|
||||
const { data: npiProviders = [] } = useQuery({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
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, date range, and provider.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 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>Provider</Label>
|
||||
<Select value={npiProviderId?.toString() ?? "all"} onValueChange={(v) => setNpiProviderId(v === "all" ? null : Number(v))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Providers"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Providers</SelectItem>
|
||||
{npiProviders.map((p) => (<SelectItem key={p.id} value={p.id.toString()}>
|
||||
{p.providerName}
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="report-type">Report Type</Label>
|
||||
<Select value={selectedReportType} onValueChange={(v) => setSelectedReportType(v)}>
|
||||
<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="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>);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
function fmtCurrency(v) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(v);
|
||||
}
|
||||
export default function SummaryCards({ startDate, endDate, npiProviderId, }) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["/api/payments-reports/summary", startDate, endDate, npiProviderId ?? "all"],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate)
|
||||
params.set("from", startDate);
|
||||
if (endDate)
|
||||
params.set("to", endDate);
|
||||
if (npiProviderId)
|
||||
params.set("npiProviderId", String(npiProviderId));
|
||||
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