fix: D0140 claim sync, schedule column prefill, multi-appt, DentaQuest OTP session
- Fix Express route ordering in appointments-procedures so /prefill-from-appointment is matched before /:id (D0140 and other codes now always reach the claim) - claim-form: always fetch AppointmentProcedure records when an existing claim loads so post-save procedure edits (e.g. adding D0140) are reflected immediately - appointments-page: replace sessionStorage with React state (newApptPrefill) for slot-click prefill so columns B-F correctly carry their staffId into the form - add-appointment-modal / appointment-form: thread prefillData prop; add NewAppointmentPrefill interface; useEffect applies values via setValue - appointments upsert: remove per-patient dedup so the same patient can have multiple appointments on the same day in the same column - DentaQuest / TuftsSCO: navigate to about:blank and minimize instead of quit_driver after each run — session cookie stays in memory so OTP is only required once per app startup, not on every eligibility or claim check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,25 +8,7 @@ import {
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
/**
|
// Specific routes must be registered before generic /:id routes to avoid shadowing.
|
||||||
* 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(
|
router.get(
|
||||||
"/prefill-from-appointment/:appointmentId",
|
"/prefill-from-appointment/:appointmentId",
|
||||||
@@ -54,6 +36,26 @@ router.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/appointment-procedures/set-npi-provider/:appointmentId
|
* PUT /api/appointment-procedures/set-npi-provider/:appointmentId
|
||||||
* Set the npiProviderId on all procedures for an appointment (lightweight update).
|
* Set the npiProviderId on all procedures for an appointment (lightweight update).
|
||||||
@@ -196,26 +198,6 @@ router.put("/:id", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* DELETE /api/appointment-procedures/clear/:appointmentId
|
||||||
* Clear all procedures for appointment
|
* Clear all procedures for appointment
|
||||||
@@ -236,4 +218,24 @@ router.delete("/clear/:appointmentId", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -193,20 +193,13 @@ router.post(
|
|||||||
const originalStartTime = appointmentData.startTime;
|
const originalStartTime = appointmentData.startTime;
|
||||||
const MAX_END_TIME = "18:30";
|
const MAX_END_TIME = "18:30";
|
||||||
|
|
||||||
// 1. Verify patient exists and belongs to user
|
// 1. Verify patient exists
|
||||||
const patient = await storage.getPatient(appointmentData.patientId);
|
const patient = await storage.getPatient(appointmentData.patientId);
|
||||||
if (!patient) {
|
if (!patient) {
|
||||||
return res.status(404).json({ message: "Patient not found" });
|
return res.status(404).json({ message: "Patient not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. One patient per column per day: find existing appointment for this patient in the same staff column today
|
// 2. Find the next available slot for this staff member (multiple same-patient same-day bookings allowed)
|
||||||
const existingPatientAppointment = await storage.getPatientAppointmentByDateAndStaff(
|
|
||||||
appointmentData.patientId,
|
|
||||||
appointmentData.date,
|
|
||||||
appointmentData.staffId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Attempt to find the next available slot
|
|
||||||
let [hour, minute] = originalStartTime.split(":").map(Number);
|
let [hour, minute] = originalStartTime.split(":").map(Number);
|
||||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
|
||||||
@@ -216,45 +209,24 @@ router.post(
|
|||||||
while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) {
|
while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) {
|
||||||
const currentStartTime = `${pad(hour)}:${pad(minute)}`;
|
const currentStartTime = `${pad(hour)}:${pad(minute)}`;
|
||||||
|
|
||||||
// Check staff conflict at this time (exclude the patient's existing appointment so it can move)
|
|
||||||
const staffConflict = await storage.getStaffAppointmentByDateTime(
|
const staffConflict = await storage.getStaffAppointmentByDateTime(
|
||||||
appointmentData.staffId,
|
appointmentData.staffId,
|
||||||
appointmentData.date,
|
appointmentData.date,
|
||||||
currentStartTime,
|
currentStartTime,
|
||||||
existingPatientAppointment?.id
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!staffConflict) {
|
if (!staffConflict) {
|
||||||
const endMinute = minute + APPT_DURATION_MINUTES;
|
const endMinute = minute + APPT_DURATION_MINUTES;
|
||||||
let endHour = hour + Math.floor(endMinute / 60);
|
const endHour = hour + Math.floor(endMinute / 60);
|
||||||
let realEndMinute = endMinute % 60;
|
const realEndMinute = endMinute % 60;
|
||||||
|
|
||||||
const currentEndTime = `${pad(endHour)}:${pad(realEndMinute)}`;
|
const currentEndTime = `${pad(endHour)}:${pad(realEndMinute)}`;
|
||||||
|
|
||||||
const payload = {
|
const created = await storage.createAppointment({
|
||||||
...appointmentData,
|
...appointmentData,
|
||||||
startTime: currentStartTime,
|
startTime: currentStartTime,
|
||||||
endTime: currentEndTime,
|
endTime: currentEndTime,
|
||||||
};
|
|
||||||
|
|
||||||
if (existingPatientAppointment?.id !== undefined) {
|
|
||||||
// Replace the existing appointment in-place (preserves linked claims/procedures)
|
|
||||||
const updated = await storage.updateAppointment(
|
|
||||||
existingPatientAppointment.id,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return res.status(200).json({
|
|
||||||
...updated,
|
|
||||||
originalRequestedTime: originalStartTime,
|
|
||||||
finalScheduledTime: currentStartTime,
|
|
||||||
message:
|
|
||||||
originalStartTime !== currentStartTime
|
|
||||||
? `Your requested time (${originalStartTime}) was unavailable. Appointment was updated to ${currentStartTime}.`
|
|
||||||
: `Appointment successfully updated at ${currentStartTime}.`,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const created = await storage.createAppointment(payload);
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
...created,
|
...created,
|
||||||
originalRequestedTime: originalStartTime,
|
originalRequestedTime: originalStartTime,
|
||||||
@@ -266,7 +238,6 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to next STEP_MINUTES slot
|
|
||||||
minute += STEP_MINUTES;
|
minute += STEP_MINUTES;
|
||||||
if (minute >= 60) {
|
if (minute >= 60) {
|
||||||
hour += Math.floor(minute / 60);
|
hour += Math.floor(minute / 60);
|
||||||
@@ -275,8 +246,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
message:
|
message: "No available slots remaining until 6:30 PM for this staff member. Please choose another day.",
|
||||||
"No available slots remaining until 6:30 PM for this Staff. Please choose another day.",
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in upsert appointment:", error);
|
console.error("Error in upsert appointment:", error);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { AppointmentForm } from "./appointment-form";
|
import { AppointmentForm, type NewAppointmentPrefill } from "./appointment-form";
|
||||||
import {
|
import {
|
||||||
Appointment,
|
Appointment,
|
||||||
InsertAppointment,
|
InsertAppointment,
|
||||||
@@ -18,6 +18,7 @@ interface AddAppointmentModalProps {
|
|||||||
onDelete?: (id: number) => void;
|
onDelete?: (id: number) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
appointment?: Appointment;
|
appointment?: Appointment;
|
||||||
|
prefillData?: NewAppointmentPrefill | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddAppointmentModal({
|
export function AddAppointmentModal({
|
||||||
@@ -27,6 +28,7 @@ export function AddAppointmentModal({
|
|||||||
onDelete,
|
onDelete,
|
||||||
isLoading,
|
isLoading,
|
||||||
appointment,
|
appointment,
|
||||||
|
prefillData,
|
||||||
}: AddAppointmentModalProps) {
|
}: AddAppointmentModalProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -39,6 +41,7 @@ export function AddAppointmentModal({
|
|||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
<AppointmentForm
|
<AppointmentForm
|
||||||
appointment={appointment}
|
appointment={appointment}
|
||||||
|
prefillData={prefillData}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
onSubmit(data);
|
onSubmit(data);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|||||||
@@ -37,8 +37,18 @@ import { DateInputField } from "@/components/ui/dateInputField";
|
|||||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export interface NewAppointmentPrefill {
|
||||||
|
staffId: number;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
patientId?: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AppointmentFormProps {
|
interface AppointmentFormProps {
|
||||||
appointment?: Appointment;
|
appointment?: Appointment;
|
||||||
|
prefillData?: NewAppointmentPrefill | null;
|
||||||
onSubmit: (data: InsertAppointment | UpdateAppointment) => void;
|
onSubmit: (data: InsertAppointment | UpdateAppointment) => void;
|
||||||
onDelete?: (id: number) => void;
|
onDelete?: (id: number) => void;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
@@ -47,6 +57,7 @@ interface AppointmentFormProps {
|
|||||||
|
|
||||||
export function AppointmentForm({
|
export function AppointmentForm({
|
||||||
appointment,
|
appointment,
|
||||||
|
prefillData,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -87,19 +98,6 @@ export function AppointmentForm({
|
|||||||
color: colorMap[staff.name] || "bg-gray-400",
|
color: colorMap[staff.name] || "bg-gray-400",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get the stored data from session storage
|
|
||||||
const storedDataString = sessionStorage.getItem("newAppointmentData");
|
|
||||||
let parsedStoredData = null;
|
|
||||||
|
|
||||||
// Try to parse it if it exists
|
|
||||||
if (storedDataString) {
|
|
||||||
try {
|
|
||||||
parsedStoredData = JSON.parse(storedDataString);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error parsing stored appointment data:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format the date and times for the form
|
// Format the date and times for the form
|
||||||
const defaultValues: Partial<Appointment> = appointment
|
const defaultValues: Partial<Appointment> = appointment
|
||||||
? {
|
? {
|
||||||
@@ -107,8 +105,8 @@ export function AppointmentForm({
|
|||||||
patientId: appointment.patientId,
|
patientId: appointment.patientId,
|
||||||
title: appointment.title,
|
title: appointment.title,
|
||||||
date: parseLocalDate(appointment.date),
|
date: parseLocalDate(appointment.date),
|
||||||
startTime: appointment.startTime || "09:00", // Default "09:00"
|
startTime: appointment.startTime || "09:00",
|
||||||
endTime: appointment.endTime || "09:30", // Default "09:30"
|
endTime: appointment.endTime || "09:30",
|
||||||
type: appointment.type?.startsWith("other:") ? "other" : appointment.type,
|
type: appointment.type?.startsWith("other:") ? "other" : appointment.type,
|
||||||
notes: appointment.notes || "",
|
notes: appointment.notes || "",
|
||||||
status: appointment.status || "scheduled",
|
status: appointment.status || "scheduled",
|
||||||
@@ -117,23 +115,18 @@ export function AppointmentForm({
|
|||||||
? appointment.staffId
|
? appointment.staffId
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
: parsedStoredData
|
: prefillData
|
||||||
? {
|
? {
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
patientId: Number(parsedStoredData.patientId),
|
patientId: prefillData.patientId,
|
||||||
date: parsedStoredData.date
|
date: prefillData.date ? parseLocalDate(prefillData.date) : new Date(),
|
||||||
? parseLocalDate(parsedStoredData.date)
|
title: "",
|
||||||
: parseLocalDate(new Date()),
|
startTime: prefillData.startTime,
|
||||||
title: parsedStoredData.title || "",
|
endTime: prefillData.endTime,
|
||||||
startTime: parsedStoredData.startTime,
|
type: prefillData.type || "checkup",
|
||||||
endTime: parsedStoredData.endTime,
|
status: "scheduled",
|
||||||
type: parsedStoredData.type || "checkup",
|
notes: "",
|
||||||
status: parsedStoredData.status || "scheduled",
|
staffId: prefillData.staffId,
|
||||||
notes: parsedStoredData.notes || "",
|
|
||||||
staffId:
|
|
||||||
typeof parsedStoredData.staff === "number"
|
|
||||||
? parsedStoredData.staff
|
|
||||||
: (staffMembers?.[0]?.id ?? undefined),
|
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
userId: user?.id ?? 0,
|
userId: user?.id ?? 0,
|
||||||
@@ -192,67 +185,24 @@ export function AppointmentForm({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectOpen]);
|
}, [selectOpen]);
|
||||||
|
|
||||||
// Force form field values to update and clean up storage
|
// Prefill form from prefillData prop (new appointment slot click)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!parsedStoredData) return;
|
if (!prefillData) return;
|
||||||
|
form.setValue("staffId", prefillData.staffId);
|
||||||
// set times/staff/date as before
|
form.setValue("startTime", prefillData.startTime);
|
||||||
if (parsedStoredData.startTime)
|
form.setValue("endTime", prefillData.endTime);
|
||||||
form.setValue("startTime", parsedStoredData.startTime);
|
form.setValue("date", parseLocalDate(prefillData.date));
|
||||||
if (parsedStoredData.endTime)
|
if (prefillData.type) form.setValue("type", prefillData.type);
|
||||||
form.setValue("endTime", parsedStoredData.endTime);
|
if (prefillData.patientId) {
|
||||||
if (parsedStoredData.staff)
|
form.setValue("patientId", prefillData.patientId);
|
||||||
form.setValue("staffId", parsedStoredData.staff);
|
|
||||||
if (parsedStoredData.date) {
|
|
||||||
form.setValue("date", parseLocalDate(parsedStoredData.date));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- patient prefill: check main cache, else fetch once ----
|
|
||||||
if (parsedStoredData.patientId) {
|
|
||||||
const pid = Number(parsedStoredData.patientId);
|
|
||||||
if (!Number.isNaN(pid)) {
|
|
||||||
// ensure the form value is set
|
|
||||||
form.setValue("patientId", pid);
|
|
||||||
|
|
||||||
// fetch single patient record (preferred)
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiRequest("GET", `/api/patients/${pid}`);
|
const res = await apiRequest("GET", `/api/patients/${prefillData.patientId}`);
|
||||||
if (res.ok) {
|
if (res.ok) setPrefillPatient(await res.json());
|
||||||
const patientRecord = await res.json();
|
|
||||||
setPrefillPatient(patientRecord);
|
|
||||||
} else {
|
|
||||||
// non-OK response: show toast with status / message
|
|
||||||
let msg = `Failed to load patient (status ${res.status})`;
|
|
||||||
try {
|
|
||||||
const body = await res.json().catch(() => null);
|
|
||||||
if (body && body.message) msg = body.message;
|
|
||||||
} catch {}
|
} catch {}
|
||||||
toast({
|
|
||||||
title: "Could not load patient",
|
|
||||||
description: msg,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: "Error fetching patient",
|
|
||||||
description:
|
|
||||||
(err as Error)?.message ||
|
|
||||||
"An unknown error occurred while fetching patient details.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
// remove the one-time transport
|
|
||||||
sessionStorage.removeItem("newAppointmentData");
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
} else {
|
}, [prefillData]);
|
||||||
// no patientId in storage — still remove to avoid stale state
|
|
||||||
sessionStorage.removeItem("newAppointmentData");
|
|
||||||
}
|
|
||||||
}, [form]);
|
|
||||||
|
|
||||||
// When editing an appointment, ensure we prefill the patient so SelectValue can render
|
// When editing an appointment, ensure we prefill the patient so SelectValue can render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -331,8 +331,35 @@ export function ClaimForm({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore service lines
|
// Prefer AppointmentProcedure records for service lines — they reflect any
|
||||||
const mappedLines = (claim.serviceLines ?? []).map((sl: any) => ({
|
// updates the user made in the Select Procedure dialog after the claim was saved.
|
||||||
|
let serviceLines: InputServiceLine[] = [];
|
||||||
|
try {
|
||||||
|
const procRes = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/appointment-procedures/prefill-from-appointment/${appointmentId}`,
|
||||||
|
);
|
||||||
|
if (procRes.ok) {
|
||||||
|
const procData = await procRes.json();
|
||||||
|
if ((procData.procedures || []).length > 0) {
|
||||||
|
serviceLines = (procData.procedures as any[]).map((p) => ({
|
||||||
|
procedureCode: p.procedureCode ?? "",
|
||||||
|
procedureDate: claimDate || serviceDate,
|
||||||
|
quad: p.quad || "",
|
||||||
|
arch: p.arch || "",
|
||||||
|
toothNumber: p.toothNumber || "",
|
||||||
|
toothSurface: p.toothSurface || "",
|
||||||
|
totalBilled: new Decimal(Number(p.fee ?? 0)),
|
||||||
|
totalAdjusted: new Decimal(0),
|
||||||
|
totalPaid: new Decimal(0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Fall back to the claim's own service lines if no AppointmentProcedure records exist
|
||||||
|
if (serviceLines.length === 0) {
|
||||||
|
serviceLines = (claim.serviceLines ?? []).map((sl: any) => ({
|
||||||
procedureCode: sl.procedureCode ?? "",
|
procedureCode: sl.procedureCode ?? "",
|
||||||
procedureDate: sl.procedureDate
|
procedureDate: sl.procedureDate
|
||||||
? String(sl.procedureDate).split("T")[0]
|
? String(sl.procedureDate).split("T")[0]
|
||||||
@@ -345,12 +372,13 @@ export function ClaimForm({
|
|||||||
totalAdjusted: new Decimal(Number(sl.totalAdjusted ?? 0)),
|
totalAdjusted: new Decimal(Number(sl.totalAdjusted ?? 0)),
|
||||||
totalPaid: new Decimal(Number(sl.totalPaid ?? 0)),
|
totalPaid: new Decimal(Number(sl.totalPaid ?? 0)),
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
claimId: claim.id,
|
claimId: claim.id,
|
||||||
serviceDate: claimDate || prev.serviceDate,
|
serviceDate: claimDate || prev.serviceDate,
|
||||||
serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines,
|
serviceLines: serviceLines.length > 0 ? serviceLines : prev.serviceLines,
|
||||||
remarks: claim.remarks ?? "",
|
remarks: claim.remarks ?? "",
|
||||||
missingTeethStatus: (claim.missingTeethStatus as MissingTeethStatus) ?? "No_missing",
|
missingTeethStatus: (claim.missingTeethStatus as MissingTeethStatus) ?? "No_missing",
|
||||||
missingTeeth: (claim.missingTeeth as Record<string, "X" | "O">) ?? {},
|
missingTeeth: (claim.missingTeeth as Record<string, "X" | "O">) ?? {},
|
||||||
@@ -2168,10 +2196,10 @@ export function ClaimForm({
|
|||||||
</h3>
|
</h3>
|
||||||
{proceduresOnly ? (
|
{proceduresOnly ? (
|
||||||
<div className="flex justify-center gap-3">
|
<div className="flex justify-center gap-3">
|
||||||
<Button className="w-40" variant="default" onClick={handleProceduresSave}>
|
<Button className="w-40" variant="default" onClick={() => runWithPriceCheck(handleProceduresSave)}>
|
||||||
Save Procedures
|
Save Procedures
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-40" variant="secondary" onClick={handleProceduresUpdate}>
|
<Button className="w-40" variant="secondary" onClick={() => runWithPriceCheck(handleProceduresUpdate)}>
|
||||||
Update & Resubmit
|
Update & Resubmit
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-28" variant="destructive" onClick={handleProceduresVoid}>
|
<Button className="w-28" variant="destructive" onClick={handleProceduresVoid}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
formatDateToHumanReadable,
|
formatDateToHumanReadable,
|
||||||
} from "@/utils/dateUtils";
|
} from "@/utils/dateUtils";
|
||||||
import { AddAppointmentModal } from "@/components/appointments/add-appointment-modal";
|
import { AddAppointmentModal } from "@/components/appointments/add-appointment-modal";
|
||||||
|
import type { NewAppointmentPrefill } from "@/components/appointments/appointment-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Calendar as CalendarIcon,
|
Calendar as CalendarIcon,
|
||||||
@@ -177,6 +178,7 @@ export default function AppointmentsPage() {
|
|||||||
timeSlot: TimeSlot;
|
timeSlot: TimeSlot;
|
||||||
staffId: number;
|
staffId: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [newApptPrefill, setNewApptPrefill] = useState<NewAppointmentPrefill | null>(null);
|
||||||
|
|
||||||
const toggleReminderColumn = (staffId: number) => {
|
const toggleReminderColumn = (staffId: number) => {
|
||||||
setSelectedReminderColumns((prev) => {
|
setSelectedReminderColumns((prev) => {
|
||||||
@@ -366,53 +368,21 @@ export default function AppointmentsPage() {
|
|||||||
|
|
||||||
// Check for newPatient parameter in URL
|
// Check for newPatient parameter in URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Parse URL search params to check for newPatient
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const newPatientId = params.get("newPatient");
|
const newPatientId = params.get("newPatient");
|
||||||
|
|
||||||
if (newPatientId) {
|
if (newPatientId) {
|
||||||
const patientId = parseInt(newPatientId);
|
const patientId = parseInt(newPatientId);
|
||||||
// Choose first available staff safely (fallback to 1 if none)
|
const firstStaff = staffMembers.length > 0 ? staffMembers[0] : undefined;
|
||||||
const firstStaff =
|
|
||||||
staffMembers && staffMembers.length > 0 ? staffMembers[0] : undefined;
|
|
||||||
const staffId = firstStaff ? Number(firstStaff.id) : 1;
|
const staffId = firstStaff ? Number(firstStaff.id) : 1;
|
||||||
// Find first time slot today (9:00 AM is a common starting time)
|
const defaultTimeSlot = timeSlots.find((slot) => slot.time === "09:00") || timeSlots[0];
|
||||||
const defaultTimeSlot =
|
|
||||||
timeSlots.find((slot) => slot.time === "09:00") || timeSlots[0];
|
|
||||||
if (!defaultTimeSlot) {
|
if (!defaultTimeSlot) {
|
||||||
toast({
|
toast({ title: "Unable to schedule", description: "No available time slots.", variant: "destructive" });
|
||||||
title: "Unable to schedule",
|
|
||||||
description:
|
|
||||||
"No available time slots to schedule the new patient right now.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge any existing "newAppointmentData" with the patient info BEFORE opening modal
|
handleCreateAppointmentAtSlot(defaultTimeSlot, staffId, patientId);
|
||||||
try {
|
|
||||||
const existingRaw = sessionStorage.getItem("newAppointmentData");
|
|
||||||
const existing = existingRaw ? JSON.parse(existingRaw) : {};
|
|
||||||
const newAppointmentData = {
|
|
||||||
...existing,
|
|
||||||
patientId: patientId,
|
|
||||||
};
|
|
||||||
sessionStorage.setItem(
|
|
||||||
"newAppointmentData",
|
|
||||||
JSON.stringify(newAppointmentData)
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
// If sessionStorage parsing fails, overwrite with a fresh object
|
|
||||||
sessionStorage.setItem(
|
|
||||||
"newAppointmentData",
|
|
||||||
JSON.stringify({ patientId: patientId })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open/create the appointment modal (will read sessionStorage in the modal)
|
|
||||||
handleCreateAppointmentAtSlot(defaultTimeSlot, Number(staffId));
|
|
||||||
|
|
||||||
// Remove the query param from the URL so this doesn't re-run on navigation/refresh
|
|
||||||
params.delete("newPatient");
|
params.delete("newPatient");
|
||||||
const newSearch = params.toString();
|
const newSearch = params.toString();
|
||||||
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ""}${window.location.hash || ""}`;
|
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ""}${window.location.hash || ""}`;
|
||||||
@@ -671,9 +641,9 @@ 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,
|
||||||
|
patientId?: number
|
||||||
) => {
|
) => {
|
||||||
// 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);
|
||||||
const startMinute = parseInt(timeSlot.time.split(":")[1] as string);
|
const startMinute = parseInt(timeSlot.time.split(":")[1] as string);
|
||||||
const startDate = parseLocalDate(selectedDate);
|
const startDate = parseLocalDate(selectedDate);
|
||||||
@@ -682,59 +652,17 @@ export default function AppointmentsPage() {
|
|||||||
const endDate = addMinutes(startDate, 30);
|
const endDate = addMinutes(startDate, 30);
|
||||||
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
|
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
// Find staff member
|
|
||||||
const staff = staffMembers.find((s) => Number(s.id) === Number(staffId));
|
const staff = staffMembers.find((s) => Number(s.id) === Number(staffId));
|
||||||
|
|
||||||
// Try to read any existing prefill data (may include patientId from the URL handler)
|
setNewApptPrefill({
|
||||||
let existingStored: any = null;
|
staffId: Number(staffId),
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem("newAppointmentData");
|
|
||||||
existingStored = raw ? JSON.parse(raw) : null;
|
|
||||||
} catch (e) {
|
|
||||||
// ignore parse errors and treat as no existing stored data
|
|
||||||
existingStored = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the prefill appointment object and merge existing stored data
|
|
||||||
const newAppointment = {
|
|
||||||
// base defaults
|
|
||||||
date: formatLocalDate(selectedDate),
|
date: formatLocalDate(selectedDate),
|
||||||
startTime: timeSlot.time, // This is in "HH:MM" format
|
startTime: timeSlot.time,
|
||||||
endTime: endTime,
|
endTime,
|
||||||
type: staff?.role === "doctor" ? "checkup" : "cleaning",
|
type: staff?.role === "doctor" ? "checkup" : "cleaning",
|
||||||
status: "scheduled",
|
patientId,
|
||||||
title: `Appointment with ${staff?.name}`,
|
});
|
||||||
notes: `Appointment with ${staff?.name}`,
|
|
||||||
staff: Number(staffId), // consistent field name that matches update mutation
|
|
||||||
// if existingStored has patientId (or other fields) merge them below
|
|
||||||
...(existingStored || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure explicit values from this function override stale values from storage
|
|
||||||
// (for example, prefer current slot and staff)
|
|
||||||
const mergedAppointment = {
|
|
||||||
...newAppointment,
|
|
||||||
date: newAppointment.date,
|
|
||||||
startTime: newAppointment.startTime,
|
|
||||||
endTime: newAppointment.endTime,
|
|
||||||
staff: Number(staffId),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Persist merged prefill so the modal/form can read it
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(
|
|
||||||
"newAppointmentData",
|
|
||||||
JSON.stringify(mergedAppointment)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore sessionStorage write failures
|
|
||||||
console.error("Failed to write newAppointmentData to sessionStorage", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For new appointments, set editingAppointment to undefined
|
|
||||||
setEditingAppointment(undefined);
|
setEditingAppointment(undefined);
|
||||||
|
|
||||||
// Open modal
|
|
||||||
setIsAddModalOpen(true);
|
setIsAddModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1898,13 +1826,17 @@ export default function AppointmentsPage() {
|
|||||||
{/* Add/Edit Appointment Modal */}
|
{/* Add/Edit Appointment Modal */}
|
||||||
<AddAppointmentModal
|
<AddAppointmentModal
|
||||||
open={isAddModalOpen}
|
open={isAddModalOpen}
|
||||||
onOpenChange={setIsAddModalOpen}
|
onOpenChange={(open) => {
|
||||||
|
setIsAddModalOpen(open);
|
||||||
|
if (!open) setNewApptPrefill(null);
|
||||||
|
}}
|
||||||
onSubmit={handleAppointmentSubmit}
|
onSubmit={handleAppointmentSubmit}
|
||||||
isLoading={
|
isLoading={
|
||||||
createAppointmentMutation.isPending ||
|
createAppointmentMutation.isPending ||
|
||||||
updateAppointmentMutation.isPending
|
updateAppointmentMutation.isPending
|
||||||
}
|
}
|
||||||
appointment={editingAppointment}
|
appointment={editingAppointment}
|
||||||
|
prefillData={editingAppointment ? null : newApptPrefill}
|
||||||
onDelete={handleDeleteAppointment}
|
onDelete={handleDeleteAppointment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -296,13 +296,20 @@ async def start_tuftssco_claim_run(sid: str, data: dict, url: str):
|
|||||||
s["result"] = result
|
s["result"] = result
|
||||||
s["message"] = "completed"
|
s["message"] = "completed"
|
||||||
|
|
||||||
# Close browser window (session preserved in profile)
|
# Navigate away and minimize — keeps session cookie in memory so next
|
||||||
|
# eligibility check or claim run skips OTP entirely.
|
||||||
try:
|
try:
|
||||||
from dentaquest_browser_manager import get_browser_manager as _gbm
|
bot.driver.get("about:blank")
|
||||||
_gbm().quit_driver()
|
try:
|
||||||
print("[TuftsSCO Claim] Browser closed - session preserved in profile")
|
bot.driver.minimize_window()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
bot.driver.set_window_position(-10000, -10000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("[TuftsSCO Claim] Browser minimized - session preserved for next run")
|
||||||
except Exception as close_err:
|
except Exception as close_err:
|
||||||
print(f"[TuftsSCO Claim] Could not close browser (non-fatal): {close_err}")
|
print(f"[TuftsSCO Claim] Could not minimize browser (non-fatal): {close_err}")
|
||||||
|
|
||||||
asyncio.create_task(_remove_session_later(sid, 60))
|
asyncio.create_task(_remove_session_later(sid, 60))
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -694,13 +694,21 @@ class AutomationDentaQuestEligibilityCheck:
|
|||||||
f.write(pdf_data)
|
f.write(pdf_data)
|
||||||
print(f"[DentaQuest step2] PDF saved: {pdf_path}")
|
print(f"[DentaQuest step2] PDF saved: {pdf_path}")
|
||||||
|
|
||||||
# Close browser after PDF (session preserved in profile)
|
# Navigate away and minimize — keeps session cookie in memory so next
|
||||||
|
# run detects ALREADY_LOGGED_IN and skips OTP entirely.
|
||||||
|
# (OTP is only needed once per app startup, not on every check.)
|
||||||
try:
|
try:
|
||||||
from dentaquest_browser_manager import get_browser_manager
|
self.driver.get("about:blank")
|
||||||
get_browser_manager().quit_driver()
|
try:
|
||||||
print("[DentaQuest step2] Browser closed")
|
self.driver.minimize_window()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.driver.set_window_position(-10000, -10000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("[DentaQuest step2] Browser minimized - session preserved for next run")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[DentaQuest step2] Error closing browser: {e}")
|
print(f"[DentaQuest step2] Error minimizing browser (non-fatal): {e}")
|
||||||
|
|
||||||
output = {
|
output = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|||||||
Reference in New Issue
Block a user