diff --git a/app/components/datasources/DataConnections.js b/app/components/datasources/DataConnections.js index 709ef5f43c..d47ac28a1e 100644 --- a/app/components/datasources/DataConnections.js +++ b/app/components/datasources/DataConnections.js @@ -306,15 +306,13 @@ export const getConnectStateUI = (patient, isLoggedInUser, providerName) => { } }; -export const getDataConnectionProps = (patient, isLoggedInUser, selectedClinicId, setActiveHandler) => reduce(availableProviders, (result, providerName) => { - result[providerName] = {}; - - let connectState; - +export const resolveConnectState = (patient, providerName, isLoggedInUser = false) => { const dataSource = getCurrentDataSourceForProvider(patient, providerName); const connectStateUI = getConnectStateUI(patient, isLoggedInUser, providerName); const inviteExpired = dataSource?.expirationTime < moment.utc().toISOString(); + let connectState; + if (dataSource?.state) { connectState = includes(keys(connectStateUI), dataSource.state) ? dataSource.state @@ -331,6 +329,15 @@ export const getDataConnectionProps = (patient, isLoggedInUser, selectedClinicId connectState = 'noPendingConnections'; } + return connectState; +}; + +export const getDataConnectionProps = (patient, isLoggedInUser, selectedClinicId, setActiveHandler) => reduce(availableProviders, (result, providerName) => { + result[providerName] = {}; + + const connectStateUI = getConnectStateUI(patient, isLoggedInUser, providerName); + const connectState = resolveConnectState(patient, providerName, isLoggedInUser); + const { color, icon, message, text, handler } = connectStateUI[connectState]; const { diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataIssues/Cells.js b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/Cells.js new file mode 100644 index 0000000000..1c7877dad4 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/Cells.js @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import moment from 'moment-timezone'; +import { Box, Text } from 'theme-ui'; +import { resolveConnectState } from '../../../../components/datasources/DataConnections'; +import includes from 'lodash/includes'; + +import Pill from '../../../../components/elements/Pill'; +import HoverButton from '../../../../components/elements/HoverButton'; +import PopoverMenu from '../../../../components/elements/PopoverMenu'; +import { colors, fontWeights } from '../../../../themes/baseTheme'; +import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded'; +import EditIcon from '@material-ui/icons/EditRounded'; +import DataInIcon from '../../../../core/icons/DataInIcon.svg'; + +export const DexcomConnectionStatusCell = ({ patient, onOpenDataConnectionsModal }) => { + const { t } = useTranslation(); + + const dexcomConnectStateUI = useMemo(() => ({ + noPendingConnections: { colorPalette: 'neutral', icon: null, text: t('No Pending Connections') }, + inviteJustSent: { colorPalette: 'info', icon: null, text: t('Invite Sent') }, + pending: { colorPalette: 'info', icon: null, text: t('Invite Sent') }, + pendingReconnect: { colorPalette: 'info', icon: null, text: t('Invite Sent') }, + pendingExpired: { colorPalette: 'warning', icon: ErrorRoundedIcon, text: t('Invite Expired') }, + connected: { colorPalette: 'info', icon: null, text: t('Connected') }, + disconnected: { colorPalette: 'warning', icon: ErrorRoundedIcon, text: t('Patient Disconnected') }, + error: { colorPalette: 'warning', icon: ErrorRoundedIcon, text: t('Error Connecting') }, + unknown: { colorPalette: 'warning', icon: ErrorRoundedIcon, text: t('Unknown Status') }, + }), [t]); + + const dexcomConnectState = resolveConnectState(patient, 'dexcom'); + + if (!dexcomConnectState) return null; + + const showViewButton = includes([ + 'disconnected', + 'error', + 'noPendingConnections', + 'pendingExpired', + 'unknown', + ], dexcomConnectState); + + const handleOpenDataConnectionsModal = () => onOpenDataConnectionsModal(patient.id); + + const StatusBadge = () => ( + + ); + + if (!showViewButton) return ; + + return ( + + + + + + ); +}; + +export const DaysSinceLastDataCell = ({ patient }) => { + const timePrefs = useSelector(state => state.blip.timePrefs); + + const daysSinceLastData = useMemo(() => { + if (!patient?.lastData) return null; + + const timezone = timePrefs?.timezoneName || new Intl.DateTimeFormat().resolvedOptions().timeZone; + const startOfLastDataDay = moment.utc(patient.lastData).tz(timezone).startOf('day'); + const startOfCurrentDay = moment.utc().tz(timezone).startOf('day'); + + return startOfCurrentDay.diff(startOfLastDataDay, 'days'); + }, [patient?.lastData, timePrefs?.timezoneName]); + + return ( + {daysSinceLastData ?? '-'} + ); +}; + +export const MoreMenuCell = ({ patient, onOpenEditPatientDialog, onOpenDataConnectionsModal }) => { + const { t } = useTranslation(); + + return ( + { + _popupState.close(); + onOpenEditPatientDialog(patient.id); + }, + text: t('Edit Patient Details'), + }, { + iconSrc: DataInIcon, + iconLabel: t('Bring Data into Tidepool'), + iconPosition: 'left', + id: `edit-data-connections-${patient?.id}`, + variant: 'actionListItem', + onClick: (_popupState) => { + _popupState.close(); + onOpenDataConnectionsModal(patient.id); + }, + text: t('Bring Data into Tidepool'), + }]} + sx={{ position: 'relative', left: '-2px' }} + /> + ); +}; + +export default { + DexcomConnectionStatusCell, + DaysSinceLastDataCell, + MoreMenuCell, +}; diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataIssues/DataConnectionsModalController.js b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/DataConnectionsModalController.js new file mode 100644 index 0000000000..f8a7673db9 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/DataConnectionsModalController.js @@ -0,0 +1,12 @@ +import React from 'react'; +import DataConnectionsModal from '../../../../components/datasources/DataConnectionsModal'; + +const DataConnectionsModalController = ({ isOpen, patient, onClose }) => ( + +); + +export default DataConnectionsModalController; diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataIssues/DataIssues.js b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/DataIssues.js new file mode 100644 index 0000000000..f50ee16e54 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/DataIssues.js @@ -0,0 +1,125 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { Box, Flex, Text } from 'theme-ui'; + +import Table from '../../../../components/elements/Table'; + +import { PatientCell } from '../Cells'; +import { DexcomConnectionStatusCell, DaysSinceLastDataCell, MoreMenuCell } from './Cells'; +import TagListCell from '../../components/TagListCell'; +import EmptyContentNode from '../EmptyContentNode'; +import useTideReportNoDataPatients from './useTideReportNoDataPatients'; +import EditPatientDialogController from './EditPatientDialogController'; +import DataConnectionsModalController from './DataConnectionsModalController'; +import { useGetPatientFromClinicQuery } from './tideDashboardLegacyApi'; + +const usePatientFromClinic = (patientId) => { + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); + + const { data: patient } = useGetPatientFromClinicQuery( + { clinicId: selectedClinicId, patientId }, + { skip: !selectedClinicId || !patientId } + ); + + return patient; +}; + +const DataIssues = ({ api }) => { + const { t } = useTranslation(); + const { patients } = useTideReportNoDataPatients(); + + const [activePatientId, setActivePatientId] = useState(null); + const [isEditPatientDialogOpen, setIsEditPatientDialogOpen] = useState(false); + const [isDataConnectionsModalOpen, setIsDataConnectionsModalOpen] = useState(false); + + const activePatient = usePatientFromClinic(activePatientId); + + const handleOpenEditPatientDialog = useCallback((patientId) => { + setActivePatientId(patientId); + setIsEditPatientDialogOpen(true); + }, []); + + const handleOpenDataConnectionsModal = (patientId) => { + setActivePatientId(patientId); + setIsDataConnectionsModalOpen(true); + }; + + const handleCloseModals = () => { + setIsEditPatientDialogOpen(false); + setIsDataConnectionsModalOpen(false); + setActivePatientId(null); + }; + + const columns = useMemo(() => ([ + { + title: t('Patient Details'), + field: 'fullName', + align: 'left', + render: patient => , + }, + { + title: t('Dexcom Connection Status'), + field: 'dexcomConnectionStatus', + align: 'left', + render: patient => , + }, + { + title: t('Days Since Last Data'), + field: 'daysSinceLastData', + align: 'center', + render: patient => , + }, + { + title: t('Tags'), + field: 'tags', + align: 'center', + render: patient => , + }, + { + title: '', + field: 'moreMenu', + align: 'center', + render: patient => ( + + ), + }, + ]), [t, handleOpenEditPatientDialog, handleOpenDataConnectionsModal]); + + return ( + + + {t('Data Issues')} + + + } + containerProps={{ sx: { containerType: 'inline-size' } }} + /> + + + + + + ); +}; + +export default DataIssues; diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataIssues/EditPatientDialogController.js b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/EditPatientDialogController.js new file mode 100644 index 0000000000..143421772a --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/EditPatientDialogController.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { RTKQueryApi } from '../../../../redux/api/baseApi'; +import EditPatientDialog from '../../../../components/modals/EditPatientDialog'; +import { tagTypes } from '../tideDashboardApi'; + +const { TIDE_DASHBOARD_PATIENTS } = tagTypes; + +const trackMetric = () => {}; + +const EditPatientDialogController = ({ api, isOpen, patient, onClose }) => { + const dispatch = useDispatch(); + + const handleEditSuccess = () => { + dispatch(RTKQueryApi.util.invalidateTags([TIDE_DASHBOARD_PATIENTS])); + }; + + return ( + + ); +}; + +export default EditPatientDialogController; diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataIssues/index.js b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/index.js new file mode 100644 index 0000000000..cacca4eddb --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/index.js @@ -0,0 +1,3 @@ +import DataIssues from './DataIssues'; + +export default DataIssues; diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataIssues/tideDashboardLegacyApi.js b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/tideDashboardLegacyApi.js new file mode 100644 index 0000000000..8040d16209 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/tideDashboardLegacyApi.js @@ -0,0 +1,60 @@ +import { RTKQueryApi } from '../../../../redux/api/baseApi'; +import { tagTypes } from '../tideDashboardApi'; + +// This file isolates legacy TIDE Dashboard API calls (the V1 +// `/v1/clinics/:clinicId/tide_report` endpoint) so they are not entangled with +// the new V2 endpoints in `tideDashboardApi`. It shares the primary +// `RTKQueryApi` instance (so no additional store wiring is required), but should +// be kept separate from new work. Prefer `tideDashboardApi` for all new code. + +const { TIDE_DASHBOARD_PATIENTS } = tagTypes; + +export const buildGetTideReportParams = (period, lastData, tags = [], lastDataCutoff, categories = []) => { + const formattedTags = tags.length > 0 ? tags.join(',') : undefined; + const formattedCategories = categories.length > 0 ? categories.join(',') : undefined; + + return { + period, + lastData, + tags: formattedTags, + lastDataCutoff, + categories: formattedCategories, + }; +}; + +const tideDashboardLegacyApi = RTKQueryApi.injectEndpoints({ + endpoints: (builder) => ({ + getTideReport: builder.query({ + query: ({ clinicId, period, lastData, tags, lastDataCutoff, categories }) => { + const params = buildGetTideReportParams(period, lastData, tags, lastDataCutoff, categories); + + return { + url: `/clinics/${clinicId}/tide_report`, + params, + }; + }, + // The tide_report response groups patients by category. The "Data Issues" + // table only renders the `noData` group. We flatten each entry so that the + // row === patient (matching how the V2 table cells consume rows), keeping + // the top-level `lastData` needed to derive days since last data. + transformResponse: (response) => { + const noData = response?.results?.noData || []; + + return { + patients: noData.map(({ patient, lastData }) => ({ ...patient, lastData })), + }; + }, + providesTags: [TIDE_DASHBOARD_PATIENTS], + }), + getPatientFromClinic: builder.query({ + queryFn: async ({ clinicId, patientId }, _queryApi, _extraOptions, baseQuery) => { + if (!patientId) return { data: null }; + + return baseQuery({ url: `/clinics/${clinicId}/patients/${patientId}` }); + }, + providesTags: [TIDE_DASHBOARD_PATIENTS], + }), + }), +}); + +export const { useGetTideReportQuery, useGetPatientFromClinicQuery } = tideDashboardLegacyApi; diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataIssues/useTideReportNoDataPatients.js b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/useTideReportNoDataPatients.js new file mode 100644 index 0000000000..e4b51a48fb --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/useTideReportNoDataPatients.js @@ -0,0 +1,38 @@ +import { useSelector } from 'react-redux'; +import { useGetTideReportQuery } from './tideDashboardLegacyApi'; +import useDerivedDataRecencyEndpoints from '../useDerivedDataRecencyEndpoints'; + +// The Data Issues table always requests the report; `categories` is a required +// param on the endpoint, but the `noData` group is returned regardless of which +// category is requested, so we send a single category to satisfy the contract. +const CATEGORIES = ['meetingTargets']; + +// Fetches the tide_report and returns the `noData` (Data Issues) patients. +// Both the DataIssues table and the parent (for wiring the row action menu's +// modals) call this; RTK Query dedupes the identical request. +const useTideReportNoDataPatients = () => { + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); + const { summaryPeriod, lastData, patientTags } = useSelector(state => state.blip.tideDashboardFilters); + + // `lastDataFrom` is derived identically to V1's `lastDataCutoff`. + const [lastDataCutoff] = useDerivedDataRecencyEndpoints(); + + const result = useGetTideReportQuery( + { + clinicId: selectedClinicId, + period: summaryPeriod, + lastData, + tags: patientTags, + lastDataCutoff, + categories: CATEGORIES, + }, + { skip: !selectedClinicId } + ); + + return { + ...result, + patients: result.data?.patients || [], + }; +}; + +export default useTideReportNoDataPatients; diff --git a/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js b/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js index 78b1ac02d0..f83465147e 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js +++ b/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js @@ -28,6 +28,7 @@ import FilterBySites from './FilterBySites'; import PatientCount from '../components/PatientCount'; import EditPatientDialogController from './EditPatientDialogController'; import DataConnectionsModalController from './DataConnectionsModalController'; +import DataIssues from './DataIssues'; const LIMIT = 12; @@ -122,6 +123,8 @@ const TideDashboard = ({ api, trackMetric }) => { + + { const patientTags = clinic?.patientTags || []; const tagIds = patient?.tags || []; - const tags = tagIds.map(tag => patientTags.find(ptTag => ptTag.id === tag)); // TODO: index + const tags = tagIds + .map(tag => patientTags.find(ptTag => ptTag.id === tag)) // TODO: index + .filter(Boolean); return ; };