From 4318ffda9597a8ff1abed4dc5922deb38eba1873 Mon Sep 17 00:00:00 2001 From: Vysakh Premkumar <84713473+tellmeY18@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:49:24 +0530 Subject: [PATCH 01/29] disabled image build and deploy for care stagin (#9779) --- .github/workflows/deploy.yaml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 4d8ac14635a..79e4d357951 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -61,7 +61,7 @@ jobs: build: needs: test - if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/staging' || startsWith(github.event.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/develop' || startsWith(github.event.ref, 'refs/tags/v') name: Build & Push to container registries runs-on: ubuntu-latest steps: @@ -139,18 +139,3 @@ jobs: run: | echo "Release ${{ github.sha }} is ready to be deployed to production" - deploy-staging-gcp: - needs: build - if: github.ref == 'refs/heads/staging' - name: Deploy to staging GCP cluster - runs-on: ubuntu-latest - environment: - name: Staging-GCP - url: https://care-staging.ohc.network/ - steps: - - name: Trigger deploy - run: | - COMMIT_SHA=${{ github.sha }} - JSON='{ "substitutions": { "care_be_tag":"", "care_fe_tag": "'"$COMMIT_SHA"'", "metabase_tag": "" } }' - curl --location "${{ secrets.STAGING_GCP_DEPLOY_URL }}" \ - --header 'Content-Type: application/json' --data "$JSON" From 51ca34f06aef2bb2d2e99dc67357575e2a3e2063 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jan 2025 14:54:34 +0530 Subject: [PATCH 02/29] Bump @tanstack/react-query-devtools from 5.62.11 to 5.62.15 (#9785) Bumps [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) from 5.62.11 to 5.62.15. - [Release notes](https://github.com/TanStack/query/releases) - [Commits](https://github.com/TanStack/query/commits/HEAD/packages/react-query-devtools) --- updated-dependencies: - dependency-name: "@tanstack/react-query-devtools" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 24 ++++++++++++------------ package.json | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 83d8318e57d..aae20921b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "@sentry/browser": "^8.47.0", "@tanstack/react-query": "^5.62.8", - "@tanstack/react-query-devtools": "^5.62.7", + "@tanstack/react-query-devtools": "^5.62.15", "@vitejs/plugin-react": "^4.3.4", "@yudiel/react-qr-scanner": "^2.1.0", "bowser": "^2.11.0", @@ -6291,9 +6291,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.62.9", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.9.tgz", - "integrity": "sha512-lwePd8hNYhyQ4nM/iRQ+Wz2cDtspGeZZHFZmCzHJ7mfKXt+9S301fULiY2IR2byJYY6Z03T427E5PoVfMexHjw==", + "version": "5.62.15", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.15.tgz", + "integrity": "sha512-wT20X14CxcWY8YLJ/1pnsXn/y1Q2uRJZYWW93PWRtZt+3/JlGZyiyTcO4pGnqycnP7CokCROAyatsraosqZsDA==", "license": "MIT", "funding": { "type": "github", @@ -6311,12 +6311,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.62.11", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.11.tgz", - "integrity": "sha512-Xb1nw0cYMdtFmwkvH9+y5yYFhXvLRCnXoqlzSw7UkqtCVFq3cG8q+rHZ2Yz1XrC+/ysUaTqbLKJqk95mCgC1oQ==", + "version": "5.62.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.15.tgz", + "integrity": "sha512-Ny3xxsOWmEQCFyHiV3CF7t6+QAV+LpBEREiXyllKR4+tStyd8smOAa98ZHmEx0ZNy36M31K8enifB5wTSYAKJw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.62.9" + "@tanstack/query-core": "5.62.15" }, "funding": { "type": "github", @@ -6327,9 +6327,9 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.62.11", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.62.11.tgz", - "integrity": "sha512-i0vKgdM4ORRzqduz7UeUF52UhLrvRp4sNY/DnLsd5NqNyiKct3a0bLQMWE2fqjF5tEExQ0d0xY60ILXW/T62xA==", + "version": "5.62.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.62.15.tgz", + "integrity": "sha512-8aE7uD45NHZgNtHMVQC7PvM9f72mKK4bqcpHr9La8TsTRX7x8dy2Kdu2ReFNLCrdlEWkxdP5843tc/lHg+Q/rg==", "license": "MIT", "dependencies": { "@tanstack/query-devtools": "5.62.9" @@ -6339,7 +6339,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.62.11", + "@tanstack/react-query": "^5.62.15", "react": "^18 || ^19" } }, diff --git a/package.json b/package.json index 75b8501fc68..e66c7f8a6aa 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "@sentry/browser": "^8.47.0", "@tanstack/react-query": "^5.62.8", - "@tanstack/react-query-devtools": "^5.62.7", + "@tanstack/react-query-devtools": "^5.62.15", "@vitejs/plugin-react": "^4.3.4", "@yudiel/react-qr-scanner": "^2.1.0", "bowser": "^2.11.0", From ef4d63b4fac167974d7a79caf4c28eed3fc64832 Mon Sep 17 00:00:00 2001 From: Bodhisha Thomas Date: Mon, 6 Jan 2025 17:54:43 +0530 Subject: [PATCH 03/29] Patient encounter notes (#9617) Co-authored-by: Bodhisha Thomas Co-authored-by: Bodhish Thomas --- public/locale/en.json | 22 +- src/Utils/request/api.tsx | 31 + src/pages/Encounters/EncounterShow.tsx | 3 + .../Encounters/tabs/EncounterNotesTab.tsx | 677 ++++++++++++++++++ src/types/notes/messages.ts | 10 + src/types/notes/threads.ts | 5 + 6 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 src/pages/Encounters/tabs/EncounterNotesTab.tsx create mode 100644 src/types/notes/messages.ts create mode 100644 src/types/notes/threads.ts 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 */} +
+
+
+