feat: chat window, preferred language, insurance contact, and AI call eligibility
- Schedule: right-click Chat option opens floating SMS chat window - Chat window: SMS template selector with appointment date/time pre-filled - Chat window: office name and phone pulled from Settings > Office Contact - Chat window: Preferred Language selector (English, Spanish, Portuguese, Mandarin, Cantonese, Arabic, Haitian Creole) with fully translated templates and locale-aware date/time formatting - Patient form: Preferred Language field (add/edit), default English - Settings > Office Contact: added Dental Office Name field - Settings > Advanced: Insurance Contact page (CRUD — company name + phone) - Prisma schema: preferredLanguage on Patient, officeName on OfficeContact, new InsuranceContact model - Patient management: Upload Patient Document moved below Patient Records - Insurance Eligibility: AI Call Insurance collapsible section; insurance company and phone auto-populated from saved Insurance Contacts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
255
apps/Frontend/src/components/settings/insurance-contact-card.tsx
Normal file
255
apps/Frontend/src/components/settings/insurance-contact-card.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||
import { Plus, Pencil, Trash2, X, Check } from "lucide-react";
|
||||
|
||||
type InsuranceContact = {
|
||||
id: number;
|
||||
name: string;
|
||||
phoneNumber?: string | null;
|
||||
};
|
||||
|
||||
type FormState = { name: string; phoneNumber: string };
|
||||
|
||||
const EMPTY_FORM: FormState = { name: "", phoneNumber: "" };
|
||||
|
||||
export function InsuranceContactCard() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [deleteTarget, setDeleteTarget] = useState<InsuranceContact | null>(null);
|
||||
|
||||
const { data: contacts = [], isLoading } = useQuery<InsuranceContact[]>({
|
||||
queryKey: ["/api/insurance-contacts"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/insurance-contacts");
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["/api/insurance-contacts"] });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: FormState) => {
|
||||
const res = await apiRequest("POST", "/api/insurance-contacts", data);
|
||||
if (!res.ok) { const e = await res.json().catch(() => null); throw new Error(e?.message || "Failed to save"); }
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setShowForm(false);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: "Insurance contact added" });
|
||||
},
|
||||
onError: (e: any) => toast({ title: "Error", description: e.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: number; data: FormState }) => {
|
||||
const res = await apiRequest("PUT", `/api/insurance-contacts/${id}`, data);
|
||||
if (!res.ok) { const e = await res.json().catch(() => null); throw new Error(e?.message || "Failed to update"); }
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: "Insurance contact updated" });
|
||||
},
|
||||
onError: (e: any) => toast({ title: "Error", description: e.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await apiRequest("DELETE", `/api/insurance-contacts/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setDeleteTarget(null);
|
||||
toast({ title: "Insurance contact deleted" });
|
||||
},
|
||||
onError: (e: any) => toast({ title: "Error", description: e.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (c: InsuranceContact) => {
|
||||
setShowForm(false);
|
||||
setEditingId(c.id);
|
||||
setForm({ name: c.name, phoneNumber: c.phoneNumber ?? "" });
|
||||
};
|
||||
|
||||
const cancelEdit = () => { setEditingId(null); setForm(EMPTY_FORM); };
|
||||
const cancelAdd = () => { setShowForm(false); setForm(EMPTY_FORM); };
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ title: "Name is required", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
if (editingId !== null) {
|
||||
updateMutation.mutate({ id: editingId, data: form });
|
||||
} else {
|
||||
createMutation.mutate(form);
|
||||
}
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Insurance Contacts</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Phone numbers for insurance companies</p>
|
||||
</div>
|
||||
<Button onClick={openAdd} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Contact
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
{showForm && (
|
||||
<div className="p-4 border-b bg-gray-50">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">New Insurance Contact</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Company Name *</label>
|
||||
<Input
|
||||
placeholder="e.g. Delta MA"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Phone Number</label>
|
||||
<Input
|
||||
placeholder="e.g. (800) 555-0100"
|
||||
value={form.phoneNumber}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phoneNumber: e.target.value }))}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={cancelAdd} disabled={isSaving}>
|
||||
<X className="h-3.5 w-3.5 mr-1" /> Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Insurance Company
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Phone Number
|
||||
</th>
|
||||
<th className="px-4 py-3 w-24" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-6 text-sm text-gray-400">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : contacts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-10 text-sm text-gray-400">
|
||||
No insurance contacts yet. Click "Add Contact" to add one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
contacts.map((c) =>
|
||||
editingId === c.id ? (
|
||||
/* Inline edit row */
|
||||
<tr key={c.id} className="bg-blue-50">
|
||||
<td className="px-4 py-2">
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Company name"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Input
|
||||
value={form.phoneNumber}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phoneNumber: e.target.value }))}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Phone number"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={cancelEdit} disabled={isSaving}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
/* Display row */
|
||||
<tr key={c.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{c.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{c.phoneNumber || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
|
||||
<Pencil className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(c)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={!!deleteTarget}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
entityName={deleteTarget?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
|
||||
type OfficeContact = {
|
||||
id?: number;
|
||||
officeName?: string | null;
|
||||
receptionistName?: string | null;
|
||||
dentistName?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
@@ -16,6 +17,7 @@ type OfficeContact = {
|
||||
export function OfficeContactCard() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [officeName, setOfficeName] = useState("");
|
||||
const [receptionistName, setReceptionistName] = useState("");
|
||||
const [dentistName, setDentistName] = useState("");
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
@@ -33,6 +35,7 @@ export function OfficeContactCard() {
|
||||
|
||||
useEffect(() => {
|
||||
if (contact) {
|
||||
setOfficeName(contact.officeName ?? "");
|
||||
setReceptionistName(contact.receptionistName ?? "");
|
||||
setDentistName(contact.dentistName ?? "");
|
||||
setPhoneNumber(contact.phoneNumber ?? "");
|
||||
@@ -61,7 +64,7 @@ export function OfficeContactCard() {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate({ receptionistName, dentistName, phoneNumber, email, fax });
|
||||
saveMutation.mutate({ officeName, receptionistName, dentistName, phoneNumber, email, fax });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -78,6 +81,17 @@ export function OfficeContactCard() {
|
||||
<p className="text-sm text-gray-400">Loading...</p>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Dental Office Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={officeName}
|
||||
onChange={(e) => setOfficeName(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full text-sm"
|
||||
placeholder="e.g. Summit Dental Care"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Receptionist Name</label>
|
||||
|
||||
Reference in New Issue
Block a user