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

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
Dialog,
@@ -9,6 +9,13 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Trash2, Plus, Save, X } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
@@ -17,7 +24,7 @@ import {
CODE_MAP,
getPriceForCodeWithAgeFromMap,
} from "@/utils/procedureCombosMapping";
import { Patient, AppointmentProcedure } from "@repo/db/types";
import { Patient, AppointmentProcedure, NpiProvider } from "@repo/db/types";
import { useLocation } from "wouter";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import {
@@ -31,6 +38,7 @@ interface Props {
appointmentId: number;
patientId: number;
patient: Patient;
serviceDate?: string;
}
export function AppointmentProceduresDialog({
@@ -39,54 +47,88 @@ export function AppointmentProceduresDialog({
appointmentId,
patientId,
patient,
serviceDate,
}: Props) {
const { toast } = useToast();
const [, setLocation] = useLocation();
// -----------------------------
// state for manual add
// -----------------------------
// NPI provider state — stored per-appointment on the procedure rows
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState<number | null>(null);
// manual add row state
const [manualCode, setManualCode] = useState("");
const [manualLabel, setManualLabel] = useState("");
const [manualFee, setManualFee] = useState("");
const [manualTooth, setManualTooth] = useState("");
const [manualSurface, setManualSurface] = useState("");
// -----------------------------
// state for inline edit
// -----------------------------
// inline edit state
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,
// ── 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();
},
);
enabled: open,
});
// ── 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,
});
// Sync NPI provider from saved procedures when they load
useEffect(() => {
if (!procedures.length) return;
const saved = (procedures[0] as any)?.npiProviderId ?? null;
if (saved != null) setSelectedNpiProviderId(Number(saved));
}, [procedures]);
// Default NPI provider to Mary Scannell / first when none saved yet
useEffect(() => {
if (selectedNpiProviderId != null || !npiProviders.length) return;
const mary = npiProviders.find((p) => p.providerName.toLowerCase() === "mary scannell");
setSelectedNpiProviderId((mary ?? npiProviders[0])?.id ?? null);
}, [npiProviders, selectedNpiProviderId]);
// ── Mutations ──────────────────────────────────────────────────
const setNpiMutation = useMutation({
mutationFn: async (npiProviderId: number | null) => {
const res = await apiRequest(
"PUT",
`/api/appointment-procedures/set-npi-provider/${appointmentId}`,
{ npiProviderId },
);
if (!res.ok) throw new Error("Failed to update provider");
},
onSuccess: () => {
toast({ title: "Rendering provider saved" });
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
},
onError: (err: any) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
// -----------------------------
// mutations
// -----------------------------
const addManualMutation = useMutation({
mutationFn: async () => {
const payload = {
appointmentId,
patientId,
npiProviderId: selectedNpiProviderId ?? null,
procedureCode: manualCode,
procedureLabel: manualLabel || null,
fee: manualFee ? Number(manualFee) : null,
@@ -94,101 +136,63 @@ export function AppointmentProceduresDialog({
toothSurface: manualSurface || null,
source: "MANUAL",
};
const res = await apiRequest(
"POST",
"/api/appointment-procedures",
payload,
);
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],
});
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",
});
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,
);
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],
});
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await apiRequest(
"DELETE",
`/api/appointment-procedures/${id}`,
);
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],
});
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
},
});
const clearAllMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest(
"DELETE",
`/api/appointment-procedures/clear/${appointmentId}`,
);
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],
});
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
setClearAllOpen(false);
},
onError: (err: any) => {
toast({
title: "Error",
description: err.message ?? "Failed to clear procedures",
variant: "destructive",
});
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,
);
const res = await apiRequest("PUT", `/api/appointment-procedures/${editingId}`, editRow);
if (!res.ok) throw new Error("Failed to update");
return res.json();
},
@@ -196,55 +200,42 @@ export function AppointmentProceduresDialog({
toast({ title: "Updated" });
setEditingId(null);
setEditRow({});
queryClient.invalidateQueries({
queryKey: ["appointment-procedures", appointmentId],
});
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
},
});
// -----------------------------
// handlers
// -----------------------------
// ── Handlers ───────────────────────────────────────────────────
const handleAddCombo = (comboKey: string) => {
const combo = PROCEDURE_COMBOS[comboKey];
if (!combo || !patient?.dateOfBirth) return;
const serviceDate = new Date();
const dob = patient.dateOfBirth;
const ref = new Date();
const birth = new Date(dob as any);
let age = ref.getFullYear() - birth.getFullYear();
const hadBirthday =
ref.getMonth() > birth.getMonth() ||
(ref.getMonth() === birth.getMonth() && ref.getDate() >= birth.getDate());
if (!hadBirthday) age -= 1;
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,
};
});
const rows = combo.codes.map((code: string, idx: number) => ({
appointmentId,
patientId,
npiProviderId: selectedNpiProviderId ?? null,
procedureCode: code,
procedureLabel: combo.label,
fee: getPriceForCodeWithAgeFromMap(CODE_MAP, code, age).toNumber(),
source: "COMBO",
comboKey,
toothNumber: combo.toothNumbers?.[idx] ?? null,
}));
bulkAddMutation.mutate(rows);
};
const startEdit = (row: AppointmentProcedure) => {
if (!row.id) return;
setEditingId(row.id);
setEditRow({
procedureCode: row.procedureCode,
@@ -255,10 +246,7 @@ export function AppointmentProceduresDialog({
});
};
const cancelEdit = () => {
setEditingId(null);
setEditRow({});
};
const cancelEdit = () => { setEditingId(null); setEditRow({}); };
const handleDirectClaim = () => {
setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`);
@@ -270,256 +258,146 @@ export function AppointmentProceduresDialog({
onOpenChange(false);
};
// -----------------------------
// UI
// -----------------------------
const selectedProvider = npiProviders.find((p) => p.id === selectedNpiProviderId);
// ── 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
}
}}
onPointerDownOutside={(e) => { if (clearAllOpen) e.preventDefault(); }}
onInteractOutside={(e) => { if (clearAllOpen) e.preventDefault(); }}
>
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
Appointment Procedures
{serviceDate && <span className="ml-3 text-base font-normal text-muted-foreground">{serviceDate}</span>}
</DialogTitle>
</DialogHeader>
{/* ================= COMBOS ================= */}
<div className="space-y-8 pointer-events-auto">
<DirectComboButtons
onDirectCombo={(comboKey) => {
handleAddCombo(comboKey);
}}
/>
<RegularComboButtons
onRegularCombo={(comboKey) => {
handleAddCombo(comboKey);
}}
/>
{/* ── Rendering Provider ─────────────────────────────── */}
<div className="flex items-end gap-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex-1">
<Label className="text-sm font-medium text-blue-800">Rendering Provider (NPI)</Label>
<Select
value={selectedNpiProviderId?.toString() ?? ""}
onValueChange={(v) => setSelectedNpiProviderId(v ? Number(v) : null)}
>
<SelectTrigger className="mt-1 bg-white">
<SelectValue placeholder="Select NPI Provider" />
</SelectTrigger>
<SelectContent>
{npiProviders.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.npiNumber} {p.providerName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
className="mb-0.5"
onClick={() => setNpiMutation.mutate(selectedNpiProviderId)}
disabled={setNpiMutation.isPending || !procedures.length}
>
<Save className="h-4 w-4 mr-1" />
Set for All
</Button>
{selectedProvider && (
<span className="text-sm text-blue-700 mb-1 whitespace-nowrap">
{selectedProvider.providerName}
</span>
)}
</div>
{/* ================= MANUAL ADD ================= */}
{/* ── Combos ─────────────────────────────────────────── */}
<div className="space-y-8 pointer-events-auto">
<DirectComboButtons onDirectCombo={handleAddCombo} />
<RegularComboButtons onRegularCombo={handleAddCombo} />
</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"
/>
<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"
/>
<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"
/>
<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"
/>
<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"
/>
<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}
>
<Button size="sm" onClick={() => addManualMutation.mutate()} disabled={!manualCode || addManualMutation.isPending}>
<Plus className="h-4 w-4 mr-1" />
Add Procedure
</Button>
</div>
</div>
{/* ================= LIST ================= */}
{/* ── Procedures 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)}
>
<div className="text-sm font-semibold">Saved Procedures ({procedures.length})</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>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 && <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>
<div className="p-4 text-sm text-muted-foreground">No procedures added yet</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"
>
<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,
})
}
/>
<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>
<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>
<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-[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>
<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!)}
>
<Button size="icon" variant="ghost" onClick={() => deleteMutation.mutate(p.id!)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
@@ -530,7 +408,7 @@ export function AppointmentProceduresDialog({
</div>
</div>
{/* ================= FOOTER ================= */}
{/* ── Footer ─────────────────────────────────────────── */}
<div className="flex justify-between items-center gap-2 mt-8 pt-4 border-t">
<div className="flex gap-2">
<Button
@@ -540,7 +418,6 @@ export function AppointmentProceduresDialog({
>
Direct Claim
</Button>
<Button
variant="outline"
className="border-blue-500 text-blue-600 hover:bg-blue-50"
@@ -550,10 +427,7 @@ export function AppointmentProceduresDialog({
Manual Claim
</Button>
</div>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
</div>
</DialogContent>
@@ -561,10 +435,7 @@ export function AppointmentProceduresDialog({
isOpen={clearAllOpen}
entityName="all procedures for this appointment"
onCancel={() => setClearAllOpen(false)}
onConfirm={() => {
setClearAllOpen(false);
clearAllMutation.mutate();
}}
onConfirm={() => { setClearAllOpen(false); clearAllMutation.mutate(); }}
/>
</Dialog>
);

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>

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}