import { useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; 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 { CODE_MAP, getPriceForCodeWithAgeFromMap, } from "@/utils/procedureCombosMapping"; import { Patient, AppointmentProcedure } from "@repo/db/types"; import { useLocation } from "wouter"; import { DeleteConfirmationDialog } from "../ui/deleteDialog"; import { DirectComboButtons, RegularComboButtons, } from "@/components/procedure/procedure-combo-buttons"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; appointmentId: number; patientId: number; patient: Patient; } export function AppointmentProceduresDialog({ open, onOpenChange, appointmentId, patientId, patient, }: Props) { const { toast } = useToast(); // ----------------------------- // state for manual add // ----------------------------- const [manualCode, setManualCode] = useState(""); const [manualLabel, setManualLabel] = useState(""); const [manualFee, setManualFee] = useState(""); const [manualTooth, setManualTooth] = useState(""); const [manualSurface, setManualSurface] = useState(""); // ----------------------------- // state for inline edit // ----------------------------- const [editingId, setEditingId] = useState(null); const [editRow, setEditRow] = useState>({}); const [clearAllOpen, setClearAllOpen] = useState(false); // for redirection to claim submission const [, setLocation] = useLocation(); // ----------------------------- // fetch 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, }, ); // ----------------------------- // mutations // ----------------------------- const addManualMutation = useMutation({ mutationFn: async () => { const payload = { appointmentId, patientId, procedureCode: manualCode, procedureLabel: manualLabel || null, fee: manualFee ? Number(manualFee) : null, toothNumber: manualTooth || null, toothSurface: manualSurface || null, source: "MANUAL", }; 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], }); }, onError: (err: any) => { 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, ); 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], }); }, }); const deleteMutation = useMutation({ mutationFn: async (id: number) => { 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: any) => { 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: string) => { const combo = PROCEDURE_COMBOS[comboKey]; if (!combo || !patient?.dateOfBirth) return; const serviceDate = new Date(); const dob = patient.dateOfBirth; 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, }; }); bulkAddMutation.mutate(rows); }; const startEdit = (row: AppointmentProcedure) => { 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 handleDirectClaim = () => { setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`); onOpenChange(false); }; const handleManualClaim = () => { setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`); onOpenChange(false); }; // ----------------------------- // UI // ----------------------------- return ( { if (clearAllOpen) { e.preventDefault(); // block only when delete dialog is open } }} onInteractOutside={(e) => { if (clearAllOpen) { e.preventDefault(); // block only when delete dialog is open } }} > Appointment Procedures {/* ================= COMBOS ================= */}
{ handleAddCombo(comboKey); }} /> { handleAddCombo(comboKey); }} />
{/* ================= MANUAL ADD ================= */}
Add Manual Procedure
setManualCode(e.target.value)} placeholder="D0120" />
setManualLabel(e.target.value)} placeholder="Exam" />
setManualFee(e.target.value)} placeholder="100" type="number" />
setManualTooth(e.target.value)} placeholder="14" />
setManualSurface(e.target.value)} placeholder="MO" />
{/* ================= LIST ================= */}
Selected Procedures
{/* ===== TABLE HEADER ===== */}
Code
Label
Fee
Tooth
Surface
Edit
Delete
{isLoading && (
Loading...
)} {!isLoading && procedures.length === 0 && (
No procedures added
)} {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(); }} />
); }