diff --git a/package-lock.json b/package-lock.json index f34e828..1c2c5d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,13 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.13", + "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", "axios": "^1.10.0", "d3": "^7.9.0", "date-fns": "^3.6.0", "date-fns-tz": "^3.1.3", + "dompurify": "^3.3.3", "js-cookie": "^3.0.5", "next": "16.1.6", "next-themes": "^0.4.3", @@ -3878,6 +3880,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4073,6 +4084,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/video.js": { "version": "7.3.58", "resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz", @@ -7801,6 +7818,15 @@ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index c8e1334..551d223 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.13", + "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", "axios": "^1.10.0", "d3": "^7.9.0", "date-fns": "^3.6.0", "date-fns-tz": "^3.1.3", + "dompurify": "^3.3.3", "js-cookie": "^3.0.5", "next": "16.1.6", "next-themes": "^0.4.3", diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index d4b6a08..d7abb49 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -13,7 +13,7 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { isAuthenticated, isLoading, isLoggingOut } = useAuth(); // Routes that should use authenticated layout - const authenticatedRoutes = ['/dashboard', '/profile']; + const authenticatedRoutes = ['/dashboard', '/profile', '/search']; const shouldUseAuthLayout = authenticatedRoutes.some((route) => pathname.startsWith(route)); // Show loading state while checking authentication or during logout diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..1f46cf0 --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1,267 @@ +'use client'; + +import React, { Suspense, useEffect, useState } from 'react'; +import { Box, Button, Container, HStack, Spinner, Center, Text } from '@chakra-ui/react'; +import { FaSave, FaCheckSquare, FaTimes } from 'react-icons/fa'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/contexts/AuthContext'; +import { useEncounterSearch } from '@/hooks'; +import { EncounterSearchFilters, EncounterSearchHit } from '@/interfaces/search'; +import { Cohort } from '@/interfaces/cohort'; +import SearchBar from '@/components/search/SearchBar'; +import FacetSidebar from '@/components/search/FacetSidebar'; +import ResultsList from '@/components/search/ResultsList'; +import PaginationControls from '@/components/dashboard/ResearchTab/PaginationControls'; +import VisitDetailDrawer from '@/components/search/VisitDetailDrawer'; +import SaveSearchCohortDialog, { SaveCohortMode } from '@/components/search/SaveSearchCohortDialog'; +import { toaster } from '@/components/ui/toaster'; + +function SearchLoading() { + return ( +
+ +
+ ); +} + +export default function SearchPage() { + return ( + }> + + + ); +} + +function SearchContent() { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const router = useRouter(); + + const { + results, + loading, + error, + pagination, + aggregations, + query, + setQuery, + setFilters, + setPage, + } = useEncounterSearch(); + + const [localFilters, setLocalFilters] = useState({}); + const [selectedVisit, setSelectedVisit] = useState(null); + + // --- Cohort save dialog --- + const [saveCohortOpen, setSaveCohortOpen] = useState(false); + const [saveCohortMode, setSaveCohortMode] = useState('search'); + + // --- Selection mode --- + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, authLoading, router]); + + if (authLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return null; + } + + const hasSearched = query.trim().length > 0 || Object.keys(localFilters).length > 0; + + const activeFilterCount = Object.values(localFilters).filter((v) => + Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null + ).length; + + function handleFilterChange(filters: EncounterSearchFilters) { + setLocalFilters(filters); + setFilters(filters); + } + + function handleToggleSelect(encounterId: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(encounterId)) { + next.delete(encounterId); + } else { + next.add(encounterId); + } + return next; + }); + } + + function handleSaveSearch() { + setSaveCohortMode('search'); + setSaveCohortOpen(true); + } + + function handleSaveSelected() { + setSaveCohortMode('selected'); + setSaveCohortOpen(true); + } + + function handleCohortSaved(cohort: Cohort) { + toaster.create({ + title: 'Cohort saved', + description: `"${cohort.name}" created with ${cohort.visitCount.toLocaleString()} encounters.`, + type: 'success', + duration: 4000, + }); + setSelectionMode(false); + setSelectedIds(new Set()); + } + + function exitSelectionMode() { + setSelectionMode(false); + setSelectedIds(new Set()); + } + + const showToolbar = hasSearched && !loading && pagination.totalCount > 0; + + return ( + + {/* Hero search bar */} + + + + Encounter Search + + + {error && ( + + + {error} + + + )} + + + + {/* Main layout */} + + + + + + + + {/* Cohort toolbar */} + {showToolbar && ( + + {!selectionMode ? ( + <> + + + + ) : ( + <> + + + {selectedIds.size > 0 && ( + + {selectedIds.size} encounter{selectedIds.size !== 1 ? 's' : ''} selected + + )} + + )} + + )} + + + + {hasSearched && !loading && pagination.totalCount > 0 && ( + + + + )} + + + + + setSelectedVisit(null)} + /> + + setSaveCohortOpen(false)} + onSaved={handleCohortSaved} + mode={saveCohortMode} + filters={localFilters} + query={query} + totalCount={pagination.totalCount} + encounterIds={saveCohortMode === 'selected' ? Array.from(selectedIds) : undefined} + /> + + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ec273fd..0647aed 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -140,6 +140,7 @@ const Header: React.FC = ({ showLinks = true, logo = { width: 200, Dashboard Dataset + Search {/* Avatar Menu */} @@ -222,6 +223,10 @@ const Header: React.FC = ({ showLinks = true, logo = { width: 200, Dataset + + Search + + {/* Mobile Auth Section */} {showAuthenticatedMenu ? ( diff --git a/src/components/dashboard/CohortTab/CohortCard.tsx b/src/components/dashboard/CohortTab/CohortCard.tsx index 46de7d3..7c768b8 100644 --- a/src/components/dashboard/CohortTab/CohortCard.tsx +++ b/src/components/dashboard/CohortTab/CohortCard.tsx @@ -2,8 +2,10 @@ import React, { useMemo } from 'react'; import { Card, VStack, HStack, Text, Button, Badge, Box } from '@chakra-ui/react'; -import { FaTrash, FaEye, FaPencilAlt } from 'react-icons/fa'; -import { Cohort, getDetailedFilterSummary } from '@/interfaces/cohort'; +import { FaTrash, FaEye, FaPencilAlt, FaSearch } from 'react-icons/fa'; +import { Cohort, getDetailedFilterSummary, getSearchFilterCount } from '@/interfaces/cohort'; +import { VisitSearchFilters } from '@/interfaces/research'; +import { EncounterSearchFilters } from '@/interfaces/search'; import { COLORS } from '@/constants/colors'; import { COHORT_NAME_MAX_LENGTH } from '@/lib/utils/cohortValidation'; import { formatVisitSource, expandDemographic, formatDateForDisplay } from '@/lib/utils/utils'; @@ -211,9 +213,91 @@ interface CohortCardProps { onDelete: (cohortId: string) => void; } +/** + * Simple summary for search-source cohorts (flat EncounterSearchFilters). + */ +function SearchFilterSummary({ + filters, + encounterIds, + searchQuery, +}: { + filters: EncounterSearchFilters; + encounterIds?: string[] | null; + searchQuery?: string; +}) { + if (encounterIds && encounterIds.length > 0) { + return ( + + + {encounterIds.length} selected encounter{encounterIds.length !== 1 ? 's' : ''} + + + ); + } + + const activeCount = getSearchFilterCount(filters); + const badges: React.ReactNode[] = []; + + if (searchQuery) { + badges.push( + + query: {searchQuery.length > 20 ? `${searchQuery.slice(0, 20)}...` : searchQuery} + + ); + } + if (filters.department && filters.department.length > 0) { + badges.push( + + {filters.department.join(', ')} + + ); + } + if (filters.date_from || filters.date_to) { + const label = + filters.date_from && filters.date_to + ? `${filters.date_from} – ${filters.date_to}` + : filters.date_from + ? `from ${filters.date_from}` + : `to ${filters.date_to}`; + badges.push( + + {label} + + ); + } + if (filters.patient_gender && filters.patient_gender.length > 0) { + badges.push( + + {filters.patient_gender.join(', ')} + + ); + } + + return ( + + {badges.length > 0 ? ( + + {badges} + + ) : ( + + {activeCount > 0 + ? `${activeCount} filter${activeCount !== 1 ? 's' : ''} applied` + : 'No filters'} + + )} + + ); +} + export default function CohortCard({ cohort, onView, onRename, onDelete }: CohortCardProps) { - // Memoize detailed filter summary calculation - const filterDetails = useMemo(() => getDetailedFilterSummary(cohort.filters), [cohort.filters]); + const isSearchSource = cohort.source === 'search'; + + // Only compute research filter details for research-source cohorts + const filterDetails = useMemo( + () => (isSearchSource ? null : getDetailedFilterSummary(cohort.filters as VisitSearchFilters)), + [cohort.filters, isSearchSource] + ); // Truncate name for display if too long const displayName = useMemo(() => { @@ -277,31 +361,40 @@ export default function CohortCard({ cohort, onView, onRename, onDelete }: Cohor - {/* Filter Summary - Always show all categories with labels */} - - {/* Visit Filters */} - - - {/* Patient Demographic Filters */} - - - {/* Provider Demographic Filters */} - - - {/* TODO: Add Clinical Filters section when clinical filtering is implemented */} - + {/* Filter Summary */} + {isSearchSource ? ( + + + + + Search + + + + + ) : filterDetails ? ( + + + + + + ) : null} {/* Actions */} diff --git a/src/components/dashboard/CohortTab/CreateCohortDialog.tsx b/src/components/dashboard/CohortTab/CreateCohortDialog.tsx index 863a48c..14fea7d 100644 --- a/src/components/dashboard/CohortTab/CreateCohortDialog.tsx +++ b/src/components/dashboard/CohortTab/CreateCohortDialog.tsx @@ -54,6 +54,7 @@ export default function CreateCohortDialog({ await onSave({ name: name.trim(), description: description.trim() || undefined, + source: 'research', filters, visitCount, }); diff --git a/src/components/dashboard/ResearchTab/index.tsx b/src/components/dashboard/ResearchTab/index.tsx index 9a52eb6..07d4494 100644 --- a/src/components/dashboard/ResearchTab/index.tsx +++ b/src/components/dashboard/ResearchTab/index.tsx @@ -43,6 +43,7 @@ export default function ResearchTab({ hasValidTier, onCohortCreated }: ResearchT filterOptions, loading: filterOptionsLoading, error: filterOptionsError, + refetch: refetchFilterOptions, } = useFilterOptions(isAuthenticated); const { @@ -157,115 +158,124 @@ export default function ResearchTab({ hasValidTier, onCohortCreated }: ResearchT return ; } + // Show error state if filter options failed to load + if (!filterOptions) { + return ( +
+ + Failed to load research data. + + +
+ ); + } + return ( {/* Main Content Area */} - {filterOptions && ( - - {/* Filter Sidebar */} - + + {/* Filter Sidebar */} + - {/* Data Table */} - - - - - Research Visits - - {filterSummary && pagination.totalCount > 0 && ( - <> - Showing {(pagination.currentPage - 1) * DEFAULT_PAGE_SIZE + 1}- - {Math.min( - pagination.currentPage * DEFAULT_PAGE_SIZE, - pagination.totalCount - )}{' '} - of {pagination.totalCount} visits - {filterSummary.activeFilters > 0 && ( - - ({filterSummary.activeFilters}{' '} - {filterSummary.activeFilters === 1 ? 'filter' : 'filters'} active) - - )} - - )} - - - - - {!hasValidTier ? ( -
- - - - - - Request Data Access - - - Your account has been created, but you don't have data access yet. - Please reach out to our team to get started. - - - observerproject@pennmedicine.upenn.edu - - -
- ) : visitsLoading ? ( - - - {Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - - ))} - - - ) : visits.length > 0 ? ( - <> - - - - ) : ( - - - No visits match your current filters. Try adjusting your search criteria. + {/* Data Table */} + + + + + Research Visits + + {filterSummary && pagination.totalCount > 0 && ( + <> + Showing {(pagination.currentPage - 1) * DEFAULT_PAGE_SIZE + 1}- + {Math.min(pagination.currentPage * DEFAULT_PAGE_SIZE, pagination.totalCount)}{' '} + of {pagination.totalCount} visits + {filterSummary.activeFilters > 0 && ( + + ({filterSummary.activeFilters}{' '} + {filterSummary.activeFilters === 1 ? 'filter' : 'filters'} active) + + )} + + )} + + + + + {!hasValidTier ? ( +
+ + + + + + Request Data Access + + + Your account has been created, but you don't have data access yet. Please + reach out to our team to get started. - {filterSummary && filterSummary.activeFilters > 0 && ( - - )} + + observerproject@pennmedicine.upenn.edu + - )} - - - - - )} +
+ ) : visitsLoading ? ( + + + {Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + ))} + + + ) : visits.length > 0 ? ( + <> + + + + ) : ( + + + No visits match your current filters. Try adjusting your search criteria. + + {filterSummary && filterSummary.activeFilters > 0 && ( + + )} + + )} +
+
+
+
{/* Create Cohort Dialog */} {filterSummary && ( diff --git a/src/components/search/EntityBadge.tsx b/src/components/search/EntityBadge.tsx new file mode 100644 index 0000000..2ccb755 --- /dev/null +++ b/src/components/search/EntityBadge.tsx @@ -0,0 +1,25 @@ +'use client'; + +import React from 'react'; +import { Badge } from '@chakra-ui/react'; + +const LABEL_COLORS: Record = { + MEDICATION: 'blue', + PROBLEM: 'red', + PROCEDURE: 'purple', + TEST: 'orange', +}; + +interface EntityBadgeProps { + label: string; + text: string; +} + +export default function EntityBadge({ label, text }: EntityBadgeProps) { + const color = LABEL_COLORS[label] ?? 'gray'; + return ( + + {text} + + ); +} diff --git a/src/components/search/FacetSidebar.tsx b/src/components/search/FacetSidebar.tsx new file mode 100644 index 0000000..cfb1c01 --- /dev/null +++ b/src/components/search/FacetSidebar.tsx @@ -0,0 +1,533 @@ +'use client'; + +import React from 'react'; +import { + Box, + VStack, + Text, + Input, + Checkbox, + Collapsible, + HStack, + Badge, + Card, +} from '@chakra-ui/react'; +import { + FaCalendar, + FaUsers, + FaUserMd, + FaVideo, + FaFileMedical, + FaChevronDown, + FaHospital, +} from 'react-icons/fa'; +import { EncounterSearchFilters, SearchAggregations } from '@/interfaces/search'; +import { + GENDER_LABELS, + RACE_LABELS, + ETHNICITY_LABELS, + labelDemographic, +} from '@/constants/demographicLabels'; + +interface FacetSidebarProps { + filters: EncounterSearchFilters; + aggregations: SearchAggregations | null; + onChange: (filters: EncounterSearchFilters) => void; +} + +function SectionHeader({ + icon, + title, + activeCount, + iconColor, +}: { + icon: React.ReactNode; + title: string; + activeCount: number; + iconColor: string; +}) { + return ( + + + {icon} + + {title} + + + + {activeCount > 0 && ( + + {activeCount} + + )} + + + + + + ); +} + +function AggCheckboxList({ + buckets, + selected, + onToggle, + labelMap, +}: { + buckets: { key: string; count: number }[]; + selected: string[] | undefined; + onToggle: (value: string) => void; + labelMap?: Record; +}) { + const validBuckets = buckets.filter((b) => b.key !== ''); + if (!validBuckets.length) { + return null; + } + return ( + + {validBuckets.map((b) => ( + onToggle(b.key)} + size="sm" + > + + + + + + {labelMap ? (labelDemographic(labelMap, b.key) ?? b.key) : b.key} + + + {b.count} + + + + + ))} + + ); +} + +function CollapsibleSection({ + children, + activeBg, + activeHoverBg, + activeBorderColor, + isActive, + header, +}: { + children: React.ReactNode; + activeBg: string; + activeHoverBg: string; + activeBorderColor: string; + isActive: boolean; + header: React.ReactNode; +}) { + return ( + + + {header} + + + + {children} + + + + ); +} + +export default function FacetSidebar({ filters, aggregations, onChange }: FacetSidebarProps) { + function update(patch: Partial) { + onChange({ ...filters, ...patch }); + } + + function toggleArrayValue(field: keyof EncounterSearchFilters, value: string) { + const current = (filters[field] as string[] | undefined) ?? []; + const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; + update({ [field]: next.length ? next : undefined }); + } + + function toggleBool(field: keyof EncounterSearchFilters, current?: boolean) { + update({ [field]: current === true ? undefined : true }); + } + + const departmentBuckets = aggregations?.departments ?? []; + const patientGenderBuckets = aggregations?.patient_genders ?? []; + const patientRaceBuckets = aggregations?.patient_races ?? []; + const patientEthnicityBuckets = aggregations?.patient_ethnicities ?? []; + const providerGenderBuckets = aggregations?.provider_genders ?? []; + const providerRaceBuckets = aggregations?.provider_races ?? []; + const providerEthnicityBuckets = aggregations?.provider_ethnicities ?? []; + const noteTypeBuckets = aggregations?.note_types ?? []; + + const departmentActiveCount = filters.department?.length ?? 0; + const dateActiveCount = [filters.date_from, filters.date_to].filter(Boolean).length; + const demographicActiveCount = [ + ...(filters.patient_gender ?? []), + ...(filters.patient_race ?? []), + ...(filters.patient_ethnicity ?? []), + ...(filters.provider_gender ?? []), + ...(filters.provider_race ?? []), + ...(filters.provider_ethnicity ?? []), + ].length; + const multimodalActiveCount = [ + filters.has_transcript, + filters.has_audio, + filters.has_provider_view, + filters.has_patient_view, + filters.has_room_view, + ].filter(Boolean).length; + const clinicalActiveCount = + (filters.has_notes ? 1 : 0) + + (filters.note_types?.length ?? 0) + + (filters.drug_names?.length ?? 0) + + (filters.icd_codes?.length ?? 0) + + (filters.cpt_codes?.length ?? 0); + + return ( + + + + + Filters + + + + + 0} + header={ + } + title="Date Range" + activeCount={dateActiveCount} + iconColor="blue.500" + /> + } + > + + + + From + + update({ date_from: e.target.value || undefined })} + borderColor="gray.300" + _focus={{ borderColor: 'blue.500' }} + /> + + + + To + + update({ date_to: e.target.value || undefined })} + borderColor="gray.300" + _focus={{ borderColor: 'blue.500' }} + /> + + + + + 0} + header={ + } + title="Department" + activeCount={departmentActiveCount} + iconColor="orange.500" + /> + } + > + + toggleArrayValue('department', val)} + /> + + + + 0} + header={ + } + title="Demographics" + activeCount={demographicActiveCount} + iconColor="green.500" + /> + } + > + + {patientGenderBuckets.length > 0 && ( + + + + + + + Patient Gender + + + toggleArrayValue('patient_gender', v)} + labelMap={GENDER_LABELS} + /> + + )} + {patientRaceBuckets.length > 0 && ( + + + Patient Race + + toggleArrayValue('patient_race', v)} + labelMap={RACE_LABELS} + /> + + )} + {patientEthnicityBuckets.length > 0 && ( + + + Patient Ethnicity + + toggleArrayValue('patient_ethnicity', v)} + labelMap={ETHNICITY_LABELS} + /> + + )} + {providerGenderBuckets.length > 0 && ( + + + + + + + Provider Gender + + + toggleArrayValue('provider_gender', v)} + labelMap={GENDER_LABELS} + /> + + )} + {providerRaceBuckets.length > 0 && ( + + + Provider Race + + toggleArrayValue('provider_race', v)} + labelMap={RACE_LABELS} + /> + + )} + {providerEthnicityBuckets.length > 0 && ( + + + Provider Ethnicity + + toggleArrayValue('provider_ethnicity', v)} + labelMap={ETHNICITY_LABELS} + /> + + )} + + + + 0} + header={ + } + title="Multimodal Data" + activeCount={multimodalActiveCount} + iconColor="teal.500" + /> + } + > + + {( + [ + { field: 'has_transcript', label: 'Transcript' }, + { field: 'has_audio', label: 'Audio' }, + { field: 'has_provider_view', label: 'Provider View' }, + { field: 'has_patient_view', label: 'Patient View' }, + { field: 'has_room_view', label: 'Room View' }, + ] as { field: keyof EncounterSearchFilters; label: string }[] + ).map(({ field, label }) => ( + toggleBool(field, filters[field] as boolean | undefined)} + size="sm" + > + + + + {label} + + + ))} + + + + 0} + header={ + } + title="Clinical Data" + activeCount={clinicalActiveCount} + iconColor="purple.500" + /> + } + > + + toggleBool('has_notes', filters.has_notes)} + size="sm" + > + + + + Has Clinical Notes + + + + {noteTypeBuckets.length > 0 && ( + + + Note Types + + toggleArrayValue('note_types', v)} + /> + + )} + + + + Drug Name + + { + const val = e.target.value.trim(); + update({ drug_names: val ? [val] : undefined }); + }} + borderColor="gray.300" + _focus={{ borderColor: 'purple.500' }} + /> + + + + + ICD Code + + { + const val = e.target.value.trim(); + update({ icd_codes: val ? [val] : undefined }); + }} + borderColor="gray.300" + _focus={{ borderColor: 'purple.500' }} + /> + + + + + CPT Code / Procedure + + { + const val = e.target.value.trim(); + update({ cpt_codes: val ? [val] : undefined }); + }} + borderColor="gray.300" + _focus={{ borderColor: 'purple.500' }} + /> + + + + + + + ); +} diff --git a/src/components/search/ResultCard.tsx b/src/components/search/ResultCard.tsx new file mode 100644 index 0000000..b55a301 --- /dev/null +++ b/src/components/search/ResultCard.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { Box, Card, HStack, VStack, Text, Badge, Checkbox } from '@chakra-ui/react'; +import { EncounterSearchHit } from '@/interfaces/search'; +import { COLORS } from '@/constants/colors'; +import { + GENDER_LABELS, + RACE_LABELS, + ETHNICITY_LABELS, + labelDemographic, +} from '@/constants/demographicLabels'; +import DOMPurify from 'dompurify'; +import { TierBadge } from './TierBadge'; + +interface ResultCardProps { + hit: EncounterSearchHit; + onSelect?: (hit: EncounterSearchHit) => void; + selectionMode?: boolean; + isSelected?: boolean; + onToggleSelect?: (encounterId: string) => void; +} + +function sanitizeHighlight(html: string): string { + const stripped = html.replace(/<(?!\/?mark\b)[^>]+>/gi, ''); + return DOMPurify.sanitize(stripped, { ALLOWED_TAGS: ['mark'], ALLOWED_ATTR: [] }); +} + +export default function ResultCard({ + hit, + onSelect, + selectionMode, + isSelected, + onToggleSelect, +}: ResultCardProps) { + const visitDate = hit.visit_date + ? new Date(`${hit.visit_date}T00:00:00`).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : null; + + const uniqueIcds = [...new Set(hit.icd_codes)]; + const visibleIcds = uniqueIcds.slice(0, 3); + const extraIcds = uniqueIcds.length - 3; + + const icdHighlight = hit.highlights?.['icd_codes.text']?.[0]; + const drugHighlight = hit.highlights?.['drug_names.text']?.[0]; + + // Only show active multimodal capabilities + const activeCapabilities: string[] = []; + if (hit.has_transcript) { + activeCapabilities.push('Transcript'); + } + if (hit.has_audio) { + activeCapabilities.push('Audio'); + } + if (hit.has_provider_view) { + activeCapabilities.push('Provider View'); + } + if (hit.has_patient_view) { + activeCapabilities.push('Patient View'); + } + if (hit.has_room_view) { + activeCapabilities.push('Room View'); + } + + const handleCardClick = () => { + if (selectionMode) { + onToggleSelect?.(hit.encounter_id); + } else { + onSelect?.(hit); + } + }; + + return ( + + + + {selectionMode && ( + + onToggleSelect?.(hit.encounter_id)} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + colorPalette="blue" + size="md" + > + + + + + )} + + {/* Header: demographics + tier + date */} + + + {/* Patient */} + + + Person: + + {hit.patient_gender && ( + + {labelDemographic(GENDER_LABELS, hit.patient_gender)} + + )} + {hit.patient_race && ( + + {labelDemographic(RACE_LABELS, hit.patient_race)} + + )} + {hit.patient_ethnicity && ( + + {labelDemographic(ETHNICITY_LABELS, hit.patient_ethnicity)} + + )} + {hit.patient_year_of_birth && ( + + b. {hit.patient_year_of_birth} + + )} + + {/* Provider */} + {(hit.provider_gender || hit.provider_race) && ( + + + Provider: + + {hit.provider_gender && ( + + {labelDemographic(GENDER_LABELS, hit.provider_gender)} + + )} + {hit.provider_race && ( + + {labelDemographic(RACE_LABELS, hit.provider_race)} + + )} + {hit.provider_year_of_birth && ( + + b. {hit.provider_year_of_birth} + + )} + + )} + + + + {hit.encounter_id && ( + + #{hit.encounter_id} + + )} + {visitDate && ( + + {visitDate} + + )} + {hit.department && ( + + {hit.department} + + )} + + + + {/* Active multimodal capabilities */} + {activeCapabilities.length > 0 && ( + + {activeCapabilities.map((cap) => ( + + {cap} + + ))} + + )} + + {/* Match source */} + {hit.matched_in && hit.matched_in.length > 0 && ( + + + Matched in: + + {hit.matched_in.map((source) => ( + + {source} + + ))} + + )} + + {/* Clinical summary */} + {(hit.drug_count > 0 || hit.note_count > 0 || uniqueIcds.length > 0) && ( + + {hit.drug_count > 0 && + (drugHighlight ? ( + + + + ) : ( + + {hit.drug_count} drug{hit.drug_count !== 1 ? 's' : ''} + + ))} + {hit.note_count > 0 && ( + + {hit.note_count} note{hit.note_count !== 1 ? 's' : ''} + + )} + {icdHighlight ? ( + + + + + + ) : ( + visibleIcds.map((code) => ( + + + {code} + + + )) + )} + {extraIcds > 0 && ( + + +{extraIcds} more + + )} + + )} + + + + + ); +} diff --git a/src/components/search/ResultsList.tsx b/src/components/search/ResultsList.tsx new file mode 100644 index 0000000..b9a2c54 --- /dev/null +++ b/src/components/search/ResultsList.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { VStack, Text, Box, Skeleton, Stack, HStack, Icon } from '@chakra-ui/react'; +import { HiSearch } from 'react-icons/hi'; +import { EncounterSearchHit, EncounterSearchSort } from '@/interfaces/search'; +import ResultCard from './ResultCard'; + +interface ResultsListProps { + results: EncounterSearchHit[]; + loading: boolean; + totalCount: number; + hasSearched: boolean; + sort?: EncounterSearchSort; + onSelect?: (hit: EncounterSearchHit) => void; + selectionMode?: boolean; + selectedIds?: Set; + onToggleSelect?: (encounterId: string) => void; +} + +function LoadingSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ); +} + +export default function ResultsList({ + results, + loading, + totalCount, + hasSearched, + sort, + onSelect, + selectionMode, + selectedIds, + onToggleSelect, +}: ResultsListProps) { + const sortLabel = sort?.visit_date + ? `sorted by date (${sort.visit_date === 'asc' ? 'oldest first' : 'newest first'})` + : sort?.tier_level + ? `sorted by tier (${sort.tier_level})` + : 'sorted by relevance'; + if (loading) { + return ; + } + + if (!hasSearched) { + return ( + + + + + + Search encounters + + + Enter a query or apply filters to find encounters. + + + ); + } + + if (results.length === 0) { + return ( + + + + + + No encounters found + + + Try adjusting your search query or filters. + + + ); + } + + return ( + + + + + {totalCount.toLocaleString()} + {' '} + encounter{totalCount !== 1 ? 's' : ''} found + + + {sortLabel} + + + {results.map((hit) => ( + + ))} + + ); +} diff --git a/src/components/search/SaveSearchCohortDialog.tsx b/src/components/search/SaveSearchCohortDialog.tsx new file mode 100644 index 0000000..86be424 --- /dev/null +++ b/src/components/search/SaveSearchCohortDialog.tsx @@ -0,0 +1,219 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Button, Input, Textarea, VStack, Text, Badge, HStack } from '@chakra-ui/react'; +import { + DialogRoot, + DialogContent, + DialogHeader, + DialogBody, + DialogFooter, + DialogTitle, + DialogCloseTrigger, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { CohortCreateRequest, Cohort } from '@/interfaces/cohort'; +import { EncounterSearchFilters } from '@/interfaces/search'; +import { validateCohortName, COHORT_NAME_MAX_LENGTH } from '@/lib/utils/cohortValidation'; +import { createCohort, getCohorts } from '@/lib/utils/cohortStorage'; + +export type SaveCohortMode = 'search' | 'selected'; + +interface SaveSearchCohortDialogProps { + isOpen: boolean; + onClose: () => void; + onSaved: (cohort: Cohort) => void; + mode: SaveCohortMode; + filters: EncounterSearchFilters; + query: string; + totalCount: number; + encounterIds?: string[]; +} + +export default function SaveSearchCohortDialog({ + isOpen, + onClose, + onSaved, + mode, + filters, + query, + totalCount, + encounterIds, +}: SaveSearchCohortDialogProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [existingCohorts, setExistingCohorts] = useState([]); + + useEffect(() => { + if (isOpen) { + getCohorts().then(setExistingCohorts); + } + }, [isOpen]); + + const count = mode === 'selected' ? (encounterIds?.length ?? 0) : totalCount; + + const handleSave = async () => { + const validationError = validateCohortName(name, existingCohorts); + if (validationError) { + setError(validationError); + return; + } + + try { + setLoading(true); + setError(null); + + const request: CohortCreateRequest = { + name: name.trim(), + description: description.trim() || undefined, + source: 'search', + filters: mode === 'search' ? filters : undefined, + encounterIds: mode === 'selected' ? encounterIds : undefined, + searchQuery: query || undefined, + visitCount: count, + }; + + const cohort = await createCohort(request); + setName(''); + setDescription(''); + onSaved(cohort); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create cohort'); + } finally { + setLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !loading && name.trim()) { + handleSave(); + } + }; + + const handleClose = () => { + if (!loading) { + setName(''); + setDescription(''); + setError(null); + onClose(); + } + }; + + return ( + !e.open && handleClose()}> + + + + Save as Cohort + + + + + + + {/* Context */} + + + {mode === 'selected' ? ( + <> + Saving {count.toLocaleString()} selected encounter + {count !== 1 ? 's' : ''} + + ) : ( + <> + Saving search with {count.toLocaleString()} encounter + {count !== 1 ? 's' : ''} + + )} + + {query && ( + + query: {query.length > 30 ? `${query.slice(0, 30)}...` : query} + + )} + + + {/* Name */} + + { + setName(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + placeholder="e.g., Diabetic patients with transcript" + disabled={loading} + maxLength={COHORT_NAME_MAX_LENGTH} + variant="outline" + padding={2} + border="1px solid" + borderColor="gray.300" + _hover={{ borderColor: 'gray.400' }} + _focus={{ borderColor: 'blue.500' }} + /> + + + {/* Description */} + +