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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user