feat: appointment type inference, procedure codes on cards, claim attachment fixes
- Add appointment type categories matching insurance claim form (recall, filling, pedo, dentures, implant, endo, crown, perio, extraction, ortho, consultation, emergency, other) - Auto-infer appointment type from CDT codes with priority rules (endo > implant > crown > ...) - typeLocked flag prevents auto-overwrite when user manually sets type - Show appointment type label and procedure codes on schedule cards - Background sync on /day route retroactively fixes stale appointment types - Fix PUT /api/claims/:id to save claimFiles (previously silently dropped) - Auto-link AppointmentFile records to ClaimFile when claim is created or updated - Fix D5750 (denture reline) CDT range to map correctly to dentures category - Fix typeLocked Zod rejection in appointment update route Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,35 @@ import {
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Mirrors the same logic in claims.ts and appointmentTypeUtils.ts
|
||||
function inferApptType(codes: string[]): string | null {
|
||||
const priority = ["endo","implant","crown","pedo","dentures","extraction","perio","filling","ortho","recall","consultation","emergency"];
|
||||
const scores: Record<string, number> = {};
|
||||
for (const raw of codes) {
|
||||
const c = raw.replace(/\s/g, "").toUpperCase();
|
||||
let t: string | null = null;
|
||||
if (c === "D1351" || c === "D2930" || c === "D3220") t = "pedo";
|
||||
else if (c === "D9110") t = "emergency";
|
||||
else if (c === "D9310") t = "consultation";
|
||||
else if (/^D3/.test(c)) t = "endo";
|
||||
else if (/^D6/.test(c)) t = "implant";
|
||||
else if (/^D2[78]/.test(c)) t = "crown";
|
||||
else if (/^D5[1-8]/.test(c)) t = "dentures";
|
||||
else if (/^D71/.test(c)) t = "extraction";
|
||||
else if (/^D4[3-9]/.test(c)) t = "perio";
|
||||
else if (/^D2/.test(c)) t = "filling";
|
||||
else if (/^D8/.test(c)) t = "ortho";
|
||||
else if (/^D[01]/.test(c)) t = "recall";
|
||||
if (t) scores[t] = (scores[t] ?? 0) + 1;
|
||||
}
|
||||
let best: string | null = null, bestCount = 0;
|
||||
for (const t of priority) {
|
||||
const n = scores[t] ?? 0;
|
||||
if (n > bestCount) { best = t; bestCount = n; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Get all appointments
|
||||
router.get("/all", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
@@ -58,18 +87,36 @@ router.get("/day", async (req: Request, res: Response): Promise<any> => {
|
||||
|
||||
// Enrich each appointment with procedure / claim status flags
|
||||
const appointmentIds = appointments.map((a) => a.id).filter((id): id is number => id != null);
|
||||
const [idsWithProcedures, idsWithClaimNumbers] = await Promise.all([
|
||||
const [idsWithProcedures, idsWithClaimNumbers, procedureCodesByAppt] = await Promise.all([
|
||||
storage.getAppointmentIdsWithProcedures(appointmentIds),
|
||||
storage.getAppointmentIdsWithClaimNumbers(appointmentIds),
|
||||
storage.getProcedureCodesByAppointmentIds(appointmentIds),
|
||||
]);
|
||||
|
||||
const enrichedAppointments = appointments.map((a) => ({
|
||||
...a,
|
||||
hasProcedures: a.id != null && idsWithProcedures.has(a.id),
|
||||
hasClaimWithNumber: a.id != null && idsWithClaimNumbers.has(a.id),
|
||||
procedureCodes: a.id != null ? (procedureCodesByAppt.get(a.id) ?? []) : [],
|
||||
}));
|
||||
|
||||
return res.json({ appointments: enrichedAppointments, patients });
|
||||
res.json({ appointments: enrichedAppointments, patients });
|
||||
|
||||
// Background: fix any appointments whose stored type doesn't match their procedure codes.
|
||||
// Runs after the response is sent so it never delays the page load.
|
||||
// Skips appointments where typeLocked = true (user manually set the type).
|
||||
setImmediate(async () => {
|
||||
for (const a of enrichedAppointments) {
|
||||
if (!a.id || !(a as any).procedureCodes?.length) continue;
|
||||
if ((a as any).typeLocked) continue;
|
||||
const inferred = inferApptType((a as any).procedureCodes);
|
||||
if (inferred && (a as any).type !== inferred) {
|
||||
try {
|
||||
await storage.updateAppointment(a.id, { type: inferred } as any);
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error in /api/appointments/day:", err);
|
||||
res.status(500).json({ message: "Failed to load appointments for date" });
|
||||
@@ -276,8 +323,12 @@ router.put(
|
||||
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
// Extract typeLocked before Zod parse — the strict schema may not yet
|
||||
// know about this field if the server started before prisma generate ran.
|
||||
const { typeLocked: rawTypeLocked, ...bodyWithoutTypeLocked } = req.body;
|
||||
|
||||
const appointmentData = updateAppointmentSchema.parse({
|
||||
...req.body,
|
||||
...bodyWithoutTypeLocked,
|
||||
userId: req.user!.id,
|
||||
});
|
||||
|
||||
@@ -358,7 +409,8 @@ router.put(
|
||||
if (appointmentData.date !== undefined) updatePayload.date = appointmentData.date;
|
||||
if (appointmentData.startTime !== undefined) updatePayload.startTime = appointmentData.startTime;
|
||||
if (appointmentData.endTime !== undefined) updatePayload.endTime = appointmentData.endTime;
|
||||
if (appointmentData.type !== undefined) updatePayload.type = appointmentData.type;
|
||||
if (appointmentData.type !== undefined) updatePayload.type = appointmentData.type;
|
||||
if (rawTypeLocked !== undefined) updatePayload.typeLocked = Boolean(rawTypeLocked);
|
||||
if (appointmentData.status !== undefined) updatePayload.status = appointmentData.status;
|
||||
if (appointmentData.notes !== undefined) updatePayload.notes = appointmentData.notes;
|
||||
if (isDateChanged) updatePayload.eligibilityStatus = "UNKNOWN";
|
||||
@@ -387,6 +439,27 @@ router.put(
|
||||
}
|
||||
);
|
||||
|
||||
// Update just the appointment type (called after procedures are auto-inferred)
|
||||
// Skips if typeLocked = true (user has manually set the type).
|
||||
router.patch("/:id/type", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ message: "Invalid appointment ID" });
|
||||
const { type } = req.body;
|
||||
if (typeof type !== "string" || !type.trim()) {
|
||||
return res.status(400).json({ message: "type is required" });
|
||||
}
|
||||
const apt = await storage.getAppointment(id);
|
||||
if (!apt) return res.status(404).json({ message: "Appointment not found" });
|
||||
if ((apt as any).typeLocked) return res.json(apt); // honour user's manual choice
|
||||
const updated = await storage.updateAppointment(id, { type } as any);
|
||||
return res.json(updated);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ message: msg });
|
||||
}
|
||||
});
|
||||
|
||||
// Manually confirm an AI-moved appointment (clears the movedByAi flag)
|
||||
router.patch("/:id/confirm", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,54 @@ import { formatDobForAgent } from "../utils/dateUtils";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Appointment-type inference (mirrors frontend appointmentTypeUtils.ts) ──────
|
||||
function codeToApptType(raw: string): string | null {
|
||||
const c = raw.replace(/\s/g, "").toUpperCase();
|
||||
if (c === "D1351" || c === "D2930" || c === "D3220") return "pedo";
|
||||
if (c === "D9110") return "emergency";
|
||||
if (c === "D9310") return "consultation";
|
||||
if (/^D3/.test(c)) return "endo";
|
||||
if (/^D6/.test(c)) return "implant";
|
||||
if (/^D2[78]/.test(c)) return "crown";
|
||||
if (/^D5[1-8]/.test(c)) return "dentures";
|
||||
if (/^D71/.test(c)) return "extraction";
|
||||
if (/^D4[3-9]/.test(c)) return "perio";
|
||||
if (/^D2/.test(c)) return "filling";
|
||||
if (/^D8/.test(c)) return "ortho";
|
||||
if (/^D[01]/.test(c)) return "recall";
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferApptTypeFromCodes(codes: string[]): string | null {
|
||||
const priority = ["endo","implant","crown","pedo","dentures","extraction","perio","filling","ortho","recall","consultation","emergency"];
|
||||
const scores: Record<string, number> = {};
|
||||
for (const code of codes) {
|
||||
const t = codeToApptType(code);
|
||||
if (t) scores[t] = (scores[t] ?? 0) + 1;
|
||||
}
|
||||
let best: string | null = null, bestCount = 0;
|
||||
for (const t of priority) {
|
||||
const n = scores[t] ?? 0;
|
||||
if (n > bestCount) { best = t; bestCount = n; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Update appointment.type if the inferred type differs and the user has not locked it. */
|
||||
async function syncAppointmentType(appointmentId: number | null | undefined, codes: string[]): Promise<void> {
|
||||
if (!appointmentId || !codes.length) return;
|
||||
const inferred = inferApptTypeFromCodes(codes);
|
||||
if (!inferred) return;
|
||||
try {
|
||||
const apt = await storage.getAppointment(appointmentId);
|
||||
if (apt && !(apt as any).typeLocked && apt.type !== inferred) {
|
||||
await storage.updateAppointment(appointmentId, { type: inferred } as any);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — don't break claim creation if type sync fails
|
||||
}
|
||||
}
|
||||
|
||||
// Routes
|
||||
const multerStorage = multer.memoryStorage(); // NO DISK
|
||||
const upload = multer({
|
||||
@@ -1015,6 +1063,27 @@ router.get("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// Links AppointmentFile records to a claim as ClaimFile records (dedup by filename).
|
||||
// Called after claim create or update so files saved in the procedures form always appear.
|
||||
async function autoLinkAppointmentFiles(claimId: number, appointmentId: number | null | undefined) {
|
||||
if (!appointmentId) return;
|
||||
const { prisma: db } = await import("@repo/db/client");
|
||||
const aptFiles = await db.appointmentFile.findMany({ where: { appointmentId } });
|
||||
if (!aptFiles.length) return;
|
||||
const existing = await db.claimFile.findMany({ where: { claimId }, select: { filename: true } });
|
||||
const existingNames = new Set(existing.map((f: any) => f.filename));
|
||||
const toLink = aptFiles.filter((f: any) => f.filename && !existingNames.has(f.filename));
|
||||
if (!toLink.length) return;
|
||||
await db.claimFile.createMany({
|
||||
data: toLink.map((f: any) => ({
|
||||
claimId,
|
||||
filename: f.filename,
|
||||
mimeType: f.mimeType ?? "",
|
||||
...(f.filePath ? { filePath: String(f.filePath) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new claim
|
||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
@@ -1095,6 +1164,13 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
// Step 2: Create claim (with service lines)
|
||||
const claim = await storage.createClaim(parsedClaim);
|
||||
|
||||
// Step 2b: Link any AppointmentFile records saved via procedures form
|
||||
await autoLinkAppointmentFiles(claim.id, claim.appointmentId);
|
||||
|
||||
// Step 2c: Sync appointment type from final CDT codes
|
||||
const rawCodes = lines.map((l: any) => String(l.procedureCode ?? "")).filter(Boolean);
|
||||
await syncAppointmentType(claim.appointmentId, rawCodes);
|
||||
|
||||
// Step 3: Create payment only for real submissions (not draft saves)
|
||||
const isDraft = req.query.draft === "true";
|
||||
if (!isDraft) {
|
||||
@@ -1170,10 +1246,28 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
totalDue: Number(line.totalBilled),
|
||||
})),
|
||||
});
|
||||
|
||||
// Sync appointment type from updated CDT codes
|
||||
const updatedCodes = req.body.serviceLines
|
||||
.map((l: InputServiceLine) => String(l.procedureCode ?? ""))
|
||||
.filter(Boolean);
|
||||
await syncAppointmentType(existingClaim.appointmentId, updatedCodes);
|
||||
}
|
||||
|
||||
// Explicitly pick only scalar fields — skip serviceLines (handled above)
|
||||
// and claimFiles (plain array from frontend is not Prisma nested format)
|
||||
// If new claimFiles are provided, append them (don't delete existing ones)
|
||||
if (Array.isArray(req.body.claimFiles) && req.body.claimFiles.length > 0) {
|
||||
const { prisma: db } = await import("@repo/db/client");
|
||||
await db.claimFile.createMany({
|
||||
data: req.body.claimFiles.map((f: any) => ({
|
||||
claimId,
|
||||
filename: String(f.filename || ""),
|
||||
mimeType: String(f.mimeType || f.mime || ""),
|
||||
...(f.filePath ? { filePath: String(f.filePath) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Explicitly pick only scalar fields — skip serviceLines and claimFiles (handled above)
|
||||
// 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);
|
||||
@@ -1197,6 +1291,9 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
});
|
||||
const updatedClaim = await storage.updateClaim(claimId, claimData);
|
||||
|
||||
// Link any AppointmentFile records saved via procedures form (dedup by filename)
|
||||
await autoLinkAppointmentFiles(claimId, updatedClaim.appointmentId);
|
||||
|
||||
// Propagate provider change to the linked payment so both stay in sync
|
||||
if (req.body.npiProviderId) {
|
||||
const { prisma: db } = await import("@repo/db/client");
|
||||
|
||||
@@ -13,6 +13,7 @@ const SCHEDULE_FILES: Record<string, string> = {
|
||||
TUFTS_SCO: "procedureCodesTuftsSCO.json",
|
||||
UNITEDDH: "procedureCodesUnitedDH.json",
|
||||
UNITED_SCO: "procedureCodesUnitedDH.json",
|
||||
UNITEDSCO: "procedureCodesUnitedDH.json",
|
||||
};
|
||||
|
||||
function getSchedulePath(siteKey: string): string | null {
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface IAppointmentProceduresStorage {
|
||||
clearByAppointmentId(appointmentId: number): Promise<void>;
|
||||
getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]>;
|
||||
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
|
||||
getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>>;
|
||||
}
|
||||
|
||||
export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
@@ -171,6 +172,23 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
return new Set(rows.map((r: any) => r.appointmentId));
|
||||
},
|
||||
|
||||
async getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>> {
|
||||
if (!ids.length) return new Map();
|
||||
const rows = await db.appointmentProcedure.findMany({
|
||||
where: { appointmentId: { in: ids } },
|
||||
select: { appointmentId: true, procedureCode: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const map = new Map<number, string[]>();
|
||||
for (const r of rows as any[]) {
|
||||
if (!r.procedureCode) continue;
|
||||
const list = map.get(r.appointmentId) ?? [];
|
||||
list.push(r.procedureCode);
|
||||
map.set(r.appointmentId, list);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
|
||||
async getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]> {
|
||||
const rows = await db.appointmentFile.findMany({
|
||||
where: { appointmentId },
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
{
|
||||
"Procedure Code": "D0230",
|
||||
"Description": "Intraoral - periapical, each additional radiographic image",
|
||||
"PriceLTEQ21": "13",
|
||||
"PriceGT21": "13"
|
||||
"PriceLTEQ21": 60,
|
||||
"PriceGT21": 60
|
||||
},
|
||||
{
|
||||
"Procedure Code": "D0240",
|
||||
|
||||
@@ -854,8 +854,8 @@
|
||||
{
|
||||
"Procedure Code": "D7210",
|
||||
"Description": "Extraction, erupted tooth requiring removal of bone and/or sectioning of tooth, and including elevation of mucoperiosteal flap if indicated",
|
||||
"PriceLTEQ21": "149",
|
||||
"PriceGT21": "149"
|
||||
"PriceLTEQ21": 200,
|
||||
"PriceGT21": 200
|
||||
},
|
||||
{
|
||||
"Procedure Code": "D7220",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -6,6 +6,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -23,6 +33,8 @@ import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||
import {
|
||||
CODE_MAP,
|
||||
getPriceForCodeWithAgeFromMap,
|
||||
findPriceMismatches,
|
||||
type PriceMismatch,
|
||||
} from "@/utils/procedureCombosMapping";
|
||||
import { Patient, AppointmentProcedure, NpiProvider } from "@repo/db/types";
|
||||
import { useLocation } from "wouter";
|
||||
@@ -67,6 +79,51 @@ export function AppointmentProceduresDialog({
|
||||
const [editRow, setEditRow] = useState<Partial<AppointmentProcedure>>({});
|
||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||
|
||||
// price mismatch dialog
|
||||
const [priceMismatches, setPriceMismatches] = useState<PriceMismatch[]>([]);
|
||||
const pendingAction = useRef<(() => void) | null>(null);
|
||||
|
||||
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") || p.includes("delta dental ma")) return "DDMA";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
||||
if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO";
|
||||
return "";
|
||||
};
|
||||
|
||||
const runWithPriceCheck = (procedureCode: string, fee: number, action: () => void) => {
|
||||
const siteKey = deriveInsuranceSiteKey((patient as any)?.insuranceProvider);
|
||||
if (!siteKey || !procedureCode.trim() || !fee) { action(); return; }
|
||||
const mismatches = findPriceMismatches(
|
||||
[{ procedureCode, totalBilled: fee as any, procedureDate: "" }],
|
||||
siteKey,
|
||||
(patient?.dateOfBirth as string) || "",
|
||||
serviceDate ?? new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
if (mismatches.length === 0) {
|
||||
action();
|
||||
} else {
|
||||
pendingAction.current = action;
|
||||
setPriceMismatches(mismatches);
|
||||
}
|
||||
};
|
||||
|
||||
const savePricesToSchedule = async (mismatches: PriceMismatch[]) => {
|
||||
const siteKey = deriveInsuranceSiteKey((patient as any)?.insuranceProvider);
|
||||
await Promise.all(
|
||||
mismatches.map(m =>
|
||||
apiRequest("POST", "/api/fee-schedule/update-price", {
|
||||
siteKey,
|
||||
procedureCode: m.procedureCode,
|
||||
price: m.enteredPrice,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ── NPI Providers ──────────────────────────────────────────────
|
||||
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
@@ -343,7 +400,7 @@ export function AppointmentProceduresDialog({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => addManualMutation.mutate()} disabled={!manualCode || addManualMutation.isPending}>
|
||||
<Button size="sm" onClick={() => runWithPriceCheck(manualCode, Number(manualFee), () => addManualMutation.mutate())} disabled={!manualCode || addManualMutation.isPending}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Procedure
|
||||
</Button>
|
||||
@@ -380,7 +437,7 @@ export function AppointmentProceduresDialog({
|
||||
<Input className="w-[80px]" value={editRow.toothNumber ?? ""} onChange={(e) => setEditRow({ ...editRow, toothNumber: e.target.value })} />
|
||||
<Input className="w-[80px]" value={editRow.toothSurface ?? ""} onChange={(e) => setEditRow({ ...editRow, toothSurface: e.target.value })} />
|
||||
<div className="flex justify-center">
|
||||
<Button size="icon" variant="ghost" onClick={() => updateMutation.mutate()}><Save className="h-4 w-4" /></Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => runWithPriceCheck(editRow.procedureCode || "", Number(editRow.fee), () => updateMutation.mutate())}><Save className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="icon" variant="ghost" onClick={cancelEdit}><X className="h-4 w-4" /></Button>
|
||||
@@ -437,6 +494,47 @@ export function AppointmentProceduresDialog({
|
||||
onCancel={() => setClearAllOpen(false)}
|
||||
onConfirm={() => { setClearAllOpen(false); clearAllMutation.mutate(); }}
|
||||
/>
|
||||
|
||||
{/* Price mismatch dialog */}
|
||||
<AlertDialog open={priceMismatches.length > 0} onOpenChange={open => { if (!open) setPriceMismatches([]); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Save new price to the app?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>The following procedure prices differ from the fee schedule:</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{priceMismatches.map(m => (
|
||||
<li key={m.procedureCode} className="flex justify-between gap-4">
|
||||
<span className="font-medium">{m.procedureCode}</span>
|
||||
<span className="text-muted-foreground">Schedule: ${m.schedulePrice.toFixed(2)}</span>
|
||||
<span className="text-foreground font-semibold">Entered: ${m.enteredPrice.toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm">Do you want to save the new price(s) to the fee schedule for future use?</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => {
|
||||
setPriceMismatches([]);
|
||||
pendingAction.current?.();
|
||||
pendingAction.current = null;
|
||||
}}>
|
||||
No
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={async () => {
|
||||
await savePricesToSchedule(priceMismatches);
|
||||
setPriceMismatches([]);
|
||||
pendingAction.current?.();
|
||||
pendingAction.current = null;
|
||||
}}>
|
||||
Yes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format } from "date-fns";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { APPOINTMENT_TYPES } from "@/utils/appointmentTypeUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -70,6 +71,10 @@ export function AppointmentForm({
|
||||
const t = appointment?.type ?? "";
|
||||
return t.startsWith("other:") ? t.slice(6) : "";
|
||||
});
|
||||
// Track whether the user explicitly changed the type during this edit session.
|
||||
// Used to set typeLocked so the auto-sync won't overwrite a deliberate choice.
|
||||
const originalType = useRef<string>(appointment?.type ?? "");
|
||||
const [typeChangedByUser, setTypeChangedByUser] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -295,6 +300,8 @@ export function AppointmentForm({
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
type: resolvedType,
|
||||
// Lock the type when the user has explicitly changed it on an existing appointment
|
||||
...(appointment && typeChangedByUser ? { typeLocked: true } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -522,6 +529,7 @@ export function AppointmentForm({
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
if (val !== "other") setOtherTypeDesc("");
|
||||
if (val !== originalType.current) setTypeChangedByUser(true);
|
||||
}}
|
||||
value={field.value}
|
||||
defaultValue={field.value}
|
||||
@@ -532,16 +540,9 @@ export function AppointmentForm({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="checkup">Checkup</SelectItem>
|
||||
<SelectItem value="cleaning">Cleaning</SelectItem>
|
||||
<SelectItem value="filling">Filling</SelectItem>
|
||||
<SelectItem value="extraction">Extraction</SelectItem>
|
||||
<SelectItem value="root-canal">Root Canal</SelectItem>
|
||||
<SelectItem value="crown">Crown</SelectItem>
|
||||
<SelectItem value="dentures">Dentures</SelectItem>
|
||||
<SelectItem value="consultation">Consultation</SelectItem>
|
||||
<SelectItem value="emergency">Emergency</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
{APPOINTMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{field.value === "other" && (
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import {
|
||||
MultipleFileUploadZone,
|
||||
MultipleFileUploadZoneHandle,
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
DirectComboButtons,
|
||||
RegularComboButtons,
|
||||
} from "@/components/procedure/procedure-combo-buttons";
|
||||
import { inferTypeFromProcedureCodes, getAppointmentTypeLabel } from "@/utils/appointmentTypeUtils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -1487,8 +1488,23 @@ export function ClaimForm({
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error("Failed to save procedures");
|
||||
|
||||
// Auto-infer appointment type from saved procedure codes
|
||||
const codes = filteredServiceLines.map((l) => l.procedureCode ?? "").filter(Boolean);
|
||||
const inferredType = inferTypeFromProcedureCodes(codes);
|
||||
if (inferredType && appointmentId) {
|
||||
try {
|
||||
await apiRequest("PATCH", `/api/appointments/${appointmentId}/type`, { type: inferredType });
|
||||
// Refresh the schedule view so the new type shows on the card immediately
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments", "day"] });
|
||||
} catch {
|
||||
// Non-fatal: type update is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
const attachMsg = attachments.length ? ` and ${attachments.length} attachment(s)` : "";
|
||||
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved.` });
|
||||
const typeMsg = inferredType ? ` · Type → ${getAppointmentTypeLabel(inferredType)}` : "";
|
||||
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved${typeMsg}.` });
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
toast({ title: "Save failed", description: err?.message ?? "Failed to save procedures.", variant: "destructive" });
|
||||
|
||||
@@ -67,6 +67,7 @@ import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
||||
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
|
||||
import type { OfficeHoursData } from "@/components/settings/office-hours-card";
|
||||
import { MessageThread } from "@/components/patient-connection/message-thread";
|
||||
import { getAppointmentTypeLabel } from "@/utils/appointmentTypeUtils";
|
||||
|
||||
// Define types for scheduling
|
||||
interface TimeSlot {
|
||||
@@ -97,6 +98,7 @@ interface ScheduledAppointment {
|
||||
endTime: string | Date;
|
||||
status: string | null;
|
||||
type: string;
|
||||
procedureCodes?: string[];
|
||||
}
|
||||
|
||||
function appointmentCardColor(apt: ScheduledAppointment): string {
|
||||
@@ -569,6 +571,7 @@ export default function AppointmentsPage() {
|
||||
patientInsuranceProvider,
|
||||
hasProcedures: !!(apt as any).hasProcedures,
|
||||
hasClaimWithNumber: !!(apt as any).hasClaimWithNumber,
|
||||
procedureCodes: (apt as any).procedureCodes ?? [],
|
||||
movedByAi: !!(apt as any).movedByAi,
|
||||
staffId,
|
||||
status: apt.status ?? null,
|
||||
@@ -797,11 +800,14 @@ export default function AppointmentsPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
{appointment.type?.startsWith("other:")
|
||||
? appointment.type.slice(6)
|
||||
: appointment.type}
|
||||
<div className="truncate text-[11px]">
|
||||
{getAppointmentTypeLabel(appointment.type)}
|
||||
</div>
|
||||
{appointment.procedureCodes && appointment.procedureCodes.length > 0 && (
|
||||
<div className="truncate text-[10px] opacity-80">
|
||||
{appointment.procedureCodes.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
97
apps/Frontend/src/utils/appointmentTypeUtils.ts
Normal file
97
apps/Frontend/src/utils/appointmentTypeUtils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export const APPOINTMENT_TYPES = [
|
||||
{ value: "recall", label: "Recalls & New Patients" },
|
||||
{ value: "filling", label: "Filling (Composite)" },
|
||||
{ value: "pedo", label: "Pedo" },
|
||||
{ value: "dentures", label: "Dentures / Partials" },
|
||||
{ value: "implant", label: "Implant" },
|
||||
{ value: "endo", label: "Endo (Root Canal)" },
|
||||
{ value: "crown", label: "Crown / Prosthodontics" },
|
||||
{ value: "perio", label: "Periodontics" },
|
||||
{ value: "extraction", label: "Extraction" },
|
||||
{ value: "ortho", label: "Orthodontics" },
|
||||
{ value: "consultation", label: "Consultation" },
|
||||
{ value: "emergency", label: "Emergency" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
const LEGACY_LABELS: Record<string, string> = {
|
||||
checkup: "Checkup",
|
||||
cleaning: "Cleaning",
|
||||
"root-canal": "Root Canal",
|
||||
};
|
||||
|
||||
export function getAppointmentTypeLabel(type: string | null | undefined): string {
|
||||
if (!type) return "";
|
||||
if (type.startsWith("other:")) return type.slice(6);
|
||||
const found = APPOINTMENT_TYPES.find((t) => t.value === type);
|
||||
if (found) return found.label;
|
||||
return LEGACY_LABELS[type] ?? type;
|
||||
}
|
||||
|
||||
function codeToType(code: string): string | null {
|
||||
const c = code.replace(/\s/g, "").toUpperCase();
|
||||
|
||||
// Special cases (pedo-specific codes that share ranges with other categories)
|
||||
if (c === "D1351" || c === "D2930" || c === "D3220") return "pedo";
|
||||
// Emergency / consultation
|
||||
if (c === "D9110") return "emergency";
|
||||
if (c === "D9310") return "consultation";
|
||||
|
||||
// Endo: D3xxx (root canals, pulp therapy)
|
||||
if (/^D3/.test(c)) return "endo";
|
||||
|
||||
// Implants: D6xxx
|
||||
if (/^D6/.test(c)) return "implant";
|
||||
|
||||
// Crown / Prosthodontics: D27xx, D28xx (fixed partials, crowns)
|
||||
if (/^D2[78]/.test(c)) return "crown";
|
||||
|
||||
// Dentures / Partials: D51xx–D58xx (complete/partial dentures, relines, repairs, adjustments)
|
||||
if (/^D5[1-8]/.test(c)) return "dentures";
|
||||
|
||||
// Extractions: D71xx
|
||||
if (/^D71/.test(c)) return "extraction";
|
||||
|
||||
// Periodontics: D43xx–D49xx
|
||||
if (/^D4[3-9]/.test(c)) return "perio";
|
||||
|
||||
// Fillings / Restorations: remaining D2xxx
|
||||
if (/^D2/.test(c)) return "filling";
|
||||
|
||||
// Orthodontics: D8xxx
|
||||
if (/^D8/.test(c)) return "ortho";
|
||||
|
||||
// Recalls & New Patients: D0xxx (exams, x-rays), D1xxx (preventive)
|
||||
if (/^D[01]/.test(c)) return "recall";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inferTypeFromProcedureCodes(codes: string[]): string | null {
|
||||
if (!codes.length) return null;
|
||||
|
||||
const scores: Record<string, number> = {};
|
||||
for (const code of codes) {
|
||||
const t = codeToType(code);
|
||||
if (t) scores[t] = (scores[t] ?? 0) + 1;
|
||||
}
|
||||
if (!Object.keys(scores).length) return null;
|
||||
|
||||
// Priority order: most specialized/dominant type wins on ties
|
||||
const priority = [
|
||||
"endo", "implant", "crown", "pedo", "dentures",
|
||||
"extraction", "perio", "filling", "ortho",
|
||||
"recall", "consultation", "emergency",
|
||||
];
|
||||
|
||||
let best: string | null = null;
|
||||
let bestCount = 0;
|
||||
for (const type of priority) {
|
||||
const count = scores[type] ?? 0;
|
||||
if (count > bestCount) {
|
||||
best = type;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ const TUFTSSCO_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
||||
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
|
||||
if (insuranceSiteKey === "DDMA") return DDMA_CODE_MAP;
|
||||
if (insuranceSiteKey === "UNITED_SCO") return UNITEDDH_CODE_MAP;
|
||||
if (insuranceSiteKey === "UNITED_SCO" || insuranceSiteKey === "UnitedSCO" || insuranceSiteKey === "UNITEDDH") return UNITEDDH_CODE_MAP;
|
||||
if (insuranceSiteKey === "TuftsSCO") return TUFTSSCO_CODE_MAP;
|
||||
return CODE_MAP; // default: MassHealth
|
||||
}
|
||||
@@ -386,7 +386,7 @@ export function findPriceMismatches(
|
||||
patientDOB: string,
|
||||
serviceDate: string,
|
||||
): PriceMismatch[] {
|
||||
const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "TUFTSSCO"];
|
||||
const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "UNITEDSCO", "TUFTSSCO"];
|
||||
if (!insuranceSiteKey || !supported.includes(insuranceSiteKey.toUpperCase())) return [];
|
||||
|
||||
const map = getCodeMap(insuranceSiteKey);
|
||||
|
||||
@@ -647,17 +647,34 @@ class AutomationUnitedDHClaimSubmit:
|
||||
))
|
||||
)
|
||||
|
||||
# Explicit wait: hold until Angular has auto-filled the location dropdown
|
||||
# Select Treatment Location (first dropdown) — page auto-fills Billing Entity and rest
|
||||
print("[UnitedDH Claim] step3: Selecting Treatment Location...")
|
||||
location_selected = False
|
||||
try:
|
||||
WebDriverWait(self.driver, 10).until(
|
||||
lambda d: d.find_elements(By.XPATH,
|
||||
"//ng-select//span[contains(@class,'ng-value-label') and normalize-space(text())!=''] | "
|
||||
"//ng-select//div[contains(@class,'ng-value') and normalize-space(.)!='']"
|
||||
)
|
||||
location_ng = self.driver.find_element(By.XPATH,
|
||||
"//label[contains(text(),'Treatment Location') or contains(text(),'treatment location')]"
|
||||
"/..//ng-select | "
|
||||
"(//ng-select)[1]"
|
||||
)
|
||||
print("[UnitedDH Claim] step3: Location auto-filled")
|
||||
except TimeoutException:
|
||||
print("[UnitedDH Claim] step3: Location field did not populate in time — proceeding anyway")
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", location_ng)
|
||||
time.sleep(0.3)
|
||||
location_ng.click()
|
||||
time.sleep(1)
|
||||
first_option = WebDriverWait(self.driver, 5).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//ng-dropdown-panel//div[contains(@class,'ng-option') and not(contains(@class,'disabled'))]"
|
||||
))
|
||||
)
|
||||
option_text = first_option.text.strip()
|
||||
first_option.click()
|
||||
print(f"[UnitedDH Claim] step3: Selected Treatment Location: {option_text}")
|
||||
location_selected = True
|
||||
time.sleep(1) # wait for page to auto-fill remaining fields
|
||||
except Exception as e:
|
||||
print(f"[UnitedDH Claim] step3: Treatment Location selection failed: {e}")
|
||||
|
||||
if not location_selected:
|
||||
print("[UnitedDH Claim] step3: WARNING: Could not select Treatment Location — continuing anyway")
|
||||
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", continue_btn)
|
||||
continue_btn.click()
|
||||
@@ -706,28 +723,29 @@ class AutomationUnitedDHClaimSubmit:
|
||||
line.get("billedAmount") or
|
||||
line.get("fee") or ""
|
||||
).strip()
|
||||
print(f"[UnitedDH Claim] step4: line {idx}: code={code}, billed={billed}")
|
||||
tooth = str(line.get("toothNumber") or line.get("tooth_number") or "").strip()
|
||||
surface = str(line.get("toothSurface") or line.get("tooth_surface") or "").strip().upper()
|
||||
print(f"[UnitedDH Claim] step4: line {idx}: code={code}, billed={billed}, tooth={tooth}, surface={surface}")
|
||||
|
||||
# For lines after the first, click btnAddItem to open a new procedure row
|
||||
if idx > 0:
|
||||
try:
|
||||
add_btn = WebDriverWait(self.driver, 8).until(
|
||||
EC.element_to_be_clickable((By.ID, "btnAddItem"))
|
||||
)
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", add_btn)
|
||||
add_btn.click()
|
||||
print(f"[UnitedDH Claim] step4: clicked btnAddItem to start row {idx}")
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
print(f"[UnitedDH Claim] step4: could not click btnAddItem for row {idx}: {e}")
|
||||
# For ALL rows, click btnAddItem to open/activate the procedure row
|
||||
try:
|
||||
add_btn = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.ID, "btnAddItem"))
|
||||
)
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", add_btn)
|
||||
add_btn.click()
|
||||
print(f"[UnitedDH Claim] step4: clicked btnAddItem to open row {idx}")
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
print(f"[UnitedDH Claim] step4: could not click btnAddItem to open row {idx}: {e}")
|
||||
|
||||
# Type CDT code in procedureCode input
|
||||
try:
|
||||
proc_input = WebDriverWait(self.driver, 8).until(
|
||||
proc_input = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.ID, "procedureCode"))
|
||||
)
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", proc_input)
|
||||
proc_input.click()
|
||||
self.driver.execute_script("arguments[0].click();", proc_input)
|
||||
proc_input.send_keys(Keys.CONTROL + "a")
|
||||
proc_input.send_keys(Keys.DELETE)
|
||||
proc_input.send_keys(code)
|
||||
@@ -750,6 +768,50 @@ class AutomationUnitedDHClaimSubmit:
|
||||
print(f"[UnitedDH Claim] step4: could not click btnAddItem for billed amount row {idx}: {e}")
|
||||
continue
|
||||
|
||||
# Fill tooth number
|
||||
if tooth:
|
||||
try:
|
||||
tooth_input = WebDriverWait(self.driver, 5).until(
|
||||
EC.element_to_be_clickable((By.ID, "tooth"))
|
||||
)
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", tooth_input)
|
||||
tooth_input.click()
|
||||
tooth_input.send_keys(Keys.CONTROL + "a")
|
||||
tooth_input.send_keys(Keys.DELETE)
|
||||
tooth_input.send_keys(tooth)
|
||||
print(f"[UnitedDH Claim] step4: entered tooth number: {tooth} for row {idx}")
|
||||
time.sleep(0.3)
|
||||
except Exception as e:
|
||||
print(f"[UnitedDH Claim] step4: could not fill tooth number for row {idx}: {e}")
|
||||
|
||||
# Click surface boxes (B, D, F, L, M, O, etc.) — only present for filling codes
|
||||
if surface:
|
||||
try:
|
||||
# Check surface box group is present before trying to click
|
||||
surface_boxes = self.driver.find_elements(By.XPATH,
|
||||
"//div[contains(@class,'claim-add-item-group__box')]"
|
||||
)
|
||||
if surface_boxes:
|
||||
for letter in surface:
|
||||
if not letter.strip():
|
||||
continue
|
||||
try:
|
||||
box = self.driver.find_element(By.XPATH,
|
||||
f"//div[contains(@class,'claim-add-item-group__box') "
|
||||
f"and not(contains(@class,'--disabled')) "
|
||||
f"and @id='{letter}']"
|
||||
)
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", box)
|
||||
box.click()
|
||||
print(f"[UnitedDH Claim] step4: clicked surface '{letter}' for row {idx}")
|
||||
time.sleep(0.2)
|
||||
except Exception:
|
||||
print(f"[UnitedDH Claim] step4: surface '{letter}' not found or disabled for row {idx}")
|
||||
else:
|
||||
print(f"[UnitedDH Claim] step4: no surface boxes on page for row {idx} — skipping")
|
||||
except Exception as e:
|
||||
print(f"[UnitedDH Claim] step4: surface click error for row {idx}: {e}")
|
||||
|
||||
# Fill billed amount
|
||||
if billed:
|
||||
try:
|
||||
|
||||
@@ -702,12 +702,45 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
except TimeoutException:
|
||||
print("[UnitedSCO step1] Select Insurance popup not found — proceeding")
|
||||
|
||||
# Step 1.5: Provider & Location page — just click Continue
|
||||
# Step 1.5: Provider & Location page — select Treatment Location (first dropdown),
|
||||
# page auto-fills the rest, then click Continue
|
||||
print("[UnitedSCO step1] Waiting for Provider & Location page...")
|
||||
try:
|
||||
# Wait for the Continue button to confirm the page loaded
|
||||
continue_btn2 = WebDriverWait(self.driver, 15).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'Continue')]"))
|
||||
)
|
||||
|
||||
# Select Treatment Location — click the dropdown and pick the first option;
|
||||
# the page will auto-fill Billing Entity and other fields automatically
|
||||
print("[UnitedSCO step1] Selecting Treatment Location...")
|
||||
location_selected = False
|
||||
try:
|
||||
location_ng = self.driver.find_element(By.XPATH,
|
||||
"//label[contains(text(),'Treatment Location') or contains(text(),'treatment location')]"
|
||||
"/..//ng-select | "
|
||||
"(//ng-select)[1]"
|
||||
)
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", location_ng)
|
||||
time.sleep(0.3)
|
||||
location_ng.click()
|
||||
time.sleep(1)
|
||||
first_option = WebDriverWait(self.driver, 5).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//ng-dropdown-panel//div[contains(@class,'ng-option') and not(contains(@class,'disabled'))]"
|
||||
))
|
||||
)
|
||||
option_text = first_option.text.strip()
|
||||
first_option.click()
|
||||
print(f"[UnitedSCO step1] Selected Treatment Location: {option_text}")
|
||||
location_selected = True
|
||||
time.sleep(1) # wait for page to auto-fill remaining fields
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO step1] Treatment Location selection failed: {e}")
|
||||
|
||||
if not location_selected:
|
||||
print("[UnitedSCO step1] WARNING: Could not select Treatment Location — continuing anyway")
|
||||
|
||||
continue_btn2.click()
|
||||
print("[UnitedSCO step1] Clicked Continue button (Provider & Location)")
|
||||
time.sleep(5)
|
||||
|
||||
Reference in New Issue
Block a user