Files
DentalManagementMH07/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.jsx
Gitead 1edf73fdc8 feat: add new frontend components, MH batch worker, and gitignore rules
- 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>
2026-06-26 00:23:43 -04:00

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>);
}