Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ebe3bc6f2 | |||
| 9975fe9252 | |||
| add03289c6 | |||
| 4594a264a1 | |||
| b8df9459ca | |||
| 1b66474913 | |||
| e2b67b26fe | |||
| c2167a65dd | |||
| eca21f398c | |||
| f1ea2d603a | |||
| 279a6b8dbc | |||
| aa609da33d | |||
| f88a5bf7ec | |||
| fd6d55be18 | |||
| c8d2c139c7 | |||
| e2daca574f | |||
| a0b3189430 | |||
| c53dfd544d | |||
| fce816f13f | |||
| a52fdb3b9f | |||
| 04eb7209ce | |||
| 656e98687f | |||
|
|
3907672185 | ||
|
|
0961c01660 | ||
|
|
ef18a0017c | ||
| 7a4ea21658 | |||
| b45383de33 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,3 +37,5 @@ dist/
|
|||||||
|
|
||||||
# env
|
# env
|
||||||
*.env
|
*.env
|
||||||
|
*chrome_profile_ddma*
|
||||||
|
*chrome_profile_dentaquest*
|
||||||
@@ -9,8 +9,8 @@ import { backupDatabaseToPath } from "../services/databaseBackupService";
|
|||||||
* Creates a backup notification if overdue
|
* Creates a backup notification if overdue
|
||||||
*/
|
*/
|
||||||
export const startBackupCron = () => {
|
export const startBackupCron = () => {
|
||||||
cron.schedule("0 2 * * *", async () => {
|
cron.schedule("0 22 * * *", async () => {
|
||||||
// Every calendar days, at 2 AM
|
// Every calendar days, at 10 PM
|
||||||
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
|
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
|
||||||
|
|
||||||
console.log("🔄 Running backup check...");
|
console.log("🔄 Running backup check...");
|
||||||
|
|||||||
164
apps/Backend/src/routes/appointments-procedures.ts
Normal file
164
apps/Backend/src/routes/appointments-procedures.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
import {
|
||||||
|
insertAppointmentProcedureSchema,
|
||||||
|
updateAppointmentProcedureSchema,
|
||||||
|
} from "@repo/db/types";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/appointment-procedures/:appointmentId
|
||||||
|
* Get all procedures for an appointment
|
||||||
|
*/
|
||||||
|
router.get("/:appointmentId", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const appointmentId = Number(req.params.appointmentId);
|
||||||
|
if (isNaN(appointmentId)) {
|
||||||
|
return res.status(400).json({ message: "Invalid appointmentId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await storage.getByAppointmentId(appointmentId);
|
||||||
|
|
||||||
|
return res.json(rows);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("GET appointment procedures error", err);
|
||||||
|
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/prefill-from-appointment/:appointmentId",
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const appointmentId = Number(req.params.appointmentId);
|
||||||
|
|
||||||
|
if (!appointmentId || isNaN(appointmentId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid appointmentId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await storage.getPrefillDataByAppointmentId(appointmentId);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return res.status(404).json({ error: "Appointment not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("prefill-from-appointment error", err);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: err.message ?? "Failed to prefill claim data" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/appointment-procedures
|
||||||
|
* Add single manual procedure
|
||||||
|
*/
|
||||||
|
router.post("/", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const parsed = insertAppointmentProcedureSchema.parse(req.body);
|
||||||
|
|
||||||
|
const created = await storage.createProcedure(parsed);
|
||||||
|
|
||||||
|
return res.json(created);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("POST appointment procedure error", err);
|
||||||
|
if (err.name === "ZodError") {
|
||||||
|
return res.status(400).json({ message: err.errors });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/appointment-procedures/bulk
|
||||||
|
* Add multiple procedures (combos)
|
||||||
|
*/
|
||||||
|
router.post("/bulk", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const rows = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
return res.status(400).json({ message: "Invalid payload" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await storage.createProceduresBulk(rows);
|
||||||
|
|
||||||
|
return res.json({ success: true, count });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("POST bulk appointment procedures error", err);
|
||||||
|
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/appointment-procedures/:id
|
||||||
|
* Update a procedure
|
||||||
|
*/
|
||||||
|
router.put("/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({ message: "Invalid id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateAppointmentProcedureSchema.parse(req.body);
|
||||||
|
|
||||||
|
const updated = await storage.updateProcedure(id, parsed);
|
||||||
|
|
||||||
|
return res.json(updated);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("PUT appointment procedure error", err);
|
||||||
|
|
||||||
|
if (err.name === "ZodError") {
|
||||||
|
return res.status(400).json({ message: err.errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/appointment-procedures/:id
|
||||||
|
* Delete single procedure
|
||||||
|
*/
|
||||||
|
router.delete("/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({ message: "Invalid id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.deleteProcedure(id);
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("DELETE appointment procedure error", err);
|
||||||
|
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/appointment-procedures/clear/:appointmentId
|
||||||
|
* Clear all procedures for appointment
|
||||||
|
*/
|
||||||
|
router.delete("/clear/:appointmentId", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const appointmentId = Number(req.params.appointmentId);
|
||||||
|
if (isNaN(appointmentId)) {
|
||||||
|
return res.status(400).json({ message: "Invalid appointmentId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.clearByAppointmentId(appointmentId);
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("CLEAR appointment procedures error", err);
|
||||||
|
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -49,7 +49,7 @@ router.get("/day", async (req: Request, res: Response): Promise<any> => {
|
|||||||
|
|
||||||
// dedupe patient ids referenced by those appointments
|
// dedupe patient ids referenced by those appointments
|
||||||
const patientIds = Array.from(
|
const patientIds = Array.from(
|
||||||
new Set(appointments.map((a) => a.patientId).filter(Boolean))
|
new Set(appointments.map((a) => a.patientId).filter(Boolean)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const patients = patientIds.length
|
const patients = patientIds.length
|
||||||
@@ -76,6 +76,43 @@ router.get("/recent", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/appointments/:id/procedure-notes
|
||||||
|
*/
|
||||||
|
router.get("/:id/procedure-notes", async (req: Request, res: Response) => {
|
||||||
|
const appointmentId = Number(req.params.id);
|
||||||
|
if (isNaN(appointmentId)) {
|
||||||
|
return res.status(400).json({ message: "Invalid appointment ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointment = await storage.getAppointment(appointmentId);
|
||||||
|
if (!appointment) {
|
||||||
|
return res.status(404).json({ message: "Appointment not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
procedureNotes: appointment.procedureCodeNotes ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/appointments/:id/procedure-notes
|
||||||
|
*/
|
||||||
|
router.put("/:id/procedure-notes", async (req: Request, res: Response) => {
|
||||||
|
const appointmentId = Number(req.params.id);
|
||||||
|
if (isNaN(appointmentId)) {
|
||||||
|
return res.status(400).json({ message: "Invalid appointment ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { procedureNotes } = req.body as { procedureNotes?: string };
|
||||||
|
|
||||||
|
const updated = await storage.updateAppointment(appointmentId, {
|
||||||
|
procedureCodeNotes: procedureNotes ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
// Get a single appointment by ID
|
// Get a single appointment by ID
|
||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
@@ -101,7 +138,7 @@ router.get(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: "Failed to retrieve appointment" });
|
res.status(500).json({ message: "Failed to retrieve appointment" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all appointments for a specific patient
|
// Get all appointments for a specific patient
|
||||||
@@ -128,7 +165,7 @@ router.get(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ message: "Failed to get patient appointments" });
|
res.status(500).json({ message: "Failed to get patient appointments" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,7 +199,7 @@ router.get(
|
|||||||
.status(500)
|
.status(500)
|
||||||
.json({ message: "Failed to retrieve patient for appointment" });
|
.json({ message: "Failed to retrieve patient for appointment" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a new appointment
|
// Create a new appointment
|
||||||
@@ -202,7 +239,7 @@ router.post(
|
|||||||
await storage.getPatientAppointmentByDateTime(
|
await storage.getPatientAppointmentByDateTime(
|
||||||
appointmentData.patientId,
|
appointmentData.patientId,
|
||||||
appointmentData.date,
|
appointmentData.date,
|
||||||
currentStartTime
|
currentStartTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check staff conflict at this time
|
// Check staff conflict at this time
|
||||||
@@ -210,7 +247,7 @@ router.post(
|
|||||||
appointmentData.staffId,
|
appointmentData.staffId,
|
||||||
appointmentData.date,
|
appointmentData.date,
|
||||||
currentStartTime,
|
currentStartTime,
|
||||||
sameDayAppointment?.id // Ignore self if updating
|
sameDayAppointment?.id, // Ignore self if updating
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!staffConflict) {
|
if (!staffConflict) {
|
||||||
@@ -231,7 +268,7 @@ router.post(
|
|||||||
if (sameDayAppointment?.id !== undefined) {
|
if (sameDayAppointment?.id !== undefined) {
|
||||||
const updated = await storage.updateAppointment(
|
const updated = await storage.updateAppointment(
|
||||||
sameDayAppointment.id,
|
sameDayAppointment.id,
|
||||||
payload
|
payload,
|
||||||
);
|
);
|
||||||
responseData = {
|
responseData = {
|
||||||
...updated,
|
...updated,
|
||||||
@@ -276,7 +313,7 @@ router.post(
|
|||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
console.log(
|
console.log(
|
||||||
"Validation error details:",
|
"Validation error details:",
|
||||||
JSON.stringify(error.format(), null, 2)
|
JSON.stringify(error.format(), null, 2),
|
||||||
);
|
);
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
message: "Validation error",
|
message: "Validation error",
|
||||||
@@ -289,7 +326,7 @@ router.post(
|
|||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update an existing appointment
|
// Update an existing appointment
|
||||||
@@ -343,7 +380,7 @@ router.put(
|
|||||||
existingAppointment.patientId,
|
existingAppointment.patientId,
|
||||||
date,
|
date,
|
||||||
startTime,
|
startTime,
|
||||||
appointmentId
|
appointmentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (patientConflict) {
|
if (patientConflict) {
|
||||||
@@ -356,7 +393,7 @@ router.put(
|
|||||||
staffId,
|
staffId,
|
||||||
date,
|
date,
|
||||||
startTime,
|
startTime,
|
||||||
appointmentId
|
appointmentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (staffConflict) {
|
if (staffConflict) {
|
||||||
@@ -381,7 +418,7 @@ router.put(
|
|||||||
// Update appointment
|
// Update appointment
|
||||||
const updatedAppointment = await storage.updateAppointment(
|
const updatedAppointment = await storage.updateAppointment(
|
||||||
appointmentId,
|
appointmentId,
|
||||||
updatePayload
|
updatePayload,
|
||||||
);
|
);
|
||||||
return res.json(updatedAppointment);
|
return res.json(updatedAppointment);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -390,7 +427,7 @@ router.put(
|
|||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
console.log(
|
console.log(
|
||||||
"Validation error details:",
|
"Validation error details:",
|
||||||
JSON.stringify(error.format(), null, 2)
|
JSON.stringify(error.format(), null, 2),
|
||||||
);
|
);
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
message: "Validation error",
|
message: "Validation error",
|
||||||
@@ -403,7 +440,7 @@ router.put(
|
|||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete an appointment
|
// Delete an appointment
|
||||||
|
|||||||
@@ -336,6 +336,15 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- TRANSFORM serviceLines
|
// --- TRANSFORM serviceLines
|
||||||
|
if (
|
||||||
|
!Array.isArray(req.body.serviceLines) ||
|
||||||
|
req.body.serviceLines.length === 0
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "At least one service line is required to create a claim",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(req.body.serviceLines)) {
|
if (Array.isArray(req.body.serviceLines)) {
|
||||||
req.body.serviceLines = req.body.serviceLines.map(
|
req.body.serviceLines = req.body.serviceLines.map(
|
||||||
(line: InputServiceLine) => ({
|
(line: InputServiceLine) => ({
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ router.put(
|
|||||||
|
|
||||||
const updated = await storage.updatePdfFile(id, {
|
const updated = await storage.updatePdfFile(id, {
|
||||||
filename: file?.originalname,
|
filename: file?.originalname,
|
||||||
pdfData: file?.buffer,
|
pdfData: file?.buffer as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated)
|
if (!updated)
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import patientsRoutes from "./patients";
|
import patientsRoutes from "./patients";
|
||||||
import appointmentsRoutes from "./appointments";
|
import appointmentsRoutes from "./appointments";
|
||||||
|
import appointmentProceduresRoutes from "./appointments-procedures";
|
||||||
import usersRoutes from "./users";
|
import usersRoutes from "./users";
|
||||||
import staffsRoutes from "./staffs";
|
import staffsRoutes from "./staffs";
|
||||||
|
import npiProvidersRoutes from "./npiProviders";
|
||||||
import claimsRoutes from "./claims";
|
import claimsRoutes from "./claims";
|
||||||
import patientDataExtractionRoutes from "./patientDataExtraction";
|
import patientDataExtractionRoutes from "./patientDataExtraction";
|
||||||
import insuranceCredsRoutes from "./insuranceCreds";
|
import insuranceCredsRoutes from "./insuranceCreds";
|
||||||
import documentsRoutes from "./documents";
|
import documentsRoutes from "./documents";
|
||||||
import insuranceStatusRoutes from "./insuranceStatus";
|
import insuranceStatusRoutes from "./insuranceStatus";
|
||||||
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||||
|
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
|
||||||
import paymentsRoutes from "./payments";
|
import paymentsRoutes from "./payments";
|
||||||
import databaseManagementRoutes from "./database-management";
|
import databaseManagementRoutes from "./database-management";
|
||||||
import notificationsRoutes from "./notifications";
|
import notificationsRoutes from "./notifications";
|
||||||
@@ -21,14 +24,17 @@ const router = Router();
|
|||||||
|
|
||||||
router.use("/patients", patientsRoutes);
|
router.use("/patients", patientsRoutes);
|
||||||
router.use("/appointments", appointmentsRoutes);
|
router.use("/appointments", appointmentsRoutes);
|
||||||
|
router.use("/appointment-procedures", appointmentProceduresRoutes);
|
||||||
router.use("/users", usersRoutes);
|
router.use("/users", usersRoutes);
|
||||||
router.use("/staffs", staffsRoutes);
|
router.use("/staffs", staffsRoutes);
|
||||||
|
router.use("/npiProviders", npiProvidersRoutes);
|
||||||
router.use("/patientDataExtraction", patientDataExtractionRoutes);
|
router.use("/patientDataExtraction", patientDataExtractionRoutes);
|
||||||
router.use("/claims", claimsRoutes);
|
router.use("/claims", claimsRoutes);
|
||||||
router.use("/insuranceCreds", insuranceCredsRoutes);
|
router.use("/insuranceCreds", insuranceCredsRoutes);
|
||||||
router.use("/documents", documentsRoutes);
|
router.use("/documents", documentsRoutes);
|
||||||
router.use("/insurance-status", insuranceStatusRoutes);
|
router.use("/insurance-status", insuranceStatusRoutes);
|
||||||
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||||
|
router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes);
|
||||||
router.use("/payments", paymentsRoutes);
|
router.use("/payments", paymentsRoutes);
|
||||||
router.use("/database-management", databaseManagementRoutes);
|
router.use("/database-management", databaseManagementRoutes);
|
||||||
router.use("/notifications", notificationsRoutes);
|
router.use("/notifications", notificationsRoutes);
|
||||||
|
|||||||
700
apps/Backend/src/routes/insuranceStatusDentaQuest.ts
Normal file
700
apps/Backend/src/routes/insuranceStatusDentaQuest.ts
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
import {
|
||||||
|
forwardToSeleniumDentaQuestEligibilityAgent,
|
||||||
|
forwardOtpToSeleniumDentaQuestAgent,
|
||||||
|
getSeleniumDentaQuestSessionStatus,
|
||||||
|
} from "../services/seleniumDentaQuestInsuranceEligibilityClient";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import fsSync from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import PDFDocument from "pdfkit";
|
||||||
|
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
|
||||||
|
import {
|
||||||
|
InsertPatient,
|
||||||
|
insertPatientSchema,
|
||||||
|
} from "../../../../packages/db/types/patient-types";
|
||||||
|
import { io } from "../socket";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/** Job context stored in memory by sessionId */
|
||||||
|
interface DentaQuestJobContext {
|
||||||
|
userId: number;
|
||||||
|
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
|
||||||
|
socketId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dentaquestJobs: Record<string, DentaQuestJobContext> = {};
|
||||||
|
|
||||||
|
/** Utility: naive name splitter */
|
||||||
|
function splitName(fullName?: string | null) {
|
||||||
|
if (!fullName) return { firstName: "", lastName: "" };
|
||||||
|
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||||
|
const firstName = parts.shift() ?? "";
|
||||||
|
const lastName = parts.join(" ") ?? "";
|
||||||
|
return { firstName, lastName };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const doc = new PDFDocument({ autoFirstPage: false });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
doc.on("data", (chunk: any) => chunks.push(chunk));
|
||||||
|
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on("error", (err: any) => reject(err));
|
||||||
|
|
||||||
|
const A4_WIDTH = 595.28; // points
|
||||||
|
const A4_HEIGHT = 841.89; // points
|
||||||
|
|
||||||
|
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
|
||||||
|
|
||||||
|
doc.image(imagePath, 0, 0, {
|
||||||
|
fit: [A4_WIDTH, A4_HEIGHT],
|
||||||
|
align: "center",
|
||||||
|
valign: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure patient exists for given insuranceId.
|
||||||
|
*/
|
||||||
|
async function createOrUpdatePatientByInsuranceId(options: {
|
||||||
|
insuranceId: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
dob?: string | Date | null;
|
||||||
|
userId: number;
|
||||||
|
}) {
|
||||||
|
const { insuranceId, firstName, lastName, dob, userId } = options;
|
||||||
|
if (!insuranceId) throw new Error("Missing insuranceId");
|
||||||
|
|
||||||
|
const incomingFirst = (firstName || "").trim();
|
||||||
|
const incomingLast = (lastName || "").trim();
|
||||||
|
|
||||||
|
let patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||||
|
|
||||||
|
if (patient && patient.id) {
|
||||||
|
const updates: any = {};
|
||||||
|
if (
|
||||||
|
incomingFirst &&
|
||||||
|
String(patient.firstName ?? "").trim() !== incomingFirst
|
||||||
|
) {
|
||||||
|
updates.firstName = incomingFirst;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
incomingLast &&
|
||||||
|
String(patient.lastName ?? "").trim() !== incomingLast
|
||||||
|
) {
|
||||||
|
updates.lastName = incomingLast;
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await storage.updatePatient(patient.id, updates);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const createPayload: any = {
|
||||||
|
firstName: incomingFirst,
|
||||||
|
lastName: incomingLast,
|
||||||
|
dateOfBirth: dob,
|
||||||
|
gender: "",
|
||||||
|
phone: "",
|
||||||
|
userId,
|
||||||
|
insuranceId,
|
||||||
|
};
|
||||||
|
let patientData: InsertPatient;
|
||||||
|
try {
|
||||||
|
patientData = insertPatientSchema.parse(createPayload);
|
||||||
|
} catch (err) {
|
||||||
|
const safePayload = { ...createPayload };
|
||||||
|
delete (safePayload as any).dateOfBirth;
|
||||||
|
patientData = insertPatientSchema.parse(safePayload);
|
||||||
|
}
|
||||||
|
await storage.createPatient(patientData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When Selenium finishes for a given sessionId, run your patient + PDF pipeline,
|
||||||
|
* and return the final API response shape.
|
||||||
|
*/
|
||||||
|
async function handleDentaQuestCompletedJob(
|
||||||
|
sessionId: string,
|
||||||
|
job: DentaQuestJobContext,
|
||||||
|
seleniumResult: any
|
||||||
|
) {
|
||||||
|
let createdPdfFileId: number | null = null;
|
||||||
|
const outputResult: any = {};
|
||||||
|
|
||||||
|
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
||||||
|
try {
|
||||||
|
// 1) ensuring memberid.
|
||||||
|
const insuranceEligibilityData = job.insuranceEligibilityData;
|
||||||
|
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||||
|
if (!insuranceId) {
|
||||||
|
throw new Error("Missing memberId for DentaQuest job");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Create or update patient (with name from selenium result if available)
|
||||||
|
const patientNameFromResult =
|
||||||
|
typeof seleniumResult?.patientName === "string"
|
||||||
|
? seleniumResult.patientName.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { firstName, lastName } = splitName(patientNameFromResult);
|
||||||
|
|
||||||
|
await createOrUpdatePatientByInsuranceId({
|
||||||
|
insuranceId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
dob: insuranceEligibilityData.dateOfBirth,
|
||||||
|
userId: job.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Update patient status + PDF upload
|
||||||
|
const patient = await storage.getPatientByInsuranceId(
|
||||||
|
insuranceEligibilityData.memberId
|
||||||
|
);
|
||||||
|
if (!patient?.id) {
|
||||||
|
outputResult.patientUpdateStatus =
|
||||||
|
"Patient not found; no update performed";
|
||||||
|
return {
|
||||||
|
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||||
|
pdfUploadStatus: "none",
|
||||||
|
pdfFileId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// update patient status.
|
||||||
|
const newStatus =
|
||||||
|
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||||
|
await storage.updatePatient(patient.id, { status: newStatus });
|
||||||
|
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||||
|
|
||||||
|
// convert screenshot -> pdf if available
|
||||||
|
let pdfBuffer: Buffer | null = null;
|
||||||
|
let generatedPdfPath: string | null = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
seleniumResult &&
|
||||||
|
seleniumResult.ss_path &&
|
||||||
|
typeof seleniumResult.ss_path === "string" &&
|
||||||
|
(seleniumResult.ss_path.endsWith(".png") ||
|
||||||
|
seleniumResult.ss_path.endsWith(".jpg") ||
|
||||||
|
seleniumResult.ss_path.endsWith(".jpeg"))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!fsSync.existsSync(seleniumResult.ss_path)) {
|
||||||
|
throw new Error(
|
||||||
|
`Screenshot file not found: ${seleniumResult.ss_path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
|
||||||
|
|
||||||
|
const pdfFileName = `dentaquest_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
|
||||||
|
generatedPdfPath = path.join(
|
||||||
|
path.dirname(seleniumResult.ss_path),
|
||||||
|
pdfFileName
|
||||||
|
);
|
||||||
|
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
||||||
|
|
||||||
|
// ensure cleanup uses this
|
||||||
|
seleniumResult.pdf_path = generatedPdfPath;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to convert screenshot to PDF:", err);
|
||||||
|
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outputResult.pdfUploadStatus =
|
||||||
|
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdfBuffer && generatedPdfPath) {
|
||||||
|
const groupTitle = "Eligibility Status";
|
||||||
|
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||||
|
|
||||||
|
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||||
|
patient.id,
|
||||||
|
groupTitleKey
|
||||||
|
);
|
||||||
|
if (!group) {
|
||||||
|
group = await storage.createPdfGroup(
|
||||||
|
patient.id,
|
||||||
|
groupTitle,
|
||||||
|
groupTitleKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!group?.id) {
|
||||||
|
throw new Error("PDF group creation failed: missing group ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await storage.createPdfFile(
|
||||||
|
group.id,
|
||||||
|
path.basename(generatedPdfPath),
|
||||||
|
pdfBuffer
|
||||||
|
);
|
||||||
|
if (created && typeof created === "object" && "id" in created) {
|
||||||
|
createdPdfFileId = Number(created.id);
|
||||||
|
}
|
||||||
|
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||||
|
} else {
|
||||||
|
outputResult.pdfUploadStatus =
|
||||||
|
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||||
|
pdfUploadStatus: outputResult.pdfUploadStatus,
|
||||||
|
pdfFileId: createdPdfFileId,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||||
|
pdfUploadStatus:
|
||||||
|
outputResult.pdfUploadStatus ??
|
||||||
|
`Failed to process DentaQuest job: ${err?.message ?? String(err)}`,
|
||||||
|
pdfFileId: createdPdfFileId,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// ALWAYS attempt cleanup of temp files
|
||||||
|
try {
|
||||||
|
if (seleniumResult && seleniumResult.pdf_path) {
|
||||||
|
await emptyFolderContainingFile(seleniumResult.pdf_path);
|
||||||
|
} else if (seleniumResult && seleniumResult.ss_path) {
|
||||||
|
await emptyFolderContainingFile(seleniumResult.ss_path);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[dentaquest-eligibility] no pdf_path or ss_path available to cleanup`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.error(
|
||||||
|
`[dentaquest-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
|
||||||
|
cleanupErr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- top of file, alongside dentaquestJobs ---
|
||||||
|
let currentFinalSessionId: string | null = null;
|
||||||
|
let currentFinalResult: any = null;
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
function log(tag: string, msg: string, ctx?: any) {
|
||||||
|
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
||||||
|
if (!socketId) {
|
||||||
|
log("socket", "no socketId for emit", { event });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const socket = io?.sockets.sockets.get(socketId);
|
||||||
|
if (!socket) {
|
||||||
|
log("socket", "socket not found (maybe disconnected)", {
|
||||||
|
socketId,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.emit(event, payload);
|
||||||
|
log("socket", "emitted", { socketId, event });
|
||||||
|
} catch (err: any) {
|
||||||
|
log("socket", "emit failed", { socketId, event, err: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls Python agent for session status and emits socket events:
|
||||||
|
* - 'selenium:otp_required' when waiting_for_otp
|
||||||
|
* - 'selenium:session_update' when completed/error
|
||||||
|
* - absolute timeout + transient error handling.
|
||||||
|
* - pollTimeoutMs default = 2 minutes (adjust where invoked)
|
||||||
|
*/
|
||||||
|
async function pollAgentSessionAndProcess(
|
||||||
|
sessionId: string,
|
||||||
|
socketId?: string,
|
||||||
|
pollTimeoutMs = 2 * 60 * 1000
|
||||||
|
) {
|
||||||
|
const maxAttempts = 300;
|
||||||
|
const baseDelayMs = 1000;
|
||||||
|
const maxTransientErrors = 12;
|
||||||
|
|
||||||
|
// NEW: give up if same non-terminal status repeats this many times
|
||||||
|
const noProgressLimit = 100;
|
||||||
|
|
||||||
|
const job = dentaquestJobs[sessionId];
|
||||||
|
let transientErrorCount = 0;
|
||||||
|
let consecutiveNoProgress = 0;
|
||||||
|
let lastStatus: string | null = null;
|
||||||
|
const deadline = Date.now() + pollTimeoutMs;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
// absolute deadline check
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
emitSafe(socketId, "selenium:session_update", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`,
|
||||||
|
});
|
||||||
|
delete dentaquestJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
"poller",
|
||||||
|
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const st = await getSeleniumDentaQuestSessionStatus(sessionId);
|
||||||
|
const status = st?.status ?? null;
|
||||||
|
log("poller", "got status", {
|
||||||
|
sessionId,
|
||||||
|
status,
|
||||||
|
message: st?.message,
|
||||||
|
resultKeys: st?.result ? Object.keys(st.result) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset transient errors on success
|
||||||
|
transientErrorCount = 0;
|
||||||
|
|
||||||
|
// if status unchanged and non-terminal, increment no-progress counter
|
||||||
|
const isTerminalLike =
|
||||||
|
status === "completed" || status === "error" || status === "not_found";
|
||||||
|
if (status === lastStatus && !isTerminalLike) {
|
||||||
|
consecutiveNoProgress++;
|
||||||
|
} else {
|
||||||
|
consecutiveNoProgress = 0;
|
||||||
|
}
|
||||||
|
lastStatus = status;
|
||||||
|
|
||||||
|
// if no progress for too many consecutive polls -> abort
|
||||||
|
if (consecutiveNoProgress >= noProgressLimit) {
|
||||||
|
emitSafe(socketId, "selenium:session_update", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`,
|
||||||
|
});
|
||||||
|
emitSafe(socketId, "selenium:session_error", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message: "No progress from selenium agent",
|
||||||
|
});
|
||||||
|
delete dentaquestJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// always emit debug to client if socket exists
|
||||||
|
emitSafe(socketId, "selenium:debug", {
|
||||||
|
session_id: sessionId,
|
||||||
|
attempt,
|
||||||
|
status,
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// If agent is waiting for OTP, inform client but keep polling (do not return)
|
||||||
|
if (status === "waiting_for_otp") {
|
||||||
|
emitSafe(socketId, "selenium:otp_required", {
|
||||||
|
session_id: sessionId,
|
||||||
|
message: "OTP required. Please enter the OTP.",
|
||||||
|
});
|
||||||
|
// do not return — keep polling (allows same poller to pick up completion)
|
||||||
|
await new Promise((r) => setTimeout(r, baseDelayMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed path
|
||||||
|
if (status === "completed") {
|
||||||
|
log("poller", "agent completed; processing result", {
|
||||||
|
sessionId,
|
||||||
|
resultKeys: st.result ? Object.keys(st.result) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist raw result so frontend can fetch if socket disconnects
|
||||||
|
currentFinalSessionId = sessionId;
|
||||||
|
currentFinalResult = {
|
||||||
|
rawSelenium: st.result,
|
||||||
|
processedAt: null,
|
||||||
|
final: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let finalResult: any = null;
|
||||||
|
if (job && st.result) {
|
||||||
|
try {
|
||||||
|
finalResult = await handleDentaQuestCompletedJob(
|
||||||
|
sessionId,
|
||||||
|
job,
|
||||||
|
st.result
|
||||||
|
);
|
||||||
|
currentFinalResult.final = finalResult;
|
||||||
|
currentFinalResult.processedAt = Date.now();
|
||||||
|
} catch (err: any) {
|
||||||
|
currentFinalResult.final = {
|
||||||
|
error: "processing_failed",
|
||||||
|
detail: err?.message ?? String(err),
|
||||||
|
};
|
||||||
|
currentFinalResult.processedAt = Date.now();
|
||||||
|
log("poller", "handleDentaQuestCompletedJob failed", {
|
||||||
|
sessionId,
|
||||||
|
err: err?.message ?? err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentFinalResult.final = {
|
||||||
|
error: "no_job_or_no_result",
|
||||||
|
};
|
||||||
|
currentFinalResult.processedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit final update (if socket present)
|
||||||
|
emitSafe(socketId, "selenium:session_update", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "completed",
|
||||||
|
rawSelenium: st.result,
|
||||||
|
final: currentFinalResult.final,
|
||||||
|
});
|
||||||
|
|
||||||
|
// cleanup job context
|
||||||
|
delete dentaquestJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal error / not_found
|
||||||
|
if (status === "error" || status === "not_found") {
|
||||||
|
const emitPayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
status,
|
||||||
|
message: st?.message || "Selenium session error",
|
||||||
|
};
|
||||||
|
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||||
|
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||||
|
delete dentaquestJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const axiosStatus =
|
||||||
|
err?.response?.status ?? (err?.status ? Number(err.status) : undefined);
|
||||||
|
const errCode = err?.code ?? err?.errno;
|
||||||
|
const errMsg = err?.message ?? String(err);
|
||||||
|
const errData = err?.response?.data ?? null;
|
||||||
|
|
||||||
|
// If agent explicitly returned 404 -> terminal (session gone)
|
||||||
|
if (
|
||||||
|
axiosStatus === 404 ||
|
||||||
|
(typeof errMsg === "string" && errMsg.includes("not_found"))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit not_found to client
|
||||||
|
const emitPayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "not_found",
|
||||||
|
message:
|
||||||
|
errData?.detail || "Selenium session not found (agent cleaned up).",
|
||||||
|
};
|
||||||
|
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||||
|
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||||
|
|
||||||
|
// Remove job context and stop polling
|
||||||
|
delete dentaquestJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed transient error logging
|
||||||
|
transientErrorCount++;
|
||||||
|
if (transientErrorCount > maxTransientErrors) {
|
||||||
|
const emitPayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message:
|
||||||
|
"Repeated network errors while polling selenium agent; giving up.",
|
||||||
|
};
|
||||||
|
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||||
|
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||||
|
delete dentaquestJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backoffMs = Math.min(
|
||||||
|
30_000,
|
||||||
|
baseDelayMs * Math.pow(2, transientErrorCount - 1)
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
`${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}`
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
`${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt`
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, backoffMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// normal poll interval
|
||||||
|
await new Promise((r) => setTimeout(r, baseDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// overall timeout fallback
|
||||||
|
emitSafe(socketId, "selenium:session_update", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message: "Polling timeout while waiting for selenium session",
|
||||||
|
});
|
||||||
|
delete dentaquestJobs[sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /dentaquest-eligibility
|
||||||
|
* Starts DentaQuest eligibility Selenium job.
|
||||||
|
* Expects:
|
||||||
|
* - req.body.data: stringified JSON like your existing /eligibility-check
|
||||||
|
* - req.body.socketId: socket.io client id
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/dentaquest-eligibility",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
if (!req.body.data) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing Insurance Eligibility data for selenium" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user || !req.user.id) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawData =
|
||||||
|
typeof req.body.data === "string"
|
||||||
|
? JSON.parse(req.body.data)
|
||||||
|
: req.body.data;
|
||||||
|
|
||||||
|
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||||
|
req.user.id,
|
||||||
|
rawData.insuranceSiteKey
|
||||||
|
);
|
||||||
|
if (!credentials) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error:
|
||||||
|
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichedData = {
|
||||||
|
...rawData,
|
||||||
|
dentaquestUsername: credentials.username,
|
||||||
|
dentaquestPassword: credentials.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const socketId: string | undefined = req.body.socketId;
|
||||||
|
|
||||||
|
const agentResp =
|
||||||
|
await forwardToSeleniumDentaQuestEligibilityAgent(enrichedData);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!agentResp ||
|
||||||
|
agentResp.status !== "started" ||
|
||||||
|
!agentResp.session_id
|
||||||
|
) {
|
||||||
|
return res.status(502).json({
|
||||||
|
error: "Selenium agent did not return a started session",
|
||||||
|
detail: agentResp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = agentResp.session_id as string;
|
||||||
|
|
||||||
|
// Save job context
|
||||||
|
dentaquestJobs[sessionId] = {
|
||||||
|
userId: req.user.id,
|
||||||
|
insuranceEligibilityData: enrichedData,
|
||||||
|
socketId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// start polling in background to notify client via socket and process job
|
||||||
|
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
|
||||||
|
console.warn("pollAgentSessionAndProcess failed", e)
|
||||||
|
);
|
||||||
|
|
||||||
|
// reply immediately with started status
|
||||||
|
return res.json({ status: "started", session_id: sessionId });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: err.message || "Failed to start DentaQuest selenium agent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /selenium/submit-otp
|
||||||
|
* Body: { session_id, otp, socketId? }
|
||||||
|
* Forwards OTP to Python agent and optionally notifies client socket.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/selenium/submit-otp",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
const { session_id: sessionId, otp, socketId } = req.body;
|
||||||
|
if (!sessionId || !otp) {
|
||||||
|
return res.status(400).json({ error: "session_id and otp are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await forwardOtpToSeleniumDentaQuestAgent(sessionId, otp);
|
||||||
|
|
||||||
|
// emit OTP accepted (if socket present)
|
||||||
|
emitSafe(socketId, "selenium:otp_submitted", {
|
||||||
|
session_id: sessionId,
|
||||||
|
result: r,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(r);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(
|
||||||
|
"Failed to forward OTP:",
|
||||||
|
err?.response?.data || err?.message || err
|
||||||
|
);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Failed to forward otp to selenium agent",
|
||||||
|
detail: err?.message || err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /selenium/session/:sid/final
|
||||||
|
router.get(
|
||||||
|
"/selenium/session/:sid/final",
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const sid = req.params.sid;
|
||||||
|
if (!sid) return res.status(400).json({ error: "session id required" });
|
||||||
|
|
||||||
|
// Only the current in-memory result is available
|
||||||
|
if (currentFinalSessionId !== sid || !currentFinalResult) {
|
||||||
|
return res.status(404).json({ error: "final result not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(currentFinalResult);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
99
apps/Backend/src/routes/npiProviders.ts
Normal file
99
apps/Backend/src/routes/npiProviders.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import express, { Request, Response } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
import { insertNpiProviderSchema } from "@repo/db/types";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.id) {
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = await storage.getNpiProvidersByUser(req.user.id);
|
||||||
|
res.status(200).json(providers);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to fetch NPI providers",
|
||||||
|
details: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.id) {
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = insertNpiProviderSchema.safeParse({
|
||||||
|
...req.body,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
const flat = parsed.error.flatten();
|
||||||
|
const firstError =
|
||||||
|
Object.values(flat.fieldErrors)[0]?.[0] || "Invalid input";
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
message: firstError,
|
||||||
|
details: flat.fieldErrors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await storage.createNpiProvider(parsed.data);
|
||||||
|
res.status(201).json(provider);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "P2002") {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "This NPI already exists for the user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to create NPI provider",
|
||||||
|
details: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
||||||
|
|
||||||
|
const provider = await storage.updateNpiProvider(id, req.body);
|
||||||
|
res.status(200).json(provider);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to update NPI provider",
|
||||||
|
details: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.id) {
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
||||||
|
|
||||||
|
const ok = await storage.deleteNpiProvider(req.user.id, id);
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(404).json({ message: "NPI provider not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to delete NPI provider",
|
||||||
|
details: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { z } from "zod";
|
import {
|
||||||
import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
StaffCreateBody,
|
||||||
|
StaffCreateInput,
|
||||||
type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
|
staffCreateSchema,
|
||||||
|
staffUpdateSchema,
|
||||||
const staffCreateSchema = StaffUncheckedCreateInputObjectSchema;
|
} from "@repo/db/types";
|
||||||
const staffUpdateSchema = (
|
|
||||||
StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
|
||||||
).partial();
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const validatedData = staffCreateSchema.parse(req.body);
|
const userId = req.user!.id; // from auth middleware
|
||||||
const newStaff = await storage.createStaff(validatedData);
|
|
||||||
|
const body = staffCreateSchema.parse(req.body) as StaffCreateBody;
|
||||||
|
|
||||||
|
const data: StaffCreateInput = {
|
||||||
|
...body,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newStaff = await storage.createStaff(data);
|
||||||
res.status(200).json(newStaff);
|
res.status(200).json(newStaff);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create staff:", error);
|
console.error("Failed to create staff:", error);
|
||||||
@@ -46,12 +51,17 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
|||||||
const validatedData = staffUpdateSchema.parse(req.body);
|
const validatedData = staffUpdateSchema.parse(req.body);
|
||||||
const updatedStaff = await storage.updateStaff(
|
const updatedStaff = await storage.updateStaff(
|
||||||
parsedStaffId,
|
parsedStaffId,
|
||||||
validatedData
|
validatedData,
|
||||||
);
|
);
|
||||||
if (!updatedStaff) return res.status(404).send("Staff not found");
|
if (!updatedStaff) return res.status(404).send("Staff not found");
|
||||||
|
|
||||||
res.json(updatedStaff);
|
res.json(updatedStaff);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (error.message?.includes("displayOrder")) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
console.error("Failed to update staff:", error);
|
console.error("Failed to update staff:", error);
|
||||||
res.status(500).send("Failed to update staff");
|
res.status(500).send("Failed to update staff");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import http from "http";
|
||||||
|
import https from "https";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export interface SeleniumPayload {
|
||||||
|
data: any;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL;
|
||||||
|
|
||||||
|
const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
|
||||||
|
const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: SELENIUM_AGENT_BASE,
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
|
httpAgent,
|
||||||
|
httpsAgent,
|
||||||
|
validateStatus: (s) => s >= 200 && s < 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requestWithRetries(
|
||||||
|
config: any,
|
||||||
|
retries = 4,
|
||||||
|
baseBackoffMs = 300
|
||||||
|
) {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
const r = await client.request(config);
|
||||||
|
if (![502, 503, 504].includes(r.status)) return r;
|
||||||
|
console.warn(
|
||||||
|
`[selenium-dentaquest-client] retryable HTTP status ${r.status} (attempt ${attempt})`
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
const code = err?.code;
|
||||||
|
const isTransient =
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "EPIPE" ||
|
||||||
|
code === "ETIMEDOUT";
|
||||||
|
if (!isTransient) throw err;
|
||||||
|
console.warn(
|
||||||
|
`[selenium-dentaquest-client] transient network error ${code} (attempt ${attempt})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
|
||||||
|
}
|
||||||
|
// final attempt (let exception bubble if it fails)
|
||||||
|
return client.request(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
function log(tag: string, msg: string, ctx?: any) {
|
||||||
|
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forwardToSeleniumDentaQuestEligibilityAgent(
|
||||||
|
insuranceEligibilityData: any
|
||||||
|
): Promise<any> {
|
||||||
|
const payload = { data: insuranceEligibilityData };
|
||||||
|
const url = `/dentaquest-eligibility`;
|
||||||
|
log("selenium-dentaquest-client", "POST dentaquest-eligibility", {
|
||||||
|
url: SELENIUM_AGENT_BASE + url,
|
||||||
|
keys: Object.keys(payload),
|
||||||
|
});
|
||||||
|
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
|
||||||
|
log("selenium-dentaquest-client", "agent response", {
|
||||||
|
status: r.status,
|
||||||
|
dataKeys: r.data ? Object.keys(r.data) : null,
|
||||||
|
});
|
||||||
|
if (r.status >= 500)
|
||||||
|
throw new Error(`Selenium agent server error: ${r.status}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forwardOtpToSeleniumDentaQuestAgent(
|
||||||
|
sessionId: string,
|
||||||
|
otp: string
|
||||||
|
): Promise<any> {
|
||||||
|
const url = `/dentaquest-submit-otp`;
|
||||||
|
log("selenium-dentaquest-client", "POST dentaquest-submit-otp", {
|
||||||
|
url: SELENIUM_AGENT_BASE + url,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
const r = await requestWithRetries(
|
||||||
|
{ url, method: "POST", data: { session_id: sessionId, otp } },
|
||||||
|
4
|
||||||
|
);
|
||||||
|
log("selenium-dentaquest-client", "submit-otp response", {
|
||||||
|
status: r.status,
|
||||||
|
data: r.data,
|
||||||
|
});
|
||||||
|
if (r.status >= 500)
|
||||||
|
throw new Error(`Selenium agent server error on submit-otp: ${r.status}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSeleniumDentaQuestSessionStatus(
|
||||||
|
sessionId: string
|
||||||
|
): Promise<any> {
|
||||||
|
const url = `/dentaquest-session/${sessionId}/status`;
|
||||||
|
log("selenium-dentaquest-client", "GET session status", {
|
||||||
|
url: SELENIUM_AGENT_BASE + url,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
const r = await requestWithRetries({ url, method: "GET" }, 4);
|
||||||
|
log("selenium-dentaquest-client", "session status response", {
|
||||||
|
status: r.status,
|
||||||
|
dataKeys: r.data ? Object.keys(r.data) : null,
|
||||||
|
});
|
||||||
|
if (r.status === 404) {
|
||||||
|
const e: any = new Error("not_found");
|
||||||
|
e.response = { status: 404, data: r.data };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
100
apps/Backend/src/storage/appointment-procedures-storage.ts
Normal file
100
apps/Backend/src/storage/appointment-procedures-storage.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
Appointment,
|
||||||
|
AppointmentProcedure,
|
||||||
|
InsertAppointmentProcedure,
|
||||||
|
Patient,
|
||||||
|
UpdateAppointmentProcedure,
|
||||||
|
} from "@repo/db/types";
|
||||||
|
import { prisma as db } from "@repo/db/client";
|
||||||
|
|
||||||
|
export interface IAppointmentProceduresStorage {
|
||||||
|
getByAppointmentId(appointmentId: number): Promise<AppointmentProcedure[]>;
|
||||||
|
getPrefillDataByAppointmentId(appointmentId: number): Promise<{
|
||||||
|
appointment: Appointment;
|
||||||
|
patient: Patient;
|
||||||
|
procedures: AppointmentProcedure[];
|
||||||
|
} | null>;
|
||||||
|
|
||||||
|
createProcedure(
|
||||||
|
data: InsertAppointmentProcedure
|
||||||
|
): Promise<AppointmentProcedure>;
|
||||||
|
createProceduresBulk(data: InsertAppointmentProcedure[]): Promise<number>;
|
||||||
|
updateProcedure(
|
||||||
|
id: number,
|
||||||
|
data: UpdateAppointmentProcedure
|
||||||
|
): Promise<AppointmentProcedure>;
|
||||||
|
deleteProcedure(id: number): Promise<void>;
|
||||||
|
clearByAppointmentId(appointmentId: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||||
|
async getByAppointmentId(
|
||||||
|
appointmentId: number
|
||||||
|
): Promise<AppointmentProcedure[]> {
|
||||||
|
return db.appointmentProcedure.findMany({
|
||||||
|
where: { appointmentId },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPrefillDataByAppointmentId(appointmentId: number) {
|
||||||
|
const appointment = await db.appointment.findUnique({
|
||||||
|
where: { id: appointmentId },
|
||||||
|
include: {
|
||||||
|
patient: true,
|
||||||
|
procedures: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appointment,
|
||||||
|
patient: appointment.patient,
|
||||||
|
procedures: appointment.procedures,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async createProcedure(
|
||||||
|
data: InsertAppointmentProcedure
|
||||||
|
): Promise<AppointmentProcedure> {
|
||||||
|
return db.appointmentProcedure.create({
|
||||||
|
data: data as AppointmentProcedure,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createProceduresBulk(
|
||||||
|
data: InsertAppointmentProcedure[]
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await db.appointmentProcedure.createMany({
|
||||||
|
data: data as any[],
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProcedure(
|
||||||
|
id: number,
|
||||||
|
data: UpdateAppointmentProcedure
|
||||||
|
): Promise<AppointmentProcedure> {
|
||||||
|
return db.appointmentProcedure.update({
|
||||||
|
where: { id },
|
||||||
|
data: data as any,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProcedure(id: number): Promise<void> {
|
||||||
|
await db.appointmentProcedure.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearByAppointmentId(appointmentId: number): Promise<void> {
|
||||||
|
await db.appointmentProcedure.deleteMany({
|
||||||
|
where: { appointmentId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { usersStorage } from './users-storage';
|
import { usersStorage } from './users-storage';
|
||||||
import { patientsStorage } from './patients-storage';
|
import { patientsStorage } from './patients-storage';
|
||||||
import { appointmentsStorage } from './appointements-storage';
|
import { appointmentsStorage } from './appointments-storage';
|
||||||
|
import { appointmentProceduresStorage } from './appointment-procedures-storage';
|
||||||
import { staffStorage } from './staff-storage';
|
import { staffStorage } from './staff-storage';
|
||||||
|
import { npiProviderStorage } from './npi-providers-storage';
|
||||||
import { claimsStorage } from './claims-storage';
|
import { claimsStorage } from './claims-storage';
|
||||||
import { insuranceCredsStorage } from './insurance-creds-storage';
|
import { insuranceCredsStorage } from './insurance-creds-storage';
|
||||||
import { generalPdfStorage } from './general-pdf-storage';
|
import { generalPdfStorage } from './general-pdf-storage';
|
||||||
@@ -19,7 +21,9 @@ export const storage = {
|
|||||||
...usersStorage,
|
...usersStorage,
|
||||||
...patientsStorage,
|
...patientsStorage,
|
||||||
...appointmentsStorage,
|
...appointmentsStorage,
|
||||||
|
...appointmentProceduresStorage,
|
||||||
...staffStorage,
|
...staffStorage,
|
||||||
|
...npiProviderStorage,
|
||||||
...claimsStorage,
|
...claimsStorage,
|
||||||
...insuranceCredsStorage,
|
...insuranceCredsStorage,
|
||||||
...generalPdfStorage,
|
...generalPdfStorage,
|
||||||
|
|||||||
50
apps/Backend/src/storage/npi-providers-storage.ts
Normal file
50
apps/Backend/src/storage/npi-providers-storage.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { prisma as db } from "@repo/db/client";
|
||||||
|
import { InsertNpiProvider, NpiProvider } from "@repo/db/types";
|
||||||
|
|
||||||
|
export interface INpiProviderStorage {
|
||||||
|
getNpiProvider(id: number): Promise<NpiProvider | null>;
|
||||||
|
getNpiProvidersByUser(userId: number): Promise<NpiProvider[]>;
|
||||||
|
createNpiProvider(data: InsertNpiProvider): Promise<NpiProvider>;
|
||||||
|
updateNpiProvider(
|
||||||
|
id: number,
|
||||||
|
updates: Partial<NpiProvider>,
|
||||||
|
): Promise<NpiProvider | null>;
|
||||||
|
deleteNpiProvider(userId: number, id: number): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const npiProviderStorage: INpiProviderStorage = {
|
||||||
|
async getNpiProvider(id: number) {
|
||||||
|
return db.npiProvider.findUnique({ where: { id } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getNpiProvidersByUser(userId: number) {
|
||||||
|
return db.npiProvider.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNpiProvider(data: InsertNpiProvider) {
|
||||||
|
return db.npiProvider.create({
|
||||||
|
data: data as NpiProvider,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateNpiProvider(id: number, updates: Partial<NpiProvider>) {
|
||||||
|
return db.npiProvider.update({
|
||||||
|
where: { id },
|
||||||
|
data: updates,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteNpiProvider(userId: number, id: number) {
|
||||||
|
try {
|
||||||
|
await db.npiProvider.delete({
|
||||||
|
where: { id, userId },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Staff } from "@repo/db/types";
|
import { Staff, StaffCreateInput, StaffUpdateInput } from "@repo/db/types";
|
||||||
import { prisma as db } from "@repo/db/client";
|
import { prisma as db } from "@repo/db/client";
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
getStaff(id: number): Promise<Staff | undefined>;
|
getStaff(id: number): Promise<Staff | undefined>;
|
||||||
getAllStaff(): Promise<Staff[]>;
|
getAllStaff(): Promise<Staff[]>;
|
||||||
createStaff(staff: Staff): Promise<Staff>;
|
createStaff(staff: StaffCreateInput): Promise<Staff>;
|
||||||
updateStaff(id: number, updates: Partial<Staff>): Promise<Staff | undefined>;
|
updateStaff(
|
||||||
|
id: number,
|
||||||
|
updates: StaffUpdateInput,
|
||||||
|
): Promise<Staff | undefined>;
|
||||||
deleteStaff(id: number): Promise<boolean>;
|
deleteStaff(id: number): Promise<boolean>;
|
||||||
countAppointmentsByStaffId(staffId: number): Promise<number>;
|
countAppointmentsByStaffId(staffId: number): Promise<number>;
|
||||||
countClaimsByStaffId(staffId: number): Promise<number>;
|
countClaimsByStaffId(staffId: number): Promise<number>;
|
||||||
@@ -19,38 +22,122 @@ export const staffStorage: IStorage = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getAllStaff(): Promise<Staff[]> {
|
async getAllStaff(): Promise<Staff[]> {
|
||||||
const staff = await db.staff.findMany();
|
return db.staff.findMany({
|
||||||
return staff;
|
orderBy: { displayOrder: "asc" },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async createStaff(staff: Staff): Promise<Staff> {
|
async createStaff(staff: StaffCreateInput): Promise<Staff> {
|
||||||
const createdStaff = await db.staff.create({
|
const max = await db.staff.aggregate({
|
||||||
data: staff,
|
where: {
|
||||||
|
userId: staff.userId,
|
||||||
|
displayOrder: { gt: 0 },
|
||||||
|
},
|
||||||
|
_max: { displayOrder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.staff.create({
|
||||||
|
data: {
|
||||||
|
...staff,
|
||||||
|
displayOrder: (max._max.displayOrder ?? 0) + 1,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return createdStaff;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateStaff(
|
async updateStaff(
|
||||||
id: number,
|
id: number,
|
||||||
updates: Partial<Staff>
|
updates: StaffUpdateInput,
|
||||||
): Promise<Staff | undefined> {
|
): Promise<Staff | undefined> {
|
||||||
const updatedStaff = await db.staff.update({
|
return db.$transaction(async (tx: any) => {
|
||||||
|
const staff = await tx.staff.findUnique({ where: { id } });
|
||||||
|
if (!staff) return undefined;
|
||||||
|
|
||||||
|
const { userId, displayOrder: oldOrder } = staff;
|
||||||
|
const newOrder = updates.displayOrder;
|
||||||
|
|
||||||
|
if (newOrder === undefined || newOrder === oldOrder) {
|
||||||
|
return tx.staff.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updates,
|
data: updates,
|
||||||
});
|
});
|
||||||
return updatedStaff ?? undefined;
|
}
|
||||||
|
|
||||||
|
const totalStaff = await tx.staff.count({ where: { userId } });
|
||||||
|
|
||||||
|
if (newOrder < 1 || newOrder > totalStaff) {
|
||||||
|
throw new Error(`displayOrder must be between 1 and ${totalStaff}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const occupyingStaff = await tx.staff.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
displayOrder: newOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// CASE 1: staff already had a slot → SWAP
|
||||||
|
if (oldOrder && oldOrder > 0 && occupyingStaff) {
|
||||||
|
await tx.staff.update({
|
||||||
|
where: { id: occupyingStaff.id },
|
||||||
|
data: { displayOrder: oldOrder },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CASE 2: first-time assignment (oldOrder = 0)
|
||||||
|
if ((!oldOrder || oldOrder === 0) && occupyingStaff) {
|
||||||
|
// find first free slot
|
||||||
|
const usedOrders = await tx.staff.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
displayOrder: { gt: 0 },
|
||||||
|
},
|
||||||
|
select: { displayOrder: true },
|
||||||
|
orderBy: { displayOrder: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedSet = new Set(usedOrders.map((s: any) => s.displayOrder));
|
||||||
|
let freeSlot = 1;
|
||||||
|
while (usedSet.has(freeSlot)) freeSlot++;
|
||||||
|
|
||||||
|
await tx.staff.update({
|
||||||
|
where: { id: occupyingStaff.id },
|
||||||
|
data: { displayOrder: freeSlot },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.staff.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...updates,
|
||||||
|
displayOrder: newOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteStaff(id: number): Promise<boolean> {
|
async deleteStaff(id: number): Promise<boolean> {
|
||||||
try {
|
return db.$transaction(async (tx: any) => {
|
||||||
await db.staff.delete({ where: { id } });
|
const staff = await tx.staff.findUnique({ where: { id } });
|
||||||
return true;
|
if (!staff) return false;
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting staff:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
|
const { userId, displayOrder } = staff;
|
||||||
|
|
||||||
|
await tx.staff.delete({ where: { id } });
|
||||||
|
|
||||||
|
// Shift left to remove gap
|
||||||
|
await tx.staff.updateMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
displayOrder: { gt: displayOrder },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
displayOrder: { decrement: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
async countAppointmentsByStaffId(staffId: number): Promise<number> {
|
async countAppointmentsByStaffId(staffId: number): Promise<number> {
|
||||||
return await db.appointment.count({ where: { staffId } });
|
return await db.appointment.count({ where: { staffId } });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { Save, Pencil, X } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appointmentId: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppointmentProcedureNotes({
|
||||||
|
appointmentId,
|
||||||
|
enabled,
|
||||||
|
}: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [originalNotes, setOriginalNotes] = useState("");
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Load procedure notes
|
||||||
|
// -------------------------
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["appointment-procedure-notes", appointmentId],
|
||||||
|
enabled: enabled && !!appointmentId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/appointments/${appointmentId}/procedure-notes`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to load notes");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const value = data.procedureNotes ?? "";
|
||||||
|
|
||||||
|
setNotes(value);
|
||||||
|
setOriginalNotes(value);
|
||||||
|
setIsEditing(false);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Save procedure notes
|
||||||
|
// -------------------------
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"PUT",
|
||||||
|
`/api/appointments/${appointmentId}/procedure-notes`,
|
||||||
|
{ procedureNotes: notes }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to save notes");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Procedure notes saved" });
|
||||||
|
setOriginalNotes(notes);
|
||||||
|
setIsEditing(false);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["appointment-procedure-notes", appointmentId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: err.message ?? "Failed to save notes",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Autofocus textarea when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setNotes(originalNotes);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = notes !== originalNotes;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-4 bg-muted/20 space-y-2">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">Procedure Notes</div>
|
||||||
|
|
||||||
|
{!isEditing ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending || !hasChanges}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Textarea */}
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className={`w-full min-h-[90px] rounded-md border px-3 py-2 text-sm transition
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-ring
|
||||||
|
${
|
||||||
|
isEditing
|
||||||
|
? "bg-background text-foreground"
|
||||||
|
: "bg-white text-foreground border-dashed border-muted-foreground/40 cursor-default"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder='Example: "#20 DO filling"'
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
readOnly={!isEditing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* View-mode hint */}
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
View only — click <span className="font-medium">Edit</span> to modify
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Trash2, Plus, Save, X } from "lucide-react";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||||
|
import {
|
||||||
|
CODE_MAP,
|
||||||
|
getPriceForCodeWithAgeFromMap,
|
||||||
|
} from "@/utils/procedureCombosMapping";
|
||||||
|
import { Patient, AppointmentProcedure } from "@repo/db/types";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||||
|
import {
|
||||||
|
DirectComboButtons,
|
||||||
|
RegularComboButtons,
|
||||||
|
} from "@/components/procedure/procedure-combo-buttons";
|
||||||
|
import { AppointmentProcedureNotes } from "./appointment-procedure-notes";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
appointmentId: number;
|
||||||
|
patientId: number;
|
||||||
|
patient: Patient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppointmentProceduresDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
appointmentId,
|
||||||
|
patientId,
|
||||||
|
patient,
|
||||||
|
}: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// state for manual add
|
||||||
|
// -----------------------------
|
||||||
|
const [manualCode, setManualCode] = useState("");
|
||||||
|
const [manualLabel, setManualLabel] = useState("");
|
||||||
|
const [manualFee, setManualFee] = useState("");
|
||||||
|
const [manualTooth, setManualTooth] = useState("");
|
||||||
|
const [manualSurface, setManualSurface] = useState("");
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// state for inline edit
|
||||||
|
// -----------------------------
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editRow, setEditRow] = useState<Partial<AppointmentProcedure>>({});
|
||||||
|
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||||
|
|
||||||
|
// for redirection to claim submission
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// fetch procedures
|
||||||
|
// -----------------------------
|
||||||
|
const { data: procedures = [], isLoading } = useQuery<AppointmentProcedure[]>(
|
||||||
|
{
|
||||||
|
queryKey: ["appointment-procedures", appointmentId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/appointment-procedures/${appointmentId}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to load procedures");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: open && !!appointmentId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// mutations
|
||||||
|
// -----------------------------
|
||||||
|
const addManualMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = {
|
||||||
|
appointmentId,
|
||||||
|
patientId,
|
||||||
|
procedureCode: manualCode,
|
||||||
|
procedureLabel: manualLabel || null,
|
||||||
|
fee: manualFee ? Number(manualFee) : null,
|
||||||
|
toothNumber: manualTooth || null,
|
||||||
|
toothSurface: manualSurface || null,
|
||||||
|
source: "MANUAL",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/appointment-procedures",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to add procedure");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Procedure added" });
|
||||||
|
setManualCode("");
|
||||||
|
setManualLabel("");
|
||||||
|
setManualFee("");
|
||||||
|
setManualTooth("");
|
||||||
|
setManualSurface("");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["appointment-procedures", appointmentId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: err.message ?? "Failed to add procedure",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkAddMutation = useMutation({
|
||||||
|
mutationFn: async (rows: any[]) => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/appointment-procedures/bulk",
|
||||||
|
rows,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to add combo procedures");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Combo added" });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["appointment-procedures", appointmentId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"DELETE",
|
||||||
|
`/api/appointment-procedures/${id}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to delete");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Deleted" });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["appointment-procedures", appointmentId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAllMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"DELETE",
|
||||||
|
`/api/appointment-procedures/clear/${appointmentId}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to clear procedures");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "All procedures cleared" });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["appointment-procedures", appointmentId],
|
||||||
|
});
|
||||||
|
setClearAllOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: err.message ?? "Failed to clear procedures",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
const res = await apiRequest(
|
||||||
|
"PUT",
|
||||||
|
`/api/appointment-procedures/${editingId}`,
|
||||||
|
editRow,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to update");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Updated" });
|
||||||
|
setEditingId(null);
|
||||||
|
setEditRow({});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["appointment-procedures", appointmentId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// handlers
|
||||||
|
// -----------------------------
|
||||||
|
const handleAddCombo = (comboKey: string) => {
|
||||||
|
const combo = PROCEDURE_COMBOS[comboKey];
|
||||||
|
if (!combo || !patient?.dateOfBirth) return;
|
||||||
|
|
||||||
|
const serviceDate = new Date();
|
||||||
|
const dob = patient.dateOfBirth;
|
||||||
|
|
||||||
|
const age = (() => {
|
||||||
|
const birth = new Date(dob);
|
||||||
|
const ref = new Date(serviceDate);
|
||||||
|
let a = ref.getFullYear() - birth.getFullYear();
|
||||||
|
const hadBirthday =
|
||||||
|
ref.getMonth() > birth.getMonth() ||
|
||||||
|
(ref.getMonth() === birth.getMonth() &&
|
||||||
|
ref.getDate() >= birth.getDate());
|
||||||
|
if (!hadBirthday) a -= 1;
|
||||||
|
return a;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const rows = combo.codes.map((code: string, idx: number) => {
|
||||||
|
const priceDecimal = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appointmentId,
|
||||||
|
patientId,
|
||||||
|
procedureCode: code,
|
||||||
|
procedureLabel: combo.label,
|
||||||
|
fee: priceDecimal.toNumber(),
|
||||||
|
source: "COMBO",
|
||||||
|
comboKey: comboKey,
|
||||||
|
toothNumber: combo.toothNumbers?.[idx] ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
bulkAddMutation.mutate(rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (row: AppointmentProcedure) => {
|
||||||
|
if (!row.id) return;
|
||||||
|
|
||||||
|
setEditingId(row.id);
|
||||||
|
setEditRow({
|
||||||
|
procedureCode: row.procedureCode,
|
||||||
|
procedureLabel: row.procedureLabel,
|
||||||
|
fee: row.fee,
|
||||||
|
toothNumber: row.toothNumber,
|
||||||
|
toothSurface: row.toothSurface,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditRow({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDirectClaim = () => {
|
||||||
|
setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualClaim = () => {
|
||||||
|
setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// UI
|
||||||
|
// -----------------------------
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-6xl max-h-[90vh] overflow-y-auto pointer-events-none"
|
||||||
|
onPointerDownOutside={(e) => {
|
||||||
|
if (clearAllOpen) {
|
||||||
|
e.preventDefault(); // block only when delete dialog is open
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
if (clearAllOpen) {
|
||||||
|
e.preventDefault(); // block only when delete dialog is open
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
Appointment Procedures
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<AppointmentProcedureNotes
|
||||||
|
appointmentId={appointmentId}
|
||||||
|
enabled={open}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ================= COMBOS ================= */}
|
||||||
|
<div className="space-y-8 pointer-events-auto">
|
||||||
|
<DirectComboButtons
|
||||||
|
onDirectCombo={(comboKey) => {
|
||||||
|
handleAddCombo(comboKey);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RegularComboButtons
|
||||||
|
onRegularCombo={(comboKey) => {
|
||||||
|
handleAddCombo(comboKey);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ================= MANUAL ADD ================= */}
|
||||||
|
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-3">
|
||||||
|
<div className="font-medium text-sm">Add Manual Procedure</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Code</Label>
|
||||||
|
<Input
|
||||||
|
value={manualCode}
|
||||||
|
onChange={(e) => setManualCode(e.target.value)}
|
||||||
|
placeholder="D0120"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Label</Label>
|
||||||
|
<Input
|
||||||
|
value={manualLabel}
|
||||||
|
onChange={(e) => setManualLabel(e.target.value)}
|
||||||
|
placeholder="Exam"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Fee</Label>
|
||||||
|
<Input
|
||||||
|
value={manualFee}
|
||||||
|
onChange={(e) => setManualFee(e.target.value)}
|
||||||
|
placeholder="100"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Tooth</Label>
|
||||||
|
<Input
|
||||||
|
value={manualTooth}
|
||||||
|
onChange={(e) => setManualTooth(e.target.value)}
|
||||||
|
placeholder="14"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Surface</Label>
|
||||||
|
<Input
|
||||||
|
value={manualSurface}
|
||||||
|
onChange={(e) => setManualSurface(e.target.value)}
|
||||||
|
placeholder="MO"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => addManualMutation.mutate()}
|
||||||
|
disabled={!manualCode || addManualMutation.isPending}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Procedure
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ================= LIST ================= */}
|
||||||
|
<div className="mt-8 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold">Selected Procedures</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={!procedures.length}
|
||||||
|
onClick={() => setClearAllOpen(true)}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg divide-y bg-white">
|
||||||
|
{/* ===== TABLE HEADER ===== */}
|
||||||
|
<div className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted/40">
|
||||||
|
<div>Code</div>
|
||||||
|
<div>Label</div>
|
||||||
|
<div>Fee</div>
|
||||||
|
<div>Tooth</div>
|
||||||
|
<div>Surface</div>
|
||||||
|
<div className="text-center">Edit</div>
|
||||||
|
<div className="text-center">Delete</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && procedures.length === 0 && (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
No procedures added
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{procedures.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-3 text-sm hover:bg-muted/40 transition"
|
||||||
|
>
|
||||||
|
{editingId === p.id ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
className="w-[90px]"
|
||||||
|
value={editRow.procedureCode ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditRow({
|
||||||
|
...editRow,
|
||||||
|
procedureCode: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
value={editRow.procedureLabel ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditRow({
|
||||||
|
...editRow,
|
||||||
|
procedureLabel: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="w-[90px]"
|
||||||
|
value={
|
||||||
|
editRow.fee !== undefined && editRow.fee !== null
|
||||||
|
? String(editRow.fee)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditRow({ ...editRow, fee: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="w-[80px]"
|
||||||
|
value={editRow.toothNumber ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditRow({
|
||||||
|
...editRow,
|
||||||
|
toothNumber: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="w-[80px]"
|
||||||
|
value={editRow.toothSurface ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditRow({
|
||||||
|
...editRow,
|
||||||
|
toothSurface: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => updateMutation.mutate()}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button size="icon" variant="ghost" onClick={cancelEdit}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-[90px] font-medium">
|
||||||
|
{p.procedureCode}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-muted-foreground">
|
||||||
|
{p.procedureLabel}
|
||||||
|
</div>
|
||||||
|
<div className="w-[90px]">
|
||||||
|
{p.fee !== null && p.fee !== undefined
|
||||||
|
? String(p.fee)
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[80px]">{p.toothNumber}</div>
|
||||||
|
<div className="w-[80px]">{p.toothSurface}</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => startEdit(p)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => deleteMutation.mutate(p.id!)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ================= FOOTER ================= */}
|
||||||
|
<div className="flex justify-between items-center gap-2 mt-8 pt-4 border-t">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
disabled={!procedures.length}
|
||||||
|
onClick={handleDirectClaim}
|
||||||
|
>
|
||||||
|
Direct Claim
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-blue-500 text-blue-600 hover:bg-blue-50"
|
||||||
|
disabled={!procedures.length}
|
||||||
|
onClick={handleManualClaim}
|
||||||
|
>
|
||||||
|
Manual Claim
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
isOpen={clearAllOpen}
|
||||||
|
entityName="all procedures for this appointment"
|
||||||
|
onCancel={() => setClearAllOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setClearAllOpen(false);
|
||||||
|
clearAllMutation.mutate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, memo } from "react";
|
import { useState, useEffect, useRef, useCallback, memo, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
InputServiceLine,
|
InputServiceLine,
|
||||||
InsertAppointment,
|
InsertAppointment,
|
||||||
MissingTeethStatus,
|
MissingTeethStatus,
|
||||||
|
NpiProvider,
|
||||||
Patient,
|
Patient,
|
||||||
Staff,
|
Staff,
|
||||||
UpdateAppointment,
|
UpdateAppointment,
|
||||||
@@ -50,17 +51,22 @@ import {
|
|||||||
applyComboToForm,
|
applyComboToForm,
|
||||||
getDescriptionForCode,
|
getDescriptionForCode,
|
||||||
} from "@/utils/procedureCombosMapping";
|
} from "@/utils/procedureCombosMapping";
|
||||||
import { COMBO_CATEGORIES, PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||||
import { DateInput } from "../ui/dateInput";
|
import { DateInput } from "../ui/dateInput";
|
||||||
import { MissingTeethSimple, type MissingMapStrict } from "./tooth-ui";
|
import { MissingTeethSimple, type MissingMapStrict } from "./tooth-ui";
|
||||||
import { RemarksField } from "./claims-ui";
|
import { RemarksField } from "./claims-ui";
|
||||||
|
import {
|
||||||
|
DirectComboButtons,
|
||||||
|
RegularComboButtons,
|
||||||
|
} from "@/components/procedure/procedure-combo-buttons";
|
||||||
|
|
||||||
interface ClaimFormProps {
|
interface ClaimFormProps {
|
||||||
patientId: number;
|
patientId: number;
|
||||||
appointmentId?: number;
|
appointmentId?: number;
|
||||||
|
autoSubmit?: boolean;
|
||||||
onSubmit: (data: ClaimFormData) => Promise<Claim>;
|
onSubmit: (data: ClaimFormData) => Promise<Claim>;
|
||||||
onHandleAppointmentSubmit: (
|
onHandleAppointmentSubmit: (
|
||||||
appointmentData: InsertAppointment | UpdateAppointment
|
appointmentData: InsertAppointment | UpdateAppointment,
|
||||||
) => Promise<number | { id: number }>;
|
) => Promise<number | { id: number }>;
|
||||||
onHandleUpdatePatient: (patient: UpdatePatient & { id: number }) => void;
|
onHandleUpdatePatient: (patient: UpdatePatient & { id: number }) => void;
|
||||||
onHandleForMHSeleniumClaim: (data: ClaimFormData) => void;
|
onHandleForMHSeleniumClaim: (data: ClaimFormData) => void;
|
||||||
@@ -68,23 +74,10 @@ interface ClaimFormProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERMANENT_TOOTH_NAMES = Array.from(
|
|
||||||
{ length: 32 },
|
|
||||||
(_, i) => `T_${i + 1}`
|
|
||||||
);
|
|
||||||
const PRIMARY_TOOTH_NAMES = Array.from("ABCDEFGHIJKLMNOPQRST").map(
|
|
||||||
(ch) => `T_${ch}`
|
|
||||||
);
|
|
||||||
|
|
||||||
function isValidToothKey(key: string) {
|
|
||||||
return (
|
|
||||||
PERMANENT_TOOTH_NAMES.includes(key) || PRIMARY_TOOTH_NAMES.includes(key)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClaimForm({
|
export function ClaimForm({
|
||||||
patientId,
|
patientId,
|
||||||
appointmentId,
|
appointmentId,
|
||||||
|
autoSubmit,
|
||||||
onHandleAppointmentSubmit,
|
onHandleAppointmentSubmit,
|
||||||
onHandleUpdatePatient,
|
onHandleUpdatePatient,
|
||||||
onHandleForMHSeleniumClaim,
|
onHandleForMHSeleniumClaim,
|
||||||
@@ -95,6 +88,9 @@ export function ClaimForm({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [prefillDone, setPrefillDone] = useState(false);
|
||||||
|
const autoSubmittedRef = useRef(false);
|
||||||
|
|
||||||
const [patient, setPatient] = useState<Patient | null>(null);
|
const [patient, setPatient] = useState<Patient | null>(null);
|
||||||
|
|
||||||
// Query patient based on given patient id
|
// Query patient based on given patient id
|
||||||
@@ -132,17 +128,50 @@ export function ClaimForm({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (staffMembersRaw.length > 0 && !staff) {
|
if (staffMembersRaw.length > 0 && !staff) {
|
||||||
const kaiGao = staffMembersRaw.find(
|
const kaiGao = staffMembersRaw.find(
|
||||||
(member) => member.name === "Kai Gao"
|
(member) => member.name === "Kai Gao",
|
||||||
);
|
);
|
||||||
const defaultStaff = kaiGao || staffMembersRaw[0];
|
const defaultStaff = kaiGao || staffMembersRaw[0];
|
||||||
if (defaultStaff) setStaff(defaultStaff);
|
if (defaultStaff) setStaff(defaultStaff);
|
||||||
}
|
}
|
||||||
}, [staffMembersRaw, staff]);
|
}, [staffMembersRaw, staff]);
|
||||||
|
|
||||||
|
// fetching npi providers
|
||||||
|
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
|
||||||
|
queryKey: ["/api/npiProviders/"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch NPI providers");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!npiProviders.length) return;
|
||||||
|
|
||||||
|
// do not override if user already selected
|
||||||
|
if (form.npiProvider?.npiNumber) return;
|
||||||
|
|
||||||
|
const kaiGaoNpi = npiProviders.find(
|
||||||
|
(p) => p.providerName.toLowerCase() === "kai gao",
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallback = kaiGaoNpi || npiProviders[0];
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
npiProvider: {
|
||||||
|
npiNumber: fallback.npiNumber,
|
||||||
|
providerName: fallback.providerName,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [npiProviders]);
|
||||||
|
|
||||||
// Service date state
|
// Service date state
|
||||||
const [serviceDateValue, setServiceDateValue] = useState<Date>(new Date());
|
const [serviceDateValue, setServiceDateValue] = useState<Date>(new Date());
|
||||||
const [serviceDate, setServiceDate] = useState<string>(
|
const [serviceDate, setServiceDate] = useState<string>(
|
||||||
formatLocalDate(new Date())
|
formatLocalDate(new Date()),
|
||||||
);
|
);
|
||||||
const [serviceDateOpen, setServiceDateOpen] = useState(false);
|
const [serviceDateOpen, setServiceDateOpen] = useState(false);
|
||||||
const [openProcedureDateIndex, setOpenProcedureDateIndex] = useState<
|
const [openProcedureDateIndex, setOpenProcedureDateIndex] = useState<
|
||||||
@@ -161,7 +190,7 @@ export function ClaimForm({
|
|||||||
try {
|
try {
|
||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"GET",
|
"GET",
|
||||||
`/api/appointments/${appointmentId}`
|
`/api/appointments/${appointmentId}`,
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let body: any = null;
|
let body: any = null;
|
||||||
@@ -200,7 +229,7 @@ export function ClaimForm({
|
|||||||
dateVal = new Date(
|
dateVal = new Date(
|
||||||
maybe.getFullYear(),
|
maybe.getFullYear(),
|
||||||
maybe.getMonth(),
|
maybe.getMonth(),
|
||||||
maybe.getDate()
|
maybe.getDate(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +254,53 @@ export function ClaimForm({
|
|||||||
};
|
};
|
||||||
}, [appointmentId]);
|
}, [appointmentId]);
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// 2. effect - prefill proceduresCodes (if exists for appointment) into serviceLines
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appointmentId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/appointment-procedures/prefill-from-appointment/${appointmentId}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const mappedLines = (data.procedures || []).map((p: any) => ({
|
||||||
|
procedureCode: p.procedureCode,
|
||||||
|
procedureDate: serviceDate,
|
||||||
|
oralCavityArea: p.oralCavityArea || "",
|
||||||
|
toothNumber: p.toothNumber || "",
|
||||||
|
toothSurface: p.toothSurface || "",
|
||||||
|
totalBilled: new Decimal(p.fee || 0),
|
||||||
|
totalAdjusted: new Decimal(0),
|
||||||
|
totalPaid: new Decimal(0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serviceLines: mappedLines,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setPrefillDone(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to prefill procedures:", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [appointmentId, serviceDate]);
|
||||||
|
|
||||||
// Update service date when calendar date changes
|
// Update service date when calendar date changes
|
||||||
const onServiceDateChange = (date: Date | undefined) => {
|
const onServiceDateChange = (date: Date | undefined) => {
|
||||||
if (date) {
|
if (date) {
|
||||||
@@ -391,7 +467,7 @@ export function ClaimForm({
|
|||||||
const updateServiceLine = (
|
const updateServiceLine = (
|
||||||
index: number,
|
index: number,
|
||||||
field: keyof InputServiceLine,
|
field: keyof InputServiceLine,
|
||||||
value: any
|
value: any,
|
||||||
) => {
|
) => {
|
||||||
const updatedLines = [...form.serviceLines];
|
const updatedLines = [...form.serviceLines];
|
||||||
|
|
||||||
@@ -439,21 +515,6 @@ export function ClaimForm({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMissingTooth = useCallback(
|
|
||||||
(name: string, value: "" | "X" | "O") => {
|
|
||||||
if (!isValidToothKey(name)) return;
|
|
||||||
setForm((prev) => {
|
|
||||||
const current = prev.missingTeeth[name] ?? "";
|
|
||||||
if (current === value) return prev;
|
|
||||||
const nextMap = { ...prev.missingTeeth };
|
|
||||||
if (!value) delete nextMap[name];
|
|
||||||
else nextMap[name] = value;
|
|
||||||
return { ...prev, missingTeeth: nextMap };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearAllToothSelections = () =>
|
const clearAllToothSelections = () =>
|
||||||
setForm((prev) => ({ ...prev, missingTeeth: {} as MissingMapStrict }));
|
setForm((prev) => ({ ...prev, missingTeeth: {} as MissingMapStrict }));
|
||||||
|
|
||||||
@@ -473,7 +534,7 @@ export function ClaimForm({
|
|||||||
mapPricesForForm({
|
mapPricesForForm({
|
||||||
form: prev,
|
form: prev,
|
||||||
patientDOB: patient?.dateOfBirth ?? "",
|
patientDOB: patient?.dateOfBirth ?? "",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -488,7 +549,7 @@ export function ClaimForm({
|
|||||||
|
|
||||||
// 1st Button workflow - Mass Health Button Handler
|
// 1st Button workflow - Mass Health Button Handler
|
||||||
const handleMHSubmit = async (
|
const handleMHSubmit = async (
|
||||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] }
|
formToUse?: ClaimFormData & { uploadedFiles?: File[] },
|
||||||
) => {
|
) => {
|
||||||
// Use the passed form, or fallback to current state
|
// Use the passed form, or fallback to current state
|
||||||
const f = formToUse ?? form;
|
const f = formToUse ?? form;
|
||||||
@@ -511,7 +572,7 @@ export function ClaimForm({
|
|||||||
|
|
||||||
// require at least one procedure code before proceeding
|
// require at least one procedure code before proceeding
|
||||||
const filteredServiceLines = (f.serviceLines || []).filter(
|
const filteredServiceLines = (f.serviceLines || []).filter(
|
||||||
(line) => (line.procedureCode ?? "").trim() !== ""
|
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||||
);
|
);
|
||||||
if (filteredServiceLines.length === 0) {
|
if (filteredServiceLines.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -523,6 +584,15 @@ export function ClaimForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!f.npiProvider?.npiNumber) {
|
||||||
|
toast({
|
||||||
|
title: "NPI Provider Required",
|
||||||
|
description: "Please select a NPI Provider.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Create or update appointment
|
// 1. Create or update appointment
|
||||||
let appointmentIdToUse = appointmentId;
|
let appointmentIdToUse = appointmentId;
|
||||||
|
|
||||||
@@ -560,7 +630,12 @@ export function ClaimForm({
|
|||||||
|
|
||||||
// 3. Create Claim(if not)
|
// 3. Create Claim(if not)
|
||||||
// Filter out empty service lines (empty procedureCode)
|
// Filter out empty service lines (empty procedureCode)
|
||||||
const { uploadedFiles, insuranceSiteKey, ...formToCreateClaim } = f;
|
const {
|
||||||
|
uploadedFiles,
|
||||||
|
insuranceSiteKey,
|
||||||
|
npiProvider,
|
||||||
|
...formToCreateClaim
|
||||||
|
} = f;
|
||||||
|
|
||||||
// build claimFiles metadata from uploadedFiles (only filename + mimeType)
|
// build claimFiles metadata from uploadedFiles (only filename + mimeType)
|
||||||
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
|
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
|
||||||
@@ -584,6 +659,7 @@ export function ClaimForm({
|
|||||||
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
|
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
|
||||||
serviceLines: filteredServiceLines,
|
serviceLines: filteredServiceLines,
|
||||||
staffId: Number(staff?.id),
|
staffId: Number(staff?.id),
|
||||||
|
npiProvider: f.npiProvider,
|
||||||
patientId: patientId,
|
patientId: patientId,
|
||||||
insuranceProvider: "Mass Health",
|
insuranceProvider: "Mass Health",
|
||||||
appointmentId: appointmentIdToUse!,
|
appointmentId: appointmentIdToUse!,
|
||||||
@@ -597,7 +673,7 @@ export function ClaimForm({
|
|||||||
|
|
||||||
// 2st Button workflow - Mass Health Pre Auth Button Handler
|
// 2st Button workflow - Mass Health Pre Auth Button Handler
|
||||||
const handleMHPreAuth = async (
|
const handleMHPreAuth = async (
|
||||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] }
|
formToUse?: ClaimFormData & { uploadedFiles?: File[] },
|
||||||
) => {
|
) => {
|
||||||
// Use the passed form, or fallback to current state
|
// Use the passed form, or fallback to current state
|
||||||
const f = formToUse ?? form;
|
const f = formToUse ?? form;
|
||||||
@@ -620,7 +696,7 @@ export function ClaimForm({
|
|||||||
|
|
||||||
// require at least one procedure code before proceeding
|
// require at least one procedure code before proceeding
|
||||||
const filteredServiceLines = (f.serviceLines || []).filter(
|
const filteredServiceLines = (f.serviceLines || []).filter(
|
||||||
(line) => (line.procedureCode ?? "").trim() !== ""
|
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||||
);
|
);
|
||||||
if (filteredServiceLines.length === 0) {
|
if (filteredServiceLines.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -632,6 +708,15 @@ export function ClaimForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!f.npiProvider?.npiNumber) {
|
||||||
|
toast({
|
||||||
|
title: "NPI Provider Required",
|
||||||
|
description: "Please select a NPI Provider.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Update patient
|
// 2. Update patient
|
||||||
if (patient && typeof patient.id === "number") {
|
if (patient && typeof patient.id === "number") {
|
||||||
const { id, createdAt, userId, ...sanitizedFields } = patient;
|
const { id, createdAt, userId, ...sanitizedFields } = patient;
|
||||||
@@ -655,6 +740,7 @@ export function ClaimForm({
|
|||||||
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
|
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
|
||||||
serviceLines: filteredServiceLines,
|
serviceLines: filteredServiceLines,
|
||||||
staffId: Number(staff?.id),
|
staffId: Number(staff?.id),
|
||||||
|
npiProvider: f.npiProvider,
|
||||||
patientId: patientId,
|
patientId: patientId,
|
||||||
insuranceProvider: "Mass Health",
|
insuranceProvider: "Mass Health",
|
||||||
insuranceSiteKey: "MH",
|
insuranceSiteKey: "MH",
|
||||||
@@ -684,7 +770,7 @@ export function ClaimForm({
|
|||||||
|
|
||||||
// require at least one procedure code before proceeding
|
// require at least one procedure code before proceeding
|
||||||
const filteredServiceLines = (form.serviceLines || []).filter(
|
const filteredServiceLines = (form.serviceLines || []).filter(
|
||||||
(line) => (line.procedureCode ?? "").trim() !== ""
|
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||||
);
|
);
|
||||||
if (filteredServiceLines.length === 0) {
|
if (filteredServiceLines.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -757,13 +843,13 @@ export function ClaimForm({
|
|||||||
|
|
||||||
// for direct combo button.
|
// for direct combo button.
|
||||||
const applyComboAndThenMH = async (
|
const applyComboAndThenMH = async (
|
||||||
comboId: keyof typeof PROCEDURE_COMBOS
|
comboId: keyof typeof PROCEDURE_COMBOS,
|
||||||
) => {
|
) => {
|
||||||
const nextForm = applyComboToForm(
|
const nextForm = applyComboToForm(
|
||||||
form,
|
form,
|
||||||
comboId,
|
comboId,
|
||||||
patient?.dateOfBirth ?? "",
|
patient?.dateOfBirth ?? "",
|
||||||
{ replaceAll: false, lineDate: form.serviceDate }
|
{ replaceAll: false, lineDate: form.serviceDate },
|
||||||
);
|
);
|
||||||
|
|
||||||
setForm(nextForm);
|
setForm(nextForm);
|
||||||
@@ -772,6 +858,37 @@ export function ClaimForm({
|
|||||||
await handleMHSubmit(nextForm);
|
await handleMHSubmit(nextForm);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFormReady = useMemo(() => {
|
||||||
|
return (
|
||||||
|
!!patient &&
|
||||||
|
!!form.memberId?.trim() &&
|
||||||
|
!!form.dateOfBirth?.trim() &&
|
||||||
|
!!form.patientName?.trim() &&
|
||||||
|
Array.isArray(form.serviceLines) &&
|
||||||
|
form.serviceLines.some(
|
||||||
|
(l) => l.procedureCode && l.procedureCode.trim() !== "",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
patient,
|
||||||
|
form.memberId,
|
||||||
|
form.dateOfBirth,
|
||||||
|
form.patientName,
|
||||||
|
form.serviceLines,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// when autoSubmit mode is given, it will then submit the claims.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSubmit) return;
|
||||||
|
if (!prefillDone) return;
|
||||||
|
if (!isFormReady) return;
|
||||||
|
|
||||||
|
if (autoSubmittedRef.current) return;
|
||||||
|
autoSubmittedRef.current = true;
|
||||||
|
|
||||||
|
handleMHSubmit();
|
||||||
|
}, [autoSubmit, prefillDone, isFormReady]);
|
||||||
|
|
||||||
// overlay click handler (close when clicking backdrop)
|
// overlay click handler (close when clicking backdrop)
|
||||||
const onOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
const onOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// only close if clicked the backdrop itself (not inner modal)
|
// only close if clicked the backdrop itself (not inner modal)
|
||||||
@@ -780,6 +897,14 @@ export function ClaimForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// reset when ClaimForm unmounts (modal closes)
|
||||||
|
autoSubmittedRef.current = false;
|
||||||
|
setPrefillDone(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto"
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto"
|
||||||
@@ -901,7 +1026,7 @@ export function ClaimForm({
|
|||||||
value={staff?.id?.toString() || ""}
|
value={staff?.id?.toString() || ""}
|
||||||
onValueChange={(id) => {
|
onValueChange={(id) => {
|
||||||
const selected = staffMembersRaw.find(
|
const selected = staffMembersRaw.find(
|
||||||
(member) => member.id?.toString() === id
|
(member) => member.id?.toString() === id,
|
||||||
);
|
);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setStaff(selected);
|
setStaff(selected);
|
||||||
@@ -934,6 +1059,42 @@ export function ClaimForm({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* Rendering Npi Provider */}
|
||||||
|
<Label className="flex items-center ml-2">
|
||||||
|
Rendering Provider
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={form.npiProvider?.npiNumber || ""}
|
||||||
|
onValueChange={(npiNumber) => {
|
||||||
|
const selected = npiProviders.find(
|
||||||
|
(p) => p.npiNumber === npiNumber,
|
||||||
|
);
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
npiProvider: {
|
||||||
|
// ✅ CORRECT KEY
|
||||||
|
npiNumber: selected.npiNumber,
|
||||||
|
providerName: selected.providerName,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-56">
|
||||||
|
<SelectValue placeholder="Select NPI Provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{npiProviders.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.npiNumber}>
|
||||||
|
{p.npiNumber} — {p.providerName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{/* Map Price Button */}
|
{/* Map Price Button */}
|
||||||
<Button
|
<Button
|
||||||
className="ml-4"
|
className="ml-4"
|
||||||
@@ -945,163 +1106,11 @@ export function ClaimForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<DirectComboButtons
|
||||||
{/* Section Title */}
|
onDirectCombo={(comboKey) =>
|
||||||
<div className="text-sm font-semibold text-muted-foreground">
|
applyComboAndThenMH(comboKey as any)
|
||||||
Direct Claim Submission Buttons
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
{/* CHILD RECALL GROUP */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium opacity-80">
|
|
||||||
Child Recall
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{[
|
|
||||||
"childRecallDirect",
|
|
||||||
"childRecallDirect2BW",
|
|
||||||
"childRecallDirect4BW",
|
|
||||||
"childRecallDirect2PA2BW",
|
|
||||||
"childRecallDirect2PA4BW",
|
|
||||||
"childRecallDirect3PA2BW",
|
|
||||||
"childRecallDirect3PA",
|
|
||||||
"childRecallDirect4PA",
|
|
||||||
"childRecallDirectPANO",
|
|
||||||
].map((comboId) => {
|
|
||||||
const b = PROCEDURE_COMBOS[comboId];
|
|
||||||
if (!b) return null;
|
|
||||||
const codesWithTooth = b.codes.map((code, idx) => {
|
|
||||||
const tooth = b.toothNumbers?.[idx];
|
|
||||||
return tooth ? `${code} (tooth ${tooth})` : code;
|
|
||||||
});
|
|
||||||
const tooltipText = codesWithTooth.join(", ");
|
|
||||||
const labelMap: Record<string, string> = {
|
|
||||||
childRecallDirect: "Direct",
|
|
||||||
childRecallDirect2BW: "Direct 2BW",
|
|
||||||
childRecallDirect4BW: "Direct 4BW",
|
|
||||||
childRecallDirect2PA2BW: "Direct 2PA 2BW",
|
|
||||||
childRecallDirect2PA4BW: "Direct 2PA 4BW",
|
|
||||||
childRecallDirect3PA2BW: "Direct 3PA 2BW",
|
|
||||||
childRecallDirect3PA: "Direct 3PA",
|
|
||||||
childRecallDirect4PA: "Direct 4PA",
|
|
||||||
childRecallDirectPANO: "Direct Pano",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Tooltip key={b.id}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => applyComboAndThenMH(b.id)}
|
|
||||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
|
||||||
>
|
|
||||||
{labelMap[comboId] ?? b.label}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center">
|
|
||||||
<div className="text-sm max-w-xs break-words">
|
|
||||||
{tooltipText}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ADULT RECALL GROUP */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium opacity-80">
|
|
||||||
Adult Recall
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{[
|
|
||||||
"adultRecallDirect",
|
|
||||||
"adultRecallDirect2BW",
|
|
||||||
"adultRecallDirect4BW",
|
|
||||||
"adultRecallDirect2PA2BW",
|
|
||||||
"adultRecallDirect2PA4BW",
|
|
||||||
"adultRecallDirect4PA",
|
|
||||||
"adultRecallDirectPano",
|
|
||||||
].map((comboId) => {
|
|
||||||
const b = PROCEDURE_COMBOS[comboId];
|
|
||||||
if (!b) return null;
|
|
||||||
const codesWithTooth = b.codes.map((code, idx) => {
|
|
||||||
const tooth = b.toothNumbers?.[idx];
|
|
||||||
return tooth ? `${code} (tooth ${tooth})` : code;
|
|
||||||
});
|
|
||||||
const tooltipText = codesWithTooth.join(", ");
|
|
||||||
const labelMap: Record<string, string> = {
|
|
||||||
adultRecallDirect: "Direct",
|
|
||||||
adultRecallDirect2BW: "Direct 2BW",
|
|
||||||
adultRecallDirect4BW: "Direct 4BW",
|
|
||||||
adultRecallDirect2PA2BW: "Direct 2PA 2BW",
|
|
||||||
adultRecallDirect2PA4BW: "Direct 2PA 4BW",
|
|
||||||
adultRecallDirect4PA: "Direct 4PA",
|
|
||||||
adultRecallDirectPano: "Direct Pano",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Tooltip key={b.id}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => applyComboAndThenMH(b.id)}
|
|
||||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
|
||||||
>
|
|
||||||
{labelMap[comboId] ?? b.label}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center">
|
|
||||||
<div className="text-sm max-w-xs break-words">
|
|
||||||
{tooltipText}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ORTH GROUP */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium opacity-80">Orth</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{[
|
|
||||||
"orthPreExamDirect",
|
|
||||||
"orthRecordDirect",
|
|
||||||
"orthPerioVisitDirect",
|
|
||||||
"orthRetentionDirect",
|
|
||||||
].map((comboId) => {
|
|
||||||
const b = PROCEDURE_COMBOS[comboId];
|
|
||||||
if (!b) return null;
|
|
||||||
|
|
||||||
const tooltipText = b.codes.join(", ");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={b.id}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => applyComboAndThenMH(b.id)}
|
|
||||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
|
||||||
>
|
|
||||||
{b.label}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent side="top" align="center">
|
|
||||||
<div className="text-sm max-w-xs break-words">
|
|
||||||
{tooltipText}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -1159,7 +1168,7 @@ export function ClaimForm({
|
|||||||
updateServiceLine(
|
updateServiceLine(
|
||||||
i,
|
i,
|
||||||
"procedureCode",
|
"procedureCode",
|
||||||
e.target.value.toUpperCase()
|
e.target.value.toUpperCase(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1246,7 +1255,7 @@ export function ClaimForm({
|
|||||||
updateServiceLine(
|
updateServiceLine(
|
||||||
i,
|
i,
|
||||||
"totalBilled",
|
"totalBilled",
|
||||||
isNaN(rounded) ? 0 : rounded
|
isNaN(rounded) ? 0 : rounded,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1279,66 +1288,24 @@ export function ClaimForm({
|
|||||||
+ Add Service Line
|
+ Add Service Line
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="space-y-4 mt-8">
|
<RegularComboButtons
|
||||||
{Object.entries(COMBO_CATEGORIES).map(([section, ids]) => (
|
onRegularCombo={(comboKey) => {
|
||||||
<div key={section}>
|
|
||||||
<div className="mb-3 text-sm font-semibold opacity-70">
|
|
||||||
{section}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{ids.map((id) => {
|
|
||||||
const b = PROCEDURE_COMBOS[id];
|
|
||||||
if (!b) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Build a human readable string for the tooltip
|
|
||||||
const codesWithTooth = b.codes.map((code, idx) => {
|
|
||||||
const tooth = b.toothNumbers?.[idx];
|
|
||||||
return tooth ? `${code} (tooth ${tooth})` : code;
|
|
||||||
});
|
|
||||||
const tooltipText = codesWithTooth.join(", ");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={b.id}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
key={b.id}
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() =>
|
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
const next = applyComboToForm(
|
const next = applyComboToForm(
|
||||||
prev,
|
prev,
|
||||||
b.id as any,
|
comboKey as any,
|
||||||
patient?.dateOfBirth ?? "",
|
patient?.dateOfBirth ?? "",
|
||||||
{
|
{
|
||||||
replaceAll: false,
|
replaceAll: false,
|
||||||
lineDate: prev.serviceDate,
|
lineDate: prev.serviceDate,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
setTimeout(() => scrollToLine(0), 0);
|
setTimeout(() => scrollToLine(0), 0);
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
/>
|
||||||
>
|
|
||||||
{b.label}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent side="top" align="center">
|
|
||||||
<div className="text-sm max-w-xs break-words">
|
|
||||||
{tooltipText}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Upload Section */}
|
{/* File Upload Section */}
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { io as ioClient, Socket } from "socket.io-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { useAppDispatch } from "@/redux/hooks";
|
||||||
|
import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice";
|
||||||
|
import { formatLocalDate } from "@/utils/dateUtils";
|
||||||
|
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||||
|
|
||||||
|
const SOCKET_URL =
|
||||||
|
import.meta.env.VITE_API_BASE_URL_BACKEND ||
|
||||||
|
(typeof window !== "undefined" ? window.location.origin : "");
|
||||||
|
|
||||||
|
// ---------- OTP Modal component ----------
|
||||||
|
interface DentaQuestOtpModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (otp: string) => Promise<void> | void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DentaQuestOtpModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
}: DentaQuestOtpModalProps) {
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setOtp("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!otp.trim()) return;
|
||||||
|
await onSubmit(otp.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Enter OTP</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-500 hover:text-slate-800"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-4">
|
||||||
|
We need the one-time password (OTP) sent by the DentaQuest portal
|
||||||
|
to complete this eligibility check.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dentaquest-otp">OTP</Label>
|
||||||
|
<Input
|
||||||
|
id="dentaquest-otp"
|
||||||
|
placeholder="Enter OTP code"
|
||||||
|
value={otp}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Submit OTP"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Main DentaQuest Eligibility button component ----------
|
||||||
|
interface DentaQuestEligibilityButtonProps {
|
||||||
|
memberId: string;
|
||||||
|
dateOfBirth: Date | null;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
isFormIncomplete: boolean;
|
||||||
|
/** Called when backend has finished and PDF is ready */
|
||||||
|
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DentaQuestEligibilityButton({
|
||||||
|
memberId,
|
||||||
|
dateOfBirth,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
isFormIncomplete,
|
||||||
|
onPdfReady,
|
||||||
|
}: DentaQuestEligibilityButtonProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const connectingRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||||
|
|
||||||
|
// Clean up socket on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.removeAllListeners();
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
connectingRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSocket = () => {
|
||||||
|
try {
|
||||||
|
socketRef.current?.removeAllListeners();
|
||||||
|
socketRef.current?.disconnect();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lazy socket setup: called only when we actually need it (first click)
|
||||||
|
const ensureSocketConnected = async () => {
|
||||||
|
// If already connected, nothing to do
|
||||||
|
if (socketRef.current && socketRef.current.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a connection is in progress, reuse that promise
|
||||||
|
if (connectingRef.current) {
|
||||||
|
return connectingRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = ioClient(SOCKET_URL, {
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// connection error when first connecting (or later)
|
||||||
|
socket.on("connect_error", (err: any) => {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: "Connection failed",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Realtime connection failed",
|
||||||
|
description:
|
||||||
|
"Could not connect to realtime server. Retrying automatically...",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
// do not reject here because socket.io will attempt reconnection
|
||||||
|
});
|
||||||
|
|
||||||
|
// socket.io will emit 'reconnect_attempt' for retries
|
||||||
|
socket.on("reconnect_attempt", (attempt: number) => {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: `Realtime reconnect attempt #${attempt}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// when reconnection failed after configured attempts
|
||||||
|
socket.on("reconnect_failed", () => {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: "Reconnect failed",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Realtime reconnect failed",
|
||||||
|
description:
|
||||||
|
"Connection to realtime server could not be re-established. Please try again later.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
// terminal failure — cleanup and reject so caller can stop start flow
|
||||||
|
closeSocket();
|
||||||
|
reject(new Error("Realtime reconnect failed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", (reason: any) => {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: "Connection disconnected",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Connection Disconnected",
|
||||||
|
description:
|
||||||
|
"Connection to the server was lost. If a DentaQuest job was running it may have failed.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
// clear sessionId/OTP modal
|
||||||
|
setSessionId(null);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OTP required
|
||||||
|
socket.on("selenium:otp_required", (payload: any) => {
|
||||||
|
if (!payload?.session_id) return;
|
||||||
|
setSessionId(payload.session_id);
|
||||||
|
setOtpModalOpen(true);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: "OTP required for DentaQuest eligibility. Please enter the OTP.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OTP submitted (optional UX)
|
||||||
|
socket.on("selenium:otp_submitted", (payload: any) => {
|
||||||
|
if (!payload?.session_id) return;
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: "OTP submitted. Finishing DentaQuest eligibility check...",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session update
|
||||||
|
socket.on("selenium:session_update", (payload: any) => {
|
||||||
|
const { session_id, status, final } = payload || {};
|
||||||
|
if (!session_id) return;
|
||||||
|
|
||||||
|
if (status === "completed") {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "success",
|
||||||
|
message:
|
||||||
|
"DentaQuest eligibility updated and PDF attached to patient documents.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "DentaQuest eligibility complete",
|
||||||
|
description:
|
||||||
|
"Patient status was updated and the eligibility PDF was saved.",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfId = final?.pdfFileId;
|
||||||
|
if (pdfId) {
|
||||||
|
const filename =
|
||||||
|
final?.pdfFilename ?? `eligibility_dentaquest_${memberId}.pdf`;
|
||||||
|
onPdfReady(Number(pdfId), filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionId(null);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
} else if (status === "error") {
|
||||||
|
const msg =
|
||||||
|
payload?.message ||
|
||||||
|
final?.error ||
|
||||||
|
"DentaQuest eligibility session failed.";
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: msg,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "DentaQuest selenium error",
|
||||||
|
description: msg,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure socket is torn down for this session (stop receiving stale events)
|
||||||
|
try {
|
||||||
|
closeSocket();
|
||||||
|
} catch (e) {}
|
||||||
|
setSessionId(null);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||||
|
});
|
||||||
|
|
||||||
|
// explicit session error event (helpful)
|
||||||
|
socket.on("selenium:session_error", (payload: any) => {
|
||||||
|
const msg = payload?.message || "Selenium session error";
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: msg,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Selenium session error",
|
||||||
|
description: msg,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
// tear down socket to avoid stale updates
|
||||||
|
try {
|
||||||
|
closeSocket();
|
||||||
|
} catch (e) {}
|
||||||
|
setSessionId(null);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If socket.io initial connection fails permanently (very rare: client-level)
|
||||||
|
// set a longer timeout to reject the first attempt to connect.
|
||||||
|
const initialConnectTimeout = setTimeout(() => {
|
||||||
|
if (!socket.connected) {
|
||||||
|
// if still not connected after 8s, treat as failure and reject so caller can handle it
|
||||||
|
closeSocket();
|
||||||
|
reject(new Error("Realtime initial connection timeout"));
|
||||||
|
}
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
// When the connect resolves we should clear this timer
|
||||||
|
socket.once("connect", () => {
|
||||||
|
clearTimeout(initialConnectTimeout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// store promise to prevent multiple concurrent connections
|
||||||
|
connectingRef.current = promise;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} finally {
|
||||||
|
connectingRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDentaQuestEligibility = async () => {
|
||||||
|
if (!memberId || !dateOfBirth) {
|
||||||
|
toast({
|
||||||
|
title: "Missing fields",
|
||||||
|
description: "Member ID and Date of Birth are required.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
memberId,
|
||||||
|
dateOfBirth: formattedDob,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
insuranceSiteKey: "DENTAQUEST", // make sure this matches backend credential key
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsStarting(true);
|
||||||
|
|
||||||
|
// 1) Ensure socket is connected (lazy)
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: "Opening realtime channel for DentaQuest eligibility...",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await ensureSocketConnected();
|
||||||
|
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (!socket || !socket.connected) {
|
||||||
|
throw new Error("Socket connection failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketId = socket.id;
|
||||||
|
|
||||||
|
// 2) Start the selenium job via backend
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: "Starting DentaQuest eligibility check via selenium...",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/insurance-status-dentaquest/dentaquest-eligibility",
|
||||||
|
{
|
||||||
|
data: JSON.stringify(payload),
|
||||||
|
socketId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If apiRequest threw, we would have caught above; but just in case it returns.
|
||||||
|
let result: any = null;
|
||||||
|
let backendError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// attempt JSON first
|
||||||
|
result = await response.clone().json();
|
||||||
|
backendError =
|
||||||
|
result?.error || result?.message || result?.detail || null;
|
||||||
|
} catch {
|
||||||
|
// fallback to text response
|
||||||
|
try {
|
||||||
|
const text = await response.clone().text();
|
||||||
|
backendError = text?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
backendError = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
backendError ||
|
||||||
|
`DentaQuest selenium start failed (status ${response.status})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal success path: optional: if backend returns non-error shape still check for result.error
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "started" && result.session_id) {
|
||||||
|
setSessionId(result.session_id as string);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message:
|
||||||
|
"DentaQuest eligibility job started. Waiting for OTP or final result...",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// fallback if backend returns immediate result
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "success",
|
||||||
|
message: "DentaQuest eligibility completed.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("startDentaQuestEligibility error:", err);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: err?.message || "Failed to start DentaQuest eligibility",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "DentaQuest selenium error",
|
||||||
|
description: err?.message || "Failed to start DentaQuest eligibility",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitOtp = async (otp: string) => {
|
||||||
|
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
|
||||||
|
toast({
|
||||||
|
title: "Session not ready",
|
||||||
|
description:
|
||||||
|
"Could not submit OTP because the DentaQuest session or socket is not ready.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmittingOtp(true);
|
||||||
|
const resp = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/insurance-status-dentaquest/selenium/submit-otp",
|
||||||
|
{
|
||||||
|
session_id: sessionId,
|
||||||
|
otp,
|
||||||
|
socketId: socketRef.current.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
throw new Error(data.error || "Failed to submit OTP");
|
||||||
|
}
|
||||||
|
|
||||||
|
// from here we rely on websocket events (otp_submitted + session_update)
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("handleSubmitOtp error:", err);
|
||||||
|
toast({
|
||||||
|
title: "Failed to submit OTP",
|
||||||
|
description: err?.message || "Error forwarding OTP to selenium agent",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingOtp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isFormIncomplete || isStarting}
|
||||||
|
onClick={startDentaQuestEligibility}
|
||||||
|
>
|
||||||
|
{isStarting ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
Tufts SCO/SWH/Navi/Mass Gen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DentaQuestOtpModal
|
||||||
|
open={otpModalOpen}
|
||||||
|
onClose={() => setOtpModalOpen(false)}
|
||||||
|
onSubmit={handleSubmitOtp}
|
||||||
|
isSubmitting={isSubmittingOtp}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
PROCEDURE_COMBOS,
|
||||||
|
COMBO_CATEGORIES,
|
||||||
|
} from "@/utils/procedureCombos";
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
DIRECT COMBO BUTTONS (TOP SECTION)
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
export function DirectComboButtons({
|
||||||
|
onDirectCombo,
|
||||||
|
}: {
|
||||||
|
onDirectCombo: (comboKey: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Section Title */}
|
||||||
|
<div className="text-sm font-semibold text-muted-foreground">
|
||||||
|
Direct Claim Submission Buttons
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* CHILD RECALL */}
|
||||||
|
<DirectGroup
|
||||||
|
title="Child Recall"
|
||||||
|
combos={[
|
||||||
|
"childRecallDirect",
|
||||||
|
"childRecallDirect2BW",
|
||||||
|
"childRecallDirect4BW",
|
||||||
|
"childRecallDirect2PA2BW",
|
||||||
|
"childRecallDirect2PA4BW",
|
||||||
|
"childRecallDirect3PA2BW",
|
||||||
|
"childRecallDirect3PA",
|
||||||
|
"childRecallDirect4PA",
|
||||||
|
"childRecallDirectPANO",
|
||||||
|
]}
|
||||||
|
labelMap={{
|
||||||
|
childRecallDirect: "Direct",
|
||||||
|
childRecallDirect2BW: "Direct 2BW",
|
||||||
|
childRecallDirect4BW: "Direct 4BW",
|
||||||
|
childRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||||
|
childRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||||
|
childRecallDirect3PA2BW: "Direct 3PA 2BW",
|
||||||
|
childRecallDirect3PA: "Direct 3PA",
|
||||||
|
childRecallDirect4PA: "Direct 4PA",
|
||||||
|
childRecallDirectPANO: "Direct Pano",
|
||||||
|
}}
|
||||||
|
onSelect={onDirectCombo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ADULT RECALL */}
|
||||||
|
<DirectGroup
|
||||||
|
title="Adult Recall"
|
||||||
|
combos={[
|
||||||
|
"adultRecallDirect",
|
||||||
|
"adultRecallDirect2BW",
|
||||||
|
"adultRecallDirect4BW",
|
||||||
|
"adultRecallDirect2PA2BW",
|
||||||
|
"adultRecallDirect2PA4BW",
|
||||||
|
"adultRecallDirect4PA",
|
||||||
|
"adultRecallDirectPano",
|
||||||
|
]}
|
||||||
|
labelMap={{
|
||||||
|
adultRecallDirect: "Direct",
|
||||||
|
adultRecallDirect2BW: "Direct 2BW",
|
||||||
|
adultRecallDirect4BW: "Direct 4BW",
|
||||||
|
adultRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||||
|
adultRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||||
|
adultRecallDirect4PA: "Direct 4PA",
|
||||||
|
adultRecallDirectPano: "Direct Pano",
|
||||||
|
}}
|
||||||
|
onSelect={onDirectCombo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ORTH */}
|
||||||
|
<DirectGroup
|
||||||
|
title="Orth"
|
||||||
|
combos={[
|
||||||
|
"orthPreExamDirect",
|
||||||
|
"orthRecordDirect",
|
||||||
|
"orthPerioVisitDirect",
|
||||||
|
"orthRetentionDirect",
|
||||||
|
]}
|
||||||
|
onSelect={onDirectCombo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
REGULAR COMBO BUTTONS (BOTTOM SECTION)
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
export function RegularComboButtons({
|
||||||
|
onRegularCombo,
|
||||||
|
}: {
|
||||||
|
onRegularCombo: (comboKey: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-8">
|
||||||
|
{Object.entries(COMBO_CATEGORIES).map(([section, ids]) => (
|
||||||
|
<div key={section}>
|
||||||
|
<div className="mb-3 text-sm font-semibold opacity-70">
|
||||||
|
{section}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{ids.map((id) => {
|
||||||
|
const b = PROCEDURE_COMBOS[id];
|
||||||
|
if (!b) return null;
|
||||||
|
|
||||||
|
const tooltipText = b.codes
|
||||||
|
.map((code, idx) => {
|
||||||
|
const tooth = b.toothNumbers?.[idx];
|
||||||
|
return tooth ? `${code} (tooth ${tooth})` : code;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onRegularCombo(id)}
|
||||||
|
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent side="top" align="center">
|
||||||
|
<div className="text-sm max-w-xs break-words">
|
||||||
|
{tooltipText}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
INTERNAL HELPERS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
function DirectGroup({
|
||||||
|
title,
|
||||||
|
combos,
|
||||||
|
labelMap,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
combos: string[];
|
||||||
|
labelMap?: Record<string, string>;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium opacity-80">{title}</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{combos.map((id) => {
|
||||||
|
const b = PROCEDURE_COMBOS[id];
|
||||||
|
if (!b) return null;
|
||||||
|
|
||||||
|
const tooltipText = b.codes
|
||||||
|
.map((code, idx) => {
|
||||||
|
const tooth = b.toothNumbers?.[idx];
|
||||||
|
return tooth ? `${code} (tooth ${tooth})` : code;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onSelect(id)}
|
||||||
|
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||||
|
>
|
||||||
|
{labelMap?.[id] ?? b.label}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent side="top" align="center">
|
||||||
|
<div className="text-sm max-w-xs break-words">
|
||||||
|
{tooltipText}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@ import { useEffect, useState } from "react";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
type CredentialFormProps = {
|
type CredentialFormProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -14,7 +21,16 @@ type CredentialFormProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
const SITE_KEYS = [
|
||||||
|
{ label: "MassHealth (MH)", value: "MH" },
|
||||||
|
{ label: "Delta Dental MA", value: "DDMA" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CredentialForm({
|
||||||
|
onClose,
|
||||||
|
userId,
|
||||||
|
defaultValues,
|
||||||
|
}: CredentialFormProps) {
|
||||||
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
|
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
|
||||||
const [username, setUsername] = useState(defaultValues?.username || "");
|
const [username, setUsername] = useState(defaultValues?.username || "");
|
||||||
const [password, setPassword] = useState(defaultValues?.password || "");
|
const [password, setPassword] = useState(defaultValues?.password || "");
|
||||||
@@ -92,13 +108,19 @@ export function CredentialForm({ onClose, userId, defaultValues }: CredentialFor
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium">Site Key</label>
|
<label className="block text-sm font-medium">Site Key</label>
|
||||||
<input
|
<Select value={siteKey} onValueChange={setSiteKey}>
|
||||||
type="text"
|
<SelectTrigger className="mt-1">
|
||||||
value={siteKey}
|
<SelectValue placeholder="Select Site" />
|
||||||
onChange={(e) => setSiteKey(e.target.value)}
|
</SelectTrigger>
|
||||||
className="mt-1 p-2 border rounded w-full"
|
|
||||||
placeholder="e.g., MH, Delta MA, (keep the site key exact same)"
|
<SelectContent>
|
||||||
/>
|
{SITE_KEYS.map((site) => (
|
||||||
|
<SelectItem key={site.value} value={site.value}>
|
||||||
|
{site.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium">Username</label>
|
<label className="block text-sm font-medium">Username</label>
|
||||||
|
|||||||
149
apps/Frontend/src/components/settings/npiProviderForm.tsx
Normal file
149
apps/Frontend/src/components/settings/npiProviderForm.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
defaultValues?: {
|
||||||
|
id?: number;
|
||||||
|
npiNumber: string;
|
||||||
|
providerName: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NpiProviderForm({ onClose, defaultValues }: Props) {
|
||||||
|
const [npiNumber, setNpiNumber] = useState(
|
||||||
|
defaultValues?.npiNumber || ""
|
||||||
|
);
|
||||||
|
const [providerName, setProviderName] = useState(
|
||||||
|
defaultValues?.providerName || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = {
|
||||||
|
npiNumber: npiNumber.trim(),
|
||||||
|
providerName: providerName.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = defaultValues?.id
|
||||||
|
? `/api/npiProviders/${defaultValues.id}`
|
||||||
|
: "/api/npiProviders/";
|
||||||
|
|
||||||
|
const method = defaultValues?.id ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const res = await apiRequest(method, url, payload);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null);
|
||||||
|
throw new Error(err?.message || "Failed to save NPI provider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: `NPI provider ${
|
||||||
|
defaultValues?.id ? "updated" : "created"
|
||||||
|
}.`,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["/api/npiProviders/"],
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNpiNumber(defaultValues?.npiNumber || "");
|
||||||
|
setProviderName(defaultValues?.providerName || "");
|
||||||
|
}, [defaultValues]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!npiNumber || !providerName) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "All fields are required.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
|
||||||
|
<h2 className="text-lg font-bold mb-4">
|
||||||
|
{defaultValues?.id
|
||||||
|
? "Edit NPI Provider"
|
||||||
|
: "Create NPI Provider"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
NPI Number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={npiNumber}
|
||||||
|
onChange={(e) => setNpiNumber(e.target.value)}
|
||||||
|
className="mt-1 p-2 border rounded w-full"
|
||||||
|
placeholder="e.g., 1489890992"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
Provider Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={providerName}
|
||||||
|
onChange={(e) => setProviderName(e.target.value)}
|
||||||
|
className="mt-1 p-2 border rounded w-full"
|
||||||
|
placeholder="e.g., Kai Gao"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-600 hover:underline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{mutation.isPending
|
||||||
|
? defaultValues?.id
|
||||||
|
? "Updating..."
|
||||||
|
: "Creating..."
|
||||||
|
: defaultValues?.id
|
||||||
|
? "Update"
|
||||||
|
: "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
apps/Frontend/src/components/settings/npiProviderTable.tsx
Normal file
199
apps/Frontend/src/components/settings/npiProviderTable.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Edit, Delete, Plus } from "lucide-react";
|
||||||
|
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||||
|
import { NpiProviderForm } from "./npiProviderForm";
|
||||||
|
|
||||||
|
type NpiProvider = {
|
||||||
|
id: number;
|
||||||
|
npiNumber: string;
|
||||||
|
providerName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NpiProviderTable() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingProvider, setEditingProvider] =
|
||||||
|
useState<NpiProvider | null>(null);
|
||||||
|
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [providerToDelete, setProviderToDelete] =
|
||||||
|
useState<NpiProvider | null>(null);
|
||||||
|
|
||||||
|
const providersPerPage = 5;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: providers = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["/api/npiProviders/"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch NPI providers");
|
||||||
|
return res.json() as Promise<NpiProvider[]>;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (provider: NpiProvider) => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"DELETE",
|
||||||
|
`/api/npiProviders/${provider.id}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to delete NPI provider");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["/api/npiProviders/"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteClick = (provider: NpiProvider) => {
|
||||||
|
setProviderToDelete(provider);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
if (!providerToDelete) return;
|
||||||
|
|
||||||
|
deleteMutation.mutate(providerToDelete, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setProviderToDelete(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const indexOfLast = currentPage * providersPerPage;
|
||||||
|
const indexOfFirst = indexOfLast - providersPerPage;
|
||||||
|
const currentProviders = providers.slice(
|
||||||
|
indexOfFirst,
|
||||||
|
indexOfLast
|
||||||
|
);
|
||||||
|
const totalPages = Math.ceil(providers.length / providersPerPage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
NPI Providers
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingProvider(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Add NPI Provider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
NPI Number
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Provider Name
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center py-4">
|
||||||
|
Loading NPI providers...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : error ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center py-4 text-red-600">
|
||||||
|
Error loading NPI providers
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : currentProviders.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center py-4">
|
||||||
|
No NPI providers found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
currentProviders.map((provider) => (
|
||||||
|
<tr key={provider.id}>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{provider.npiNumber}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{provider.providerName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingProvider(provider);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteClick(provider)}
|
||||||
|
>
|
||||||
|
<Delete className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providers.length > providersPerPage && (
|
||||||
|
<div className="px-4 py-3 border-t flex justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onClick={() => setCurrentPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalOpen && (
|
||||||
|
<NpiProviderForm
|
||||||
|
defaultValues={editingProvider || undefined}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
isOpen={isDeleteDialogOpen}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => setIsDeleteDialogOpen(false)}
|
||||||
|
entityName={providerToDelete?.providerName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Staff } from "@repo/db/types";
|
import { Staff, StaffFormData } from "@repo/db/types";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
interface StaffFormProps {
|
interface StaffFormProps {
|
||||||
initialData?: Partial<Staff>;
|
initialData?: Partial<Staff>;
|
||||||
onSubmit: (data: Omit<Staff, "id" | "createdAt">) => void;
|
onSubmit: (data: StaffFormData) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,8 @@ export function StaffForm({
|
|||||||
|
|
||||||
const [hasTypedRole, setHasTypedRole] = useState(false);
|
const [hasTypedRole, setHasTypedRole] = useState(false);
|
||||||
|
|
||||||
|
const [displayOrder, setDisplayOrder] = useState<number | "">("");
|
||||||
|
|
||||||
// Set initial values once on mount
|
// Set initial values once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
@@ -28,6 +30,9 @@ export function StaffForm({
|
|||||||
if (initialData.email) setEmail(initialData.email);
|
if (initialData.email) setEmail(initialData.email);
|
||||||
if (initialData.role) setRole(initialData.role);
|
if (initialData.role) setRole(initialData.role);
|
||||||
if (initialData.phone) setPhone(initialData.phone);
|
if (initialData.phone) setPhone(initialData.phone);
|
||||||
|
if (initialData?.displayOrder !== undefined) {
|
||||||
|
setDisplayOrder(initialData.displayOrder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []); // run once only
|
}, []); // run once only
|
||||||
|
|
||||||
@@ -43,6 +48,7 @@ export function StaffForm({
|
|||||||
email: email.trim() || undefined,
|
email: email.trim() || undefined,
|
||||||
role: role.trim(),
|
role: role.trim(),
|
||||||
phone: phone.trim() || undefined,
|
phone: phone.trim() || undefined,
|
||||||
|
displayOrder: displayOrder === "" ? undefined : displayOrder,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,6 +101,24 @@ export function StaffForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Column Order
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="mt-1 block w-full border rounded p-2"
|
||||||
|
value={displayOrder}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDisplayOrder(e.target.value === "" ? "" : Number(e.target.value))
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Lower number appears first in appointment schedule
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Phone</label>
|
<label className="block text-sm font-medium text-gray-700">Phone</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -12,22 +12,36 @@ export const DeleteConfirmationDialog = ({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-[9999] pointer-events-auto">
|
||||||
<div className="bg-white p-6 rounded-md shadow-md w-[90%] max-w-md">
|
<div
|
||||||
|
className="bg-white p-6 rounded-md shadow-md w-[90%] max-w-md pointer-events-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<h2 className="text-xl font-semibold mb-4">Confirm Deletion</h2>
|
<h2 className="text-xl font-semibold mb-4">Confirm Deletion</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to delete <strong>{entityName}</strong>?
|
Are you sure you want to delete <strong>{entityName}</strong>?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end space-x-4">
|
<div className="mt-6 flex justify-end space-x-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
|
className="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
|
||||||
onClick={onCancel}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
|
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
|
||||||
onClick={onConfirm}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onConfirm();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL_BACKEND ?? "";
|
|||||||
|
|
||||||
async function throwIfResNotOk(res: Response) {
|
async function throwIfResNotOk(res: Response) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401) {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
if (!window.location.pathname.startsWith("/auth")) {
|
if (!window.location.pathname.startsWith("/auth")) {
|
||||||
window.location.href = "/auth";
|
window.location.href = "/auth";
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
Patient,
|
Patient,
|
||||||
PatientStatus,
|
PatientStatus,
|
||||||
UpdateAppointment,
|
UpdateAppointment,
|
||||||
|
Staff as DBStaff,
|
||||||
} from "@repo/db/types";
|
} from "@repo/db/types";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
} from "@/redux/slices/seleniumEligibilityBatchCheckTaskSlice";
|
} from "@/redux/slices/seleniumEligibilityBatchCheckTaskSlice";
|
||||||
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
||||||
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
|
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
|
||||||
|
import { AppointmentProceduresDialog } from "@/components/appointment-procedures/appointment-procedures-dialog";
|
||||||
|
|
||||||
// Define types for scheduling
|
// Define types for scheduling
|
||||||
interface TimeSlot {
|
interface TimeSlot {
|
||||||
@@ -60,12 +62,9 @@ interface TimeSlot {
|
|||||||
displayTime: string;
|
displayTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Staff {
|
type StaffWithColor = DBStaff & {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: "doctor" | "hygienist";
|
|
||||||
color: string;
|
color: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ScheduledAppointment {
|
interface ScheduledAppointment {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -93,6 +92,17 @@ export default function AppointmentsPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
const [proceduresDialogOpen, setProceduresDialogOpen] = useState(false);
|
||||||
|
const [proceduresAppointmentId, setProceduresAppointmentId] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [proceduresPatientId, setProceduresPatientId] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [proceduresPatient, setProceduresPatient] = useState<Patient | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||||
const [editingAppointment, setEditingAppointment] = useState<
|
const [editingAppointment, setEditingAppointment] = useState<
|
||||||
Appointment | undefined
|
Appointment | undefined
|
||||||
@@ -105,7 +115,7 @@ export default function AppointmentsPage() {
|
|||||||
}>({ open: false });
|
}>({ open: false });
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const batchTask = useAppSelector(
|
const batchTask = useAppSelector(
|
||||||
(state) => state.seleniumEligibilityBatchCheckTask
|
(state) => state.seleniumEligibilityBatchCheckTask,
|
||||||
);
|
);
|
||||||
const [isCheckingAllElig, setIsCheckingAllElig] = useState(false);
|
const [isCheckingAllElig, setIsCheckingAllElig] = useState(false);
|
||||||
const [processedAppointmentIds, setProcessedAppointmentIds] = useState<
|
const [processedAppointmentIds, setProcessedAppointmentIds] = useState<
|
||||||
@@ -141,7 +151,7 @@ export default function AppointmentsPage() {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"GET",
|
"GET",
|
||||||
`/api/appointments/day?date=${formattedSelectedDate}`
|
`/api/appointments/day?date=${formattedSelectedDate}`,
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Failed to load appointments for date");
|
throw new Error("Failed to load appointments for date");
|
||||||
@@ -156,7 +166,7 @@ export default function AppointmentsPage() {
|
|||||||
const patientsFromDay = dayPayload.patients ?? [];
|
const patientsFromDay = dayPayload.patients ?? [];
|
||||||
|
|
||||||
// Staff memebers
|
// Staff memebers
|
||||||
const { data: staffMembersRaw = [] as Staff[] } = useQuery<Staff[]>({
|
const { data: staffMembersRaw = [] as DBStaff[] } = useQuery<DBStaff[]>({
|
||||||
queryKey: ["/api/staffs/"],
|
queryKey: ["/api/staffs/"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiRequest("GET", "/api/staffs/");
|
const res = await apiRequest("GET", "/api/staffs/");
|
||||||
@@ -174,11 +184,18 @@ export default function AppointmentsPage() {
|
|||||||
"bg-orange-500", // softer warm orange
|
"bg-orange-500", // softer warm orange
|
||||||
];
|
];
|
||||||
|
|
||||||
// Assign colors cycling through the list
|
// Assign colors cycling through the list, and order them by display order for the page column.
|
||||||
const staffMembers = staffMembersRaw.map((staff, index) => ({
|
|
||||||
...staff,
|
|
||||||
|
|
||||||
color: colors[index % colors.length] || "bg-gray-400",
|
const orderedStaff = staffMembersRaw.filter(
|
||||||
|
(s): s is DBStaff & { displayOrder: number } =>
|
||||||
|
typeof s.displayOrder === "number" && s.displayOrder > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
orderedStaff.sort((a, b) => a.displayOrder - b.displayOrder);
|
||||||
|
|
||||||
|
const staffMembers: StaffWithColor[] = orderedStaff.map((staff, index) => ({
|
||||||
|
...staff,
|
||||||
|
color: colors[index % colors.length] ?? "bg-gray-400",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Generate time slots from 8:00 AM to 6:00 PM in 15-minute increments
|
// Generate time slots from 8:00 AM to 6:00 PM in 15-minute increments
|
||||||
@@ -233,13 +250,13 @@ export default function AppointmentsPage() {
|
|||||||
};
|
};
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
"newAppointmentData",
|
"newAppointmentData",
|
||||||
JSON.stringify(newAppointmentData)
|
JSON.stringify(newAppointmentData),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If sessionStorage parsing fails, overwrite with a fresh object
|
// If sessionStorage parsing fails, overwrite with a fresh object
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
"newAppointmentData",
|
"newAppointmentData",
|
||||||
JSON.stringify({ patientId: patientId })
|
JSON.stringify({ patientId: patientId }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +277,7 @@ export default function AppointmentsPage() {
|
|||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/appointments/upsert",
|
"/api/appointments/upsert",
|
||||||
appointment
|
appointment,
|
||||||
);
|
);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
},
|
},
|
||||||
@@ -296,7 +313,7 @@ export default function AppointmentsPage() {
|
|||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"PUT",
|
"PUT",
|
||||||
`/api/appointments/${id}`,
|
`/api/appointments/${id}`,
|
||||||
appointment
|
appointment,
|
||||||
);
|
);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
},
|
},
|
||||||
@@ -347,7 +364,7 @@ export default function AppointmentsPage() {
|
|||||||
|
|
||||||
// Handle appointment submission (create or update)
|
// Handle appointment submission (create or update)
|
||||||
const handleAppointmentSubmit = (
|
const handleAppointmentSubmit = (
|
||||||
appointmentData: InsertAppointment | UpdateAppointment
|
appointmentData: InsertAppointment | UpdateAppointment,
|
||||||
) => {
|
) => {
|
||||||
// Converts local date to exact UTC date with no offset issues
|
// Converts local date to exact UTC date with no offset issues
|
||||||
const rawDate = parseLocalDate(appointmentData.date);
|
const rawDate = parseLocalDate(appointmentData.date);
|
||||||
@@ -467,7 +484,7 @@ export default function AppointmentsPage() {
|
|||||||
// Handle creating a new appointment at a specific time slot and for a specific staff member
|
// Handle creating a new appointment at a specific time slot and for a specific staff member
|
||||||
const handleCreateAppointmentAtSlot = (
|
const handleCreateAppointmentAtSlot = (
|
||||||
timeSlot: TimeSlot,
|
timeSlot: TimeSlot,
|
||||||
staffId: number
|
staffId: number,
|
||||||
) => {
|
) => {
|
||||||
// Calculate end time (30 minutes after start time)
|
// Calculate end time (30 minutes after start time)
|
||||||
const startHour = parseInt(timeSlot.time.split(":")[0] as string);
|
const startHour = parseInt(timeSlot.time.split(":")[0] as string);
|
||||||
@@ -520,7 +537,7 @@ export default function AppointmentsPage() {
|
|||||||
try {
|
try {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
"newAppointmentData",
|
"newAppointmentData",
|
||||||
JSON.stringify(mergedAppointment)
|
JSON.stringify(mergedAppointment),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore sessionStorage write failures
|
// ignore sessionStorage write failures
|
||||||
@@ -538,7 +555,7 @@ export default function AppointmentsPage() {
|
|||||||
const handleMoveAppointment = (
|
const handleMoveAppointment = (
|
||||||
appointmentId: number,
|
appointmentId: number,
|
||||||
newTimeSlot: TimeSlot,
|
newTimeSlot: TimeSlot,
|
||||||
newStaffId: number
|
newStaffId: number,
|
||||||
) => {
|
) => {
|
||||||
const appointment = appointments.find((a) => a.id === appointmentId);
|
const appointment = appointments.find((a) => a.id === appointmentId);
|
||||||
if (!appointment) return;
|
if (!appointment) return;
|
||||||
@@ -591,7 +608,7 @@ export default function AppointmentsPage() {
|
|||||||
staff,
|
staff,
|
||||||
}: {
|
}: {
|
||||||
appointment: ScheduledAppointment;
|
appointment: ScheduledAppointment;
|
||||||
staff: Staff;
|
staff: StaffWithColor;
|
||||||
}) {
|
}) {
|
||||||
const [{ isDragging }, drag] = useDrag(() => ({
|
const [{ isDragging }, drag] = useDrag(() => ({
|
||||||
type: ItemTypes.APPOINTMENT,
|
type: ItemTypes.APPOINTMENT,
|
||||||
@@ -612,7 +629,7 @@ export default function AppointmentsPage() {
|
|||||||
// Only allow edit on click if we're not dragging
|
// Only allow edit on click if we're not dragging
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
const fullAppointment = appointments.find(
|
const fullAppointment = appointments.find(
|
||||||
(a) => a.id === appointment.id
|
(a) => a.id === appointment.id,
|
||||||
);
|
);
|
||||||
if (fullAppointment) {
|
if (fullAppointment) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -647,7 +664,7 @@ export default function AppointmentsPage() {
|
|||||||
timeSlot: TimeSlot;
|
timeSlot: TimeSlot;
|
||||||
staffId: number;
|
staffId: number;
|
||||||
appointment: ScheduledAppointment | undefined;
|
appointment: ScheduledAppointment | undefined;
|
||||||
staff: Staff;
|
staff: StaffWithColor;
|
||||||
}) {
|
}) {
|
||||||
const [{ isOver, canDrop }, drop] = useDrop(() => ({
|
const [{ isOver, canDrop }, drop] = useDrop(() => ({
|
||||||
accept: ItemTypes.APPOINTMENT,
|
accept: ItemTypes.APPOINTMENT,
|
||||||
@@ -688,13 +705,13 @@ export default function AppointmentsPage() {
|
|||||||
// appointment page — update these handlers
|
// appointment page — update these handlers
|
||||||
const handleCheckEligibility = (appointmentId: number) => {
|
const handleCheckEligibility = (appointmentId: number) => {
|
||||||
setLocation(
|
setLocation(
|
||||||
`/insurance-status?appointmentId=${appointmentId}&action=eligibility`
|
`/insurance-status?appointmentId=${appointmentId}&action=eligibility`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckClaimStatus = (appointmentId: number) => {
|
const handleCheckClaimStatus = (appointmentId: number) => {
|
||||||
setLocation(
|
setLocation(
|
||||||
`/insurance-status?appointmentId=${appointmentId}&action=claim`
|
`/insurance-status?appointmentId=${appointmentId}&action=claim`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -708,7 +725,7 @@ export default function AppointmentsPage() {
|
|||||||
|
|
||||||
const handleChartPlan = (appointmentId: number) => {
|
const handleChartPlan = (appointmentId: number) => {
|
||||||
console.log(
|
console.log(
|
||||||
`Viewing chart/treatment plan for appointment: ${appointmentId}`
|
`Viewing chart/treatment plan for appointment: ${appointmentId}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -733,7 +750,7 @@ export default function AppointmentsPage() {
|
|||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: `Checking eligibility for appointments on ${dateParam}...`,
|
message: `Checking eligibility for appointments on ${dateParam}...`,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
setIsCheckingAllElig(true);
|
setIsCheckingAllElig(true);
|
||||||
@@ -743,7 +760,7 @@ export default function AppointmentsPage() {
|
|||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"POST",
|
"POST",
|
||||||
`/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}`,
|
`/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}`,
|
||||||
{}
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
// read body for all cases so we can show per-appointment info
|
// read body for all cases so we can show per-appointment info
|
||||||
@@ -761,7 +778,7 @@ export default function AppointmentsPage() {
|
|||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: `Batch eligibility failed: ${errMsg}`,
|
message: `Batch eligibility failed: ${errMsg}`,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
toast({
|
toast({
|
||||||
title: "Batch check failed",
|
title: "Batch check failed",
|
||||||
@@ -839,7 +856,7 @@ export default function AppointmentsPage() {
|
|||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
status: skippedCount > 0 ? "error" : "success",
|
status: skippedCount > 0 ? "error" : "success",
|
||||||
message: `Batch processed ${results.length} appointments — success: ${successCount}, warnings: ${warningCount}, skipped: ${skippedCount}.`,
|
message: `Batch processed ${results.length} appointments — success: ${successCount}, warnings: ${warningCount}, skipped: ${skippedCount}.`,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// also show final toast summary
|
// also show final toast summary
|
||||||
@@ -854,7 +871,7 @@ export default function AppointmentsPage() {
|
|||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: `Batch eligibility error: ${err?.message ?? String(err)}`,
|
message: `Batch eligibility error: ${err?.message ?? String(err)}`,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
toast({
|
toast({
|
||||||
title: "Batch check failed",
|
title: "Batch check failed",
|
||||||
@@ -866,6 +883,34 @@ export default function AppointmentsPage() {
|
|||||||
// intentionally do not clear task status here so banner persists until user dismisses it
|
// intentionally do not clear task status here so banner persists until user dismisses it
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenProcedures = (appointmentId: number) => {
|
||||||
|
const apt = appointments.find((a) => a.id === appointmentId);
|
||||||
|
if (!apt) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Appointment not found",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patient = patientsFromDay.find((p) => p.id === apt.patientId);
|
||||||
|
if (!patient) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Patient not found for this appointment",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProceduresAppointmentId(Number(apt.id));
|
||||||
|
setProceduresPatientId(apt.patientId);
|
||||||
|
setProceduresPatient(patient);
|
||||||
|
setProceduresDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SeleniumTaskBanner
|
<SeleniumTaskBanner
|
||||||
@@ -930,7 +975,7 @@ export default function AppointmentsPage() {
|
|||||||
<Item
|
<Item
|
||||||
onClick={({ props }) => {
|
onClick={({ props }) => {
|
||||||
const fullAppointment = appointments.find(
|
const fullAppointment = appointments.find(
|
||||||
(a) => a.id === props.appointmentId
|
(a) => a.id === props.appointmentId,
|
||||||
);
|
);
|
||||||
if (fullAppointment) {
|
if (fullAppointment) {
|
||||||
handleEditAppointment(fullAppointment);
|
handleEditAppointment(fullAppointment);
|
||||||
@@ -999,6 +1044,16 @@ export default function AppointmentsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
|
{/* Procedures */}
|
||||||
|
<Item
|
||||||
|
onClick={({ props }) => handleOpenProcedures(props.appointmentId)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-4 w-4" />
|
||||||
|
Procedures
|
||||||
|
</span>
|
||||||
|
</Item>
|
||||||
|
|
||||||
{/* Clinic Notes */}
|
{/* Clinic Notes */}
|
||||||
<Item onClick={({ props }) => handleClinicNotes(props.appointmentId)}>
|
<Item onClick={({ props }) => handleClinicNotes(props.appointmentId)}>
|
||||||
<span className="flex items-center gap-2 text-yellow-600">
|
<span className="flex items-center gap-2 text-yellow-600">
|
||||||
@@ -1098,7 +1153,7 @@ export default function AppointmentsPage() {
|
|||||||
staffId={Number(staff.id)}
|
staffId={Number(staff.id)}
|
||||||
appointment={getAppointmentAtSlot(
|
appointment={getAppointmentAtSlot(
|
||||||
timeSlot,
|
timeSlot,
|
||||||
Number(staff.id)
|
Number(staff.id),
|
||||||
)}
|
)}
|
||||||
staff={staff}
|
staff={staff}
|
||||||
/>
|
/>
|
||||||
@@ -1126,6 +1181,24 @@ export default function AppointmentsPage() {
|
|||||||
onDelete={handleDeleteAppointment}
|
onDelete={handleDeleteAppointment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Appointment Procedure Dialog */}
|
||||||
|
{proceduresAppointmentId && proceduresPatientId && proceduresPatient && (
|
||||||
|
<AppointmentProceduresDialog
|
||||||
|
open={proceduresDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setProceduresDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setProceduresAppointmentId(null);
|
||||||
|
setProceduresPatientId(null);
|
||||||
|
setProceduresPatient(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
appointmentId={proceduresAppointmentId}
|
||||||
|
patientId={proceduresPatientId}
|
||||||
|
patient={proceduresPatient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DeleteConfirmationDialog
|
<DeleteConfirmationDialog
|
||||||
isOpen={confirmDeleteState.open}
|
isOpen={confirmDeleteState.open}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={handleConfirmDelete}
|
||||||
|
|||||||
@@ -146,9 +146,13 @@ export default function ClaimsPage() {
|
|||||||
|
|
||||||
// case1: - this params are set by pdf extraction/patient page or either by patient-add-form. then used in claim page here.
|
// case1: - this params are set by pdf extraction/patient page or either by patient-add-form. then used in claim page here.
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { newPatient } = useMemo(() => {
|
|
||||||
|
const { newPatient, mode } = useMemo(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
return { newPatient: params.get("newPatient") };
|
return {
|
||||||
|
newPatient: params.get("newPatient"),
|
||||||
|
mode: params.get("mode"), // direct | manual | null};
|
||||||
|
};
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
const handleNewClaim = (patientId: number, appointmentId?: number) => {
|
const handleNewClaim = (patientId: number, appointmentId?: number) => {
|
||||||
@@ -532,6 +536,7 @@ export default function ClaimsPage() {
|
|||||||
<ClaimForm
|
<ClaimForm
|
||||||
patientId={selectedPatientId}
|
patientId={selectedPatientId}
|
||||||
appointmentId={selectedAppointmentId ?? undefined}
|
appointmentId={selectedAppointmentId ?? undefined}
|
||||||
|
autoSubmit={mode === "direct"}
|
||||||
onClose={closeClaim}
|
onClose={closeClaim}
|
||||||
onSubmit={handleClaimSubmit}
|
onSubmit={handleClaimSubmit}
|
||||||
onHandleAppointmentSubmit={handleAppointmentSubmit}
|
onHandleAppointmentSubmit={handleAppointmentSubmit}
|
||||||
|
|||||||
@@ -28,13 +28,14 @@ import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
|||||||
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
||||||
|
import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal";
|
||||||
|
|
||||||
export default function InsuranceStatusPage() {
|
export default function InsuranceStatusPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { status, message, show } = useAppSelector(
|
const { status, message, show } = useAppSelector(
|
||||||
(state) => state.seleniumEligibilityCheckTask
|
(state) => state.seleniumEligibilityCheckTask,
|
||||||
);
|
);
|
||||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
@@ -56,12 +57,6 @@ export default function InsuranceStatusPage() {
|
|||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
// 1) state to remember we should auto-run once patient arrives
|
|
||||||
const [pendingAutoAction, setPendingAutoAction] = useState<{
|
|
||||||
appointmentId: number;
|
|
||||||
action: "eligibility" | "claim";
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Populate fields from selected patient
|
// Populate fields from selected patient
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPatient) {
|
if (selectedPatient) {
|
||||||
@@ -129,12 +124,12 @@ export default function InsuranceStatusPage() {
|
|||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: "Sending Data to Selenium...",
|
message: "Sending Data to Selenium...",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
const response = await apiRequest(
|
const response = await apiRequest(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/insurance-status/eligibility-check",
|
"/api/insurance-status/eligibility-check",
|
||||||
{ data: JSON.stringify(data) }
|
{ data: JSON.stringify(data) },
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.error) throw new Error(result.error);
|
if (result.error) throw new Error(result.error);
|
||||||
@@ -144,7 +139,7 @@ export default function InsuranceStatusPage() {
|
|||||||
status: "success",
|
status: "success",
|
||||||
message:
|
message:
|
||||||
"Patient status is updated, and its eligibility pdf is uploaded at Document Page.",
|
"Patient status is updated, and its eligibility pdf is uploaded at Document Page.",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -161,7 +156,7 @@ export default function InsuranceStatusPage() {
|
|||||||
setPreviewPdfId(Number(result.pdfFileId));
|
setPreviewPdfId(Number(result.pdfFileId));
|
||||||
// optional fallback name while header is parsed
|
// optional fallback name while header is parsed
|
||||||
setPreviewFallbackFilename(
|
setPreviewFallbackFilename(
|
||||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`
|
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||||
);
|
);
|
||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
}
|
}
|
||||||
@@ -170,7 +165,7 @@ export default function InsuranceStatusPage() {
|
|||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: error.message || "Selenium submission failed",
|
message: error.message || "Selenium submission failed",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
toast({
|
toast({
|
||||||
title: "Selenium service error",
|
title: "Selenium service error",
|
||||||
@@ -194,12 +189,12 @@ export default function InsuranceStatusPage() {
|
|||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: "Sending Data to Selenium...",
|
message: "Sending Data to Selenium...",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
const response = await apiRequest(
|
const response = await apiRequest(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/insurance-status/claim-status-check",
|
"/api/insurance-status/claim-status-check",
|
||||||
{ data: JSON.stringify(data) }
|
{ data: JSON.stringify(data) },
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.error) throw new Error(result.error);
|
if (result.error) throw new Error(result.error);
|
||||||
@@ -209,7 +204,7 @@ export default function InsuranceStatusPage() {
|
|||||||
status: "success",
|
status: "success",
|
||||||
message:
|
message:
|
||||||
"Claim status is updated, and its pdf is uploaded at Document Page.",
|
"Claim status is updated, and its pdf is uploaded at Document Page.",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -226,7 +221,7 @@ export default function InsuranceStatusPage() {
|
|||||||
setPreviewPdfId(Number(result.pdfFileId));
|
setPreviewPdfId(Number(result.pdfFileId));
|
||||||
// optional fallback name while header is parsed
|
// optional fallback name while header is parsed
|
||||||
setPreviewFallbackFilename(
|
setPreviewFallbackFilename(
|
||||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`
|
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||||
);
|
);
|
||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
}
|
}
|
||||||
@@ -235,7 +230,7 @@ export default function InsuranceStatusPage() {
|
|||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: error.message || "Selenium submission failed",
|
message: error.message || "Selenium submission failed",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
toast({
|
toast({
|
||||||
title: "Selenium service error",
|
title: "Selenium service error",
|
||||||
@@ -369,7 +364,6 @@ export default function InsuranceStatusPage() {
|
|||||||
// set selectedPatient as before
|
// set selectedPatient as before
|
||||||
setSelectedPatient(patient as Patient);
|
setSelectedPatient(patient as Patient);
|
||||||
|
|
||||||
setPendingAutoAction({ appointmentId: id, action: action as any });
|
|
||||||
clearUrlParams(["appointmentId", "action"]);
|
clearUrlParams(["appointmentId", "action"]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -390,82 +384,41 @@ export default function InsuranceStatusPage() {
|
|||||||
};
|
};
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
// ---------- same case1: runs when selectedPatient AND form fields are ready ----------
|
// handling case-1, when redirect happens from appointment page:
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingAutoAction) return;
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (!selectedPatient) return; // wait until fetch effect set it
|
const appointmentId = params.get("appointmentId");
|
||||||
|
if (!appointmentId) return;
|
||||||
|
|
||||||
if (
|
const id = Number(appointmentId);
|
||||||
selectedPatient &&
|
if (Number.isNaN(id) || id <= 0) return;
|
||||||
memberId === "" &&
|
|
||||||
firstName === "" &&
|
|
||||||
dateOfBirth === null
|
|
||||||
) {
|
|
||||||
// form hasn't been populated yet; do nothing and wait for the next re-render
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let inFlight = false;
|
|
||||||
|
|
||||||
// helper: determine final values using both selectedPatient and current form state
|
|
||||||
const finalMemberId =
|
|
||||||
(selectedPatient?.insuranceId
|
|
||||||
? String(selectedPatient.insuranceId).trim()
|
|
||||||
: "") || (memberId ? memberId.trim() : "");
|
|
||||||
|
|
||||||
const finalFirstName =
|
|
||||||
(selectedPatient?.firstName
|
|
||||||
? String(selectedPatient.firstName).trim()
|
|
||||||
: "") || (firstName ? firstName.trim() : "");
|
|
||||||
|
|
||||||
// DOB: try component state first (user may have typed), else patient fallback
|
|
||||||
const parsedDobFromPatient =
|
|
||||||
selectedPatient?.dateOfBirth != null
|
|
||||||
? typeof selectedPatient.dateOfBirth === "string"
|
|
||||||
? parseLocalDate(selectedPatient.dateOfBirth)
|
|
||||||
: selectedPatient.dateOfBirth
|
|
||||||
: null;
|
|
||||||
const finalDob = dateOfBirth ?? parsedDobFromPatient ?? null;
|
|
||||||
|
|
||||||
const missing: string[] = [];
|
|
||||||
if (!finalMemberId) missing.push("Member ID");
|
|
||||||
if (!finalFirstName) missing.push("First Name");
|
|
||||||
if (!finalDob) missing.push("Date of Birth");
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
toast({
|
|
||||||
title: "Missing Fields",
|
|
||||||
description: `Cannot auto-run. Missing: ${missing.join(", ")}.`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If ready, call the requested handler once. Clear pendingAutoAction afterwards.
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (cancelled) return;
|
|
||||||
if (inFlight) return;
|
|
||||||
inFlight = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (pendingAutoAction.action === "eligibility") {
|
const res = await apiRequest("GET", `/api/appointments/${id}/patient`);
|
||||||
await handleMHEligibilityButton();
|
if (!res.ok) return;
|
||||||
} else {
|
|
||||||
await handleMHStatusButton();
|
const data = await res.json();
|
||||||
|
const patient = data?.patient ?? data;
|
||||||
|
|
||||||
|
if (!cancelled && patient) {
|
||||||
|
// ✅ ONLY prefill patient
|
||||||
|
setSelectedPatient(patient as Patient);
|
||||||
|
|
||||||
|
// ✅ clean URL (no auto selenium)
|
||||||
|
clearUrlParams(["appointmentId", "action"]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Auto MH action failed:", err);
|
console.error("Failed to fetch patient from appointment", err);
|
||||||
} finally {
|
|
||||||
inFlight = false;
|
|
||||||
if (!cancelled) setPendingAutoAction(null); // clear so it doesn't run again
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [pendingAutoAction, selectedPatient, memberId, firstName, dateOfBirth]);
|
}, [location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -589,7 +542,7 @@ export default function InsuranceStatusPage() {
|
|||||||
onPdfReady={(pdfId, fallbackFilename) => {
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
setPreviewPdfId(pdfId);
|
setPreviewPdfId(pdfId);
|
||||||
setPreviewFallbackFilename(
|
setPreviewFallbackFilename(
|
||||||
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`
|
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`,
|
||||||
);
|
);
|
||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
}}
|
}}
|
||||||
@@ -616,14 +569,20 @@ export default function InsuranceStatusPage() {
|
|||||||
|
|
||||||
{/* Row 2 */}
|
{/* Row 2 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Button
|
<DentaQuestEligibilityButton
|
||||||
className="w-full"
|
memberId={memberId}
|
||||||
variant="outline"
|
dateOfBirth={dateOfBirth}
|
||||||
disabled={isFormIncomplete}
|
firstName={firstName}
|
||||||
>
|
lastName={lastName}
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
isFormIncomplete={isFormIncomplete}
|
||||||
Tufts SCO/SWH/Navi/Mass Gen
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
</Button>
|
setPreviewPdfId(pdfId);
|
||||||
|
setPreviewFallbackFilename(
|
||||||
|
fallbackFilename ?? `eligibility_dentaquest_${memberId}.pdf`
|
||||||
|
);
|
||||||
|
setPreviewOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { StaffForm } from "@/components/staffs/staff-form";
|
|||||||
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||||
import { CredentialTable } from "@/components/settings/insuranceCredTable";
|
import { CredentialTable } from "@/components/settings/insuranceCredTable";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { Staff } from "@repo/db/types";
|
import { Staff, StaffFormData } from "@repo/db/types";
|
||||||
|
import { NpiProviderTable } from "@/components/settings/npiProviderTable";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -44,9 +44,9 @@ export default function SettingsPage() {
|
|||||||
const addStaffMutate = useMutation<
|
const addStaffMutate = useMutation<
|
||||||
Staff, // Return type
|
Staff, // Return type
|
||||||
Error, // Error type
|
Error, // Error type
|
||||||
Omit<Staff, "id" | "createdAt"> // Variables
|
StaffFormData
|
||||||
>({
|
>({
|
||||||
mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => {
|
mutationFn: async (newStaff: StaffFormData) => {
|
||||||
const res = await apiRequest("POST", "/api/staffs/", newStaff);
|
const res = await apiRequest("POST", "/api/staffs/", newStaff);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => null);
|
const errorData = await res.json().catch(() => null);
|
||||||
@@ -75,7 +75,7 @@ export default function SettingsPage() {
|
|||||||
const updateStaffMutate = useMutation<
|
const updateStaffMutate = useMutation<
|
||||||
Staff,
|
Staff,
|
||||||
Error,
|
Error,
|
||||||
{ id: number; updatedFields: Partial<Staff> }
|
{ id: number; updatedFields: Partial<StaffFormData> }
|
||||||
>({
|
>({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
id,
|
id,
|
||||||
@@ -157,7 +157,7 @@ export default function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submit for Add or Edit
|
// Handle form submit for Add or Edit
|
||||||
const handleFormSubmit = (formData: Omit<Staff, "id" | "createdAt">) => {
|
const handleFormSubmit = (formData: StaffFormData) => {
|
||||||
if (editingStaff) {
|
if (editingStaff) {
|
||||||
// Editing existing staff
|
// Editing existing staff
|
||||||
if (editingStaff.id === undefined) {
|
if (editingStaff.id === undefined) {
|
||||||
@@ -190,7 +190,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const [isDeleteStaffOpen, setIsDeleteStaffOpen] = useState(false);
|
const [isDeleteStaffOpen, setIsDeleteStaffOpen] = useState(false);
|
||||||
const [currentStaff, setCurrentStaff] = useState<Staff | undefined>(
|
const [currentStaff, setCurrentStaff] = useState<Staff | undefined>(
|
||||||
undefined
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteStaff = (staff: Staff) => {
|
const handleDeleteStaff = (staff: Staff) => {
|
||||||
@@ -212,7 +212,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const handleViewStaff = (staff: Staff) =>
|
const handleViewStaff = (staff: Staff) =>
|
||||||
alert(
|
alert(
|
||||||
`Viewing staff member:\n${staff.name} (${staff.email || "No email"})`
|
`Viewing staff member:\n${staff.name} (${staff.email || "No email"})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// MANAGE USER
|
// MANAGE USER
|
||||||
@@ -229,7 +229,7 @@ export default function SettingsPage() {
|
|||||||
//update user mutation
|
//update user mutation
|
||||||
const updateUserMutate = useMutation({
|
const updateUserMutate = useMutation({
|
||||||
mutationFn: async (
|
mutationFn: async (
|
||||||
updates: Partial<{ username: string; password: string }>
|
updates: Partial<{ username: string; password: string }>,
|
||||||
) => {
|
) => {
|
||||||
if (!user?.id) throw new Error("User not loaded");
|
if (!user?.id) throw new Error("User not loaded");
|
||||||
const res = await apiRequest("PUT", `/api/users/${user.id}`, updates);
|
const res = await apiRequest("PUT", `/api/users/${user.id}`, updates);
|
||||||
@@ -333,9 +333,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium">
|
<label className="block text-sm font-medium">New Password</label>
|
||||||
New Password
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
@@ -362,6 +360,11 @@ export default function SettingsPage() {
|
|||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<CredentialTable />
|
<CredentialTable />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* NpiProvider Section */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<NpiProviderTable />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,22 +253,22 @@ export const PROCEDURE_COMBOS: Record<
|
|||||||
// Orthodontics
|
// Orthodontics
|
||||||
orthPreExamDirect: {
|
orthPreExamDirect: {
|
||||||
id: "orthPreExamDirect",
|
id: "orthPreExamDirect",
|
||||||
label: "Pre-Orth Exam",
|
label: "Direct Pre-Orth Exam",
|
||||||
codes: ["D9310"],
|
codes: ["D9310"],
|
||||||
},
|
},
|
||||||
orthRecordDirect: {
|
orthRecordDirect: {
|
||||||
id: "orthRecordDirect",
|
id: "orthRecordDirect",
|
||||||
label: "Orth Record",
|
label: "Direct Orth Record",
|
||||||
codes: ["D8660"],
|
codes: ["D8660"],
|
||||||
},
|
},
|
||||||
orthPerioVisitDirect: {
|
orthPerioVisitDirect: {
|
||||||
id: "orthPerioVisitDirect",
|
id: "orthPerioVisitDirect",
|
||||||
label: "Perio Orth Visit ",
|
label: "Direct Perio Orth Visit ",
|
||||||
codes: ["D8670"],
|
codes: ["D8670"],
|
||||||
},
|
},
|
||||||
orthRetentionDirect: {
|
orthRetentionDirect: {
|
||||||
id: "orthRetentionDirect",
|
id: "orthRetentionDirect",
|
||||||
label: "Orth Retention",
|
label: "Direct Orth Retention",
|
||||||
codes: ["D8680"],
|
codes: ["D8680"],
|
||||||
},
|
},
|
||||||
orthPA: {
|
orthPA: {
|
||||||
@@ -276,6 +276,20 @@ export const PROCEDURE_COMBOS: Record<
|
|||||||
label: "Orth PA",
|
label: "Orth PA",
|
||||||
codes: ["D8080", "D8670", "D8660"],
|
codes: ["D8080", "D8670", "D8660"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Exam
|
||||||
|
limitedExamPA: {
|
||||||
|
id: "limitedExamPA",
|
||||||
|
label: "Limited Exam – PA",
|
||||||
|
codes: ["D0140", "D0220"],
|
||||||
|
},
|
||||||
|
|
||||||
|
emergencyExamPA: {
|
||||||
|
id: "emergencyExamPA",
|
||||||
|
label: "Emergency Exam – PA",
|
||||||
|
codes: ["D9110", "D0220"],
|
||||||
|
},
|
||||||
|
|
||||||
// add more…
|
// add more…
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -291,6 +305,9 @@ export const COMBO_CATEGORIES: Record<
|
|||||||
"newAdultPatientPano",
|
"newAdultPatientPano",
|
||||||
"newAdultPatientFMX",
|
"newAdultPatientFMX",
|
||||||
],
|
],
|
||||||
|
|
||||||
|
Exams: ["limitedExamPA", "emergencyExamPA"],
|
||||||
|
|
||||||
"Composite Fillings (Front)": [
|
"Composite Fillings (Front)": [
|
||||||
"oneSurfCompFront",
|
"oneSurfCompFront",
|
||||||
"twoSurfCompFront",
|
"twoSurfCompFront",
|
||||||
|
|||||||
@@ -308,3 +308,6 @@ export function applyComboToForm<T extends ClaimFormLike>(
|
|||||||
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export { CODE_MAP, getPriceForCodeWithAgeFromMap };
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "pip install -r requirements.txt",
|
"postinstall": "pip install -r requirements.txt",
|
||||||
"dev": "python main.py"
|
"dev": "python3 main.py"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "pip install -r requirements.txt",
|
"postinstall": "pip install -r requirements.txt",
|
||||||
"dev": "python main.py"
|
"dev": "python3 main.py"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,26 @@ from selenium_preAuthWorker import AutomationMassHealthPreAuth
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import helpers_ddma_eligibility as hddma
|
import helpers_ddma_eligibility as hddma
|
||||||
|
import helpers_dentaquest_eligibility as hdentaquest
|
||||||
|
|
||||||
|
# Import session clear functions for startup
|
||||||
|
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||||
|
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Clear all sessions on startup (after PC restart)
|
||||||
|
# This ensures users must login again after PC restart
|
||||||
|
print("=" * 50)
|
||||||
|
print("SELENIUM AGENT STARTING - CLEARING ALL SESSIONS")
|
||||||
|
print("=" * 50)
|
||||||
|
clear_ddma_session_on_startup()
|
||||||
|
clear_dentaquest_session_on_startup()
|
||||||
|
print("=" * 50)
|
||||||
|
print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
# Allow 1 selenium session at a time
|
# Allow 1 selenium session at a time
|
||||||
semaphore = asyncio.Semaphore(1)
|
semaphore = asyncio.Semaphore(1)
|
||||||
@@ -186,6 +202,79 @@ async def ddma_eligibility(request: Request):
|
|||||||
return {"status": "started", "session_id": sid}
|
return {"status": "started", "session_id": sid}
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoint:6 - DentaQuest eligibility (background, OTP)
|
||||||
|
|
||||||
|
async def _dentaquest_worker_wrapper(sid: str, data: dict, url: str):
|
||||||
|
"""
|
||||||
|
Background worker that:
|
||||||
|
- acquires semaphore (to keep 1 selenium at a time),
|
||||||
|
- updates active/queued counters,
|
||||||
|
- runs the DentaQuest flow via helpers.start_dentaquest_run.
|
||||||
|
"""
|
||||||
|
global active_jobs, waiting_jobs
|
||||||
|
async with semaphore:
|
||||||
|
async with lock:
|
||||||
|
waiting_jobs -= 1
|
||||||
|
active_jobs += 1
|
||||||
|
try:
|
||||||
|
await hdentaquest.start_dentaquest_run(sid, data, url)
|
||||||
|
finally:
|
||||||
|
async with lock:
|
||||||
|
active_jobs -= 1
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/dentaquest-eligibility")
|
||||||
|
async def dentaquest_eligibility(request: Request):
|
||||||
|
"""
|
||||||
|
Starts a DentaQuest eligibility session in the background.
|
||||||
|
Body: { "data": { ... }, "url"?: string }
|
||||||
|
Returns: { status: "started", session_id: "<uuid>" }
|
||||||
|
"""
|
||||||
|
global waiting_jobs
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
data = body.get("data", {})
|
||||||
|
|
||||||
|
# create session
|
||||||
|
sid = hdentaquest.make_session_entry()
|
||||||
|
hdentaquest.sessions[sid]["type"] = "dentaquest_eligibility"
|
||||||
|
hdentaquest.sessions[sid]["last_activity"] = time.time()
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
waiting_jobs += 1
|
||||||
|
|
||||||
|
# run in background (queued under semaphore)
|
||||||
|
asyncio.create_task(_dentaquest_worker_wrapper(sid, data, url="https://providers.dentaquest.com/onboarding/start/"))
|
||||||
|
|
||||||
|
return {"status": "started", "session_id": sid}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/dentaquest-submit-otp")
|
||||||
|
async def dentaquest_submit_otp(request: Request):
|
||||||
|
"""
|
||||||
|
Body: { "session_id": "<sid>", "otp": "123456" }
|
||||||
|
Node / frontend call this when user provides OTP for DentaQuest.
|
||||||
|
"""
|
||||||
|
body = await request.json()
|
||||||
|
sid = body.get("session_id")
|
||||||
|
otp = body.get("otp")
|
||||||
|
if not sid or not otp:
|
||||||
|
raise HTTPException(status_code=400, detail="session_id and otp required")
|
||||||
|
|
||||||
|
res = hdentaquest.submit_otp(sid, otp)
|
||||||
|
if res.get("status") == "error":
|
||||||
|
raise HTTPException(status_code=400, detail=res.get("message"))
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dentaquest-session/{sid}/status")
|
||||||
|
async def dentaquest_session_status(sid: str):
|
||||||
|
s = hdentaquest.get_session_status(sid)
|
||||||
|
if s.get("status") == "not_found":
|
||||||
|
raise HTTPException(status_code=404, detail="session not found")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
@app.post("/submit-otp")
|
@app.post("/submit-otp")
|
||||||
async def submit_otp(request: Request):
|
async def submit_otp(request: Request):
|
||||||
"""
|
"""
|
||||||
|
|||||||
277
apps/SeleniumService/ddma_browser_manager.py
Normal file
277
apps/SeleniumService/ddma_browser_manager.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive.
|
||||||
|
Clears session cookies on startup (after PC restart) to force fresh login.
|
||||||
|
Tracks credentials to detect changes mid-session.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import shutil
|
||||||
|
import hashlib
|
||||||
|
import threading
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
|
||||||
|
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
|
||||||
|
if not os.environ.get("DISPLAY"):
|
||||||
|
os.environ["DISPLAY"] = ":0"
|
||||||
|
|
||||||
|
|
||||||
|
class DDMABrowserManager:
|
||||||
|
"""
|
||||||
|
Singleton that manages a persistent Chrome browser instance.
|
||||||
|
- Uses --user-data-dir for persistent profile (device trust tokens)
|
||||||
|
- Clears session cookies on startup (after PC restart)
|
||||||
|
- Tracks credentials to detect changes mid-session
|
||||||
|
"""
|
||||||
|
_instance = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._driver = None
|
||||||
|
cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma")
|
||||||
|
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
|
||||||
|
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
|
||||||
|
cls._instance._needs_session_clear = False # Flag to clear session on next driver creation
|
||||||
|
os.makedirs(cls._instance.profile_dir, exist_ok=True)
|
||||||
|
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def clear_session_on_startup(self):
|
||||||
|
"""
|
||||||
|
Clear session cookies from Chrome profile on startup.
|
||||||
|
This forces a fresh login after PC restart.
|
||||||
|
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
|
||||||
|
"""
|
||||||
|
print("[DDMA BrowserManager] Clearing session on startup...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the credentials tracking file
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
os.remove(self._credentials_file)
|
||||||
|
print("[DDMA BrowserManager] Cleared credentials tracking file")
|
||||||
|
|
||||||
|
# Clear session-related files from Chrome profile
|
||||||
|
# These are the files that store login session cookies
|
||||||
|
session_files = [
|
||||||
|
"Cookies",
|
||||||
|
"Cookies-journal",
|
||||||
|
"Login Data",
|
||||||
|
"Login Data-journal",
|
||||||
|
"Web Data",
|
||||||
|
"Web Data-journal",
|
||||||
|
]
|
||||||
|
|
||||||
|
for filename in session_files:
|
||||||
|
filepath = os.path.join(self.profile_dir, "Default", filename)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
print(f"[DDMA BrowserManager] Removed {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not remove {filename}: {e}")
|
||||||
|
|
||||||
|
# Also try root level (some Chrome versions)
|
||||||
|
for filename in session_files:
|
||||||
|
filepath = os.path.join(self.profile_dir, filename)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
print(f"[DDMA BrowserManager] Removed root {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not remove root {filename}: {e}")
|
||||||
|
|
||||||
|
# Clear Session Storage (contains login state)
|
||||||
|
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
|
||||||
|
if os.path.exists(session_storage_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(session_storage_dir)
|
||||||
|
print("[DDMA BrowserManager] Cleared Session Storage")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not clear Session Storage: {e}")
|
||||||
|
|
||||||
|
# Clear Local Storage (may contain auth tokens)
|
||||||
|
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
|
||||||
|
if os.path.exists(local_storage_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(local_storage_dir)
|
||||||
|
print("[DDMA BrowserManager] Cleared Local Storage")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not clear Local Storage: {e}")
|
||||||
|
|
||||||
|
# Clear IndexedDB (may contain auth tokens)
|
||||||
|
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
|
||||||
|
if os.path.exists(indexeddb_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(indexeddb_dir)
|
||||||
|
print("[DDMA BrowserManager] Cleared IndexedDB")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not clear IndexedDB: {e}")
|
||||||
|
|
||||||
|
# Set flag to clear session via JavaScript after browser opens
|
||||||
|
self._needs_session_clear = True
|
||||||
|
|
||||||
|
print("[DDMA BrowserManager] Session cleared - will require fresh login")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Error clearing session: {e}")
|
||||||
|
|
||||||
|
def _hash_credentials(self, username: str) -> str:
|
||||||
|
"""Create a hash of the username to track credential changes."""
|
||||||
|
return hashlib.sha256(username.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
def get_last_credentials_hash(self) -> str | None:
|
||||||
|
"""Get the hash of the last-used credentials."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
with open(self._credentials_file, 'r') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_credentials_hash(self, username: str):
|
||||||
|
"""Save the hash of the current credentials."""
|
||||||
|
try:
|
||||||
|
cred_hash = self._hash_credentials(username)
|
||||||
|
with open(self._credentials_file, 'w') as f:
|
||||||
|
f.write(cred_hash)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Failed to save credentials hash: {e}")
|
||||||
|
|
||||||
|
def credentials_changed(self, username: str) -> bool:
|
||||||
|
"""Check if the credentials have changed since last login."""
|
||||||
|
last_hash = self.get_last_credentials_hash()
|
||||||
|
if last_hash is None:
|
||||||
|
return False # No previous credentials, not a change
|
||||||
|
current_hash = self._hash_credentials(username)
|
||||||
|
changed = last_hash != current_hash
|
||||||
|
if changed:
|
||||||
|
print(f"[DDMA BrowserManager] Credentials changed - logout required")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def clear_credentials_hash(self):
|
||||||
|
"""Clear the saved credentials hash (used after logout)."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
os.remove(self._credentials_file)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Failed to clear credentials hash: {e}")
|
||||||
|
|
||||||
|
def _kill_existing_chrome_for_profile(self):
|
||||||
|
"""Kill any existing Chrome processes using this profile and clean up locks."""
|
||||||
|
import subprocess
|
||||||
|
import time as time_module
|
||||||
|
try:
|
||||||
|
# Find and kill Chrome processes using this profile
|
||||||
|
result = subprocess.run(
|
||||||
|
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
pids = result.stdout.strip().split('\n')
|
||||||
|
for pid in pids:
|
||||||
|
try:
|
||||||
|
subprocess.run(["kill", "-9", pid], check=False)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time_module.sleep(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remove lock files if they exist
|
||||||
|
for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]:
|
||||||
|
lock_path = os.path.join(self.profile_dir, lock_file)
|
||||||
|
try:
|
||||||
|
if os.path.islink(lock_path) or os.path.exists(lock_path):
|
||||||
|
os.remove(lock_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_driver(self, headless=False):
|
||||||
|
"""Get or create the persistent browser instance."""
|
||||||
|
with self._lock:
|
||||||
|
if self._driver is None:
|
||||||
|
print("[DDMA BrowserManager] Driver is None, creating new driver")
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
|
self._create_driver(headless)
|
||||||
|
elif not self._is_alive():
|
||||||
|
print("[DDMA BrowserManager] Driver not alive, recreating")
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
|
self._create_driver(headless)
|
||||||
|
else:
|
||||||
|
print("[DDMA BrowserManager] Reusing existing driver")
|
||||||
|
return self._driver
|
||||||
|
|
||||||
|
def _is_alive(self):
|
||||||
|
"""Check if browser is still responsive."""
|
||||||
|
try:
|
||||||
|
if self._driver is None:
|
||||||
|
return False
|
||||||
|
url = self._driver.current_url
|
||||||
|
print(f"[DDMA BrowserManager] Driver alive, current URL: {url[:50]}...")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Driver not alive: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _create_driver(self, headless=False):
|
||||||
|
"""Create browser with persistent profile."""
|
||||||
|
if self._driver:
|
||||||
|
try:
|
||||||
|
self._driver.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
options = webdriver.ChromeOptions()
|
||||||
|
if headless:
|
||||||
|
options.add_argument("--headless")
|
||||||
|
|
||||||
|
# Persistent profile - THIS IS THE KEY for device trust
|
||||||
|
options.add_argument(f"--user-data-dir={self.profile_dir}")
|
||||||
|
options.add_argument("--no-sandbox")
|
||||||
|
options.add_argument("--disable-dev-shm-usage")
|
||||||
|
|
||||||
|
prefs = {
|
||||||
|
"download.default_directory": self.download_dir,
|
||||||
|
"plugins.always_open_pdf_externally": True,
|
||||||
|
"download.prompt_for_download": False,
|
||||||
|
"download.directory_upgrade": True
|
||||||
|
}
|
||||||
|
options.add_experimental_option("prefs", prefs)
|
||||||
|
|
||||||
|
service = Service(ChromeDriverManager().install())
|
||||||
|
self._driver = webdriver.Chrome(service=service, options=options)
|
||||||
|
self._driver.maximize_window()
|
||||||
|
|
||||||
|
# Reset the session clear flag (file-based clearing is done on startup)
|
||||||
|
self._needs_session_clear = False
|
||||||
|
|
||||||
|
def quit_driver(self):
|
||||||
|
"""Quit browser (only call on shutdown)."""
|
||||||
|
with self._lock:
|
||||||
|
if self._driver:
|
||||||
|
try:
|
||||||
|
self._driver.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self._driver = None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton accessor
|
||||||
|
_manager = None
|
||||||
|
|
||||||
|
def get_browser_manager():
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = DDMABrowserManager()
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def clear_ddma_session_on_startup():
|
||||||
|
"""Called by agent.py on startup to clear session."""
|
||||||
|
manager = get_browser_manager()
|
||||||
|
manager.clear_session_on_startup()
|
||||||
277
apps/SeleniumService/dentaquest_browser_manager.py
Normal file
277
apps/SeleniumService/dentaquest_browser_manager.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
Minimal browser manager for DentaQuest - only handles persistent profile and keeping browser alive.
|
||||||
|
Clears session cookies on startup (after PC restart) to force fresh login.
|
||||||
|
Tracks credentials to detect changes mid-session.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import hashlib
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
|
||||||
|
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
|
||||||
|
if not os.environ.get("DISPLAY"):
|
||||||
|
os.environ["DISPLAY"] = ":0"
|
||||||
|
|
||||||
|
|
||||||
|
class DentaQuestBrowserManager:
|
||||||
|
"""
|
||||||
|
Singleton that manages a persistent Chrome browser instance for DentaQuest.
|
||||||
|
- Uses --user-data-dir for persistent profile (device trust tokens)
|
||||||
|
- Clears session cookies on startup (after PC restart)
|
||||||
|
- Tracks credentials to detect changes mid-session
|
||||||
|
"""
|
||||||
|
_instance = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._driver = None
|
||||||
|
cls._instance.profile_dir = os.path.abspath("chrome_profile_dentaquest")
|
||||||
|
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
|
||||||
|
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
|
||||||
|
cls._instance._needs_session_clear = False # Flag to clear session on next driver creation
|
||||||
|
os.makedirs(cls._instance.profile_dir, exist_ok=True)
|
||||||
|
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def clear_session_on_startup(self):
|
||||||
|
"""
|
||||||
|
Clear session cookies from Chrome profile on startup.
|
||||||
|
This forces a fresh login after PC restart.
|
||||||
|
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
|
||||||
|
"""
|
||||||
|
print("[DentaQuest BrowserManager] Clearing session on startup...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the credentials tracking file
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
os.remove(self._credentials_file)
|
||||||
|
print("[DentaQuest BrowserManager] Cleared credentials tracking file")
|
||||||
|
|
||||||
|
# Clear session-related files from Chrome profile
|
||||||
|
# These are the files that store login session cookies
|
||||||
|
session_files = [
|
||||||
|
"Cookies",
|
||||||
|
"Cookies-journal",
|
||||||
|
"Login Data",
|
||||||
|
"Login Data-journal",
|
||||||
|
"Web Data",
|
||||||
|
"Web Data-journal",
|
||||||
|
]
|
||||||
|
|
||||||
|
for filename in session_files:
|
||||||
|
filepath = os.path.join(self.profile_dir, "Default", filename)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
print(f"[DentaQuest BrowserManager] Removed {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest BrowserManager] Could not remove {filename}: {e}")
|
||||||
|
|
||||||
|
# Also try root level (some Chrome versions)
|
||||||
|
for filename in session_files:
|
||||||
|
filepath = os.path.join(self.profile_dir, filename)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
print(f"[DentaQuest BrowserManager] Removed root {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest BrowserManager] Could not remove root {filename}: {e}")
|
||||||
|
|
||||||
|
# Clear Session Storage (contains login state)
|
||||||
|
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
|
||||||
|
if os.path.exists(session_storage_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(session_storage_dir)
|
||||||
|
print("[DentaQuest BrowserManager] Cleared Session Storage")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest BrowserManager] Could not clear Session Storage: {e}")
|
||||||
|
|
||||||
|
# Clear Local Storage (may contain auth tokens)
|
||||||
|
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
|
||||||
|
if os.path.exists(local_storage_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(local_storage_dir)
|
||||||
|
print("[DentaQuest BrowserManager] Cleared Local Storage")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest BrowserManager] Could not clear Local Storage: {e}")
|
||||||
|
|
||||||
|
# Clear IndexedDB (may contain auth tokens)
|
||||||
|
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
|
||||||
|
if os.path.exists(indexeddb_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(indexeddb_dir)
|
||||||
|
print("[DentaQuest BrowserManager] Cleared IndexedDB")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest BrowserManager] Could not clear IndexedDB: {e}")
|
||||||
|
|
||||||
|
# Set flag to clear session via JavaScript after browser opens
|
||||||
|
self._needs_session_clear = True
|
||||||
|
|
||||||
|
print("[DentaQuest BrowserManager] Session cleared - will require fresh login")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest BrowserManager] Error clearing session: {e}")
|
||||||
|
|
||||||
|
def _hash_credentials(self, username: str) -> str:
|
||||||
|
"""Create a hash of the username to track credential changes."""
|
||||||
|
return hashlib.sha256(username.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
def get_last_credentials_hash(self) -> str | None:
|
||||||
|
"""Get the hash of the last-used credentials."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
with open(self._credentials_file, 'r') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_credentials_hash(self, username: str):
|
||||||
|
"""Save the hash of the current credentials."""
|
||||||
|
try:
|
||||||
|
cred_hash = self._hash_credentials(username)
|
||||||
|
with open(self._credentials_file, 'w') as f:
|
||||||
|
f.write(cred_hash)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest BrowserManager] Failed to save credentials hash: {e}")
|
||||||
|
|
||||||
|
def credentials_changed(self, username: str) -> bool:
|
||||||
|
"""Check if the credentials have changed since last login."""
|
||||||
|
last_hash = self.get_last_credentials_hash()
|
||||||
|
if last_hash is None:
|
||||||
|
return False # No previous credentials, not a change
|
||||||
|
current_hash = self._hash_credentials(username)
|
||||||
|
changed = last_hash != current_hash
|
||||||
|
if changed:
|
||||||
|
print(f"[DentaQuest BrowserManager] Credentials changed - logout required")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def clear_credentials_hash(self):
|
||||||
|
"""Clear the saved credentials hash (used after logout)."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
os.remove(self._credentials_file)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest BrowserManager] Failed to clear credentials hash: {e}")
|
||||||
|
|
||||||
|
def _kill_existing_chrome_for_profile(self):
|
||||||
|
"""Kill any existing Chrome processes using this profile."""
|
||||||
|
try:
|
||||||
|
# Find and kill Chrome processes using this profile
|
||||||
|
result = subprocess.run(
|
||||||
|
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
pids = result.stdout.strip().split('\n')
|
||||||
|
for pid in pids:
|
||||||
|
try:
|
||||||
|
subprocess.run(["kill", "-9", pid], check=False)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remove SingletonLock if exists
|
||||||
|
lock_file = os.path.join(self.profile_dir, "SingletonLock")
|
||||||
|
try:
|
||||||
|
if os.path.islink(lock_file) or os.path.exists(lock_file):
|
||||||
|
os.remove(lock_file)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_driver(self, headless=False):
|
||||||
|
"""Get or create the persistent browser instance."""
|
||||||
|
with self._lock:
|
||||||
|
if self._driver is None:
|
||||||
|
print("[DentaQuest BrowserManager] Driver is None, creating new driver")
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
|
self._create_driver(headless)
|
||||||
|
elif not self._is_alive():
|
||||||
|
print("[DentaQuest BrowserManager] Driver not alive, recreating")
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
|
self._create_driver(headless)
|
||||||
|
else:
|
||||||
|
print("[DentaQuest BrowserManager] Reusing existing driver")
|
||||||
|
return self._driver
|
||||||
|
|
||||||
|
def _is_alive(self):
|
||||||
|
"""Check if browser is still responsive."""
|
||||||
|
try:
|
||||||
|
if self._driver is None:
|
||||||
|
return False
|
||||||
|
url = self._driver.current_url
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _create_driver(self, headless=False):
|
||||||
|
"""Create browser with persistent profile."""
|
||||||
|
if self._driver:
|
||||||
|
try:
|
||||||
|
self._driver.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self._driver = None
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
options = webdriver.ChromeOptions()
|
||||||
|
if headless:
|
||||||
|
options.add_argument("--headless")
|
||||||
|
|
||||||
|
# Persistent profile - THIS IS THE KEY for device trust
|
||||||
|
options.add_argument(f"--user-data-dir={self.profile_dir}")
|
||||||
|
options.add_argument("--no-sandbox")
|
||||||
|
options.add_argument("--disable-dev-shm-usage")
|
||||||
|
|
||||||
|
prefs = {
|
||||||
|
"download.default_directory": self.download_dir,
|
||||||
|
"plugins.always_open_pdf_externally": True,
|
||||||
|
"download.prompt_for_download": False,
|
||||||
|
"download.directory_upgrade": True
|
||||||
|
}
|
||||||
|
options.add_experimental_option("prefs", prefs)
|
||||||
|
|
||||||
|
service = Service(ChromeDriverManager().install())
|
||||||
|
self._driver = webdriver.Chrome(service=service, options=options)
|
||||||
|
self._driver.maximize_window()
|
||||||
|
|
||||||
|
# Reset the session clear flag (file-based clearing is done on startup)
|
||||||
|
self._needs_session_clear = False
|
||||||
|
|
||||||
|
def quit_driver(self):
|
||||||
|
"""Quit browser (only call on shutdown)."""
|
||||||
|
with self._lock:
|
||||||
|
if self._driver:
|
||||||
|
try:
|
||||||
|
self._driver.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self._driver = None
|
||||||
|
# Also clean up any orphaned processes
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton accessor
|
||||||
|
_manager = None
|
||||||
|
|
||||||
|
def get_browser_manager():
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = DentaQuestBrowserManager()
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def clear_dentaquest_session_on_startup():
|
||||||
|
"""Called by agent.py on startup to clear session."""
|
||||||
|
manager = get_browser_manager()
|
||||||
|
manager.clear_session_on_startup()
|
||||||
@@ -60,14 +60,8 @@ async def cleanup_session(sid: str, message: str | None = None):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Attempt to quit driver (may already be dead)
|
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
||||||
driver = s.get("driver")
|
# Browser manager handles the persistent browser instance
|
||||||
if driver:
|
|
||||||
try:
|
|
||||||
driver.quit()
|
|
||||||
except Exception:
|
|
||||||
# ignore errors from quit (session already gone)
|
|
||||||
pass
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Remove session entry from map
|
# Remove session entry from map
|
||||||
@@ -126,8 +120,15 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
|||||||
await cleanup_session(sid, s["message"])
|
await cleanup_session(sid, s["message"])
|
||||||
return {"status": "error", "message": s["message"]}
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
# Already logged in - session persisted from profile, skip to step1
|
||||||
|
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
||||||
|
print("[start_ddma_run] Session persisted - skipping OTP")
|
||||||
|
s["status"] = "running"
|
||||||
|
s["message"] = "Session persisted"
|
||||||
|
# Continue to step1 below
|
||||||
|
|
||||||
# OTP required path
|
# OTP required path
|
||||||
if isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||||
s["status"] = "waiting_for_otp"
|
s["status"] = "waiting_for_otp"
|
||||||
s["message"] = "OTP required for login"
|
s["message"] = "OTP required for login"
|
||||||
s["last_activity"] = time.time()
|
s["last_activity"] = time.time()
|
||||||
@@ -147,11 +148,21 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
|||||||
await cleanup_session(sid)
|
await cleanup_session(sid)
|
||||||
return {"status": "error", "message": "OTP missing after event"}
|
return {"status": "error", "message": "OTP missing after event"}
|
||||||
|
|
||||||
# Submit OTP in the same Selenium window
|
# Submit OTP - check if it's in a popup window
|
||||||
try:
|
try:
|
||||||
driver = s["driver"]
|
driver = s["driver"]
|
||||||
wait = WebDriverWait(driver, 30)
|
wait = WebDriverWait(driver, 30)
|
||||||
|
|
||||||
|
# Check if there's a popup window and switch to it
|
||||||
|
original_window = driver.current_window_handle
|
||||||
|
all_windows = driver.window_handles
|
||||||
|
if len(all_windows) > 1:
|
||||||
|
for window in all_windows:
|
||||||
|
if window != original_window:
|
||||||
|
driver.switch_to.window(window)
|
||||||
|
print(f"[OTP] Switched to popup window for OTP entry")
|
||||||
|
break
|
||||||
|
|
||||||
otp_input = wait.until(
|
otp_input = wait.until(
|
||||||
EC.presence_of_element_located(
|
EC.presence_of_element_located(
|
||||||
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
|
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
|
||||||
@@ -170,6 +181,11 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
otp_input.send_keys("\n")
|
otp_input.send_keys("\n")
|
||||||
|
|
||||||
|
# Wait for verification and switch back to main window if needed
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
if len(driver.window_handles) > 0:
|
||||||
|
driver.switch_to.window(driver.window_handles[0])
|
||||||
|
|
||||||
s["status"] = "otp_submitted"
|
s["status"] = "otp_submitted"
|
||||||
s["last_activity"] = time.time()
|
s["last_activity"] = time.time()
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|||||||
264
apps/SeleniumService/helpers_dentaquest_eligibility.py
Normal file
264
apps/SeleniumService/helpers_dentaquest_eligibility.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.common.exceptions import WebDriverException
|
||||||
|
|
||||||
|
from selenium_DentaQuest_eligibilityCheckWorker import AutomationDentaQuestEligibilityCheck
|
||||||
|
|
||||||
|
# In-memory session store
|
||||||
|
sessions: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_entry() -> str:
|
||||||
|
"""Create a new session entry and return its ID."""
|
||||||
|
import uuid
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
sessions[sid] = {
|
||||||
|
"status": "created", # created -> running -> waiting_for_otp -> otp_submitted -> completed / error
|
||||||
|
"created_at": time.time(),
|
||||||
|
"last_activity": time.time(),
|
||||||
|
"bot": None, # worker instance
|
||||||
|
"driver": None, # selenium webdriver
|
||||||
|
"otp_event": asyncio.Event(),
|
||||||
|
"otp_value": None,
|
||||||
|
"result": None,
|
||||||
|
"message": None,
|
||||||
|
"type": None,
|
||||||
|
}
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_session(sid: str, message: str | None = None):
|
||||||
|
"""
|
||||||
|
Close driver (if any), wake OTP waiter, set final state, and remove session entry.
|
||||||
|
Idempotent: safe to call multiple times.
|
||||||
|
"""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Ensure final state
|
||||||
|
try:
|
||||||
|
if s.get("status") not in ("completed", "error", "not_found"):
|
||||||
|
s["status"] = "error"
|
||||||
|
if message:
|
||||||
|
s["message"] = message
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Wake any OTP waiter (so awaiting coroutines don't hang)
|
||||||
|
try:
|
||||||
|
ev = s.get("otp_event")
|
||||||
|
if ev and not ev.is_set():
|
||||||
|
ev.set()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
||||||
|
# Browser manager handles the persistent browser instance
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Remove session entry from map
|
||||||
|
sessions.pop(sid, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _remove_session_later(sid: str, delay: int = 20):
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await cleanup_session(sid)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_dentaquest_run(sid: str, data: dict, url: str):
|
||||||
|
"""
|
||||||
|
Run the DentaQuest workflow for a session (WITHOUT managing semaphore/counters).
|
||||||
|
Called by agent.py inside a wrapper that handles queue/counters.
|
||||||
|
"""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "error", "message": "session not found"}
|
||||||
|
|
||||||
|
s["status"] = "running"
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
bot = AutomationDentaQuestEligibilityCheck({"data": data})
|
||||||
|
bot.config_driver()
|
||||||
|
|
||||||
|
s["bot"] = bot
|
||||||
|
s["driver"] = bot.driver
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
# Navigate to login URL
|
||||||
|
try:
|
||||||
|
if not url:
|
||||||
|
raise ValueError("URL not provided for DentaQuest run")
|
||||||
|
bot.driver.maximize_window()
|
||||||
|
bot.driver.get(url)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Navigation failed: {e}"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
# Login
|
||||||
|
try:
|
||||||
|
login_result = bot.login(url)
|
||||||
|
except WebDriverException as wde:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Selenium driver error during login: {wde}"
|
||||||
|
await cleanup_session(sid, s["message"])
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Unexpected error during login: {e}"
|
||||||
|
await cleanup_session(sid, s["message"])
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
# Already logged in - session persisted from profile, skip to step1
|
||||||
|
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
||||||
|
s["status"] = "running"
|
||||||
|
s["message"] = "Session persisted"
|
||||||
|
# Continue to step1 below
|
||||||
|
|
||||||
|
# OTP required path
|
||||||
|
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||||
|
s["status"] = "waiting_for_otp"
|
||||||
|
s["message"] = "OTP required for login"
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(s["otp_event"].wait(), timeout=SESSION_OTP_TIMEOUT)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = "OTP timeout"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": "OTP not provided in time"}
|
||||||
|
|
||||||
|
otp_value = s.get("otp_value")
|
||||||
|
if not otp_value:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = "OTP missing after event"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": "OTP missing after event"}
|
||||||
|
|
||||||
|
# Submit OTP
|
||||||
|
try:
|
||||||
|
driver = s["driver"]
|
||||||
|
wait = WebDriverWait(driver, 30)
|
||||||
|
|
||||||
|
# Check if there's a popup window and switch to it
|
||||||
|
original_window = driver.current_window_handle
|
||||||
|
all_windows = driver.window_handles
|
||||||
|
if len(all_windows) > 1:
|
||||||
|
for window in all_windows:
|
||||||
|
if window != original_window:
|
||||||
|
driver.switch_to.window(window)
|
||||||
|
break
|
||||||
|
|
||||||
|
# DentaQuest OTP input field - adjust selectors as needed
|
||||||
|
otp_input = wait.until(
|
||||||
|
EC.presence_of_element_located(
|
||||||
|
(By.XPATH, "//input[contains(@name,'otp') or contains(@name,'code') or contains(@placeholder,'code') or contains(@id,'otp') or @type='tel']")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
otp_input.clear()
|
||||||
|
otp_input.send_keys(otp_value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
submit_btn = wait.until(
|
||||||
|
EC.element_to_be_clickable(
|
||||||
|
(By.XPATH, "//button[contains(text(),'Verify') or contains(text(),'Submit') or contains(text(),'Continue') or @type='submit']")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
submit_btn.click()
|
||||||
|
except Exception:
|
||||||
|
otp_input.send_keys("\n")
|
||||||
|
|
||||||
|
# Wait for verification and switch back to main window if needed
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
if len(driver.window_handles) > 0:
|
||||||
|
driver.switch_to.window(driver.window_handles[0])
|
||||||
|
|
||||||
|
s["status"] = "otp_submitted"
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Failed to submit OTP into page: {e}"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = login_result
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": login_result}
|
||||||
|
|
||||||
|
# Step 1
|
||||||
|
step1_result = bot.step1()
|
||||||
|
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = step1_result
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": step1_result}
|
||||||
|
|
||||||
|
# Step 2 (PDF)
|
||||||
|
step2_result = bot.step2()
|
||||||
|
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
|
||||||
|
s["status"] = "completed"
|
||||||
|
s["result"] = step2_result
|
||||||
|
s["message"] = "completed"
|
||||||
|
asyncio.create_task(_remove_session_later(sid, 30))
|
||||||
|
return step2_result
|
||||||
|
else:
|
||||||
|
s["status"] = "error"
|
||||||
|
if isinstance(step2_result, dict):
|
||||||
|
s["message"] = step2_result.get("message", "unknown error")
|
||||||
|
else:
|
||||||
|
s["message"] = str(step2_result)
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"worker exception: {e}"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
|
||||||
|
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
||||||
|
"""Set OTP for a session and wake waiting runner."""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "error", "message": "session not found"}
|
||||||
|
if s.get("status") != "waiting_for_otp":
|
||||||
|
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
|
||||||
|
s["otp_value"] = otp
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
try:
|
||||||
|
s["otp_event"].set()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"status": "ok", "message": "otp accepted"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_status(sid: str) -> Dict[str, Any]:
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "not_found"}
|
||||||
|
return {
|
||||||
|
"session_id": sid,
|
||||||
|
"status": s.get("status"),
|
||||||
|
"message": s.get("message"),
|
||||||
|
"created_at": s.get("created_at"),
|
||||||
|
"last_activity": s.get("last_activity"),
|
||||||
|
"result": s.get("result") if s.get("status") == "completed" else None,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
from selenium import webdriver
|
from selenium.common.exceptions import TimeoutException
|
||||||
from selenium.common.exceptions import WebDriverException, TimeoutException
|
|
||||||
from selenium.webdriver.chrome.service import Service
|
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from webdriver_manager.chrome import ChromeDriverManager
|
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
from ddma_browser_manager import get_browser_manager
|
||||||
|
|
||||||
class AutomationDeltaDentalMAEligibilityCheck:
|
class AutomationDeltaDentalMAEligibilityCheck:
|
||||||
def __init__(self, data):
|
def __init__(self, data):
|
||||||
self.headless = False
|
self.headless = False
|
||||||
@@ -24,31 +23,193 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
self.massddma_username = self.data.get("massddmaUsername", "")
|
self.massddma_username = self.data.get("massddmaUsername", "")
|
||||||
self.massddma_password = self.data.get("massddmaPassword", "")
|
self.massddma_password = self.data.get("massddmaPassword", "")
|
||||||
|
|
||||||
self.download_dir = os.path.abspath("seleniumDownloads")
|
# Use browser manager's download dir
|
||||||
|
self.download_dir = get_browser_manager().download_dir
|
||||||
os.makedirs(self.download_dir, exist_ok=True)
|
os.makedirs(self.download_dir, exist_ok=True)
|
||||||
|
|
||||||
def config_driver(self):
|
def config_driver(self):
|
||||||
options = webdriver.ChromeOptions()
|
# Use persistent browser from manager (keeps device trust tokens)
|
||||||
if self.headless:
|
self.driver = get_browser_manager().get_driver(self.headless)
|
||||||
options.add_argument("--headless")
|
|
||||||
|
|
||||||
# Add PDF download preferences
|
def _force_logout(self):
|
||||||
prefs = {
|
"""Force logout by clearing cookies for Delta Dental domain."""
|
||||||
"download.default_directory": self.download_dir,
|
try:
|
||||||
"plugins.always_open_pdf_externally": True,
|
print("[DDMA login] Forcing logout due to credential change...")
|
||||||
"download.prompt_for_download": False,
|
browser_manager = get_browser_manager()
|
||||||
"download.directory_upgrade": True
|
|
||||||
}
|
|
||||||
options.add_experimental_option("prefs", prefs)
|
|
||||||
|
|
||||||
s = Service(ChromeDriverManager().install())
|
# First try to click logout button if visible
|
||||||
driver = webdriver.Chrome(service=s, options=options)
|
try:
|
||||||
self.driver = driver
|
self.driver.get("https://providers.deltadentalma.com/")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
logout_selectors = [
|
||||||
|
"//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||||
|
"//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||||
|
"//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']",
|
||||||
|
"//*[contains(@class, 'logout') or contains(@class, 'signout')]"
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in logout_selectors:
|
||||||
|
try:
|
||||||
|
logout_btn = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, selector))
|
||||||
|
)
|
||||||
|
logout_btn.click()
|
||||||
|
print("[DDMA login] Clicked logout button")
|
||||||
|
time.sleep(2)
|
||||||
|
break
|
||||||
|
except TimeoutException:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA login] Could not click logout button: {e}")
|
||||||
|
|
||||||
|
# Clear cookies as backup
|
||||||
|
try:
|
||||||
|
self.driver.delete_all_cookies()
|
||||||
|
print("[DDMA login] Cleared all cookies")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA login] Error clearing cookies: {e}")
|
||||||
|
|
||||||
|
browser_manager.clear_credentials_hash()
|
||||||
|
print("[DDMA login] Logout complete")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA login] Error during forced logout: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def login(self, url):
|
def login(self, url):
|
||||||
wait = WebDriverWait(self.driver, 30)
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
browser_manager = get_browser_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Check if credentials have changed - if so, force logout first
|
||||||
|
if self.massddma_username and browser_manager.credentials_changed(self.massddma_username):
|
||||||
|
self._force_logout()
|
||||||
self.driver.get(url)
|
self.driver.get(url)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# First check if we're already on a logged-in page (from previous run)
|
||||||
|
try:
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
print(f"[login] Current URL: {current_url}")
|
||||||
|
|
||||||
|
# Check if we're on any logged-in page (dashboard, member pages, etc.)
|
||||||
|
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients"]
|
||||||
|
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
|
||||||
|
|
||||||
|
if is_logged_in_url and "onboarding" not in current_url.lower():
|
||||||
|
print(f"[login] Already on logged-in page - skipping login entirely")
|
||||||
|
# Navigate directly to member search if not already there
|
||||||
|
if "member" not in current_url.lower():
|
||||||
|
# Try to find a link to member search or just check for search input
|
||||||
|
try:
|
||||||
|
member_search = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
print("[login] Found member search input - returning ALREADY_LOGGED_IN")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
except TimeoutException:
|
||||||
|
# Try navigating to members page
|
||||||
|
members_url = "https://providers.deltadentalma.com/members"
|
||||||
|
print(f"[login] Navigating to members page: {members_url}")
|
||||||
|
self.driver.get(members_url)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Verify we have the member search input
|
||||||
|
try:
|
||||||
|
member_search = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
print("[login] Member search found - ALREADY_LOGGED_IN")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
except TimeoutException:
|
||||||
|
print("[login] Could not find member search, will try login")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[login] Error checking current state: {e}")
|
||||||
|
|
||||||
|
# Navigate to login URL
|
||||||
|
self.driver.get(url)
|
||||||
|
time.sleep(2) # Wait for page to load and any redirects
|
||||||
|
|
||||||
|
# Check if we got redirected to member search (session still valid)
|
||||||
|
try:
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
print(f"[login] URL after navigation: {current_url}")
|
||||||
|
|
||||||
|
if "onboarding" not in current_url.lower():
|
||||||
|
member_search = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
if member_search:
|
||||||
|
print("[login] Session valid - skipping login")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
except TimeoutException:
|
||||||
|
print("[login] Proceeding with login")
|
||||||
|
|
||||||
|
# Dismiss any "Authentication flow continued in another tab" modal
|
||||||
|
modal_dismissed = False
|
||||||
|
try:
|
||||||
|
ok_button = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK']"))
|
||||||
|
)
|
||||||
|
ok_button.click()
|
||||||
|
print("[login] Dismissed authentication modal")
|
||||||
|
modal_dismissed = True
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Check if a popup window opened for authentication
|
||||||
|
all_windows = self.driver.window_handles
|
||||||
|
print(f"[login] Windows after modal dismiss: {len(all_windows)}")
|
||||||
|
|
||||||
|
if len(all_windows) > 1:
|
||||||
|
# Switch to the auth popup
|
||||||
|
original_window = self.driver.current_window_handle
|
||||||
|
for window in all_windows:
|
||||||
|
if window != original_window:
|
||||||
|
self.driver.switch_to.window(window)
|
||||||
|
print(f"[login] Switched to auth popup window")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Look for OTP input in the popup
|
||||||
|
try:
|
||||||
|
otp_candidate = WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.presence_of_element_located(
|
||||||
|
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code') or contains(@aria-label,'Verification code')]")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if otp_candidate:
|
||||||
|
print("[login] OTP input found in popup -> OTP_REQUIRED")
|
||||||
|
return "OTP_REQUIRED"
|
||||||
|
except TimeoutException:
|
||||||
|
print("[login] No OTP in popup, checking main window")
|
||||||
|
self.driver.switch_to.window(original_window)
|
||||||
|
|
||||||
|
except TimeoutException:
|
||||||
|
pass # No modal present
|
||||||
|
|
||||||
|
# If modal was dismissed but no popup, page might have changed - wait and check
|
||||||
|
if modal_dismissed:
|
||||||
|
time.sleep(2)
|
||||||
|
# Check if we're now on member search page (already authenticated)
|
||||||
|
try:
|
||||||
|
member_search = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
if member_search:
|
||||||
|
print("[login] Already authenticated after modal dismiss")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try to fill login form
|
||||||
|
try:
|
||||||
|
email_field = WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' and @type='text']"))
|
||||||
|
)
|
||||||
|
except TimeoutException:
|
||||||
|
print("[login] Could not find login form - page may have changed")
|
||||||
|
return "ERROR: Login form not found"
|
||||||
|
|
||||||
email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@name='username' and @type='text']")))
|
email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@name='username' and @type='text']")))
|
||||||
email_field.clear()
|
email_field.clear()
|
||||||
email_field.send_keys(self.massddma_username)
|
email_field.send_keys(self.massddma_username)
|
||||||
@@ -69,6 +230,10 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
|
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
|
||||||
login_button.click()
|
login_button.click()
|
||||||
|
|
||||||
|
# Save credentials hash after login attempt
|
||||||
|
if self.massddma_username:
|
||||||
|
browser_manager.save_credentials_hash(self.massddma_username)
|
||||||
|
|
||||||
# OTP detection
|
# OTP detection
|
||||||
try:
|
try:
|
||||||
otp_candidate = WebDriverWait(self.driver, 30).until(
|
otp_candidate = WebDriverWait(self.driver, 30).until(
|
||||||
@@ -226,6 +391,15 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
print("Screenshot saved at:", screenshot_path)
|
print("Screenshot saved at:", screenshot_path)
|
||||||
|
|
||||||
|
# Close the browser window after screenshot (session preserved in profile)
|
||||||
|
try:
|
||||||
|
from ddma_browser_manager import get_browser_manager
|
||||||
|
get_browser_manager().quit_driver()
|
||||||
|
print("[step2] Browser closed - session preserved in profile")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[step2] Error closing browser: {e}")
|
||||||
|
|
||||||
output = {
|
output = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"eligibility": eligibilityText,
|
"eligibility": eligibilityText,
|
||||||
@@ -254,14 +428,7 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}")
|
print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
finally:
|
# NOTE: Do NOT quit driver here - keep browser alive for next patient
|
||||||
# Keep your existing quit behavior; if you want the driver to remain open for further
|
|
||||||
# actions, remove or change this.
|
|
||||||
if self.driver:
|
|
||||||
try:
|
|
||||||
self.driver.quit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def main_workflow(self, url):
|
def main_workflow(self, url):
|
||||||
try:
|
try:
|
||||||
@@ -289,10 +456,4 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"message": e
|
"message": e
|
||||||
}
|
}
|
||||||
|
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
if self.driver:
|
|
||||||
self.driver.quit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
from selenium import webdriver
|
||||||
|
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from dentaquest_browser_manager import get_browser_manager
|
||||||
|
|
||||||
|
class AutomationDentaQuestEligibilityCheck:
|
||||||
|
def __init__(self, data):
|
||||||
|
self.headless = False
|
||||||
|
self.driver = None
|
||||||
|
|
||||||
|
self.data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
# Flatten values for convenience
|
||||||
|
self.memberId = self.data.get("memberId", "")
|
||||||
|
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||||
|
self.dentaquest_username = self.data.get("dentaquestUsername", "")
|
||||||
|
self.dentaquest_password = self.data.get("dentaquestPassword", "")
|
||||||
|
|
||||||
|
# Use browser manager's download dir
|
||||||
|
self.download_dir = get_browser_manager().download_dir
|
||||||
|
os.makedirs(self.download_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def config_driver(self):
|
||||||
|
# Use persistent browser from manager (keeps device trust tokens)
|
||||||
|
self.driver = get_browser_manager().get_driver(self.headless)
|
||||||
|
|
||||||
|
def _force_logout(self):
|
||||||
|
"""Force logout by clearing cookies for DentaQuest domain."""
|
||||||
|
try:
|
||||||
|
print("[DentaQuest login] Forcing logout due to credential change...")
|
||||||
|
browser_manager = get_browser_manager()
|
||||||
|
|
||||||
|
# First try to click logout button if visible
|
||||||
|
try:
|
||||||
|
self.driver.get("https://providers.dentaquest.com/")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
logout_selectors = [
|
||||||
|
"//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||||
|
"//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||||
|
"//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']",
|
||||||
|
"//*[contains(@class, 'logout') or contains(@class, 'signout')]"
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in logout_selectors:
|
||||||
|
try:
|
||||||
|
logout_btn = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, selector))
|
||||||
|
)
|
||||||
|
logout_btn.click()
|
||||||
|
print("[DentaQuest login] Clicked logout button")
|
||||||
|
time.sleep(2)
|
||||||
|
break
|
||||||
|
except TimeoutException:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest login] Could not click logout button: {e}")
|
||||||
|
|
||||||
|
# Clear cookies as backup
|
||||||
|
try:
|
||||||
|
self.driver.delete_all_cookies()
|
||||||
|
print("[DentaQuest login] Cleared all cookies")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest login] Error clearing cookies: {e}")
|
||||||
|
|
||||||
|
browser_manager.clear_credentials_hash()
|
||||||
|
print("[DentaQuest login] Logout complete")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest login] Error during forced logout: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def login(self, url):
|
||||||
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
browser_manager = get_browser_manager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if credentials have changed - if so, force logout first
|
||||||
|
if self.dentaquest_username and browser_manager.credentials_changed(self.dentaquest_username):
|
||||||
|
self._force_logout()
|
||||||
|
self.driver.get(url)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# First check if we're already on a logged-in page (from previous run)
|
||||||
|
try:
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
print(f"[DentaQuest login] Current URL: {current_url}")
|
||||||
|
|
||||||
|
# Check if we're already on dashboard with member search
|
||||||
|
if "dashboard" in current_url.lower() or "member" in current_url.lower():
|
||||||
|
try:
|
||||||
|
member_search = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
print("[DentaQuest login] Already on dashboard with member search")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest login] Error checking current state: {e}")
|
||||||
|
|
||||||
|
# Navigate to login URL
|
||||||
|
self.driver.get(url)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
print(f"[DentaQuest login] After navigation URL: {current_url}")
|
||||||
|
|
||||||
|
# If already on dashboard, we're logged in
|
||||||
|
if "dashboard" in current_url.lower():
|
||||||
|
print("[DentaQuest login] Already on dashboard")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
|
||||||
|
# Try to dismiss the modal by clicking OK
|
||||||
|
try:
|
||||||
|
ok_button = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK' or normalize-space(text())='Continue']"))
|
||||||
|
)
|
||||||
|
ok_button.click()
|
||||||
|
print("[DentaQuest login] Clicked OK modal button")
|
||||||
|
time.sleep(3)
|
||||||
|
except TimeoutException:
|
||||||
|
print("[DentaQuest login] No OK modal button found")
|
||||||
|
|
||||||
|
# Check if we're now on dashboard (session was valid)
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
print(f"[DentaQuest login] After modal click URL: {current_url}")
|
||||||
|
|
||||||
|
if "dashboard" in current_url.lower():
|
||||||
|
# Check for member search input to confirm logged in
|
||||||
|
try:
|
||||||
|
member_search = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
print("[DentaQuest login] Session valid - on dashboard with member search")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check if OTP is required (popup window or OTP input)
|
||||||
|
if len(self.driver.window_handles) > 1:
|
||||||
|
original_window = self.driver.current_window_handle
|
||||||
|
for window in self.driver.window_handles:
|
||||||
|
if window != original_window:
|
||||||
|
self.driver.switch_to.window(window)
|
||||||
|
print("[DentaQuest login] Switched to popup window")
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
otp_input = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]"))
|
||||||
|
)
|
||||||
|
print("[DentaQuest login] OTP input found in popup")
|
||||||
|
return "OTP_REQUIRED"
|
||||||
|
except TimeoutException:
|
||||||
|
self.driver.switch_to.window(original_window)
|
||||||
|
|
||||||
|
# Check for OTP input on main page
|
||||||
|
try:
|
||||||
|
otp_input = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]"))
|
||||||
|
)
|
||||||
|
print("[DentaQuest login] OTP input found")
|
||||||
|
return "OTP_REQUIRED"
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If still on login page, need to fill credentials
|
||||||
|
if "onboarding" in current_url.lower() or "login" in current_url.lower():
|
||||||
|
print("[DentaQuest login] Need to fill login credentials")
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_field = WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' or @type='text']"))
|
||||||
|
)
|
||||||
|
email_field.clear()
|
||||||
|
email_field.send_keys(self.dentaquest_username)
|
||||||
|
|
||||||
|
password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@type='password']")))
|
||||||
|
password_field.clear()
|
||||||
|
password_field.send_keys(self.dentaquest_password)
|
||||||
|
|
||||||
|
# Click login button
|
||||||
|
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit']")))
|
||||||
|
login_button.click()
|
||||||
|
print("[DentaQuest login] Submitted login form")
|
||||||
|
|
||||||
|
# Save credentials hash after login attempt
|
||||||
|
if self.dentaquest_username:
|
||||||
|
browser_manager.save_credentials_hash(self.dentaquest_username)
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Check for OTP after login
|
||||||
|
try:
|
||||||
|
otp_input = WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]"))
|
||||||
|
)
|
||||||
|
return "OTP_REQUIRED"
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check if login succeeded
|
||||||
|
if "dashboard" in self.driver.current_url.lower():
|
||||||
|
return "SUCCESS"
|
||||||
|
|
||||||
|
except TimeoutException:
|
||||||
|
print("[DentaQuest login] Login form elements not found")
|
||||||
|
return "ERROR: Login form not found"
|
||||||
|
|
||||||
|
return "SUCCESS"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest login] Exception: {e}")
|
||||||
|
return f"ERROR:LOGIN FAILED: {e}"
|
||||||
|
|
||||||
|
def step1(self):
|
||||||
|
"""Navigate to member search and enter member ID + DOB"""
|
||||||
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"[DentaQuest step1] Starting member search for ID: {self.memberId}, DOB: {self.dateOfBirth}")
|
||||||
|
|
||||||
|
# Wait for page to be ready
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Parse DOB - format: YYYY-MM-DD
|
||||||
|
try:
|
||||||
|
dob_parts = self.dateOfBirth.split("-")
|
||||||
|
dob_year = dob_parts[0]
|
||||||
|
dob_month = dob_parts[1].zfill(2)
|
||||||
|
dob_day = dob_parts[2].zfill(2)
|
||||||
|
print(f"[DentaQuest step1] Parsed DOB: {dob_month}/{dob_day}/{dob_year}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest step1] Error parsing DOB: {e}")
|
||||||
|
return "ERROR: PARSING DOB"
|
||||||
|
|
||||||
|
# Get today's date for Date of Service
|
||||||
|
from datetime import datetime
|
||||||
|
today = datetime.now()
|
||||||
|
service_month = str(today.month).zfill(2)
|
||||||
|
service_day = str(today.day).zfill(2)
|
||||||
|
service_year = str(today.year)
|
||||||
|
print(f"[DentaQuest step1] Service date: {service_month}/{service_day}/{service_year}")
|
||||||
|
|
||||||
|
# Helper function to fill contenteditable date spans within a specific container
|
||||||
|
def fill_date_by_testid(testid, month_val, day_val, year_val, field_name):
|
||||||
|
try:
|
||||||
|
container = self.driver.find_element(By.XPATH, f"//div[@data-testid='{testid}']")
|
||||||
|
month_elem = container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
||||||
|
day_elem = container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
||||||
|
year_elem = container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
||||||
|
|
||||||
|
def replace_with_sendkeys(el, value):
|
||||||
|
el.click()
|
||||||
|
time.sleep(0.05)
|
||||||
|
el.send_keys(Keys.CONTROL, "a")
|
||||||
|
el.send_keys(Keys.BACKSPACE)
|
||||||
|
el.send_keys(value)
|
||||||
|
|
||||||
|
replace_with_sendkeys(month_elem, month_val)
|
||||||
|
time.sleep(0.1)
|
||||||
|
replace_with_sendkeys(day_elem, day_val)
|
||||||
|
time.sleep(0.1)
|
||||||
|
replace_with_sendkeys(year_elem, year_val)
|
||||||
|
print(f"[DentaQuest step1] Filled {field_name}: {month_val}/{day_val}/{year_val}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest step1] Error filling {field_name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 1. Fill Date of Service with TODAY's date using specific data-testid
|
||||||
|
fill_date_by_testid("member-search_date-of-service", service_month, service_day, service_year, "Date of Service")
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# 2. Fill Date of Birth with patient's DOB using specific data-testid
|
||||||
|
fill_date_by_testid("member-search_date-of-birth", dob_month, dob_day, dob_year, "Date of Birth")
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# 3. Fill Member ID
|
||||||
|
member_id_input = wait.until(EC.presence_of_element_located(
|
||||||
|
(By.XPATH, '//input[@placeholder="Search by member ID"]')
|
||||||
|
))
|
||||||
|
member_id_input.clear()
|
||||||
|
member_id_input.send_keys(self.memberId)
|
||||||
|
print(f"[DentaQuest step1] Entered member ID: {self.memberId}")
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# 4. Click Search button
|
||||||
|
try:
|
||||||
|
search_btn = wait.until(EC.element_to_be_clickable(
|
||||||
|
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
|
||||||
|
))
|
||||||
|
search_btn.click()
|
||||||
|
print("[DentaQuest step1] Clicked search button")
|
||||||
|
except TimeoutException:
|
||||||
|
# Fallback
|
||||||
|
try:
|
||||||
|
search_btn = self.driver.find_element(By.XPATH, '//button[contains(text(),"Search")]')
|
||||||
|
search_btn.click()
|
||||||
|
print("[DentaQuest step1] Clicked search button (fallback)")
|
||||||
|
except:
|
||||||
|
member_id_input.send_keys(Keys.RETURN)
|
||||||
|
print("[DentaQuest step1] Pressed Enter to search")
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Check for "no results" error
|
||||||
|
try:
|
||||||
|
error_msg = WebDriverWait(self.driver, 3).until(EC.presence_of_element_located(
|
||||||
|
(By.XPATH, '//*[contains(@data-testid,"no-results") or contains(@class,"no-results") or contains(text(),"No results") or contains(text(),"not found") or contains(text(),"No member found") or contains(text(),"Nothing was found")]')
|
||||||
|
))
|
||||||
|
if error_msg and error_msg.is_displayed():
|
||||||
|
print("[DentaQuest step1] No results found")
|
||||||
|
return "ERROR: INVALID MEMBERID OR DOB"
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("[DentaQuest step1] Search completed successfully")
|
||||||
|
return "Success"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest step1] Exception: {e}")
|
||||||
|
return f"ERROR:STEP1 - {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def step2(self):
|
||||||
|
"""Get eligibility status and capture screenshot"""
|
||||||
|
wait = WebDriverWait(self.driver, 90)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("[DentaQuest step2] Starting eligibility capture")
|
||||||
|
|
||||||
|
# Wait for results to load
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# Try to find eligibility status from the results
|
||||||
|
eligibilityText = "unknown"
|
||||||
|
try:
|
||||||
|
# Look for a link or element with eligibility status
|
||||||
|
status_elem = wait.until(EC.presence_of_element_located((
|
||||||
|
By.XPATH,
|
||||||
|
"//a[contains(@href,'eligibility')] | //*[contains(@class,'status')] | //*[contains(text(),'Active') or contains(text(),'Inactive') or contains(text(),'Eligible')]"
|
||||||
|
)))
|
||||||
|
eligibilityText = status_elem.text.strip().lower()
|
||||||
|
print(f"[DentaQuest step2] Found status element: {eligibilityText}")
|
||||||
|
|
||||||
|
# Normalize status
|
||||||
|
if "active" in eligibilityText or "eligible" in eligibilityText:
|
||||||
|
eligibilityText = "active"
|
||||||
|
elif "inactive" in eligibilityText or "ineligible" in eligibilityText:
|
||||||
|
eligibilityText = "inactive"
|
||||||
|
except TimeoutException:
|
||||||
|
print("[DentaQuest step2] Could not find specific eligibility status")
|
||||||
|
|
||||||
|
# Try to find patient name
|
||||||
|
patientName = ""
|
||||||
|
try:
|
||||||
|
# Look for the patient name in the results
|
||||||
|
name_elem = self.driver.find_element(By.XPATH, "//h1 | //div[contains(@class,'name')] | //*[contains(@class,'member-name') or contains(@class,'patient-name')]")
|
||||||
|
patientName = name_elem.text.strip()
|
||||||
|
print(f"[DentaQuest step2] Found patient name: {patientName}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Wait for page to fully load
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 30).until(
|
||||||
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Capture full page screenshot
|
||||||
|
print("[DentaQuest step2] Capturing screenshot")
|
||||||
|
total_width = int(self.driver.execute_script(
|
||||||
|
"return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth);"
|
||||||
|
))
|
||||||
|
total_height = int(self.driver.execute_script(
|
||||||
|
"return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight);"
|
||||||
|
))
|
||||||
|
dpr = float(self.driver.execute_script("return window.devicePixelRatio || 1;"))
|
||||||
|
|
||||||
|
self.driver.execute_cdp_cmd('Emulation.setDeviceMetricsOverride', {
|
||||||
|
"mobile": False,
|
||||||
|
"width": total_width,
|
||||||
|
"height": total_height,
|
||||||
|
"deviceScaleFactor": dpr,
|
||||||
|
"screenOrientation": {"angle": 0, "type": "portraitPrimary"}
|
||||||
|
})
|
||||||
|
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Capture screenshot
|
||||||
|
result = self.driver.execute_cdp_cmd("Page.captureScreenshot", {"format": "png", "fromSurface": True})
|
||||||
|
image_data = base64.b64decode(result.get('data', ''))
|
||||||
|
screenshot_path = os.path.join(self.download_dir, f"dentaquest_ss_{self.memberId}_{int(time.time())}.png")
|
||||||
|
with open(screenshot_path, "wb") as f:
|
||||||
|
f.write(image_data)
|
||||||
|
print(f"[DentaQuest step2] Screenshot saved: {screenshot_path}")
|
||||||
|
|
||||||
|
# Restore original metrics
|
||||||
|
try:
|
||||||
|
self.driver.execute_cdp_cmd('Emulation.clearDeviceMetricsOverride', {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Close the browser window after screenshot
|
||||||
|
try:
|
||||||
|
from dentaquest_browser_manager import get_browser_manager
|
||||||
|
get_browser_manager().quit_driver()
|
||||||
|
print("[DentaQuest step2] Browser closed")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest step2] Error closing browser: {e}")
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status": "success",
|
||||||
|
"eligibility": eligibilityText,
|
||||||
|
"ss_path": screenshot_path,
|
||||||
|
"patientName": patientName
|
||||||
|
}
|
||||||
|
print(f"[DentaQuest step2] Success: {output}")
|
||||||
|
return output
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DentaQuest step2] Exception: {e}")
|
||||||
|
# Cleanup download folder on error
|
||||||
|
try:
|
||||||
|
dl = os.path.abspath(self.download_dir)
|
||||||
|
if os.path.isdir(dl):
|
||||||
|
for name in os.listdir(dl):
|
||||||
|
item = os.path.join(dl, name)
|
||||||
|
try:
|
||||||
|
if os.path.isfile(item) or os.path.islink(item):
|
||||||
|
os.remove(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
def main_workflow(self, url):
|
||||||
|
try:
|
||||||
|
self.config_driver()
|
||||||
|
self.driver.maximize_window()
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
login_result = self.login(url)
|
||||||
|
if login_result.startswith("ERROR"):
|
||||||
|
return {"status": "error", "message": login_result}
|
||||||
|
if login_result == "OTP_REQUIRED":
|
||||||
|
return {"status": "otp_required", "message": "OTP required after login"}
|
||||||
|
|
||||||
|
step1_result = self.step1()
|
||||||
|
if step1_result.startswith("ERROR"):
|
||||||
|
return {"status": "error", "message": step1_result}
|
||||||
|
|
||||||
|
step2_result = self.step2()
|
||||||
|
if step2_result.get("status") == "error":
|
||||||
|
return {"status": "error", "message": step2_result.get("message")}
|
||||||
|
|
||||||
|
return step2_result
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": str(e)
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ class AutomationMassHealth:
|
|||||||
self.remarks = self.claim.get("remarks", "")
|
self.remarks = self.claim.get("remarks", "")
|
||||||
self.massdhp_username = self.claim.get("massdhpUsername", "")
|
self.massdhp_username = self.claim.get("massdhpUsername", "")
|
||||||
self.massdhp_password = self.claim.get("massdhpPassword", "")
|
self.massdhp_password = self.claim.get("massdhpPassword", "")
|
||||||
|
self.npiProvider = self.claim.get("npiProvider", {})
|
||||||
|
self.npiNumber = self.npiProvider.get("npiNumber", "")
|
||||||
|
self.npiName = self.npiProvider.get("providerName", "")
|
||||||
self.serviceLines = self.claim.get("serviceLines", [])
|
self.serviceLines = self.claim.get("serviceLines", [])
|
||||||
self.missingTeethStatus = self.claim.get("missingTeethStatus", "")
|
self.missingTeethStatus = self.claim.get("missingTeethStatus", "")
|
||||||
self.missingTeeth = self.claim.get("missingTeeth", {})
|
self.missingTeeth = self.claim.get("missingTeeth", {})
|
||||||
@@ -40,6 +43,39 @@ class AutomationMassHealth:
|
|||||||
driver = webdriver.Chrome(service=s, options=options)
|
driver = webdriver.Chrome(service=s, options=options)
|
||||||
self.driver = driver
|
self.driver = driver
|
||||||
|
|
||||||
|
def select_rendering_npi(self, select_element):
|
||||||
|
options = select_element.options
|
||||||
|
|
||||||
|
target_npi = (self.npiNumber or "").strip()
|
||||||
|
target_name = (self.npiName or "").strip().lower()
|
||||||
|
|
||||||
|
# 1️⃣ Exact NPI match (value or text)
|
||||||
|
for opt in options:
|
||||||
|
value = (opt.get_attribute("value") or "").strip()
|
||||||
|
text = (opt.text or "").lower()
|
||||||
|
|
||||||
|
if target_npi and (
|
||||||
|
value == target_npi or target_npi in text
|
||||||
|
):
|
||||||
|
opt.click()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2️⃣ Name match fallback
|
||||||
|
for opt in options:
|
||||||
|
text = (opt.text or "").lower()
|
||||||
|
if target_name and target_name in text:
|
||||||
|
opt.click()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 3️⃣ Last fallback → first valid option
|
||||||
|
for opt in options:
|
||||||
|
if opt.get_attribute("value"):
|
||||||
|
opt.click()
|
||||||
|
return True
|
||||||
|
|
||||||
|
raise Exception("No valid Rendering Provider NPI found")
|
||||||
|
|
||||||
|
|
||||||
def login(self):
|
def login(self):
|
||||||
wait = WebDriverWait(self.driver, 30)
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
|
||||||
@@ -98,7 +134,7 @@ class AutomationMassHealth:
|
|||||||
# Rendering Provider NPI dropdown
|
# Rendering Provider NPI dropdown
|
||||||
npi_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select1"]')))
|
npi_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select1"]')))
|
||||||
select_npi = Select(npi_dropdown)
|
select_npi = Select(npi_dropdown)
|
||||||
select_npi.select_by_index(1)
|
self.select_rendering_npi(select_npi)
|
||||||
|
|
||||||
# Office Location dropdown
|
# Office Location dropdown
|
||||||
location_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select2"]')))
|
location_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select2"]')))
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class AutomationMassHealthPreAuth:
|
|||||||
self.remarks = self.claim.get("remarks", "")
|
self.remarks = self.claim.get("remarks", "")
|
||||||
self.massdhp_username = self.claim.get("massdhpUsername", "")
|
self.massdhp_username = self.claim.get("massdhpUsername", "")
|
||||||
self.massdhp_password = self.claim.get("massdhpPassword", "")
|
self.massdhp_password = self.claim.get("massdhpPassword", "")
|
||||||
|
self.npiProvider = self.claim.get("npiProvider", {})
|
||||||
|
self.npiNumber = self.npiProvider.get("npiNumber", "")
|
||||||
|
self.npiName = self.npiProvider.get("providerName", "")
|
||||||
self.serviceLines = self.claim.get("serviceLines", [])
|
self.serviceLines = self.claim.get("serviceLines", [])
|
||||||
self.missingTeethStatus = self.claim.get("missingTeethStatus", "")
|
self.missingTeethStatus = self.claim.get("missingTeethStatus", "")
|
||||||
self.missingTeeth = self.claim.get("missingTeeth", {})
|
self.missingTeeth = self.claim.get("missingTeeth", {})
|
||||||
@@ -39,6 +42,39 @@ class AutomationMassHealthPreAuth:
|
|||||||
driver = webdriver.Chrome(service=s, options=options)
|
driver = webdriver.Chrome(service=s, options=options)
|
||||||
self.driver = driver
|
self.driver = driver
|
||||||
|
|
||||||
|
def select_rendering_npi(self, select_element):
|
||||||
|
options = select_element.options
|
||||||
|
|
||||||
|
target_npi = (self.npiNumber or "").strip()
|
||||||
|
target_name = (self.npiName or "").strip().lower()
|
||||||
|
|
||||||
|
# 1️⃣ Exact NPI match (value or text)
|
||||||
|
for opt in options:
|
||||||
|
value = (opt.get_attribute("value") or "").strip()
|
||||||
|
text = (opt.text or "").lower()
|
||||||
|
|
||||||
|
if target_npi and (
|
||||||
|
value == target_npi or target_npi in text
|
||||||
|
):
|
||||||
|
opt.click()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2️⃣ Name match fallback
|
||||||
|
for opt in options:
|
||||||
|
text = (opt.text or "").lower()
|
||||||
|
if target_name and target_name in text:
|
||||||
|
opt.click()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 3️⃣ Last fallback → first valid option
|
||||||
|
for opt in options:
|
||||||
|
if opt.get_attribute("value"):
|
||||||
|
opt.click()
|
||||||
|
return True
|
||||||
|
|
||||||
|
raise Exception("No valid Rendering Provider NPI found")
|
||||||
|
|
||||||
|
|
||||||
def login(self):
|
def login(self):
|
||||||
wait = WebDriverWait(self.driver, 30)
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
|
||||||
@@ -97,7 +133,7 @@ class AutomationMassHealthPreAuth:
|
|||||||
# Rendering Provider NPI dropdown
|
# Rendering Provider NPI dropdown
|
||||||
npi_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select1"]')))
|
npi_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select1"]')))
|
||||||
select_npi = Select(npi_dropdown)
|
select_npi = Select(npi_dropdown)
|
||||||
select_npi.select_by_index(1)
|
self.select_rendering_npi(select_npi)
|
||||||
|
|
||||||
# Office Location dropdown
|
# Office Location dropdown
|
||||||
location_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select2"]')))
|
location_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select2"]')))
|
||||||
|
|||||||
34
package-lock.json
generated
34
package-lock.json
generated
@@ -239,6 +239,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -586,7 +587,8 @@
|
|||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
||||||
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@electric-sql/pglite-socket": {
|
"node_modules/@electric-sql/pglite-socket": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
@@ -4560,6 +4562,7 @@
|
|||||||
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
|
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hoist-non-react-statics": "^3.3.0"
|
"hoist-non-react-statics": "^3.3.0"
|
||||||
},
|
},
|
||||||
@@ -4619,6 +4622,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz",
|
||||||
"integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==",
|
"integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.19.2"
|
"undici-types": "~6.19.2"
|
||||||
}
|
}
|
||||||
@@ -4683,6 +4687,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -4693,6 +4698,7 @@
|
|||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -4843,6 +4849,7 @@
|
|||||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.3",
|
"@typescript-eslint/scope-manager": "8.46.3",
|
||||||
"@typescript-eslint/types": "8.46.3",
|
"@typescript-eslint/types": "8.46.3",
|
||||||
@@ -5126,6 +5133,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5648,6 +5656,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
@@ -6696,7 +6705,8 @@
|
|||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/embla-carousel-react": {
|
"node_modules/embla-carousel-react": {
|
||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
@@ -7013,6 +7023,7 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -8220,6 +8231,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.7.10.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.7.10.tgz",
|
||||||
"integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==",
|
"integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -9998,6 +10010,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||||
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
|
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -10030,6 +10043,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
@@ -10185,6 +10199,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -10402,6 +10417,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -11128,6 +11144,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -11219,6 +11236,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -11231,6 +11249,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -11262,6 +11281,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@@ -11521,7 +11541,8 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -12486,6 +12507,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -12766,6 +12788,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -13037,6 +13060,7 @@
|
|||||||
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
|
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.25.0",
|
"esbuild": "~0.25.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -13212,6 +13236,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -13489,6 +13514,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -13580,6 +13606,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -13891,6 +13918,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"db:generate": "npx prisma generate --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma && npx ts-node packages/db/scripts/patch-prisma-imports.ts && ts-node packages/db/scripts/patch-zod-buffer.ts",
|
"db:generate": "npx prisma generate --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma && npx ts-node packages/db/scripts/patch-prisma-imports.ts && ts-node packages/db/scripts/patch-zod-buffer.ts",
|
||||||
"db:migrate": "npx prisma migrate dev --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma",
|
"db:migrate": "npx prisma migrate dev --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma",
|
||||||
"db:seed": "npx prisma db seed --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma",
|
"db:seed": "npx prisma db seed --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma",
|
||||||
|
"db:studio": "npx prisma studio --config=packages/db/prisma/prisma.config.ts",
|
||||||
"setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PatientDataExtractorService/.env.example apps/PatientDataExtractorService/.env && shx cp apps/SeleniumService/.env.example apps/SeleniumService/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env",
|
"setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PatientDataExtractorService/.env.example apps/PatientDataExtractorService/.env && shx cp apps/SeleniumService/.env.example apps/SeleniumService/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env",
|
||||||
"postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall"
|
"postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,21 +23,9 @@ tar -xzf /path/to/backup.tar.gz -C /tmp/dental_dump_dir
|
|||||||
|
|
||||||
# create DB (if not exists) and restore (data-only or custom format)
|
# create DB (if not exists) and restore (data-only or custom format)
|
||||||
PGPASSWORD='mypassword' createdb -U postgres -h localhost -O postgres dentalapp
|
PGPASSWORD='mypassword' createdb -U postgres -h localhost -O postgres dentalapp
|
||||||
PGPASSWORD='mypassword' pg_restore -U postgres -h localhost -d dentalapp -j 4 /tmp/dental_dump_dir
|
|
||||||
# (or use /usr/lib/postgresql/<ver>/bin/pg_restore if version mismatch)
|
# (or use /usr/lib/postgresql/<ver>/bin/pg_restore if version mismatch)
|
||||||
```
|
```
|
||||||
|
# 1.2 — (If needed) fix postgres user password / auth
|
||||||
---
|
|
||||||
|
|
||||||
# 2 — Confirm DB has tables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "\dt"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 3 — (If needed) fix postgres user password / auth
|
|
||||||
|
|
||||||
If `createdb` or `pg_restore` fails with password auth:
|
If `createdb` or `pg_restore` fails with password auth:
|
||||||
|
|
||||||
@@ -47,110 +35,74 @@ sudo -u postgres psql -c "ALTER ROLE postgres WITH PASSWORD 'mypassword';"
|
|||||||
```
|
```
|
||||||
---
|
---
|
||||||
|
|
||||||
# 4 — Inspect `_prisma_migrations` in restored DB
|
---
|
||||||
|
|
||||||
|
## 3 — Let Prisma create the schema (RUN ONCE)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "SELECT id, migration_name, finished_at FROM _prisma_migrations ORDER BY finished_at;"
|
npx prisma migrate dev --config=packages/db/prisma/prisma.config.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why:** the backup included `_prisma_migrations` from the original PC, which causes Prisma to detect "missing migrations" locally.
|
Expected:
|
||||||
|
|
||||||
|
```
|
||||||
|
Your database is now in sync with your schema.
|
||||||
|
```
|
||||||
|
|
||||||
|
This step:
|
||||||
|
|
||||||
|
* Creates tables, enums, indexes, FKs
|
||||||
|
* Creates `_prisma_migrations`
|
||||||
|
* Creates the first local migration
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 5 — (If present) remove old Prisma bookkeeping from DB
|
## 4 — Restore DATA ONLY from backup
|
||||||
|
|
||||||
> We prefer to *not* use the old history from PC1 and create a fresh baseline on PC2.
|
```bash
|
||||||
|
PGPASSWORD='mypassword' pg_restore -U postgres -h localhost -d dentalapp -Fd /tmp/dental_dump_dir --data-only --disable-triggers
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ This will also restore old `_prisma_migrations` rows — we fix that next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 — Remove old Prisma bookkeeping from backup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# truncate migration records (bookkeeping only)
|
|
||||||
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "TRUNCATE TABLE _prisma_migrations;"
|
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "TRUNCATE TABLE _prisma_migrations;"
|
||||||
# verify
|
|
||||||
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "SELECT count(*) FROM _prisma_migrations;"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why:** remove migration rows copied from PC1 so we can register a clean baseline for PC2.
|
This:
|
||||||
|
|
||||||
|
* Does NOT touch data
|
||||||
|
* Does NOT touch schema
|
||||||
|
* Removes old PC1 migration history
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 6 — Create a migrations directory + baseline migration folder (bookkeeping)
|
## 6 — Re-register the current migration as applied (CRITICAL STEP)
|
||||||
|
|
||||||
From project root (where `prisma/schema.prisma` lives — in your repo it’s `packages/db/prisma/schema.prisma`):
|
Replace the migration name with the one created in step 3
|
||||||
|
(example: `20260103121811`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# create migrations dir if missing (adjust path if your prisma folder is elsewhere)
|
npx prisma migrate resolve --applied 20260103121811 --config=packages/db/prisma/prisma.config.ts
|
||||||
mkdir -p packages/db/prisma/migrations
|
|
||||||
|
|
||||||
# create a timestamped folder (example uses date command)
|
|
||||||
folder="packages/db/prisma/migrations/$(date +%Y%m%d%H%M%S)_init"
|
|
||||||
mkdir -p "$folder"
|
|
||||||
|
|
||||||
# create placeholder migration files
|
|
||||||
cat > "$folder/migration.sql" <<'SQL'
|
|
||||||
-- Baseline migration for PC2 (will be replaced with real SQL)
|
|
||||||
SQL
|
|
||||||
|
|
||||||
cat > "$folder/README.md" <<'TXT'
|
|
||||||
Initial baseline migration created on PC2.
|
|
||||||
This is intended as a bookkeeping-only migration.
|
|
||||||
TXT
|
|
||||||
|
|
||||||
# confirm folder name
|
|
||||||
ls -la packages/db/prisma/migrations
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why:** Prisma requires at least one migration file locally as a baseline.
|
This tells Prisma:
|
||||||
|
|
||||||
|
> “Yes, this migration already created the schema.”
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 7 — Generate the full baseline SQL (so Prisma’s expected schema matches DB)
|
## 7 — Verify Prisma state
|
||||||
|
|
||||||
Use Prisma `migrate diff` to produce SQL that creates your current schema, writing it into the migration file you created:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# replace the folder name with the real one printed above, e.g. 20251203101323_init
|
npx prisma migrate status --config=packages/db/prisma/prisma.config.ts
|
||||||
npx prisma migrate diff \
|
|
||||||
--from-empty \
|
|
||||||
--to-schema-datamodel=packages/db/prisma/schema.prisma \
|
|
||||||
--script > packages/db/prisma/migrations/20251203101323_init/migration.sql
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If your shell complains about line breaks, run the whole command on one line (as above).
|
Expected:
|
||||||
|
|
||||||
**Fallback (if `migrate diff` not available):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PGPASSWORD='mypassword' pg_dump -U postgres -h localhost -s dentalapp > /tmp/dental_schema.sql
|
|
||||||
cp /tmp/dental_schema.sql packages/db/prisma/migrations/20251203101323_init/migration.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why:** this makes the migration file contain CREATE TABLE / CREATE TYPE / FK / INDEX statements matching the DB so Prisma's expected schema = actual DB.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 8 — Register the baseline migration with Prisma (using the exact env/schema your scripts use)
|
|
||||||
|
|
||||||
Important: use same env file and `--schema` (and `--config` if used) that your npm script uses. Example for your repo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# from repo root, mark applied for the migrations folder we created
|
|
||||||
npx dotenv -e packages/db/.env -- npx prisma migrate resolve --applied "20251203101323_init" --schema=packages/db/prisma/schema.prisma
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why:** record the baseline in `_prisma_migrations` with the checksum matching the `migration.sql` file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
# 9 — Verify status and generate client
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# same env/schema flags
|
|
||||||
npx dotenv -e packages/db/.env -- npx prisma migrate status --schema=packages/db/prisma/schema.prisma
|
|
||||||
|
|
||||||
npx dotenv -e packages/db/.env -- npx prisma generate --schema=packages/db/prisma/schema.prisma
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
1 migration found in prisma/migrations
|
1 migration found in prisma/migrations
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"./client": "./src/index.ts",
|
"./client": "./src/index.ts",
|
||||||
"./shared/schemas": "./shared/schemas/index.ts",
|
"./shared/schemas": "./shared/schemas/index.ts",
|
||||||
"./usedSchemas": "./usedSchemas/index.ts",
|
"./usedSchemas": "./usedSchemas/index.ts",
|
||||||
"./types": "./types/index.ts"
|
"./types": "./types/index.ts",
|
||||||
|
"./generated/prisma": "./generated/prisma/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.0.1",
|
"@prisma/adapter-pg": "^7.0.1",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ model User {
|
|||||||
patients Patient[]
|
patients Patient[]
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
staff Staff[]
|
staff Staff[]
|
||||||
|
npiProviders NpiProvider[]
|
||||||
claims Claim[]
|
claims Claim[]
|
||||||
insuranceCredentials InsuranceCredential[]
|
insuranceCredentials InsuranceCredential[]
|
||||||
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
||||||
@@ -58,6 +59,7 @@ model Patient {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
|
procedures AppointmentProcedure[]
|
||||||
claims Claim[]
|
claims Claim[]
|
||||||
groups PdfGroup[]
|
groups PdfGroup[]
|
||||||
payment Payment[]
|
payment Payment[]
|
||||||
@@ -84,6 +86,7 @@ model Appointment {
|
|||||||
endTime String // Store time as "hh:mm"
|
endTime String // Store time as "hh:mm"
|
||||||
type String // e.g., "checkup", "cleaning", "filling", etc.
|
type String // e.g., "checkup", "cleaning", "filling", etc.
|
||||||
notes String?
|
notes String?
|
||||||
|
procedureCodeNotes String?
|
||||||
status String @default("scheduled") // "scheduled", "completed", "cancelled", "no-show"
|
status String @default("scheduled") // "scheduled", "completed", "cancelled", "no-show"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ model Appointment {
|
|||||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
staff Staff? @relation(fields: [staffId], references: [id])
|
staff Staff? @relation(fields: [staffId], references: [id])
|
||||||
|
procedures AppointmentProcedure[]
|
||||||
claims Claim[]
|
claims Claim[]
|
||||||
|
|
||||||
@@index([patientId])
|
@@index([patientId])
|
||||||
@@ -100,17 +104,63 @@ model Appointment {
|
|||||||
|
|
||||||
model Staff {
|
model Staff {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int?
|
userId Int
|
||||||
name String
|
name String
|
||||||
email String?
|
email String?
|
||||||
role String // e.g., "Dentist", "Hygienist", "Assistant"
|
role String // e.g., "Dentist", "Hygienist", "Assistant"
|
||||||
phone String?
|
phone String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
displayOrder Int @default(0)
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
claims Claim[] @relation("ClaimStaff")
|
claims Claim[] @relation("ClaimStaff")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NpiProvider {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
npiNumber String
|
||||||
|
providerName String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, npiNumber])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProcedureSource {
|
||||||
|
COMBO
|
||||||
|
MANUAL
|
||||||
|
}
|
||||||
|
|
||||||
|
model AppointmentProcedure {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
appointmentId Int
|
||||||
|
patientId Int
|
||||||
|
|
||||||
|
procedureCode String
|
||||||
|
procedureLabel String?
|
||||||
|
fee Decimal? @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
category String?
|
||||||
|
|
||||||
|
toothNumber String?
|
||||||
|
toothSurface String?
|
||||||
|
oralCavityArea String?
|
||||||
|
|
||||||
|
source ProcedureSource @default(MANUAL)
|
||||||
|
comboKey String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
||||||
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([appointmentId])
|
||||||
|
@@index([patientId])
|
||||||
|
}
|
||||||
|
|
||||||
model Claim {
|
model Claim {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
patientId Int
|
patientId Int
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppointmentUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
import { AppointmentUncheckedCreateInputObjectSchema, AppointmentProcedureUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
export type Appointment = z.infer<typeof AppointmentUncheckedCreateInputObjectSchema>;
|
export type Appointment = z.infer<typeof AppointmentUncheckedCreateInputObjectSchema>;
|
||||||
@@ -20,3 +20,34 @@ export const updateAppointmentSchema = (
|
|||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
export type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
|
export type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
|
||||||
|
|
||||||
|
|
||||||
|
// Appointment Procedure Types.
|
||||||
|
|
||||||
|
export type AppointmentProcedure = z.infer<
|
||||||
|
typeof AppointmentProcedureUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const insertAppointmentProcedureSchema = (
|
||||||
|
AppointmentProcedureUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertAppointmentProcedure = z.infer<
|
||||||
|
typeof insertAppointmentProcedureSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const updateAppointmentProcedureSchema = (
|
||||||
|
AppointmentProcedureUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
)
|
||||||
|
.omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
})
|
||||||
|
.partial();
|
||||||
|
|
||||||
|
export type UpdateAppointmentProcedure = z.infer<
|
||||||
|
typeof updateAppointmentProcedureSchema
|
||||||
|
>;
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ export interface ClaimFormData {
|
|||||||
serviceDate: string; // YYYY-MM-DD
|
serviceDate: string; // YYYY-MM-DD
|
||||||
insuranceProvider: string;
|
insuranceProvider: string;
|
||||||
insuranceSiteKey?: string;
|
insuranceSiteKey?: string;
|
||||||
|
npiProvider?: {
|
||||||
|
npiNumber: string;
|
||||||
|
providerName: string;
|
||||||
|
};
|
||||||
status: string; // default "pending"
|
status: string; // default "pending"
|
||||||
serviceLines: InputServiceLine[];
|
serviceLines: InputServiceLine[];
|
||||||
claimId?: number;
|
claimId?: number;
|
||||||
@@ -118,6 +122,10 @@ export interface ClaimPreAuthData {
|
|||||||
serviceDate: string; // YYYY-MM-DD
|
serviceDate: string; // YYYY-MM-DD
|
||||||
insuranceProvider: string;
|
insuranceProvider: string;
|
||||||
insuranceSiteKey?: string;
|
insuranceSiteKey?: string;
|
||||||
|
npiProvider?: {
|
||||||
|
npiNumber: string;
|
||||||
|
providerName: string;
|
||||||
|
};
|
||||||
status: string; // default "pending"
|
status: string; // default "pending"
|
||||||
serviceLines: InputServiceLine[];
|
serviceLines: InputServiceLine[];
|
||||||
claimFiles?: ClaimFileMeta[];
|
claimFiles?: ClaimFileMeta[];
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from "./notifications-types";
|
|||||||
export * from "./cloudStorage-types";
|
export * from "./cloudStorage-types";
|
||||||
export * from "./payments-reports-types";
|
export * from "./payments-reports-types";
|
||||||
export * from "./patientConnection-types";
|
export * from "./patientConnection-types";
|
||||||
|
export * from "./npiProviders-types";
|
||||||
14
packages/db/types/npiProviders-types.ts
Normal file
14
packages/db/types/npiProviders-types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { NpiProviderUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
|
|
||||||
|
export type NpiProvider = z.infer<
|
||||||
|
typeof NpiProviderUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const insertNpiProviderSchema = (
|
||||||
|
NpiProviderUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
).omit({ id: true });
|
||||||
|
|
||||||
|
export type InsertNpiProvider = z.infer<
|
||||||
|
typeof insertNpiProviderSchema
|
||||||
|
>;
|
||||||
@@ -22,10 +22,10 @@ export const insuranceIdSchema = z.preprocess(
|
|||||||
}
|
}
|
||||||
return val;
|
return val;
|
||||||
},
|
},
|
||||||
// After preprocess, require digits-only string (or optional nullable)
|
// After preprocess, allow alphanumeric insurance IDs (some providers like DentaQuest use letter prefixes)
|
||||||
z
|
z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^\d+$/, { message: "Insurance ID must contain only digits" })
|
.regex(/^[A-Za-z0-9]+$/, { message: "Insurance ID must contain only letters and digits" })
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(32)
|
.max(32)
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -1,4 +1,44 @@
|
|||||||
import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
|
export type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
|
||||||
|
|
||||||
|
export const staffCreateSchema = (
|
||||||
|
StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
displayOrder: true,
|
||||||
|
userId: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const staffUpdateSchema = (
|
||||||
|
StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
)
|
||||||
|
.partial()
|
||||||
|
.omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
userId: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StaffFormData = {
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
role: string;
|
||||||
|
phone?: string;
|
||||||
|
displayOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StaffCreateInput = z.infer<
|
||||||
|
typeof StaffUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type StaffCreateBody = Omit<
|
||||||
|
StaffCreateInput,
|
||||||
|
"id" | "createdAt" | "userId" | "displayOrder"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type StaffUpdateInput = Partial<
|
||||||
|
Omit<StaffCreateInput, "id" | "createdAt" | "userId">
|
||||||
|
>;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// using this, as the browser load only the required files , not whole db/shared/schemas/ files.
|
// using this, as the browser load only the required files , not whole db/shared/schemas/ files.
|
||||||
export * from '../shared/schemas/objects/AppointmentUncheckedCreateInput.schema';
|
export * from '../shared/schemas/objects/AppointmentUncheckedCreateInput.schema';
|
||||||
|
export * from '../shared/schemas/objects/AppointmentProcedureUncheckedCreateInput.schema';
|
||||||
export * from '../shared/schemas/objects/PatientUncheckedCreateInput.schema';
|
export * from '../shared/schemas/objects/PatientUncheckedCreateInput.schema';
|
||||||
export * from '../shared/schemas/enums/PatientStatus.schema';
|
export * from '../shared/schemas/enums/PatientStatus.schema';
|
||||||
export * from '../shared/schemas/objects/UserUncheckedCreateInput.schema';
|
export * from '../shared/schemas/objects/UserUncheckedCreateInput.schema';
|
||||||
export * from '../shared/schemas/objects/StaffUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/StaffUncheckedCreateInput.schema'
|
||||||
|
export * from '../shared/schemas/objects/NpiProviderUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/ClaimUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/ClaimUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/InsuranceCredentialUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/InsuranceCredentialUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema'
|
||||||
|
|||||||
Reference in New Issue
Block a user