- Add all new Frontend source files (pages, components, hooks, utils) - Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py - Add install-steps-5-13.sh setup script - Update .gitignore to exclude runtime/sensitive data (backups, uploads, chat-history, keys, downloads, generated .d.ts files) while keeping folders - Add .gitkeep to preserve empty runtime folders in git Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
418 lines
22 KiB
JavaScript
418 lines
22 KiB
JavaScript
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 (<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(); }} 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>
|
|
|
|
{/* ── 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>
|
|
|
|
{/* ── Combos ─────────────────────────────────────────── */}
|
|
<div className="space-y-8 pointer-events-auto">
|
|
<DirectComboButtons onDirectCombo={handleAddCombo}/>
|
|
<RegularComboButtons onRegularCombo={handleAddCombo}/>
|
|
</div>
|
|
|
|
{/* ── Pending Lines ───────────────────────────────────── */}
|
|
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-2">
|
|
<div className="font-medium text-sm mb-3">Add Procedures</div>
|
|
{/* Column headers */}
|
|
<div className="grid grid-cols-[100px_1fr_90px_80px_80px_36px] gap-2 px-1 text-xs font-semibold text-muted-foreground">
|
|
<div>Code</div><div>Label</div><div>Fee</div><div>Tooth</div><div>Surface</div><div />
|
|
</div>
|
|
{pendingRows.map((row, i) => (<div key={i} className="grid grid-cols-[100px_1fr_90px_80px_80px_36px] gap-2 items-center">
|
|
<Input placeholder="D0120" value={row.code} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, code: e.target.value } : r))}/>
|
|
<Input placeholder="Exam" value={row.label} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, label: e.target.value } : r))}/>
|
|
<Input type="number" placeholder="0.00" value={row.fee} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, fee: e.target.value } : r))}/>
|
|
<Input placeholder="14" value={row.tooth} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, tooth: e.target.value } : r))}/>
|
|
<Input placeholder="MO" value={row.surface} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, surface: e.target.value } : r))}/>
|
|
<button type="button" className="p-1 rounded hover:bg-red-50" onClick={() => setPendingRows((prev) => prev.filter((_, idx) => idx !== i))}>
|
|
<Trash2 className="h-4 w-4 text-red-400 hover:text-red-600"/>
|
|
</button>
|
|
</div>))}
|
|
<div className="flex justify-between items-center pt-2">
|
|
<Button size="sm" variant="outline" onClick={() => setPendingRows((prev) => [...prev, emptyRow()])}>
|
|
<Plus className="h-4 w-4 mr-1"/>
|
|
Add Line
|
|
</Button>
|
|
<Button size="sm" onClick={handleSavePendingRows} disabled={!pendingRows.some((r) => r.code.trim()) || bulkAddMutation.isPending}>
|
|
<Save className="h-4 w-4 mr-1"/>
|
|
Save Lines
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Procedures List ─────────────────────────────────── */}
|
|
<div className="mt-8 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<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">
|
|
<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>
|
|
|
|
{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 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">
|
|
{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 })}/>
|
|
<div className="flex justify-center">
|
|
<Button size="icon" variant="ghost" onClick={() => runWithPriceCheck(editRow.procedureCode || "", Number(editRow.fee), () => 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>
|
|
</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>
|
|
</div>
|
|
<div className="flex justify-center">
|
|
<Button size="icon" variant="ghost" onClick={() => deleteMutation.mutate(p.id)}>
|
|
<Trash2 className="h-4 w-4 text-red-500"/>
|
|
</Button>
|
|
</div>
|
|
</>)}
|
|
</div>))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Footer ─────────────────────────────────────────── */}
|
|
<div className="flex justify-between items-center gap-2 mt-8 pt-4 border-t">
|
|
<div className="flex gap-2">
|
|
<Button className="bg-green-600 hover:bg-green-700" disabled={!procedures.length} onClick={handleDirectClaim}>
|
|
Direct Claim
|
|
</Button>
|
|
<Button variant="outline" className="border-blue-500 text-blue-600 hover:bg-blue-50" disabled={!procedures.length} onClick={handleManualClaim}>
|
|
Manual Claim
|
|
</Button>
|
|
</div>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
|
</div>
|
|
</DialogContent>
|
|
|
|
<DeleteConfirmationDialog isOpen={clearAllOpen} entityName="all procedures for this appointment" onCancel={() => setClearAllOpen(false)} onConfirm={() => { setClearAllOpen(false); clearAllMutation.mutate(); }}/>
|
|
|
|
{/* Price mismatch dialog */}
|
|
<AlertDialog open={priceMismatches.length > 0} onOpenChange={open => { if (!open)
|
|
setPriceMismatches([]); }}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Save new price to the app?</AlertDialogTitle>
|
|
<AlertDialogDescription asChild>
|
|
<div className="space-y-2">
|
|
<p>The following procedure prices differ from the fee schedule:</p>
|
|
<ul className="text-sm space-y-1">
|
|
{priceMismatches.map(m => (<li key={m.procedureCode} className="flex justify-between gap-4">
|
|
<span className="font-medium">{m.procedureCode}</span>
|
|
<span className="text-muted-foreground">Schedule: ${m.schedulePrice.toFixed(2)}</span>
|
|
<span className="text-foreground font-semibold">Entered: ${m.enteredPrice.toFixed(2)}</span>
|
|
</li>))}
|
|
</ul>
|
|
<p className="text-sm">Do you want to save the new price(s) to the fee schedule for future use?</p>
|
|
</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => {
|
|
setPriceMismatches([]);
|
|
pendingAction.current?.();
|
|
pendingAction.current = null;
|
|
}}>
|
|
No
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction onClick={async () => {
|
|
await savePricesToSchedule(priceMismatches);
|
|
setPriceMismatches([]);
|
|
pendingAction.current?.();
|
|
pendingAction.current = null;
|
|
}}>
|
|
Yes
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Dialog>);
|
|
}
|