import { useState, useEffect, useRef } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; 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"; import { PROCEDURE_COMBOS } from "@/utils/procedureCombos"; import { findPriceMismatches, } from "@/utils/procedureCombosMapping"; import { useLocation } from "wouter"; import { DeleteConfirmationDialog } from "../ui/deleteDialog"; import { DirectComboButtons, RegularComboButtons, } from "@/components/procedure/procedure-combo-buttons"; export function AppointmentProceduresDialog({ open, onOpenChange, appointmentId, patientId, patient, serviceDate, }) { const { toast } = useToast(); const [, setLocation] = useLocation(); // NPI provider state — stored per-appointment on the procedure rows const [selectedNpiProviderId, setSelectedNpiProviderId] = useState(null); const emptyRow = () => ({ code: "", label: "", fee: "", tooth: "", surface: "" }); const [pendingRows, setPendingRows] = useState([emptyRow(), emptyRow(), emptyRow()]); // reset pending rows when dialog opens useEffect(() => { if (open) setPendingRows([emptyRow(), emptyRow(), emptyRow()]); }, [open]); // inline edit state const [editingId, setEditingId] = useState(null); const [editRow, setEditRow] = useState({}); const [clearAllOpen, setClearAllOpen] = useState(false); // price mismatch dialog const [priceMismatches, setPriceMismatches] = useState([]); const pendingAction = useRef(null); const deriveInsuranceSiteKey = (provider) => { const p = (provider || "").toLowerCase().trim(); if (!p) return ""; if (p.includes("masshealth") || p === "mh" || p === "mass health") return "MH"; if (p.includes("commonwealth care alliance") || p === "cca") return "CCA"; if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA"; if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO"; if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO"; return ""; }; const runWithPriceCheck = (procedureCode, fee, action) => { const siteKey = deriveInsuranceSiteKey(patient?.insuranceProvider); if (!siteKey || !procedureCode.trim() || !fee) { action(); return; } const mismatches = findPriceMismatches([{ procedureCode, totalBilled: fee, procedureDate: "" }], siteKey, patient?.dateOfBirth || "", serviceDate ?? new Date().toISOString().slice(0, 10)); if (mismatches.length === 0) { action(); } else { pendingAction.current = action; setPriceMismatches(mismatches); } }; const savePricesToSchedule = async (mismatches) => { const siteKey = deriveInsuranceSiteKey(patient?.insuranceProvider); await Promise.all(mismatches.map(m => apiRequest("POST", "/api/fee-schedule/update-price", { siteKey, procedureCode: m.procedureCode, price: m.enteredPrice, }))); }; // ── NPI Providers ────────────────────────────────────────────── const { data: npiProviders = [] } = useQuery({ 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({ 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]?.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) => { 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) => { toast({ title: "Error", description: err.message, variant: "destructive" }); }, }); const bulkAddMutation = useMutation({ mutationFn: async (rows) => { const res = await apiRequest("POST", "/api/appointment-procedures/bulk", rows); if (!res.ok) throw new Error("Failed to add procedures"); return res.json(); }, onSuccess: () => { toast({ title: "Procedures saved" }); setPendingRows([emptyRow(), emptyRow(), emptyRow()]); queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); }, }); const deleteMutation = useMutation({ mutationFn: async (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] }); }, }); const clearAllMutation = useMutation({ mutationFn: async () => { 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] }); setClearAllOpen(false); }, onError: (err) => { 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); if (!res.ok) throw new Error("Failed to update"); return res.json(); }, onSuccess: () => { toast({ title: "Updated" }); setEditingId(null); setEditRow({}); queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); }, }); // ── Handlers ─────────────────────────────────────────────────── const handleAddCombo = (comboKey) => { const combo = PROCEDURE_COMBOS[comboKey]; if (!combo) return; const rows = combo.codes.map((code, idx) => ({ appointmentId, patientId, npiProviderId: selectedNpiProviderId ?? null, procedureCode: code, procedureLabel: combo.label, fee: 0, source: "COMBO", comboKey, toothNumber: combo.toothNumbers?.[idx] ?? null, })); bulkAddMutation.mutate(rows); }; const startEdit = (row) => { if (!row.id) return; setEditingId(row.id); setEditRow({ procedureCode: row.procedureCode, procedureLabel: row.procedureLabel, fee: row.fee, toothNumber: row.toothNumber, toothSurface: row.toothSurface, }); }; const cancelEdit = () => { setEditingId(null); setEditRow({}); }; const handleSavePendingRows = () => { const rows = pendingRows .filter((r) => r.code.trim()) .map((r) => ({ appointmentId, patientId, npiProviderId: selectedNpiProviderId ?? null, procedureCode: r.code.trim().toUpperCase(), procedureLabel: r.label || null, fee: r.fee ? Number(r.fee) : 0, toothNumber: r.tooth || null, toothSurface: r.surface || null, source: "MANUAL", })); if (!rows.length) return; bulkAddMutation.mutate(rows); }; const handleDirectClaim = () => { setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`); onOpenChange(false); }; const handleManualClaim = () => { setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`); onOpenChange(false); }; const selectedProvider = npiProviders.find((p) => p.id === selectedNpiProviderId); // ── UI ───────────────────────────────────────────────────────── return ( { if (clearAllOpen) e.preventDefault(); }} onInteractOutside={(e) => { if (clearAllOpen) e.preventDefault(); }}> Appointment Procedures {serviceDate && {serviceDate}} {/* ── Rendering Provider ─────────────────────────────── */}
{selectedProvider && ( ✓ {selectedProvider.providerName} )}
{/* ── Combos ─────────────────────────────────────────── */}
{/* ── Pending Lines ───────────────────────────────────── */}
Add Procedures
{/* Column headers */}
Code
Label
Fee
Tooth
Surface
{pendingRows.map((row, i) => (
setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, code: e.target.value } : r))}/> setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, label: e.target.value } : r))}/> setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, fee: e.target.value } : r))}/> setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, tooth: e.target.value } : r))}/> setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, surface: e.target.value } : r))}/>
))}
{/* ── Procedures List ─────────────────────────────────── */}
Saved Procedures ({procedures.length})
Code
Label
Fee
Tooth
Surface
Edit
Delete
{isLoading &&
Loading...
} {!isLoading && procedures.length === 0 && (
No procedures added yet
)} {procedures.map((p) => (
{editingId === p.id ? (<> setEditRow({ ...editRow, procedureCode: e.target.value })}/> setEditRow({ ...editRow, procedureLabel: e.target.value })}/> setEditRow({ ...editRow, fee: Number(e.target.value) })}/> setEditRow({ ...editRow, toothNumber: e.target.value })}/> setEditRow({ ...editRow, toothSurface: e.target.value })}/>
) : (<>
{p.procedureCode}
{p.procedureLabel}
{p.fee !== null && p.fee !== undefined ? String(p.fee) : ""}
{p.toothNumber}
{p.toothSurface}
)}
))}
{/* ── Footer ─────────────────────────────────────────── */}
setClearAllOpen(false)} onConfirm={() => { setClearAllOpen(false); clearAllMutation.mutate(); }}/> {/* Price mismatch dialog */} 0} onOpenChange={open => { if (!open) setPriceMismatches([]); }}> Save new price to the app?

The following procedure prices differ from the fee schedule:

    {priceMismatches.map(m => (
  • {m.procedureCode} Schedule: ${m.schedulePrice.toFixed(2)} Entered: ${m.enteredPrice.toFixed(2)}
  • ))}

Do you want to save the new price(s) to the fee schedule for future use?

{ setPriceMismatches([]); pendingAction.current?.(); pendingAction.current = null; }}> No { await savePricesToSchedule(priceMismatches); setPriceMismatches([]); pendingAction.current?.(); pendingAction.current = null; }}> Yes
); }