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:
Gitead
2026-05-07 16:42:37 -04:00
parent dd0df4a435
commit 16429320fa
16 changed files with 977 additions and 115 deletions

View File

@@ -30,6 +30,7 @@ import aiSettingsRoutes from "./ai-settings";
import officeHoursRoutes from "./office-hours";
import officeContactRoutes from "./office-contact";
import procedureTimeslotRoutes from "./procedure-timeslot";
import insuranceContactsRoutes from "./insurance-contacts";
const router = Router();
@@ -64,5 +65,6 @@ router.use("/ai", aiSettingsRoutes);
router.use("/office-hours", officeHoursRoutes);
router.use("/office-contact", officeContactRoutes);
router.use("/procedure-timeslot", procedureTimeslotRoutes);
router.use("/insurance-contacts", insuranceContactsRoutes);
export default router;

View File

@@ -0,0 +1,66 @@
import express, { Request, Response } from "express";
import { storage } from "../storage";
const router = express.Router();
router.get("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const contacts = await storage.getInsuranceContactsByUser(userId);
return res.json(contacts);
} catch (err) {
return res.status(500).json({ error: "Failed to fetch insurance contacts", details: String(err) });
}
});
router.post("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { name, phoneNumber } = req.body;
if (!name?.trim()) return res.status(400).json({ message: "Insurance company name is required" });
const contact = await storage.createInsuranceContact({
userId,
name: name.trim(),
phoneNumber: phoneNumber?.trim() || undefined,
});
return res.status(201).json(contact);
} catch (err) {
return res.status(500).json({ error: "Failed to create insurance contact", details: String(err) });
}
});
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const id = Number(req.params.id);
if (isNaN(id)) return res.status(400).json({ message: "Invalid ID" });
const { name, phoneNumber } = req.body;
if (name !== undefined && !name?.trim()) return res.status(400).json({ message: "Name cannot be empty" });
const contact = await storage.updateInsuranceContact(id, {
name: name?.trim(),
phoneNumber: phoneNumber?.trim() || undefined,
});
return res.json(contact);
} catch (err) {
return res.status(500).json({ error: "Failed to update insurance contact", details: String(err) });
}
});
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const id = Number(req.params.id);
if (isNaN(id)) return res.status(400).json({ message: "Invalid ID" });
const ok = await storage.deleteInsuranceContact(userId, id);
if (!ok) return res.status(404).json({ message: "Insurance contact not found" });
return res.status(204).send();
} catch (err) {
return res.status(500).json({ error: "Failed to delete insurance contact", details: String(err) });
}
});
export default router;

View File

@@ -22,8 +22,9 @@ router.put("/", async (req: Request, res: Response): Promise<any> => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { receptionistName, dentistName, phoneNumber, email, fax } = req.body;
const { officeName, receptionistName, dentistName, phoneNumber, email, fax } = req.body;
const record = await storage.upsertOfficeContact(userId, {
officeName: officeName ?? undefined,
receptionistName: receptionistName ?? undefined,
dentistName: dentistName ?? undefined,
phoneNumber: phoneNumber ?? undefined,

View File

@@ -22,6 +22,7 @@ import { aiSettingsStorage } from "./ai-settings-storage";
import { officeHoursStorage } from "./office-hours-storage";
import { officeContactStorage } from "./office-contact-storage";
import { procedureTimeslotStorage } from "./procedure-timeslot-storage";
import { insuranceContactStorage } from "./insurance-contact-storage";
export const storage = {
@@ -47,6 +48,7 @@ export const storage = {
...officeHoursStorage,
...officeContactStorage,
...procedureTimeslotStorage,
...insuranceContactStorage,
};

View File

@@ -0,0 +1,25 @@
import { prisma as db } from "@repo/db/client";
export const insuranceContactStorage = {
async getInsuranceContactsByUser(userId: number) {
return db.insuranceContact.findMany({
where: { userId },
orderBy: { name: "asc" },
});
},
async createInsuranceContact(data: { userId: number; name: string; phoneNumber?: string }) {
return db.insuranceContact.create({ data });
},
async updateInsuranceContact(id: number, data: { name?: string; phoneNumber?: string }) {
return db.insuranceContact.update({ where: { id }, data });
},
async deleteInsuranceContact(userId: number, id: number) {
const record = await db.insuranceContact.findFirst({ where: { id, userId } });
if (!record) return false;
await db.insuranceContact.delete({ where: { id } });
return true;
},
};

View File

@@ -6,6 +6,7 @@ export const officeContactStorage = {
},
async upsertOfficeContact(userId: number, data: {
officeName?: string;
receptionistName?: string;
dentistName?: string;
phoneNumber?: string;

View File

@@ -29,6 +29,7 @@ import {
Clock,
Building2,
Timer,
BookOpen,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useMemo, useState, useEffect } from "react";
@@ -239,6 +240,11 @@ export function Sidebar() {
path: "/settings/proceduretimeslot",
icon: <Timer className="h-4 w-4 text-gray-400" />,
},
{
name: "Insurance Contact",
path: "/settings/insurancecontact",
icon: <BookOpen className="h-4 w-4 text-gray-400" />,
},
],
},
],

View File

@@ -2,54 +2,304 @@ import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Send, ArrowLeft } from "lucide-react";
import { Send, ArrowLeft, FileText, Globe } from "lucide-react";
import type { Patient, Communication } from "@repo/db/types";
import { format, isToday, isYesterday, parseISO } from "date-fns";
import { es, pt, zhCN, zhTW, ar, fr } from "date-fns/locale";
import type { Locale } from "date-fns";
// ─── Language config ──────────────────────────────────────────────────────────
export const SUPPORTED_LANGUAGES = [
"English",
"Spanish",
"Portuguese",
"Mandarin",
"Cantonese",
"Arabic",
"Haitian Creole",
] as const;
export type Language = (typeof SUPPORTED_LANGUAGES)[number];
const LOCALES: Partial<Record<Language, Locale>> = {
Spanish: es,
Portuguese: pt,
Mandarin: zhCN,
Cantonese: zhTW,
Arabic: ar,
"Haitian Creole": fr, // French locale — closest approximation for day/month names
};
// Date pattern per language (date-fns format tokens)
const DATE_FMT: Record<Language, string> = {
English: "MMMM do, EEEE",
Spanish: "EEEE, d 'de' MMMM",
Portuguese: "EEEE, d 'de' MMMM",
Mandarin: "M月d日EEEE",
Cantonese: "M月d日EEEE",
Arabic: "EEEE d MMMM",
"Haitian Creole": "EEEE d MMMM",
};
function formatTime(h: number, m: number, lang: Language): string {
const pad = (n: number) => String(n).padStart(2, "0");
const h12 = h % 12 || 12;
if (lang === "Mandarin" || lang === "Cantonese") {
return `${h >= 12 ? "下午" : "上午"}${h12}:${pad(m)}`;
}
if (lang === "Arabic") {
return `${h12}:${pad(m)} ${h >= 12 ? "مساءً" : "صباحاً"}`;
}
if (lang === "Spanish" || lang === "Portuguese") {
return `${pad(h)}:${pad(m)}`;
}
// English, Haitian Creole
return `${h12}:${pad(m)} ${h >= 12 ? "pm" : "am"}`;
}
function buildApptPhrase(date: string, startTime: string, lang: Language): string {
const [y, mo, d] = date.split("-").map(Number);
const dateObj = new Date(y!, mo! - 1, d!);
const locale = LOCALES[lang];
const datePart = format(dateObj, DATE_FMT[lang], { locale });
const [h, m] = startTime.split(":").map(Number);
const timePart = formatTime(h!, m!, lang);
switch (lang) {
case "Spanish": return `el ${datePart} a las ${timePart}`;
case "Portuguese": return `na ${datePart} às ${timePart}`;
case "Mandarin": return `${datePart}${timePart}`;
case "Cantonese": return `${datePart}${timePart}`;
case "Arabic": return `في ${datePart} الساعة ${timePart}`;
case "Haitian Creole": return `le ${datePart} a ${timePart}`;
default: return `on ${datePart} at ${timePart}`;
}
}
// ─── Office contact ───────────────────────────────────────────────────────────
interface OfficeContact {
officeName?: string | null;
phoneNumber?: string | null;
}
// ─── Template builders ────────────────────────────────────────────────────────
export interface AppointmentInfo {
date: string; // "YYYY-MM-DD"
startTime: string; // "HH:MM"
}
const DEFAULT_OFFICE_NAME: Record<Language, string> = {
English: "the dental office",
Spanish: "la clínica dental",
Portuguese: "o consultório dentário",
Mandarin: "牙科诊所",
Cantonese: "牙科診所",
Arabic: "عيادة الأسنان",
"Haitian Creole": "kabinè dantis la",
};
function callPhrase(phone: string | null | undefined, lang: Language): string {
if (!phone?.trim()) {
return { English: "call us", Spanish: "llámenos", Portuguese: "ligue-nos",
Mandarin: "联系我们", Cantonese: "聯繫我們", Arabic: "اتصل بنا",
"Haitian Creole": "rele nou" }[lang];
}
return {
English: `call us at ${phone}`,
Spanish: `llámenos al ${phone}`,
Portuguese: `ligue-nos pelo ${phone}`,
Mandarin: `致电 ${phone}`,
Cantonese: `致電 ${phone}`,
Arabic: `اتصل بنا على ${phone}`,
"Haitian Creole": `rele nou nan ${phone}`,
}[lang];
}
function buildTemplates(
patient: Patient,
lang: Language,
apptInfo?: AppointmentInfo,
office?: OfficeContact | null,
) {
const n = patient.firstName;
const o = office?.officeName?.trim() || DEFAULT_OFFICE_NAME[lang];
const c = callPhrase(office?.phoneNumber, lang);
const appt = apptInfo ? buildApptPhrase(apptInfo.date, apptInfo.startTime, lang) : "";
const T = {
English: {
label_reminder: "Appointment Reminder",
label_confirmed: "Appointment Confirmed",
label_followup: "Follow-Up",
label_payment: "Payment Reminder",
label_general: "General",
reminder: apptInfo
? `Hi ${n}, this is ${o}. Reminder: You have an appointment scheduled ${appt}. Please confirm through text messages or ${c} if you need to reschedule.`
: `Hi ${n}, this is ${o}. Reminder: You have an upcoming appointment. Please confirm through text messages or ${c} if you need to reschedule.`,
confirmed: `Hi ${n}, this is ${o}. Your appointment has been confirmed. We look forward to seeing you! If you have any questions, please ${c}.`,
followup: `Hi ${n}, this is ${o}. Thank you for visiting us. How are you feeling after your treatment? Please let us know if you have any concerns.`,
payment: `Hi ${n}, this is ${o}. This is a friendly reminder about your outstanding balance. Please ${c} to discuss payment options.`,
general: `Hi ${n}, this is ${o}. `,
},
Spanish: {
label_reminder: "Recordatorio de cita",
label_confirmed: "Cita confirmada",
label_followup: "Seguimiento",
label_payment: "Recordatorio de pago",
label_general: "General",
reminder: apptInfo
? `Hola ${n}, le habla ${o}. Recordatorio: Tiene una cita programada ${appt}. Por favor confirme por mensaje de texto o ${c} si necesita reprogramar.`
: `Hola ${n}, le habla ${o}. Recordatorio: Tiene una próxima cita. Por favor confirme por mensaje de texto o ${c} si necesita reprogramar.`,
confirmed: `Hola ${n}, le habla ${o}. Su cita ha sido confirmada. ¡Esperamos verle pronto! Si tiene alguna pregunta, por favor ${c}.`,
followup: `Hola ${n}, le habla ${o}. Gracias por visitarnos. ¿Cómo se siente después de su tratamiento? Por favor, háganos saber si tiene alguna inquietud.`,
payment: `Hola ${n}, le habla ${o}. Este es un recordatorio amable sobre su saldo pendiente. Por favor ${c} para hablar sobre las opciones de pago.`,
general: `Hola ${n}, le habla ${o}. `,
},
Portuguese: {
label_reminder: "Lembrete de consulta",
label_confirmed: "Consulta confirmada",
label_followup: "Acompanhamento",
label_payment: "Lembrete de pagamento",
label_general: "Geral",
reminder: apptInfo
? `Olá ${n}, aqui é ${o}. Lembrete: Você tem uma consulta agendada ${appt}. Por favor confirme por mensagem de texto ou ${c} se precisar reagendar.`
: `Olá ${n}, aqui é ${o}. Lembrete: Você tem uma consulta próxima. Por favor confirme por mensagem de texto ou ${c} se precisar reagendar.`,
confirmed: `Olá ${n}, aqui é ${o}. Sua consulta foi confirmada. Aguardamos sua visita! Se tiver alguma dúvida, por favor ${c}.`,
followup: `Olá ${n}, aqui é ${o}. Obrigado pela sua visita. Como está se sentindo após o tratamento? Por favor, nos informe se tiver alguma preocupação.`,
payment: `Olá ${n}, aqui é ${o}. Este é um lembrete amigável sobre o seu saldo pendente. Por favor ${c} para discutir as opções de pagamento.`,
general: `Olá ${n}, aqui é ${o}. `,
},
Mandarin: {
label_reminder: "预约提醒",
label_confirmed: "预约确认",
label_followup: "回访",
label_payment: "付款提醒",
label_general: "一般消息",
reminder: apptInfo
? `您好 ${n},我们是${o}。提醒:您有一个预约安排${appt}。请通过短信确认,或如需重新安排请${c}`
: `您好 ${n},我们是${o}。提醒:您有一个即将到来的预约。请通过短信确认,或如需重新安排请${c}`,
confirmed: `您好 ${n},我们是${o}。您的预约已确认。期待您的光临!如有疑问,请${c}`,
followup: `您好 ${n},我们是${o}。感谢您的来访。治疗后您感觉怎么样?如有任何疑虑,请告诉我们。`,
payment: `您好 ${n},我们是${o}。这是关于您未付余额的友好提醒。请${c}讨论付款方案。`,
general: `您好 ${n},我们是${o}`,
},
Cantonese: {
label_reminder: "預約提醒",
label_confirmed: "預約確認",
label_followup: "跟進",
label_payment: "付款提醒",
label_general: "一般消息",
reminder: apptInfo
? `您好 ${n},我哋係${o}。提醒:您有一個預約安排${appt}。請透過短訊確認,或如需重新安排請${c}`
: `您好 ${n},我哋係${o}。提醒:您有一個即將到嘅預約。請透過短訊確認,或如需重新安排請${c}`,
confirmed: `您好 ${n},我哋係${o}。您嘅預約已確認。期待見到您!如有疑問,請${c}`,
followup: `您好 ${n},我哋係${o}。感謝您嘅到訪。治療後您感覺點樣?如有任何疑慮,請告知我們。`,
payment: `您好 ${n},我哋係${o}。呢係關於您未付餘額嘅友好提醒。請${c}討論付款方案。`,
general: `您好 ${n},我哋係${o}`,
},
Arabic: {
label_reminder: "تذكير بالموعد",
label_confirmed: "تأكيد الموعد",
label_followup: "متابعة",
label_payment: "تذكير بالدفع",
label_general: "رسالة عامة",
reminder: apptInfo
? `مرحباً ${n}، نحن ${o}. تذكير: لديك موعد مجدول ${appt}. يرجى التأكيد عبر الرسائل النصية أو ${c} إذا كنت بحاجة إلى إعادة الجدولة.`
: `مرحباً ${n}، نحن ${o}. تذكير: لديك موعد قادم. يرجى التأكيد عبر الرسائل النصية أو ${c} إذا كنت بحاجة إلى إعادة الجدولة.`,
confirmed: `مرحباً ${n}، نحن ${o}. تم تأكيد موعدك. نتطلع إلى رؤيتك! إذا كان لديك أي أسئلة، يرجى ${c}.`,
followup: `مرحباً ${n}، نحن ${o}. شكراً لزيارتك. كيف تشعر بعد العلاج؟ يرجى إبلاغنا إذا كان لديك أي مخاوف.`,
payment: `مرحباً ${n}، نحن ${o}. هذا تذكير ودي بشأن رصيدك المستحق. يرجى ${c} لمناقشة خيارات الدفع.`,
general: `مرحباً ${n}، نحن ${o}. `,
},
"Haitian Creole": {
label_reminder: "Rapèl randevou",
label_confirmed: "Randevou konfime",
label_followup: "Swivi",
label_payment: "Rapèl peman",
label_general: "Jeneral",
reminder: apptInfo
? `Bonjou ${n}, se ${o} k'ap pale. Rapèl: Ou gen yon randevou pwograme ${appt}. Tanpri konfime pa mesaj tèks oswa ${c} si ou bezwen repwograme.`
: `Bonjou ${n}, se ${o} k'ap pale. Rapèl: Ou gen yon randevou k'ap vini. Tanpri konfime pa mesaj tèks oswa ${c} si ou bezwen repwograme.`,
confirmed: `Bonjou ${n}, se ${o} k'ap pale. Randevou ou a konfime. N'ap tann ou! Si ou gen kesyon, tanpri ${c}.`,
followup: `Bonjou ${n}, se ${o} k'ap pale. Mèsi dèske ou te vizite nou. Kijan ou santi ou apre tretman ou? Tanpri fè nou konnen si ou gen nenpòt enkyetid.`,
payment: `Bonjou ${n}, se ${o} k'ap pale. Sa se yon rapèl amical sou balans ou ki annatant. Tanpri ${c} pou diskite opsyon peman.`,
general: `Bonjou ${n}, se ${o} k'ap pale. `,
},
}[lang];
return [
{ key: "appointment_reminder", label: T.label_reminder, body: T.reminder },
{ key: "appointment_confirmed", label: T.label_confirmed, body: T.confirmed },
{ key: "follow_up", label: T.label_followup, body: T.followup },
{ key: "payment_reminder", label: T.label_payment, body: T.payment },
{ key: "general", label: T.label_general, body: T.general },
];
}
// ─── Component ────────────────────────────────────────────────────────────────
interface MessageThreadProps {
patient: Patient;
onBack?: () => void;
appointmentInfo?: AppointmentInfo;
}
export function MessageThread({ patient, onBack }: MessageThreadProps) {
export function MessageThread({ patient, onBack, appointmentInfo }: MessageThreadProps) {
const { toast } = useToast();
const [messageText, setMessageText] = useState("");
const [language, setLanguage] = useState<Language>(
(patient.preferredLanguage as Language | null | undefined) &&
SUPPORTED_LANGUAGES.includes(patient.preferredLanguage as Language)
? (patient.preferredLanguage as Language)
: "English"
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: officeContact } = useQuery<OfficeContact | null>({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok) return null;
return res.json();
},
staleTime: 60_000,
});
const { data: communications = [], isLoading } = useQuery<Communication[]>({
queryKey: ["/api/patients", patient.id, "communications"],
queryFn: async () => {
const res = await apiRequest("GET", `/api/patients/${patient.id}/communications`);
return res.json();
},
refetchInterval: 5000, // Refresh every 5 seconds to get new messages
refetchInterval: 5000,
});
const sendMessageMutation = useMutation({
mutationFn: async (message: string) => {
return apiRequest("POST", "/api/twilio/send-sms", {
mutationFn: async (message: string) =>
apiRequest("POST", "/api/twilio/send-sms", {
to: patient.phone,
message: message,
message,
patientId: patient.id,
});
},
}),
onSuccess: () => {
setMessageText("");
queryClient.invalidateQueries({
queryKey: ["/api/patients", patient.id, "communications"],
});
toast({
title: "Message sent",
description: "Your message has been sent successfully.",
});
queryClient.invalidateQueries({ queryKey: ["/api/patients", patient.id, "communications"] });
toast({ title: "Message sent", description: "Your message has been sent successfully." });
},
onError: (error: any) => {
toast({
title: "Failed to send message",
description:
error.message || "Unable to send message. Please try again.",
description: error.message || "Unable to send message. Please try again.",
variant: "destructive",
});
},
@@ -72,141 +322,146 @@ export function MessageThread({ patient, onBack }: MessageThreadProps) {
}, [communications]);
const formatMessageDate = (dateValue: string | Date) => {
const date =
typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(date)) {
return format(date, "h:mm a");
} else if (isYesterday(date)) {
return `Yesterday ${format(date, "h:mm a")}`;
} else {
return format(date, "MMM d, h:mm a");
}
const date = typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(date)) return format(date, "h:mm a");
if (isYesterday(date)) return `Yesterday ${format(date, "h:mm a")}`;
return format(date, "MMM d, h:mm a");
};
const getDateDivider = (dateValue: string | Date) => {
const messageDate =
typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(messageDate)) {
return "Today";
} else if (isYesterday(messageDate)) {
return "Yesterday";
} else {
return format(messageDate, "MMMM d, yyyy");
}
const d = typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(d)) return "Today";
if (isYesterday(d)) return "Yesterday";
return format(d, "MMMM d, yyyy");
};
const groupedMessages: { date: string; messages: Communication[] }[] = [];
communications.forEach((comm) => {
if (!comm.createdAt) return;
const messageDate =
typeof comm.createdAt === "string"
? parseISO(comm.createdAt)
: comm.createdAt;
const messageDate = typeof comm.createdAt === "string" ? parseISO(comm.createdAt) : comm.createdAt;
const dateKey = format(messageDate, "yyyy-MM-dd");
const existingGroup = groupedMessages.find((g) => g.date === dateKey);
if (existingGroup) {
existingGroup.messages.push(comm);
} else {
groupedMessages.push({ date: dateKey, messages: [comm] });
}
const existing = groupedMessages.find((g) => g.date === dateKey);
if (existing) existing.messages.push(comm);
else groupedMessages.push({ date: dateKey, messages: [comm] });
});
const templates = buildTemplates(patient, language, appointmentInfo, officeContact);
return (
<div className="flex flex-col h-full bg-white rounded-lg border">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
<div className="p-4 border-b bg-gray-50 space-y-2">
{/* Patient identity row */}
<div className="flex items-center gap-3">
{onBack && (
<Button
variant="ghost"
size="icon"
onClick={onBack}
data-testid="button-back"
>
<Button variant="ghost" size="icon" onClick={onBack} data-testid="button-back">
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold">
{patient.firstName[0]}
{patient.lastName[0]}
</div>
<div>
<h3 className="font-semibold text-base">
{patient.firstName} {patient.lastName}
</h3>
<p className="text-sm text-muted-foreground">{patient.phone}</p>
</div>
<div className="h-10 w-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold flex-shrink-0">
{patient.firstName[0]}{patient.lastName[0]}
</div>
<div>
<h3 className="font-semibold text-base">
{patient.firstName} {patient.lastName}
</h3>
<p className="text-sm text-muted-foreground">{patient.phone}</p>
</div>
</div>
{/* Controls row: language + template */}
<div className="flex items-center gap-2 pl-1 flex-wrap">
{/* Language selector */}
<div className="flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<Select
value={language}
onValueChange={(v) => setLanguage(v as Language)}
>
<SelectTrigger className="h-7 text-xs w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((l) => (
<SelectItem key={l} value={l} className="text-xs">{l}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Template selector */}
<div className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<Select
value=""
onValueChange={(key) => {
const tpl = templates.find((t) => t.key === key);
if (tpl) setMessageText(tpl.body);
}}
>
<SelectTrigger className="h-7 text-xs border-dashed w-[180px]">
<SelectValue placeholder="Use a template…" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Messages */}
<div
className="flex-1 overflow-y-auto p-6 space-y-4"
data-testid="messages-container"
>
<div className="flex-1 overflow-y-auto p-6 space-y-4" data-testid="messages-container">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading messages...</p>
</div>
) : communications.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
No messages yet. Start the conversation!
</p>
<p className="text-muted-foreground">No messages yet. Start the conversation!</p>
</div>
) : (
<>
{groupedMessages.map((group) => (
<div key={group.date}>
{/* Date Divider */}
<div className="flex items-center justify-center my-8">
<div className="px-4 py-1 bg-gray-100 rounded-full text-xs text-muted-foreground">
{getDateDivider(group.messages[0]?.createdAt!)}
</div>
</div>
{/* Messages for this date */}
{group.messages.map((comm) => (
<div
key={comm.id}
className={`flex mb-4 ${comm.direction === "outbound" ? "justify-end" : "justify-start"}`}
data-testid={`message-${comm.id}`}
>
<div
className={`max-w-md ${comm.direction === "outbound" ? "ml-auto" : "mr-auto"}`}
>
<div className={`max-w-md ${comm.direction === "outbound" ? "ml-auto" : "mr-auto"}`}>
{comm.direction === "inbound" && (
<div className="flex items-start gap-2">
<div className="h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center text-xs font-semibold flex-shrink-0">
{patient.firstName[0]}
{patient.lastName[0]}
{patient.firstName[0]}{patient.lastName[0]}
</div>
<div>
<div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md">
<p className="text-sm whitespace-pre-wrap break-words">
{comm.body}
</p>
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
{comm.createdAt &&
formatMessageDate(comm.createdAt)}
{comm.createdAt && formatMessageDate(comm.createdAt)}
</p>
</div>
</div>
)}
{comm.direction === "outbound" && (
<div>
<div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md">
<p className="text-sm whitespace-pre-wrap break-words">
{comm.body}
</p>
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div>
<p className="text-xs text-muted-foreground mt-1 text-right">
{comm.createdAt &&
formatMessageDate(comm.createdAt)}
{comm.createdAt && formatMessageDate(comm.createdAt)}
</p>
</div>
)}

View File

@@ -86,6 +86,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
policyHolder: "",
allergies: "",
medicalConditions: "",
preferredLanguage: "English",
status: "UNKNOWN",
userId: user?.id,
};
@@ -208,6 +209,36 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
</FormItem>
)}
/>
<FormField
control={form.control}
name="preferredLanguage"
render={({ field }) => (
<FormItem>
<FormLabel>Preferred Language</FormLabel>
<Select
onValueChange={field.onChange}
value={(field.value as string) || "English"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="English">English</SelectItem>
<SelectItem value="Spanish">Spanish</SelectItem>
<SelectItem value="Portuguese">Portuguese</SelectItem>
<SelectItem value="Mandarin">Mandarin</SelectItem>
<SelectItem value="Cantonese">Cantonese</SelectItem>
<SelectItem value="Arabic">Arabic</SelectItem>
<SelectItem value="Haitian Creole">Haitian Creole</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>

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

View File

@@ -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>

View File

@@ -26,6 +26,7 @@ import {
LoaderCircleIcon,
Stethoscope,
Download,
MessageSquare,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Calendar } from "@/components/ui/calendar";
@@ -60,6 +61,7 @@ import {
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
import type { OfficeHoursData } from "@/components/settings/office-hours-card";
import { MessageThread } from "@/components/patient-connection/message-thread";
// Define types for scheduling
interface TimeSlot {
@@ -201,6 +203,10 @@ export default function AppointmentsPage() {
const [selectProceduresPatientId, setSelectProceduresPatientId] = useState<number | null>(null);
const [selectProceduresAppointmentId, setSelectProceduresAppointmentId] = useState<number | null>(null);
// Chat popup state
const [chatPatient, setChatPatient] = useState<Patient | null>(null);
const [chatAppointmentInfo, setChatAppointmentInfo] = useState<{ date: string; startTime: string } | undefined>(undefined);
// Create context menu hook
const { show } = useContextMenu({
id: APPOINTMENT_CONTEXT_MENU_ID,
@@ -756,6 +762,24 @@ export default function AppointmentsPage() {
});
};
// Open chat window for the patient linked to this appointment
const handleChat = (appointmentId: number) => {
const apt = appointments.find((a) => a.id === appointmentId);
if (!apt) return;
const patient = patientsFromDay.find((p) => p.id === (apt as any).patientId);
if (!patient) return;
const processed = processedAppointments.find((a) => a.id === appointmentId);
setChatPatient(patient as Patient);
if (processed) {
setChatAppointmentInfo({
date: typeof processed.date === "string" ? processed.date : formatLocalDate(processed.date as Date),
startTime: typeof processed.startTime === "string" ? processed.startTime.substring(0, 5) : "",
});
} else {
setChatAppointmentInfo(undefined);
}
};
// Function to display context menu
const handleContextMenu = (e: React.MouseEvent, appointmentId: number) => {
// Prevent the default browser context menu
@@ -1478,6 +1502,14 @@ export default function AppointmentsPage() {
Claim Status
</span>
</Item>
{/* Chat */}
<Item onClick={({ props }) => handleChat(props.appointmentId)}>
<span className="flex items-center gap-2 text-blue-600">
<MessageSquare className="h-4 w-4" />
Chat
</span>
</Item>
</Menu>
{/* Main Content */}
@@ -1688,6 +1720,19 @@ export default function AppointmentsPage() {
</div>
)}
{/* Chat popup */}
{chatPatient && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg h-[600px] flex flex-col overflow-hidden">
<MessageThread
patient={chatPatient}
appointmentInfo={chatAppointmentInfo}
onBack={() => { setChatPatient(null); setChatAppointmentInfo(undefined); }}
/>
</div>
</div>
)}
{/* Select Procedures modal — stays on appointments page */}
{isSelectProceduresOpen && selectProceduresPatientId !== null && (
<ClaimForm

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
@@ -10,7 +10,14 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CheckCircle, LoaderCircleIcon, Bot, PhoneCall, ChevronDown, ChevronUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useToast } from "@/hooks/use-toast";
import { PatientTable } from "@/components/patients/patient-table";
@@ -57,6 +64,25 @@ export default function InsuranceStatusPage() {
const [isCheckingEligibilityClaimsPreAuth, setIsCheckingEligibilityClaimsPreAuth] =
useState(false);
// AI Call Insurance section
const [aiCallOpen, setAiCallOpen] = useState(false);
const [aiSelectedContactId, setAiSelectedContactId] = useState<string>("");
const [aiCallNotes, setAiCallNotes] = useState("");
type InsuranceContact = { id: number; name: string; phoneNumber?: string | null };
const { data: insuranceContacts = [] } = useQuery<InsuranceContact[]>({
queryKey: ["/api/insurance-contacts"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/insurance-contacts");
if (!res.ok) return [];
return res.json();
},
});
const selectedInsuranceContact = insuranceContacts.find(
(c) => String(c.id) === aiSelectedContactId
) ?? null;
// PDF preview modal state
const [previewOpen, setPreviewOpen] = useState(false);
const [previewPdfId, setPreviewPdfId] = useState<number | null>(null);
@@ -742,6 +768,119 @@ export default function InsuranceStatusPage() {
</CardContent>
</Card>
{/* AI Call Insurance */}
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setAiCallOpen((o) => !o)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-violet-100 flex items-center justify-center flex-shrink-0">
<Bot className="h-5 w-5 text-violet-600" />
</div>
<div>
<CardTitle className="text-base">AI Call Insurance</CardTitle>
<CardDescription className="mt-0.5">
Use AI to call the insurance company and check eligibility automatically
</CardDescription>
</div>
</div>
{aiCallOpen
? <ChevronUp className="h-4 w-4 text-muted-foreground" />
: <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</div>
</CardHeader>
{aiCallOpen && (
<CardContent className="space-y-5 pt-0">
{/* Patient context banner */}
{selectedPatient && (
<div className="flex items-center gap-2 text-sm bg-violet-50 border border-violet-200 rounded-md px-3 py-2">
<Bot className="h-4 w-4 text-violet-500 flex-shrink-0" />
<span>
Selected patient:{" "}
<span className="font-medium">
{selectedPatient.firstName} {selectedPatient.lastName}
</span>
{selectedPatient.insuranceId && (
<> &mdash; Member ID: <span className="font-medium">{selectedPatient.insuranceId}</span></>
)}
</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Insurance Company</Label>
{insuranceContacts.length === 0 ? (
<p className="text-sm text-muted-foreground italic py-2">
No insurance contacts saved.{" "}
<a href="/settings/insurancecontact" className="underline text-violet-600">
Add one in Settings Insurance Contact
</a>
</p>
) : (
<Select
value={aiSelectedContactId}
onValueChange={setAiSelectedContactId}
>
<SelectTrigger>
<SelectValue placeholder="Select insurance company…" />
</SelectTrigger>
<SelectContent>
{insuranceContacts.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-1.5">
<Label>Phone Number</Label>
<Input
readOnly
value={selectedInsuranceContact?.phoneNumber ?? ""}
placeholder="Auto-filled from insurance contact"
className="bg-gray-50 text-gray-600 cursor-default"
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="ai-call-notes">
Additional Notes for AI{" "}
<span className="text-muted-foreground font-normal">(Optional)</span>
</Label>
<Input
id="ai-call-notes"
placeholder="e.g. Ask about dental benefits and annual maximum"
value={aiCallNotes}
onChange={(e) => setAiCallNotes(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<Button
disabled
className="gap-2 bg-violet-600 hover:bg-violet-700 text-white opacity-60 cursor-not-allowed"
>
<PhoneCall className="h-4 w-4" />
Start AI Call
</Button>
<p className="text-xs text-muted-foreground italic">
{selectedInsuranceContact
? `Will call ${selectedInsuranceContact.name}${selectedInsuranceContact.phoneNumber ? ` at ${selectedInsuranceContact.phoneNumber}` : ""} — AI calling logic coming soon`
: "Select an insurance company to begin — AI calling logic coming soon"}
</p>
</div>
</CardContent>
)}
</Card>
{/* Patients Table */}
<Card>
<CardHeader>

View File

@@ -401,6 +401,24 @@ export default function PatientsPage() {
</div>
</div>
{/* Patients Table */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
View and manage all patient information
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable
allowDelete={true}
allowEdit={true}
allowView={true}
allowFinancial={true}
/>
</CardContent>
</Card>
{/* File Upload Zone */}
<div className="space-y-8 py-8">
<Card>
@@ -518,24 +536,6 @@ export default function PatientsPage() {
</Card>
</div>
{/* Patients Table */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
View and manage all patient information
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable
allowDelete={true}
allowEdit={true}
allowView={true}
allowFinancial={true}
/>
</CardContent>
</Card>
{/* Add/Edit Patient Modal */}
<AddPatientModal
ref={addPatientModalRef}

View File

@@ -18,6 +18,7 @@ import { AiSettingsCard } from "@/components/settings/ai-settings-card";
import { OfficeHoursCard } from "@/components/settings/office-hours-card";
import { OfficeContactCard } from "@/components/settings/office-contact-card";
import { ProcedureTimeslotCard } from "@/components/settings/procedure-timeslot-card";
import { InsuranceContactCard } from "@/components/settings/insurance-contact-card";
type SectionId =
| "staff"
@@ -30,7 +31,8 @@ type SectionId =
| "ai"
| "officehours"
| "officecontact"
| "proceduretimeslot";
| "proceduretimeslot"
| "insurancecontact";
export default function SettingsPage() {
const { toast } = useToast();
@@ -266,6 +268,9 @@ export default function SettingsPage() {
case "proceduretimeslot":
return <ProcedureTimeslotCard />;
case "insurancecontact":
return <InsuranceContactCard />;
default:
return null;
}

View File

@@ -42,6 +42,7 @@ model User {
officeHours OfficeHours?
officeContact OfficeContact?
procedureTimeslot ProcedureTimeslot?
insuranceContacts InsuranceContact[]
}
model Patient {
@@ -59,9 +60,10 @@ model Patient {
insuranceId String?
groupNumber String?
policyHolder String?
allergies String?
medicalConditions String?
status PatientStatus @default(UNKNOWN)
allergies String?
medicalConditions String?
preferredLanguage String? @default("English")
status PatientStatus @default(UNKNOWN)
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -593,6 +595,7 @@ model OfficeHours {
model OfficeContact {
id Int @id @default(autoincrement())
userId Int @unique
officeName String?
receptionistName String?
dentistName String?
phoneNumber String?
@@ -604,6 +607,18 @@ model OfficeContact {
@@map("office_contact")
}
model InsuranceContact {
id Int @id @default(autoincrement())
userId Int
name String
phoneNumber String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("insurance_contact")
}
model ProcedureTimeslot {
id Int @id @default(autoincrement())
userId Int @unique