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

@@ -22,6 +22,7 @@ import {
Shield,
FileCheck,
LoaderCircleIcon,
Stethoscope,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Calendar } from "@/components/ui/calendar";
@@ -53,7 +54,6 @@ import {
} from "@/redux/slices/seleniumTaskSlice";
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
import { AppointmentProceduresDialog } from "@/components/appointment-procedures/appointment-procedures-dialog";
// Define types for scheduling
interface TimeSlot {
@@ -94,17 +94,6 @@ export default function AppointmentsPage() {
const { toast } = useToast();
const { user } = useAuth();
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 [editingAppointment, setEditingAppointment] = useState<
Appointment | undefined
@@ -119,10 +108,45 @@ export default function AppointmentsPage() {
const batchTask = useAppSelector(
(state) => state.seleniumTasks.eligibilityBatchCheck
);
const claimBatchTask = useAppSelector(
(state) => state.seleniumTasks.claimBatchCheck
);
const [isCheckingAllElig, setIsCheckingAllElig] = useState(false);
const [processedAppointmentIds, setProcessedAppointmentIds] = useState<
Record<number, boolean>
>({});
const [selectedStaffColumns, setSelectedStaffColumns] = useState<Set<number>>(new Set());
const toggleStaffColumn = (staffId: number) => {
setSelectedStaffColumns((prev) => {
const next = new Set(prev);
if (next.has(staffId)) next.delete(staffId);
else next.add(staffId);
return next;
});
};
const [selectedClaimColumns, setSelectedClaimColumns] = useState<Set<number>>(new Set());
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
const toggleReminderColumn = (staffId: number) => {
setSelectedReminderColumns((prev) => {
const next = new Set(prev);
if (next.has(staffId)) next.delete(staffId);
else next.add(staffId);
return next;
});
};
const toggleClaimColumn = (staffId: number) => {
setSelectedClaimColumns((prev) => {
const next = new Set(prev);
if (next.has(staffId)) next.delete(staffId);
else next.add(staffId);
return next;
});
};
const [, setLocation] = useLocation();
@@ -716,6 +740,10 @@ export default function AppointmentsPage() {
setLocation(`/claims?appointmentId=${appointmentId}`);
};
const handleSelectProcedures = (appointmentId: number) => {
setLocation(`/claims?appointmentId=${appointmentId}&mode=procedures`);
};
const handlePayments = (appointmentId: number) => {
setLocation(`/payments?appointmentId=${appointmentId}`);
};
@@ -742,6 +770,8 @@ export default function AppointmentsPage() {
const dateParam = formattedSelectedDate; // existing variable in your component
const staffIdsParam = `&staffIds=${Array.from(selectedStaffColumns).join(",")}`;
// Start: set redux task status (visible globally)
dispatch(
setTaskStatus({
@@ -757,7 +787,7 @@ export default function AppointmentsPage() {
try {
const res = await apiRequest(
"POST",
`/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}`,
`/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}${staffIdsParam}`,
{}
);
@@ -885,31 +915,86 @@ export default function AppointmentsPage() {
}
};
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 handleClaimForColumn = async () => {
if (!user || selectedClaimColumns.size === 0) 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;
}
const staffIdsParam = Array.from(selectedClaimColumns).join(",");
setProceduresAppointmentId(Number(apt.id));
setProceduresPatientId(apt.patientId);
setProceduresPatient(patient);
setProceduresDialogOpen(true);
dispatch(
setTaskStatus({
key: "claimBatchCheck",
status: "pending",
message: `Submitting claims for selected columns on ${formattedSelectedDate}...`,
})
);
setIsClaimingColumn(true);
try {
const res = await apiRequest(
"POST",
`/api/claims/batch-column?date=${formattedSelectedDate}&staffIds=${staffIdsParam}`,
{}
);
let body: any;
try { body = await res.json(); } catch { body = null; }
if (!res.ok) {
const errMsg = body?.error ?? `Server error ${res.status}`;
dispatch(setTaskStatus({ key: "claimBatchCheck", status: "error", message: `Batch claim failed: ${errMsg}` }));
toast({ title: "Batch claim failed", description: errMsg, variant: "destructive" });
return;
}
const results: any[] = Array.isArray(body?.results) ? body.results : [];
const appointmentMap = new Map<number, Appointment>();
for (const a of appointments) {
if (a && typeof a.id === "number") appointmentMap.set(a.id, a);
}
let queued = 0, skippedNoProcedures = 0, skippedAlreadyClaimed = 0, errCount = 0;
for (const r of results) {
const aptId = Number(r.appointmentId);
const apt = appointmentMap.get(aptId);
const patientName = apt
? patientsFromDay.find((p) => p.id === apt.patientId)
? `${patientsFromDay.find((p) => p.id === apt.patientId)!.firstName ?? ""} ${patientsFromDay.find((p) => p.id === apt.patientId)!.lastName ?? ""}`.trim()
: `patient#${apt.patientId}`
: `appointment#${aptId}`;
if (r.skipped && r.error === "Already claimed") {
skippedAlreadyClaimed++;
} else if (r.skipped) {
skippedNoProcedures++;
} else if (r.error) {
errCount++;
toast({ title: `Skipped: ${patientName}`, description: r.error, variant: "destructive" });
} else if (r.processed) {
queued++;
}
}
queryClient.invalidateQueries({ queryKey: qkAppointmentsDay(formattedSelectedDate) });
dispatch(
setTaskStatus({
key: "claimBatchCheck",
status: errCount > 0 ? "error" : "success",
message: `Claims queued: ${queued}, already claimed: ${skippedAlreadyClaimed}, no procedures: ${skippedNoProcedures}, errors: ${errCount}.`,
})
);
toast({
title: "Claim batch queued",
description: `Queued: ${queued}, already claimed: ${skippedAlreadyClaimed}, no procedures: ${skippedNoProcedures}, errors: ${errCount}.`,
variant: errCount > 0 ? "destructive" : "default",
});
} catch (err: any) {
dispatch(setTaskStatus({ key: "claimBatchCheck", status: "error", message: `Batch claim error: ${err?.message ?? String(err)}` }));
toast({ title: "Batch claim failed", description: err?.message ?? String(err), variant: "destructive" });
} finally {
setIsClaimingColumn(false);
}
};
return (
@@ -920,6 +1005,12 @@ export default function AppointmentsPage() {
show={batchTask.show}
onClear={() => dispatch(clearTaskStatus("eligibilityBatchCheck"))}
/>
<SeleniumTaskBanner
status={claimBatchTask.status}
message={claimBatchTask.message}
show={claimBatchTask.show}
onClear={() => dispatch(clearTaskStatus("claimBatchCheck"))}
/>
<div className="container mx-auto">
<div className="flex justify-between items-center mb-6">
@@ -932,7 +1023,7 @@ export default function AppointmentsPage() {
</p>
</div>
<div className="flex justify-between gap-2">
<div className="flex items-center gap-3 flex-wrap">
<Button
onClick={() => {
setEditingAppointment(undefined);
@@ -944,30 +1035,105 @@ export default function AppointmentsPage() {
New Appointment
</Button>
<Button
onClick={() => handleCheckAllEligibilities()}
disabled={isLoading || isCheckingAllElig}
>
{isCheckingAllElig ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Checking...
</>
) : (
<>
<Shield className="h-4 w-4 mr-2" />
Check all eligibilities
</>
)}
</Button>
<Button disabled={true}>
<Shield className="h-4 w-4 mr-2" />
Claim Column A
</Button>
<Button disabled={true}>
<Shield className="h-4 w-4 mr-2" />
Claim Column B
</Button>
{/* Check Eligibility for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleCheckAllEligibilities()}
disabled={isLoading || isCheckingAllElig || selectedStaffColumns.size === 0}
size="sm"
>
{isCheckingAllElig ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
Checking...
</>
) : (
<>
<Shield className="h-4 w-4 mr-1" />
Check Eligibility for Column
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedStaffColumns.has(Number(staff.id))}
onChange={() => toggleStaffColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{String.fromCharCode(65 + index)}
</span>
</label>
))}
</div>
{/* Claim for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleClaimForColumn()}
disabled={isLoading || isClaimingColumn || selectedClaimColumns.size === 0}
size="sm"
>
{isClaimingColumn ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
Submitting...
</>
) : (
<>
<FileCheck className="h-4 w-4 mr-1" />
Claim for Column
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedClaimColumns.has(Number(staff.id))}
onChange={() => toggleClaimColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{String.fromCharCode(65 + index)}
</span>
</label>
))}
</div>
{/* Text Reminder for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
disabled={true}
size="sm"
>
Text Reminder for Column
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedReminderColumns.has(Number(staff.id))}
onChange={() => toggleReminderColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{String.fromCharCode(65 + index)}
</span>
</label>
))}
</div>
</div>
</div>
@@ -1009,13 +1175,13 @@ export default function AppointmentsPage() {
</span>
</Item>
{/* Check Eligibility */}
{/* Select Procedures */}
<Item
onClick={({ props }) => handleCheckClaimStatus(props.appointmentId)}
onClick={({ props }) => handleSelectProcedures(props.appointmentId)}
>
<span className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Claim Status
<span className="flex items-center gap-2 text-purple-600">
<Stethoscope className="h-4 w-4" />
Select Procedures
</span>
</Item>
@@ -1025,7 +1191,7 @@ export default function AppointmentsPage() {
>
<span className="flex items-center gap-2">
<FileCheck className="h-4 w-4" />
Claims / PreAuth
Claims/PreAuth
</span>
</Item>
@@ -1045,16 +1211,6 @@ export default function AppointmentsPage() {
</span>
</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 */}
<Item onClick={({ props }) => handleClinicNotes(props.appointmentId)}>
<span className="flex items-center gap-2 text-yellow-600">
@@ -1062,6 +1218,16 @@ export default function AppointmentsPage() {
Clinic Notes
</span>
</Item>
{/* Claim Status */}
<Item
onClick={({ props }) => handleCheckClaimStatus(props.appointmentId)}
>
<span className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Claim Status
</span>
</Item>
</Menu>
{/* Main Content */}
@@ -1182,24 +1348,6 @@ export default function AppointmentsPage() {
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
isOpen={confirmDeleteState.open}
onConfirm={handleConfirmDelete}

View File

@@ -321,11 +321,27 @@ export default function ClaimsPage() {
}
};
// 3. create claim.
const handleClaimSubmit = (claimData: any): Promise<Claim> => {
return createClaimMutation.mutateAsync(claimData).then((data) => {
// 3. create or update claim (update when claimId is present)
const handleClaimSubmit = async (claimData: any): Promise<Claim> => {
const { isDraft, claimId, uploadedFiles: _uf, ...cleanData } = claimData;
if (claimId) {
// Update existing saved claim (PUT never creates a Payment)
const res = await apiRequest("PUT", `/api/claims/${claimId}`, cleanData);
const data = await res.json();
if (!res.ok) throw new Error(data?.message || "Failed to update claim");
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
if (!isDraft) toast({ title: "Claim updated successfully", variant: "default" });
return data;
});
}
// New claim: draft saves skip Payment creation
const url = isDraft ? "/api/claims/?draft=true" : "/api/claims/";
const res = await apiRequest("POST", url, cleanData);
const data = await res.json();
if (!res.ok) throw new Error(data?.message || "Failed to save claim");
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
return data;
};
// 4. handle selenium sybmiting Mass Health claim
@@ -579,6 +595,7 @@ export default function ClaimsPage() {
patientId={selectedPatientId}
appointmentId={selectedAppointmentId ?? undefined}
autoSubmit={mode === "direct"}
proceduresOnly={mode === "procedures"}
onClose={closeClaim}
onSubmit={handleClaimSubmit}
onHandleAppointmentSubmit={handleAppointmentSubmit}