feat: Select Procedures flow, batch-column NPI provider fix, auto PDF save
- Add 'Select Procedures' right-click option on appointment page (separate from Claims/PreAuth) - Select Procedures form saves CDT codes + NPI provider to AppointmentProcedure storage - Remove Save button from insurance claim form; Claims/PreAuth opens for insurance submission only - Claims/PreAuth auto-prefills from saved procedures including NPI provider - Batch-column: procedures npiProviderId takes priority over stale claim npiProviderId - Batch-column: auto-save PDF to patient Documents after successful submission (no socket needed) - Add npiProviderId column to AppointmentProcedure table (prisma db push) - Fix 'invalid db creation invocation': guard staffId, npiProviderId, procedureDate as Date object, totalBilled NaN guard - Add full error logging to batch-column catch block Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,7 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
||||
files: data.files ?? [],
|
||||
claimId: data.claimId,
|
||||
variant: jobType === "claim-pre-auth" ? "claim-pre-auth" : "claimsubmit",
|
||||
socketId: data.socketId,
|
||||
});
|
||||
}
|
||||
if (jobType === "ddma-eligibility-check") {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Processors for "claim-submit" and "claim-pre-auth" jobs.
|
||||
* Mirrors routes/claims.ts /selenium-claim and /selenium-claim-pre-auth
|
||||
*/
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { storage } from "../../storage";
|
||||
import { callPythonSync } from "./_shared";
|
||||
|
||||
@@ -11,6 +13,8 @@ export interface ClaimSubmitProcessorInput {
|
||||
claimId?: number;
|
||||
/** "claimsubmit" (default) or "claim-pre-auth" */
|
||||
variant?: "claimsubmit" | "claim-pre-auth";
|
||||
/** When set, the frontend socket listener will handle the PDF download — skip auto-save */
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
export interface ClaimSubmitProcessorResult {
|
||||
@@ -20,10 +24,40 @@ export interface ClaimSubmitProcessorResult {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Fetch the PDF from the selenium service and save it to cloud storage. */
|
||||
async function savePdfFromSelenium(
|
||||
pdf_url: string,
|
||||
patientId: number,
|
||||
variant: "claimsubmit" | "claim-pre-auth"
|
||||
) {
|
||||
try {
|
||||
const filename = path.basename(new URL(pdf_url).pathname);
|
||||
const seleniumPort = process.env.SELENIUM_PORT || "5002";
|
||||
const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`;
|
||||
|
||||
const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 });
|
||||
|
||||
const groupTitleKey = variant === "claim-pre-auth" ? "INSURANCE_CLAIM_PREAUTH" : "INSURANCE_CLAIM";
|
||||
const groupTitle = variant === "claim-pre-auth" ? "Claims Preauth" : "Claims";
|
||||
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(patientId, groupTitleKey);
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(patientId, groupTitle, groupTitleKey);
|
||||
}
|
||||
|
||||
await storage.createPdfFile(group.id!, filename, resp.data);
|
||||
console.log(`[claimSubmitProcessor] PDF saved for patient ${patientId}: ${filename}`);
|
||||
} catch (err: any) {
|
||||
// Non-fatal — claim was submitted; just log the PDF failure
|
||||
console.error("[claimSubmitProcessor] failed to save PDF:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClaimSubmitProcessor(
|
||||
input: ClaimSubmitProcessorInput
|
||||
): Promise<ClaimSubmitProcessorResult> {
|
||||
const { enrichedPayload, files, claimId } = input;
|
||||
const variant = input.variant ?? "claimsubmit";
|
||||
|
||||
// Build the same payload shape the Python /claimsubmit endpoint expects
|
||||
const pdfs = files
|
||||
@@ -36,13 +70,12 @@ export async function runClaimSubmitProcessor(
|
||||
|
||||
const payload = { claim: enrichedPayload, pdfs, images };
|
||||
|
||||
const endpoint =
|
||||
input.variant === "claim-pre-auth" ? "/claim-pre-auth" : "/claimsubmit";
|
||||
const endpoint = variant === "claim-pre-auth" ? "/claim-pre-auth" : "/claimsubmit";
|
||||
|
||||
// 1) Call the Python service synchronously (BullMQ worker handles async)
|
||||
const result = await callPythonSync(endpoint, payload, 10 * 60 * 1000);
|
||||
|
||||
// 2) Persist claimNumber and update status to REVIEW after successful submission
|
||||
// 2) Persist claimNumber and update status to REVIEW
|
||||
if (claimId) {
|
||||
try {
|
||||
const updates: Record<string, any> = { status: "REVIEW" };
|
||||
@@ -53,5 +86,14 @@ export async function runClaimSubmitProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Auto-save PDF for batch jobs (no socketId = no frontend listener to call fetchpdf)
|
||||
if (result?.pdf_url && enrichedPayload?.patientId && !input.socketId) {
|
||||
await savePdfFromSelenium(
|
||||
result.pdf_url,
|
||||
Number(enrichedPayload.patientId),
|
||||
variant
|
||||
);
|
||||
}
|
||||
|
||||
return { ...result, claimId };
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ async function processSeleniumJob(job: Job<SeleniumJobData>) {
|
||||
files: job.data.files ?? [],
|
||||
claimId: job.data.claimId,
|
||||
variant: "claimsubmit",
|
||||
socketId,
|
||||
});
|
||||
} else if (jobType === "claim-pre-auth") {
|
||||
result = await runClaimSubmitProcessor({
|
||||
@@ -64,6 +65,7 @@ async function processSeleniumJob(job: Job<SeleniumJobData>) {
|
||||
files: job.data.files ?? [],
|
||||
claimId: job.data.claimId,
|
||||
variant: "claim-pre-auth",
|
||||
socketId,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unknown selenium jobType: ${jobType}`);
|
||||
|
||||
@@ -54,6 +54,71 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/appointment-procedures/set-npi-provider/:appointmentId
|
||||
* Set the npiProviderId on all procedures for an appointment (lightweight update).
|
||||
*/
|
||||
router.put("/set-npi-provider/:appointmentId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const appointmentId = Number(req.params.appointmentId);
|
||||
if (isNaN(appointmentId)) return res.status(400).json({ message: "Invalid appointmentId" });
|
||||
|
||||
const npiProviderId = req.body.npiProviderId != null ? Number(req.body.npiProviderId) : null;
|
||||
|
||||
await prisma.appointmentProcedure.updateMany({
|
||||
where: { appointmentId },
|
||||
data: { npiProviderId: npiProviderId ?? null },
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error("set-npi-provider error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/appointment-procedures/save-for-appointment
|
||||
* Replace all procedures for an appointment (clear + insert).
|
||||
* Body: { appointmentId, patientId, npiProviderId?, procedures: [{procedureCode, fee?, toothNumber?, toothSurface?}] }
|
||||
*/
|
||||
router.post("/save-for-appointment", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { appointmentId, patientId, npiProviderId, procedures } = req.body;
|
||||
|
||||
if (!appointmentId || isNaN(Number(appointmentId))) {
|
||||
return res.status(400).json({ message: "Invalid appointmentId" });
|
||||
}
|
||||
if (!patientId || isNaN(Number(patientId))) {
|
||||
return res.status(400).json({ message: "Invalid patientId" });
|
||||
}
|
||||
if (!Array.isArray(procedures)) {
|
||||
return res.status(400).json({ message: "procedures must be an array" });
|
||||
}
|
||||
|
||||
const filtered = (procedures as any[]).filter(
|
||||
(p) => String(p.procedureCode ?? "").trim() !== ""
|
||||
);
|
||||
|
||||
const count = await storage.saveForAppointment({
|
||||
appointmentId: Number(appointmentId),
|
||||
patientId: Number(patientId),
|
||||
npiProviderId: npiProviderId ? Number(npiProviderId) : null,
|
||||
procedures: filtered.map((p) => ({
|
||||
procedureCode: String(p.procedureCode).trim(),
|
||||
fee: p.fee != null ? Number(p.fee) : null,
|
||||
toothNumber: p.toothNumber || null,
|
||||
toothSurface: p.toothSurface || null,
|
||||
})),
|
||||
});
|
||||
|
||||
return res.json({ success: true, count });
|
||||
} catch (err: any) {
|
||||
console.error("save-for-appointment error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/appointment-procedures
|
||||
* Add single manual procedure
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import multer from "multer";
|
||||
import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import axios from "axios";
|
||||
import { seleniumQueue } from "../queue/queues";
|
||||
import { Prisma } from "@repo/db/generated/prisma";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
updateClaimSchema,
|
||||
} from "@repo/db/types";
|
||||
import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient";
|
||||
import { formatDobForAgent } from "../utils/dateUtils";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -38,6 +40,48 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
// Disk-storage uploader for claim attachments saved under uploads/patients/<name>/
|
||||
const attachmentDiskStorage = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim();
|
||||
const dir = path.join(process.cwd(), "uploads", "patients", patientName);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
const base = path.basename(file.originalname, ext).replace(/\s+/g, "_");
|
||||
cb(null, `${Date.now()}_${base}${ext}`);
|
||||
},
|
||||
});
|
||||
const attachmentUpload = multer({
|
||||
storage: attachmentDiskStorage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
|
||||
cb(null, allowed.includes(file.mimetype));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/claims/upload-attachments
|
||||
// Saves files to uploads/patients/<patientName>/ and returns their paths.
|
||||
router.post(
|
||||
"/upload-attachments",
|
||||
attachmentUpload.array("files", 10),
|
||||
(req: Request, res: Response): any => {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: true, message: "No files uploaded" });
|
||||
|
||||
const result = files.map((f) => ({
|
||||
filename: f.originalname,
|
||||
mimeType: f.mimetype,
|
||||
filePath: `/uploads/patients/${path.basename(path.dirname(f.path))}/${path.basename(f.path)}`,
|
||||
}));
|
||||
|
||||
return res.json({ error: false, data: result });
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/mh-provider-login",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
@@ -341,6 +385,298 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/claims/batch-column
|
||||
// Query params: date=YYYY-MM-DD (required), staffIds=1,2 (required)
|
||||
// For each appointment in the selected staff columns:
|
||||
// - skip if no saved AppointmentProcedure records
|
||||
// - otherwise create a Claim + Payment, enqueue selenium claim-submit job
|
||||
router.post(
|
||||
"/batch-column",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const date = String(req.query.date ?? "").trim();
|
||||
const staffIdsRaw = String(req.query.staffIds ?? "").trim();
|
||||
|
||||
if (!date) return res.status(400).json({ error: "Missing date query param" });
|
||||
if (!staffIdsRaw) return res.status(400).json({ error: "Missing staffIds query param" });
|
||||
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const staffIdFilter = new Set(
|
||||
staffIdsRaw.split(",").map(Number).filter((n) => Number.isFinite(n) && n > 0)
|
||||
);
|
||||
|
||||
try {
|
||||
const allAppointments = await storage.getAppointmentsByDateForUser(date, req.user.id);
|
||||
const appointments = allAppointments.filter((a) => staffIdFilter.has(Number(a.staffId)));
|
||||
|
||||
const results: Array<any> = [];
|
||||
|
||||
for (const apt of appointments) {
|
||||
const resultItem: any = {
|
||||
appointmentId: apt.id,
|
||||
patientId: apt.patientId ?? null,
|
||||
processed: false,
|
||||
skipped: false,
|
||||
error: null,
|
||||
jobId: null,
|
||||
claimId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
// Fetch active claim for this appointment (includes service lines from draft saves)
|
||||
const activeClaim = await storage.getActiveClaimByAppointmentId(Number(apt.id));
|
||||
|
||||
// "Already claimed" = has a real claim number OR status is REVIEW/APPROVED
|
||||
// A PENDING claim with no claimNumber is just a draft save — not yet submitted
|
||||
const alreadyClaimed =
|
||||
activeClaim &&
|
||||
((activeClaim.claimNumber != null &&
|
||||
String(activeClaim.claimNumber).trim() !== "") ||
|
||||
activeClaim.status === "REVIEW" ||
|
||||
activeClaim.status === "APPROVED");
|
||||
|
||||
if (alreadyClaimed) {
|
||||
resultItem.skipped = true;
|
||||
resultItem.error = "Already claimed";
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build service lines: prefer draft claim's saved lines, fall back to AppointmentProcedure
|
||||
const serviceDate = formatDobForAgent(
|
||||
apt.date instanceof Date ? apt.date : new Date(apt.date as any)
|
||||
) ?? date;
|
||||
|
||||
let serviceLines: Array<{
|
||||
procedureCode: string;
|
||||
procedureDate: string | Date;
|
||||
toothNumber?: string;
|
||||
toothSurface?: string;
|
||||
totalBilled: number;
|
||||
totalAdjusted: number;
|
||||
totalPaid: number;
|
||||
totalDue: number;
|
||||
}>;
|
||||
|
||||
// Fetch AppointmentProcedure records once (used for service lines fallback + npiProviderId)
|
||||
const apptProcedures = await storage.getByAppointmentId(Number(apt.id));
|
||||
|
||||
const safeProcedureDate = new Date(serviceDate);
|
||||
|
||||
if (activeClaim?.serviceLines?.length) {
|
||||
// Use saved service lines from the draft claim
|
||||
serviceLines = activeClaim.serviceLines
|
||||
.filter((sl) => sl.procedureCode)
|
||||
.map((sl) => ({
|
||||
procedureCode: sl.procedureCode,
|
||||
procedureDate: safeProcedureDate,
|
||||
toothNumber: sl.toothNumber ?? undefined,
|
||||
toothSurface: sl.toothSurface ?? undefined,
|
||||
totalBilled: Math.max(0, Number(sl.totalBilled ?? 0) || 0),
|
||||
totalAdjusted: 0,
|
||||
totalPaid: 0,
|
||||
totalDue: Math.max(0, Number(sl.totalBilled ?? 0) || 0),
|
||||
}));
|
||||
} else {
|
||||
// Fall back to AppointmentProcedure records (saved via Select Procedures)
|
||||
serviceLines = apptProcedures.map((proc) => ({
|
||||
procedureCode: proc.procedureCode,
|
||||
procedureDate: safeProcedureDate,
|
||||
toothNumber: proc.toothNumber ?? undefined,
|
||||
toothSurface: proc.toothSurface ?? undefined,
|
||||
totalBilled: Math.max(0, Number(proc.fee ?? 0) || 0),
|
||||
totalAdjusted: 0,
|
||||
totalPaid: 0,
|
||||
totalDue: Math.max(0, Number(proc.fee ?? 0) || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!serviceLines.length) {
|
||||
resultItem.skipped = true;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Patient
|
||||
const patient = apt.patientId ? await storage.getPatient(apt.patientId) : null;
|
||||
if (!patient) {
|
||||
resultItem.error = "Patient not found";
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
const memberId = String(patient.insuranceId ?? "").trim();
|
||||
if (!memberId) {
|
||||
resultItem.error = "Missing insurance ID";
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dobStr = formatDobForAgent(patient.dateOfBirth);
|
||||
if (!dobStr) {
|
||||
resultItem.error = "Missing or invalid date of birth";
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// MassHealth credentials
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(req.user.id, "MH");
|
||||
if (!credentials) {
|
||||
resultItem.error = "No MassHealth credentials found — check Settings";
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
const npiProviders = await storage.getNpiProvidersByUser(req.user.id);
|
||||
const npiProvider = npiProviders[0] ?? null;
|
||||
|
||||
// procNpiProviderId = user's explicit choice saved via "Select Procedures" form
|
||||
// This ALWAYS wins over any previously stored claim npiProviderId
|
||||
const procNpiProviderId = (apptProcedures as any[]).find((p) => p.npiProviderId)?.npiProviderId ?? null;
|
||||
|
||||
// Priority: Select Procedures choice > existing claim > first provider
|
||||
const claimNpiProviderId = procNpiProviderId ?? activeClaim?.npiProviderId ?? npiProvider?.id ?? null;
|
||||
|
||||
console.log(`[batch-column] apt=${apt.id} procNpiId=${procNpiProviderId} claimNpiId=${activeClaim?.npiProviderId} resolved=${claimNpiProviderId}`);
|
||||
|
||||
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||||
|
||||
// Reuse the existing draft claim, or create a new one
|
||||
let claimId: number;
|
||||
if (activeClaim?.id) {
|
||||
claimId = activeClaim.id;
|
||||
// Update claim's npiProviderId if the user chose a different provider via Select Procedures
|
||||
if (procNpiProviderId && activeClaim.npiProviderId !== procNpiProviderId) {
|
||||
await storage.updateClaim(claimId, { npiProviderId: procNpiProviderId });
|
||||
}
|
||||
} else {
|
||||
// Validate required integer fields before sending to Prisma
|
||||
const safeStaffId = apt.staffId ? Number(apt.staffId) : null;
|
||||
if (!safeStaffId || !Number.isFinite(safeStaffId)) {
|
||||
resultItem.error = "Appointment has no valid staff column (staffId missing)";
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
const safeNpiId = claimNpiProviderId && Number.isFinite(Number(claimNpiProviderId))
|
||||
? Number(claimNpiProviderId)
|
||||
: null;
|
||||
|
||||
console.log(`[batch-column] creating claim: patientId=${patient.id} aptId=${apt.id} staffId=${safeStaffId} npiProviderId=${safeNpiId} serviceDate=${serviceDate} dobStr=${dobStr} lines=${serviceLines.length}`);
|
||||
|
||||
const newClaim = await storage.createClaim({
|
||||
patientId: Number(patient.id),
|
||||
appointmentId: Number(apt.id),
|
||||
userId: req.user.id,
|
||||
staffId: safeStaffId,
|
||||
patientName,
|
||||
memberId,
|
||||
dateOfBirth: new Date(dobStr),
|
||||
serviceDate: new Date(serviceDate),
|
||||
insuranceProvider: "MassHealth",
|
||||
remarks: "",
|
||||
missingTeethStatus: "No_missing",
|
||||
missingTeeth: {},
|
||||
status: "PENDING",
|
||||
serviceLines: { create: serviceLines },
|
||||
...(safeNpiId ? { npiProviderId: safeNpiId } : {}),
|
||||
} as any);
|
||||
claimId = newClaim.id;
|
||||
}
|
||||
resultItem.claimId = claimId;
|
||||
|
||||
// Create Payment only if one doesn't already exist (prevents duplicates on retry)
|
||||
const existingPayment = await storage.getPaymentsByClaimId(claimId);
|
||||
if (!existingPayment) {
|
||||
const totalBilled = serviceLines.reduce((sum, l) => sum + Number(l.totalBilled), 0);
|
||||
await storage.createPayment({
|
||||
claimId,
|
||||
patientId: Number(patient.id),
|
||||
userId: req.user.id,
|
||||
totalBilled: new Decimal(totalBilled),
|
||||
totalPaid: new Decimal(0),
|
||||
totalDue: new Decimal(totalBilled),
|
||||
status: "PENDING",
|
||||
notes: "",
|
||||
} as any);
|
||||
}
|
||||
|
||||
// Resolve NPI provider object for selenium payload
|
||||
let resolvedNpiProvider = npiProvider;
|
||||
if (claimNpiProviderId) {
|
||||
const saved = npiProviders.find((p) => p.id === Number(claimNpiProviderId));
|
||||
if (saved) resolvedNpiProvider = saved;
|
||||
}
|
||||
|
||||
// Build enriched payload for selenium
|
||||
const enrichedPayload: any = {
|
||||
patientId: Number(patient.id),
|
||||
appointmentId: Number(apt.id),
|
||||
userId: req.user.id,
|
||||
staffId: Number(apt.staffId),
|
||||
patientName,
|
||||
memberId,
|
||||
dateOfBirth: dobStr,
|
||||
serviceDate,
|
||||
insuranceProvider: "MassHealth",
|
||||
insuranceSiteKey: "MH",
|
||||
missingTeethStatus: activeClaim?.missingTeethStatus ?? "No_missing",
|
||||
missingTeeth: activeClaim?.missingTeeth ?? {},
|
||||
remarks: activeClaim?.remarks ?? "",
|
||||
serviceLines,
|
||||
claimId,
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
};
|
||||
if (resolvedNpiProvider) {
|
||||
enrichedPayload.npiProvider = {
|
||||
npiNumber: resolvedNpiProvider.npiNumber,
|
||||
providerName: resolvedNpiProvider.providerName,
|
||||
};
|
||||
}
|
||||
|
||||
// Enqueue selenium claim-submit job
|
||||
const job = await seleniumQueue.add("claim-submit", {
|
||||
jobType: "claim-submit",
|
||||
userId: req.user.id,
|
||||
enrichedPayload,
|
||||
files: [],
|
||||
claimId,
|
||||
});
|
||||
|
||||
resultItem.processed = true;
|
||||
resultItem.jobId = String(job.id);
|
||||
|
||||
} catch (aptErr: any) {
|
||||
console.error("[batch-column] apt error:", aptErr);
|
||||
resultItem.error = aptErr?.message ?? String(aptErr);
|
||||
}
|
||||
|
||||
results.push(resultItem);
|
||||
}
|
||||
|
||||
return res.json({ results });
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("[batch-column claims] error", err);
|
||||
return res.status(500).json({ error: err?.message ?? "Batch claim failed" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/claims/by-appointment/:appointmentId
|
||||
// Returns the most recent active (non-cancelled/void) claim with service lines and files
|
||||
router.get(
|
||||
"/by-appointment/:appointmentId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const aptId = parseInt(req.params.appointmentId, 10);
|
||||
if (isNaN(aptId)) return res.status(400).json({ error: "Invalid appointmentId" });
|
||||
|
||||
const claim = await storage.getActiveClaimByAppointmentId(aptId);
|
||||
if (!claim) return res.status(404).json({ message: "No active claim found" });
|
||||
return res.json(claim);
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/claims/recent
|
||||
router.get("/recent", async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -435,6 +771,7 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
create: req.body.claimFiles.map((f: any) => ({
|
||||
filename: String(f.filename),
|
||||
mimeType: String(f.mimeType || f.mime || ""),
|
||||
...(f.filePath ? { filePath: String(f.filePath) } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -452,7 +789,12 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
if (Array.isArray(req.body.serviceLines)) {
|
||||
req.body.serviceLines = req.body.serviceLines.map(
|
||||
(line: InputServiceLine) => ({
|
||||
...line,
|
||||
procedureCode: String(line.procedureCode ?? ""),
|
||||
procedureDate: line.procedureDate,
|
||||
quad: line.quad ?? null,
|
||||
arch: line.arch ?? null,
|
||||
toothNumber: line.toothNumber ?? null,
|
||||
toothSurface: line.toothSurface ?? null,
|
||||
totalBilled: Number(line.totalBilled),
|
||||
totalAdjusted: 0,
|
||||
totalPaid: 0,
|
||||
@@ -462,8 +804,23 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
req.body.serviceLines = { create: req.body.serviceLines };
|
||||
}
|
||||
|
||||
const parsedClaim = ExtendedClaimSchema.parse({
|
||||
...req.body,
|
||||
// Strip unknown keys so strict() doesn't reject any extra properties the frontend may send
|
||||
const parsedClaim = (ExtendedClaimSchema as unknown as z.ZodObject<any>).strip().parse({
|
||||
patientId: req.body.patientId,
|
||||
appointmentId: req.body.appointmentId,
|
||||
staffId: req.body.staffId,
|
||||
patientName: req.body.patientName,
|
||||
memberId: req.body.memberId,
|
||||
dateOfBirth: req.body.dateOfBirth,
|
||||
remarks: req.body.remarks ?? "",
|
||||
missingTeethStatus: req.body.missingTeethStatus,
|
||||
missingTeeth: req.body.missingTeeth,
|
||||
serviceDate: req.body.serviceDate,
|
||||
insuranceProvider: req.body.insuranceProvider,
|
||||
status: req.body.status,
|
||||
serviceLines: req.body.serviceLines,
|
||||
claimFiles: req.body.claimFiles,
|
||||
...(req.body.npiProviderId ? { npiProviderId: Number(req.body.npiProviderId) } : {}),
|
||||
userId: req.user!.id,
|
||||
});
|
||||
|
||||
@@ -484,28 +841,35 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
// Step 2: Create claim (with service lines)
|
||||
const claim = await storage.createClaim(parsedClaim);
|
||||
|
||||
// Step 3: Create empty payment
|
||||
await storage.createPayment({
|
||||
claimId: claim.id,
|
||||
patientId: claim.patientId,
|
||||
userId: req.user!.id,
|
||||
totalBilled: new Decimal(totalBilled),
|
||||
totalPaid: new Decimal(0),
|
||||
totalDue: new Decimal(totalBilled),
|
||||
status: "PENDING",
|
||||
notes: "",
|
||||
});
|
||||
// Step 3: Create payment only for real submissions (not draft saves)
|
||||
const isDraft = req.query.draft === "true";
|
||||
if (!isDraft) {
|
||||
await storage.createPayment({
|
||||
claimId: claim.id,
|
||||
patientId: claim.patientId,
|
||||
userId: req.user!.id,
|
||||
totalBilled: new Decimal(totalBilled),
|
||||
totalPaid: new Decimal(0),
|
||||
totalDue: new Decimal(totalBilled),
|
||||
status: "PENDING",
|
||||
notes: "",
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(claim);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const firstIssue = error.issues[0];
|
||||
const fieldPath = firstIssue?.path?.join(".") ?? "unknown";
|
||||
const fieldMsg = firstIssue?.message ?? "invalid";
|
||||
console.error("❌ Claim validation error:", error.issues);
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
message: `Validation error on field "${fieldPath}": ${fieldMsg}`,
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
console.error("❌ Failed to create claim:", error); // logs full error to server
|
||||
console.error("❌ Failed to create claim:", error);
|
||||
|
||||
// Send more detailed info to the client (for dev only)
|
||||
return res.status(500).json({
|
||||
@@ -533,16 +897,64 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
return res.status(404).json({ message: "Claim not found" });
|
||||
}
|
||||
|
||||
const claimData = updateClaimSchema.parse(req.body);
|
||||
// If service lines are provided, replace them (delete old, create new)
|
||||
if (Array.isArray(req.body.serviceLines) && req.body.serviceLines.length > 0) {
|
||||
const { prisma: db } = await import("@repo/db/client");
|
||||
await db.serviceLine.deleteMany({ where: { claimId } });
|
||||
await db.serviceLine.createMany({
|
||||
data: req.body.serviceLines.map((line: InputServiceLine) => ({
|
||||
claimId,
|
||||
procedureCode: String(line.procedureCode ?? ""),
|
||||
procedureDate: new Date(line.procedureDate),
|
||||
quad: line.quad || null,
|
||||
arch: line.arch || null,
|
||||
toothNumber: line.toothNumber || null,
|
||||
toothSurface: line.toothSurface || null,
|
||||
totalBilled: Number(line.totalBilled),
|
||||
totalAdjusted: 0,
|
||||
totalPaid: 0,
|
||||
totalDue: Number(line.totalBilled),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Explicitly pick only scalar fields — skip serviceLines (handled above)
|
||||
// and claimFiles (plain array from frontend is not Prisma nested format)
|
||||
// Use req.user!.id for userId (always trust the authenticated session, not the client body)
|
||||
// Skip null/empty-string values for optional fields to avoid coerce.date() failures
|
||||
const toOptionalNum = (v: any) => (v != null && !Number.isNaN(Number(v)) ? Number(v) : undefined);
|
||||
const toOptionalDate = (v: any) => (v != null && String(v).trim() !== "" ? v : undefined);
|
||||
|
||||
const claimData = (updateClaimSchema as unknown as z.ZodObject<any>).strip().parse({
|
||||
patientId: toOptionalNum(req.body.patientId),
|
||||
appointmentId: toOptionalNum(req.body.appointmentId),
|
||||
userId: req.user!.id,
|
||||
staffId: toOptionalNum(req.body.staffId),
|
||||
patientName: req.body.patientName,
|
||||
memberId: req.body.memberId,
|
||||
dateOfBirth: toOptionalDate(req.body.dateOfBirth),
|
||||
remarks: req.body.remarks ?? "",
|
||||
missingTeethStatus: req.body.missingTeethStatus,
|
||||
missingTeeth: req.body.missingTeeth,
|
||||
serviceDate: toOptionalDate(req.body.serviceDate),
|
||||
insuranceProvider: req.body.insuranceProvider,
|
||||
status: req.body.status,
|
||||
...(req.body.npiProviderId ? { npiProviderId: Number(req.body.npiProviderId) } : {}),
|
||||
});
|
||||
const updatedClaim = await storage.updateClaim(claimId, claimData);
|
||||
res.json(updatedClaim);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const firstIssue = error.issues[0];
|
||||
const fieldPath = firstIssue?.path?.join(".") ?? "unknown";
|
||||
const fieldMsg = firstIssue?.message ?? "invalid";
|
||||
console.error("❌ Claim update validation error:", error.issues);
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
message: `Validation error on field "${fieldPath}": ${fieldMsg}`,
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
console.error("❌ Failed to update claim:", error);
|
||||
res.status(500).json({ message: "Failed to update claim" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,19 @@ export interface IAppointmentProceduresStorage {
|
||||
appointment: Appointment;
|
||||
patient: Patient;
|
||||
procedures: AppointmentProcedure[];
|
||||
npiProviderId: number | null;
|
||||
} | null>;
|
||||
saveForAppointment(params: {
|
||||
appointmentId: number;
|
||||
patientId: number;
|
||||
npiProviderId: number | null;
|
||||
procedures: Array<{
|
||||
procedureCode: string;
|
||||
fee?: number | null;
|
||||
toothNumber?: string | null;
|
||||
toothSurface?: string | null;
|
||||
}>;
|
||||
}): Promise<number>;
|
||||
|
||||
createProcedure(
|
||||
data: InsertAppointmentProcedure
|
||||
@@ -52,13 +64,34 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npiProviderId = appointment.procedures[0]?.npiProviderId ?? null;
|
||||
|
||||
return {
|
||||
appointment,
|
||||
patient: appointment.patient,
|
||||
procedures: appointment.procedures,
|
||||
npiProviderId,
|
||||
};
|
||||
},
|
||||
|
||||
async saveForAppointment({ appointmentId, patientId, npiProviderId, procedures }) {
|
||||
await db.appointmentProcedure.deleteMany({ where: { appointmentId } });
|
||||
if (!procedures.length) return 0;
|
||||
const result = await db.appointmentProcedure.createMany({
|
||||
data: procedures.map((p) => ({
|
||||
appointmentId,
|
||||
patientId,
|
||||
npiProviderId: npiProviderId ?? null,
|
||||
procedureCode: p.procedureCode,
|
||||
fee: p.fee != null ? p.fee : null,
|
||||
toothNumber: p.toothNumber || null,
|
||||
toothSurface: p.toothSurface || null,
|
||||
source: "MANUAL" as const,
|
||||
})),
|
||||
});
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async createProcedure(
|
||||
data: InsertAppointmentProcedure
|
||||
): Promise<AppointmentProcedure> {
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface IStorage {
|
||||
appointment: UpdateAppointment
|
||||
): Promise<Appointment>;
|
||||
deleteAppointment(id: number): Promise<void>;
|
||||
getPatientAppointmentByDateAndStaff(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
staffId: number
|
||||
): Promise<Appointment | undefined>;
|
||||
getPatientAppointmentByDateTime(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
@@ -127,6 +132,19 @@ export const appointmentsStorage: IStorage = {
|
||||
}
|
||||
},
|
||||
|
||||
async getPatientAppointmentByDateAndStaff(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
staffId: number
|
||||
): Promise<Appointment | undefined> {
|
||||
return (
|
||||
(await db.appointment.findFirst({
|
||||
where: { patientId, date, staffId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})) ?? undefined
|
||||
);
|
||||
},
|
||||
|
||||
async getPatientAppointmentByDateTime(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Trash2, Plus, Save, X } from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -17,7 +24,7 @@ import {
|
||||
CODE_MAP,
|
||||
getPriceForCodeWithAgeFromMap,
|
||||
} from "@/utils/procedureCombosMapping";
|
||||
import { Patient, AppointmentProcedure } from "@repo/db/types";
|
||||
import { Patient, AppointmentProcedure, NpiProvider } from "@repo/db/types";
|
||||
import { useLocation } from "wouter";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import {
|
||||
@@ -31,6 +38,7 @@ interface Props {
|
||||
appointmentId: number;
|
||||
patientId: number;
|
||||
patient: Patient;
|
||||
serviceDate?: string;
|
||||
}
|
||||
|
||||
export function AppointmentProceduresDialog({
|
||||
@@ -39,54 +47,88 @@ export function AppointmentProceduresDialog({
|
||||
appointmentId,
|
||||
patientId,
|
||||
patient,
|
||||
serviceDate,
|
||||
}: Props) {
|
||||
const { toast } = useToast();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// -----------------------------
|
||||
// state for manual add
|
||||
// -----------------------------
|
||||
// NPI provider state — stored per-appointment on the procedure rows
|
||||
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState<number | null>(null);
|
||||
|
||||
// manual add row state
|
||||
const [manualCode, setManualCode] = useState("");
|
||||
const [manualLabel, setManualLabel] = useState("");
|
||||
const [manualFee, setManualFee] = useState("");
|
||||
const [manualTooth, setManualTooth] = useState("");
|
||||
const [manualSurface, setManualSurface] = useState("");
|
||||
|
||||
// -----------------------------
|
||||
// state for inline edit
|
||||
// -----------------------------
|
||||
// inline edit state
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editRow, setEditRow] = useState<Partial<AppointmentProcedure>>({});
|
||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||
|
||||
// for redirection to claim submission
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// -----------------------------
|
||||
// fetch procedures
|
||||
// -----------------------------
|
||||
const { data: procedures = [], isLoading } = useQuery<AppointmentProcedure[]>(
|
||||
{
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointment-procedures/${appointmentId}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load procedures");
|
||||
return res.json();
|
||||
},
|
||||
enabled: open && !!appointmentId,
|
||||
// ── NPI Providers ──────────────────────────────────────────────
|
||||
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||
if (!res.ok) throw new Error("Failed to fetch NPI providers");
|
||||
return res.json();
|
||||
},
|
||||
);
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// ── Procedures ─────────────────────────────────────────────────
|
||||
const { data: procedures = [], isLoading } = useQuery<AppointmentProcedure[]>({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", `/api/appointment-procedures/${appointmentId}`);
|
||||
if (!res.ok) throw new Error("Failed to load procedures");
|
||||
return res.json();
|
||||
},
|
||||
enabled: open && !!appointmentId,
|
||||
});
|
||||
|
||||
// Sync NPI provider from saved procedures when they load
|
||||
useEffect(() => {
|
||||
if (!procedures.length) return;
|
||||
const saved = (procedures[0] as any)?.npiProviderId ?? null;
|
||||
if (saved != null) setSelectedNpiProviderId(Number(saved));
|
||||
}, [procedures]);
|
||||
|
||||
// Default NPI provider to Mary Scannell / first when none saved yet
|
||||
useEffect(() => {
|
||||
if (selectedNpiProviderId != null || !npiProviders.length) return;
|
||||
const mary = npiProviders.find((p) => p.providerName.toLowerCase() === "mary scannell");
|
||||
setSelectedNpiProviderId((mary ?? npiProviders[0])?.id ?? null);
|
||||
}, [npiProviders, selectedNpiProviderId]);
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────
|
||||
|
||||
const setNpiMutation = useMutation({
|
||||
mutationFn: async (npiProviderId: number | null) => {
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/appointment-procedures/set-npi-provider/${appointmentId}`,
|
||||
{ npiProviderId },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to update provider");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Rendering provider saved" });
|
||||
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: "Error", description: err.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// mutations
|
||||
// -----------------------------
|
||||
const addManualMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
appointmentId,
|
||||
patientId,
|
||||
npiProviderId: selectedNpiProviderId ?? null,
|
||||
procedureCode: manualCode,
|
||||
procedureLabel: manualLabel || null,
|
||||
fee: manualFee ? Number(manualFee) : null,
|
||||
@@ -94,101 +136,63 @@ export function AppointmentProceduresDialog({
|
||||
toothSurface: manualSurface || null,
|
||||
source: "MANUAL",
|
||||
};
|
||||
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointment-procedures",
|
||||
payload,
|
||||
);
|
||||
const res = await apiRequest("POST", "/api/appointment-procedures", payload);
|
||||
if (!res.ok) throw new Error("Failed to add procedure");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Procedure added" });
|
||||
setManualCode("");
|
||||
setManualLabel("");
|
||||
setManualFee("");
|
||||
setManualTooth("");
|
||||
setManualSurface("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
setManualCode(""); setManualLabel(""); setManualFee("");
|
||||
setManualTooth(""); setManualSurface("");
|
||||
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message ?? "Failed to add procedure",
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({ title: "Error", description: err.message ?? "Failed to add procedure", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const bulkAddMutation = useMutation({
|
||||
mutationFn: async (rows: any[]) => {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointment-procedures/bulk",
|
||||
rows,
|
||||
);
|
||||
const res = await apiRequest("POST", "/api/appointment-procedures/bulk", rows);
|
||||
if (!res.ok) throw new Error("Failed to add combo procedures");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Combo added" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/appointment-procedures/${id}`,
|
||||
);
|
||||
const res = await apiRequest("DELETE", `/api/appointment-procedures/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Deleted" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
|
||||
},
|
||||
});
|
||||
|
||||
const clearAllMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/appointment-procedures/clear/${appointmentId}`,
|
||||
);
|
||||
const res = await apiRequest("DELETE", `/api/appointment-procedures/clear/${appointmentId}`);
|
||||
if (!res.ok) throw new Error("Failed to clear procedures");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "All procedures cleared" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
|
||||
setClearAllOpen(false);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message ?? "Failed to clear procedures",
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({ title: "Error", description: err.message ?? "Failed to clear procedures", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!editingId) return;
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/appointment-procedures/${editingId}`,
|
||||
editRow,
|
||||
);
|
||||
const res = await apiRequest("PUT", `/api/appointment-procedures/${editingId}`, editRow);
|
||||
if (!res.ok) throw new Error("Failed to update");
|
||||
return res.json();
|
||||
},
|
||||
@@ -196,55 +200,42 @@ export function AppointmentProceduresDialog({
|
||||
toast({ title: "Updated" });
|
||||
setEditingId(null);
|
||||
setEditRow({});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
|
||||
},
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// handlers
|
||||
// -----------------------------
|
||||
// ── Handlers ───────────────────────────────────────────────────
|
||||
|
||||
const handleAddCombo = (comboKey: string) => {
|
||||
const combo = PROCEDURE_COMBOS[comboKey];
|
||||
if (!combo || !patient?.dateOfBirth) return;
|
||||
|
||||
const serviceDate = new Date();
|
||||
const dob = patient.dateOfBirth;
|
||||
const ref = new Date();
|
||||
const birth = new Date(dob as any);
|
||||
let age = ref.getFullYear() - birth.getFullYear();
|
||||
const hadBirthday =
|
||||
ref.getMonth() > birth.getMonth() ||
|
||||
(ref.getMonth() === birth.getMonth() && ref.getDate() >= birth.getDate());
|
||||
if (!hadBirthday) age -= 1;
|
||||
|
||||
const age = (() => {
|
||||
const birth = new Date(dob);
|
||||
const ref = new Date(serviceDate);
|
||||
let a = ref.getFullYear() - birth.getFullYear();
|
||||
const hadBirthday =
|
||||
ref.getMonth() > birth.getMonth() ||
|
||||
(ref.getMonth() === birth.getMonth() &&
|
||||
ref.getDate() >= birth.getDate());
|
||||
if (!hadBirthday) a -= 1;
|
||||
return a;
|
||||
})();
|
||||
|
||||
const rows = combo.codes.map((code: string, idx: number) => {
|
||||
const priceDecimal = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||
|
||||
return {
|
||||
appointmentId,
|
||||
patientId,
|
||||
procedureCode: code,
|
||||
procedureLabel: combo.label,
|
||||
fee: priceDecimal.toNumber(),
|
||||
source: "COMBO",
|
||||
comboKey: comboKey,
|
||||
toothNumber: combo.toothNumbers?.[idx] ?? null,
|
||||
};
|
||||
});
|
||||
const rows = combo.codes.map((code: string, idx: number) => ({
|
||||
appointmentId,
|
||||
patientId,
|
||||
npiProviderId: selectedNpiProviderId ?? null,
|
||||
procedureCode: code,
|
||||
procedureLabel: combo.label,
|
||||
fee: getPriceForCodeWithAgeFromMap(CODE_MAP, code, age).toNumber(),
|
||||
source: "COMBO",
|
||||
comboKey,
|
||||
toothNumber: combo.toothNumbers?.[idx] ?? null,
|
||||
}));
|
||||
|
||||
bulkAddMutation.mutate(rows);
|
||||
};
|
||||
|
||||
const startEdit = (row: AppointmentProcedure) => {
|
||||
if (!row.id) return;
|
||||
|
||||
setEditingId(row.id);
|
||||
setEditRow({
|
||||
procedureCode: row.procedureCode,
|
||||
@@ -255,10 +246,7 @@ export function AppointmentProceduresDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditRow({});
|
||||
};
|
||||
const cancelEdit = () => { setEditingId(null); setEditRow({}); };
|
||||
|
||||
const handleDirectClaim = () => {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`);
|
||||
@@ -270,256 +258,146 @@ export function AppointmentProceduresDialog({
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// UI
|
||||
// -----------------------------
|
||||
const selectedProvider = npiProviders.find((p) => p.id === selectedNpiProviderId);
|
||||
|
||||
// ── UI ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-6xl max-h-[90vh] overflow-y-auto pointer-events-none"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (clearAllOpen) {
|
||||
e.preventDefault(); // block only when delete dialog is open
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (clearAllOpen) {
|
||||
e.preventDefault(); // block only when delete dialog is open
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(e) => { if (clearAllOpen) e.preventDefault(); }}
|
||||
onInteractOutside={(e) => { if (clearAllOpen) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Appointment Procedures
|
||||
{serviceDate && <span className="ml-3 text-base font-normal text-muted-foreground">{serviceDate}</span>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ================= COMBOS ================= */}
|
||||
<div className="space-y-8 pointer-events-auto">
|
||||
<DirectComboButtons
|
||||
onDirectCombo={(comboKey) => {
|
||||
handleAddCombo(comboKey);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RegularComboButtons
|
||||
onRegularCombo={(comboKey) => {
|
||||
handleAddCombo(comboKey);
|
||||
}}
|
||||
/>
|
||||
{/* ── Rendering Provider ─────────────────────────────── */}
|
||||
<div className="flex items-end gap-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm font-medium text-blue-800">Rendering Provider (NPI)</Label>
|
||||
<Select
|
||||
value={selectedNpiProviderId?.toString() ?? ""}
|
||||
onValueChange={(v) => setSelectedNpiProviderId(v ? Number(v) : null)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 bg-white">
|
||||
<SelectValue placeholder="Select NPI Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{npiProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={String(p.id)}>
|
||||
{p.npiNumber} — {p.providerName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mb-0.5"
|
||||
onClick={() => setNpiMutation.mutate(selectedNpiProviderId)}
|
||||
disabled={setNpiMutation.isPending || !procedures.length}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
Set for All
|
||||
</Button>
|
||||
{selectedProvider && (
|
||||
<span className="text-sm text-blue-700 mb-1 whitespace-nowrap">
|
||||
✓ {selectedProvider.providerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ================= MANUAL ADD ================= */}
|
||||
{/* ── Combos ─────────────────────────────────────────── */}
|
||||
<div className="space-y-8 pointer-events-auto">
|
||||
<DirectComboButtons onDirectCombo={handleAddCombo} />
|
||||
<RegularComboButtons onRegularCombo={handleAddCombo} />
|
||||
</div>
|
||||
|
||||
{/* ── Manual Add ─────────────────────────────────────── */}
|
||||
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-3">
|
||||
<div className="font-medium text-sm">Add Manual Procedure</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={manualCode}
|
||||
onChange={(e) => setManualCode(e.target.value)}
|
||||
placeholder="D0120"
|
||||
/>
|
||||
<Input value={manualCode} onChange={(e) => setManualCode(e.target.value)} placeholder="D0120" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
value={manualLabel}
|
||||
onChange={(e) => setManualLabel(e.target.value)}
|
||||
placeholder="Exam"
|
||||
/>
|
||||
<Input value={manualLabel} onChange={(e) => setManualLabel(e.target.value)} placeholder="Exam" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Fee</Label>
|
||||
<Input
|
||||
value={manualFee}
|
||||
onChange={(e) => setManualFee(e.target.value)}
|
||||
placeholder="100"
|
||||
type="number"
|
||||
/>
|
||||
<Input value={manualFee} onChange={(e) => setManualFee(e.target.value)} placeholder="100" type="number" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tooth</Label>
|
||||
<Input
|
||||
value={manualTooth}
|
||||
onChange={(e) => setManualTooth(e.target.value)}
|
||||
placeholder="14"
|
||||
/>
|
||||
<Input value={manualTooth} onChange={(e) => setManualTooth(e.target.value)} placeholder="14" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Surface</Label>
|
||||
<Input
|
||||
value={manualSurface}
|
||||
onChange={(e) => setManualSurface(e.target.value)}
|
||||
placeholder="MO"
|
||||
/>
|
||||
<Input value={manualSurface} onChange={(e) => setManualSurface(e.target.value)} placeholder="MO" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => addManualMutation.mutate()}
|
||||
disabled={!manualCode || addManualMutation.isPending}
|
||||
>
|
||||
<Button size="sm" onClick={() => addManualMutation.mutate()} disabled={!manualCode || addManualMutation.isPending}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Procedure
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================= LIST ================= */}
|
||||
{/* ── Procedures List ─────────────────────────────────── */}
|
||||
<div className="mt-8 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">Selected Procedures</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!procedures.length}
|
||||
onClick={() => setClearAllOpen(true)}
|
||||
>
|
||||
<div className="text-sm font-semibold">Saved Procedures ({procedures.length})</div>
|
||||
<Button variant="destructive" size="sm" disabled={!procedures.length} onClick={() => setClearAllOpen(true)}>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg divide-y bg-white">
|
||||
{/* ===== TABLE HEADER ===== */}
|
||||
<div className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted/40">
|
||||
<div>Code</div>
|
||||
<div>Label</div>
|
||||
<div>Fee</div>
|
||||
<div>Tooth</div>
|
||||
<div>Surface</div>
|
||||
<div className="text-center">Edit</div>
|
||||
<div className="text-center">Delete</div>
|
||||
<div>Code</div><div>Label</div><div>Fee</div><div>Tooth</div><div>Surface</div>
|
||||
<div className="text-center">Edit</div><div className="text-center">Delete</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div className="p-4 text-sm text-muted-foreground">Loading...</div>}
|
||||
{!isLoading && procedures.length === 0 && (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No procedures added
|
||||
</div>
|
||||
<div className="p-4 text-sm text-muted-foreground">No procedures added yet</div>
|
||||
)}
|
||||
|
||||
{procedures.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-3 text-sm hover:bg-muted/40 transition"
|
||||
>
|
||||
<div key={p.id} className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-3 text-sm hover:bg-muted/40 transition">
|
||||
{editingId === p.id ? (
|
||||
<>
|
||||
<Input
|
||||
className="w-[90px]"
|
||||
value={editRow.procedureCode ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
procedureCode: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={editRow.procedureLabel ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
procedureLabel: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="w-[90px]"
|
||||
value={
|
||||
editRow.fee !== undefined && editRow.fee !== null
|
||||
? String(editRow.fee)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setEditRow({ ...editRow, fee: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="w-[80px]"
|
||||
value={editRow.toothNumber ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
toothNumber: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="w-[80px]"
|
||||
value={editRow.toothSurface ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
toothSurface: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input className="w-[90px]" value={editRow.procedureCode ?? ""} onChange={(e) => setEditRow({ ...editRow, procedureCode: e.target.value })} />
|
||||
<Input className="flex-1" value={editRow.procedureLabel ?? ""} onChange={(e) => setEditRow({ ...editRow, procedureLabel: e.target.value })} />
|
||||
<Input className="w-[90px]" value={editRow.fee !== undefined && editRow.fee !== null ? String(editRow.fee) : ""} onChange={(e) => setEditRow({ ...editRow, fee: Number(e.target.value) })} />
|
||||
<Input className="w-[80px]" value={editRow.toothNumber ?? ""} onChange={(e) => setEditRow({ ...editRow, toothNumber: e.target.value })} />
|
||||
<Input className="w-[80px]" value={editRow.toothSurface ?? ""} onChange={(e) => setEditRow({ ...editRow, toothSurface: e.target.value })} />
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => updateMutation.mutate()}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => updateMutation.mutate()}><Save className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button size="icon" variant="ghost" onClick={cancelEdit}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={cancelEdit}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-[90px] font-medium">
|
||||
{p.procedureCode}
|
||||
</div>
|
||||
<div className="flex-1 text-muted-foreground">
|
||||
{p.procedureLabel}
|
||||
</div>
|
||||
<div className="w-[90px]">
|
||||
{p.fee !== null && p.fee !== undefined
|
||||
? String(p.fee)
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
<div className="w-[90px] font-medium">{p.procedureCode}</div>
|
||||
<div className="flex-1 text-muted-foreground">{p.procedureLabel}</div>
|
||||
<div className="w-[90px]">{p.fee !== null && p.fee !== undefined ? String(p.fee) : ""}</div>
|
||||
<div className="w-[80px]">{p.toothNumber}</div>
|
||||
<div className="w-[80px]">{p.toothSurface}</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => startEdit(p)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => startEdit(p)}>Edit</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => deleteMutation.mutate(p.id!)}
|
||||
>
|
||||
<Button size="icon" variant="ghost" onClick={() => deleteMutation.mutate(p.id!)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -530,7 +408,7 @@ export function AppointmentProceduresDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================= FOOTER ================= */}
|
||||
{/* ── Footer ─────────────────────────────────────────── */}
|
||||
<div className="flex justify-between items-center gap-2 mt-8 pt-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -540,7 +418,6 @@ export function AppointmentProceduresDialog({
|
||||
>
|
||||
Direct Claim
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-blue-500 text-blue-600 hover:bg-blue-50"
|
||||
@@ -550,10 +427,7 @@ export function AppointmentProceduresDialog({
|
||||
Manual Claim
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -561,10 +435,7 @@ export function AppointmentProceduresDialog({
|
||||
isOpen={clearAllOpen}
|
||||
entityName="all procedures for this appointment"
|
||||
onCancel={() => setClearAllOpen(false)}
|
||||
onConfirm={() => {
|
||||
setClearAllOpen(false);
|
||||
clearAllMutation.mutate();
|
||||
}}
|
||||
onConfirm={() => { setClearAllOpen(false); clearAllMutation.mutate(); }}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -59,11 +59,14 @@ import {
|
||||
DirectComboButtons,
|
||||
RegularComboButtons,
|
||||
} from "@/components/procedure/procedure-combo-buttons";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface ClaimFormProps {
|
||||
patientId: number;
|
||||
appointmentId?: number;
|
||||
autoSubmit?: boolean;
|
||||
/** When true: form saves to AppointmentProcedure (Select Procedures flow), shows only Save button */
|
||||
proceduresOnly?: boolean;
|
||||
onSubmit: (data: ClaimFormData) => Promise<Claim>;
|
||||
onHandleAppointmentSubmit: (
|
||||
appointmentData: InsertAppointment | UpdateAppointment,
|
||||
@@ -78,6 +81,7 @@ export function ClaimForm({
|
||||
patientId,
|
||||
appointmentId,
|
||||
autoSubmit,
|
||||
proceduresOnly = false,
|
||||
onHandleAppointmentSubmit,
|
||||
onHandleUpdatePatient,
|
||||
onHandleForMHSeleniumClaim,
|
||||
@@ -90,8 +94,16 @@ export function ClaimForm({
|
||||
|
||||
const [prefillDone, setPrefillDone] = useState(false);
|
||||
const autoSubmittedRef = useRef(false);
|
||||
// When an existing claim is loaded for the appointment, store its ID so
|
||||
// the form submits an update instead of creating a new claim.
|
||||
const [existingClaimId, setExistingClaimId] = useState<number | null>(null);
|
||||
|
||||
const [directSubmitEnabled, setDirectSubmitEnabled] = useState(false);
|
||||
const [patient, setPatient] = useState<Patient | null>(null);
|
||||
// staffId from the appointment column — used for claim creation, not shown in UI
|
||||
const [appointmentStaffId, setAppointmentStaffId] = useState<number | null>(null);
|
||||
// npiProviderId loaded from AppointmentProcedure (2b) — restored to form when npiProviders load
|
||||
const [savedProcNpiId, setSavedProcNpiId] = useState<number | null>(null);
|
||||
|
||||
// Query patient based on given patient id
|
||||
const {
|
||||
@@ -211,6 +223,12 @@ export function ClaimForm({
|
||||
}
|
||||
|
||||
const appointment = await res.json();
|
||||
|
||||
// Capture the column staffId from the appointment
|
||||
if (!cancelled && appointment?.staffId) {
|
||||
setAppointmentStaffId(Number(appointment.staffId));
|
||||
}
|
||||
|
||||
// appointment.date is expected to be either "YYYY-MM-DD" or an ISO string.
|
||||
const rawDate = appointment?.date ?? appointment?.day ?? "";
|
||||
if (!rawDate) return;
|
||||
@@ -256,9 +274,104 @@ export function ClaimForm({
|
||||
|
||||
//
|
||||
|
||||
// 2. effect - prefill proceduresCodes (if exists for appointment) into serviceLines
|
||||
// 2a. Load existing saved claim for this appointment (if any).
|
||||
// Skipped in proceduresOnly mode — that mode always reads from AppointmentProcedure.
|
||||
useEffect(() => {
|
||||
if (!appointmentId) return;
|
||||
if (proceduresOnly) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/claims/by-appointment/${appointmentId}`,
|
||||
);
|
||||
if (!res.ok) return; // 404 = no existing claim, that's fine
|
||||
|
||||
const claim = await res.json();
|
||||
if (cancelled || !claim?.id) return;
|
||||
|
||||
setExistingClaimId(claim.id);
|
||||
|
||||
// Restore service date
|
||||
const rawDate = claim.serviceDate ?? "";
|
||||
const claimDate = rawDate
|
||||
? String(rawDate).split("T")[0] ?? ""
|
||||
: "";
|
||||
if (claimDate) {
|
||||
try {
|
||||
setServiceDateValue(parseLocalDate(claimDate));
|
||||
setServiceDate(claimDate);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Restore service lines
|
||||
const mappedLines = (claim.serviceLines ?? []).map((sl: any) => ({
|
||||
procedureCode: sl.procedureCode ?? "",
|
||||
procedureDate: sl.procedureDate
|
||||
? String(sl.procedureDate).split("T")[0]
|
||||
: claimDate,
|
||||
quad: sl.quad ?? "",
|
||||
arch: sl.arch ?? "",
|
||||
toothNumber: sl.toothNumber ?? "",
|
||||
toothSurface: sl.toothSurface ?? "",
|
||||
totalBilled: new Decimal(Number(sl.totalBilled ?? 0)),
|
||||
totalAdjusted: new Decimal(Number(sl.totalAdjusted ?? 0)),
|
||||
totalPaid: new Decimal(Number(sl.totalPaid ?? 0)),
|
||||
}));
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
claimId: claim.id,
|
||||
serviceDate: claimDate || prev.serviceDate,
|
||||
serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines,
|
||||
remarks: claim.remarks ?? "",
|
||||
missingTeethStatus: (claim.missingTeethStatus as MissingTeethStatus) ?? "No_missing",
|
||||
missingTeeth: (claim.missingTeeth as Record<string, "X" | "O">) ?? {},
|
||||
insuranceProvider: claim.insuranceProvider ?? "",
|
||||
...(claim.staffId ? { staffId: claim.staffId } : {}),
|
||||
claimFiles: claim.claimFiles ?? [],
|
||||
}));
|
||||
|
||||
// Restore staff selection
|
||||
if (claim.staffId && staffMembersRaw.length > 0) {
|
||||
const matchedStaff = staffMembersRaw.find(
|
||||
(s) => Number(s.id) === Number(claim.staffId),
|
||||
);
|
||||
if (matchedStaff) setStaff(matchedStaff);
|
||||
}
|
||||
|
||||
// Restore NPI provider selection
|
||||
if ((claim as any).npiProviderId && npiProviders.length > 0) {
|
||||
const matchedNpi = npiProviders.find(
|
||||
(p) => Number(p.id) === Number((claim as any).npiProviderId),
|
||||
);
|
||||
if (matchedNpi) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
npiProvider: {
|
||||
npiNumber: matchedNpi.npiNumber,
|
||||
providerName: matchedNpi.providerName,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setPrefillDone(true);
|
||||
} catch (err) {
|
||||
// no existing claim — silently continue
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [appointmentId]);
|
||||
|
||||
// 2b. Prefill procedures from AppointmentProcedure records.
|
||||
// Skipped when an existing claim was already loaded above.
|
||||
useEffect(() => {
|
||||
if (!appointmentId) return;
|
||||
if (existingClaimId) return; // existing claim takes priority
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -291,6 +404,20 @@ export function ClaimForm({
|
||||
serviceLines: mappedLines,
|
||||
}));
|
||||
|
||||
// Restore NPI provider from saved procedures
|
||||
if (data.npiProviderId) {
|
||||
const npiId = Number(data.npiProviderId);
|
||||
setSavedProcNpiId(npiId);
|
||||
// Apply immediately if providers are already loaded
|
||||
const matched = npiProviders.find((p) => p.id === npiId);
|
||||
if (matched) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
npiProvider: { npiNumber: matched.npiNumber, providerName: matched.providerName },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setPrefillDone(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to prefill procedures:", err);
|
||||
@@ -300,7 +427,20 @@ export function ClaimForm({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [appointmentId, serviceDate]);
|
||||
}, [appointmentId, serviceDate, existingClaimId]);
|
||||
|
||||
// Restore NPI provider from saved procedures when npiProviders list loads after 2b
|
||||
useEffect(() => {
|
||||
if (!savedProcNpiId || !npiProviders.length) return;
|
||||
if (form.npiProvider?.npiNumber) return; // already set
|
||||
const matched = npiProviders.find((p) => p.id === savedProcNpiId);
|
||||
if (matched) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
npiProvider: { npiNumber: matched.npiNumber, providerName: matched.providerName },
|
||||
}));
|
||||
}
|
||||
}, [savedProcNpiId, npiProviders]);
|
||||
|
||||
// Update service date when calendar date changes
|
||||
const onServiceDateChange = (date: Date | undefined) => {
|
||||
@@ -421,7 +561,7 @@ export function ClaimForm({
|
||||
patientId: patientId || 0,
|
||||
appointmentId: 0,
|
||||
userId: Number(user?.id),
|
||||
staffId: Number(staff?.id),
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientName: `${patient?.firstName} ${patient?.lastName}`.trim(),
|
||||
memberId: patient?.insuranceId ?? "",
|
||||
dateOfBirth: normalizeToIsoDateString(patient?.dateOfBirth),
|
||||
@@ -602,7 +742,7 @@ export function ClaimForm({
|
||||
const appointmentData = {
|
||||
patientId: patientId,
|
||||
date: serviceDate,
|
||||
staffId: staff?.id,
|
||||
staffId: appointmentStaffId ?? staff?.id,
|
||||
};
|
||||
const created = await onHandleAppointmentSubmit(appointmentData);
|
||||
|
||||
@@ -648,7 +788,7 @@ export function ClaimForm({
|
||||
const createdClaim = await onSubmit({
|
||||
...formToCreateClaim,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: Number(staff?.id),
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId: patientId,
|
||||
insuranceProvider: "MassHealth",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
@@ -660,7 +800,7 @@ export function ClaimForm({
|
||||
...f,
|
||||
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: Number(staff?.id),
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
npiProvider: f.npiProvider,
|
||||
patientId: patientId,
|
||||
insuranceProvider: "Mass Health",
|
||||
@@ -741,7 +881,7 @@ export function ClaimForm({
|
||||
...f,
|
||||
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: Number(staff?.id),
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
npiProvider: f.npiProvider,
|
||||
patientId: patientId,
|
||||
insuranceProvider: "Mass Health",
|
||||
@@ -791,7 +931,7 @@ export function ClaimForm({
|
||||
const appointmentData = {
|
||||
patientId: patientId,
|
||||
date: serviceDate,
|
||||
staffId: staff?.id,
|
||||
staffId: appointmentStaffId ?? staff?.id,
|
||||
};
|
||||
const created = await onHandleAppointmentSubmit(appointmentData);
|
||||
|
||||
@@ -821,7 +961,7 @@ export function ClaimForm({
|
||||
|
||||
// 3. Create Claim(if not)
|
||||
// Filter out empty service lines (empty procedureCode)
|
||||
const { uploadedFiles, insuranceSiteKey, ...formToCreateClaim } = form;
|
||||
const { uploadedFiles, insuranceSiteKey, npiProvider: _npi, ...formToCreateClaim } = form;
|
||||
|
||||
// build claimFiles metadata from uploadedFiles (only filename + mimeType)
|
||||
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
|
||||
@@ -832,7 +972,7 @@ export function ClaimForm({
|
||||
const createdClaim = await onSubmit({
|
||||
...formToCreateClaim,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: Number(staff?.id),
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId: patientId,
|
||||
insuranceProvider: "MassHealth",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
@@ -843,6 +983,137 @@ export function ClaimForm({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const uploadAttachmentsToLocalFolder = async (files: File[]): Promise<ClaimFileMeta[]> => {
|
||||
if (!files.length) return [];
|
||||
|
||||
const patientName = patient?.firstName && patient?.lastName
|
||||
? `${patient.firstName} ${patient.lastName}`
|
||||
: patient?.firstName ?? `patient-${patientId}`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("patientName", patientName);
|
||||
files.forEach((f) => formData.append("files", f));
|
||||
|
||||
const res = await apiRequest("POST", "/api/claims/upload-attachments", formData);
|
||||
const data = await res.json();
|
||||
return (data.data ?? []) as ClaimFileMeta[];
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const filteredServiceLines = form.serviceLines.filter(
|
||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||
);
|
||||
|
||||
if (filteredServiceLines.length === 0) {
|
||||
toast({
|
||||
title: "No procedure codes",
|
||||
description: "Please add at least one procedure code before saving.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const missingFields: string[] = [];
|
||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
||||
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
||||
if (missingFields.length > 0) {
|
||||
toast({
|
||||
title: "Missing Required Fields",
|
||||
description: `Please fill out: ${missingFields.join(", ")}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let appointmentIdToUse = appointmentId;
|
||||
|
||||
if (appointmentIdToUse == null) {
|
||||
const appointmentData = {
|
||||
patientId: patientId,
|
||||
date: serviceDate,
|
||||
staffId: appointmentStaffId ?? staff?.id,
|
||||
};
|
||||
const created = await onHandleAppointmentSubmit(appointmentData);
|
||||
if (typeof created === "number" && created > 0) {
|
||||
appointmentIdToUse = created;
|
||||
} else if (created && typeof (created as any).id === "number") {
|
||||
appointmentIdToUse = (created as any).id;
|
||||
}
|
||||
}
|
||||
|
||||
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToSave } = form;
|
||||
|
||||
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
||||
? await uploadAttachmentsToLocalFolder(uploadedFiles)
|
||||
: [];
|
||||
|
||||
// Find the npiProviderId matching the currently selected NPI provider
|
||||
const selectedNpiProviderId = npiProvider?.npiNumber
|
||||
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
|
||||
: null;
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
...formToSave,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId: patientId,
|
||||
insuranceProvider: "MassHealth",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
claimFiles: claimFilesMeta,
|
||||
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
|
||||
isDraft: true,
|
||||
});
|
||||
toast({ title: "Saved", description: "Claim saved successfully." });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Save failed",
|
||||
description: err?.message ?? "Failed to save claim.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Saves CDT codes + NPI provider to AppointmentProcedure (proceduresOnly mode)
|
||||
const handleProceduresSave = async () => {
|
||||
if (!appointmentId || !patientId) {
|
||||
toast({ title: "Missing appointment", description: "Cannot save without an appointment.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredServiceLines = form.serviceLines.filter(
|
||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||
);
|
||||
if (filteredServiceLines.length === 0) {
|
||||
toast({ title: "No procedure codes", description: "Please add at least one procedure code.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedNpiProviderId = form.npiProvider?.npiNumber
|
||||
? (npiProviders.find((p) => p.npiNumber === form.npiProvider!.npiNumber)?.id ?? null)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const res = await apiRequest("POST", "/api/appointment-procedures/save-for-appointment", {
|
||||
appointmentId,
|
||||
patientId,
|
||||
npiProviderId: selectedNpiProviderId,
|
||||
procedures: filteredServiceLines.map((l) => ({
|
||||
procedureCode: l.procedureCode,
|
||||
fee: Number(l.totalBilled) || null,
|
||||
toothNumber: l.toothNumber || null,
|
||||
toothSurface: l.toothSurface || null,
|
||||
})),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error("Failed to save procedures");
|
||||
toast({ title: "Procedures saved", description: `${data.count} procedure(s) saved.` });
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
toast({ title: "Save failed", description: err?.message ?? "Failed to save procedures.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
// for direct combo button.
|
||||
const applyComboAndThenMH = async (
|
||||
comboId: keyof typeof PROCEDURE_COMBOS,
|
||||
@@ -1020,47 +1291,6 @@ export function ClaimForm({
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* Treating doctor */}
|
||||
<Label className="flex items-center ml-2">
|
||||
Treating Doctor
|
||||
</Label>
|
||||
<Select
|
||||
value={staff?.id?.toString() || ""}
|
||||
onValueChange={(id) => {
|
||||
const selected = staffMembersRaw.find(
|
||||
(member) => member.id?.toString() === id,
|
||||
);
|
||||
if (selected) {
|
||||
setStaff(selected);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
staffId: Number(selected.id),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue
|
||||
placeholder={staff ? staff.name : "Select Staff"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{staffMembersRaw.map((member) => {
|
||||
if (member.id === undefined) return null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={member.id}
|
||||
value={member.id.toString()}
|
||||
>
|
||||
{member.name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Rendering Npi Provider */}
|
||||
<Label className="flex items-center ml-2">
|
||||
Rendering Provider
|
||||
@@ -1108,10 +1338,33 @@ export function ClaimForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Switch
|
||||
id="direct-submit-toggle"
|
||||
checked={directSubmitEnabled}
|
||||
onCheckedChange={setDirectSubmitEnabled}
|
||||
/>
|
||||
<Label htmlFor="direct-submit-toggle" className="text-sm cursor-pointer select-none">
|
||||
Direct Submission {directSubmitEnabled ? <span className="text-green-600 font-semibold">ON</span> : <span className="text-muted-foreground">OFF</span>}
|
||||
</Label>
|
||||
</div>
|
||||
<DirectComboButtons
|
||||
onDirectCombo={(comboKey) =>
|
||||
applyComboAndThenMH(comboKey as any)
|
||||
}
|
||||
onDirectCombo={(comboKey) => {
|
||||
if (directSubmitEnabled) {
|
||||
applyComboAndThenMH(comboKey as any);
|
||||
} else {
|
||||
setForm((prev) => {
|
||||
const next = applyComboToForm(
|
||||
prev,
|
||||
comboKey as any,
|
||||
patient?.dateOfBirth ?? "",
|
||||
{ replaceAll: false, lineDate: prev.serviceDate },
|
||||
);
|
||||
setTimeout(() => scrollToLine(0), 0);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1464,37 +1717,51 @@ export function ClaimForm({
|
||||
{/* Insurance Carriers */}
|
||||
<div className="pt-6">
|
||||
<h3 className="text-xl font-semibold mb-4 text-center">
|
||||
Insurance Carriers
|
||||
{proceduresOnly ? "Save Procedures" : "Insurance Carriers"}
|
||||
</h3>
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
className="w-32"
|
||||
variant="secondary"
|
||||
onClick={() => handleMHSubmit()}
|
||||
>
|
||||
MH
|
||||
</Button>
|
||||
<Button
|
||||
className="w-32"
|
||||
variant="secondary"
|
||||
onClick={() => handleMHPreAuth()}
|
||||
>
|
||||
MH PreAuth
|
||||
</Button>
|
||||
<Button
|
||||
className="w-32"
|
||||
variant="secondary"
|
||||
onClick={handleAddService}
|
||||
>
|
||||
Add Service
|
||||
</Button>
|
||||
<Button className="w-32" variant="outline">
|
||||
Delta MA
|
||||
</Button>
|
||||
<Button className="w-32" variant="outline">
|
||||
Others
|
||||
</Button>
|
||||
</div>
|
||||
{proceduresOnly ? (
|
||||
/* ── Select Procedures mode: Save only ── */
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
className="w-48"
|
||||
variant="default"
|
||||
onClick={handleProceduresSave}
|
||||
>
|
||||
Save Procedures
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Insurance Claim mode: submit buttons, no Save ── */
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
className="w-32"
|
||||
variant="secondary"
|
||||
onClick={() => handleMHSubmit()}
|
||||
>
|
||||
MH
|
||||
</Button>
|
||||
<Button
|
||||
className="w-32"
|
||||
variant="secondary"
|
||||
onClick={() => handleMHPreAuth()}
|
||||
>
|
||||
MH PreAuth
|
||||
</Button>
|
||||
<Button
|
||||
className="w-32"
|
||||
variant="secondary"
|
||||
onClick={handleAddService}
|
||||
>
|
||||
Add Service
|
||||
</Button>
|
||||
<Button className="w-32" variant="outline">
|
||||
Delta MA
|
||||
</Button>
|
||||
<Button className="w-32" variant="outline">
|
||||
Others
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Shield,
|
||||
FileCheck,
|
||||
LoaderCircleIcon,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@@ -53,7 +54,6 @@ import {
|
||||
} from "@/redux/slices/seleniumTaskSlice";
|
||||
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
||||
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
|
||||
import { AppointmentProceduresDialog } from "@/components/appointment-procedures/appointment-procedures-dialog";
|
||||
|
||||
// Define types for scheduling
|
||||
interface TimeSlot {
|
||||
@@ -94,17 +94,6 @@ export default function AppointmentsPage() {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [proceduresDialogOpen, setProceduresDialogOpen] = useState(false);
|
||||
const [proceduresAppointmentId, setProceduresAppointmentId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [proceduresPatientId, setProceduresPatientId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [proceduresPatient, setProceduresPatient] = useState<Patient | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
const [editingAppointment, setEditingAppointment] = useState<
|
||||
Appointment | undefined
|
||||
@@ -119,10 +108,45 @@ export default function AppointmentsPage() {
|
||||
const batchTask = useAppSelector(
|
||||
(state) => state.seleniumTasks.eligibilityBatchCheck
|
||||
);
|
||||
const claimBatchTask = useAppSelector(
|
||||
(state) => state.seleniumTasks.claimBatchCheck
|
||||
);
|
||||
const [isCheckingAllElig, setIsCheckingAllElig] = useState(false);
|
||||
const [processedAppointmentIds, setProcessedAppointmentIds] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
const [selectedStaffColumns, setSelectedStaffColumns] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggleStaffColumn = (staffId: number) => {
|
||||
setSelectedStaffColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(staffId)) next.delete(staffId);
|
||||
else next.add(staffId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [selectedClaimColumns, setSelectedClaimColumns] = useState<Set<number>>(new Set());
|
||||
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
|
||||
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggleReminderColumn = (staffId: number) => {
|
||||
setSelectedReminderColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(staffId)) next.delete(staffId);
|
||||
else next.add(staffId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleClaimColumn = (staffId: number) => {
|
||||
setSelectedClaimColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(staffId)) next.delete(staffId);
|
||||
else next.add(staffId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
@@ -716,6 +740,10 @@ export default function AppointmentsPage() {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}`);
|
||||
};
|
||||
|
||||
const handleSelectProcedures = (appointmentId: number) => {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}&mode=procedures`);
|
||||
};
|
||||
|
||||
const handlePayments = (appointmentId: number) => {
|
||||
setLocation(`/payments?appointmentId=${appointmentId}`);
|
||||
};
|
||||
@@ -742,6 +770,8 @@ export default function AppointmentsPage() {
|
||||
|
||||
const dateParam = formattedSelectedDate; // existing variable in your component
|
||||
|
||||
const staffIdsParam = `&staffIds=${Array.from(selectedStaffColumns).join(",")}`;
|
||||
|
||||
// Start: set redux task status (visible globally)
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
@@ -757,7 +787,7 @@ export default function AppointmentsPage() {
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
`/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}`,
|
||||
`/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}${staffIdsParam}`,
|
||||
{}
|
||||
);
|
||||
|
||||
@@ -885,31 +915,86 @@ export default function AppointmentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenProcedures = (appointmentId: number) => {
|
||||
const apt = appointments.find((a) => a.id === appointmentId);
|
||||
if (!apt) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Appointment not found",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handleClaimForColumn = async () => {
|
||||
if (!user || selectedClaimColumns.size === 0) return;
|
||||
|
||||
const patient = patientsFromDay.find((p) => p.id === apt.patientId);
|
||||
if (!patient) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Patient not found for this appointment",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const staffIdsParam = Array.from(selectedClaimColumns).join(",");
|
||||
|
||||
setProceduresAppointmentId(Number(apt.id));
|
||||
setProceduresPatientId(apt.patientId);
|
||||
setProceduresPatient(patient);
|
||||
setProceduresDialogOpen(true);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "claimBatchCheck",
|
||||
status: "pending",
|
||||
message: `Submitting claims for selected columns on ${formattedSelectedDate}...`,
|
||||
})
|
||||
);
|
||||
setIsClaimingColumn(true);
|
||||
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
`/api/claims/batch-column?date=${formattedSelectedDate}&staffIds=${staffIdsParam}`,
|
||||
{}
|
||||
);
|
||||
|
||||
let body: any;
|
||||
try { body = await res.json(); } catch { body = null; }
|
||||
|
||||
if (!res.ok) {
|
||||
const errMsg = body?.error ?? `Server error ${res.status}`;
|
||||
dispatch(setTaskStatus({ key: "claimBatchCheck", status: "error", message: `Batch claim failed: ${errMsg}` }));
|
||||
toast({ title: "Batch claim failed", description: errMsg, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const results: any[] = Array.isArray(body?.results) ? body.results : [];
|
||||
const appointmentMap = new Map<number, Appointment>();
|
||||
for (const a of appointments) {
|
||||
if (a && typeof a.id === "number") appointmentMap.set(a.id, a);
|
||||
}
|
||||
|
||||
let queued = 0, skippedNoProcedures = 0, skippedAlreadyClaimed = 0, errCount = 0;
|
||||
|
||||
for (const r of results) {
|
||||
const aptId = Number(r.appointmentId);
|
||||
const apt = appointmentMap.get(aptId);
|
||||
const patientName = apt
|
||||
? patientsFromDay.find((p) => p.id === apt.patientId)
|
||||
? `${patientsFromDay.find((p) => p.id === apt.patientId)!.firstName ?? ""} ${patientsFromDay.find((p) => p.id === apt.patientId)!.lastName ?? ""}`.trim()
|
||||
: `patient#${apt.patientId}`
|
||||
: `appointment#${aptId}`;
|
||||
|
||||
if (r.skipped && r.error === "Already claimed") {
|
||||
skippedAlreadyClaimed++;
|
||||
} else if (r.skipped) {
|
||||
skippedNoProcedures++;
|
||||
} else if (r.error) {
|
||||
errCount++;
|
||||
toast({ title: `Skipped: ${patientName}`, description: r.error, variant: "destructive" });
|
||||
} else if (r.processed) {
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: qkAppointmentsDay(formattedSelectedDate) });
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "claimBatchCheck",
|
||||
status: errCount > 0 ? "error" : "success",
|
||||
message: `Claims queued: ${queued}, already claimed: ${skippedAlreadyClaimed}, no procedures: ${skippedNoProcedures}, errors: ${errCount}.`,
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Claim batch queued",
|
||||
description: `Queued: ${queued}, already claimed: ${skippedAlreadyClaimed}, no procedures: ${skippedNoProcedures}, errors: ${errCount}.`,
|
||||
variant: errCount > 0 ? "destructive" : "default",
|
||||
});
|
||||
} catch (err: any) {
|
||||
dispatch(setTaskStatus({ key: "claimBatchCheck", status: "error", message: `Batch claim error: ${err?.message ?? String(err)}` }));
|
||||
toast({ title: "Batch claim failed", description: err?.message ?? String(err), variant: "destructive" });
|
||||
} finally {
|
||||
setIsClaimingColumn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -920,6 +1005,12 @@ export default function AppointmentsPage() {
|
||||
show={batchTask.show}
|
||||
onClear={() => dispatch(clearTaskStatus("eligibilityBatchCheck"))}
|
||||
/>
|
||||
<SeleniumTaskBanner
|
||||
status={claimBatchTask.status}
|
||||
message={claimBatchTask.message}
|
||||
show={claimBatchTask.show}
|
||||
onClear={() => dispatch(clearTaskStatus("claimBatchCheck"))}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -932,7 +1023,7 @@ export default function AppointmentsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingAppointment(undefined);
|
||||
@@ -944,30 +1035,105 @@ export default function AppointmentsPage() {
|
||||
New Appointment
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleCheckAllEligibilities()}
|
||||
disabled={isLoading || isCheckingAllElig}
|
||||
>
|
||||
{isCheckingAllElig ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Check all eligibilities
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button disabled={true}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Claim Column A
|
||||
</Button>
|
||||
<Button disabled={true}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Claim Column B
|
||||
</Button>
|
||||
{/* Check Eligibility for Column section */}
|
||||
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
|
||||
<Button
|
||||
onClick={() => handleCheckAllEligibilities()}
|
||||
disabled={isLoading || isCheckingAllElig || selectedStaffColumns.size === 0}
|
||||
size="sm"
|
||||
>
|
||||
{isCheckingAllElig ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="h-4 w-4 mr-1" />
|
||||
Check Eligibility for Column
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{staffMembers.map((staff, index) => (
|
||||
<label
|
||||
key={staff.id}
|
||||
className="flex items-center gap-1 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
|
||||
checked={selectedStaffColumns.has(Number(staff.id))}
|
||||
onChange={() => toggleStaffColumn(Number(staff.id))}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Claim for Column section */}
|
||||
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
|
||||
<Button
|
||||
onClick={() => handleClaimForColumn()}
|
||||
disabled={isLoading || isClaimingColumn || selectedClaimColumns.size === 0}
|
||||
size="sm"
|
||||
>
|
||||
{isClaimingColumn ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileCheck className="h-4 w-4 mr-1" />
|
||||
Claim for Column
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{staffMembers.map((staff, index) => (
|
||||
<label
|
||||
key={staff.id}
|
||||
className="flex items-center gap-1 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
|
||||
checked={selectedClaimColumns.has(Number(staff.id))}
|
||||
onChange={() => toggleClaimColumn(Number(staff.id))}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Text Reminder for Column section */}
|
||||
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
|
||||
<Button
|
||||
disabled={true}
|
||||
size="sm"
|
||||
>
|
||||
Text Reminder for Column
|
||||
</Button>
|
||||
{staffMembers.map((staff, index) => (
|
||||
<label
|
||||
key={staff.id}
|
||||
className="flex items-center gap-1 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
|
||||
checked={selectedReminderColumns.has(Number(staff.id))}
|
||||
onChange={() => toggleReminderColumn(Number(staff.id))}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1009,13 +1175,13 @@ export default function AppointmentsPage() {
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
{/* Check Eligibility */}
|
||||
{/* Select Procedures */}
|
||||
<Item
|
||||
onClick={({ props }) => handleCheckClaimStatus(props.appointmentId)}
|
||||
onClick={({ props }) => handleSelectProcedures(props.appointmentId)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Claim Status
|
||||
<span className="flex items-center gap-2 text-purple-600">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
Select Procedures
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
@@ -1025,7 +1191,7 @@ export default function AppointmentsPage() {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
Claims / PreAuth
|
||||
Claims/PreAuth
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
@@ -1045,16 +1211,6 @@ export default function AppointmentsPage() {
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
{/* Procedures */}
|
||||
<Item
|
||||
onClick={({ props }) => handleOpenProcedures(props.appointmentId)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Procedures
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
{/* Clinic Notes */}
|
||||
<Item onClick={({ props }) => handleClinicNotes(props.appointmentId)}>
|
||||
<span className="flex items-center gap-2 text-yellow-600">
|
||||
@@ -1062,6 +1218,16 @@ export default function AppointmentsPage() {
|
||||
Clinic Notes
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
{/* Claim Status */}
|
||||
<Item
|
||||
onClick={({ props }) => handleCheckClaimStatus(props.appointmentId)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Claim Status
|
||||
</span>
|
||||
</Item>
|
||||
</Menu>
|
||||
|
||||
{/* Main Content */}
|
||||
@@ -1182,24 +1348,6 @@ export default function AppointmentsPage() {
|
||||
onDelete={handleDeleteAppointment}
|
||||
/>
|
||||
|
||||
{/* Appointment Procedure Dialog */}
|
||||
{proceduresAppointmentId && proceduresPatientId && proceduresPatient && (
|
||||
<AppointmentProceduresDialog
|
||||
open={proceduresDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setProceduresDialogOpen(open);
|
||||
if (!open) {
|
||||
setProceduresAppointmentId(null);
|
||||
setProceduresPatientId(null);
|
||||
setProceduresPatient(null);
|
||||
}
|
||||
}}
|
||||
appointmentId={proceduresAppointmentId}
|
||||
patientId={proceduresPatientId}
|
||||
patient={proceduresPatient}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={confirmDeleteState.open}
|
||||
onConfirm={handleConfirmDelete}
|
||||
|
||||
@@ -321,11 +321,27 @@ export default function ClaimsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 3. create claim.
|
||||
const handleClaimSubmit = (claimData: any): Promise<Claim> => {
|
||||
return createClaimMutation.mutateAsync(claimData).then((data) => {
|
||||
// 3. create or update claim (update when claimId is present)
|
||||
const handleClaimSubmit = async (claimData: any): Promise<Claim> => {
|
||||
const { isDraft, claimId, uploadedFiles: _uf, ...cleanData } = claimData;
|
||||
|
||||
if (claimId) {
|
||||
// Update existing saved claim (PUT never creates a Payment)
|
||||
const res = await apiRequest("PUT", `/api/claims/${claimId}`, cleanData);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || "Failed to update claim");
|
||||
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
|
||||
if (!isDraft) toast({ title: "Claim updated successfully", variant: "default" });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
// New claim: draft saves skip Payment creation
|
||||
const url = isDraft ? "/api/claims/?draft=true" : "/api/claims/";
|
||||
const res = await apiRequest("POST", url, cleanData);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.message || "Failed to save claim");
|
||||
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
|
||||
return data;
|
||||
};
|
||||
|
||||
// 4. handle selenium sybmiting Mass Health claim
|
||||
@@ -579,6 +595,7 @@ export default function ClaimsPage() {
|
||||
patientId={selectedPatientId}
|
||||
appointmentId={selectedAppointmentId ?? undefined}
|
||||
autoSubmit={mode === "direct"}
|
||||
proceduresOnly={mode === "procedures"}
|
||||
onClose={closeClaim}
|
||||
onSubmit={handleClaimSubmit}
|
||||
onHandleAppointmentSubmit={handleAppointmentSubmit}
|
||||
|
||||
Reference in New Issue
Block a user