diff --git a/public/locale/en.json b/public/locale/en.json index 5fa543e8809..7d0f51dfbcd 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -878,6 +878,7 @@ "encounter_notes__failed_send_message": "Failed to send message", "encounter_notes__new": "New", "encounter_notes__no_discussions": "No discussions yet", + "encounter_notes__no_unused_threads": "Please enter a custom title for thread", "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", @@ -1002,6 +1003,7 @@ "facility_updated_successfully": "Facility updated successfully", "failed_to_create_appointment": "Failed to create an appointment", "failed_to_link_abha_number": "Failed to link ABHA Number. Please try again later.", + "failed_to_send_message": "Failed to send message", "false": "False", "fast_track_testing_reason": "Fast track testing reason", "features": "Features", @@ -1307,6 +1309,7 @@ "medicines_administered": "Medicine(s) administered", "medicines_administered_error": "Error administering medicine(s)", "member_id_required": "Member Id is required", + "messages": "Messages", "method": "Method", "middleware_hostname": "Middleware Hostname", "middleware_hostname_example": "e.g. example.ohc.network", @@ -1515,6 +1518,7 @@ "page_not_found": "Page Not Found", "pain": "Pain", "pain_chart_description": "Mark region and intensity of pain", + "participants": "Participants", "passport_number": "Passport Number", "password": "Password", "password_length_validation": "Use at least 8 characters", @@ -2094,6 +2098,7 @@ "third_party_software_licenses": "Third Party Software Licenses", "this_action_is_irreversible": "This action is irreversible. Once a file is archived it cannot be unarchived.", "this_file_has_been_archived": "This file has been archived and cannot be unarchived.", + "thread_already_exists": "Thread with this title already exists", "time": "Time", "time_slot": "Time Slot", "title": "Title", diff --git a/src/pages/Encounters/tabs/EncounterNotesTab.tsx b/src/pages/Encounters/tabs/EncounterNotesTab.tsx index 89b535134df..058606392e4 100644 --- a/src/pages/Encounters/tabs/EncounterNotesTab.tsx +++ b/src/pages/Encounters/tabs/EncounterNotesTab.tsx @@ -9,11 +9,13 @@ import { Info, Loader2, MessageCircle, + MessageSquare, MessageSquarePlus, Plus, Send, Users, } from "lucide-react"; +import { Link, usePathParams } from "raviger"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useInView } from "react-intersection-observer"; @@ -25,6 +27,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -61,6 +64,7 @@ import { Thread } from "@/types/notes/threads"; const MESSAGES_LIMIT = 20; // Thread templates for quick selection + const threadTemplates = [ "Treatment Plan", "Medication Notes", @@ -118,6 +122,7 @@ const ThreadItem = ({ // Message item component const MessageItem = ({ message }: { message: Message }) => { const authUser = useAuthUser(); + const { facilityId } = usePathParams("/facility/:facilityId/*")!; const isCurrentUser = authUser?.external_id === message.created_by.id; return ( @@ -135,15 +140,19 @@ const MessageItem = ({ message }: { message: Message }) => { > - -
- -
-
+ + +
+ +
+
+

{message.created_by.username}

@@ -192,11 +201,13 @@ const NewThreadDialog = ({ onClose, onCreate, isCreating, + threadsUnused, }: { isOpen: boolean; onClose: () => void; onCreate: (title: string) => void; isCreating: boolean; + threadsUnused: string[]; }) => { const { t } = useTranslation(); const [title, setTitle] = useState(""); @@ -218,13 +229,15 @@ const NewThreadDialog = ({ - {t("encounter_notes__choose_template")} + {threadsUnused.length === 0 + ? t("encounter_notes__no_unused_threads") + : t("encounter_notes__choose_template")}
- {threadTemplates.map((template) => ( + {threadsUnused.map((template) => ( - + + + + @@ -308,6 +322,7 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { const [newMessage, setNewMessage] = useState(""); const messagesEndRef = useRef(null); const { ref, inView } = useInView(); + const [commentAdded, setCommentAdded] = useState(false); // Fetch threads const { data: threadsData, isLoading: threadsLoading } = useQuery({ @@ -318,17 +333,11 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { }), }); - // 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, + isFetching: isFetchingMessages, hasNextPage, fetchNextPage, isFetchingNextPage, @@ -382,28 +391,63 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["messages", selectedThread] }); setNewMessage(""); - setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); + setCommentAdded(true); }, }); - // Handle infinite scroll + // handle scrolling to last message when new message is added + useEffect(() => { - if (inView && hasNextPage) { - fetchNextPage(); + if (commentAdded && !isFetchingMessages) { + messagesEndRef.current?.scrollIntoView(); + setCommentAdded(false); } - }, [inView, hasNextPage, fetchNextPage]); + }, [commentAdded, isFetchingMessages]); + + const [threads, setThreads] = useState([...threadTemplates]); + + // Auto-select first thread - // Scroll to bottom on initial load and thread change useEffect(() => { - if (messagesData && !messagesLoading && !isFetchingNextPage) { + if (threadsData?.results.length) { + if (!selectedThread) setSelectedThread(threadsData.results[0].id); + const threadTitles = threadsData.results.map((thread) => thread.title); + setThreads( + threads.filter((template) => !threadTitles.includes(template)), + ); + } + }, [threadsData, selectedThread]); + + // hack to scroll to bottom on initial load + + useEffect(() => { + messagesEndRef.current?.scrollIntoView(); + }, [messagesLoading]); + + // Handle infinite scroll + + useEffect(() => { + if (inView && hasNextPage) { + fetchNextPage(); messagesEndRef.current?.scrollIntoView(); } - }, [selectedThread, messagesData, messagesLoading, isFetchingNextPage]); + }, [ + inView, + hasNextPage, + fetchNextPage, + messagesData, + isFetchingNextPage, + messagesLoading, + ]); const handleCreateThread = (title: string) => { if (title.trim()) { + if ( + threadsData?.results.some((thread) => thread.title === title.trim()) + ) { + toast.error(t("thread_already_exists")); + return; + } createThreadMutation.mutate({ title: title.trim(), encounter: encounter.id, @@ -423,6 +467,7 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { } const messages = messagesData?.pages.flatMap((page) => page.results) ?? []; + const totalMessages = messagesData?.pages[0]?.count ?? 0; return (
@@ -529,8 +574,8 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { {/* Main Content */}
- {/* Mobile Header */} -
+ {/* Header */} +
{selectedThread ? (

@@ -539,10 +584,27 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { ?.title }

-
- - {messages.length} -
+ + +
+ + + {new Set(messages.map((m) => m.created_by.id)).size} + + + {totalMessages} +
+
+ +

+ {t("participants")}:{" "} + {new Set(messages.map((m) => m.created_by.id)).size} +

+

+ {t("messages")}: {totalMessages} +

+
+
) : (
@@ -550,13 +612,12 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => {
)}
- {selectedThread ? ( <> {messagesLoading ? (
- +
) : ( @@ -580,17 +641,17 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { )) )} - {isFetchingNextPage && ( + {isFetchingNextPage ? (
+ ) : ( +
)} -
- {/* Message Input */}
@@ -662,6 +723,7 @@ export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { onClose={() => setShowNewThreadDialog(false)} onCreate={handleCreateThread} isCreating={createThreadMutation.isPending} + threadsUnused={threads} />
);