diff --git a/public/locale/en.json b/public/locale/en.json index 400a11697e1..c5c14ad1f89 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", @@ -1444,8 +1445,8 @@ "qualification": "Qualification", "qualification_required": "Qualification is required", "quantity_approved": "Quantity Approved", - "quantity_required": "Quantity Required", "quantity_requested": "Quantity Requested", + "quantity_required": "Quantity Required", "raise_consent_request": "Raise a consent request to fetch patient records over ABDM", "ration_card__APL": "APL", "ration_card__BPL": "BPL", @@ -1676,10 +1677,10 @@ "test_type": "Type of test done", "tested_on": "Tested on", "thank_you_for_choosing": "Thank you for choosing our care service", - "time": "Time", - "time_slot": "Time Slot", "the_request_for_resources_placed_by_yourself_is": "The request for resource (details below) placed by yourself is", "third_party_software_licenses": "Third Party Software Licenses", + "time": "Time", + "time_slot": "Time Slot", "title_of_request": "Title of Request", "titrate_dosage": "Titrate Dosage", "to": "to", @@ -1871,4 +1872,4 @@ "you_need_at_least_a_location_to_create_an_assest": "You need at least a location to create an assest.", "zoom_in": "Zoom In", "zoom_out": "Zoom Out" -} \ No newline at end of file +} diff --git a/src/Utils/request/api.tsx b/src/Utils/request/api.tsx index d4d2ad617a7..2273864d75d 100644 --- a/src/Utils/request/api.tsx +++ b/src/Utils/request/api.tsx @@ -101,6 +101,8 @@ import { FacilityOrganizationCreate, FacilityOrganizationResponse, } from "@/types/facilityOrganization/facilityOrganization"; +import { Message } from "@/types/notes/messages"; +import { Thread } from "@/types/notes/threads"; import { Organization, OrganizationResponse, @@ -1640,6 +1642,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 2a8c2fa8225..085adef35bb 100644 --- a/src/pages/Encounters/EncounterShow.tsx +++ b/src/pages/Encounters/EncounterShow.tsx @@ -22,6 +22,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; @@ -34,6 +36,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..ad6273c3fc3 --- /dev/null +++ b/src/pages/Encounters/tabs/EncounterNotesTab.tsx @@ -0,0 +1,373 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { Plus, Send } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useInView } from "react-intersection-observer"; + +import { cn } from "@/lib/utils"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; + +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 { EncounterTabProps } from "@/pages/Encounters/EncounterShow"; +import { Message } from "@/types/notes/messages"; +import { Thread } from "@/types/notes/threads"; + +const MessageSkeleton = () => ( +
+ {[1, 2, 3].map((i) => ( +
+
+ +
+ + +
+ ))} +
+); + +const ThreadItem = ({ + thread, + isSelected, + onClick, +}: { + thread: Thread; + isSelected: boolean; + onClick: () => void; +}) => ( +
+
+

{thread.title}

+ {isSelected &&
} +
+
+); + +const UserAvatar = ({ user }: { user: Message["created_by"] }) => { + const initials = user.username.charAt(0).toUpperCase(); + + return user.profile_picture_url ? ( + {user.username} + ) : ( +
+ {initials} +
+ ); +}; + +const MessageItem = ({ message }: { message: Message }) => { + const authUser = useAuthUser(); + const isCurrentUser = authUser?.external_id === message.created_by.id; + + return ( +
+
+
+ +
+ +
+ + {message.created_by.username} + +
+

{message.message}

+
+
+
+
+ ); +}; + +interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export const EncounterNotesTab = ({ encounter }: EncounterTabProps) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [selectedThread, setSelectedThread] = useState(null); + const [showNewThreadForm, setShowNewThreadForm] = useState(false); + const [newThreadTitle, setNewThreadTitle] = useState(""); + const [newMessage, setNewMessage] = useState(""); + const LIMIT = 20; + 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 the first thread when data is loaded + useEffect(() => { + if (threadsData?.results.length && !selectedThread) { + setSelectedThread(threadsData.results[0].id); + } + }, [threadsData, selectedThread]); + + // Fetch messages with offset pagination + 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(LIMIT), + offset: String(pageParam), + }, + })({ signal: new AbortController().signal }); + return response as PaginatedResponse; + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + const currentOffset = allPages.length * LIMIT; + return currentOffset < lastPage.count ? currentOffset : null; + }, + enabled: !!selectedThread, + }); + + // Add effect for infinite scroll + useEffect(() => { + if (inView && hasNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, fetchNextPage]); + + // Create thread mutation + const createThreadMutation = useMutation({ + mutationFn: mutate(routes.notes.patient.createThread, { + pathParams: { patientId: encounter.patient.id }, + }), + onSuccess: (newThread) => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + setNewThreadTitle(""); + setShowNewThreadForm(false); + // Select the newly created thread + setSelectedThread((newThread as { id: string }).id); + }, + }); + + // 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(""); + }, + }); + + const handleCreateThread = () => { + createThreadMutation.mutate({ + title: newThreadTitle, + encounter: encounter.id, + }); + }; + + if (threadsLoading) { + return ; + } + + return ( +
+ {/* Threads List - Make it more compact */} +
+
+
setShowNewThreadForm(true)} + > + + {t("Create New Thread")} +
+ + {showNewThreadForm && ( +
+ setNewThreadTitle(e.target.value)} + className="mb-2" + autoFocus + /> +
+ + +
+
+ )} + + +
+ {threadsData?.results.map((thread) => ( + setSelectedThread(thread.id)} + /> + ))} +
+
+
+
+ + {/* Messages Area - Make it more chat-like */} +
+ {selectedThread ? ( + <> + {messagesLoading ? ( +
+ +
+ ) : ( +
+ {/* Messages List */} + +
+ {isFetchingNextPage && ( +
+ +
+ )} + {messagesData?.pages.map((page) => + page.results.map((message) => ( + + )), + )} +
+
+ + + {/* New Message Input */} +
+
{ + e.preventDefault(); + if (newMessage.trim()) { + createMessageMutation.mutate({ message: newMessage }); + } + }} + className="flex gap-2" + > +