diff --git a/public/locale/en.json b/public/locale/en.json index 53586b56e86..0d34499cb35 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -53,6 +53,7 @@ "ENCOUNTER_TAB__files": "Files", "ENCOUNTER_TAB__medicines": "Medicines", "ENCOUNTER_TAB__neurological_monitoring": "Neuro", + "ENCOUNTER_TAB__notes": "Notes", "ENCOUNTER_TAB__nursing": "Nursing", "ENCOUNTER_TAB__plots": "Plots", "ENCOUNTER_TAB__pressure_sore": "Pressure Sore", @@ -125,8 +126,8 @@ "NURSING_CARE_PROCEDURE__positioning": "Positioning", "NURSING_CARE_PROCEDURE__pre_enema": "P.R.E. Enema", "NURSING_CARE_PROCEDURE__restrain": "Restrain", - "NURSING_CARE_PROCEDURE__ryles_tube_care": "Ryle’s Tube Care", - "NURSING_CARE_PROCEDURE__ryles_tube_change": "Ryle’s Tube Change", + "NURSING_CARE_PROCEDURE__ryles_tube_care": "Ryle's Tube Care", + "NURSING_CARE_PROCEDURE__ryles_tube_change": "Ryle's Tube Change", "NURSING_CARE_PROCEDURE__skin_care": "Skin Care", "NURSING_CARE_PROCEDURE__stoma_care": "Stoma Care", "NURSING_CARE_PROCEDURE__suctioning": "Suctioning", @@ -826,6 +827,23 @@ "encounter_discharge_disposition__snf": "Skilled nursing facility", "encounter_duration_confirmation": "The duration of this encounter would be", "encounter_id": "Encounter ID", + "encounter_notes__all_discussions": "All Discussions", + "encounter_notes__be_first_to_send": "Be the first to send a message", + "encounter_notes__choose_template": "Choose a template or enter a custom title", + "encounter_notes__create_discussion": "Create a new discussion thread to organize your conversation topics.", + "encounter_notes__discussions": "Discussions", + "encounter_notes__enter_discussion_title": "Enter discussion title...", + "encounter_notes__failed_create_thread": "Failed to create thread", + "encounter_notes__failed_send_message": "Failed to send message", + "encounter_notes__new": "New", + "encounter_notes__no_discussions": "No discussions yet", + "encounter_notes__select_create_thread": "Select or create a thread to start messaging", + "encounter_notes__start_conversation": "Start the Conversation", + "encounter_notes__start_new_discussion": "Start New Discussion", + "encounter_notes__thread_created": "Thread created successfully", + "encounter_notes__type_message": "Type your message...", + "encounter_notes__welcome": "Welcome to Discussions", + "encounter_notes__welcome_description": "Start a new discussion or select an existing thread to begin messaging", "encounter_priority__ASAP": "ASAP", "encounter_priority__as_needed": "As needed", "encounter_priority__asap": "ASAP", diff --git a/src/Utils/request/api.tsx b/src/Utils/request/api.tsx index a52eb23df38..773aba670a1 100644 --- a/src/Utils/request/api.tsx +++ b/src/Utils/request/api.tsx @@ -42,6 +42,8 @@ import { FacilityOrganizationCreate, FacilityOrganizationResponse, } from "@/types/facilityOrganization/facilityOrganization"; +import { Message } from "@/types/notes/messages"; +import { Thread } from "@/types/notes/threads"; import { OrganizationUserRole, RoleResponse, @@ -648,6 +650,35 @@ const routes = { }, }, + // Notes Routes + notes: { + patient: { + listThreads: { + path: "/api/v1/patient/{patientId}/thread/", + method: "GET", + TRes: Type>(), + TQuery: Type<{ encounter: string }>(), + }, + createThread: { + path: "/api/v1/patient/{patientId}/thread/", + method: "POST", + TRes: Type(), + TBody: Type<{ title: string; encounter: string }>(), + }, + getMessages: { + path: "/api/v1/patient/{patientId}/thread/{threadId}/note/", + method: "GET", + TRes: Type>(), + }, + postMessage: { + path: "/api/v1/patient/{patientId}/thread/{threadId}/note/", + method: "POST", + TRes: Type(), + TBody: Type<{ message: string }>(), + }, + }, + }, + // Encounter Routes encounter: { list: { diff --git a/src/pages/Encounters/EncounterShow.tsx b/src/pages/Encounters/EncounterShow.tsx index 9cff519d57d..fd494760724 100644 --- a/src/pages/Encounters/EncounterShow.tsx +++ b/src/pages/Encounters/EncounterShow.tsx @@ -21,6 +21,8 @@ import { EncounterUpdatesTab } from "@/pages/Encounters/tabs/EncounterUpdatesTab import { Encounter } from "@/types/emr/encounter"; import { Patient } from "@/types/emr/newPatient"; +import { EncounterNotesTab } from "./tabs/EncounterNotesTab"; + export interface EncounterTabProps { facilityId: string; encounter: Encounter; @@ -33,6 +35,7 @@ const defaultTabs = { plots: EncounterPlotsTab, medicines: EncounterMedicinesTab, files: EncounterFilesTab, + notes: EncounterNotesTab, // nursing: EncounterNursingTab, // neurological_monitoring: EncounterNeurologicalMonitoringTab, // pressure_sore: EncounterPressureSoreTab, diff --git a/src/pages/Encounters/tabs/EncounterNotesTab.tsx b/src/pages/Encounters/tabs/EncounterNotesTab.tsx new file mode 100644 index 00000000000..d8a93b592c5 --- /dev/null +++ b/src/pages/Encounters/tabs/EncounterNotesTab.tsx @@ -0,0 +1,677 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + Info, + Loader2, + MessageCircle, + MessageSquarePlus, + Plus, + Send, + Users, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useInView } from "react-intersection-observer"; +import { toast } from "sonner"; + +import { cn } from "@/lib/utils"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Markdown } from "@/components/ui/markdown"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { Avatar } from "@/components/Common/Avatar"; +import Loading from "@/components/Common/Loading"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import routes from "@/Utils/request/api"; +import mutate from "@/Utils/request/mutate"; +import query from "@/Utils/request/query"; +import { PaginatedResponse } from "@/Utils/request/types"; +import { EncounterTabProps } from "@/pages/Encounters/EncounterShow"; +import { Message } from "@/types/notes/messages"; +import { Thread } from "@/types/notes/threads"; + +const MESSAGES_LIMIT = 20; + +// Thread templates for quick selection +const threadTemplates = [ + "Treatment Plan", + "Medication Notes", + "Care Coordination", + "General Notes", + "Patient History", + "Referral Notes", + "Lab Results Discussion", +] as const; + +// Component to display loading skeleton for messages +const MessageSkeleton = () => ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ + +
+
+
+ ))} +
+); + +// Info tooltip component for help text +const InfoTooltip = ({ content }: { content: string }) => ( + + + + + + +

{content}

+
+
+
+); + +// Thread item component +const ThreadItem = ({ + thread, + isSelected, + onClick, +}: { + thread: Thread; + isSelected: boolean; + onClick: () => void; +}) => ( + +); + +// Message item component +const MessageItem = ({ message }: { message: Message }) => { + const authUser = useAuthUser(); + const isCurrentUser = authUser?.external_id === message.created_by.id; + + return ( +
+
+ + + +
+ +
+
+ +

{message.created_by.username}

+
+
+
+ +
+ + {message.created_by.username} + +
+ {message.message && ( +
+ +
+ )} +
+
+
+
+ ); +}; + +// New thread dialog component +const NewThreadDialog = ({ + isOpen, + onClose, + onCreate, + isCreating, +}: { + isOpen: boolean; + onClose: () => void; + onCreate: (title: string) => void; + isCreating: boolean; +}) => { + const { t } = useTranslation(); + const [title, setTitle] = useState(""); + + return ( + { + if (!open) { + setTitle(""); + onClose(); + } + }} + > + + + + {t("encounter_notes__start_new_discussion")} + + + + {t("encounter_notes__choose_template")} + + + +
+
+ {threadTemplates.map((template) => ( + setTitle(template)} + > + {template} + + ))} +
+ +
+ setTitle(e.target.value)} + /> +
+
+ + + + + +
+
+ ); +}; + +// Mobile navigation component +const MobileNav = ({ + threadsCount, + onOpenThreads, + onNewThread, +}: { + threadsCount: number; + onOpenThreads: () => void; + onNewThread: () => void; +}) => ( +
+ + +
+); + +// Main component +export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [selectedThread, setSelectedThread] = useState(null); + const [isThreadsExpanded, setIsThreadsExpanded] = useState(false); + const [showNewThreadDialog, setShowNewThreadDialog] = useState(false); + const [newMessage, setNewMessage] = useState(""); + const messagesEndRef = useRef(null); + const { ref, inView } = useInView(); + + // Fetch threads + const { data: threadsData, isLoading: threadsLoading } = useQuery({ + queryKey: ["threads", encounter.id], + queryFn: query(routes.notes.patient.listThreads, { + pathParams: { patientId: encounter.patient.id }, + queryParams: { encounter: encounter.id }, + }), + }); + + // Auto-select first thread + useEffect(() => { + if (threadsData?.results.length && !selectedThread) { + setSelectedThread(threadsData.results[0].id); + } + }, [threadsData, selectedThread]); + + // Fetch messages with infinite scroll + const { + data: messagesData, + isLoading: messagesLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useInfiniteQuery>({ + queryKey: ["messages", selectedThread], + queryFn: async ({ pageParam = 0 }) => { + const response = await query(routes.notes.patient.getMessages, { + pathParams: { + patientId: encounter.patient.id, + threadId: selectedThread!, + }, + queryParams: { + limit: String(MESSAGES_LIMIT), + offset: String(pageParam), + }, + })({ signal: new AbortController().signal }); + return response as PaginatedResponse; + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + const currentOffset = allPages.length * MESSAGES_LIMIT; + return currentOffset < lastPage.count ? currentOffset : null; + }, + enabled: !!selectedThread, + }); + + // Create thread mutation + const createThreadMutation = useMutation({ + mutationFn: mutate(routes.notes.patient.createThread, { + pathParams: { patientId: encounter.patient.id }, + }), + onSuccess: (newThread) => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + setShowNewThreadDialog(false); + setSelectedThread((newThread as Thread).id); + toast.success(t("encounter_notes__thread_created")); + }, + onError: () => { + toast.error(t("encounter_notes__failed_create_thread")); + }, + }); + + // Create message mutation + const createMessageMutation = useMutation({ + mutationFn: mutate(routes.notes.patient.postMessage, { + pathParams: { + patientId: encounter.patient.id, + threadId: selectedThread!, + }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["messages", selectedThread] }); + setNewMessage(""); + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, 100); + }, + onError: () => { + toast.error(t("Failed to send message")); + }, + }); + + // Handle infinite scroll + useEffect(() => { + if (inView && hasNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, fetchNextPage]); + + // Scroll to bottom on initial load and thread change + useEffect(() => { + if (messagesData && !messagesLoading && !isFetchingNextPage) { + messagesEndRef.current?.scrollIntoView(); + } + }, [selectedThread, messagesData, messagesLoading, isFetchingNextPage]); + + const handleCreateThread = (title: string) => { + if (title.trim()) { + createThreadMutation.mutate({ + title: title.trim(), + encounter: encounter.id, + }); + } + }; + + const handleSendMessage = (e: React.FormEvent) => { + e.preventDefault(); + if (newMessage.trim() && selectedThread) { + createMessageMutation.mutate({ message: newMessage.trim() }); + } + }; + + if (threadsLoading) { + return ; + } + + const messages = messagesData?.pages.flatMap((page) => page.results) ?? []; + + return ( +
+ {/* Desktop Sidebar */} +
+
+
+
+ +

+ {t("encounter_notes__discussions")} +

+
+ +
+
+ + +
+ {threadsData?.results.length === 0 ? ( +
+ +

+ {t("encounter_notes__no_discussions")} +

+
+ ) : ( + threadsData?.results.map((thread) => ( + setSelectedThread(thread.id)} + /> + )) + )} +
+
+
+ + {/* Mobile Sheet */} + + +
+
+
+
+ +

+ {t("encounter_notes__all_discussions")} +

+
+ +
+
+ + +
+ {threadsData?.results.length === 0 ? ( +
+ +

+ {t("encounter_notes__no_discussions")} +

+
+ ) : ( + threadsData?.results.map((thread) => ( + { + setSelectedThread(thread.id); + setIsThreadsExpanded(false); + }} + /> + )) + )} +
+
+
+
+
+ + {/* Main Content */} +
+
+ {/* Mobile Header */} +
+ {selectedThread ? ( +
+

+ { + threadsData?.results.find((t) => t.id === selectedThread) + ?.title + } +

+
+ + {messages.length} +
+
+ ) : ( +
+ {t("encounter_notes__select_create_thread")} +
+ )} +
+ + {selectedThread ? ( + <> + {messagesLoading ? ( +
+ +
+ ) : ( + <> + {/* Messages List */} + +
+
+ {messages.length === 0 ? ( +
+ +

+ {t("encounter_notes__start_conversation")} +

+

+ {t("encounter_notes__be_first_to_send")} +

+
+ ) : ( + messages.map((message) => ( + + )) + )} + {isFetchingNextPage && ( +
+ +
+ )} +
+
+ + + {/* Message Input */} +
+
+
+