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:
Gitead
2026-05-15 23:51:39 -04:00
parent 25a20e8a16
commit 7360b1930b
366 changed files with 10822 additions and 388 deletions

View File

@@ -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) {

View 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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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 },
});
},

View 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,
}));
},
};

View File

@@ -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,
};

View File

@@ -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

View File

@@ -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 } },
},
});

View File

@@ -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>

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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

View File

@@ -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));

View 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>
);
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>
);
}