feat(patient-connection-page demo added)
This commit is contained in:
@@ -12,6 +12,7 @@ import Dashboard from "./pages/dashboard";
|
|||||||
import LoadingScreen from "./components/ui/LoadingScreen";
|
import LoadingScreen from "./components/ui/LoadingScreen";
|
||||||
|
|
||||||
const AuthPage = lazy(() => import("./pages/auth-page"));
|
const AuthPage = lazy(() => import("./pages/auth-page"));
|
||||||
|
const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page"));
|
||||||
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
||||||
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
||||||
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
||||||
@@ -34,6 +35,7 @@ function Router() {
|
|||||||
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status" />} />
|
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status" />} />
|
||||||
|
|
||||||
<ProtectedRoute path="/dashboard" component={() => <Dashboard />} />
|
<ProtectedRoute path="/dashboard" component={() => <Dashboard />} />
|
||||||
|
<ProtectedRoute path="/patient-connection" component={() => <PatientConnectionPage />} />
|
||||||
<ProtectedRoute
|
<ProtectedRoute
|
||||||
path="/appointments"
|
path="/appointments"
|
||||||
component={() => <AppointmentsPage />}
|
component={() => <AppointmentsPage />}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
FileText,
|
FileText,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
Phone,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -27,6 +28,11 @@ export function Sidebar() {
|
|||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Patient Connection",
|
||||||
|
path: "/patient-connection",
|
||||||
|
icon: <Phone className="h-5 w-5" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Appointments",
|
name: "Appointments",
|
||||||
path: "/appointments",
|
path: "/appointments",
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { Send, ArrowLeft } from "lucide-react";
|
||||||
|
import type { Patient, Communication } from "@repo/db/types";
|
||||||
|
import { format, isToday, isYesterday, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
interface MessageThreadProps {
|
||||||
|
patient: Patient;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageThread({ patient, onBack }: MessageThreadProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [messageText, setMessageText] = useState("");
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: communications = [], isLoading } = useQuery<Communication[]>({
|
||||||
|
queryKey: ["/api/patients", patient.id, "communications"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/patients/${patient.id}/communications`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch communications");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 5000, // Refresh every 5 seconds to get new messages
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMessageMutation = useMutation({
|
||||||
|
mutationFn: async (message: string) => {
|
||||||
|
return apiRequest("POST", "/api/twilio/send-sms", {
|
||||||
|
to: patient.phone,
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Failed to send message",
|
||||||
|
description:
|
||||||
|
error.message || "Unable to send message. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!messageText.trim()) return;
|
||||||
|
sendMessageMutation.mutate(messageText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [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 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 groupedMessages: { date: string; messages: Communication[] }[] = [];
|
||||||
|
communications.forEach((comm) => {
|
||||||
|
if (!comm.createdAt) return;
|
||||||
|
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] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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="flex items-center gap-3">
|
||||||
|
{onBack && (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<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>
|
||||||
|
</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"}`}
|
||||||
|
>
|
||||||
|
{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]}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 text-right">
|
||||||
|
{comm.createdAt &&
|
||||||
|
formatMessageDate(comm.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="p-4 border-t bg-gray-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={messageText}
|
||||||
|
onChange={(e) => setMessageText(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="flex-1 rounded-full"
|
||||||
|
disabled={sendMessageMutation.isPending}
|
||||||
|
data-testid="input-message"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!messageText.trim() || sendMessageMutation.isPending}
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full h-10 w-10"
|
||||||
|
data-testid="button-send"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { 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 } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import type { Patient } from "@repo/db/types";
|
||||||
|
|
||||||
|
interface SmsTemplateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
patient: Patient | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_TEMPLATES = {
|
||||||
|
appointment_reminder: {
|
||||||
|
name: "Appointment Reminder",
|
||||||
|
template: (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.`,
|
||||||
|
},
|
||||||
|
appointment_confirmation: {
|
||||||
|
name: "Appointment Confirmation",
|
||||||
|
template: (firstName: string) =>
|
||||||
|
`Hi ${firstName}, your appointment has been confirmed. We look forward to seeing you! If you have any questions, please call our office.`,
|
||||||
|
},
|
||||||
|
follow_up: {
|
||||||
|
name: "Follow-Up",
|
||||||
|
template: (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.`,
|
||||||
|
},
|
||||||
|
payment_reminder: {
|
||||||
|
name: "Payment Reminder",
|
||||||
|
template: (firstName: string) =>
|
||||||
|
`Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`,
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
name: "General Message",
|
||||||
|
template: (firstName: string) =>
|
||||||
|
`Hi ${firstName}, this is your dental office. `,
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
name: "Custom Message",
|
||||||
|
template: () => "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SmsTemplateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
patient,
|
||||||
|
}: SmsTemplateDialogProps) {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<
|
||||||
|
keyof typeof MESSAGE_TEMPLATES
|
||||||
|
>("appointment_reminder");
|
||||||
|
const [customMessage, setCustomMessage] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const sendSmsMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
patientId,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
patientId: number;
|
||||||
|
}) => {
|
||||||
|
return apiRequest("POST", "/api/twilio/send-sms", {
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
patientId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "SMS Sent Successfully",
|
||||||
|
description: `Message sent to ${patient?.firstName} ${patient?.lastName}`,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset state
|
||||||
|
setSelectedTemplate("appointment_reminder");
|
||||||
|
setCustomMessage("");
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Failed to Send SMS",
|
||||||
|
description:
|
||||||
|
error.message ||
|
||||||
|
"Please check your Twilio configuration and try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMessage = () => {
|
||||||
|
if (!patient) return "";
|
||||||
|
|
||||||
|
if (selectedTemplate === "custom") {
|
||||||
|
return customMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MESSAGE_TEMPLATES[selectedTemplate].template(patient.firstName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!patient || !patient.phone) return;
|
||||||
|
|
||||||
|
const message = getMessage();
|
||||||
|
if (!message.trim()) return;
|
||||||
|
|
||||||
|
sendSmsMutation.mutate({
|
||||||
|
to: patient.phone,
|
||||||
|
message: message,
|
||||||
|
patientId: Number(patient.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateChange = (value: string) => {
|
||||||
|
const templateKey = value as keyof typeof MESSAGE_TEMPLATES;
|
||||||
|
setSelectedTemplate(templateKey);
|
||||||
|
|
||||||
|
// Pre-fill custom message if not custom template
|
||||||
|
if (templateKey !== "custom" && patient) {
|
||||||
|
setCustomMessage(
|
||||||
|
MESSAGE_TEMPLATES[templateKey].template(patient.firstName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 message template or write a custom message
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="template">Message Template</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedTemplate}
|
||||||
|
onValueChange={handleTemplateChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="template" data-testid="select-sms-template">
|
||||||
|
<SelectValue placeholder="Select a template" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(MESSAGE_TEMPLATES).map(([key, value]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{value.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="message">Message Preview</Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
value={
|
||||||
|
selectedTemplate === "custom" ? customMessage : getMessage()
|
||||||
|
}
|
||||||
|
onChange={(e) => setCustomMessage(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={handleSend}
|
||||||
|
disabled={
|
||||||
|
!patient?.phone ||
|
||||||
|
!getMessage().trim() ||
|
||||||
|
sendSmsMutation.isPending
|
||||||
|
}
|
||||||
|
className="gap-2"
|
||||||
|
data-testid="button-send-sms"
|
||||||
|
>
|
||||||
|
{sendSmsMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{sendSmsMutation.isPending ? "Sending..." : "Send SMS"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
380
apps/Frontend/src/pages/patient-connection-page.tsx
Normal file
380
apps/Frontend/src/pages/patient-connection-page.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
import { TopAppBar } from "@/components/layout/top-app-bar";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
PhoneCall,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Search,
|
||||||
|
MessageSquare,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
|
||||||
|
import { MessageThread } from "@/components/patient-connection/message-thread";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import type { Patient } from "@repo/db/types";
|
||||||
|
|
||||||
|
export default function PatientConnectionPage() {
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||||
|
const [isSmsDialogOpen, setIsSmsDialogOpen] = useState(false);
|
||||||
|
const [showMessaging, setShowMessaging] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const toggleMobileMenu = () => {
|
||||||
|
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeCallMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
to,
|
||||||
|
patientId,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
patientId: number;
|
||||||
|
}) => {
|
||||||
|
return apiRequest("POST", "/api/twilio/make-call", {
|
||||||
|
to,
|
||||||
|
message:
|
||||||
|
"Hello, this is a call from your dental office. We are calling to connect with you.",
|
||||||
|
patientId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Call Initiated",
|
||||||
|
description: "The call has been placed successfully.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Call Failed",
|
||||||
|
description:
|
||||||
|
error.message || "Unable to place the call. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all patients from database
|
||||||
|
const { data: patients = [], isLoading } = useQuery<Patient[]>({
|
||||||
|
queryKey: ["/api/patients"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter patients based on search term
|
||||||
|
const filteredPatients = patients.filter((patient) => {
|
||||||
|
if (!searchTerm) return false;
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
const fullName = `${patient.firstName} ${patient.lastName}`.toLowerCase();
|
||||||
|
const phone = patient.phone?.toLowerCase() || "";
|
||||||
|
const patientId = patient.id?.toString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
fullName.includes(searchLower) ||
|
||||||
|
phone.includes(searchLower) ||
|
||||||
|
patientId?.includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle calling patient via Twilio
|
||||||
|
const handleCall = (patient: Patient) => {
|
||||||
|
if (patient.phone) {
|
||||||
|
makeCallMutation.mutate({
|
||||||
|
to: patient.phone,
|
||||||
|
patientId: Number(patient.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle sending SMS
|
||||||
|
const handleSMS = (patient: Patient) => {
|
||||||
|
setSelectedPatient(patient);
|
||||||
|
setIsSmsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle opening messaging
|
||||||
|
const handleOpenMessaging = (patient: Patient) => {
|
||||||
|
setSelectedPatient(patient);
|
||||||
|
setShowMessaging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle closing messaging
|
||||||
|
const handleCloseMessaging = () => {
|
||||||
|
setShowMessaging(false);
|
||||||
|
setSelectedPatient(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sample call data
|
||||||
|
const recentCalls = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
patientName: "John Bill",
|
||||||
|
phoneNumber: "(555) 123-4567",
|
||||||
|
callType: "Appointment Request",
|
||||||
|
status: "Completed",
|
||||||
|
duration: "3:45",
|
||||||
|
time: "2 hours ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
patientName: "Emily Brown",
|
||||||
|
phoneNumber: "(555) 987-6543",
|
||||||
|
callType: "Insurance Question",
|
||||||
|
status: "Follow-up Required",
|
||||||
|
duration: "6:12",
|
||||||
|
time: "4 hours ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
patientName: "Mike Johnson",
|
||||||
|
phoneNumber: "(555) 456-7890",
|
||||||
|
callType: "Prescription Refill",
|
||||||
|
status: "Completed",
|
||||||
|
duration: "2:30",
|
||||||
|
time: "6 hours ago",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const callStats = [
|
||||||
|
{
|
||||||
|
label: "Total Calls Today",
|
||||||
|
value: "23",
|
||||||
|
icon: <Phone className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Answered Calls",
|
||||||
|
value: "21",
|
||||||
|
icon: <PhoneCall className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Average Call Time",
|
||||||
|
value: "4:32",
|
||||||
|
icon: <Clock className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active Patients",
|
||||||
|
value: patients.length.toString(),
|
||||||
|
icon: <User className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="container mx-auto space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
Patient Connection
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Search and communicate with patients
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call Statistics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
{callStats.map((stat, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{stat.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-primary">{stat.icon}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Actions */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Search Patients</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search by name, phone number, or patient ID
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4 mb-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search patient by name, phone, or ID..."
|
||||||
|
className="pl-8"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
data-testid="input-patient-search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{searchTerm && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Loading patients...
|
||||||
|
</p>
|
||||||
|
) : filteredPatients.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium mb-2">
|
||||||
|
Search Results ({filteredPatients.length})
|
||||||
|
</p>
|
||||||
|
{filteredPatients.map((patient) => (
|
||||||
|
<div
|
||||||
|
key={patient.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
data-testid={`patient-result-${patient.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{patient.firstName} {patient.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{patient.phone || "No phone number"} • ID:{" "}
|
||||||
|
{patient.id}
|
||||||
|
</p>
|
||||||
|
{patient.email && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{patient.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCall(patient)}
|
||||||
|
disabled={!patient.phone}
|
||||||
|
data-testid={`button-call-${patient.id}`}
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4 mr-1" />
|
||||||
|
Call
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenMessaging(patient)}
|
||||||
|
disabled={!patient.phone}
|
||||||
|
data-testid={`button-chat-${patient.id}`}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4 mr-1" />
|
||||||
|
Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No patients found matching "{searchTerm}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Calls */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Calls</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View and manage recent patient calls
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentCalls.map((call) => (
|
||||||
|
<div
|
||||||
|
key={call.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Phone className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{call.patientName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{call.phoneNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{call.callType}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Duration: {call.duration}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
call.status === "Completed" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{call.status}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs text-muted-foreground">{call.time}</p>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<PhoneCall className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* SMS Template Dialog */}
|
||||||
|
<SmsTemplateDialog
|
||||||
|
open={isSmsDialogOpen}
|
||||||
|
onOpenChange={setIsSmsDialogOpen}
|
||||||
|
patient={selectedPatient}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Messaging Interface */}
|
||||||
|
{showMessaging && selectedPatient && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-4xl h-[80vh] bg-white rounded-lg shadow-xl relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-4 right-4 z-10"
|
||||||
|
onClick={handleCloseMessaging}
|
||||||
|
data-testid="button-close-messaging"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="h-full">
|
||||||
|
<MessageThread
|
||||||
|
patient={selectedPatient}
|
||||||
|
onBack={handleCloseMessaging}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,13 +32,14 @@ model User {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
cloudFolders CloudFolder[]
|
cloudFolders CloudFolder[]
|
||||||
cloudFiles CloudFile[]
|
cloudFiles CloudFile[]
|
||||||
|
communications Communication[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Patient {
|
model Patient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
dateOfBirth DateTime @db.Date
|
dateOfBirth DateTime @db.Date
|
||||||
gender String
|
gender String
|
||||||
phone String
|
phone String
|
||||||
email String?
|
email String?
|
||||||
@@ -51,14 +52,15 @@ model Patient {
|
|||||||
policyHolder String?
|
policyHolder String?
|
||||||
allergies String?
|
allergies String?
|
||||||
medicalConditions String?
|
medicalConditions String?
|
||||||
status PatientStatus @default(UNKNOWN)
|
status PatientStatus @default(UNKNOWN)
|
||||||
userId Int
|
userId Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
claims Claim[]
|
claims Claim[]
|
||||||
groups PdfGroup[]
|
groups PdfGroup[]
|
||||||
payment Payment[]
|
payment Payment[]
|
||||||
|
communications Communication[]
|
||||||
|
|
||||||
@@index([insuranceId])
|
@@index([insuranceId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@ -109,22 +111,22 @@ model Staff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Claim {
|
model Claim {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
patientId Int
|
patientId Int
|
||||||
appointmentId Int
|
appointmentId Int
|
||||||
userId Int
|
userId Int
|
||||||
staffId Int
|
staffId Int
|
||||||
patientName String
|
patientName String
|
||||||
memberId String
|
memberId String
|
||||||
dateOfBirth DateTime @db.Date
|
dateOfBirth DateTime @db.Date
|
||||||
remarks String
|
remarks String
|
||||||
missingTeethStatus MissingTeethStatus @default(No_missing)
|
missingTeethStatus MissingTeethStatus @default(No_missing)
|
||||||
missingTeeth Json? // { "T_14": "X", "T_G": "O", ... }
|
missingTeeth Json? // { "T_14": "X", "T_G": "O", ... }
|
||||||
serviceDate DateTime
|
serviceDate DateTime
|
||||||
insuranceProvider String // e.g., "Delta MA"
|
insuranceProvider String // e.g., "Delta MA"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
status ClaimStatus @default(PENDING)
|
status ClaimStatus @default(PENDING)
|
||||||
|
|
||||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||||
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
||||||
@@ -145,7 +147,7 @@ enum ClaimStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum MissingTeethStatus {
|
enum MissingTeethStatus {
|
||||||
No_missing
|
No_missing
|
||||||
endentulous
|
endentulous
|
||||||
Yes_missing
|
Yes_missing
|
||||||
}
|
}
|
||||||
@@ -378,3 +380,46 @@ model CloudFileChunk {
|
|||||||
@@unique([fileId, seq])
|
@@unique([fileId, seq])
|
||||||
@@index([fileId, seq])
|
@@index([fileId, seq])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// patient-connection-
|
||||||
|
enum CommunicationChannel {
|
||||||
|
sms
|
||||||
|
voice
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CommunicationDirection {
|
||||||
|
outbound
|
||||||
|
inbound
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CommunicationStatus {
|
||||||
|
queued
|
||||||
|
sent
|
||||||
|
delivered
|
||||||
|
failed
|
||||||
|
completed
|
||||||
|
busy
|
||||||
|
no_answer
|
||||||
|
}
|
||||||
|
|
||||||
|
model Communication {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
patientId Int
|
||||||
|
userId Int?
|
||||||
|
|
||||||
|
channel CommunicationChannel
|
||||||
|
direction CommunicationDirection
|
||||||
|
status CommunicationStatus
|
||||||
|
|
||||||
|
body String?
|
||||||
|
callDuration Int?
|
||||||
|
twilioSid String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
patient Patient @relation(fields: [patientId], references: [id])
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@map("communications")
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export * from "./user-types";
|
|||||||
export * from "./databaseBackup-types";
|
export * from "./databaseBackup-types";
|
||||||
export * from "./notifications-types";
|
export * from "./notifications-types";
|
||||||
export * from "./cloudStorage-types";
|
export * from "./cloudStorage-types";
|
||||||
export * from "./payments-reports-types";
|
export * from "./payments-reports-types";
|
||||||
|
export * from "./patientConnection-types";
|
||||||
42
packages/db/types/patientConnection-types.ts
Normal file
42
packages/db/types/patientConnection-types.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { CommunicationUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full Communication type (Prisma unchecked create input)
|
||||||
|
*/
|
||||||
|
export type Communication = z.infer<
|
||||||
|
typeof CommunicationUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert Communication
|
||||||
|
* - excludes auto-generated fields
|
||||||
|
*/
|
||||||
|
export const insertCommunicationSchema = (
|
||||||
|
CommunicationUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertCommunication = z.infer<
|
||||||
|
typeof insertCommunicationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Communication
|
||||||
|
* - excludes immutable fields
|
||||||
|
* - makes everything optional
|
||||||
|
*/
|
||||||
|
export const updateCommunicationSchema = (
|
||||||
|
CommunicationUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
)
|
||||||
|
.omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
})
|
||||||
|
.partial();
|
||||||
|
|
||||||
|
export type UpdateCommunication = z.infer<
|
||||||
|
typeof updateCommunicationSchema
|
||||||
|
>;
|
||||||
@@ -17,4 +17,5 @@ export * from '../shared/schemas/enums/NotificationTypes.schema'
|
|||||||
export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/CloudFolderUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/CloudFolderUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema'
|
||||||
|
export * from '../shared/schemas/objects/CommunicationUncheckedCreateInput.schema'
|
||||||
Reference in New Issue
Block a user