227 lines
7.9 KiB
TypeScript
Executable File
227 lines
7.9 KiB
TypeScript
Executable File
import { useState, useEffect } from "react";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { MessageSquare, Send, Loader2, Save } from "lucide-react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
import type { Patient } from "@repo/db/types";
|
|
|
|
interface SmsTemplateDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
patient: Patient | null;
|
|
}
|
|
|
|
const DEFAULT_TEMPLATES: Record<string, { name: string; body: string }> = {
|
|
appointment_reminder: {
|
|
name: "Appointment Reminder",
|
|
body: (firstName: string) =>
|
|
`Hi ${firstName}, this is your dental office. Reminder: You have an appointment scheduled. Please confirm or call us if you need to reschedule.`,
|
|
} as any,
|
|
appointment_confirmation: {
|
|
name: "Appointment Confirmation",
|
|
body: (firstName: string) =>
|
|
`Hi ${firstName}, your appointment has been confirmed. We look forward to seeing you! If you have any questions, please call our office.`,
|
|
} as any,
|
|
follow_up: {
|
|
name: "Follow-Up",
|
|
body: (firstName: string) =>
|
|
`Hi ${firstName}, thank you for visiting our dental office. How are you feeling after your treatment? Please let us know if you have any concerns.`,
|
|
} as any,
|
|
payment_reminder: {
|
|
name: "Payment Reminder",
|
|
body: (firstName: string) =>
|
|
`Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`,
|
|
} as any,
|
|
general: {
|
|
name: "General Message",
|
|
body: (firstName: string) => `Hi ${firstName}, this is your dental office. `,
|
|
} as any,
|
|
custom: {
|
|
name: "Custom Message",
|
|
body: () => "",
|
|
} as any,
|
|
};
|
|
|
|
const TEMPLATE_KEYS = Object.keys(DEFAULT_TEMPLATES);
|
|
|
|
function getDefaultBody(key: string, firstName: string): string {
|
|
const t = DEFAULT_TEMPLATES[key];
|
|
if (!t) return "";
|
|
return typeof t.body === "function" ? (t.body as any)(firstName) : t.body;
|
|
}
|
|
|
|
export function SmsTemplateDialog({
|
|
open,
|
|
onOpenChange,
|
|
patient,
|
|
}: SmsTemplateDialogProps) {
|
|
const [selectedKey, setSelectedKey] = useState("appointment_reminder");
|
|
const [messageText, setMessageText] = useState("");
|
|
const { toast } = useToast();
|
|
|
|
const { data: savedTemplates = {} } = useQuery<Record<string, string>>({
|
|
queryKey: ["/api/twilio/templates"],
|
|
enabled: open,
|
|
});
|
|
|
|
// Resolve effective body for a given key (saved override or default)
|
|
const resolveBody = (key: string) => {
|
|
if (key === "custom") return "";
|
|
if (savedTemplates[key]) {
|
|
// Replace placeholder first name with actual patient name
|
|
return savedTemplates[key].replace(/^Hi \w+,/, `Hi ${patient?.firstName ?? ""},`);
|
|
}
|
|
return getDefaultBody(key, patient?.firstName ?? "");
|
|
};
|
|
|
|
// When dialog opens or template changes, populate message
|
|
useEffect(() => {
|
|
if (open) setMessageText(resolveBody(selectedKey));
|
|
}, [open, selectedKey, savedTemplates, patient?.firstName]);
|
|
|
|
const sendMutation = useMutation({
|
|
mutationFn: async (message: string) => {
|
|
return apiRequest("POST", "/api/twilio/send-sms", {
|
|
to: patient!.phone,
|
|
message,
|
|
patientId: patient!.id,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
toast({ title: "SMS Sent", description: `Message sent to ${patient?.firstName} ${patient?.lastName}` });
|
|
onOpenChange(false);
|
|
setSelectedKey("appointment_reminder");
|
|
setMessageText("");
|
|
},
|
|
onError: (err: any) => {
|
|
toast({ title: "Failed to Send SMS", description: err.message || "Please check your Twilio configuration.", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: async ({ key, body }: { key: string; body: string }) => {
|
|
return apiRequest("PUT", `/api/twilio/templates/${key}`, { body });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/twilio/templates"] });
|
|
toast({ title: "Template Updated", description: "This template will be used going forward." });
|
|
},
|
|
onError: (err: any) => {
|
|
toast({ title: "Failed to Update Template", description: err.message, variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const handleTemplateChange = (key: string) => {
|
|
setSelectedKey(key);
|
|
setMessageText(resolveBody(key));
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<MessageSquare className="h-5 w-5" />
|
|
Send SMS to {patient?.firstName} {patient?.lastName}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Choose a template or write a custom message
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label>Message Template</Label>
|
|
<Select value={selectedKey} onValueChange={handleTemplateChange}>
|
|
<SelectTrigger data-testid="select-sms-template">
|
|
<SelectValue placeholder="Select a template" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TEMPLATE_KEYS.map((key) => (
|
|
<SelectItem key={key} value={key}>
|
|
{DEFAULT_TEMPLATES[key]?.name ?? key}
|
|
{savedTemplates[key] ? " ✎" : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Message</Label>
|
|
{selectedKey !== "custom" && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-xs gap-1"
|
|
disabled={!messageText.trim() || updateMutation.isPending}
|
|
onClick={() => updateMutation.mutate({ key: selectedKey, body: messageText })}
|
|
>
|
|
{updateMutation.isPending ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Save className="h-3 w-3" />
|
|
)}
|
|
{updateMutation.isPending ? "Saving..." : "Update Template"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Textarea
|
|
value={messageText}
|
|
onChange={(e) => setMessageText(e.target.value)}
|
|
placeholder="Type your message here..."
|
|
rows={5}
|
|
className="resize-none"
|
|
data-testid="textarea-sms-message"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{patient?.phone
|
|
? `Will be sent to: ${patient.phone}`
|
|
: "No phone number available"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => sendMutation.mutate(messageText)}
|
|
disabled={!patient?.phone || !messageText.trim() || sendMutation.isPending || !patient}
|
|
className="gap-2"
|
|
data-testid="button-send-sms"
|
|
>
|
|
{sendMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="h-4 w-4" />
|
|
)}
|
|
{sendMutation.isPending ? "Sending..." : "Send SMS"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|