572 lines
17 KiB
TypeScript
572 lines
17 KiB
TypeScript
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<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,
|
|
},
|
|
);
|
|
|
|
// -----------------------------
|
|
// 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 (
|
|
<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
|
|
}
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-semibold">
|
|
Appointment Procedures
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* ================= COMBOS ================= */}
|
|
<div className="space-y-8 pointer-events-auto">
|
|
<DirectComboButtons
|
|
onDirectCombo={(comboKey) => {
|
|
handleAddCombo(comboKey);
|
|
}}
|
|
/>
|
|
|
|
<RegularComboButtons
|
|
onRegularCombo={(comboKey) => {
|
|
handleAddCombo(comboKey);
|
|
}}
|
|
/>
|
|
</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"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Label</Label>
|
|
<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"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Tooth</Label>
|
|
<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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => addManualMutation.mutate()}
|
|
disabled={!manualCode || addManualMutation.isPending}
|
|
>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
Add Procedure
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ================= 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)}
|
|
>
|
|
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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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={() => 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();
|
|
}}
|
|
/>
|
|
</Dialog>
|
|
);
|
|
}
|