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:
2026-06-26 00:23:43 -04:00
co-authored by Claude Sonnet 4.6
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions
@@ -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>);
}