diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 68e4f7f3..e523070e 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -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") { diff --git a/apps/Backend/src/queue/processors/claimSubmitProcessor.ts b/apps/Backend/src/queue/processors/claimSubmitProcessor.ts index 7c818607..8f1105b8 100644 --- a/apps/Backend/src/queue/processors/claimSubmitProcessor.ts +++ b/apps/Backend/src/queue/processors/claimSubmitProcessor.ts @@ -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 { 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 = { 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 }; } diff --git a/apps/Backend/src/queue/workers/seleniumWorker.ts b/apps/Backend/src/queue/workers/seleniumWorker.ts index f2184a1a..7a9bc84a 100644 --- a/apps/Backend/src/queue/workers/seleniumWorker.ts +++ b/apps/Backend/src/queue/workers/seleniumWorker.ts @@ -57,6 +57,7 @@ async function processSeleniumJob(job: Job) { 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) { files: job.data.files ?? [], claimId: job.data.claimId, variant: "claim-pre-auth", + socketId, }); } else { throw new Error(`Unknown selenium jobType: ${jobType}`); diff --git a/apps/Backend/src/routes/appointments-procedures.ts b/apps/Backend/src/routes/appointments-procedures.ts index 7e980ef3..4b329f00 100755 --- a/apps/Backend/src/routes/appointments-procedures.ts +++ b/apps/Backend/src/routes/appointments-procedures.ts @@ -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 diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index eaf770bc..d58b8ea2 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -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// +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// 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 => { @@ -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 => { + 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 = []; + + 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 => { + 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 => { 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 => { 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 => { 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).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 => { // 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 => { 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).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" }); } }); diff --git a/apps/Backend/src/storage/appointment-procedures-storage.ts b/apps/Backend/src/storage/appointment-procedures-storage.ts index b2437435..62c75ef8 100755 --- a/apps/Backend/src/storage/appointment-procedures-storage.ts +++ b/apps/Backend/src/storage/appointment-procedures-storage.ts @@ -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; 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 { diff --git a/apps/Backend/src/storage/appointments-storage.ts b/apps/Backend/src/storage/appointments-storage.ts index aad4fbc3..1b95e333 100755 --- a/apps/Backend/src/storage/appointments-storage.ts +++ b/apps/Backend/src/storage/appointments-storage.ts @@ -22,6 +22,11 @@ export interface IStorage { appointment: UpdateAppointment ): Promise; deleteAppointment(id: number): Promise; + getPatientAppointmentByDateAndStaff( + patientId: number, + date: Date, + staffId: number + ): Promise; getPatientAppointmentByDateTime( patientId: number, date: Date, @@ -127,6 +132,19 @@ export const appointmentsStorage: IStorage = { } }, + async getPatientAppointmentByDateAndStaff( + patientId: number, + date: Date, + staffId: number + ): Promise { + return ( + (await db.appointment.findFirst({ + where: { patientId, date, staffId }, + orderBy: { createdAt: "desc" }, + })) ?? undefined + ); + }, + async getPatientAppointmentByDateTime( patientId: number, date: Date, diff --git a/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx index c5196c7e..d708067c 100755 --- a/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx +++ b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx @@ -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(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(null); const [editRow, setEditRow] = useState>({}); const [clearAllOpen, setClearAllOpen] = useState(false); - // for redirection to claim submission - const [, setLocation] = useLocation(); - - // ----------------------------- - // fetch procedures - // ----------------------------- - const { data: procedures = [], isLoading } = useQuery( - { - 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({ + 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({ + 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 ( { - 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(); }} > Appointment Procedures + {serviceDate && {serviceDate}} - {/* ================= COMBOS ================= */} -
- { - handleAddCombo(comboKey); - }} - /> - - { - handleAddCombo(comboKey); - }} - /> + {/* ── Rendering Provider ─────────────────────────────── */} +
+
+ + +
+ + {selectedProvider && ( + + ✓ {selectedProvider.providerName} + + )}
- {/* ================= MANUAL ADD ================= */} + {/* ── Combos ─────────────────────────────────────────── */} +
+ + +
+ + {/* ── Manual Add ─────────────────────────────────────── */}
Add Manual Procedure
-
- setManualCode(e.target.value)} - placeholder="D0120" - /> + setManualCode(e.target.value)} placeholder="D0120" />
-
- setManualLabel(e.target.value)} - placeholder="Exam" - /> + setManualLabel(e.target.value)} placeholder="Exam" />
-
- setManualFee(e.target.value)} - placeholder="100" - type="number" - /> + setManualFee(e.target.value)} placeholder="100" type="number" />
-
- setManualTooth(e.target.value)} - placeholder="14" - /> + setManualTooth(e.target.value)} placeholder="14" />
-
- setManualSurface(e.target.value)} - placeholder="MO" - /> + setManualSurface(e.target.value)} placeholder="MO" />
-
-
- {/* ================= LIST ================= */} + {/* ── Procedures List ─────────────────────────────────── */}
-
Selected Procedures
- -
- {/* ===== TABLE HEADER ===== */}
-
Code
-
Label
-
Fee
-
Tooth
-
Surface
-
Edit
-
Delete
+
Code
Label
Fee
Tooth
Surface
+
Edit
Delete
- {isLoading && ( -
- Loading... -
- )} - + {isLoading &&
Loading...
} {!isLoading && procedures.length === 0 && ( -
- No procedures added -
+
No procedures added yet
)} {procedures.map((p) => ( -
+
{editingId === p.id ? ( <> - - setEditRow({ - ...editRow, - procedureCode: e.target.value, - }) - } - /> - - setEditRow({ - ...editRow, - procedureLabel: e.target.value, - }) - } - /> - - setEditRow({ ...editRow, fee: Number(e.target.value) }) - } - /> - - - setEditRow({ - ...editRow, - toothNumber: e.target.value, - }) - } - /> - - setEditRow({ - ...editRow, - toothSurface: e.target.value, - }) - } - /> + setEditRow({ ...editRow, procedureCode: e.target.value })} /> + setEditRow({ ...editRow, procedureLabel: e.target.value })} /> + setEditRow({ ...editRow, fee: Number(e.target.value) })} /> + setEditRow({ ...editRow, toothNumber: e.target.value })} /> + setEditRow({ ...editRow, toothSurface: e.target.value })} />
- +
-
- +
) : ( <> -
- {p.procedureCode} -
-
- {p.procedureLabel} -
-
- {p.fee !== null && p.fee !== undefined - ? String(p.fee) - : ""} -
- +
{p.procedureCode}
+
{p.procedureLabel}
+
{p.fee !== null && p.fee !== undefined ? String(p.fee) : ""}
{p.toothNumber}
{p.toothSurface}
-
- +
-
-
@@ -530,7 +408,7 @@ export function AppointmentProceduresDialog({
- {/* ================= FOOTER ================= */} + {/* ── Footer ─────────────────────────────────────────── */}
-
- - +
@@ -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(); }} />
); diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 0d7732d1..a21d1395 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -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; 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(null); + const [directSubmitEnabled, setDirectSubmitEnabled] = useState(false); const [patient, setPatient] = useState(null); + // staffId from the appointment column — used for claim creation, not shown in UI + const [appointmentStaffId, setAppointmentStaffId] = useState(null); + // npiProviderId loaded from AppointmentProcedure (2b) — restored to form when npiProviders load + const [savedProcNpiId, setSavedProcNpiId] = useState(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) ?? {}, + 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 => { + 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({ /> - {/* Treating doctor */} - - - {/* Rendering Npi Provider */}