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:
@@ -1063,6 +1063,16 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
...(req.body.npiProviderId ? { npiProviderId: Number(req.body.npiProviderId) } : {}),
|
||||
});
|
||||
const updatedClaim = await storage.updateClaim(claimId, claimData);
|
||||
|
||||
// Propagate provider change to the linked payment so both stay in sync
|
||||
if (req.body.npiProviderId) {
|
||||
const { prisma: db } = await import("@repo/db/client");
|
||||
await db.payment.updateMany({
|
||||
where: { claimId },
|
||||
data: { npiProviderId: Number(req.body.npiProviderId) },
|
||||
});
|
||||
}
|
||||
|
||||
res.json(updatedClaim);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
81
apps/Backend/src/routes/commissions.ts
Normal file
81
apps/Backend/src/routes/commissions.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** GET /api/commissions/eligible
|
||||
* Query: npiProviderId (required), from?, to?
|
||||
*/
|
||||
router.get("/eligible", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const npiProviderId = Number(req.query.npiProviderId);
|
||||
if (!npiProviderId || !Number.isFinite(npiProviderId)) {
|
||||
return res.status(400).json({ message: "npiProviderId is required" });
|
||||
}
|
||||
|
||||
const from = req.query.from ? new Date(String(req.query.from)) : null;
|
||||
const to = req.query.to ? new Date(String(req.query.to)) : null;
|
||||
|
||||
if (req.query.from && isNaN(from!.getTime()))
|
||||
return res.status(400).json({ message: "Invalid 'from' date" });
|
||||
if (req.query.to && isNaN(to!.getTime()))
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
|
||||
const payments = await storage.getEligiblePayments(npiProviderId, from, to);
|
||||
return res.json(payments);
|
||||
} catch (err: any) {
|
||||
console.error("GET /api/commissions/eligible error:", err);
|
||||
return res.status(500).json({ message: err?.message ?? "Failed to fetch eligible payments" });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/commissions/batches
|
||||
* Query: npiProviderId? (optional filter)
|
||||
*/
|
||||
router.get("/batches", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const npiProviderId = req.query.npiProviderId
|
||||
? Number(req.query.npiProviderId)
|
||||
: undefined;
|
||||
const batches = await storage.getCommissionBatches(npiProviderId);
|
||||
return res.json(batches);
|
||||
} catch (err: any) {
|
||||
console.error("GET /api/commissions/batches error:", err);
|
||||
return res.status(500).json({ message: err?.message ?? "Failed to fetch commission batches" });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/commissions
|
||||
* Body: { npiProviderId, paymentIds[], totalCollection, commissionAmount, notes? }
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { npiProviderId, paymentIds, totalCollection, commissionAmount, notes } = req.body;
|
||||
|
||||
if (!npiProviderId || !Array.isArray(paymentIds) || paymentIds.length === 0) {
|
||||
return res.status(400).json({ message: "npiProviderId and at least one paymentId are required" });
|
||||
}
|
||||
if (typeof totalCollection !== "number" || typeof commissionAmount !== "number") {
|
||||
return res.status(400).json({ message: "totalCollection and commissionAmount must be numbers" });
|
||||
}
|
||||
|
||||
const batch = await storage.createCommissionBatch({
|
||||
npiProviderId: Number(npiProviderId),
|
||||
paymentIds: paymentIds.map(Number),
|
||||
totalCollection,
|
||||
commissionAmount,
|
||||
notes: notes ?? undefined,
|
||||
});
|
||||
|
||||
return res.status(201).json(batch);
|
||||
} catch (err: any) {
|
||||
console.error("POST /api/commissions error:", err);
|
||||
return res.status(500).json({ message: err?.message ?? "Failed to create commission batch" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -31,6 +31,7 @@ import officeHoursRoutes from "./office-hours";
|
||||
import officeContactRoutes from "./office-contact";
|
||||
import procedureTimeslotRoutes from "./procedure-timeslot";
|
||||
import insuranceContactsRoutes from "./insurance-contacts";
|
||||
import commissionsRoutes from "./commissions";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -66,5 +67,6 @@ router.use("/office-hours", officeHoursRoutes);
|
||||
router.use("/office-contact", officeContactRoutes);
|
||||
router.use("/procedure-timeslot", procedureTimeslotRoutes);
|
||||
router.use("/insurance-contacts", insuranceContactsRoutes);
|
||||
router.use("/commissions", commissionsRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -17,8 +17,11 @@ router.get("/summary", async (req: Request, res: Response): Promise<any> => {
|
||||
if (req.query.to && isNaN(to?.getTime() ?? NaN))
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
|
||||
const summary = await storage.getSummary(from, to);
|
||||
res.json(summary);
|
||||
const npiProviderId = req.query.npiProviderId
|
||||
? Number(req.query.npiProviderId)
|
||||
: null;
|
||||
|
||||
const summary = await storage.getSummary(from, to, npiProviderId);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"GET /api/payments-reports/summary error:",
|
||||
@@ -60,11 +63,16 @@ router.get(
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
}
|
||||
|
||||
const npiProviderId = req.query.npiProviderId
|
||||
? Number(req.query.npiProviderId)
|
||||
: null;
|
||||
|
||||
const data = await storage.getPatientsWithBalances(
|
||||
limit,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
to,
|
||||
npiProviderId
|
||||
);
|
||||
// returns { balances, totalCount, nextCursor, hasMore }
|
||||
res.json(data);
|
||||
@@ -126,13 +134,18 @@ router.get(
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
}
|
||||
|
||||
const npiProviderId = req.query.npiProviderId
|
||||
? Number(req.query.npiProviderId)
|
||||
: null;
|
||||
|
||||
// use the new storage method that returns only the paged balances
|
||||
const balancesResult = await storage.getPatientsBalancesByDoctor(
|
||||
staffId,
|
||||
limit,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
to,
|
||||
npiProviderId
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -192,8 +205,12 @@ router.get(
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
}
|
||||
|
||||
const npiProviderId = req.query.npiProviderId
|
||||
? Number(req.query.npiProviderId)
|
||||
: null;
|
||||
|
||||
// use the new storage method that returns only the summary for the staff
|
||||
const summary = await storage.getSummaryByDoctor(staffId, from, to);
|
||||
const summary = await storage.getSummaryByDoctor(staffId, from, to, npiProviderId);
|
||||
|
||||
res.json(summary);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -200,10 +200,17 @@ router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
|
||||
|
||||
const claimId = parseIntOrError(req.params.claimId, "Claim ID");
|
||||
|
||||
// Inherit npiProviderId from the linked claim so commission queries work
|
||||
const linkedClaim = await prisma.claim.findUnique({
|
||||
where: { id: claimId },
|
||||
select: { npiProviderId: true },
|
||||
});
|
||||
|
||||
const validated = insertPaymentSchema.safeParse({
|
||||
...req.body,
|
||||
claimId,
|
||||
userId,
|
||||
...(linkedClaim?.npiProviderId ? { npiProviderId: linkedClaim.npiProviderId } : {}),
|
||||
});
|
||||
|
||||
if (!validated.success) {
|
||||
@@ -427,6 +434,48 @@ router.patch(
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/payments/:id/provider
|
||||
router.patch(
|
||||
"/:id/provider",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
const { npiProviderId } = req.body;
|
||||
|
||||
const existing = await storage.getPayment(paymentId);
|
||||
if (!existing) return res.status(404).json({ message: "Payment not found" });
|
||||
|
||||
// Update payment and linked claim atomically so the claims form
|
||||
// picks up the new provider when it prefills from the existing claim.
|
||||
const ops: Parameters<typeof prisma.$transaction>[0] = [
|
||||
prisma.payment.update({
|
||||
where: { id: paymentId },
|
||||
data: { npiProviderId: npiProviderId ?? null, updatedById: userId },
|
||||
include: { npiProvider: true },
|
||||
}),
|
||||
];
|
||||
|
||||
if (existing.claimId) {
|
||||
ops.push(
|
||||
prisma.claim.update({
|
||||
where: { id: existing.claimId },
|
||||
data: { npiProviderId: npiProviderId ?? null },
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await prisma.$transaction(ops);
|
||||
return res.json(updated);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to update provider";
|
||||
return res.status(500).json({ message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/payments/:id/mh-paid-amount
|
||||
router.patch(
|
||||
"/:id/mh-paid-amount",
|
||||
|
||||
@@ -46,6 +46,7 @@ export const claimsStorage: IStorage = {
|
||||
serviceLines: true,
|
||||
staff: true,
|
||||
claimFiles: true,
|
||||
npiProvider: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -63,7 +64,7 @@ export const claimsStorage: IStorage = {
|
||||
status: { notIn: ["CANCELLED"] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { serviceLines: true, claimFiles: true, staff: true },
|
||||
include: { serviceLines: true, claimFiles: true, staff: true, npiProvider: true },
|
||||
}) as Promise<ClaimWithServiceLines | null>;
|
||||
},
|
||||
|
||||
@@ -79,7 +80,7 @@ export const claimsStorage: IStorage = {
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
include: { serviceLines: true, staff: true, claimFiles: true },
|
||||
include: { serviceLines: true, staff: true, claimFiles: true, npiProvider: true },
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
193
apps/Backend/src/storage/commissions-storage.ts
Normal file
193
apps/Backend/src/storage/commissions-storage.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface EligiblePaymentRow {
|
||||
id: number;
|
||||
claimNumber: string | null;
|
||||
patientName: string;
|
||||
serviceDate: Date | null;
|
||||
mhPaidAmount: number;
|
||||
copayment: number;
|
||||
collectionAmount: number;
|
||||
paymentDate: Date;
|
||||
isOcr: boolean;
|
||||
}
|
||||
|
||||
export interface CommissionBatchRecord {
|
||||
id: number;
|
||||
npiProviderId: number;
|
||||
totalCollection: number;
|
||||
commissionAmount: number;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
providerName: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export const commissionsStorage = {
|
||||
/**
|
||||
* Eligible payments for commission — two sources:
|
||||
* 1. Claims owned by this provider (Claim.npiProviderId) whose linked payment
|
||||
* has actual collection and has not been commissioned yet.
|
||||
* 2. OCR / PDF-import payments that have no linked claim but carry the
|
||||
* provider on Payment.npiProviderId directly.
|
||||
*/
|
||||
async getEligiblePayments(
|
||||
npiProviderId: number,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<EligiblePaymentRow[]> {
|
||||
const dateFilter: any = {};
|
||||
if (from) dateFilter.gte = from;
|
||||
if (to) {
|
||||
const toEnd = new Date(to);
|
||||
toEnd.setUTCHours(23, 59, 59, 999);
|
||||
dateFilter.lte = toEnd;
|
||||
}
|
||||
const hasDateFilter = Object.keys(dateFilter).length > 0;
|
||||
|
||||
// ── 1. Claim-centric: provider lives on the Claim ──────────────────────
|
||||
const claimWhere: any = {
|
||||
npiProviderId,
|
||||
payment: {
|
||||
is: {
|
||||
commissionBatchItems: { none: {} },
|
||||
OR: [{ mhPaidAmount: { gt: 0 } }, { copayment: { gt: 0 } }],
|
||||
...(hasDateFilter ? { createdAt: dateFilter } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const claims = await db.claim.findMany({
|
||||
where: claimWhere,
|
||||
orderBy: { id: "desc" },
|
||||
include: {
|
||||
payment: {
|
||||
select: { id: true, mhPaidAmount: true, copayment: true, createdAt: true, notes: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rows: EligiblePaymentRow[] = claims
|
||||
.filter((c) => c.payment !== null)
|
||||
.map((c) => {
|
||||
const p = c.payment!;
|
||||
const mh = Number(p.mhPaidAmount ?? 0);
|
||||
const co = Number(p.copayment ?? 0);
|
||||
return {
|
||||
id: p.id,
|
||||
claimNumber: c.claimNumber ?? null,
|
||||
patientName: c.patientName,
|
||||
serviceDate: c.serviceDate,
|
||||
mhPaidAmount: mh,
|
||||
copayment: co,
|
||||
collectionAmount: mh + co,
|
||||
paymentDate: p.createdAt,
|
||||
isOcr: false,
|
||||
};
|
||||
});
|
||||
|
||||
// ── 2. OCR / PDF payments — no claim, matched by Payment.npiProviderId ──
|
||||
const ocrWhere: any = {
|
||||
npiProviderId,
|
||||
claimId: null,
|
||||
commissionBatchItems: { none: {} },
|
||||
OR: [{ mhPaidAmount: { gt: 0 } }, { copayment: { gt: 0 } }],
|
||||
...(hasDateFilter ? { createdAt: dateFilter } : {}),
|
||||
};
|
||||
|
||||
const ocrPayments = await db.payment.findMany({
|
||||
where: ocrWhere,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
patient: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const p of ocrPayments) {
|
||||
const mh = Number(p.mhPaidAmount ?? 0);
|
||||
const co = Number(p.copayment ?? 0);
|
||||
const patientName =
|
||||
`${p.patient?.firstName ?? ""} ${p.patient?.lastName ?? ""}`.trim() ||
|
||||
"Unknown";
|
||||
rows.push({
|
||||
id: p.id,
|
||||
claimNumber: null,
|
||||
patientName,
|
||||
serviceDate: null,
|
||||
mhPaidAmount: mh,
|
||||
copayment: co,
|
||||
collectionAmount: mh + co,
|
||||
paymentDate: p.createdAt,
|
||||
isOcr: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort combined list by most recent first
|
||||
rows.sort((a, b) => b.paymentDate.getTime() - a.paymentDate.getTime());
|
||||
return rows;
|
||||
},
|
||||
|
||||
/** Create a commission batch, linking the selected payments */
|
||||
async createCommissionBatch(data: {
|
||||
npiProviderId: number;
|
||||
paymentIds: number[];
|
||||
totalCollection: number;
|
||||
commissionAmount: number;
|
||||
notes?: string;
|
||||
}) {
|
||||
// Verify none of the payments are already commissioned
|
||||
const alreadyDone = await db.commissionBatchItem.count({
|
||||
where: { paymentId: { in: data.paymentIds } },
|
||||
});
|
||||
if (alreadyDone > 0) {
|
||||
throw new Error("One or more selected payments have already been commissioned.");
|
||||
}
|
||||
|
||||
// Calculate collection amounts per payment
|
||||
const payments = await db.payment.findMany({
|
||||
where: { id: { in: data.paymentIds } },
|
||||
select: { id: true, mhPaidAmount: true, copayment: true },
|
||||
});
|
||||
|
||||
const batch = await db.commissionBatch.create({
|
||||
data: {
|
||||
npiProviderId: data.npiProviderId,
|
||||
totalCollection: data.totalCollection,
|
||||
commissionAmount: data.commissionAmount,
|
||||
notes: data.notes ?? null,
|
||||
items: {
|
||||
create: payments.map((p) => ({
|
||||
paymentId: p.id,
|
||||
collectionAmount: Number(p.mhPaidAmount ?? 0) + Number(p.copayment ?? 0),
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { items: true, npiProvider: true },
|
||||
});
|
||||
|
||||
return batch;
|
||||
},
|
||||
|
||||
/** Past commission batches for a provider */
|
||||
async getCommissionBatches(npiProviderId?: number): Promise<CommissionBatchRecord[]> {
|
||||
const batches = await db.commissionBatch.findMany({
|
||||
where: npiProviderId ? { npiProviderId } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
npiProvider: { select: { providerName: true } },
|
||||
_count: { select: { items: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return batches.map((b) => ({
|
||||
id: b.id,
|
||||
npiProviderId: b.npiProviderId,
|
||||
totalCollection: Number(b.totalCollection),
|
||||
commissionAmount: Number(b.commissionAmount),
|
||||
notes: b.notes,
|
||||
createdAt: b.createdAt,
|
||||
providerName: b.npiProvider.providerName,
|
||||
itemCount: b._count.items,
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import { officeHoursStorage } from "./office-hours-storage";
|
||||
import { officeContactStorage } from "./office-contact-storage";
|
||||
import { procedureTimeslotStorage } from "./procedure-timeslot-storage";
|
||||
import { insuranceContactStorage } from "./insurance-contact-storage";
|
||||
import { commissionsStorage } from "./commissions-storage";
|
||||
|
||||
|
||||
export const storage = {
|
||||
@@ -49,6 +50,7 @@ export const storage = {
|
||||
...officeContactStorage,
|
||||
...procedureTimeslotStorage,
|
||||
...insuranceContactStorage,
|
||||
...commissionsStorage,
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
} from "../../../../packages/db/types/payments-reports-types";
|
||||
|
||||
export interface IPaymentsReportsStorage {
|
||||
// summary now returns an extra field patientsWithBalance
|
||||
getSummary(
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
to?: Date | null,
|
||||
npiProviderId?: number | null
|
||||
): Promise<{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
@@ -16,44 +16,28 @@ export interface IPaymentsReportsStorage {
|
||||
patientsWithBalance: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Cursor-based pagination:
|
||||
* - limit: page size
|
||||
* - cursorToken: base64(JSON) token for last-seen row (or null for first page)
|
||||
* - from/to: optional date range filter applied to Payment."createdAt"
|
||||
*/
|
||||
getPatientsWithBalances(
|
||||
limit: number,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
to?: Date | null,
|
||||
npiProviderId?: number | null
|
||||
): Promise<GetPatientBalancesResult>;
|
||||
|
||||
/**
|
||||
* Returns the paginated patient balances for a specific staff (doctor).
|
||||
* Same semantics / columns / ordering / cursor behavior as the previous combined function.
|
||||
*
|
||||
* - staffId required
|
||||
* - limit: page size
|
||||
* - cursorToken: optional base64 cursor (must have been produced for same staffId)
|
||||
* - from/to: optional date range applied to Payment."createdAt"
|
||||
*/
|
||||
getPatientsBalancesByDoctor(
|
||||
staffId: number,
|
||||
limit: number,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
to?: Date | null,
|
||||
npiProviderId?: number | null
|
||||
): Promise<GetPatientBalancesResult>;
|
||||
|
||||
/**
|
||||
* Returns only the summary object for the given staff (doctor).
|
||||
* Same summary shape as getSummary(), but scoped to claims/payments associated with the given staffId.
|
||||
*/
|
||||
getSummaryByDoctor(
|
||||
staffId: number,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
to?: Date | null,
|
||||
npiProviderId?: number | null
|
||||
): Promise<{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
@@ -129,208 +113,72 @@ function decodeCursor(token?: string | null): {
|
||||
}
|
||||
|
||||
export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
async getSummary(from?: Date | null, to?: Date | null) {
|
||||
async getSummary(from?: Date | null, to?: Date | null, npiProviderId?: number | null) {
|
||||
try {
|
||||
const hasFrom = from !== undefined && from !== null;
|
||||
const hasTo = to !== undefined && to !== null;
|
||||
const fromStart = isoStartOfDayLiteral(from);
|
||||
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||
|
||||
// Use inclusive start-of-day for 'from' and exclusive start-of-next-day for 'to'
|
||||
const fromStart = isoStartOfDayLiteral(from); // 'YYYY-MM-DDT00:00:00.000Z'
|
||||
const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
|
||||
// Build a shared WHERE clause for Payment rows
|
||||
const conditions: string[] = [];
|
||||
if (from) conditions.push(`pay."createdAt" >= ${fromStart}`);
|
||||
if (to) conditions.push(`pay."createdAt" <= ${toNextStart}`);
|
||||
if (npiProviderId) conditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`);
|
||||
const payWhereClause = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
// totalPatients: distinct patients who had payments in the date range
|
||||
let patientsCountSql = "";
|
||||
if (hasFrom && hasTo) {
|
||||
patientsCountSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
`;
|
||||
} else if (hasFrom) {
|
||||
patientsCountSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
`;
|
||||
} else if (hasTo) {
|
||||
patientsCountSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
`;
|
||||
} else {
|
||||
patientsCountSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`;
|
||||
}
|
||||
const patientsCntRows = (await prisma.$queryRawUnsafe(
|
||||
patientsCountSql
|
||||
)) as { cnt: number }[];
|
||||
const patientsCountSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id
|
||||
FROM "Payment" pay
|
||||
${payWhereClause}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
`;
|
||||
const patientsCntRows = (await prisma.$queryRawUnsafe(patientsCountSql)) as { cnt: number }[];
|
||||
const totalPatients = patientsCntRows?.[0]?.cnt ?? 0;
|
||||
|
||||
// totalOutstanding: totalBilled - mhPaidAmount - copayment - adjustment
|
||||
let outstandingSql = "";
|
||||
if (hasFrom && hasTo) {
|
||||
outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
} else if (hasFrom) {
|
||||
outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart}
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
} else if (hasTo) {
|
||||
outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
} else {
|
||||
outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
}
|
||||
const outstandingRows = (await prisma.$queryRawUnsafe(
|
||||
outstandingSql
|
||||
)) as { outstanding: string }[];
|
||||
const outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
${payWhereClause}
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
const outstandingRows = (await prisma.$queryRawUnsafe(outstandingSql)) as { outstanding: string }[];
|
||||
const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0);
|
||||
|
||||
// totalCollected: mhPaidAmount + copayment (adjustment is write-off, not collected)
|
||||
let collSql = "";
|
||||
if (hasFrom && hasTo) {
|
||||
collSql = `SELECT COALESCE(SUM(COALESCE("mhPaidAmount",0) + "copayment"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart} AND "createdAt" <= ${toNextStart}`;
|
||||
} else if (hasFrom) {
|
||||
collSql = `SELECT COALESCE(SUM(COALESCE("mhPaidAmount",0) + "copayment"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart}`;
|
||||
} else if (hasTo) {
|
||||
collSql = `SELECT COALESCE(SUM(COALESCE("mhPaidAmount",0) + "copayment"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toNextStart}`;
|
||||
} else {
|
||||
collSql = `SELECT COALESCE(SUM(COALESCE("mhPaidAmount",0) + "copayment"),0)::numeric(14,2) AS collected FROM "Payment"`;
|
||||
}
|
||||
const collRows = (await prisma.$queryRawUnsafe(collSql)) as {
|
||||
collected: string;
|
||||
}[];
|
||||
const collSql = `
|
||||
SELECT COALESCE(SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment"),0)::numeric(14,2) AS collected
|
||||
FROM "Payment" pay
|
||||
${payWhereClause}
|
||||
`;
|
||||
const collRows = (await prisma.$queryRawUnsafe(collSql)) as { collected: string }[];
|
||||
const totalCollected = Number(collRows?.[0]?.collected ?? 0);
|
||||
|
||||
// patientsWithBalance: patients where (billed - mhPaid - copayment - adjustment) > 0
|
||||
let patientsWithBalanceSql = "";
|
||||
if (hasFrom && hasTo) {
|
||||
patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0
|
||||
`;
|
||||
} else if (hasFrom) {
|
||||
patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0
|
||||
`;
|
||||
} else if (hasTo) {
|
||||
patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0
|
||||
`;
|
||||
} else {
|
||||
patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0
|
||||
`;
|
||||
}
|
||||
const pwbRows = (await prisma.$queryRawUnsafe(
|
||||
patientsWithBalanceSql
|
||||
)) as { cnt: number }[];
|
||||
const patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid,
|
||||
SUM(pay."copayment")::numeric(14,2) AS copayment,
|
||||
SUM(pay."adjustment")::numeric(14,2) AS adjustment
|
||||
FROM "Payment" pay
|
||||
${payWhereClause}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0
|
||||
`;
|
||||
const pwbRows = (await prisma.$queryRawUnsafe(patientsWithBalanceSql)) as { cnt: number }[];
|
||||
const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0;
|
||||
|
||||
return {
|
||||
totalPatients,
|
||||
totalOutstanding,
|
||||
totalCollected,
|
||||
patientsWithBalance,
|
||||
};
|
||||
return { totalPatients, totalOutstanding, totalCollected, patientsWithBalance };
|
||||
} catch (err) {
|
||||
console.error("[paymentsReportsStorage.getSummary] error:", err);
|
||||
throw err;
|
||||
@@ -348,7 +196,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
limit = 25,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
to?: Date | null,
|
||||
npiProviderId?: number | null
|
||||
) {
|
||||
try {
|
||||
type RawRow = {
|
||||
@@ -373,15 +222,12 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
const fromStart = isoStartOfDayLiteral(from); // 'YYYY-MM-DDT00:00:00.000Z'
|
||||
const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
|
||||
|
||||
// Build payment subquery (aggregated payments by patient, filtered by createdAt if provided)
|
||||
const paymentWhereClause =
|
||||
hasFrom && hasTo
|
||||
? `WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
|
||||
: hasFrom
|
||||
? `WHERE pay."createdAt" >= ${fromStart}`
|
||||
: hasTo
|
||||
? `WHERE pay."createdAt" <= ${toNextStart}`
|
||||
: "";
|
||||
// Build payment subquery WHERE clause (date + optional provider filter)
|
||||
const payConditions: string[] = [];
|
||||
if (hasFrom) payConditions.push(`pay."createdAt" >= ${fromStart}`);
|
||||
if (hasTo) payConditions.push(`pay."createdAt" <= ${toNextStart}`);
|
||||
if (npiProviderId) payConditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`);
|
||||
const paymentWhereClause = payConditions.length ? `WHERE ${payConditions.join(" AND ")}` : "";
|
||||
|
||||
const pmSubquery = `
|
||||
(
|
||||
@@ -542,7 +388,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
limit = 25,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
to?: Date | null,
|
||||
npiProviderId?: number | null
|
||||
): Promise<{
|
||||
balances: PatientBalanceRow[];
|
||||
totalCount: number;
|
||||
@@ -571,15 +418,14 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
const fromStart = isoStartOfDayLiteral(from);
|
||||
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||
|
||||
// Filter payments by createdAt (time window) when provided
|
||||
const paymentTimeFilter =
|
||||
hasFrom && hasTo
|
||||
? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
|
||||
: hasFrom
|
||||
? `AND pay."createdAt" >= ${fromStart}`
|
||||
: hasTo
|
||||
? `AND pay."createdAt" <= ${toNextStart}`
|
||||
: "";
|
||||
// Filter payments by createdAt and/or provider
|
||||
const payAndConditions: string[] = [];
|
||||
if (hasFrom) payAndConditions.push(`pay."createdAt" >= ${fromStart}`);
|
||||
if (hasTo) payAndConditions.push(`pay."createdAt" <= ${toNextStart}`);
|
||||
if (npiProviderId) payAndConditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`);
|
||||
const paymentTimeFilter = payAndConditions.length
|
||||
? `AND ${payAndConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// Keyset predicate — prefer numeric epoch-ms comparison for stability
|
||||
let pageKeysetPredicate = "";
|
||||
@@ -801,7 +647,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
async getSummaryByDoctor(
|
||||
staffId: number,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
to?: Date | null,
|
||||
npiProviderId?: number | null
|
||||
): Promise<{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
@@ -818,14 +665,13 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
const fromStart = isoStartOfDayLiteral(from);
|
||||
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||
|
||||
const paymentTimeFilter =
|
||||
hasFrom && hasTo
|
||||
? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
|
||||
: hasFrom
|
||||
? `AND pay."createdAt" >= ${fromStart}`
|
||||
: hasTo
|
||||
? `AND pay."createdAt" <= ${toNextStart}`
|
||||
: "";
|
||||
const drAndConditions: string[] = [];
|
||||
if (hasFrom) drAndConditions.push(`pay."createdAt" >= ${fromStart}`);
|
||||
if (hasTo) drAndConditions.push(`pay."createdAt" <= ${toNextStart}`);
|
||||
if (npiProviderId) drAndConditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`);
|
||||
const paymentTimeFilter = drAndConditions.length
|
||||
? `AND ${drAndConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
const summaryQuery = `
|
||||
WITH
|
||||
|
||||
@@ -109,6 +109,8 @@ export const paymentsStorage: IStorage = {
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
npiProvider: true,
|
||||
commissionBatchItems: { include: { commissionBatch: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -144,6 +146,8 @@ export const paymentsStorage: IStorage = {
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
npiProvider: true,
|
||||
commissionBatchItems: { include: { commissionBatch: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -177,6 +181,8 @@ export const paymentsStorage: IStorage = {
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
npiProvider: true,
|
||||
commissionBatchItems: { include: { commissionBatch: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,6 +219,8 @@ export const paymentsStorage: IStorage = {
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
npiProvider: true,
|
||||
commissionBatchItems: { include: { commissionBatch: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -251,6 +259,8 @@ export const paymentsStorage: IStorage = {
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
npiProvider: true,
|
||||
commissionBatchItems: { include: { commissionBatch: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import React, { useState } from "react";
|
||||
import { ClaimStatus, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ClaimStatus, ClaimWithServiceLines, NpiProvider } from "@repo/db/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import {
|
||||
safeParseMissingTeeth,
|
||||
splitTeeth,
|
||||
@@ -41,6 +43,28 @@ export default function ClaimEditModal({
|
||||
const [status, setStatus] = useState<ClaimStatus>(
|
||||
claim?.status ?? ("PENDING" as ClaimStatus)
|
||||
);
|
||||
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState<number | null>(
|
||||
(claim as any)?.npiProviderId ?? null
|
||||
);
|
||||
|
||||
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Default to Mary Scannell (or first provider) only when no provider is set
|
||||
useEffect(() => {
|
||||
if (!npiProviders.length) return;
|
||||
if (selectedNpiProviderId !== null) return;
|
||||
const mary = npiProviders.find((p) =>
|
||||
p.providerName.toLowerCase().includes("mary scannell")
|
||||
);
|
||||
const fallback = mary ?? npiProviders[0];
|
||||
if (fallback) setSelectedNpiProviderId(fallback.id);
|
||||
}, [npiProviders]);
|
||||
|
||||
if (!claim) return null;
|
||||
|
||||
@@ -48,7 +72,9 @@ export default function ClaimEditModal({
|
||||
const updatedClaim: ClaimWithServiceLines = {
|
||||
...claim,
|
||||
status,
|
||||
};
|
||||
npiProviderId: selectedNpiProviderId,
|
||||
npiProvider: npiProviders.find((p) => p.id === selectedNpiProviderId) ?? null,
|
||||
} as ClaimWithServiceLines;
|
||||
|
||||
onSave(updatedClaim);
|
||||
onOpenChange(false);
|
||||
@@ -109,6 +135,25 @@ export default function ClaimEditModal({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-500">Rendering Provider:</span>
|
||||
<Select
|
||||
value={selectedNpiProviderId?.toString() ?? ""}
|
||||
onValueChange={(val) => setSelectedNpiProviderId(Number(val))}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue placeholder="Select Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{npiProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id.toString()}>
|
||||
{p.npiNumber} — {p.providerName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -788,6 +788,10 @@ export function ClaimForm({
|
||||
mimeType: f.type,
|
||||
}));
|
||||
|
||||
const selectedNpiProviderId = npiProvider?.npiNumber
|
||||
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
|
||||
: null;
|
||||
|
||||
const createdClaim = await onSubmit({
|
||||
...formToCreateClaim,
|
||||
serviceLines: filteredServiceLines,
|
||||
@@ -796,6 +800,7 @@ export function ClaimForm({
|
||||
insuranceProvider: "MassHealth",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
claimFiles: claimFilesMeta,
|
||||
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
|
||||
});
|
||||
|
||||
// 4. sending form data to selenium service
|
||||
|
||||
@@ -136,6 +136,9 @@ export default function ClaimsRecentTable({
|
||||
mutationFn: async (claim: ClaimWithServiceLines) => {
|
||||
const response = await apiRequest("PUT", `/api/claims/${claim.id}`, {
|
||||
status: claim.status,
|
||||
...((claim as any).npiProviderId != null
|
||||
? { npiProviderId: (claim as any).npiProviderId }
|
||||
: {}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -315,6 +318,7 @@ export default function ClaimsRecentTable({
|
||||
<TableHead>Total Billed</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Attachments</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -448,6 +452,12 @@ export default function ClaimsRecentTable({
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900">
|
||||
{claim.npiProvider?.providerName ?? "—"}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{allowDelete && (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
paymentStatusArray,
|
||||
paymentMethodArray,
|
||||
} from "@repo/db/types";
|
||||
import { NpiProvider } from "@repo/db/types";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,7 +34,7 @@ import { toast } from "@/hooks/use-toast";
|
||||
import { X } from "lucide-react";
|
||||
import { DateInput } from "@/components/ui/dateInput";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
type PaymentEditModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -84,6 +85,57 @@ export default function PaymentEditModal({
|
||||
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus>(
|
||||
(paymentProp ?? null)?.status ?? ("PENDING" as PaymentStatus)
|
||||
);
|
||||
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState<number | null>(
|
||||
(paymentProp ?? null)?.npiProviderId ?? null
|
||||
);
|
||||
const [isUpdatingProvider, setIsUpdatingProvider] = useState(false);
|
||||
|
||||
// Fetch all NPI providers
|
||||
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Default to Mary Scannell (or first provider) if no provider set yet
|
||||
useEffect(() => {
|
||||
if (!npiProviders.length) return;
|
||||
if (selectedNpiProviderId !== null) return;
|
||||
const mary = npiProviders.find((p) =>
|
||||
p.providerName.toLowerCase().includes("mary scannell")
|
||||
);
|
||||
const fallback = mary ?? npiProviders[0];
|
||||
if (fallback) setSelectedNpiProviderId(fallback.id);
|
||||
}, [npiProviders]);
|
||||
|
||||
// Sync provider when payment changes (e.g. fetched from API)
|
||||
useEffect(() => {
|
||||
if (payment?.npiProviderId !== undefined) {
|
||||
setSelectedNpiProviderId(payment.npiProviderId ?? null);
|
||||
}
|
||||
}, [payment?.id]);
|
||||
|
||||
const handleUpdateProvider = async () => {
|
||||
if (!payment) return;
|
||||
setIsUpdatingProvider(true);
|
||||
try {
|
||||
const res = await apiRequest("PATCH", `/api/payments/${payment.id}/provider`, {
|
||||
npiProviderId: selectedNpiProviderId,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || "Failed to update provider");
|
||||
}
|
||||
toast({ title: "Success", description: "Provider updated successfully." });
|
||||
await refetchPayment(payment.id);
|
||||
} catch (err: any) {
|
||||
toast({ title: "Error", description: err?.message ?? "Failed to update provider.", variant: "destructive" });
|
||||
} finally {
|
||||
setIsUpdatingProvider(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [formState, setFormState] = useState(() => {
|
||||
return {
|
||||
@@ -588,6 +640,36 @@ export default function PaymentEditModal({
|
||||
>
|
||||
{isUpdatingStatus ? "Updating..." : "Update Status"}
|
||||
</Button>
|
||||
|
||||
{/* Provider Selector */}
|
||||
<div className="pt-3">
|
||||
<label className="block text-sm text-gray-600 mb-1">
|
||||
Rendering Provider
|
||||
</label>
|
||||
<Select
|
||||
value={selectedNpiProviderId?.toString() ?? ""}
|
||||
onValueChange={(val) => setSelectedNpiProviderId(Number(val))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{npiProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id.toString()}>
|
||||
{p.npiNumber} — {p.providerName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isUpdatingProvider}
|
||||
onClick={handleUpdateProvider}
|
||||
>
|
||||
{isUpdatingProvider ? "Updating..." : "Update Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -607,6 +689,23 @@ export default function PaymentEditModal({
|
||||
? formatDateToHumanReadable(payment.updatedAt)
|
||||
: "N/A"}
|
||||
</p>
|
||||
{(payment as any).commissionBatchItems?.length > 0 ? (
|
||||
<div className="pt-2">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800">
|
||||
✓ Commissioned
|
||||
</span>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Paid as commission on{" "}
|
||||
{formatDateToHumanReadable(
|
||||
(payment as any).commissionBatchItems[0].commissionBatch.createdAt
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="pt-1">
|
||||
<span className="text-gray-400 text-xs">Not yet commissioned</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -612,6 +612,7 @@ export default function PaymentsRecentTable({
|
||||
<TableHead>Service Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Attachments</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>MH Paid</TableHead>
|
||||
<TableHead>Copayment</TableHead>
|
||||
<TableHead>Adjustment</TableHead>
|
||||
@@ -738,20 +739,28 @@ export default function PaymentsRecentTable({
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
{payment.status === "VOID" ? (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 flex items-center">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 flex items-center w-fit">
|
||||
<Ban className="h-3 w-3 mr-1" />Void
|
||||
</span>
|
||||
) : payment.status === "PAID" ? (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-teal-100 text-teal-800 flex items-center">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-teal-100 text-teal-800 flex items-center w-fit">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />Paid in Full
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 flex items-center">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 flex items-center w-fit">
|
||||
<Clock className="h-3 w-3 mr-1" />Balance
|
||||
</span>
|
||||
)}
|
||||
{(payment as any).commissionBatchItems?.length > 0 && (
|
||||
<span
|
||||
className="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 text-purple-800 w-fit"
|
||||
title={`Commissioned on ${new Date((payment as any).commissionBatchItems[0].commissionBatch.createdAt).toLocaleDateString()}`}
|
||||
>
|
||||
✓ Commissioned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -775,6 +784,12 @@ export default function PaymentsRecentTable({
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900">
|
||||
{(payment as any).npiProvider?.providerName ?? "—"}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{editingMhPaidId === payment.id ? (
|
||||
<input
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,14 +2,13 @@ import React, { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import ReportConfig from "@/components/reports/report-config";
|
||||
import PatientsWithBalanceReport from "@/components/reports/patients-with-balance-report";
|
||||
import CollectionsByDoctorReport from "@/components/reports/collections-by-doctor-report";
|
||||
import SummaryCards from "@/components/reports/summary-cards";
|
||||
import CommissionSection from "@/components/reports/commission-section";
|
||||
|
||||
type ReportType =
|
||||
| "patients_with_balance"
|
||||
| "patients_no_balance"
|
||||
| "monthly_collections"
|
||||
| "collections_by_doctor"
|
||||
| "procedure_codes_by_doctor"
|
||||
| "payment_methods"
|
||||
| "insurance_vs_patient_payments"
|
||||
@@ -30,6 +29,7 @@ export default function ReportPage() {
|
||||
const [selectedReportType, setSelectedReportType] = useState<ReportType>(
|
||||
"patients_with_balance"
|
||||
);
|
||||
const [npiProviderId, setNpiProviderId] = useState<number | null>(null);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
@@ -59,25 +59,28 @@ export default function ReportPage() {
|
||||
setEndDate={setEndDate}
|
||||
selectedReportType={selectedReportType}
|
||||
setSelectedReportType={setSelectedReportType}
|
||||
npiProviderId={npiProviderId}
|
||||
setNpiProviderId={setNpiProviderId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SINGLE authoritative SummaryCards instance for the page */}
|
||||
<div className="mb-4">
|
||||
<SummaryCards startDate={startDate} endDate={endDate} />
|
||||
<SummaryCards startDate={startDate} endDate={endDate} npiProviderId={npiProviderId} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{selectedReportType === "patients_with_balance" && (
|
||||
<PatientsWithBalanceReport startDate={startDate} endDate={endDate} />
|
||||
)}
|
||||
|
||||
{selectedReportType === "collections_by_doctor" && (
|
||||
<CollectionsByDoctorReport startDate={startDate} endDate={endDate} />
|
||||
<PatientsWithBalanceReport startDate={startDate} endDate={endDate} npiProviderId={npiProviderId} />
|
||||
)}
|
||||
|
||||
{/* Add other report components here as needed */}
|
||||
</div>
|
||||
|
||||
{/* Commission section */}
|
||||
<div className="mt-8">
|
||||
<CommissionSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user