feat: Select Procedures flow, batch-column NPI provider fix, auto PDF save

- Add 'Select Procedures' right-click option on appointment page (separate from Claims/PreAuth)
- Select Procedures form saves CDT codes + NPI provider to AppointmentProcedure storage
- Remove Save button from insurance claim form; Claims/PreAuth opens for insurance submission only
- Claims/PreAuth auto-prefills from saved procedures including NPI provider
- Batch-column: procedures npiProviderId takes priority over stale claim npiProviderId
- Batch-column: auto-save PDF to patient Documents after successful submission (no socket needed)
- Add npiProviderId column to AppointmentProcedure table (prisma db push)
- Fix 'invalid db creation invocation': guard staffId, npiProviderId, procedureDate as Date object, totalBilled NaN guard
- Add full error logging to batch-column catch block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-04-27 00:25:24 -04:00
parent a279a3e7c1
commit 3e899376c3
838 changed files with 28488 additions and 773 deletions

View File

@@ -59,11 +59,14 @@ import {
DirectComboButtons,
RegularComboButtons,
} from "@/components/procedure/procedure-combo-buttons";
import { Switch } from "@/components/ui/switch";
interface ClaimFormProps {
patientId: number;
appointmentId?: number;
autoSubmit?: boolean;
/** When true: form saves to AppointmentProcedure (Select Procedures flow), shows only Save button */
proceduresOnly?: boolean;
onSubmit: (data: ClaimFormData) => Promise<Claim>;
onHandleAppointmentSubmit: (
appointmentData: InsertAppointment | UpdateAppointment,
@@ -78,6 +81,7 @@ export function ClaimForm({
patientId,
appointmentId,
autoSubmit,
proceduresOnly = false,
onHandleAppointmentSubmit,
onHandleUpdatePatient,
onHandleForMHSeleniumClaim,
@@ -90,8 +94,16 @@ export function ClaimForm({
const [prefillDone, setPrefillDone] = useState(false);
const autoSubmittedRef = useRef(false);
// When an existing claim is loaded for the appointment, store its ID so
// the form submits an update instead of creating a new claim.
const [existingClaimId, setExistingClaimId] = useState<number | null>(null);
const [directSubmitEnabled, setDirectSubmitEnabled] = useState(false);
const [patient, setPatient] = useState<Patient | null>(null);
// staffId from the appointment column — used for claim creation, not shown in UI
const [appointmentStaffId, setAppointmentStaffId] = useState<number | null>(null);
// npiProviderId loaded from AppointmentProcedure (2b) — restored to form when npiProviders load
const [savedProcNpiId, setSavedProcNpiId] = useState<number | null>(null);
// Query patient based on given patient id
const {
@@ -211,6 +223,12 @@ export function ClaimForm({
}
const appointment = await res.json();
// Capture the column staffId from the appointment
if (!cancelled && appointment?.staffId) {
setAppointmentStaffId(Number(appointment.staffId));
}
// appointment.date is expected to be either "YYYY-MM-DD" or an ISO string.
const rawDate = appointment?.date ?? appointment?.day ?? "";
if (!rawDate) return;
@@ -256,9 +274,104 @@ export function ClaimForm({
//
// 2. effect - prefill proceduresCodes (if exists for appointment) into serviceLines
// 2a. Load existing saved claim for this appointment (if any).
// Skipped in proceduresOnly mode — that mode always reads from AppointmentProcedure.
useEffect(() => {
if (!appointmentId) return;
if (proceduresOnly) return;
let cancelled = false;
(async () => {
try {
const res = await apiRequest(
"GET",
`/api/claims/by-appointment/${appointmentId}`,
);
if (!res.ok) return; // 404 = no existing claim, that's fine
const claim = await res.json();
if (cancelled || !claim?.id) return;
setExistingClaimId(claim.id);
// Restore service date
const rawDate = claim.serviceDate ?? "";
const claimDate = rawDate
? String(rawDate).split("T")[0] ?? ""
: "";
if (claimDate) {
try {
setServiceDateValue(parseLocalDate(claimDate));
setServiceDate(claimDate);
} catch {}
}
// Restore service lines
const mappedLines = (claim.serviceLines ?? []).map((sl: any) => ({
procedureCode: sl.procedureCode ?? "",
procedureDate: sl.procedureDate
? String(sl.procedureDate).split("T")[0]
: claimDate,
quad: sl.quad ?? "",
arch: sl.arch ?? "",
toothNumber: sl.toothNumber ?? "",
toothSurface: sl.toothSurface ?? "",
totalBilled: new Decimal(Number(sl.totalBilled ?? 0)),
totalAdjusted: new Decimal(Number(sl.totalAdjusted ?? 0)),
totalPaid: new Decimal(Number(sl.totalPaid ?? 0)),
}));
setForm((prev) => ({
...prev,
claimId: claim.id,
serviceDate: claimDate || prev.serviceDate,
serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines,
remarks: claim.remarks ?? "",
missingTeethStatus: (claim.missingTeethStatus as MissingTeethStatus) ?? "No_missing",
missingTeeth: (claim.missingTeeth as Record<string, "X" | "O">) ?? {},
insuranceProvider: claim.insuranceProvider ?? "",
...(claim.staffId ? { staffId: claim.staffId } : {}),
claimFiles: claim.claimFiles ?? [],
}));
// Restore staff selection
if (claim.staffId && staffMembersRaw.length > 0) {
const matchedStaff = staffMembersRaw.find(
(s) => Number(s.id) === Number(claim.staffId),
);
if (matchedStaff) setStaff(matchedStaff);
}
// Restore NPI provider selection
if ((claim as any).npiProviderId && npiProviders.length > 0) {
const matchedNpi = npiProviders.find(
(p) => Number(p.id) === Number((claim as any).npiProviderId),
);
if (matchedNpi) {
setForm((prev) => ({
...prev,
npiProvider: {
npiNumber: matchedNpi.npiNumber,
providerName: matchedNpi.providerName,
},
}));
}
}
setPrefillDone(true);
} catch (err) {
// no existing claim — silently continue
}
})();
return () => { cancelled = true; };
}, [appointmentId]);
// 2b. Prefill procedures from AppointmentProcedure records.
// Skipped when an existing claim was already loaded above.
useEffect(() => {
if (!appointmentId) return;
if (existingClaimId) return; // existing claim takes priority
let cancelled = false;
@@ -291,6 +404,20 @@ export function ClaimForm({
serviceLines: mappedLines,
}));
// Restore NPI provider from saved procedures
if (data.npiProviderId) {
const npiId = Number(data.npiProviderId);
setSavedProcNpiId(npiId);
// Apply immediately if providers are already loaded
const matched = npiProviders.find((p) => p.id === npiId);
if (matched) {
setForm((prev) => ({
...prev,
npiProvider: { npiNumber: matched.npiNumber, providerName: matched.providerName },
}));
}
}
setPrefillDone(true);
} catch (err) {
console.error("Failed to prefill procedures:", err);
@@ -300,7 +427,20 @@ export function ClaimForm({
return () => {
cancelled = true;
};
}, [appointmentId, serviceDate]);
}, [appointmentId, serviceDate, existingClaimId]);
// Restore NPI provider from saved procedures when npiProviders list loads after 2b
useEffect(() => {
if (!savedProcNpiId || !npiProviders.length) return;
if (form.npiProvider?.npiNumber) return; // already set
const matched = npiProviders.find((p) => p.id === savedProcNpiId);
if (matched) {
setForm((prev) => ({
...prev,
npiProvider: { npiNumber: matched.npiNumber, providerName: matched.providerName },
}));
}
}, [savedProcNpiId, npiProviders]);
// Update service date when calendar date changes
const onServiceDateChange = (date: Date | undefined) => {
@@ -421,7 +561,7 @@ export function ClaimForm({
patientId: patientId || 0,
appointmentId: 0,
userId: Number(user?.id),
staffId: Number(staff?.id),
staffId: appointmentStaffId ?? Number(staff?.id),
patientName: `${patient?.firstName} ${patient?.lastName}`.trim(),
memberId: patient?.insuranceId ?? "",
dateOfBirth: normalizeToIsoDateString(patient?.dateOfBirth),
@@ -602,7 +742,7 @@ export function ClaimForm({
const appointmentData = {
patientId: patientId,
date: serviceDate,
staffId: staff?.id,
staffId: appointmentStaffId ?? staff?.id,
};
const created = await onHandleAppointmentSubmit(appointmentData);
@@ -648,7 +788,7 @@ export function ClaimForm({
const createdClaim = await onSubmit({
...formToCreateClaim,
serviceLines: filteredServiceLines,
staffId: Number(staff?.id),
staffId: appointmentStaffId ?? Number(staff?.id),
patientId: patientId,
insuranceProvider: "MassHealth",
appointmentId: appointmentIdToUse!,
@@ -660,7 +800,7 @@ export function ClaimForm({
...f,
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
serviceLines: filteredServiceLines,
staffId: Number(staff?.id),
staffId: appointmentStaffId ?? Number(staff?.id),
npiProvider: f.npiProvider,
patientId: patientId,
insuranceProvider: "Mass Health",
@@ -741,7 +881,7 @@ export function ClaimForm({
...f,
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
serviceLines: filteredServiceLines,
staffId: Number(staff?.id),
staffId: appointmentStaffId ?? Number(staff?.id),
npiProvider: f.npiProvider,
patientId: patientId,
insuranceProvider: "Mass Health",
@@ -791,7 +931,7 @@ export function ClaimForm({
const appointmentData = {
patientId: patientId,
date: serviceDate,
staffId: staff?.id,
staffId: appointmentStaffId ?? staff?.id,
};
const created = await onHandleAppointmentSubmit(appointmentData);
@@ -821,7 +961,7 @@ export function ClaimForm({
// 3. Create Claim(if not)
// Filter out empty service lines (empty procedureCode)
const { uploadedFiles, insuranceSiteKey, ...formToCreateClaim } = form;
const { uploadedFiles, insuranceSiteKey, npiProvider: _npi, ...formToCreateClaim } = form;
// build claimFiles metadata from uploadedFiles (only filename + mimeType)
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
@@ -832,7 +972,7 @@ export function ClaimForm({
const createdClaim = await onSubmit({
...formToCreateClaim,
serviceLines: filteredServiceLines,
staffId: Number(staff?.id),
staffId: appointmentStaffId ?? Number(staff?.id),
patientId: patientId,
insuranceProvider: "MassHealth",
appointmentId: appointmentIdToUse!,
@@ -843,6 +983,137 @@ export function ClaimForm({
onClose();
};
const uploadAttachmentsToLocalFolder = async (files: File[]): Promise<ClaimFileMeta[]> => {
if (!files.length) return [];
const patientName = patient?.firstName && patient?.lastName
? `${patient.firstName} ${patient.lastName}`
: patient?.firstName ?? `patient-${patientId}`;
const formData = new FormData();
formData.append("patientName", patientName);
files.forEach((f) => formData.append("files", f));
const res = await apiRequest("POST", "/api/claims/upload-attachments", formData);
const data = await res.json();
return (data.data ?? []) as ClaimFileMeta[];
};
const handleSave = async () => {
const filteredServiceLines = form.serviceLines.filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
toast({
title: "No procedure codes",
description: "Please add at least one procedure code before saving.",
variant: "destructive",
});
return;
}
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (missingFields.length > 0) {
toast({
title: "Missing Required Fields",
description: `Please fill out: ${missingFields.join(", ")}`,
variant: "destructive",
});
return;
}
let appointmentIdToUse = appointmentId;
if (appointmentIdToUse == null) {
const appointmentData = {
patientId: patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
};
const created = await onHandleAppointmentSubmit(appointmentData);
if (typeof created === "number" && created > 0) {
appointmentIdToUse = created;
} else if (created && typeof (created as any).id === "number") {
appointmentIdToUse = (created as any).id;
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToSave } = form;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(uploadedFiles)
: [];
// Find the npiProviderId matching the currently selected NPI provider
const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
: null;
try {
await onSubmit({
...formToSave,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId: patientId,
insuranceProvider: "MassHealth",
appointmentId: appointmentIdToUse!,
claimFiles: claimFilesMeta,
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
isDraft: true,
});
toast({ title: "Saved", description: "Claim saved successfully." });
} catch (err: any) {
toast({
title: "Save failed",
description: err?.message ?? "Failed to save claim.",
variant: "destructive",
});
}
};
// Saves CDT codes + NPI provider to AppointmentProcedure (proceduresOnly mode)
const handleProceduresSave = async () => {
if (!appointmentId || !patientId) {
toast({ title: "Missing appointment", description: "Cannot save without an appointment.", variant: "destructive" });
return;
}
const filteredServiceLines = form.serviceLines.filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
toast({ title: "No procedure codes", description: "Please add at least one procedure code.", variant: "destructive" });
return;
}
const selectedNpiProviderId = form.npiProvider?.npiNumber
? (npiProviders.find((p) => p.npiNumber === form.npiProvider!.npiNumber)?.id ?? null)
: null;
try {
const res = await apiRequest("POST", "/api/appointment-procedures/save-for-appointment", {
appointmentId,
patientId,
npiProviderId: selectedNpiProviderId,
procedures: filteredServiceLines.map((l) => ({
procedureCode: l.procedureCode,
fee: Number(l.totalBilled) || null,
toothNumber: l.toothNumber || null,
toothSurface: l.toothSurface || null,
})),
});
const data = await res.json();
if (!data.success) throw new Error("Failed to save procedures");
toast({ title: "Procedures saved", description: `${data.count} procedure(s) saved.` });
onClose();
} catch (err: any) {
toast({ title: "Save failed", description: err?.message ?? "Failed to save procedures.", variant: "destructive" });
}
};
// for direct combo button.
const applyComboAndThenMH = async (
comboId: keyof typeof PROCEDURE_COMBOS,
@@ -1020,47 +1291,6 @@ export function ClaimForm({
/>
</PopoverContent>
</Popover>
{/* Treating doctor */}
<Label className="flex items-center ml-2">
Treating Doctor
</Label>
<Select
value={staff?.id?.toString() || ""}
onValueChange={(id) => {
const selected = staffMembersRaw.find(
(member) => member.id?.toString() === id,
);
if (selected) {
setStaff(selected);
setForm((prev) => ({
...prev,
staffId: Number(selected.id),
}));
}
}}
>
<SelectTrigger className="w-36">
<SelectValue
placeholder={staff ? staff.name : "Select Staff"}
/>
</SelectTrigger>
<SelectContent>
{staffMembersRaw.map((member) => {
if (member.id === undefined) return null;
return (
<SelectItem
key={member.id}
value={member.id.toString()}
>
{member.name}
</SelectItem>
);
})}
</SelectContent>
</Select>
{/* Rendering Npi Provider */}
<Label className="flex items-center ml-2">
Rendering Provider
@@ -1108,10 +1338,33 @@ export function ClaimForm({
</div>
</div>
<div className="flex items-center gap-3 mb-2">
<Switch
id="direct-submit-toggle"
checked={directSubmitEnabled}
onCheckedChange={setDirectSubmitEnabled}
/>
<Label htmlFor="direct-submit-toggle" className="text-sm cursor-pointer select-none">
Direct Submission {directSubmitEnabled ? <span className="text-green-600 font-semibold">ON</span> : <span className="text-muted-foreground">OFF</span>}
</Label>
</div>
<DirectComboButtons
onDirectCombo={(comboKey) =>
applyComboAndThenMH(comboKey as any)
}
onDirectCombo={(comboKey) => {
if (directSubmitEnabled) {
applyComboAndThenMH(comboKey as any);
} else {
setForm((prev) => {
const next = applyComboToForm(
prev,
comboKey as any,
patient?.dateOfBirth ?? "",
{ replaceAll: false, lineDate: prev.serviceDate },
);
setTimeout(() => scrollToLine(0), 0);
return next;
});
}
}}
/>
</div>
@@ -1464,37 +1717,51 @@ export function ClaimForm({
{/* Insurance Carriers */}
<div className="pt-6">
<h3 className="text-xl font-semibold mb-4 text-center">
Insurance Carriers
{proceduresOnly ? "Save Procedures" : "Insurance Carriers"}
</h3>
<div className="flex justify-between">
<Button
className="w-32"
variant="secondary"
onClick={() => handleMHSubmit()}
>
MH
</Button>
<Button
className="w-32"
variant="secondary"
onClick={() => handleMHPreAuth()}
>
MH PreAuth
</Button>
<Button
className="w-32"
variant="secondary"
onClick={handleAddService}
>
Add Service
</Button>
<Button className="w-32" variant="outline">
Delta MA
</Button>
<Button className="w-32" variant="outline">
Others
</Button>
</div>
{proceduresOnly ? (
/* ── Select Procedures mode: Save only ── */
<div className="flex justify-center">
<Button
className="w-48"
variant="default"
onClick={handleProceduresSave}
>
Save Procedures
</Button>
</div>
) : (
/* ── Insurance Claim mode: submit buttons, no Save ── */
<div className="flex justify-between">
<Button
className="w-32"
variant="secondary"
onClick={() => handleMHSubmit()}
>
MH
</Button>
<Button
className="w-32"
variant="secondary"
onClick={() => handleMHPreAuth()}
>
MH PreAuth
</Button>
<Button
className="w-32"
variant="secondary"
onClick={handleAddService}
>
Add Service
</Button>
<Button className="w-32" variant="outline">
Delta MA
</Button>
<Button className="w-32" variant="outline">
Others
</Button>
</div>
)}
</div>
</div>
</CardContent>