Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion backend/api/query/adapters/patient.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Any

from sqlalchemy import Select, and_, case, or_
from sqlalchemy import Select, and_, case, func, or_
from sqlalchemy.orm import aliased

from api.context import Info
Expand Down Expand Up @@ -55,6 +55,48 @@ def _parse_property_key(field_key: str) -> str | None:
return field_key.removeprefix("property_")


LOCATION_SORT_KEY_KINDS: dict[str, tuple[str, ...]] = {
"location-CLINIC": ("CLINIC", "PRACTICE"),
"location-WARD": ("WARD",),
"location-ROOM": ("ROOM",),
"location-BED": ("BED",),
}


LOCATION_SORT_KEY_LABELS: dict[str, str] = {
"location-CLINIC": "Clinic",
"location-WARD": "Ward",
"location-ROOM": "Room",
"location-BED": "Bed",
}


def _ensure_position_lineage_joins(
query: Select[Any], ctx: dict[str, Any]
) -> tuple[Select[Any], list[Any]]:
if "position_lineage_nodes" in ctx:
return query, ctx["position_lineage_nodes"]
query, position_node = _ensure_position_join(query, ctx)
lineage_nodes: list[Any] = [position_node]
for depth in range(1, 8):
parent_node = aliased(models.LocationNode, name=f"position_parent_{depth}")
query = query.outerjoin(parent_node, lineage_nodes[-1].parent_id == parent_node.id)
lineage_nodes.append(parent_node)
ctx["position_lineage_nodes"] = lineage_nodes
return query, lineage_nodes


def _location_title_for_kind(lineage_nodes: list[Any], target_kinds: tuple[str, ...]) -> Any:
candidates = [
case(
(node.kind.in_(target_kinds), location_title_expr(node)),
else_=None,
)
for node in lineage_nodes
]
return func.coalesce(*candidates)


def apply_patient_filter_clause(
query: Select[Any],
clause: QueryFilterClauseInput,
Expand Down Expand Up @@ -258,6 +300,10 @@ def apply_patient_sorts(
order_parts.append(
t.desc().nulls_last() if desc_order else t.asc().nulls_first()
)
elif key in LOCATION_SORT_KEY_KINDS:
query, lineage_nodes = _ensure_position_lineage_joins(query, ctx)
t = _location_title_for_kind(lineage_nodes, LOCATION_SORT_KEY_KINDS[key])
order_parts.append(t.desc() if desc_order else t.asc())

if not order_parts:
return query.order_by(models.Patient.id.asc())
Expand Down Expand Up @@ -445,4 +491,17 @@ def build_patient_queryable_fields_static() -> list[QueryableField]:
],
),
),
*[
QueryableField(
key=key,
label=label,
kind=QueryableFieldKind.SCALAR,
value_type=QueryableValueType.STRING,
allowed_operators=[],
sortable=True,
sort_directions=sort_directions_for(True),
searchable=False,
)
for key, label in LOCATION_SORT_KEY_LABELS.items()
],
]
76 changes: 76 additions & 0 deletions web/components/common/ExpandableTextBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import clsx from 'clsx'
import { useTasksTranslation } from '@/i18n/useTasksTranslation'

type ExpandableTextBlockProps = {
children: ReactNode,
collapsedLines?: number,
className?: string,
}

export const ExpandableTextBlock = ({ children, collapsedLines = 4, className }: ExpandableTextBlockProps) => {
const translation = useTasksTranslation()
const contentRef = useRef<HTMLDivElement | null>(null)
const [expanded, setExpanded] = useState(false)
const [hasOverflow, setHasOverflow] = useState(false)

useEffect(() => {
const element = contentRef.current
if (!element || expanded) {
return
}

const updateOverflow = () => {
setHasOverflow(element.scrollHeight > element.clientHeight + 1)
}

updateOverflow()

if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateOverflow)
observer.observe(element)
return () => {
observer.disconnect()
}
}

window.addEventListener('resize', updateOverflow)
return () => {
window.removeEventListener('resize', updateOverflow)
}
}, [expanded, children, collapsedLines])

const collapsedStyle: CSSProperties | undefined = expanded
? undefined
: {
display: '-webkit-box',
WebkitLineClamp: collapsedLines,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}

return (
<div className="min-w-0">
<div
ref={contentRef}
className={clsx('min-w-0 break-words whitespace-pre-wrap', className)}
style={collapsedStyle}
>
{children}
</div>
{(hasOverflow || expanded) && (
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setExpanded(prev => !prev)
}}
className="mt-1 text-sm font-medium text-primary hover:underline"
>
{expanded ? translation('hide') : translation('more')}
</button>
)}
</div>
)
}
10 changes: 6 additions & 4 deletions web/components/patients/PatientCardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import type { PatientViewModel } from '../tables/PatientList'

type PatientCardViewProps = {
patient: PatientViewModel,
onClick: (patient: PatientViewModel) => void,
onClick?: (patient: PatientViewModel) => void,
extraContent?: ReactNode,
}

export const PatientCardView = ({ patient, onClick, extraContent }: PatientCardViewProps) => {
const translation = useTasksTranslation()
const isClickable = Boolean(onClick)

const sex = patient.sex
const colorClass = sex === Sex.Male
Expand All @@ -35,12 +36,13 @@ export const PatientCardView = ({ patient, onClick, extraContent }: PatientCardV

return (
<button
onClick={() => onClick(patient)}
className="border-2 p-5 rounded-lg text-left w-full transition-colors hover:border-primary relative bg-[rgba(255,255,255,1)] dark:bg-[rgba(55,65,81,1)]"
type="button"
onClick={onClick ? () => onClick(patient) : undefined}
className={`border-2 p-5 rounded-lg text-left w-full transition-colors relative bg-[rgba(255,255,255,1)] dark:bg-[rgba(55,65,81,1)] ${isClickable ? 'cursor-pointer hover:border-primary' : 'cursor-default'}`}
>
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg flex-1">{patient.name}</h3>
<h3 className="font-semibold text-lg flex-1 min-w-0 whitespace-normal break-words">{patient.name}</h3>
{total > 0 && (
<Tooltip
tooltip={(
Expand Down
65 changes: 51 additions & 14 deletions web/components/tables/PatientList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Chip, FillerCell, HelpwaveLogo, LoadingContainer, SearchBar, ProgressIn
import clsx from 'clsx'
import { LayoutGrid, PlusIcon, Table2 } from 'lucide-react'
import type { LocationType } from '@/api/gql/generated'
import { Sex, PatientState, type GetPatientsQuery, type TaskType, PropertyEntity, FieldType } from '@/api/gql/generated'
import { Sex, PatientState, type GetPatientsQuery, type TaskType, PropertyEntity, FieldType, type QueryableField } from '@/api/gql/generated'
import { usePropertyDefinitions, usePatientsPaginated, useQueryableFields, useRefreshingEntityIds } from '@/data'
import { PatientDetailView } from '@/components/patients/PatientDetailView'
import { LocationChips } from '@/components/locations/LocationChips'
Expand All @@ -24,6 +24,7 @@ import { PatientCardView } from '@/components/patients/PatientCardView'
import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems } from '@/utils/queryableFilterList'
import { getPropertyFilterFn as getPropertyDatatype } from '@/utils/propertyFilterMapping'
import { UserSelectFilterPopUp } from './UserSelectFilterPopUp'
import { ExpandableTextBlock } from '@/components/common/ExpandableTextBlock'
import { SaveViewDialog } from '@/components/views/SaveViewDialog'
import { SaveViewActionsMenu } from '@/components/views/SaveViewActionsMenu'
import {
Expand Down Expand Up @@ -103,9 +104,10 @@ type PatientListProps = {
/** When set (e.g. on `/view/:id`), overwrite updates this saved view. */
savedViewId?: string,
onSavedViewCreated?: (id: string) => void,
onPatientUpdated?: () => void,
}

export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initialPatientId, onInitialPatientOpened, acceptedStates: _acceptedStates, rootLocationIds, locationId, viewDefaultFilters, viewDefaultSorting, viewDefaultSearchQuery, viewDefaultColumnVisibility, viewDefaultColumnOrder, readOnly: _readOnly, hideSaveView, savedViewId, onSavedViewCreated }, ref) => {
export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initialPatientId, onInitialPatientOpened, acceptedStates: _acceptedStates, rootLocationIds, locationId, viewDefaultFilters, viewDefaultSorting, viewDefaultSearchQuery, viewDefaultColumnVisibility, viewDefaultColumnOrder, readOnly: _readOnly, hideSaveView, savedViewId, onSavedViewCreated, onPatientUpdated }, ref) => {
const translation = useTasksTranslation()
const { locale } = useLocale()
const { selectedRootLocationIds } = useTasksContext()
Expand Down Expand Up @@ -579,35 +581,67 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
})),
], [translation, patientPropertyColumns, refreshingPatientIds, rowLoadingCell, dateFormat])

const propertyFieldTypeByDefId = useMemo(
() => new Map(propertyDefinitionsData?.propertyDefinitions.map(d => [d.id, d.fieldType]) ?? []),
[propertyDefinitionsData]
)

const renderPatientCardExtras = useCallback((patient: PatientViewModel): ReactNode => {
const rows: ReactNode[] = []
for (const col of columns) {
const id = col.id as string | undefined
if (!id || PATIENT_CARD_PRIMARY_COLUMN_IDS.has(id)) continue
if (columnVisibility[id] === false) continue
if (!col.cell) continue
const isExpandableTextProperty = id.startsWith('property_') &&
propertyFieldTypeByDefId.get(id.replace('property_', '')) === FieldType.FieldTypeText
const headerLabel = typeof col.header === 'string' ? col.header : id
const cell = (col.cell as (p: { row: { original: PatientViewModel } }) => ReactNode)({ row: { original: patient } })
const propertyId = id.startsWith('property_') ? id.replace('property_', '') : null
const propertyTextValue = propertyId
? patient.properties?.find(property => property.definition.id === propertyId)?.textValue
: null
rows.push(
<div key={id} className="flex flex-col gap-0.5 sm:flex-row sm:gap-3 sm:items-start text-left">
<span className="text-description shrink-0 min-w-[7rem]">{headerLabel}</span>
<div className="min-w-0 break-words">{cell}</div>
<div className="min-w-0 break-words">
{isExpandableTextProperty ? (
<ExpandableTextBlock>{propertyTextValue ?? ''}</ExpandableTextBlock>
) : cell}
</div>
</div>
)
}
if (rows.length === 0) return null
return <div className="mt-3 pt-3 border-t border-border space-y-2 w-full">{rows}</div>
}, [columns, columnVisibility])

const propertyFieldTypeByDefId = useMemo(
() => new Map(propertyDefinitionsData?.propertyDefinitions.map(d => [d.id, d.fieldType]) ?? []),
[propertyDefinitionsData]
)
}, [columns, columnVisibility, propertyFieldTypeByDefId])

const resolvePatientQueryableLabel = useCallback((field: QueryableField): string => {
if (field.propertyDefinitionId) return field.label
const key = field.key === 'locationSubtree' ? 'position' : field.key
const translatedByKey: Partial<Record<string, string>> = {
'name': translation('name'),
'firstname': translation('firstName'),
'lastname': translation('lastName'),
'birthdate': translation('birthdate'),
'sex': translation('sex'),
'state': translation('status'),
'position': translation('location'),
'location-CLINIC': translation('locationClinic'),
'location-WARD': translation('locationWard'),
'location-ROOM': translation('locationRoom'),
'location-BED': translation('locationBed'),
'tasks': translation('tasks'),
'updated': translation('updated'),
'updateDate': translation('updated'),
}
return translatedByKey[key] ?? field.label
}, [translation])

const availableFilters: FilterListItem[] = useMemo(() => {
const raw = queryableFieldsData?.queryableFields
if (raw?.length) {
return queryableFieldsToFilterListItems(raw, propertyFieldTypeByDefId)
return queryableFieldsToFilterListItems(raw, propertyFieldTypeByDefId, resolvePatientQueryableLabel)
}
return [
{
Expand Down Expand Up @@ -664,15 +698,15 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
}
}) ?? [],
]
}, [queryableFieldsData?.queryableFields, propertyFieldTypeByDefId, translation, allPatientStates, propertyDefinitionsData?.propertyDefinitions])
}, [queryableFieldsData?.queryableFields, propertyFieldTypeByDefId, resolvePatientQueryableLabel, translation, allPatientStates, propertyDefinitionsData?.propertyDefinitions])

const availableSortItems = useMemo(() => {
const raw = queryableFieldsData?.queryableFields
if (raw?.length) {
return queryableFieldsToSortingListItems(raw)
return queryableFieldsToSortingListItems(raw, resolvePatientQueryableLabel)
}
return availableFilters.map(({ id, label, dataType }) => ({ id, label, dataType }))
}, [queryableFieldsData?.queryableFields, availableFilters])
}, [queryableFieldsData?.queryableFields, availableFilters, resolvePatientQueryableLabel])

const knownColumnIdsOrdered = useMemo(
() => columnIdsFromColumnDefs(columns),
Expand Down Expand Up @@ -883,7 +917,10 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
<PatientDetailView
patientId={selectedPatient?.id ?? openedPatientId ?? undefined}
onClose={handleClose}
onSuccess={refetch}
onSuccess={() => {
refetch()
onPatientUpdated?.()
}}
/>
</Drawer>
<SaveViewDialog
Expand Down
Loading
Loading