feat: add provider column, commission tracking, and report provider filter
- Claims & Payments: save npiProviderId when submitting MH claim; sync between claim and payment on update - Claims table: add Provider column showing rendering provider name - Payments table: add Provider column + purple Commissioned badge on status - Claim edit modal: add Rendering Provider dropdown (defaults to Mary Scannell) - Payment edit modal: add Rendering Provider dropdown + Commissioned metadata display - Reports page: add Provider filter dropdown (dynamic from NPI providers settings) - Reports page: remove Collections by Doctor report type and Select Doctor dropdown - Commission section: new section in reports page with date range + provider filter, shows eligible paid claims/payments per provider, multi-select checkboxes, Pay Commission modal with print + save, marks payments as commissioned so they are excluded from future cycles - DB: add CommissionBatch and CommissionBatchItem tables; backfill Payment.npiProviderId from linked claims - Backend: PATCH /api/payments/:id/provider syncs to linked claim; PUT /api/claims/:id syncs to linked payment; new /api/commissions routes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,9 +25,11 @@ function fmtCurrency(v: number) {
|
||||
export default function CollectionsByDoctorReport({
|
||||
startDate,
|
||||
endDate,
|
||||
npiProviderId,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
npiProviderId?: number | null;
|
||||
}) {
|
||||
const [staffId, setStaffId] = useState<string>("");
|
||||
|
||||
@@ -76,6 +78,7 @@ export default function CollectionsByDoctorReport({
|
||||
perPage,
|
||||
startDate,
|
||||
endDate,
|
||||
npiProviderId ?? "all",
|
||||
],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -84,6 +87,7 @@ export default function CollectionsByDoctorReport({
|
||||
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",
|
||||
@@ -116,12 +120,13 @@ export default function CollectionsByDoctorReport({
|
||||
},
|
||||
Error
|
||||
>({
|
||||
queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate],
|
||||
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",
|
||||
@@ -152,7 +157,7 @@ export default function CollectionsByDoctorReport({
|
||||
useEffect(() => {
|
||||
setCursorStack([null]);
|
||||
setCursorIndex(0);
|
||||
}, [staffId, startDate, endDate]);
|
||||
}, [staffId, startDate, endDate, npiProviderId]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
setCursorIndex((i) => Math.max(0, i - 1));
|
||||
|
||||
516
apps/Frontend/src/components/reports/commission-section.tsx
Normal file
516
apps/Frontend/src/components/reports/commission-section.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
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";
|
||||
import { NpiProvider } from "@repo/db/types";
|
||||
|
||||
interface EligiblePayment {
|
||||
id: number;
|
||||
claimNumber: string | null;
|
||||
patientName: string;
|
||||
serviceDate: string | null;
|
||||
mhPaidAmount: number;
|
||||
copayment: number;
|
||||
collectionAmount: number;
|
||||
paymentDate: string;
|
||||
isOcr: boolean;
|
||||
}
|
||||
|
||||
interface CommissionBatch {
|
||||
id: number;
|
||||
providerName: string;
|
||||
totalCollection: number;
|
||||
commissionAmount: number;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
|
||||
}
|
||||
|
||||
export default function CommissionSection() {
|
||||
const { toast } = useToast();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filters
|
||||
const [fromDate, setFromDate] = useState<string>(() => {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
});
|
||||
const [toDate, setToDate] = useState<string>(
|
||||
() => new Date().toISOString().split("T")[0] ?? ""
|
||||
);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<number | null>(null);
|
||||
|
||||
// Selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Pay modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [commissionAmount, setCommissionAmount] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Providers
|
||||
const { data: providers = [] } = useQuery<NpiProvider[]>({
|
||||
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<EligiblePayment[]>({
|
||||
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<CommissionBatch[]>({
|
||||
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: {
|
||||
npiProviderId: number;
|
||||
paymentIds: number[];
|
||||
totalCollection: number;
|
||||
commissionAmount: number;
|
||||
notes?: string;
|
||||
}) => {
|
||||
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: any) => {
|
||||
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: number) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,11 @@ type Resp = {
|
||||
export default function PatientsWithBalanceReport({
|
||||
startDate,
|
||||
endDate,
|
||||
npiProviderId,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
npiProviderId?: number | null;
|
||||
}) {
|
||||
const balancesPerPage = 10;
|
||||
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
|
||||
@@ -32,6 +34,7 @@ export default function PatientsWithBalanceReport({
|
||||
balancesPerPage,
|
||||
startDate,
|
||||
endDate,
|
||||
npiProviderId ?? "all",
|
||||
],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -39,6 +42,7 @@ export default function PatientsWithBalanceReport({
|
||||
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()}`
|
||||
@@ -63,7 +67,7 @@ export default function PatientsWithBalanceReport({
|
||||
setCursorStack([null]);
|
||||
setCursorIndex(0);
|
||||
refetch();
|
||||
}, [startDate, endDate]);
|
||||
}, [startDate, endDate, npiProviderId]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const idx = cursorIndex;
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
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";
|
||||
import { NpiProvider } from "@repo/db/types";
|
||||
|
||||
type ReportType =
|
||||
| "patients_with_balance"
|
||||
| "patients_no_balance"
|
||||
| "monthly_collections"
|
||||
| "collections_by_doctor"
|
||||
| "procedure_codes_by_doctor"
|
||||
| "payment_methods"
|
||||
| "insurance_vs_patient_payments"
|
||||
@@ -29,34 +31,37 @@ export default function ReportConfig({
|
||||
setEndDate,
|
||||
selectedReportType,
|
||||
setSelectedReportType,
|
||||
npiProviderId,
|
||||
setNpiProviderId,
|
||||
}: {
|
||||
startDate: string; // "" or "YYYY-MM-DD"
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
setStartDate: (s: string) => void;
|
||||
setEndDate: (s: string) => void;
|
||||
selectedReportType: ReportType;
|
||||
setSelectedReportType: (r: ReportType) => void;
|
||||
npiProviderId: number | null;
|
||||
setNpiProviderId: (id: number | null) => void;
|
||||
}) {
|
||||
// Convert incoming string -> Date | null using your parseLocalDate utility.
|
||||
// parseLocalDate can throw for invalid strings, so guard with try/catch.
|
||||
let startDateObj: Date | null = null;
|
||||
if (startDate) {
|
||||
try {
|
||||
startDateObj = parseLocalDate(startDate);
|
||||
} catch {
|
||||
startDateObj = null;
|
||||
}
|
||||
try { startDateObj = parseLocalDate(startDate); } catch { startDateObj = null; }
|
||||
}
|
||||
|
||||
let endDateObj: Date | null = null;
|
||||
if (endDate) {
|
||||
try {
|
||||
endDateObj = parseLocalDate(endDate);
|
||||
} catch {
|
||||
endDateObj = null;
|
||||
}
|
||||
try { endDateObj = parseLocalDate(endDate); } catch { endDateObj = null; }
|
||||
}
|
||||
|
||||
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -67,17 +72,15 @@ export default function ReportConfig({
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
Choose the report type and date range.
|
||||
Choose the report type, date range, and provider.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<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) : "");
|
||||
}}
|
||||
onChange={(d) => setStartDate(d ? formatLocalDate(d) : "")}
|
||||
disableFuture
|
||||
/>
|
||||
</div>
|
||||
@@ -86,13 +89,33 @@ export default function ReportConfig({
|
||||
<DateInput
|
||||
label="End Date"
|
||||
value={endDateObj}
|
||||
onChange={(d) => {
|
||||
setEndDate(d ? formatLocalDate(d) : "");
|
||||
}}
|
||||
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
|
||||
@@ -106,9 +129,6 @@ export default function ReportConfig({
|
||||
<SelectItem value="patients_with_balance">
|
||||
Patients with Outstanding Balance
|
||||
</SelectItem>
|
||||
<SelectItem value="collections_by_doctor">
|
||||
Collections by Doctor
|
||||
</SelectItem>
|
||||
<SelectItem value="patients_no_balance">
|
||||
Patients with Zero Balance
|
||||
</SelectItem>
|
||||
|
||||
@@ -20,17 +20,19 @@ function fmtCurrency(v: number) {
|
||||
export default function SummaryCards({
|
||||
startDate,
|
||||
endDate,
|
||||
npiProviderId,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
npiProviderId?: number | null;
|
||||
}) {
|
||||
// Query the server summary for the given date range
|
||||
const { data, isLoading, isError } = useQuery<SummaryResp, Error>({
|
||||
queryKey: ["/api/payments-reports/summary", startDate, endDate],
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user