feat: add CCA claim submission with Selenium automation
- Add CCA claim submit Selenium worker (login, fill form, attach docs, submit, capture dashboard PDF) - Add CCA fee schedule (procedureCodesMH.json renamed, procedureCodesCCA.json added with D6010) - Add backend route /api/claims/cca-claim, processor, and Selenium client - Wire CCA claim handler in claims-page with job tracking and PDF preview popup - Add insurance type dropdown in claim form (same options as eligibility page) - Auto-populate insurance type from patient.insuranceProvider in claim form and patient edit form - Map fee schedule by insurance type in Map Price button and combo buttons - Fix CCA login speed (remove fixed sleeps, use readyState check) - Fix CCA claim DOB format bug (was sending MM-DD-YYYY, now sends YYYY-MM-DD) - Fix npiProviderId not saved for CCA claims - Change Add Service → CCA Claim button (blue), MH → MH Claim Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,10 @@ const DocumentPage = lazy(() => import("./pages/documents-page"));
|
||||
const DatabaseManagementPage = lazy(() => import("./pages/database-management-page"));
|
||||
const ReportsPage = lazy(() => import("./pages/reports-page"));
|
||||
const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page"));
|
||||
const JobMonitorPage = lazy(() => import("./pages/job-monitor-page"));
|
||||
const ChartPage = lazy(() => import("./pages/chart-page"));
|
||||
const DentalShoppingSearchTagPage = lazy(() => import("./pages/dental-shopping-search-tag-page"));
|
||||
const DentalShoppingLoginInfoPage = lazy(() => import("./pages/dental-shopping-login-info-page"));
|
||||
const NotFound = lazy(() => import("./pages/not-found"));
|
||||
function Router() {
|
||||
return (<Switch>
|
||||
@@ -31,14 +35,20 @@ function Router() {
|
||||
<ProtectedRoute path="/patient-connection" component={() => <PatientConnectionPage />}/>
|
||||
<ProtectedRoute path="/appointments" component={() => <AppointmentsPage />}/>
|
||||
<ProtectedRoute path="/patients" component={() => <PatientsPage />}/>
|
||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />}/>
|
||||
<ProtectedRoute path="/chart/:section" component={() => <ChartPage />}/>
|
||||
<ProtectedRoute path="/chart" component={() => <ChartPage />}/>
|
||||
<ProtectedRoute path="/settings/:section" component={() => <SettingsPage />} adminOnly/>
|
||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />} adminOnly/>
|
||||
<ProtectedRoute path="/claims" component={() => <ClaimsPage />}/>
|
||||
<ProtectedRoute path="/insurance-status" component={() => <InsuranceStatusPage />}/>
|
||||
<ProtectedRoute path="/payments" component={() => <PaymentsPage />}/>
|
||||
<ProtectedRoute path="/documents" component={() => <DocumentPage />}/>
|
||||
<ProtectedRoute path="/database-management" component={() => <DatabaseManagementPage />}/>
|
||||
<ProtectedRoute path="/database-management" component={() => <DatabaseManagementPage />} adminOnly/>
|
||||
<ProtectedRoute path="/reports" component={() => <ReportsPage />}/>
|
||||
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />}/>
|
||||
<ProtectedRoute path="/dental-shopping/search-tag" component={() => <DentalShoppingSearchTagPage />}/>
|
||||
<ProtectedRoute path="/dental-shopping/login-info" component={() => <DentalShoppingLoginInfoPage />}/>
|
||||
<ProtectedRoute path="/job-monitor" component={() => <JobMonitorPage />} adminOnly/>
|
||||
<Route path="/auth" component={() => <AuthPage />}/>
|
||||
<Route component={() => <NotFound />}/>
|
||||
</Switch>);
|
||||
|
||||
1196
apps/Frontend/src/assets/data/procedureCodesCCA.json
Normal file
1196
apps/Frontend/src/assets/data/procedureCodesCCA.json
Normal file
File diff suppressed because it is too large
Load Diff
1191
apps/Frontend/src/assets/data/procedureCodesMH.json
Executable file
1191
apps/Frontend/src/assets/data/procedureCodesMH.json
Executable file
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,7 @@ interface ClaimFormProps {
|
||||
onHandleUpdatePatient: (patient: UpdatePatient & { id: number }) => void;
|
||||
onHandleForMHSeleniumClaim: (data: ClaimFormData) => void;
|
||||
onHandleForMHSeleniumClaimPreAuth: (data: ClaimPreAuthData) => void;
|
||||
onHandleForCCASeleniumClaim: (data: ClaimFormData) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -87,6 +88,7 @@ export function ClaimForm({
|
||||
onHandleUpdatePatient,
|
||||
onHandleForMHSeleniumClaim,
|
||||
onHandleForMHSeleniumClaimPreAuth,
|
||||
onHandleForCCASeleniumClaim,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ClaimFormProps) {
|
||||
@@ -331,6 +333,7 @@ export function ClaimForm({
|
||||
missingTeethStatus: (claim.missingTeethStatus as MissingTeethStatus) ?? "No_missing",
|
||||
missingTeeth: (claim.missingTeeth as Record<string, "X" | "O">) ?? {},
|
||||
insuranceProvider: claim.insuranceProvider ?? "",
|
||||
insuranceSiteKey: claim.insuranceSiteKey || deriveInsuranceSiteKey(claim.insuranceProvider),
|
||||
...(claim.staffId ? { staffId: claim.staffId } : {}),
|
||||
claimFiles: claim.claimFiles ?? [],
|
||||
}));
|
||||
@@ -590,17 +593,41 @@ export function ClaimForm({
|
||||
uploadedFiles: [],
|
||||
});
|
||||
|
||||
// Map patient.insuranceProvider (free-text from eligibility) → insuranceSiteKey
|
||||
const deriveInsuranceSiteKey = (provider: string | null | undefined): string => {
|
||||
const p = (provider || "").toLowerCase().trim();
|
||||
if (!p) return "";
|
||||
if (p.includes("masshealth") || p === "mh" || p === "mass health") return "MH";
|
||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
||||
if (p.includes("ddma")) return "DDMA";
|
||||
if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
||||
if (p.includes("united sco") || p === "unitedsco") return "UnitedSCO";
|
||||
if (p.includes("cmsp")) return "CMSP";
|
||||
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
|
||||
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
|
||||
if (p.includes("aetna")) return "Aetna";
|
||||
if (p.includes("altus")) return "Altus";
|
||||
if (p.includes("metlife")) return "MetlifeDental";
|
||||
if (p.includes("cigna")) return "Cigna";
|
||||
if (p.includes("delta wa") || p === "deltawa") return "DeltaWA";
|
||||
if (p.includes("delta il") || p === "deltail") return "DeltaIL";
|
||||
return "";
|
||||
};
|
||||
|
||||
// Sync patient data to form when patient updates
|
||||
useEffect(() => {
|
||||
if (patient) {
|
||||
const fullName =
|
||||
`${patient.firstName || ""} ${patient.lastName || ""}`.trim();
|
||||
const siteKey = deriveInsuranceSiteKey(patient.insuranceProvider);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
patientId: Number(patient.id),
|
||||
patientName: fullName,
|
||||
dateOfBirth: normalizeToIsoDateString(patient.dateOfBirth),
|
||||
memberId: patient.insuranceId || "",
|
||||
...(siteKey ? { insuranceSiteKey: siteKey } : {}),
|
||||
}));
|
||||
}
|
||||
}, [patient]);
|
||||
@@ -674,12 +701,13 @@ export function ClaimForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Map Price function
|
||||
// Map Price function — uses the fee schedule for the selected insurance type
|
||||
const onMapPrice = () => {
|
||||
setForm((prev) =>
|
||||
mapPricesForForm({
|
||||
form: prev,
|
||||
patientDOB: patient?.dateOfBirth ?? "",
|
||||
insuranceSiteKey: prev.insuranceSiteKey,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -867,15 +895,12 @@ export function ClaimForm({
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 3nd Button workflow - Only Creates Data, patient, appointmetn, claim, payment, not actually submits claim to MH site.
|
||||
const handleAddService = async () => {
|
||||
// 0. Validate required fields
|
||||
// 3rd Button workflow — CCA Claim: saves to DB then submits via Selenium
|
||||
const handleCCAClaim = async () => {
|
||||
const missingFields: string[] = [];
|
||||
|
||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
||||
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
||||
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
toast({
|
||||
title: "Missing Required Fields",
|
||||
@@ -885,31 +910,26 @@ export function ClaimForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// require at least one procedure code before proceeding
|
||||
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 submitting the claim.",
|
||||
description: "Please add at least one procedure code before submitting the claim.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Create or update appointment
|
||||
// Create appointment if needed
|
||||
let appointmentIdToUse = appointmentId;
|
||||
|
||||
if (appointmentIdToUse == null) {
|
||||
const appointmentData = {
|
||||
patientId: patientId,
|
||||
const created = await onHandleAppointmentSubmit({
|
||||
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") {
|
||||
@@ -917,27 +937,40 @@ export function ClaimForm({
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create Claim(if not)
|
||||
// Filter out empty service lines (empty procedureCode)
|
||||
const { uploadedFiles, insuranceSiteKey, npiProvider: _npi, ...formToCreateClaim } = form;
|
||||
|
||||
// build claimFiles metadata from uploadedFiles (only filename + mimeType)
|
||||
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
|
||||
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
|
||||
filename: f.name,
|
||||
mimeType: f.type,
|
||||
}));
|
||||
|
||||
const selectedNpiProviderId = npiProvider?.npiNumber
|
||||
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
|
||||
: null;
|
||||
|
||||
// Save claim to DB
|
||||
const createdClaim = await onSubmit({
|
||||
...formToCreateClaim,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId: patientId,
|
||||
insuranceProvider: "MassHealth",
|
||||
patientId,
|
||||
insuranceProvider: "CCA",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
claimFiles: claimFilesMeta,
|
||||
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
|
||||
});
|
||||
|
||||
// Send to CCA Selenium — send raw YYYY-MM-DD so Python _format_dob converts correctly
|
||||
onHandleForCCASeleniumClaim({
|
||||
...form,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId,
|
||||
insuranceProvider: "CCA",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
insuranceSiteKey: "CCA",
|
||||
claimId: createdClaim.id,
|
||||
});
|
||||
|
||||
// 4. Close form
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -1186,6 +1219,7 @@ export function ClaimForm({
|
||||
comboId,
|
||||
patient?.dateOfBirth ?? "",
|
||||
{ replaceAll: false, lineDate: form.serviceDate },
|
||||
form.insuranceSiteKey,
|
||||
);
|
||||
|
||||
setForm(nextForm);
|
||||
@@ -1337,6 +1371,35 @@ export function ClaimForm({
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-end items-center mb-4">
|
||||
<div className="flex gap-2">
|
||||
<Label className="flex items-center">Insurance Type</Label>
|
||||
<Select
|
||||
value={form.insuranceSiteKey || ""}
|
||||
onValueChange={(val) =>
|
||||
setForm((prev) => ({ ...prev, insuranceSiteKey: val }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-44 mr-4">
|
||||
<SelectValue placeholder="Select Insurance" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MH">MassHealth</SelectItem>
|
||||
<SelectItem value="CCA">CCA</SelectItem>
|
||||
<SelectItem value="DDMA">DDMA</SelectItem>
|
||||
<SelectItem value="DeltaIns">Delta Ins</SelectItem>
|
||||
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
|
||||
<SelectItem value="UnitedSCO">United SCO</SelectItem>
|
||||
<SelectItem value="CMSP">CMSP</SelectItem>
|
||||
<SelectItem value="BCBS">BCBS</SelectItem>
|
||||
<SelectItem value="UnitedAAPR">United AAPR</SelectItem>
|
||||
<SelectItem value="Aetna">Aetna</SelectItem>
|
||||
<SelectItem value="Altus">Altus</SelectItem>
|
||||
<SelectItem value="MetlifeDental">Metlife Dental</SelectItem>
|
||||
<SelectItem value="Cigna">Cigna</SelectItem>
|
||||
<SelectItem value="DeltaWA">Delta WA</SelectItem>
|
||||
<SelectItem value="DeltaIL">Delta IL</SelectItem>
|
||||
<SelectItem value="Others">Others</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="flex items-center">Service Date</Label>
|
||||
<Popover
|
||||
open={serviceDateOpen}
|
||||
@@ -1421,6 +1484,7 @@ export function ClaimForm({
|
||||
comboKey as any,
|
||||
patient?.dateOfBirth ?? "",
|
||||
{ replaceAll: false, lineDate: prev.serviceDate },
|
||||
prev.insuranceSiteKey,
|
||||
);
|
||||
setTimeout(() => scrollToLine(0), 0);
|
||||
return next;
|
||||
@@ -1765,14 +1829,13 @@ export function ClaimForm({
|
||||
className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={() => handleMHSubmit()}
|
||||
>
|
||||
MH
|
||||
MH Claim
|
||||
</Button>
|
||||
<Button
|
||||
className="w-32"
|
||||
variant="secondary"
|
||||
onClick={handleAddService}
|
||||
className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={handleCCAClaim}
|
||||
>
|
||||
Add Service
|
||||
CCA Claim
|
||||
</Button>
|
||||
<Button className="w-32" variant="outline">
|
||||
Delta MA
|
||||
@@ -1862,6 +1925,35 @@ export function ClaimForm({
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-end items-center mb-4">
|
||||
<div className="flex gap-2">
|
||||
<Label className="flex items-center">Insurance Type</Label>
|
||||
<Select
|
||||
value={form.insuranceSiteKey || ""}
|
||||
onValueChange={(val) =>
|
||||
setForm((prev) => ({ ...prev, insuranceSiteKey: val }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-44 mr-4">
|
||||
<SelectValue placeholder="Select Insurance" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MH">MassHealth</SelectItem>
|
||||
<SelectItem value="CCA">CCA</SelectItem>
|
||||
<SelectItem value="DDMA">DDMA</SelectItem>
|
||||
<SelectItem value="DeltaIns">Delta Ins</SelectItem>
|
||||
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
|
||||
<SelectItem value="UnitedSCO">United SCO</SelectItem>
|
||||
<SelectItem value="CMSP">CMSP</SelectItem>
|
||||
<SelectItem value="BCBS">BCBS</SelectItem>
|
||||
<SelectItem value="UnitedAAPR">United AAPR</SelectItem>
|
||||
<SelectItem value="Aetna">Aetna</SelectItem>
|
||||
<SelectItem value="Altus">Altus</SelectItem>
|
||||
<SelectItem value="MetlifeDental">Metlife Dental</SelectItem>
|
||||
<SelectItem value="Cigna">Cigna</SelectItem>
|
||||
<SelectItem value="DeltaWA">Delta WA</SelectItem>
|
||||
<SelectItem value="DeltaIL">Delta IL</SelectItem>
|
||||
<SelectItem value="Others">Others</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="flex items-center">Service Date</Label>
|
||||
<Popover
|
||||
open={serviceDateOpen}
|
||||
|
||||
328
apps/Frontend/src/components/patients/patient-form.jsx
Normal file
328
apps/Frontend/src/components/patients/patient-form.jsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import { insertPatientSchema, patientStatusOptions, updatePatientSchema, } from "@repo/db/types";
|
||||
import { z } from "zod";
|
||||
import { DateInputField } from "@/components/ui/dateInputField";
|
||||
export const PatientForm = forwardRef(({ patient, extractedInfo, onSubmit }, ref) => {
|
||||
const { user } = useAuth();
|
||||
const isEditing = !!patient;
|
||||
const schema = useMemo(() => isEditing
|
||||
? updatePatientSchema
|
||||
: insertPatientSchema.extend({ userId: z.number().optional() }), [isEditing]);
|
||||
const normalizeInsuranceProvider = (val) => {
|
||||
const p = (val || "").toLowerCase().trim();
|
||||
if (p.includes("masshealth") || p === "mh" || p === "mass health") return "MassHealth";
|
||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
||||
if (p.includes("ddma")) return "DDMA";
|
||||
if (p.includes("delta dental") || p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
||||
if (p.includes("united sco") || p === "unitedsco") return "UnitedSCO";
|
||||
if (p.includes("cmsp")) return "CMSP";
|
||||
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
|
||||
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
|
||||
if (p.includes("aetna")) return "Aetna";
|
||||
if (p.includes("altus")) return "Altus";
|
||||
if (p.includes("metlife")) return "MetlifeDental";
|
||||
if (p.includes("cigna")) return "Cigna";
|
||||
if (p.includes("delta wa") || p === "deltawa") return "DeltaWA";
|
||||
if (p.includes("delta il") || p === "deltail") return "DeltaIL";
|
||||
if (p.includes("other")) return "Others";
|
||||
return val || "";
|
||||
};
|
||||
const computedDefaultValues = useMemo(() => {
|
||||
if (isEditing && patient) {
|
||||
const { id, userId, createdAt, ...sanitizedPatient } = patient;
|
||||
return {
|
||||
...sanitizedPatient,
|
||||
dateOfBirth: patient.dateOfBirth
|
||||
? formatLocalDate(new Date(patient.dateOfBirth))
|
||||
: null,
|
||||
insuranceProvider: normalizeInsuranceProvider(patient.insuranceProvider),
|
||||
};
|
||||
}
|
||||
return {
|
||||
firstName: extractedInfo?.firstName || "",
|
||||
lastName: extractedInfo?.lastName || "",
|
||||
dateOfBirth: extractedInfo?.dateOfBirth || null,
|
||||
gender: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
city: "",
|
||||
zipCode: "",
|
||||
insuranceProvider: "",
|
||||
insuranceId: extractedInfo?.insuranceId || "",
|
||||
groupNumber: "",
|
||||
policyHolder: "",
|
||||
allergies: "",
|
||||
medicalConditions: "",
|
||||
preferredLanguage: "English",
|
||||
status: "UNKNOWN",
|
||||
userId: user?.id,
|
||||
};
|
||||
}, [isEditing, patient, extractedInfo, user?.id]);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: computedDefaultValues,
|
||||
});
|
||||
useImperativeHandle(ref, () => ({
|
||||
submit() {
|
||||
document.getElementById("patient-form")?.requestSubmit();
|
||||
},
|
||||
}));
|
||||
// Debug form errors
|
||||
useEffect(() => {
|
||||
const errors = form.formState.errors;
|
||||
if (Object.keys(errors).length > 0) {
|
||||
console.log("❌ Form validation errors:", errors);
|
||||
}
|
||||
}, [form.formState.errors]);
|
||||
useEffect(() => {
|
||||
if (patient) {
|
||||
const { id, userId, createdAt, ...sanitizedPatient } = patient;
|
||||
const resetValues = {
|
||||
...sanitizedPatient,
|
||||
dateOfBirth: patient.dateOfBirth
|
||||
? formatLocalDate(new Date(patient.dateOfBirth))
|
||||
: null,
|
||||
insuranceProvider: normalizeInsuranceProvider(patient.insuranceProvider),
|
||||
};
|
||||
const normalized = normalizeInsuranceProvider(patient.insuranceProvider);
|
||||
form.reset(resetValues);
|
||||
form.setValue("insuranceProvider", normalized);
|
||||
}
|
||||
else {
|
||||
form.reset(computedDefaultValues);
|
||||
}
|
||||
}, [patient, computedDefaultValues, form]);
|
||||
const handleSubmit2 = (data) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
return (<Form {...form}>
|
||||
<form id="patient-form" key={patient?.id || "new"} noValidate onSubmit={form.handleSubmit((data) => {
|
||||
handleSubmit2(data);
|
||||
})} className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Personal Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField control={form.control} name="firstName" render={({ field }) => (<FormItem>
|
||||
<FormLabel>First Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<FormField control={form.control} name="lastName" render={({ field }) => (<FormItem>
|
||||
<FormLabel>Last Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<DateInputField control={form.control} name="dateOfBirth" label="Date of Birth *" disableFuture/>
|
||||
|
||||
<FormField control={form.control} name="gender" render={({ field }) => (<FormItem>
|
||||
<FormLabel>Gender</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select gender"/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
<SelectItem value="female">Female</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<FormField control={form.control} name="preferredLanguage" render={({ field }) => (<FormItem>
|
||||
<FormLabel>Preferred Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value || "English"}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select language"/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="English">English</SelectItem>
|
||||
<SelectItem value="Spanish">Spanish</SelectItem>
|
||||
<SelectItem value="Portuguese">Portuguese</SelectItem>
|
||||
<SelectItem value="Mandarin">Mandarin</SelectItem>
|
||||
<SelectItem value="Cantonese">Cantonese</SelectItem>
|
||||
<SelectItem value="Arabic">Arabic</SelectItem>
|
||||
<SelectItem value="Haitian Creole">Haitian Creole</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Contact Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField control={form.control} name="phone" render={({ field }) => {
|
||||
const display = field.value?.startsWith("1")
|
||||
? field.value.slice(1)
|
||||
: field.value || "";
|
||||
return (<FormItem>
|
||||
<FormLabel>Phone Number *</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-input bg-muted text-muted-foreground text-sm">
|
||||
+1
|
||||
</span>
|
||||
<Input className="rounded-l-none" placeholder="6175551234" value={display} onChange={(e) => {
|
||||
const digits = e.target.value.replace(/\D/g, "").slice(0, 10);
|
||||
field.onChange(digits ? "1" + digits : "");
|
||||
}} onBlur={field.onBlur} name={field.name} ref={field.ref}/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>);
|
||||
}}/>
|
||||
|
||||
<FormField control={form.control} name="email" render={({ field }) => (<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} value={field.value || ""}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<FormField control={form.control} name="address" render={({ field }) => (<FormItem className="md:col-span-2">
|
||||
<FormLabel>Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<FormField control={form.control} name="city" render={({ field }) => (<FormItem>
|
||||
<FormLabel>City</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<FormField control={form.control} name="zipCode" render={({ field }) => (<FormItem>
|
||||
<FormLabel>ZIP Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insurance Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Insurance Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField control={form.control} name="status" render={({ field }) => {
|
||||
const options = Object.values(patientStatusOptions); // ['ACTIVE','INACTIVE','UNKNOWN']
|
||||
const toLabel = (v) => {
|
||||
if (v === "PLAN_NOT_ACCEPTED")
|
||||
return "Plan Not Accepted";
|
||||
return v[0] + v.slice(1).toLowerCase();
|
||||
};
|
||||
return (<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select value={field.value ?? "UNKNOWN"} onValueChange={(v) => field.onChange(v)}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status"/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{options.map((v) => (<SelectItem key={v} value={v}>
|
||||
{toLabel(v)}
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>);
|
||||
}}/>
|
||||
|
||||
<FormField control={form.control} name="insuranceProvider" render={({ field }) => (<FormItem>
|
||||
<FormLabel>Insurance Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value || ""}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select insurance type"/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="MassHealth">MassHealth</SelectItem>
|
||||
<SelectItem value="CCA">CCA</SelectItem>
|
||||
<SelectItem value="DDMA">DDMA</SelectItem>
|
||||
<SelectItem value="DeltaIns">Delta Ins</SelectItem>
|
||||
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
|
||||
<SelectItem value="UnitedSCO">United SCO</SelectItem>
|
||||
<SelectItem value="CMSP">CMSP</SelectItem>
|
||||
<SelectItem value="BCBS">BCBS</SelectItem>
|
||||
<SelectItem value="UnitedAAPR">United AAPR</SelectItem>
|
||||
<SelectItem value="Aetna">Aetna</SelectItem>
|
||||
<SelectItem value="Altus">Altus</SelectItem>
|
||||
<SelectItem value="MetlifeDental">Metlife Dental</SelectItem>
|
||||
<SelectItem value="Cigna">Cigna</SelectItem>
|
||||
<SelectItem value="DeltaWA">Delta WA</SelectItem>
|
||||
<SelectItem value="DeltaIL">Delta IL</SelectItem>
|
||||
<SelectItem value="Others">Others</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<FormField control={form.control} name="insuranceId" render={({ field }) => (<FormItem>
|
||||
<FormLabel>Insurance ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={String(field.value) || ""}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<FormField control={form.control} name="groupNumber" render={({ field }) => (<FormItem>
|
||||
<FormLabel>Group Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
|
||||
<FormField control={form.control} name="policyHolder" render={({ field }) => (<FormItem>
|
||||
<FormLabel>Policy Holder (if not self)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>)}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden submit button for form validation */}
|
||||
<button type="submit" className="hidden" aria-hidden="true"></button>
|
||||
</form>
|
||||
</Form>);
|
||||
});
|
||||
@@ -60,6 +60,27 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
|
||||
[isEditing],
|
||||
);
|
||||
|
||||
const normalizeInsuranceProvider = (val: string | null | undefined): string => {
|
||||
const p = (val || "").toLowerCase().trim();
|
||||
if (p.includes("masshealth") || p === "mh" || p === "mass health") return "MassHealth";
|
||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
||||
if (p.includes("ddma")) return "DDMA";
|
||||
if (p.includes("delta dental") || p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
||||
if (p.includes("united sco") || p === "unitedsco") return "UnitedSCO";
|
||||
if (p.includes("cmsp")) return "CMSP";
|
||||
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
|
||||
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
|
||||
if (p.includes("aetna")) return "Aetna";
|
||||
if (p.includes("altus")) return "Altus";
|
||||
if (p.includes("metlife")) return "MetlifeDental";
|
||||
if (p.includes("cigna")) return "Cigna";
|
||||
if (p.includes("delta wa") || p === "deltawa") return "DeltaWA";
|
||||
if (p.includes("delta il") || p === "deltail") return "DeltaIL";
|
||||
if (p.includes("other")) return "Others";
|
||||
return val || "";
|
||||
};
|
||||
|
||||
const computedDefaultValues = useMemo(() => {
|
||||
if (isEditing && patient) {
|
||||
const { id, userId, createdAt, ...sanitizedPatient } = patient;
|
||||
@@ -68,6 +89,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
|
||||
dateOfBirth: patient.dateOfBirth
|
||||
? formatLocalDate(new Date(patient.dateOfBirth))
|
||||
: null,
|
||||
insuranceProvider: normalizeInsuranceProvider(patient.insuranceProvider),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -116,13 +138,17 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
|
||||
useEffect(() => {
|
||||
if (patient) {
|
||||
const { id, userId, createdAt, ...sanitizedPatient } = patient;
|
||||
const normalized = normalizeInsuranceProvider(patient.insuranceProvider);
|
||||
const resetValues: Partial<Patient> = {
|
||||
...sanitizedPatient,
|
||||
dateOfBirth: patient.dateOfBirth
|
||||
? formatLocalDate(new Date(patient.dateOfBirth))
|
||||
: null,
|
||||
insuranceProvider: normalized,
|
||||
};
|
||||
form.reset(resetValues);
|
||||
// Explicit setValue ensures the controlled Select re-renders with the new value
|
||||
form.setValue("insuranceProvider" as any, normalized);
|
||||
} else {
|
||||
form.reset(computedDefaultValues);
|
||||
}
|
||||
@@ -396,27 +422,33 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
|
||||
name="insuranceProvider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Insurance Provider</FormLabel>
|
||||
<FormLabel>Insurance Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={(field.value as string) || ""}
|
||||
value={(field.value as string) || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
<SelectValue placeholder="Select insurance type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="placeholder">
|
||||
Select provider
|
||||
</SelectItem>
|
||||
<SelectItem value="Mass Health">Mass Health</SelectItem>
|
||||
<SelectItem value="Delta MA">Delta MA</SelectItem>
|
||||
<SelectItem value="Metlife">MetLife</SelectItem>
|
||||
<SelectItem value="Cigna">Cigna</SelectItem>
|
||||
<SelectItem value="MassHealth">MassHealth</SelectItem>
|
||||
<SelectItem value="CCA">CCA</SelectItem>
|
||||
<SelectItem value="DDMA">DDMA</SelectItem>
|
||||
<SelectItem value="DeltaIns">Delta Ins</SelectItem>
|
||||
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
|
||||
<SelectItem value="UnitedSCO">United SCO</SelectItem>
|
||||
<SelectItem value="CMSP">CMSP</SelectItem>
|
||||
<SelectItem value="BCBS">BCBS</SelectItem>
|
||||
<SelectItem value="UnitedAAPR">United AAPR</SelectItem>
|
||||
<SelectItem value="Aetna">Aetna</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="Altus">Altus</SelectItem>
|
||||
<SelectItem value="MetlifeDental">Metlife Dental</SelectItem>
|
||||
<SelectItem value="Cigna">Cigna</SelectItem>
|
||||
<SelectItem value="DeltaWA">Delta WA</SelectItem>
|
||||
<SelectItem value="DeltaIL">Delta IL</SelectItem>
|
||||
<SelectItem value="Others">Others</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
|
||||
@@ -3,11 +3,11 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL_BACKEND ?? "";
|
||||
// Upload a document for a patient
|
||||
export const uploadDocument = async (patientId, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('patientId', patientId.toString());
|
||||
formData.append("file", file);
|
||||
formData.append("patientId", patientId.toString());
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(`${API_BASE_URL}/api/patient-documents/upload`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
@@ -22,12 +22,15 @@ export const uploadDocument = async (patientId, file) => {
|
||||
};
|
||||
// Get all documents for a patient
|
||||
export const getPatientDocuments = async (patientId, limit, offset) => {
|
||||
const url = new URL(`${API_BASE_URL}/api/patient-documents/patient/${patientId}`);
|
||||
const urlPath = `/api/patient-documents/patient/${patientId}`;
|
||||
const url = API_BASE_URL
|
||||
? new URL(`${API_BASE_URL}${urlPath}`)
|
||||
: new URL(urlPath, window.location.origin);
|
||||
if (limit !== undefined) {
|
||||
url.searchParams.append('limit', limit.toString());
|
||||
url.searchParams.append("limit", limit.toString());
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
url.searchParams.append('offset', offset.toString());
|
||||
url.searchParams.append("offset", offset.toString());
|
||||
}
|
||||
const token = localStorage.getItem("token");
|
||||
const headers = {
|
||||
@@ -45,11 +48,17 @@ export const getPatientDocuments = async (patientId, limit, offset) => {
|
||||
};
|
||||
// View a document (inline display)
|
||||
export const viewDocument = (documentId) => {
|
||||
return `${API_BASE_URL}/api/patient-documents/${documentId}/view`;
|
||||
if (API_BASE_URL) {
|
||||
return `${API_BASE_URL}/api/patient-documents/${documentId}/view`;
|
||||
}
|
||||
return `/api/patient-documents/${documentId}/view`;
|
||||
};
|
||||
// Download a document
|
||||
export const downloadDocument = (documentId) => {
|
||||
return `${API_BASE_URL}/api/patient-documents/${documentId}/download`;
|
||||
if (API_BASE_URL) {
|
||||
return `${API_BASE_URL}/api/patient-documents/${documentId}/download`;
|
||||
}
|
||||
return `/api/patient-documents/${documentId}/download`;
|
||||
};
|
||||
// Delete a document
|
||||
export const deleteDocument = async (documentId) => {
|
||||
@@ -74,21 +83,21 @@ export const scanDocument = async (patientId) => {
|
||||
// Helper function to format file size
|
||||
export const formatFileSize = (bytes) => {
|
||||
if (bytes === 0)
|
||||
return '0 Bytes';
|
||||
return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
};
|
||||
// Helper function to get file icon based on MIME type
|
||||
export const getFileIcon = (mimeType) => {
|
||||
if (mimeType.startsWith('image/'))
|
||||
return '🖼️';
|
||||
if (mimeType === 'application/pdf')
|
||||
return '📄';
|
||||
if (mimeType.includes('word') || mimeType.includes('document'))
|
||||
return '📝';
|
||||
if (mimeType.includes('text'))
|
||||
return '📄';
|
||||
return '📎';
|
||||
if (mimeType.startsWith("image/"))
|
||||
return "🖼️";
|
||||
if (mimeType === "application/pdf")
|
||||
return "📄";
|
||||
if (mimeType.includes("word") || mimeType.includes("document"))
|
||||
return "📝";
|
||||
if (mimeType.includes("text"))
|
||||
return "📄";
|
||||
return "📎";
|
||||
};
|
||||
|
||||
@@ -1978,6 +1978,7 @@ export default function AppointmentsPage() {
|
||||
onHandleUpdatePatient={(_patient: UpdatePatient & { id: number }) => {}}
|
||||
onHandleForMHSeleniumClaim={() => {}}
|
||||
onHandleForMHSeleniumClaimPreAuth={() => {}}
|
||||
onHandleForCCASeleniumClaim={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -145,12 +145,28 @@ export default function ClaimsPage() {
|
||||
if (!pendingClaimJobId) return;
|
||||
if (jobStatus === "completed" && jobResult) {
|
||||
setPendingClaimJobId(null);
|
||||
handleMHSeleniumPdfDownload(
|
||||
jobResult,
|
||||
pendingClaimMeta.current.patientId,
|
||||
pendingClaimMeta.current.groupKey
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["claims-recent"] });
|
||||
|
||||
// CCA result: pdfFileId is already saved by the processor — open preview directly
|
||||
if (jobResult.pdfFileId) {
|
||||
setPreviewPdfId(jobResult.pdfFileId);
|
||||
setPreviewFallbackFilename(jobResult.pdfFilename ?? `cca_claim_${jobResult.claimNumber ?? "unknown"}.pdf`);
|
||||
setPreviewOpen(true);
|
||||
dispatch(setTaskStatus({
|
||||
key: "claimSubmit",
|
||||
status: "success",
|
||||
message: jobResult.claimNumber
|
||||
? `CCA claim submitted — Encounter ID: ${jobResult.claimNumber}`
|
||||
: "CCA claim submitted successfully",
|
||||
}));
|
||||
} else {
|
||||
// MH claim: needs PDF download step
|
||||
handleMHSeleniumPdfDownload(
|
||||
jobResult,
|
||||
pendingClaimMeta.current.patientId,
|
||||
pendingClaimMeta.current.groupKey
|
||||
);
|
||||
}
|
||||
} else if (jobStatus === "failed") {
|
||||
setPendingClaimJobId(null);
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "error", message: "Selenium job failed" }));
|
||||
@@ -409,6 +425,35 @@ export default function ClaimsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// CCA claim selenium handler
|
||||
const handleCCAClaimSubmitSelenium = async (data: any) => {
|
||||
const formData = new FormData();
|
||||
formData.append("data", JSON.stringify(data));
|
||||
if (socketId) formData.append("socketId", socketId);
|
||||
const uploadedFiles: File[] = data.uploadedFiles ?? [];
|
||||
uploadedFiles.forEach((file: File) => {
|
||||
if (file.type === "application/pdf") {
|
||||
formData.append("pdfs", file);
|
||||
} else if (file.type.startsWith("image/")) {
|
||||
formData.append("images", file);
|
||||
}
|
||||
});
|
||||
try {
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "Submitting CCA claim..." }));
|
||||
const response = await apiRequest("POST", "/api/claims/cca-claim", formData);
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
// Track job so the completion useEffect fires when Selenium finishes
|
||||
pendingClaimMeta.current = { patientId: selectedPatientId, groupKey: "INSURANCE_CLAIM" };
|
||||
setPendingClaimJobId(result.jobId);
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "CCA claim queued. Awaiting Selenium..." }));
|
||||
toast({ title: "CCA Claim queued", description: "Selenium is processing the claim.", variant: "default" });
|
||||
} catch (error: any) {
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "error", message: error.message || "CCA claim failed" }));
|
||||
toast({ title: "CCA Claim error", description: error.message || "An error occurred.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
// 5. selenium pdf download handler
|
||||
const handleMHSeleniumPdfDownload = async (
|
||||
data: any,
|
||||
@@ -620,6 +665,7 @@ export default function ClaimsPage() {
|
||||
onHandleUpdatePatient={handleUpdatePatient}
|
||||
onHandleForMHSeleniumClaim={handleMHClaimSubmitSelenium}
|
||||
onHandleForMHSeleniumClaimPreAuth={handleMHClaimPreAuthSubmitSelenium}
|
||||
onHandleForCCASeleniumClaim={handleCCAClaimSubmitSelenium}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
241
apps/Frontend/src/utils/procedureCombosMapping.js
Normal file
241
apps/Frontend/src/utils/procedureCombosMapping.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import Decimal from "decimal.js";
|
||||
import rawCodeTable from "@/assets/data/procedureCodesMH.json";
|
||||
import { PROCEDURE_COMBOS } from "./procedureCombos";
|
||||
const CODE_TABLE = rawCodeTable;
|
||||
/* ----------------------------- Helpers ----------------------------- */
|
||||
export const COMBO_BUTTONS = Object.values(PROCEDURE_COMBOS).map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
}));
|
||||
// Build a fast lookup map keyed by normalized code
|
||||
const normalizeCode = (code) => code.replace(/\s+/g, "").toUpperCase();
|
||||
const CODE_MAP = (() => {
|
||||
const m = new Map();
|
||||
for (const r of CODE_TABLE) {
|
||||
const k = normalizeCode(String(r["Procedure Code"] || ""));
|
||||
if (k && !m.has(k))
|
||||
m.set(k, r);
|
||||
}
|
||||
return m;
|
||||
})();
|
||||
// this function is solely for abbrevations feature in claim-form
|
||||
export function getDescriptionForCode(code) {
|
||||
if (!code)
|
||||
return undefined;
|
||||
const row = CODE_MAP.get(normalizeCode(code));
|
||||
return row?.Description;
|
||||
}
|
||||
const isBlankPrice = (v) => {
|
||||
if (v == null)
|
||||
return true;
|
||||
const s = String(v).trim().toUpperCase();
|
||||
return s === "" || s === "IC" || s === "NC";
|
||||
};
|
||||
const toDecimalOrZero = (v) => {
|
||||
if (isBlankPrice(v))
|
||||
return new Decimal(0);
|
||||
const n = typeof v === "string" ? parseFloat(v) : v;
|
||||
return new Decimal(Number.isFinite(n) ? n : 0);
|
||||
};
|
||||
const parseDate = (d) => {
|
||||
if (d instanceof Date)
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const s = String(d).trim();
|
||||
// MM/DD/YYYY
|
||||
const mdy = /^(\d{2})\/(\d{2})\/(\d{4})$/;
|
||||
const m1 = mdy.exec(s);
|
||||
if (m1) {
|
||||
const mm = Number(m1[1]);
|
||||
const dd = Number(m1[2]);
|
||||
const yyyy = Number(m1[3]);
|
||||
return new Date(yyyy, mm - 1, dd);
|
||||
}
|
||||
// YYYY-MM-DD
|
||||
const ymd = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const m2 = ymd.exec(s);
|
||||
if (m2) {
|
||||
const yyyy = Number(m2[1]);
|
||||
const mm = Number(m2[2]);
|
||||
const dd = Number(m2[3]);
|
||||
return new Date(yyyy, mm - 1, dd);
|
||||
}
|
||||
// Fallback
|
||||
return new Date(s);
|
||||
};
|
||||
const ageOnDate = (dob, on) => {
|
||||
const birth = parseDate(dob);
|
||||
const ref = parseDate(on);
|
||||
let age = ref.getFullYear() - birth.getFullYear();
|
||||
const hadBirthday = ref.getMonth() > birth.getMonth() ||
|
||||
(ref.getMonth() === birth.getMonth() && ref.getDate() >= birth.getDate());
|
||||
if (!hadBirthday)
|
||||
age -= 1;
|
||||
return age;
|
||||
};
|
||||
/**
|
||||
* we can implement per-code age buckets without changing the JSON.
|
||||
*
|
||||
* Behavior:
|
||||
* - Default: same as before: age <= 21 -> PriceLTEQ21, else PriceGT21
|
||||
* - Fallback to Price if tiered field is blank/IC/NC
|
||||
* - Special-cases D1110 and D1120 according to MH rules
|
||||
*/
|
||||
export function pickPriceForRowByAge(row, age, normalizedCode) {
|
||||
// Special-case rules (add more codes here if needed)
|
||||
if (normalizedCode) {
|
||||
// D1110: only valid for age >=14 (14..21 => PriceLTEQ21, >21 => PriceGT21)
|
||||
if (normalizedCode === "D1110") {
|
||||
if (age < 14) {
|
||||
// D1110 not applicable to children <14 (those belong to D1120)
|
||||
return new Decimal(0);
|
||||
}
|
||||
if (age >= 14 && age <= 21) {
|
||||
// use PriceLTEQ21 only if present
|
||||
if (!isBlankPrice(row.PriceLTEQ21))
|
||||
return toDecimalOrZero(row.PriceLTEQ21);
|
||||
return new Decimal(0);
|
||||
}
|
||||
// age > 21
|
||||
if (!isBlankPrice(row.PriceGT21))
|
||||
return toDecimalOrZero(row.PriceGT21);
|
||||
return new Decimal(0);
|
||||
}
|
||||
// D1120: child 0-13 => PriceLTEQ21, otherwise no price (NC)
|
||||
if (normalizedCode === "D1120") {
|
||||
if (age < 14) {
|
||||
if (!isBlankPrice(row.PriceLTEQ21))
|
||||
return toDecimalOrZero(row.PriceLTEQ21);
|
||||
return new Decimal(0);
|
||||
}
|
||||
// age >= 14 => NC / no price
|
||||
return new Decimal(0);
|
||||
}
|
||||
}
|
||||
// Generic/default behavior (unchanged)
|
||||
if (age <= 21) {
|
||||
if (!isBlankPrice(row.PriceLTEQ21))
|
||||
return toDecimalOrZero(row.PriceLTEQ21);
|
||||
}
|
||||
else {
|
||||
if (!isBlankPrice(row.PriceGT21))
|
||||
return toDecimalOrZero(row.PriceGT21);
|
||||
}
|
||||
// Fallback to Price if tiered not available/blank
|
||||
if (!isBlankPrice(row.Price))
|
||||
return toDecimalOrZero(row.Price);
|
||||
return new Decimal(0);
|
||||
}
|
||||
/**
|
||||
* Gets price for a code using age & code table.
|
||||
*/
|
||||
function getPriceForCodeWithAgeFromMap(map, code, age) {
|
||||
const norm = normalizeCode(code);
|
||||
const row = map.get(norm);
|
||||
return row ? pickPriceForRowByAge(row, age, norm) : new Decimal(0);
|
||||
}
|
||||
// helper keeping lines empty,
|
||||
export const makeEmptyLine = (lineDate) => ({
|
||||
procedureCode: "",
|
||||
procedureDate: lineDate,
|
||||
quad: "",
|
||||
arch: "",
|
||||
toothNumber: "",
|
||||
toothSurface: "",
|
||||
totalBilled: new Decimal(0),
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalPaid: new Decimal(0),
|
||||
});
|
||||
// Ensure the array has at least `min` lines; append blank ones if needed.
|
||||
const ensureCapacity = (lines, min, lineDate) => {
|
||||
while (lines.length < min) {
|
||||
lines.push(makeEmptyLine(lineDate));
|
||||
}
|
||||
};
|
||||
/* ------------------------- Main entry points ------------------------- */
|
||||
/**
|
||||
* Map prices for ALL existing lines in a form (your "Map Price" button),
|
||||
* using patient's DOB and the form's serviceDate (or per-line procedureDate).
|
||||
* Returns a NEW form object (immutable).
|
||||
*/
|
||||
export function mapPricesForForm(params) {
|
||||
const { form, patientDOB } = params;
|
||||
return {
|
||||
...form,
|
||||
serviceLines: form.serviceLines.map((ln) => {
|
||||
const age = ageOnDate(patientDOB, form.serviceDate);
|
||||
const code = normalizeCode(ln.procedureCode || "");
|
||||
if (!code)
|
||||
return { ...ln };
|
||||
const price = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||
return { ...ln, procedureCode: code, totalBilled: price };
|
||||
}),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Apply a preset combo (fills codes & prices) using patientDOB and serviceDate.
|
||||
* Returns a NEW form object (immutable).
|
||||
*/
|
||||
export function applyComboToForm(form, comboId, patientDOB, options = {}) {
|
||||
const preset = PROCEDURE_COMBOS[String(comboId)];
|
||||
if (!preset)
|
||||
return form;
|
||||
const { append = true, startIndex, lineDate = form.serviceDate, clearTrailing = false, replaceAll = false, // NEW
|
||||
} = options;
|
||||
const next = { ...form, serviceLines: [...form.serviceLines] };
|
||||
// Replace-all: blank all existing and start from 0
|
||||
if (replaceAll) {
|
||||
for (let i = 0; i < next.serviceLines.length; i++) {
|
||||
next.serviceLines[i] = makeEmptyLine(lineDate);
|
||||
}
|
||||
}
|
||||
// determine insertion index
|
||||
let insertAt = 0;
|
||||
if (!replaceAll) {
|
||||
if (append) {
|
||||
let last = -1;
|
||||
next.serviceLines.forEach((ln, i) => {
|
||||
if (ln.procedureCode?.trim())
|
||||
last = i;
|
||||
});
|
||||
insertAt = Math.max(0, last + 1);
|
||||
}
|
||||
else if (typeof startIndex === "number") {
|
||||
insertAt = Math.max(0, Math.min(startIndex, next.serviceLines.length - 1));
|
||||
}
|
||||
} // if replaceAll, insertAt stays 0
|
||||
// Make sure we have enough rows for the whole combo
|
||||
ensureCapacity(next.serviceLines, insertAt + preset.codes.length, lineDate);
|
||||
// Age on the specific line date we will set
|
||||
const age = ageOnDate(patientDOB, lineDate);
|
||||
for (let j = 0; j < preset.codes.length; j++) {
|
||||
const i = insertAt + j;
|
||||
if (i >= next.serviceLines.length)
|
||||
break;
|
||||
const codeRaw = preset.codes[j];
|
||||
if (!codeRaw)
|
||||
continue;
|
||||
const code = normalizeCode(codeRaw);
|
||||
const price = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||
const original = next.serviceLines[i];
|
||||
next.serviceLines[i] = {
|
||||
...original,
|
||||
procedureCode: code,
|
||||
procedureDate: lineDate,
|
||||
quad: original?.quad ?? "",
|
||||
arch: original?.arch ?? "",
|
||||
toothNumber: preset.toothNumbers?.[j] ?? original?.toothNumber ?? "",
|
||||
toothSurface: original?.toothSurface ?? "",
|
||||
totalBilled: price,
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalPaid: new Decimal(0),
|
||||
};
|
||||
}
|
||||
if (replaceAll || clearTrailing) {
|
||||
const after = insertAt + preset.codes.length;
|
||||
for (let i = after; i < next.serviceLines.length; i++) {
|
||||
next.serviceLines[i] = makeEmptyLine(lineDate);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
export { CODE_MAP, getPriceForCodeWithAgeFromMap };
|
||||
@@ -1,6 +1,7 @@
|
||||
import { InputServiceLine } from "@repo/db/types";
|
||||
import Decimal from "decimal.js";
|
||||
import rawCodeTable from "@/assets/data/procedureCodes.json";
|
||||
import rawCodeTable from "@/assets/data/procedureCodesMH.json";
|
||||
import rawCCACodeTable from "@/assets/data/procedureCodesCCA.json";
|
||||
import { PROCEDURE_COMBOS } from "./procedureCombos";
|
||||
|
||||
/* ----------------------------- Types ----------------------------- */
|
||||
@@ -13,6 +14,7 @@ export type CodeRow = {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
const CODE_TABLE = rawCodeTable as CodeRow[];
|
||||
const CCA_CODE_TABLE = rawCCACodeTable as CodeRow[];
|
||||
|
||||
export type ClaimFormLike = {
|
||||
serviceDate: string; // form-level service date
|
||||
@@ -45,6 +47,21 @@ const CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
return m;
|
||||
})();
|
||||
|
||||
const CCA_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
for (const r of CCA_CODE_TABLE) {
|
||||
const k = normalizeCode(String(r["Procedure Code"] || ""));
|
||||
if (k && !m.has(k)) m.set(k, r);
|
||||
}
|
||||
return m;
|
||||
})();
|
||||
|
||||
/** Return the correct fee-schedule map for the given insurance type. */
|
||||
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
||||
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
|
||||
return CODE_MAP; // default: MassHealth
|
||||
}
|
||||
|
||||
// this function is solely for abbrevations feature in claim-form
|
||||
export function getDescriptionForCode(
|
||||
code: string | undefined
|
||||
@@ -209,15 +226,17 @@ const ensureCapacity = (
|
||||
export function mapPricesForForm<T extends ClaimFormLike>(params: {
|
||||
form: T;
|
||||
patientDOB: DateInput;
|
||||
insuranceSiteKey?: string;
|
||||
}): T {
|
||||
const { form, patientDOB } = params;
|
||||
const { form, patientDOB, insuranceSiteKey } = params;
|
||||
const map = getCodeMap(insuranceSiteKey);
|
||||
return {
|
||||
...form,
|
||||
serviceLines: form.serviceLines.map((ln) => {
|
||||
const age = ageOnDate(patientDOB, form.serviceDate);
|
||||
const code = normalizeCode(ln.procedureCode || "");
|
||||
if (!code) return { ...ln };
|
||||
const price = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||
const price = getPriceForCodeWithAgeFromMap(map, code, age);
|
||||
return { ...ln, procedureCode: code, totalBilled: price };
|
||||
}),
|
||||
};
|
||||
@@ -231,7 +250,8 @@ export function applyComboToForm<T extends ClaimFormLike>(
|
||||
form: T,
|
||||
comboId: keyof typeof PROCEDURE_COMBOS,
|
||||
patientDOB: DateInput,
|
||||
options: ApplyOptions = {}
|
||||
options: ApplyOptions = {},
|
||||
insuranceSiteKey?: string
|
||||
): T {
|
||||
const preset = PROCEDURE_COMBOS[String(comboId)];
|
||||
if (!preset) return form;
|
||||
@@ -275,6 +295,7 @@ export function applyComboToForm<T extends ClaimFormLike>(
|
||||
|
||||
// Age on the specific line date we will set
|
||||
const age = ageOnDate(patientDOB, lineDate);
|
||||
const map = getCodeMap(insuranceSiteKey);
|
||||
|
||||
for (let j = 0; j < preset.codes.length; j++) {
|
||||
const i = insertAt + j;
|
||||
@@ -283,7 +304,7 @@ export function applyComboToForm<T extends ClaimFormLike>(
|
||||
const codeRaw = preset.codes[j];
|
||||
if (!codeRaw) continue;
|
||||
const code = normalizeCode(codeRaw);
|
||||
const price = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||
const price = getPriceForCodeWithAgeFromMap(map, code, age);
|
||||
|
||||
const original = next.serviceLines[i];
|
||||
|
||||
@@ -312,4 +333,4 @@ export function applyComboToForm<T extends ClaimFormLike>(
|
||||
}
|
||||
|
||||
|
||||
export { CODE_MAP, getPriceForCodeWithAgeFromMap };
|
||||
export { CODE_MAP, CCA_CODE_MAP, getCodeMap, getPriceForCodeWithAgeFromMap };
|
||||
Reference in New Issue
Block a user