diff --git a/backend/api/query/adapters/patient.py b/backend/api/query/adapters/patient.py index a4e310c..015fde5 100644 --- a/backend/api/query/adapters/patient.py +++ b/backend/api/query/adapters/patient.py @@ -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 @@ -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, @@ -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()) @@ -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() + ], ] diff --git a/web/components/common/ExpandableTextBlock.tsx b/web/components/common/ExpandableTextBlock.tsx new file mode 100644 index 0000000..0bcf3d2 --- /dev/null +++ b/web/components/common/ExpandableTextBlock.tsx @@ -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(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 ( +
+
+ {children} +
+ {(hasOverflow || expanded) && ( + + )} +
+ ) +} diff --git a/web/components/patients/PatientCardView.tsx b/web/components/patients/PatientCardView.tsx index 0c724aa..7a765e4 100644 --- a/web/components/patients/PatientCardView.tsx +++ b/web/components/patients/PatientCardView.tsx @@ -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 @@ -35,12 +36,13 @@ export const PatientCardView = ({ patient, onClick, extraContent }: PatientCardV return ( {task.patient.locations && task.patient.locations.length > 0 && (
diff --git a/web/components/views/PatientViewTasksPanel.tsx b/web/components/views/PatientViewTasksPanel.tsx index f1a4cd3..feac806 100644 --- a/web/components/views/PatientViewTasksPanel.tsx +++ b/web/components/views/PatientViewTasksPanel.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { usePatients } from '@/data' import { PatientState } from '@/api/gql/generated' import type { QuerySearchInput } from '@/api/gql/generated' @@ -20,12 +20,14 @@ type PatientViewTasksPanelProps = { filterDefinitionJson: string, sortDefinitionJson: string, parameters: ViewParameters, + refreshVersion?: number, } export function PatientViewTasksPanel({ filterDefinitionJson, sortDefinitionJson, parameters, + refreshVersion, }: PatientViewTasksPanelProps) { const filters = deserializeColumnFiltersFromView(filterDefinitionJson) const sorting = deserializeSortingFromView(sortDefinitionJson) @@ -107,6 +109,11 @@ export function PatientViewTasksPanel({ }) }, [patientsData]) + useEffect(() => { + if (refreshVersion === undefined || refreshVersion <= 0) return + refetch() + }, [refreshVersion, refetch]) + return ( >): readonly ['id'] | false => { const id = object?.['id'] return id != null && id !== '' ? ['id'] : false } +type ReadFieldFromReference = (fieldName: string, from: Reference) => unknown + +const getReferenceIdentity = (readField: ReadFieldFromReference, reference: Reference): string => { + const id = readField('id', reference) + return typeof id === 'string' && id !== '' ? id : JSON.stringify(reference) +} + +const mergeReferencesByIdentity = ( + existing: readonly Reference[] = [], + incoming: readonly Reference[] = [], + { readField }: { readField: ReadFieldFromReference } +): readonly Reference[] => { + const incomingIdentities = new Set() + for (const reference of incoming) { + incomingIdentities.add(getReferenceIdentity(readField, reference)) + } + + const mergedReferences = [...incoming] + for (const reference of existing) { + const identity = getReferenceIdentity(readField, reference) + if (!incomingIdentities.has(identity)) { + mergedReferences.push(reference) + } + } + + return mergedReferences +} + export function buildCacheConfig(): InMemoryCacheConfig { return { typePolicies: { @@ -29,7 +57,18 @@ export function buildCacheConfig(): InMemoryCacheConfig { }, }, Task: { keyFields: ['id'] }, - Patient: { keyFields: ['id'] }, + Patient: { + keyFields: ['id'], + fields: { + properties: { merge: mergeReferencesByIdentity }, + }, + }, + PatientType: { + keyFields: ['id'], + fields: { + properties: { merge: mergeReferencesByIdentity }, + }, + }, User: { keyFields: ['id'] }, UserType: { keyFields: ['id'], diff --git a/web/i18n/translations.ts b/web/i18n/translations.ts index 75af1f2..b6ef161 100644 --- a/web/i18n/translations.ts +++ b/web/i18n/translations.ts @@ -90,6 +90,7 @@ export type TasksTranslationEntries = { 'filterUndone': string, 'firstName': string, 'freeBeds': string, + 'hide': string, 'homePage': string, 'imprint': string, 'inactive': string, @@ -116,6 +117,7 @@ export type TasksTranslationEntries = { 'markPatientDead': string, 'markPatientDeadConfirmation': string, 'menu': string, + 'more': string, 'myFavorites': string, 'myOpenTasks': string, 'myTasks': string, @@ -354,6 +356,7 @@ export const tasksTranslation: Translation { - return `Update ${name}!` + return `Update ${name}` }, 'refreshing': `Refreshing…`, 'removeProperty': `Remove Property`, @@ -1001,8 +1007,8 @@ export const tasksTranslation: Translation { return `Añadir ${name}` }, - 'readOnlyView': `Read-only`, + 'readOnlyView': `Solo lectura`, 'recentPatients': `Tus pacientes recientes`, 'recentTasks': `Tus tareas recientes`, 'rEdit': ({ name }): string => { - return `Actualizar ${name}!` + return `Actualizar ${name}` }, 'refreshing': `Actualizando…`, 'removeProperty': `Eliminar propiedad`, @@ -1289,16 +1297,16 @@ export const tasksTranslation: Translation { return `Ajouter ${name}` }, - 'readOnlyView': `Read-only`, + 'readOnlyView': `Lecture seule`, 'recentPatients': `Vos patients récents`, 'recentTasks': `Vos tâches récentes`, 'rEdit': ({ name }): string => { - return `Mettre à jour ${name} !` + return `Mettre à jour ${name}` }, 'refreshing': `Actualisation…`, 'removeProperty': `Supprimer la propriété`, @@ -1639,16 +1649,16 @@ export const tasksTranslation: Translation { return `${name} toevoegen` }, - 'readOnlyView': `Read-only`, + 'readOnlyView': `Alleen-lezen`, 'recentPatients': `Uw recente patiënten`, 'recentTasks': `Uw recente taken`, 'rEdit': ({ name }): string => { - return `${name} bijwerken!` + return `${name} bijwerken` }, 'refreshing': `Bijwerken…`, 'removeProperty': `Eigenschap verwijderen`, @@ -1911,11 +1923,11 @@ export const tasksTranslation: Translation { return `Adicionar ${name}` }, - 'readOnlyView': `Read-only`, + 'readOnlyView': `Somente leitura`, 'recentPatients': `Seus pacientes recentes`, 'recentTasks': `Suas tarefas recentes`, 'rEdit': ({ name }): string => { - return `Atualizar ${name}!` + return `Atualizar ${name}` }, 'refreshing': `Atualizando…`, 'removeProperty': `Remover propriedade`, @@ -2261,11 +2275,11 @@ export const tasksTranslation: Translation=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.1", + "@next/swc-darwin-x64": "16.2.1", + "@next/swc-linux-arm64-gnu": "16.2.1", + "@next/swc-linux-arm64-musl": "16.2.1", + "@next/swc-linux-x64-gnu": "16.2.1", + "@next/swc-linux-x64-musl": "16.2.1", + "@next/swc-win32-arm64-msvc": "16.2.1", + "@next/swc-win32-x64-msvc": "16.2.1", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/web/package.json b/web/package.json index de71a86..2dc9256 100644 --- a/web/package.json +++ b/web/package.json @@ -36,7 +36,7 @@ "formidable": "3.5.4", "graphql-ws": "6.0.6", "lucide-react": "0.468.0", - "next": "16.1.6", + "next": "^16.2.1", "next-runtime-env": "2.0.1", "oidc-client-ts": "3.4.1", "postcss": "8.5.3", diff --git a/web/pages/view/[uid].tsx b/web/pages/view/[uid].tsx index f4f3f8c..f2e5db0 100644 --- a/web/pages/view/[uid].tsx +++ b/web/pages/view/[uid].tsx @@ -382,6 +382,7 @@ const ViewPage: NextPage = () => { const [duplicateOpen, setDuplicateOpen] = useState(false) const [duplicateName, setDuplicateName] = useState('') + const [patientViewRefreshVersion, setPatientViewRefreshVersion] = useState(0) const [duplicateSavedView] = useMutation< DuplicateSavedViewMutation, @@ -527,6 +528,7 @@ const ViewPage: NextPage = () => { hideSaveView={!view.isOwner} savedViewId={view.isOwner ? view.id : undefined} onSavedViewCreated={(id) => router.push(`/view/${id}`)} + onPatientUpdated={() => setPatientViewRefreshVersion(v => v + 1)} /> @@ -534,6 +536,7 @@ const ViewPage: NextPage = () => { filterDefinitionJson={view.filterDefinition} sortDefinitionJson={view.sortDefinition} parameters={params} + refreshVersion={patientViewRefreshVersion} /> diff --git a/web/utils/queryableFilterList.tsx b/web/utils/queryableFilterList.tsx index 5d42317..68f00c0 100644 --- a/web/utils/queryableFilterList.tsx +++ b/web/utils/queryableFilterList.tsx @@ -27,13 +27,16 @@ function filterFieldDataType(field: QueryableField): DataType { } export type QueryableSortListItem = Pick +export type QueryableFieldLabelResolver = (field: QueryableField) => string export function queryableFieldsToFilterListItems( fields: QueryableField[], - propertyFieldTypeByDefId: Map + propertyFieldTypeByDefId: Map, + resolveLabel?: QueryableFieldLabelResolver ): FilterListItem[] { return fields.filter(field => field.filterable).map((field): FilterListItem => { const dataType = filterFieldDataType(field) + const label = resolveLabel ? resolveLabel(field) : field.label const tags = field.choice ? field.choice.optionLabels.map((label, idx) => ({ label, @@ -49,20 +52,20 @@ export function queryableFieldsToFilterListItems( return { id: field.key, - label: field.label, + label, dataType, tags, activeLabelBuilder: field.key === 'position' ? (v: FilterValue): ReactNode => ( <> - {field.label} + {label} ) : isUserFilterUi ? (v: FilterValue): ReactNode => ( <> - {field.label} + {label} ) @@ -77,13 +80,14 @@ export function queryableFieldsToFilterListItems( } export function queryableFieldsToSortingListItems( - fields: QueryableField[] + fields: QueryableField[], + resolveLabel?: QueryableFieldLabelResolver ): QueryableSortListItem[] { return fields .filter(field => field.sortable && field.sortDirections.length > 0) .map((field): QueryableSortListItem => ({ id: field.key, - label: field.label, + label: resolveLabel ? resolveLabel(field) : field.label, dataType: valueKindToDataType(field), })) }