diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 44f99a0d..f7879784 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -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; diff --git a/apps/Backend/src/routes/insurance-contacts.ts b/apps/Backend/src/routes/insurance-contacts.ts new file mode 100644 index 00000000..609a3f44 --- /dev/null +++ b/apps/Backend/src/routes/insurance-contacts.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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; diff --git a/apps/Backend/src/routes/office-contact.ts b/apps/Backend/src/routes/office-contact.ts index 56f693c5..29a3c55d 100644 --- a/apps/Backend/src/routes/office-contact.ts +++ b/apps/Backend/src/routes/office-contact.ts @@ -22,8 +22,9 @@ router.put("/", async (req: Request, res: Response): Promise => { 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, diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index d9667ad5..18c8c337 100755 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -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, }; diff --git a/apps/Backend/src/storage/insurance-contact-storage.ts b/apps/Backend/src/storage/insurance-contact-storage.ts new file mode 100644 index 00000000..8b0148f0 --- /dev/null +++ b/apps/Backend/src/storage/insurance-contact-storage.ts @@ -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; + }, +}; diff --git a/apps/Backend/src/storage/office-contact-storage.ts b/apps/Backend/src/storage/office-contact-storage.ts index 598d9736..7d402f13 100644 --- a/apps/Backend/src/storage/office-contact-storage.ts +++ b/apps/Backend/src/storage/office-contact-storage.ts @@ -6,6 +6,7 @@ export const officeContactStorage = { }, async upsertOfficeContact(userId: number, data: { + officeName?: string; receptionistName?: string; dentistName?: string; phoneNumber?: string; diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index bbc43396..368d27f1 100755 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -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: , }, + { + name: "Insurance Contact", + path: "/settings/insurancecontact", + icon: , + }, ], }, ], diff --git a/apps/Frontend/src/components/patient-connection/message-thread.tsx b/apps/Frontend/src/components/patient-connection/message-thread.tsx index eb18f220..619616e8 100755 --- a/apps/Frontend/src/components/patient-connection/message-thread.tsx +++ b/apps/Frontend/src/components/patient-connection/message-thread.tsx @@ -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> = { + 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 = { + 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 = { + 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( + (patient.preferredLanguage as Language | null | undefined) && + SUPPORTED_LANGUAGES.includes(patient.preferredLanguage as Language) + ? (patient.preferredLanguage as Language) + : "English" + ); const messagesEndRef = useRef(null); + const { data: officeContact } = useQuery({ + 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({ 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 (
{/* Header */} -
+
+ {/* Patient identity row */}
{onBack && ( - )} -
-
- {patient.firstName[0]} - {patient.lastName[0]} -
-
-

- {patient.firstName} {patient.lastName} -

-

{patient.phone}

-
+
+ {patient.firstName[0]}{patient.lastName[0]} +
+
+

+ {patient.firstName} {patient.lastName} +

+

{patient.phone}

+
+
+ + {/* Controls row: language + template */} +
+ {/* Language selector */} +
+ + +
+ + {/* Template selector */} +
+ +
{/* Messages */} -
+
{isLoading ? (

Loading messages...

) : communications.length === 0 ? (
-

- No messages yet. Start the conversation! -

+

No messages yet. Start the conversation!

) : ( <> {groupedMessages.map((group) => (
- {/* Date Divider */}
{getDateDivider(group.messages[0]?.createdAt!)}
- - {/* Messages for this date */} {group.messages.map((comm) => (
-
+
{comm.direction === "inbound" && (
- {patient.firstName[0]} - {patient.lastName[0]} + {patient.firstName[0]}{patient.lastName[0]}
-

- {comm.body} -

+

{comm.body}

- {comm.createdAt && - formatMessageDate(comm.createdAt)} + {comm.createdAt && formatMessageDate(comm.createdAt)}

)} - {comm.direction === "outbound" && (
-

- {comm.body} -

+

{comm.body}

- {comm.createdAt && - formatMessageDate(comm.createdAt)} + {comm.createdAt && formatMessageDate(comm.createdAt)}

)} diff --git a/apps/Frontend/src/components/patients/patient-form.tsx b/apps/Frontend/src/components/patients/patient-form.tsx index b0f5121c..0b0f2edf 100755 --- a/apps/Frontend/src/components/patients/patient-form.tsx +++ b/apps/Frontend/src/components/patients/patient-form.tsx @@ -86,6 +86,7 @@ export const PatientForm = forwardRef( policyHolder: "", allergies: "", medicalConditions: "", + preferredLanguage: "English", status: "UNKNOWN", userId: user?.id, }; @@ -208,6 +209,36 @@ export const PatientForm = forwardRef( )} /> + + ( + + Preferred Language + + + + )} + />
diff --git a/apps/Frontend/src/components/settings/insurance-contact-card.tsx b/apps/Frontend/src/components/settings/insurance-contact-card.tsx new file mode 100644 index 00000000..3d8ec8b5 --- /dev/null +++ b/apps/Frontend/src/components/settings/insurance-contact-card.tsx @@ -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(null); + const [form, setForm] = useState(EMPTY_FORM); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data: contacts = [], isLoading } = useQuery({ + 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 ( +
+ {/* Header */} +
+
+

Insurance Contacts

+

Phone numbers for insurance companies

+
+ +
+ + {/* Add form */} + {showForm && ( +
+

New Insurance Contact

+
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + className="h-9 text-sm" + /> +
+
+ + setForm((f) => ({ ...f, phoneNumber: e.target.value }))} + className="h-9 text-sm" + /> +
+
+
+ + +
+
+ )} + + {/* Table */} +
+ + + + + + + + + {isLoading ? ( + + + + ) : contacts.length === 0 ? ( + + + + ) : ( + contacts.map((c) => + editingId === c.id ? ( + /* Inline edit row */ + + + + + + ) : ( + /* Display row */ + + + + + + ) + ) + )} + +
+ Insurance Company + + Phone Number + +
+ Loading... +
+ No insurance contacts yet. Click "Add Contact" to add one. +
+ setForm((f) => ({ ...f, name: e.target.value }))} + className="h-8 text-sm" + placeholder="Company name" + /> + + setForm((f) => ({ ...f, phoneNumber: e.target.value }))} + className="h-8 text-sm" + placeholder="Phone number" + /> + + + +
{c.name} + {c.phoneNumber || } + + + +
+
+ + deleteTarget && deleteMutation.mutate(deleteTarget.id)} + onCancel={() => setDeleteTarget(null)} + entityName={deleteTarget?.name} + /> +
+ ); +} diff --git a/apps/Frontend/src/components/settings/office-contact-card.tsx b/apps/Frontend/src/components/settings/office-contact-card.tsx index ec8b6713..d7006770 100644 --- a/apps/Frontend/src/components/settings/office-contact-card.tsx +++ b/apps/Frontend/src/components/settings/office-contact-card.tsx @@ -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() {

Loading...

) : (
+
+ + setOfficeName(e.target.value)} + className="mt-1 p-2 border rounded w-full text-sm" + placeholder="e.g. Summit Dental Care" + /> +
+
diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 720efe73..804a330a 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -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(null); const [selectProceduresAppointmentId, setSelectProceduresAppointmentId] = useState(null); + // Chat popup state + const [chatPatient, setChatPatient] = useState(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 + + {/* Chat */} + handleChat(props.appointmentId)}> + + + Chat + + {/* Main Content */} @@ -1688,6 +1720,19 @@ export default function AppointmentsPage() {
)} + {/* Chat popup */} + {chatPatient && ( +
+
+ { setChatPatient(null); setChatAppointmentInfo(undefined); }} + /> +
+
+ )} + {/* Select Procedures modal — stays on appointments page */} {isSelectProceduresOpen && selectProceduresPatientId !== null && ( (""); + const [aiCallNotes, setAiCallNotes] = useState(""); + + type InsuranceContact = { id: number; name: string; phoneNumber?: string | null }; + const { data: insuranceContacts = [] } = useQuery({ + 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(null); @@ -742,6 +768,119 @@ export default function InsuranceStatusPage() { + {/* AI Call Insurance */} + + setAiCallOpen((o) => !o)} + > +
+
+
+ +
+
+ AI Call Insurance + + Use AI to call the insurance company and check eligibility automatically + +
+
+ {aiCallOpen + ? + : } +
+
+ + {aiCallOpen && ( + + {/* Patient context banner */} + {selectedPatient && ( +
+ + + Selected patient:{" "} + + {selectedPatient.firstName} {selectedPatient.lastName} + + {selectedPatient.insuranceId && ( + <> — Member ID: {selectedPatient.insuranceId} + )} + +
+ )} + +
+
+ + {insuranceContacts.length === 0 ? ( +

+ No insurance contacts saved.{" "} + + Add one in Settings → Insurance Contact + +

+ ) : ( + + )} +
+ +
+ + +
+ +
+ + setAiCallNotes(e.target.value)} + /> +
+
+ +
+ +

+ {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"} +

+
+
+ )} +
+ {/* Patients Table */} diff --git a/apps/Frontend/src/pages/patients-page.tsx b/apps/Frontend/src/pages/patients-page.tsx index 9624ac9e..5eb7613f 100755 --- a/apps/Frontend/src/pages/patients-page.tsx +++ b/apps/Frontend/src/pages/patients-page.tsx @@ -401,6 +401,24 @@ export default function PatientsPage() {
+ {/* Patients Table */} + + + Patient Records + + View and manage all patient information + + + + + + + {/* File Upload Zone */}
@@ -518,24 +536,6 @@ export default function PatientsPage() {
- {/* Patients Table */} - - - Patient Records - - View and manage all patient information - - - - - - - {/* Add/Edit Patient Modal */} ; + case "insurancecontact": + return ; + default: return null; } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0dd8d0d7..d06a7238 100755 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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