vintasend 0.4.0 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/README.md +5 -0
  2. package/dist/examples/vintasend-medplum-example/.env.example +11 -0
  3. package/dist/examples/vintasend-medplum-example/IMPLEMENTATION_PLAN_FILE_ATTACHMENTS.md +597 -0
  4. package/dist/examples/vintasend-medplum-example/README.md +190 -0
  5. package/dist/examples/vintasend-medplum-example/TUTORIAL_EMAIL_NOTIFICATIONS.md +2596 -0
  6. package/dist/examples/vintasend-medplum-example/bots/handlers/send-pending-notifications-bot.ts +39 -0
  7. package/dist/examples/vintasend-medplum-example/bots/handlers/send-task-assignment-email.ts +41 -0
  8. package/dist/examples/vintasend-medplum-example/bots/handlers/task-due-soon-notification-bot.ts +86 -0
  9. package/dist/examples/vintasend-medplum-example/bots/index.ts +53 -0
  10. package/dist/examples/vintasend-medplum-example/bots/services/emails/schedule-task-due-soon-email.ts +84 -0
  11. package/dist/examples/vintasend-medplum-example/bots/services/emails/send-task-assignment-email.test.ts +388 -0
  12. package/dist/examples/vintasend-medplum-example/bots/services/emails/send-task-assignment-email.ts +113 -0
  13. package/dist/examples/vintasend-medplum-example/bots/shared/task-due-soon-helpers.ts +115 -0
  14. package/dist/examples/vintasend-medplum-example/bots/task-assignment-bot.ts +41 -0
  15. package/dist/examples/vintasend-medplum-example/compiled-notification-templates.json +6 -0
  16. package/dist/examples/vintasend-medplum-example/esbuild-script.mjs +71 -0
  17. package/dist/examples/vintasend-medplum-example/index.html +14 -0
  18. package/dist/examples/vintasend-medplum-example/lib/constants.ts +32 -0
  19. package/dist/examples/vintasend-medplum-example/lib/extensions.ts +1 -0
  20. package/dist/examples/vintasend-medplum-example/lib/file-upload.test.ts +389 -0
  21. package/dist/examples/vintasend-medplum-example/lib/file-upload.ts +222 -0
  22. package/dist/examples/vintasend-medplum-example/lib/medplum-singleton.ts +18 -0
  23. package/dist/examples/vintasend-medplum-example/lib/notification-service.test.ts +293 -0
  24. package/dist/examples/vintasend-medplum-example/lib/notification-service.ts +284 -0
  25. package/dist/examples/vintasend-medplum-example/lib/patients.ts +20 -0
  26. package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-assignment/body.html.pug +37 -0
  27. package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-assignment/subject.txt.pug +4 -0
  28. package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-due-soon/body.html.pug +34 -0
  29. package/dist/examples/vintasend-medplum-example/notification-templates/emails/task-due-soon/subject.txt.pug +4 -0
  30. package/dist/examples/vintasend-medplum-example/package.json +75 -0
  31. package/dist/examples/vintasend-medplum-example/plugins/gql-plugin.mjs +31 -0
  32. package/dist/examples/vintasend-medplum-example/postcss.config.mjs +21 -0
  33. package/dist/examples/vintasend-medplum-example/public/favicon.ico +0 -0
  34. package/dist/examples/vintasend-medplum-example/public/img/integrations/acuity.png +0 -0
  35. package/dist/examples/vintasend-medplum-example/public/img/integrations/auth0.png +0 -0
  36. package/dist/examples/vintasend-medplum-example/public/img/integrations/azure.png +0 -0
  37. package/dist/examples/vintasend-medplum-example/public/img/integrations/calcom.png +0 -0
  38. package/dist/examples/vintasend-medplum-example/public/img/integrations/candid.png +0 -0
  39. package/dist/examples/vintasend-medplum-example/public/img/integrations/claude.png +0 -0
  40. package/dist/examples/vintasend-medplum-example/public/img/integrations/datadog.png +0 -0
  41. package/dist/examples/vintasend-medplum-example/public/img/integrations/deepseek.png +0 -0
  42. package/dist/examples/vintasend-medplum-example/public/img/integrations/entra.png +0 -0
  43. package/dist/examples/vintasend-medplum-example/public/img/integrations/epic.png +0 -0
  44. package/dist/examples/vintasend-medplum-example/public/img/integrations/google.png +0 -0
  45. package/dist/examples/vintasend-medplum-example/public/img/integrations/healthgorilla.png +0 -0
  46. package/dist/examples/vintasend-medplum-example/public/img/integrations/healthie.png +0 -0
  47. package/dist/examples/vintasend-medplum-example/public/img/integrations/labcorp.png +0 -0
  48. package/dist/examples/vintasend-medplum-example/public/img/integrations/okta.png +0 -0
  49. package/dist/examples/vintasend-medplum-example/public/img/integrations/openai.png +0 -0
  50. package/dist/examples/vintasend-medplum-example/public/img/integrations/particle.png +0 -0
  51. package/dist/examples/vintasend-medplum-example/public/img/integrations/quest.png +0 -0
  52. package/dist/examples/vintasend-medplum-example/public/img/integrations/recaptcha.png +0 -0
  53. package/dist/examples/vintasend-medplum-example/public/img/integrations/snowflake.png +0 -0
  54. package/dist/examples/vintasend-medplum-example/public/img/integrations/stedi.png +0 -0
  55. package/dist/examples/vintasend-medplum-example/public/img/integrations/stripe.png +0 -0
  56. package/dist/examples/vintasend-medplum-example/public/img/integrations/sumo.png +0 -0
  57. package/dist/examples/vintasend-medplum-example/scripts/README.md +162 -0
  58. package/dist/examples/vintasend-medplum-example/scripts/client.ts +18 -0
  59. package/dist/examples/vintasend-medplum-example/scripts/deploy-bots.ts +171 -0
  60. package/dist/examples/vintasend-medplum-example/src/App.tsx +185 -0
  61. package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemList.test.tsx +350 -0
  62. package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemList.tsx +241 -0
  63. package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemPanel.test.tsx +616 -0
  64. package/dist/examples/vintasend-medplum-example/src/components/ChargeItem/ChargeItemPanel.tsx +138 -0
  65. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionItem.test.tsx +92 -0
  66. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionItem.tsx +47 -0
  67. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionList.test.tsx +464 -0
  68. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionList.tsx +186 -0
  69. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionModal.test.tsx +80 -0
  70. package/dist/examples/vintasend-medplum-example/src/components/Conditions/ConditionModal.tsx +82 -0
  71. package/dist/examples/vintasend-medplum-example/src/components/DoseSpotIcon.test.tsx +100 -0
  72. package/dist/examples/vintasend-medplum-example/src/components/DoseSpotIcon.tsx +20 -0
  73. package/dist/examples/vintasend-medplum-example/src/components/IntegrationCard.module.css +3 -0
  74. package/dist/examples/vintasend-medplum-example/src/components/IntegrationCard.tsx +62 -0
  75. package/dist/examples/vintasend-medplum-example/src/components/MessageWithLinks.tsx +47 -0
  76. package/dist/examples/vintasend-medplum-example/src/components/PerformingLabInput.test.tsx +299 -0
  77. package/dist/examples/vintasend-medplum-example/src/components/PerformingLabInput.tsx +52 -0
  78. package/dist/examples/vintasend-medplum-example/src/components/ResourceFormWithRequiredProfile.tsx +82 -0
  79. package/dist/examples/vintasend-medplum-example/src/components/encounter/BillingTab.test.tsx +1016 -0
  80. package/dist/examples/vintasend-medplum-example/src/components/encounter/BillingTab.tsx +298 -0
  81. package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterChart.test.tsx +732 -0
  82. package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterChart.tsx +282 -0
  83. package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterHeader.test.tsx +268 -0
  84. package/dist/examples/vintasend-medplum-example/src/components/encounter/EncounterHeader.tsx +224 -0
  85. package/dist/examples/vintasend-medplum-example/src/components/encounter/SignAddendum.test.tsx +255 -0
  86. package/dist/examples/vintasend-medplum-example/src/components/encounter/SignAddendum.tsx +212 -0
  87. package/dist/examples/vintasend-medplum-example/src/components/encounter/SignLockDialog.test.tsx +120 -0
  88. package/dist/examples/vintasend-medplum-example/src/components/encounter/SignLockDialog.tsx +57 -0
  89. package/dist/examples/vintasend-medplum-example/src/components/encounter/VisitDetailsPanel.test.tsx +224 -0
  90. package/dist/examples/vintasend-medplum-example/src/components/encounter/VisitDetailsPanel.tsx +100 -0
  91. package/dist/examples/vintasend-medplum-example/src/components/labs/CoverageInput.test.tsx +431 -0
  92. package/dist/examples/vintasend-medplum-example/src/components/labs/CoverageInput.tsx +130 -0
  93. package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.module.css +31 -0
  94. package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.test.tsx +234 -0
  95. package/dist/examples/vintasend-medplum-example/src/components/labs/LabListItem.tsx +143 -0
  96. package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.module.css +11 -0
  97. package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.test.tsx +875 -0
  98. package/dist/examples/vintasend-medplum-example/src/components/labs/LabOrderDetails.tsx +943 -0
  99. package/dist/examples/vintasend-medplum-example/src/components/labs/LabResultDetails.test.tsx +413 -0
  100. package/dist/examples/vintasend-medplum-example/src/components/labs/LabResultDetails.tsx +203 -0
  101. package/dist/examples/vintasend-medplum-example/src/components/labs/LabSelectEmpty.tsx +22 -0
  102. package/dist/examples/vintasend-medplum-example/src/components/labs/README.md +104 -0
  103. package/dist/examples/vintasend-medplum-example/src/components/labs/TestMetadataCardInput.test.tsx +318 -0
  104. package/dist/examples/vintasend-medplum-example/src/components/labs/TestMetadataCardInput.tsx +87 -0
  105. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatList.test.tsx +126 -0
  106. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatList.tsx +38 -0
  107. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.module.css +23 -0
  108. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.test.tsx +167 -0
  109. package/dist/examples/vintasend-medplum-example/src/components/messages/ChatListItem.tsx +53 -0
  110. package/dist/examples/vintasend-medplum-example/src/components/messages/NewTopicDialog.test.tsx +94 -0
  111. package/dist/examples/vintasend-medplum-example/src/components/messages/NewTopicDialog.tsx +165 -0
  112. package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.module.css +8 -0
  113. package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.test.tsx +523 -0
  114. package/dist/examples/vintasend-medplum-example/src/components/messages/ParticipantFilter.tsx +230 -0
  115. package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.module.css +23 -0
  116. package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.test.tsx +567 -0
  117. package/dist/examples/vintasend-medplum-example/src/components/messages/ThreadInbox.tsx +358 -0
  118. package/dist/examples/vintasend-medplum-example/src/components/plandefinition/AddPlanDefinition.module.css +40 -0
  119. package/dist/examples/vintasend-medplum-example/src/components/plandefinition/AddPlanDefinition.tsx +257 -0
  120. package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.module.css +7 -0
  121. package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.test.tsx +279 -0
  122. package/dist/examples/vintasend-medplum-example/src/components/schedule/CreateVisit.tsx +156 -0
  123. package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.module.css +45 -0
  124. package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.test.tsx +90 -0
  125. package/dist/examples/vintasend-medplum-example/src/components/spaces/HistoryList.tsx +84 -0
  126. package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourceBox.module.css +26 -0
  127. package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourceBox.tsx +90 -0
  128. package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourcePanel.test.tsx +305 -0
  129. package/dist/examples/vintasend-medplum-example/src/components/spaces/ResourcePanel.tsx +46 -0
  130. package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.module.css +262 -0
  131. package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.test.tsx +622 -0
  132. package/dist/examples/vintasend-medplum-example/src/components/spaces/SpacesInbox.tsx +286 -0
  133. package/dist/examples/vintasend-medplum-example/src/components/tasks/NewTaskModal.tsx +275 -0
  134. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskAttachmentList.tsx +132 -0
  135. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.module.css +45 -0
  136. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.test.tsx +749 -0
  137. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskBoard.tsx +416 -0
  138. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailPanel.test.tsx +278 -0
  139. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailPanel.tsx +133 -0
  140. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.module.css +16 -0
  141. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.test.tsx +255 -0
  142. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskDetailsModal.tsx +203 -0
  143. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFileUpload.tsx +129 -0
  144. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.test.tsx +156 -0
  145. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.tsx +142 -0
  146. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskFilterMenu.utils.ts +28 -0
  147. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskInputNote.test.tsx +134 -0
  148. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskInputNote.tsx +250 -0
  149. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.module.css +23 -0
  150. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.test.tsx +149 -0
  151. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskListItem.tsx +53 -0
  152. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskNoteItem.test.tsx +68 -0
  153. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskNoteItem.tsx +46 -0
  154. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskProperties.test.tsx +555 -0
  155. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskProperties.tsx +170 -0
  156. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskSelectEmpty.test.tsx +32 -0
  157. package/dist/examples/vintasend-medplum-example/src/components/tasks/TaskSelectEmpty.tsx +34 -0
  158. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/SimpleTask.test.tsx +47 -0
  159. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/SimpleTask.tsx +29 -0
  160. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskPanel.test.tsx +285 -0
  161. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskPanel.tsx +129 -0
  162. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskQuestionnaireForm.test.tsx +455 -0
  163. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskQuestionnaireForm.tsx +167 -0
  164. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskServiceRequest.test.tsx +435 -0
  165. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskServiceRequest.tsx +116 -0
  166. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.module.css +38 -0
  167. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.test.tsx +200 -0
  168. package/dist/examples/vintasend-medplum-example/src/components/tasks/encounter/TaskStatusPanel.tsx +84 -0
  169. package/dist/examples/vintasend-medplum-example/src/components/utils.test.ts +176 -0
  170. package/dist/examples/vintasend-medplum-example/src/components/utils.ts +17 -0
  171. package/dist/examples/vintasend-medplum-example/src/config/constants.ts +3 -0
  172. package/dist/examples/vintasend-medplum-example/src/hooks/useDebouncedUpdateResource.test.tsx +166 -0
  173. package/dist/examples/vintasend-medplum-example/src/hooks/useDebouncedUpdateResource.ts +28 -0
  174. package/dist/examples/vintasend-medplum-example/src/hooks/useEncounter.test.tsx +94 -0
  175. package/dist/examples/vintasend-medplum-example/src/hooks/useEncounter.ts +11 -0
  176. package/dist/examples/vintasend-medplum-example/src/hooks/useEncounterChart.test.tsx +477 -0
  177. package/dist/examples/vintasend-medplum-example/src/hooks/useEncounterChart.ts +191 -0
  178. package/dist/examples/vintasend-medplum-example/src/hooks/usePatient.test.tsx +100 -0
  179. package/dist/examples/vintasend-medplum-example/src/hooks/usePatient.ts +18 -0
  180. package/dist/examples/vintasend-medplum-example/src/hooks/useThreadInbox.test.tsx +379 -0
  181. package/dist/examples/vintasend-medplum-example/src/hooks/useThreadInbox.ts +194 -0
  182. package/dist/examples/vintasend-medplum-example/src/index.css +8 -0
  183. package/dist/examples/vintasend-medplum-example/src/main.tsx +57 -0
  184. package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.module.css +6 -0
  185. package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.test.tsx +295 -0
  186. package/dist/examples/vintasend-medplum-example/src/pages/SearchPage.tsx +124 -0
  187. package/dist/examples/vintasend-medplum-example/src/pages/SignInPage.test.tsx +77 -0
  188. package/dist/examples/vintasend-medplum-example/src/pages/SignInPage.tsx +22 -0
  189. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterChartPage.test.tsx +87 -0
  190. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterChartPage.tsx +27 -0
  191. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.module.css +16 -0
  192. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.test.tsx +287 -0
  193. package/dist/examples/vintasend-medplum-example/src/pages/encounter/EncounterModal.tsx +151 -0
  194. package/dist/examples/vintasend-medplum-example/src/pages/integrations/DoseSpotFavoritesPage.test.tsx +519 -0
  195. package/dist/examples/vintasend-medplum-example/src/pages/integrations/DoseSpotFavoritesPage.tsx +179 -0
  196. package/dist/examples/vintasend-medplum-example/src/pages/integrations/FavoriteMedicationsTable.tsx +76 -0
  197. package/dist/examples/vintasend-medplum-example/src/pages/integrations/IntegrationsPage.test.tsx +234 -0
  198. package/dist/examples/vintasend-medplum-example/src/pages/integrations/IntegrationsPage.tsx +222 -0
  199. package/dist/examples/vintasend-medplum-example/src/pages/labs/OrderLabsPage.test.tsx +356 -0
  200. package/dist/examples/vintasend-medplum-example/src/pages/labs/OrderLabsPage.tsx +275 -0
  201. package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.module.css +8 -0
  202. package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.test.tsx +103 -0
  203. package/dist/examples/vintasend-medplum-example/src/pages/messages/MessagesPage.tsx +78 -0
  204. package/dist/examples/vintasend-medplum-example/src/pages/patient/CommunicationTab.test.tsx +84 -0
  205. package/dist/examples/vintasend-medplum-example/src/pages/patient/CommunicationTab.tsx +82 -0
  206. package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotAdvancedOptions.test.tsx +364 -0
  207. package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotAdvancedOptions.tsx +149 -0
  208. package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotTab.test.tsx +159 -0
  209. package/dist/examples/vintasend-medplum-example/src/pages/patient/DoseSpotTab.tsx +37 -0
  210. package/dist/examples/vintasend-medplum-example/src/pages/patient/EditTab.test.tsx +140 -0
  211. package/dist/examples/vintasend-medplum-example/src/pages/patient/EditTab.tsx +72 -0
  212. package/dist/examples/vintasend-medplum-example/src/pages/patient/ExportTab.test.tsx +57 -0
  213. package/dist/examples/vintasend-medplum-example/src/pages/patient/ExportTab.tsx +14 -0
  214. package/dist/examples/vintasend-medplum-example/src/pages/patient/IntakeFormPage.test.tsx +241 -0
  215. package/dist/examples/vintasend-medplum-example/src/pages/patient/IntakeFormPage.tsx +710 -0
  216. package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.module.css +37 -0
  217. package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.test.tsx +428 -0
  218. package/dist/examples/vintasend-medplum-example/src/pages/patient/LabsPage.tsx +334 -0
  219. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.module.css +24 -0
  220. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.test.tsx +154 -0
  221. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.tsx +115 -0
  222. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.utils.test.ts +223 -0
  223. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientPage.utils.ts +89 -0
  224. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientSearchPage.test.tsx +147 -0
  225. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientSearchPage.tsx +79 -0
  226. package/dist/examples/vintasend-medplum-example/src/pages/patient/PatientTabsNavigation.tsx +35 -0
  227. package/dist/examples/vintasend-medplum-example/src/pages/patient/TasksTab.test.tsx +185 -0
  228. package/dist/examples/vintasend-medplum-example/src/pages/patient/TasksTab.tsx +115 -0
  229. package/dist/examples/vintasend-medplum-example/src/pages/patient/TimelineTab.tsx +14 -0
  230. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceCreatePage.test.tsx +170 -0
  231. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceCreatePage.tsx +117 -0
  232. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceDetailPage.tsx +28 -0
  233. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceEditPage.test.tsx +131 -0
  234. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceEditPage.tsx +65 -0
  235. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceHistoryPage.test.tsx +108 -0
  236. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourceHistoryPage.tsx +16 -0
  237. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.module.css +7 -0
  238. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.test.tsx +37 -0
  239. package/dist/examples/vintasend-medplum-example/src/pages/resource/ResourcePage.tsx +44 -0
  240. package/dist/examples/vintasend-medplum-example/src/pages/resource/useResourceType.ts +44 -0
  241. package/dist/examples/vintasend-medplum-example/src/pages/resource/utils.ts +9 -0
  242. package/dist/examples/vintasend-medplum-example/src/pages/schedule/SchedulePage.test.tsx +302 -0
  243. package/dist/examples/vintasend-medplum-example/src/pages/schedule/SchedulePage.tsx +416 -0
  244. package/dist/examples/vintasend-medplum-example/src/pages/spaces/ChatInput.tsx +91 -0
  245. package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.module.css +6 -0
  246. package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.test.tsx +102 -0
  247. package/dist/examples/vintasend-medplum-example/src/pages/spaces/SpacesPage.tsx +44 -0
  248. package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.module.css +7 -0
  249. package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.test.tsx +133 -0
  250. package/dist/examples/vintasend-medplum-example/src/pages/tasks/TasksPage.tsx +91 -0
  251. package/dist/examples/vintasend-medplum-example/src/test-utils/render.tsx +20 -0
  252. package/dist/examples/vintasend-medplum-example/src/test.setup.ts +49 -0
  253. package/dist/examples/vintasend-medplum-example/src/types/encounter.ts +8 -0
  254. package/dist/examples/vintasend-medplum-example/src/types/spaces.ts +10 -0
  255. package/dist/examples/vintasend-medplum-example/src/utils/chargeitems.test.ts +141 -0
  256. package/dist/examples/vintasend-medplum-example/src/utils/chargeitems.ts +59 -0
  257. package/dist/examples/vintasend-medplum-example/src/utils/claims.test.ts +153 -0
  258. package/dist/examples/vintasend-medplum-example/src/utils/claims.ts +65 -0
  259. package/dist/examples/vintasend-medplum-example/src/utils/communication-search.ts +47 -0
  260. package/dist/examples/vintasend-medplum-example/src/utils/coverage.test.ts +48 -0
  261. package/dist/examples/vintasend-medplum-example/src/utils/coverage.ts +33 -0
  262. package/dist/examples/vintasend-medplum-example/src/utils/documentReference.test.ts +102 -0
  263. package/dist/examples/vintasend-medplum-example/src/utils/documentReference.ts +55 -0
  264. package/dist/examples/vintasend-medplum-example/src/utils/encounter.test.ts +169 -0
  265. package/dist/examples/vintasend-medplum-example/src/utils/encounter.ts +261 -0
  266. package/dist/examples/vintasend-medplum-example/src/utils/intake-form.test.ts +154 -0
  267. package/dist/examples/vintasend-medplum-example/src/utils/intake-form.ts +272 -0
  268. package/dist/examples/vintasend-medplum-example/src/utils/intake-utils.test.ts +1137 -0
  269. package/dist/examples/vintasend-medplum-example/src/utils/intake-utils.ts +827 -0
  270. package/dist/examples/vintasend-medplum-example/src/utils/notifications.test.ts +27 -0
  271. package/dist/examples/vintasend-medplum-example/src/utils/notifications.ts +15 -0
  272. package/dist/examples/vintasend-medplum-example/src/utils/spaceMessaging.ts +249 -0
  273. package/dist/examples/vintasend-medplum-example/src/utils/spacePersistence.test.ts +450 -0
  274. package/dist/examples/vintasend-medplum-example/src/utils/spacePersistence.ts +147 -0
  275. package/dist/examples/vintasend-medplum-example/src/utils/task-search.ts +63 -0
  276. package/dist/examples/vintasend-medplum-example/src/vite-env.d.ts +3 -0
  277. package/dist/examples/vintasend-medplum-example/tsconfig.bots.json +4 -0
  278. package/dist/examples/vintasend-medplum-example/tsconfig.json +19 -0
  279. package/dist/examples/vintasend-medplum-example/vercel.json +3 -0
  280. package/dist/examples/vintasend-medplum-example/vite.config.ts +44 -0
  281. package/dist/services/notification-backends/base-notification-backend.d.ts +5 -0
  282. package/dist/services/notification-service.js +11 -1
  283. package/dist/services/notification-template-renderers/base-email-template-renderer.d.ts +5 -0
  284. package/package.json +1 -1
@@ -0,0 +1,416 @@
1
+ // SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Box, Button, Drawer, Group, SegmentedControl, Title } from '@mantine/core';
4
+ import { useDisclosure } from '@mantine/hooks';
5
+ import { createReference, getReferenceString } from '@medplum/core';
6
+ import type { WithId } from '@medplum/core';
7
+ import type { Appointment, Practitioner, Schedule, Slot } from '@medplum/fhirtypes';
8
+ import { useMedplum, useMedplumProfile } from '@medplum/react';
9
+ import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
10
+ import dayjs from 'dayjs';
11
+ import timezone from 'dayjs/plugin/timezone';
12
+ import utc from 'dayjs/plugin/utc';
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import type { JSX } from 'react';
15
+ import { Calendar, dayjsLocalizer } from 'react-big-calendar';
16
+ import type { Event, SlotInfo, ToolbarProps, View } from 'react-big-calendar';
17
+ import 'react-big-calendar/lib/css/react-big-calendar.css';
18
+ import { useNavigate } from 'react-router';
19
+ import { CreateVisit } from '../../components/schedule/CreateVisit';
20
+ import { showErrorNotification } from '../../utils/notifications';
21
+
22
+ type AppointmentEvent = Event & { type: 'appointment'; appointment: Appointment; start: Date; end: Date };
23
+ type SlotEvent = Event & { type: 'slot'; status: string; start: Date; end: Date };
24
+ type ScheduleEvent = AppointmentEvent | SlotEvent;
25
+
26
+ /**
27
+ * Schedule page that displays the practitioner's schedule.
28
+ * Allows the practitioner to create/update slots and create appointments.
29
+ * @returns A React component that displays the schedule page.
30
+ */
31
+ export function SchedulePage(): JSX.Element | null {
32
+ const navigate = useNavigate();
33
+ const medplum = useMedplum();
34
+ const profile = useMedplumProfile() as Practitioner;
35
+ const calendarRef = useRef<Calendar<ScheduleEvent>>(null);
36
+ const [schedule, setSchedule] = useState<WithId<Schedule> | undefined>();
37
+ const [view, setView] = useState<View>('week');
38
+ const [date, setDate] = useState<Date>(new Date());
39
+ const [range, setRange] = useState<{ start: Date; end: Date } | undefined>(undefined);
40
+ const [createAppointmentOpened, createAppointmentHandlers] = useDisclosure(false);
41
+ const [slotEvents, setSlotEvents] = useState<ScheduleEvent[]>();
42
+ const [appointmentEvents, setAppointmentEvents] = useState<ScheduleEvent[]>();
43
+
44
+ const [appointmentSlot, setAppointmentSlot] = useState<SlotInfo>();
45
+
46
+ useEffect(() => {
47
+ if (medplum.isLoading() || !profile) {
48
+ return;
49
+ }
50
+
51
+ // Search for a Schedule associated with the logged user,
52
+ // create one if it doesn't exist
53
+ medplum
54
+ .searchOne('Schedule', { actor: getReferenceString(profile) })
55
+ .then((foundSchedule) => {
56
+ if (foundSchedule) {
57
+ setSchedule(foundSchedule);
58
+ } else {
59
+ medplum
60
+ .createResource({
61
+ resourceType: 'Schedule',
62
+ actor: [createReference(profile)],
63
+ active: true,
64
+ })
65
+ .then(setSchedule)
66
+ .catch(console.error);
67
+ }
68
+ })
69
+ .catch(console.error);
70
+ }, [medplum, profile]);
71
+
72
+ const handleRangeChange = useCallback(
73
+ (newRange: Date[] | { start: Date; end: Date }) => {
74
+ let newStart: Date;
75
+ let newEnd: Date;
76
+ if (Array.isArray(newRange)) {
77
+ // Week view passes the range as an array of dates
78
+ newStart = newRange[0];
79
+ newEnd = new Date(newRange[newRange.length - 1].getTime() + 24 * 60 * 60 * 1000);
80
+ } else {
81
+ // Other views pass the range as an object
82
+ newStart = newRange.start;
83
+ newEnd = newRange.end;
84
+ }
85
+
86
+ // Only update state if the range has changed
87
+ if (newStart.getTime() !== range?.start.getTime() || newEnd.getTime() !== range.end.getTime()) {
88
+ setRange({ start: newStart, end: newEnd });
89
+ }
90
+ },
91
+ [range, setRange]
92
+ );
93
+
94
+ const refreshEvents = useCallback(
95
+ (cache?: RequestCache) => {
96
+ const calendar = calendarRef.current;
97
+ if (!calendar || !schedule || !range) {
98
+ return;
99
+ }
100
+
101
+ const start = range.start.toISOString();
102
+ const end = range.end.toISOString();
103
+
104
+ async function searchSlots(): Promise<void> {
105
+ const slots = await medplum.searchResources(
106
+ 'Slot',
107
+ [
108
+ ['_count', '1000'],
109
+ ['schedule', getReferenceString(schedule as WithId<Schedule>)],
110
+ ['start', `ge${start}`],
111
+ ['start', `le${end}`],
112
+ ],
113
+ { cache }
114
+ );
115
+ setSlotEvents(slotsToEvents(slots));
116
+ }
117
+
118
+ async function searchAppointments(): Promise<void> {
119
+ const appointments = await medplum.searchResources(
120
+ 'Appointment',
121
+ [
122
+ ['_count', '1000'],
123
+ ['actor', getReferenceString(profile as WithId<Practitioner>)],
124
+ ['date', `ge${start}`],
125
+ ['date', `le${end}`],
126
+ ],
127
+ { cache }
128
+ );
129
+ setAppointmentEvents(appointmentsToEvents(appointments));
130
+ }
131
+
132
+ Promise.allSettled([searchSlots(), searchAppointments()]).catch(console.error);
133
+ },
134
+ [medplum, profile, schedule, range]
135
+ );
136
+
137
+ useEffect(() => {
138
+ refreshEvents();
139
+ }, [refreshEvents]);
140
+
141
+ /**
142
+ * When a date/time range is selected, set the event object and open the create slot modal
143
+ */
144
+ const handleSelectSlot = useCallback(
145
+ (slot: SlotInfo) => {
146
+ createAppointmentHandlers.open();
147
+ setAppointmentSlot(slot);
148
+ },
149
+ [createAppointmentHandlers]
150
+ );
151
+
152
+ /**
153
+ * When an existing event (slot/appointment) is selected, set the event object and open the
154
+ * appropriate modal.
155
+ * - If the event is a free slot, open the create appointment modal.
156
+ * - If the event is an appointment, navigate to the appointment page.
157
+ */
158
+ const handleSelectEvent = useCallback(
159
+ async (event: ScheduleEvent) => {
160
+ const { resourceType, status } = event.resource;
161
+
162
+ function handleSlot(): void {
163
+ if (status === 'free') {
164
+ createAppointmentHandlers.open();
165
+ }
166
+ }
167
+
168
+ async function handleAppointment(): Promise<void> {
169
+ const encounters = await medplum.searchResources('Encounter', [
170
+ ['appointment', getReferenceString(event.resource)],
171
+ ['_count', '1'],
172
+ ]);
173
+ const patient = encounters?.[0]?.subject;
174
+ if (patient?.reference) {
175
+ navigate(`/${patient.reference}/Encounter/${encounters?.[0]?.id}`)?.catch(console.error);
176
+ }
177
+ }
178
+
179
+ if (resourceType === 'Slot') {
180
+ handleSlot();
181
+ return;
182
+ }
183
+
184
+ if (resourceType === 'Appointment') {
185
+ handleAppointment().catch((err) => showErrorNotification(err));
186
+ }
187
+ },
188
+ [createAppointmentHandlers, navigate, medplum]
189
+ );
190
+
191
+ if (!schedule) {
192
+ return null;
193
+ }
194
+
195
+ const height = window.innerHeight - 60;
196
+
197
+ const CustomToolbar = (props: ToolbarProps<ScheduleEvent>): JSX.Element => {
198
+ const [firstRender, setFirstRender] = useState(true);
199
+ useEffect(() => {
200
+ // The calendar does not provide any way to receive the range of dates that
201
+ // are visible except when they change. This is the cleanest way I could find
202
+ // to extend it to provide the _initial_ range (`onView` calls `onRangeChange`).
203
+ // https://github.com/jquense/react-big-calendar/issues/1752#issuecomment-761051235
204
+ if (firstRender) {
205
+ props.onView(props.view);
206
+ setFirstRender(false);
207
+ }
208
+ }, [props, firstRender, setFirstRender]);
209
+ return (
210
+ <Group justify="space-between" pb="sm">
211
+ <Group>
212
+ <Title order={4} mr="md">
213
+ {props.view !== 'day' && dayjs(props.date).format('MMMM YYYY')}
214
+ {props.view === 'day' && dayjs(props.date).format('MMMM D YYYY')}
215
+ </Title>
216
+ <Button.Group>
217
+ <Button variant="default" size="xs" onClick={() => props.onNavigate('PREV')}>
218
+ <IconChevronLeft size={12} />
219
+ </Button>
220
+ <Button variant="default" size="xs" onClick={() => props.onNavigate('TODAY')}>
221
+ Today
222
+ </Button>
223
+ <Button variant="default" size="xs" onClick={() => props.onNavigate('NEXT')}>
224
+ <IconChevronRight size={12} />
225
+ </Button>
226
+ </Button.Group>
227
+ </Group>
228
+ <SegmentedControl
229
+ size="xs"
230
+ value={props.view}
231
+ onChange={(newView) => setView(newView as View)}
232
+ data={[
233
+ { label: 'Month', value: 'month' },
234
+ { label: 'Week', value: 'week' },
235
+ { label: 'Day', value: 'day' },
236
+ ]}
237
+ />
238
+ </Group>
239
+ );
240
+ };
241
+
242
+ function eventPropGetter(
243
+ event: ScheduleEvent,
244
+ _start: Date,
245
+ _end: Date,
246
+ _isSelected: boolean
247
+ ): { className?: string | undefined; style?: React.CSSProperties } {
248
+ const result = {
249
+ style: {
250
+ backgroundColor: '#228be6',
251
+ border: '1px solid rgba(255, 255, 255, 0)',
252
+ borderRadius: '4px',
253
+ color: 'white',
254
+ display: 'block',
255
+ opacity: 1.0,
256
+ },
257
+ };
258
+
259
+ if (event.type === 'slot') {
260
+ result.style.backgroundColor = event.status === 'free' ? '#d3f9d8' : '#ced4da';
261
+ result.style.color = 'black';
262
+ result.style.opacity = 0.6;
263
+ }
264
+
265
+ return result;
266
+ }
267
+
268
+ dayjs.extend(utc);
269
+ dayjs.extend(timezone);
270
+ dayjs.tz.setDefault(dayjs.tz.guess());
271
+
272
+ return (
273
+ <Box pos="relative" bg="white" p="md" style={{ height }}>
274
+ <Calendar
275
+ ref={calendarRef}
276
+ components={{ toolbar: CustomToolbar }}
277
+ view={view}
278
+ date={date}
279
+ localizer={dayjsLocalizer(dayjs)}
280
+ events={appointmentEvents}
281
+ backgroundEvents={slotEvents} // Background events don't show in the month view
282
+ onNavigate={(newDate: Date, newView: View) => {
283
+ setDate(newDate);
284
+ setView(newView);
285
+ }}
286
+ onRangeChange={handleRangeChange}
287
+ onSelectSlot={handleSelectSlot}
288
+ onSelectEvent={handleSelectEvent}
289
+ scrollToTime={date} // Default scroll to current time
290
+ style={{ height: height - 150 }}
291
+ selectable
292
+ eventPropGetter={eventPropGetter}
293
+ />
294
+
295
+ {/* Modals */}
296
+ <Drawer
297
+ opened={createAppointmentOpened}
298
+ onClose={createAppointmentHandlers.close}
299
+ title="New Calendar Event"
300
+ position="right"
301
+ h="100%"
302
+ >
303
+ <CreateVisit appointmentSlot={appointmentSlot} />
304
+ </Drawer>
305
+ </Box>
306
+ );
307
+ }
308
+
309
+ // This function collapses contiguous or overlapping slots of the same status into single events
310
+ function slotsToEvents(slots: Slot[]): SlotEvent[] {
311
+ if (!slots || slots.length === 0) {
312
+ return [];
313
+ }
314
+
315
+ // First, filter the slots as before
316
+ const filteredSlots = slots.filter((slot) => slot.status === 'free' || slot.status === 'busy-unavailable');
317
+
318
+ // Group slots by status
319
+ const slotsByStatus: Record<string, SlotEvent[]> = {};
320
+ filteredSlots.forEach((slot) => {
321
+ if (!slotsByStatus[slot.status]) {
322
+ slotsByStatus[slot.status] = [];
323
+ }
324
+ slotsByStatus[slot.status].push({
325
+ type: 'slot',
326
+ status: slot.status,
327
+ start: new Date(slot.start),
328
+ end: new Date(slot.end),
329
+ });
330
+ });
331
+
332
+ const collapsedEvents: SlotEvent[] = [];
333
+
334
+ // Process each status group separately
335
+ Object.entries(slotsByStatus).forEach(([status, statusSlots]) => {
336
+ // Sort slots by start time
337
+ statusSlots.sort((a, b) => a.start.getTime() - b.start.getTime());
338
+
339
+ // Merge contiguous/overlapping slots
340
+ let currentGroup: SlotEvent | undefined = undefined;
341
+
342
+ for (const slot of statusSlots) {
343
+ if (!currentGroup) {
344
+ // Start a new group
345
+ currentGroup = {
346
+ type: 'slot',
347
+ status,
348
+ start: slot.start,
349
+ end: slot.end,
350
+ };
351
+ } else if (slot.start <= new Date(currentGroup.end.getTime() + 1000)) {
352
+ // Slot is contiguous or overlapping with current group
353
+ // The +1000ms (1 second) tolerance handles potential tiny gaps
354
+
355
+ // Extend end time if needed
356
+ if (slot.end > currentGroup.end) {
357
+ currentGroup.end = slot.end;
358
+ }
359
+ } else {
360
+ // This slot doesn't connect to the current group
361
+ // Finish current group and start a new one
362
+ collapsedEvents.push({
363
+ type: 'slot',
364
+ status: currentGroup.status,
365
+ title: status === 'free' ? 'Available' : 'Blocked',
366
+ start: currentGroup.start,
367
+ end: currentGroup.end,
368
+ });
369
+
370
+ currentGroup = {
371
+ type: 'slot',
372
+ status,
373
+ start: slot.start,
374
+ end: slot.end,
375
+ };
376
+ }
377
+ }
378
+
379
+ // Don't forget to add the last group
380
+ if (currentGroup) {
381
+ collapsedEvents.push({
382
+ type: 'slot',
383
+ status: currentGroup.status,
384
+ title: status === 'free' ? 'Available' : 'Blocked',
385
+ start: currentGroup.start,
386
+ end: currentGroup.end,
387
+ resource: {
388
+ status,
389
+ },
390
+ });
391
+ }
392
+ });
393
+
394
+ return collapsedEvents;
395
+ }
396
+
397
+ function appointmentsToEvents(appointments: Appointment[]): AppointmentEvent[] {
398
+ return appointments
399
+ .filter((appointment) => appointment.status !== 'cancelled')
400
+ .map((appointment) => {
401
+ // Find the patient among the participants to use as title
402
+ const patientParticipant = appointment?.participant?.find((p) => p.actor?.reference?.startsWith('Patient/'));
403
+ const status = !['booked', 'arrived', 'fulfilled'].includes(appointment.status as string)
404
+ ? ` (${appointment.status})`
405
+ : '';
406
+
407
+ return {
408
+ type: 'appointment',
409
+ appointment,
410
+ title: `${patientParticipant?.actor?.display} ${status}`,
411
+ start: new Date(appointment.start as string),
412
+ end: new Date(appointment.end as string),
413
+ resource: appointment,
414
+ };
415
+ });
416
+ }
@@ -0,0 +1,91 @@
1
+ // SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Paper, Textarea, Select, Button, Group, Stack } from '@mantine/core';
4
+ import { IconSend } from '@tabler/icons-react';
5
+ import type { JSX } from 'react';
6
+
7
+ const MODELS = [
8
+ { value: 'gpt-5', label: 'GPT-5' },
9
+ { value: 'gpt-4o', label: 'GPT-4o' },
10
+ { value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
11
+ ];
12
+
13
+ interface ChatInputProps {
14
+ input: string;
15
+ onInputChange: (value: string) => void;
16
+ onKeyDown: (e: React.KeyboardEvent) => void;
17
+ onSend: () => void;
18
+ loading: boolean;
19
+ selectedModel: string;
20
+ onModelChange: (value: string) => void;
21
+ backgroundColor?: string;
22
+ }
23
+
24
+ export function ChatInput({
25
+ input,
26
+ onInputChange,
27
+ onKeyDown,
28
+ onSend,
29
+ loading,
30
+ selectedModel,
31
+ onModelChange,
32
+ backgroundColor = '#fff',
33
+ }: ChatInputProps): JSX.Element {
34
+ return (
35
+ <Paper p="md" radius="lg" withBorder style={{ backgroundColor }}>
36
+ <Stack gap="sm">
37
+ <Group gap="md" wrap="nowrap" align="flex-end">
38
+ <Textarea
39
+ placeholder="Ask, search, or make anything..."
40
+ value={input}
41
+ onChange={(e) => onInputChange(e.currentTarget.value)}
42
+ onKeyDown={onKeyDown}
43
+ disabled={loading}
44
+ autosize
45
+ minRows={1}
46
+ maxRows={5}
47
+ style={{ flex: 1 }}
48
+ styles={{
49
+ input: {
50
+ border: 'none',
51
+ backgroundColor: 'transparent',
52
+ fontSize: '15px',
53
+ padding: 0,
54
+ resize: 'none',
55
+ },
56
+ }}
57
+ />
58
+ <Button
59
+ aria-label="Send message"
60
+ radius="xl"
61
+ size="sm"
62
+ onClick={onSend}
63
+ disabled={loading || !input.trim()}
64
+ w="36px"
65
+ h="36px"
66
+ bg="#7c3aed"
67
+ p={0}
68
+ >
69
+ <IconSend size={18} />
70
+ </Button>
71
+ </Group>
72
+ <Select
73
+ size="xs"
74
+ data={MODELS}
75
+ value={selectedModel}
76
+ onChange={(value) => onModelChange(value ?? 'gpt-5')}
77
+ w="120px"
78
+ withCheckIcon={false}
79
+ fw={500}
80
+ pr="md"
81
+ styles={{
82
+ input: {
83
+ fontSize: '12px',
84
+ cursor: 'pointer',
85
+ },
86
+ }}
87
+ />
88
+ </Stack>
89
+ </Paper>
90
+ );
91
+ }
@@ -0,0 +1,6 @@
1
+ .container {
2
+ height: calc(100vh - var(--app-shell-header-height, 0));
3
+ background-color: var(--mantine-color-gray-0);
4
+ display: flex;
5
+ overflow: hidden;
6
+ }
@@ -0,0 +1,102 @@
1
+ // SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { MantineProvider } from '@mantine/core';
4
+ import { act, render, screen, waitFor } from '@testing-library/react';
5
+ import { MedplumProvider } from '@medplum/react';
6
+ import { MockClient } from '@medplum/mock';
7
+ import { MemoryRouter, Route, Routes } from 'react-router';
8
+ import { describe, expect, test, vi, beforeEach } from 'vitest';
9
+ import { SpacesPage } from './SpacesPage';
10
+ import type { Communication } from '@medplum/fhirtypes';
11
+
12
+ const mockTopic: Communication = {
13
+ resourceType: 'Communication',
14
+ id: 'topic-123',
15
+ status: 'in-progress',
16
+ identifier: [
17
+ {
18
+ system: 'http://medplum.com/ai-message',
19
+ value: 'ai-message-topic',
20
+ },
21
+ ],
22
+ topic: {
23
+ text: 'Test conversation',
24
+ },
25
+ };
26
+
27
+ const mockProfile = {
28
+ resourceType: 'Practitioner' as const,
29
+ id: 'practitioner-123',
30
+ };
31
+
32
+ describe('SpacesPage', () => {
33
+ let medplum: MockClient;
34
+
35
+ beforeEach(() => {
36
+ medplum = new MockClient();
37
+ vi.clearAllMocks();
38
+
39
+ Element.prototype.scrollTo = vi.fn();
40
+ medplum.getProfile = vi.fn().mockResolvedValue(mockProfile);
41
+ medplum.searchResources = vi.fn().mockResolvedValue([]);
42
+ medplum.readReference = vi.fn().mockResolvedValue(mockTopic);
43
+ });
44
+
45
+ const setup = (initialEntries = ['/Spaces']): ReturnType<typeof render> => {
46
+ return render(
47
+ <MemoryRouter initialEntries={initialEntries}>
48
+ <MedplumProvider medplum={medplum}>
49
+ <MantineProvider>
50
+ <Routes>
51
+ <Route path="/Spaces" element={<SpacesPage />}>
52
+ <Route index element={<SpacesPage />} />
53
+ <Route path="Communication" element={<SpacesPage />} />
54
+ <Route path="Communication/:topicId" element={<SpacesPage />} />
55
+ </Route>
56
+ </Routes>
57
+ </MantineProvider>
58
+ </MedplumProvider>
59
+ </MemoryRouter>
60
+ );
61
+ };
62
+
63
+ test('renders SpaceInbox with no topicId when at root', async () => {
64
+ await act(async () => {
65
+ setup(['/Spaces']);
66
+ });
67
+
68
+ expect(screen.getByText('How can I help you today?')).toBeInTheDocument();
69
+ expect(screen.getByPlaceholderText('Ask, search, or make anything...')).toBeInTheDocument();
70
+ });
71
+
72
+ test('renders SpaceInbox with topic reference from URL', async () => {
73
+ await act(async () => {
74
+ setup(['/Spaces/Communication/123']);
75
+ });
76
+
77
+ await waitFor(() => {
78
+ expect(medplum.readReference).toHaveBeenCalledWith({ reference: 'Communication/123' });
79
+ });
80
+ });
81
+
82
+ test('generates correct link for selected item', async () => {
83
+ medplum.searchResources = vi.fn().mockImplementation((resourceType: string, query: any) => {
84
+ if (query?.identifier === 'http://medplum.com/ai-message|ai-message-topic') {
85
+ return Promise.resolve([mockTopic]);
86
+ }
87
+ return Promise.resolve([]);
88
+ });
89
+
90
+ await act(async () => {
91
+ setup(['/Spaces']);
92
+ });
93
+
94
+ // Sidebar is open by default, so we don't need to click history button
95
+ await waitFor(() => {
96
+ expect(screen.getByText('Test conversation')).toBeInTheDocument();
97
+ });
98
+
99
+ const link = screen.getByText('Test conversation').closest('a');
100
+ expect(link).toHaveAttribute('href', '/Spaces/Communication/topic-123');
101
+ });
102
+ });
@@ -0,0 +1,44 @@
1
+ // SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import type { Communication, Reference } from '@medplum/fhirtypes';
4
+ import type { JSX } from 'react';
5
+ import { useNavigate, useParams } from 'react-router';
6
+ import classes from './SpacesPage.module.css';
7
+ import { SpacesInbox } from '../../components/spaces/SpacesInbox';
8
+
9
+ /**
10
+ * SpacesPage component that handles routing for AI conversation spaces.
11
+ * Follows the same pattern as MessagesPage by delegating all logic to SpaceInbox.
12
+ * @returns A React component that displays the AI conversation interface.
13
+ */
14
+ export function SpacesPage(): JSX.Element {
15
+ const { topicId } = useParams();
16
+ const navigate = useNavigate();
17
+
18
+ const handleNewTopic = (newTopic: Communication): void => {
19
+ navigate(`/Spaces/Communication/${newTopic.id}`)?.catch(console.error);
20
+ };
21
+
22
+ const onSelectedItem = (selectedTopic: Communication): string => {
23
+ return `/Spaces/Communication/${selectedTopic.id}`;
24
+ };
25
+
26
+ const topicRef: Reference<Communication> | undefined = topicId
27
+ ? { reference: `Communication/${topicId}` }
28
+ : undefined;
29
+
30
+ const handleNewConversation = (): void => {
31
+ navigate('/Spaces/Communication')?.catch(console.error);
32
+ };
33
+
34
+ return (
35
+ <div className={classes.container}>
36
+ <SpacesInbox
37
+ topic={topicRef}
38
+ onNewTopic={handleNewTopic}
39
+ onSelectedItem={onSelectedItem}
40
+ onAdd={handleNewConversation}
41
+ />
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,7 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: row;
4
+ align-items: stretch;
5
+ height: calc(100vh - var(--app-shell-header-height, 0));
6
+ overflow: hidden;
7
+ }