From 6bd0688ebb73900b02a205d0dcab66f1831b09d6 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Mon, 26 Jan 2026 20:21:55 +0100 Subject: [PATCH] establish table backend connection --- backend/api/decorators/filter_sort.py | 98 ++++- backend/api/decorators/full_text_search.py | 8 +- backend/api/resolvers/patient.py | 396 ++++++++++++++---- backend/api/resolvers/task.py | 302 ++++++++++++- backend/api/types/pagination.py | 19 + backend/main.py | 12 +- web/api/gql/generated.ts | 321 +++++++++++++- web/api/graphql/GetOverviewData.graphql | 44 +- web/api/graphql/GetPatients.graphql | 17 +- web/api/graphql/GetTasks.graphql | 23 +- web/components/patients/PatientList.tsx | 218 +++++++++- web/components/tables/RecentPatientsTable.tsx | 187 ++++++++- web/components/tables/RecentTasksTable.tsx | 187 ++++++++- web/components/tasks/TaskList.tsx | 193 ++++++++- web/hooks/usePaginatedQuery.ts | 22 +- web/pages/index.tsx | 11 +- web/pages/tasks/index.tsx | 7 +- web/utils/tableToGraphQL.ts | 132 ++++++ 18 files changed, 2025 insertions(+), 172 deletions(-) create mode 100644 backend/api/types/pagination.py create mode 100644 web/utils/tableToGraphQL.ts diff --git a/backend/api/decorators/filter_sort.py b/backend/api/decorators/filter_sort.py index a7a82480..c38cacb0 100644 --- a/backend/api/decorators/filter_sort.py +++ b/backend/api/decorators/filter_sort.py @@ -49,7 +49,9 @@ def get_property_join_alias( ) -> Any: entity_type = detect_entity_type(model_class) if not entity_type: - raise ValueError(f"Unsupported entity type for property filtering: {model_class}") + raise ValueError( + f"Unsupported entity type for property filtering: {model_class}" + ) property_alias = aliased(models.PropertyValue) value_column = get_property_value_column(field_type) @@ -97,10 +99,16 @@ def apply_sorting( continue field_type = property_field_types.get( - sort_input.property_definition_id, "FIELD_TYPE_TEXT" + sort_input.property_definition_id, + "FIELD_TYPE_TEXT" ) - query, property_alias, value_column = get_property_join_alias( - query, model_class, sort_input.property_definition_id, field_type + query, property_alias, value_column = ( + get_property_join_alias( + query, + model_class, + sort_input.property_definition_id, + field_type, + ) ) if sort_input.direction == SortDirection.DESC: @@ -114,7 +122,9 @@ def apply_sorting( return query -def apply_text_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: +def apply_text_filter( + column: Any, operator: FilterOperator, parameter: Any +) -> Any: search_text = parameter.search_text if search_text is None: return None @@ -155,7 +165,9 @@ def apply_text_filter(column: Any, operator: FilterOperator, parameter: Any) -> return None -def apply_number_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: +def apply_number_filter( + column: Any, operator: FilterOperator, parameter: Any +) -> Any: compare_value = parameter.compare_value min_value = parameter.min max_value = parameter.max @@ -192,7 +204,9 @@ def normalize_date_for_comparison(date_value: Any) -> Any: return date_value -def apply_date_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: +def apply_date_filter( + column: Any, operator: FilterOperator, parameter: Any +) -> Any: compare_date = parameter.compare_date min_date = parameter.min_date max_date = parameter.max_date @@ -276,7 +290,9 @@ def apply_datetime_filter( return None -def apply_boolean_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: +def apply_boolean_filter( + column: Any, operator: FilterOperator, parameter: Any +) -> Any: if operator == FilterOperator.BOOLEAN_IS_TRUE: return column.is_(True) if operator == FilterOperator.BOOLEAN_IS_FALSE: @@ -336,7 +352,9 @@ def apply_tags_single_filter( return None -def apply_null_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: +def apply_null_filter( + column: Any, operator: FilterOperator, parameter: Any +) -> Any: if operator == FilterOperator.IS_NULL: return column.is_(None) if operator == FilterOperator.IS_NOT_NULL: @@ -432,7 +450,8 @@ def apply_filtering( continue field_type = property_field_types.get( - filter_input.property_definition_id, "FIELD_TYPE_TEXT" + filter_input.property_definition_id, + "FIELD_TYPE_TEXT" ) query, property_alias, value_column = get_property_join_alias( query, model_class, filter_input.property_definition_id, field_type @@ -450,7 +469,9 @@ def apply_filtering( FilterOperator.TEXT_STARTS_WITH, FilterOperator.TEXT_ENDS_WITH, ]: - condition = apply_text_filter(value_column, operator, parameter) + condition = apply_text_filter( + value_column, operator, parameter + ) elif operator in [ FilterOperator.NUMBER_EQUALS, @@ -474,7 +495,9 @@ def apply_filtering( FilterOperator.DATE_BETWEEN, FilterOperator.DATE_NOT_BETWEEN, ]: - condition = apply_date_filter(value_column, operator, parameter) + condition = apply_date_filter( + value_column, operator, parameter + ) elif operator in [ FilterOperator.DATETIME_EQUALS, @@ -492,7 +515,9 @@ def apply_filtering( FilterOperator.BOOLEAN_IS_TRUE, FilterOperator.BOOLEAN_IS_FALSE, ]: - condition = apply_boolean_filter(value_column, operator, parameter) + condition = apply_boolean_filter( + value_column, operator, parameter + ) elif operator in [ FilterOperator.TAGS_EQUALS, @@ -500,7 +525,9 @@ def apply_filtering( FilterOperator.TAGS_CONTAINS, FilterOperator.TAGS_NOT_CONTAINS, ]: - condition = apply_tags_filter(value_column, operator, parameter) + condition = apply_tags_filter( + value_column, operator, parameter + ) elif operator in [ FilterOperator.TAGS_SINGLE_EQUALS, @@ -514,7 +541,9 @@ def apply_filtering( FilterOperator.IS_NULL, FilterOperator.IS_NOT_NULL, ]: - condition = apply_null_filter(value_column, operator, parameter) + condition = apply_null_filter( + value_column, operator, parameter + ) if condition is not None: filter_conditions.append(condition) @@ -546,13 +575,20 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: if not model_class: if isinstance(result, Select): for arg in args: - if hasattr(arg, "context") and hasattr(arg.context, "db"): + if ( + hasattr(arg, "context") + and hasattr(arg.context, "db") + ): db = arg.context.db query_result = await db.execute(result) return query_result.scalars().all() else: info = kwargs.get("info") - if info and hasattr(info, "context") and hasattr(info.context, "db"): + if ( + info + and hasattr(info, "context") + and hasattr(info.context, "db") + ): db = info.context.db query_result = await db.execute(result) return query_result.scalars().all() @@ -579,7 +615,10 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: if property_def_ids: for arg in args: - if hasattr(arg, "context") and hasattr(arg.context, "db"): + if ( + hasattr(arg, "context") + and hasattr(arg.context, "db") + ): db = arg.context.db prop_defs_result = await db.execute( select(models.PropertyDefinition).where( @@ -588,12 +627,17 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: ) prop_defs = prop_defs_result.scalars().all() property_field_types = { - str(prop_def.id): prop_def.field_type for prop_def in prop_defs + str(prop_def.id): prop_def.field_type + for prop_def in prop_defs } break else: info = kwargs.get("info") - if info and hasattr(info, "context") and hasattr(info.context, "db"): + if ( + info + and hasattr(info, "context") + and hasattr(info.context, "db") + ): db = info.context.db prop_defs_result = await db.execute( select(models.PropertyDefinition).where( @@ -602,7 +646,8 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: ) prop_defs = prop_defs_result.scalars().all() property_field_types = { - str(prop_def.id): prop_def.field_type for prop_def in prop_defs + str(prop_def.id): prop_def.field_type + for prop_def in prop_defs } if filtering: @@ -624,13 +669,20 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: if isinstance(result, Select): for arg in args: - if hasattr(arg, "context") and hasattr(arg.context, "db"): + if ( + hasattr(arg, "context") + and hasattr(arg.context, "db") + ): db = arg.context.db query_result = await db.execute(result) return query_result.scalars().all() else: info = kwargs.get("info") - if info and hasattr(info, "context") and hasattr(info.context, "db"): + if ( + info + and hasattr(info, "context") + and hasattr(info.context, "db") + ): db = info.context.db query_result = await db.execute(result) return query_result.scalars().all() diff --git a/backend/api/decorators/full_text_search.py b/backend/api/decorators/full_text_search.py index e6136bdb..b251996d 100644 --- a/backend/api/decorators/full_text_search.py +++ b/backend/api/decorators/full_text_search.py @@ -65,10 +65,14 @@ def apply_full_text_search( if search_input.property_definition_ids: property_filter = and_( property_alias.text_value.ilike(search_pattern), - property_alias.definition_id.in_(search_input.property_definition_ids), + property_alias.definition_id.in_( + search_input.property_definition_ids + ), ) else: - property_filter = property_alias.text_value.ilike(search_pattern) + property_filter = ( + property_alias.text_value.ilike(search_pattern) + ) query = query.outerjoin(property_alias, join_condition) search_conditions.append(property_filter) diff --git a/backend/api/resolvers/patient.py b/backend/api/resolvers/patient.py index d7354407..cb018e20 100644 --- a/backend/api/resolvers/patient.py +++ b/backend/api/resolvers/patient.py @@ -3,9 +3,17 @@ import strawberry from api.audit import audit_log from api.context import Info -from api.decorators.filter_sort import filtered_and_sorted_query -from api.decorators.full_text_search import full_text_search_query +from api.decorators.filter_sort import ( + apply_filtering, + apply_sorting, + filtered_and_sorted_query, +) +from api.decorators.full_text_search import ( + apply_full_text_search, + full_text_search_query, +) from api.inputs import ( + ColumnType, FilterInput, FullTextSearchInput, PaginationInput, @@ -21,52 +29,20 @@ from api.types.patient import PatientType from database import models from graphql import GraphQLError -from sqlalchemy import desc, func, select +from sqlalchemy import func, select from sqlalchemy.orm import aliased, selectinload +from sqlalchemy.sql import Select @strawberry.type class PatientQuery: - @strawberry.field - async def patient( - self, - info: Info, - id: strawberry.ID, - ) -> PatientType | None: - result = await info.context.db.execute( - select(models.Patient) - .where(models.Patient.id == id) - .where(models.Patient.deleted.is_(False)) - .options( - selectinload(models.Patient.assigned_locations), - selectinload(models.Patient.tasks), - selectinload(models.Patient.teams), - ), - ) - patient = result.scalars().first() - if patient: - auth_service = AuthorizationService(info.context.db) - if not await auth_service.can_access_patient(info.context.user, patient, info.context): - raise GraphQLError( - "Insufficient permission. Please contact an administrator if you believe this is an error.", - extensions={"code": "FORBIDDEN"}, - ) - return patient - - @strawberry.field - @filtered_and_sorted_query() - @full_text_search_query() - async def patients( - self, + @staticmethod + async def _build_patients_base_query( info: Info, location_node_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, states: list[PatientState] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, - pagination: PaginationInput | None = None, - search: FullTextSearchInput | None = None, - ) -> list[PatientType]: + ) -> tuple[Select, list[strawberry.ID]]: query = select(models.Patient).options( selectinload(models.Patient.assigned_locations), selectinload(models.Patient.tasks), @@ -81,12 +57,14 @@ async def patients( models.Patient.state == PatientState.ADMITTED.value ) auth_service = AuthorizationService(info.context.db) - accessible_location_ids = await auth_service.get_user_accessible_location_ids( - info.context.user, info.context + accessible_location_ids = ( + await auth_service.get_user_accessible_location_ids( + info.context.user, info.context + ) ) if not accessible_location_ids: - return [] + return query.where(False), [] query = auth_service.filter_patients_by_access( info.context.user, query, accessible_location_ids @@ -96,7 +74,10 @@ async def patients( if location_node_id: if location_node_id not in accessible_location_ids: raise GraphQLError( - "Insufficient permission. Please contact an administrator if you believe this is an error.", + ( + "Insufficient permission. Please contact an administrator " + "if you believe this is an error." + ), extensions={"code": "FORBIDDEN"}, ) filter_cte = ( @@ -110,9 +91,11 @@ async def patients( ) filter_cte = filter_cte.union_all(children) elif root_location_ids: - valid_root_location_ids = [lid for lid in root_location_ids if lid in accessible_location_ids] + valid_root_location_ids = [ + lid for lid in root_location_ids if lid in accessible_location_ids + ] if not valid_root_location_ids: - return [] + return query.where(False), [] root_location_ids = valid_root_location_ids filter_cte = ( select(models.LocationNode.id) @@ -141,29 +124,170 @@ async def patients( (models.Patient.clinic_id.in_(select(filter_cte.c.id))) | ( models.Patient.position_id.isnot(None) - & models.Patient.position_id.in_(select(filter_cte.c.id)) + & models.Patient.position_id.in_( + select(filter_cte.c.id) + ) ) | ( models.Patient.assigned_location_id.isnot(None) - & models.Patient.assigned_location_id.in_(select(filter_cte.c.id)) + & models.Patient.assigned_location_id.in_( + select(filter_cte.c.id) + ) + ) + | ( + patient_locations_filter.c.location_id.in_( + select(filter_cte.c.id) + ) + ) + | ( + patient_teams_filter.c.location_id.in_( + select(filter_cte.c.id) + ) ) - | (patient_locations_filter.c.location_id.in_(select(filter_cte.c.id))) - | (patient_teams_filter.c.location_id.in_(select(filter_cte.c.id))) ) .distinct() ) + return query, accessible_location_ids + + @strawberry.field + async def patient( + self, + info: Info, + id: strawberry.ID, + ) -> PatientType | None: + result = await info.context.db.execute( + select(models.Patient) + .where(models.Patient.id == id) + .where(models.Patient.deleted.is_(False)) + .options( + selectinload(models.Patient.assigned_locations), + selectinload(models.Patient.tasks), + selectinload(models.Patient.teams), + ), + ) + patient = result.scalars().first() + if patient: + auth_service = AuthorizationService(info.context.db) + if not await auth_service.can_access_patient( + info.context.user, patient, info.context + ): + raise GraphQLError( + ( + "Insufficient permission. Please contact an administrator " + "if you believe this is an error." + ), + extensions={"code": "FORBIDDEN"}, + ) + return patient + + @strawberry.field + @filtered_and_sorted_query() + @full_text_search_query() + async def patients( + self, + info: Info, + location_node_id: strawberry.ID | None = None, + root_location_ids: list[strawberry.ID] | None = None, + states: list[PatientState] | None = None, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + pagination: PaginationInput | None = None, + search: FullTextSearchInput | None = None, + ) -> list[PatientType]: + query, _ = await PatientQuery._build_patients_base_query( + info, location_node_id, root_location_ids, states + ) return query @strawberry.field + async def patientsTotal( + self, + info: Info, + location_node_id: strawberry.ID | None = None, + root_location_ids: list[strawberry.ID] | None = None, + states: list[PatientState] | None = None, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + search: FullTextSearchInput | None = None, + ) -> int: + query, _ = await PatientQuery._build_patients_base_query( + info, location_node_id, root_location_ids, states + ) + + if search and search is not strawberry.UNSET: + query = apply_full_text_search(query, search, models.Patient) + + if filtering: + property_field_types: dict[str, str] = {} + property_def_ids = set() + for f in filtering: + if ( + f.column_type == ColumnType.PROPERTY + and f.property_definition_id + ): + property_def_ids.add(f.property_definition_id) + + if property_def_ids: + prop_defs_result = await info.context.db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type + for prop_def in prop_defs + } + + query = apply_filtering( + query, filtering, models.Patient, property_field_types + ) + + if sorting: + property_field_types: dict[str, str] = {} + property_def_ids = set() + for s in sorting: + if ( + s.column_type == ColumnType.PROPERTY + and s.property_definition_id + ): + property_def_ids.add(s.property_definition_id) + + if property_def_ids: + prop_defs_result = await info.context.db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type for prop_def in prop_defs + } + + query = apply_sorting(query, sorting, models.Patient, property_field_types) + + subquery = query.subquery() + count_query = select(func.count(func.distinct(subquery.c.id))) + result = await info.context.db.execute(count_query) + return result.scalar() or 0 + + @strawberry.field + @filtered_and_sorted_query() + @full_text_search_query() async def recent_patients( self, info: Info, - limit: int = 5, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + pagination: PaginationInput | None = None, + search: FullTextSearchInput | None = None, ) -> list[PatientType]: auth_service = AuthorizationService(info.context.db) - accessible_location_ids = await auth_service.get_user_accessible_location_ids( - info.context.user, info.context + accessible_location_ids = ( + await auth_service.get_user_accessible_location_ids( + info.context.user, info.context + ) ) if not accessible_location_ids: @@ -179,7 +303,7 @@ async def recent_patients( ) query = ( - select(models.Patient, max_task_update_date.c.max_update_date) + select(models.Patient) .options( selectinload(models.Patient.assigned_locations), selectinload(models.Patient.tasks), @@ -190,14 +314,108 @@ async def recent_patients( models.Patient.id == max_task_update_date.c.patient_id, ) .where(models.Patient.deleted.is_(False)) - .order_by(desc(max_task_update_date.c.max_update_date), desc(models.Patient.id)) - .limit(limit) ) query = auth_service.filter_patients_by_access( info.context.user, query, accessible_location_ids ) - result = await info.context.db.execute(query) - return [row[0] for row in result.all()] + + return query + + @strawberry.field + async def recentPatientsTotal( + self, + info: Info, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + search: FullTextSearchInput | None = None, + ) -> int: + auth_service = AuthorizationService(info.context.db) + accessible_location_ids = ( + await auth_service.get_user_accessible_location_ids( + info.context.user, info.context + ) + ) + + if not accessible_location_ids: + return 0 + + max_task_update_date = ( + select( + func.max(models.Task.update_date).label("max_update_date"), + models.Task.patient_id.label("patient_id"), + ) + .group_by(models.Task.patient_id) + .subquery() + ) + + query = ( + select(models.Patient) + .outerjoin( + max_task_update_date, + models.Patient.id == max_task_update_date.c.patient_id, + ) + .where(models.Patient.deleted.is_(False)) + ) + query = auth_service.filter_patients_by_access( + info.context.user, query, accessible_location_ids + ) + + if search and search is not strawberry.UNSET: + query = apply_full_text_search(query, search, models.Patient) + + if filtering: + property_field_types: dict[str, str] = {} + property_def_ids = set() + for f in filtering: + if ( + f.column_type == ColumnType.PROPERTY + and f.property_definition_id + ): + property_def_ids.add(f.property_definition_id) + + if property_def_ids: + prop_defs_result = await info.context.db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type + for prop_def in prop_defs + } + + query = apply_filtering( + query, filtering, models.Patient, property_field_types + ) + + if sorting: + property_field_types: dict[str, str] = {} + property_def_ids = set() + for s in sorting: + if ( + s.column_type == ColumnType.PROPERTY + and s.property_definition_id + ): + property_def_ids.add(s.property_definition_id) + + if property_def_ids: + prop_defs_result = await info.context.db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type for prop_def in prop_defs + } + + query = apply_sorting(query, sorting, models.Patient, property_field_types) + + subquery = query.subquery() + count_query = select(func.count(func.distinct(subquery.c.id))) + result = await info.context.db.execute(count_query) + return result.scalar() or 0 @strawberry.type @@ -224,19 +442,27 @@ async def create_patient( ) auth_service = AuthorizationService(db) - accessible_location_ids = await auth_service.get_user_accessible_location_ids( - info.context.user, info.context + accessible_location_ids = ( + await auth_service.get_user_accessible_location_ids( + info.context.user, info.context + ) ) if not accessible_location_ids: raise GraphQLError( - "Insufficient permission. Please contact an administrator if you believe this is an error.", + ( + "Insufficient permission. Please contact an " + "administrator if you believe this is an error." + ), extensions={"code": "FORBIDDEN"}, ) if data.clinic_id not in accessible_location_ids: raise GraphQLError( - "Insufficient permission. Please contact an administrator if you believe this is an error.", + ( + "Insufficient permission. Please contact an administrator " + "if you believe this is an error." + ), extensions={"code": "FORBIDDEN"}, ) @@ -255,7 +481,10 @@ async def create_patient( for team_id in data.team_ids: if team_id not in accessible_location_ids: raise GraphQLError( - "Insufficient permission. Please contact an administrator if you believe this is an error.", + ( + "Insufficient permission. Please contact an " + "administrator if you believe this is an error." + ), extensions={"code": "FORBIDDEN"}, ) teams = await location_service.validate_and_get_teams( @@ -289,9 +518,15 @@ async def create_patient( ) new_patient.assigned_locations = locations elif data.assigned_location_id: - if data.assigned_location_id not in accessible_location_ids: + if ( + data.assigned_location_id + not in accessible_location_ids + ): raise GraphQLError( - "Insufficient permission. Please contact an administrator if you believe this is an error.", + ( + "Insufficient permission. Please contact an " + "administrator if you believe this is an error." + ), extensions={"code": "FORBIDDEN"}, ) location = await location_service.get_location_by_id( @@ -335,7 +570,9 @@ async def update_patient( raise Exception("Patient not found") auth_service = AuthorizationService(db) - if not await auth_service.can_access_patient(info.context.user, patient, info.context): + if not await auth_service.can_access_patient( + info.context.user, patient, info.context + ): raise GraphQLError( "Forbidden: You do not have access to this patient", extensions={"code": "FORBIDDEN"}, @@ -356,8 +593,10 @@ async def update_patient( patient.description = data.description location_service = PatientMutation._get_location_service(db) - accessible_location_ids = await auth_service.get_user_accessible_location_ids( - info.context.user + accessible_location_ids = ( + await auth_service.get_user_accessible_location_ids( + info.context.user + ) ) if data.clinic_id is not None: @@ -375,7 +614,10 @@ async def update_patient( else: if data.position_id not in accessible_location_ids: raise GraphQLError( - "Insufficient permission. Please contact an administrator if you believe this is an error.", + ( + "Insufficient permission. Please contact an " + "administrator if you believe this is an error." + ), extensions={"code": "FORBIDDEN"}, ) await location_service.validate_and_get_position( @@ -409,9 +651,15 @@ async def update_patient( ) patient.assigned_locations = locations elif data.assigned_location_id is not None: - if data.assigned_location_id not in accessible_location_ids: + if ( + data.assigned_location_id + not in accessible_location_ids + ): raise GraphQLError( - "Insufficient permission. Please contact an administrator if you believe this is an error.", + ( + "Insufficient permission. Please contact an " + "administrator if you believe this is an error." + ), extensions={"code": "FORBIDDEN"}, ) location = await location_service.get_location_by_id( @@ -449,7 +697,9 @@ async def delete_patient(self, info: Info, id: strawberry.ID) -> bool: return False auth_service = AuthorizationService(db) - if not await auth_service.can_access_patient(info.context.user, patient, info.context): + if not await auth_service.can_access_patient( + info.context.user, patient, info.context + ): raise GraphQLError( "Forbidden: You do not have access to this patient", extensions={"code": "FORBIDDEN"}, @@ -483,7 +733,9 @@ async def _update_patient_state( raise Exception("Patient not found") auth_service = AuthorizationService(db) - if not await auth_service.can_access_patient(info.context.user, patient, info.context): + if not await auth_service.can_access_patient( + info.context.user, patient, info.context + ): raise GraphQLError( "Forbidden: You do not have access to this patient", extensions={"code": "FORBIDDEN"}, diff --git a/backend/api/resolvers/task.py b/backend/api/resolvers/task.py index 343b3473..b2bb58f1 100644 --- a/backend/api/resolvers/task.py +++ b/backend/api/resolvers/task.py @@ -3,9 +3,17 @@ import strawberry from api.audit import audit_log from api.context import Info -from api.decorators.filter_sort import filtered_and_sorted_query -from api.decorators.full_text_search import full_text_search_query +from api.decorators.filter_sort import ( + apply_filtering, + apply_sorting, + filtered_and_sorted_query, +) +from api.decorators.full_text_search import ( + apply_full_text_search, + full_text_search_query, +) from api.inputs import ( + ColumnType, FilterInput, FullTextSearchInput, PaginationInput, @@ -20,7 +28,7 @@ from api.types.task import TaskType from database import models from graphql import GraphQLError -from sqlalchemy import desc, select +from sqlalchemy import desc, func, select from sqlalchemy.orm import aliased, selectinload @@ -175,10 +183,182 @@ async def tasks( return query @strawberry.field + async def tasksTotal( + self, + info: Info, + patient_id: strawberry.ID | None = None, + assignee_id: strawberry.ID | None = None, + assignee_team_id: strawberry.ID | None = None, + root_location_ids: list[strawberry.ID] | None = None, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + search: FullTextSearchInput | None = None, + ) -> int: + auth_service = AuthorizationService(info.context.db) + + if patient_id: + if not await auth_service.can_access_patient_id(info.context.user, patient_id, info.context): + raise GraphQLError( + "Insufficient permission. Please contact an administrator if you believe this is an error.", + extensions={"code": "FORBIDDEN"}, + ) + + query = select(models.Task).where(models.Task.patient_id == patient_id) + + if assignee_id: + query = query.where(models.Task.assignee_id == assignee_id) + if assignee_team_id: + query = query.where(models.Task.assignee_team_id == assignee_team_id) + else: + accessible_location_ids = await auth_service.get_user_accessible_location_ids( + info.context.user, info.context + ) + + if not accessible_location_ids: + return 0 + + patient_locations = aliased(models.patient_locations) + patient_teams = aliased(models.patient_teams) + + cte = ( + select(models.LocationNode.id) + .where(models.LocationNode.id.in_(accessible_location_ids)) + .cte(name="accessible_locations", recursive=True) + ) + + children = select(models.LocationNode.id).join( + cte, models.LocationNode.parent_id == cte.c.id + ) + cte = cte.union_all(children) + + if root_location_ids: + invalid_ids = [lid for lid in root_location_ids if lid not in accessible_location_ids] + if invalid_ids: + raise GraphQLError( + "Insufficient permission. Please contact an administrator if you believe this is an error.", + extensions={"code": "FORBIDDEN"}, + ) + root_cte = ( + select(models.LocationNode.id) + .where(models.LocationNode.id.in_(root_location_ids)) + .cte(name="root_location_descendants", recursive=True) + ) + root_children = select(models.LocationNode.id).join( + root_cte, models.LocationNode.parent_id == root_cte.c.id + ) + root_cte = root_cte.union_all(root_children) + else: + root_cte = cte + + team_location_cte = None + if assignee_team_id: + if assignee_team_id not in accessible_location_ids: + raise GraphQLError( + "Insufficient permission. Please contact an administrator if you believe this is an error.", + extensions={"code": "FORBIDDEN"}, + ) + team_location_cte = ( + select(models.LocationNode.id) + .where(models.LocationNode.id == assignee_team_id) + .cte(name="team_location_descendants", recursive=True) + ) + team_children = select(models.LocationNode.id).join( + team_location_cte, models.LocationNode.parent_id == team_location_cte.c.id + ) + team_location_cte = team_location_cte.union_all(team_children) + + query = ( + select(models.Task) + .join(models.Patient, models.Task.patient_id == models.Patient.id) + .outerjoin( + patient_locations, + models.Patient.id == patient_locations.c.patient_id, + ) + .outerjoin( + patient_teams, + models.Patient.id == patient_teams.c.patient_id, + ) + .where( + (models.Patient.clinic_id.in_(select(root_cte.c.id))) + | ( + models.Patient.position_id.isnot(None) + & models.Patient.position_id.in_(select(root_cte.c.id)) + ) + | ( + models.Patient.assigned_location_id.isnot(None) + & models.Patient.assigned_location_id.in_(select(root_cte.c.id)) + ) + | (patient_locations.c.location_id.in_(select(root_cte.c.id))) + | (patient_teams.c.location_id.in_(select(root_cte.c.id))) + ) + .distinct() + ) + + if assignee_id: + query = query.where(models.Task.assignee_id == assignee_id) + if assignee_team_id: + query = query.where( + models.Task.assignee_team_id.in_(select(team_location_cte.c.id)) + ) + + if search and search is not strawberry.UNSET: + query = apply_full_text_search(query, search, models.Task) + + if filtering: + property_field_types: dict[str, str] = {} + property_def_ids = set() + for f in filtering: + if f.column_type == ColumnType.PROPERTY and f.property_definition_id: + property_def_ids.add(f.property_definition_id) + + if property_def_ids: + prop_defs_result = await info.context.db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type for prop_def in prop_defs + } + + query = apply_filtering(query, filtering, models.Task, property_field_types) + + if sorting: + property_field_types: dict[str, str] = {} + property_def_ids = set() + for s in sorting: + if s.column_type == ColumnType.PROPERTY and s.property_definition_id: + property_def_ids.add(s.property_definition_id) + + if property_def_ids: + prop_defs_result = await info.context.db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type for prop_def in prop_defs + } + + query = apply_sorting(query, sorting, models.Task, property_field_types) + + subquery = query.subquery() + count_query = select(func.count(func.distinct(subquery.c.id))) + result = await info.context.db.execute(count_query) + return result.scalar() or 0 + + @strawberry.field + @filtered_and_sorted_query() + @full_text_search_query() async def recent_tasks( self, info: Info, - limit: int = 10, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + pagination: PaginationInput | None = None, + search: FullTextSearchInput | None = None, ) -> list[TaskType]: auth_service = AuthorizationService(info.context.db) accessible_location_ids = await auth_service.get_user_accessible_location_ids( @@ -229,13 +409,119 @@ async def recent_tasks( | (patient_locations.c.location_id.in_(select(cte.c.id))) | (patient_teams.c.location_id.in_(select(cte.c.id))) ) - .order_by(desc(models.Task.update_date)) - .limit(limit) .distinct() ) - result = await info.context.db.execute(query) - return result.scalars().all() + default_sorting = sorting is None or len(sorting) == 0 + if default_sorting: + query = query.order_by(desc(models.Task.update_date)) + + return query + + @strawberry.field + async def recentTasksTotal( + self, + info: Info, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + search: FullTextSearchInput | None = None, + ) -> int: + auth_service = AuthorizationService(info.context.db) + accessible_location_ids = await auth_service.get_user_accessible_location_ids( + info.context.user, info.context + ) + + if not accessible_location_ids: + return 0 + + patient_locations = aliased(models.patient_locations) + patient_teams = aliased(models.patient_teams) + + cte = ( + select(models.LocationNode.id) + .where(models.LocationNode.id.in_(accessible_location_ids)) + .cte(name="accessible_locations", recursive=True) + ) + + children = select(models.LocationNode.id).join( + cte, models.LocationNode.parent_id == cte.c.id + ) + cte = cte.union_all(children) + + query = ( + select(models.Task) + .join(models.Patient, models.Task.patient_id == models.Patient.id) + .outerjoin( + patient_locations, + models.Patient.id == patient_locations.c.patient_id, + ) + .outerjoin( + patient_teams, + models.Patient.id == patient_teams.c.patient_id, + ) + .where( + (models.Patient.clinic_id.in_(select(cte.c.id))) + | ( + models.Patient.position_id.isnot(None) + & models.Patient.position_id.in_(select(cte.c.id)) + ) + | ( + models.Patient.assigned_location_id.isnot(None) + & models.Patient.assigned_location_id.in_(select(cte.c.id)) + ) + | (patient_locations.c.location_id.in_(select(cte.c.id))) + | (patient_teams.c.location_id.in_(select(cte.c.id))) + ) + .distinct() + ) + + if search and search is not strawberry.UNSET: + query = apply_full_text_search(query, search, models.Task) + + if filtering: + property_field_types: dict[str, str] = {} + property_def_ids = set() + for f in filtering: + if f.column_type == ColumnType.PROPERTY and f.property_definition_id: + property_def_ids.add(f.property_definition_id) + + if property_def_ids: + prop_defs_result = await info.context.db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type for prop_def in prop_defs + } + + query = apply_filtering(query, filtering, models.Task, property_field_types) + + if sorting: + property_field_types: dict[str, str] = {} + property_def_ids = set() + for s in sorting: + if s.column_type == ColumnType.PROPERTY and s.property_definition_id: + property_def_ids.add(s.property_definition_id) + + if property_def_ids: + prop_defs_result = await info.context.db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type for prop_def in prop_defs + } + + query = apply_sorting(query, sorting, models.Task, property_field_types) + + subquery = query.subquery() + count_query = select(func.count(func.distinct(subquery.c.id))) + result = await info.context.db.execute(count_query) + return result.scalar() or 0 @strawberry.type diff --git a/backend/api/types/pagination.py b/backend/api/types/pagination.py new file mode 100644 index 00000000..5acc30c4 --- /dev/null +++ b/backend/api/types/pagination.py @@ -0,0 +1,19 @@ +from typing import TYPE_CHECKING, Annotated + +import strawberry + +if TYPE_CHECKING: + from api.types.patient import PatientType + from api.types.task import TaskType + + +@strawberry.type +class PaginatedPatientResult: + items: list[Annotated["PatientType", strawberry.lazy("api.types.patient")]] + total_count: int + + +@strawberry.type +class PaginatedTaskResult: + items: list[Annotated["TaskType", strawberry.lazy("api.types.task")]] + total_count: int diff --git a/backend/main.py b/backend/main.py index d8890d92..6b755093 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,10 +7,12 @@ from api.router import AuthedGraphQLRouter from auth import UnauthenticatedRedirect, unauthenticated_redirect_handler from config import ALLOWED_ORIGINS, IS_DEV, LOGGER -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from routers import auth from scaffold import load_scaffold_data +from starlette.requests import ClientDisconnect from strawberry import Schema logger = logging.getLogger(LOGGER) @@ -50,6 +52,14 @@ async def lifespan(app: FastAPI): unauthenticated_redirect_handler, ) + +@app.exception_handler(ClientDisconnect) +async def client_disconnect_handler(request: Request, exc: ClientDisconnect): + logger.debug(f"Client disconnected: {request.url}") + return JSONResponse( + status_code=499, content={"detail": "Client disconnected"} + ) + app.include_router(auth.router) app.include_router(graphql_app, prefix="/graphql") diff --git a/web/api/gql/generated.ts b/web/api/gql/generated.ts index 5bd43e54..5ae37a3e 100644 --- a/web/api/gql/generated.ts +++ b/web/api/gql/generated.ts @@ -27,6 +27,11 @@ export type AuditLogType = { userId?: Maybe; }; +export enum ColumnType { + DirectAttribute = 'DIRECT_ATTRIBUTE', + Property = 'PROPERTY' +} + export type CreateLocationNodeInput = { kind: LocationType; parentId?: InputMaybe; @@ -81,6 +86,83 @@ export enum FieldType { FieldTypeUnspecified = 'FIELD_TYPE_UNSPECIFIED' } +export type FilterInput = { + column: Scalars['String']['input']; + columnType?: ColumnType; + operator: FilterOperator; + parameter: FilterParameter; + propertyDefinitionId?: InputMaybe; +}; + +export enum FilterOperator { + BooleanIsFalse = 'BOOLEAN_IS_FALSE', + BooleanIsTrue = 'BOOLEAN_IS_TRUE', + DatetimeBetween = 'DATETIME_BETWEEN', + DatetimeEquals = 'DATETIME_EQUALS', + DatetimeGreaterThan = 'DATETIME_GREATER_THAN', + DatetimeGreaterThanOrEqual = 'DATETIME_GREATER_THAN_OR_EQUAL', + DatetimeLessThan = 'DATETIME_LESS_THAN', + DatetimeLessThanOrEqual = 'DATETIME_LESS_THAN_OR_EQUAL', + DatetimeNotBetween = 'DATETIME_NOT_BETWEEN', + DatetimeNotEquals = 'DATETIME_NOT_EQUALS', + DateBetween = 'DATE_BETWEEN', + DateEquals = 'DATE_EQUALS', + DateGreaterThan = 'DATE_GREATER_THAN', + DateGreaterThanOrEqual = 'DATE_GREATER_THAN_OR_EQUAL', + DateLessThan = 'DATE_LESS_THAN', + DateLessThanOrEqual = 'DATE_LESS_THAN_OR_EQUAL', + DateNotBetween = 'DATE_NOT_BETWEEN', + DateNotEquals = 'DATE_NOT_EQUALS', + IsNotNull = 'IS_NOT_NULL', + IsNull = 'IS_NULL', + NumberBetween = 'NUMBER_BETWEEN', + NumberEquals = 'NUMBER_EQUALS', + NumberGreaterThan = 'NUMBER_GREATER_THAN', + NumberGreaterThanOrEqual = 'NUMBER_GREATER_THAN_OR_EQUAL', + NumberLessThan = 'NUMBER_LESS_THAN', + NumberLessThanOrEqual = 'NUMBER_LESS_THAN_OR_EQUAL', + NumberNotBetween = 'NUMBER_NOT_BETWEEN', + NumberNotEquals = 'NUMBER_NOT_EQUALS', + TagsContains = 'TAGS_CONTAINS', + TagsEquals = 'TAGS_EQUALS', + TagsNotContains = 'TAGS_NOT_CONTAINS', + TagsNotEquals = 'TAGS_NOT_EQUALS', + TagsSingleContains = 'TAGS_SINGLE_CONTAINS', + TagsSingleEquals = 'TAGS_SINGLE_EQUALS', + TagsSingleNotContains = 'TAGS_SINGLE_NOT_CONTAINS', + TagsSingleNotEquals = 'TAGS_SINGLE_NOT_EQUALS', + TextContains = 'TEXT_CONTAINS', + TextEndsWith = 'TEXT_ENDS_WITH', + TextEquals = 'TEXT_EQUALS', + TextNotContains = 'TEXT_NOT_CONTAINS', + TextNotEquals = 'TEXT_NOT_EQUALS', + TextNotWhitespace = 'TEXT_NOT_WHITESPACE', + TextStartsWith = 'TEXT_STARTS_WITH' +} + +export type FilterParameter = { + compareDate?: InputMaybe; + compareDateTime?: InputMaybe; + compareValue?: InputMaybe; + isCaseSensitive?: Scalars['Boolean']['input']; + max?: InputMaybe; + maxDate?: InputMaybe; + maxDateTime?: InputMaybe; + min?: InputMaybe; + minDate?: InputMaybe; + minDateTime?: InputMaybe; + propertyDefinitionId?: InputMaybe; + searchTags?: InputMaybe>; + searchText?: InputMaybe; +}; + +export type FullTextSearchInput = { + includeProperties?: Scalars['Boolean']['input']; + propertyDefinitionIds?: InputMaybe>; + searchColumns?: InputMaybe>; + searchText: Scalars['String']['input']; +}; + export type LocationNodeType = { __typename?: 'LocationNodeType'; children: Array; @@ -252,6 +334,11 @@ export type MutationWaitPatientArgs = { id: Scalars['ID']['input']; }; +export type PaginationInput = { + pageIndex?: Scalars['Int']['input']; + pageSize?: InputMaybe; +}; + export enum PatientState { Admitted = 'ADMITTED', Dead = 'DEAD', @@ -336,11 +423,15 @@ export type Query = { me?: Maybe; patient?: Maybe; patients: Array; + patientsTotal: Scalars['Int']['output']; propertyDefinitions: Array; recentPatients: Array; + recentPatientsTotal: Scalars['Int']['output']; recentTasks: Array; + recentTasksTotal: Scalars['Int']['output']; task?: Maybe; tasks: Array; + tasksTotal: Scalars['Int']['output']; user?: Maybe; users: Array; }; @@ -375,21 +466,53 @@ export type QueryPatientArgs = { export type QueryPatientsArgs = { - limit?: InputMaybe; + filtering?: InputMaybe>; + locationNodeId?: InputMaybe; + pagination?: InputMaybe; + rootLocationIds?: InputMaybe>; + search?: InputMaybe; + sorting?: InputMaybe>; + states?: InputMaybe>; +}; + + +export type QueryPatientsTotalArgs = { + filtering?: InputMaybe>; locationNodeId?: InputMaybe; - offset?: InputMaybe; rootLocationIds?: InputMaybe>; + search?: InputMaybe; + sorting?: InputMaybe>; states?: InputMaybe>; }; export type QueryRecentPatientsArgs = { - limit?: Scalars['Int']['input']; + filtering?: InputMaybe>; + pagination?: InputMaybe; + search?: InputMaybe; + sorting?: InputMaybe>; +}; + + +export type QueryRecentPatientsTotalArgs = { + filtering?: InputMaybe>; + search?: InputMaybe; + sorting?: InputMaybe>; }; export type QueryRecentTasksArgs = { - limit?: Scalars['Int']['input']; + filtering?: InputMaybe>; + pagination?: InputMaybe; + search?: InputMaybe; + sorting?: InputMaybe>; +}; + + +export type QueryRecentTasksTotalArgs = { + filtering?: InputMaybe>; + search?: InputMaybe; + sorting?: InputMaybe>; }; @@ -401,10 +524,23 @@ export type QueryTaskArgs = { export type QueryTasksArgs = { assigneeId?: InputMaybe; assigneeTeamId?: InputMaybe; - limit?: InputMaybe; - offset?: InputMaybe; + filtering?: InputMaybe>; + pagination?: InputMaybe; patientId?: InputMaybe; rootLocationIds?: InputMaybe>; + search?: InputMaybe; + sorting?: InputMaybe>; +}; + + +export type QueryTasksTotalArgs = { + assigneeId?: InputMaybe; + assigneeTeamId?: InputMaybe; + filtering?: InputMaybe>; + patientId?: InputMaybe; + rootLocationIds?: InputMaybe>; + search?: InputMaybe; + sorting?: InputMaybe>; }; @@ -412,12 +548,32 @@ export type QueryUserArgs = { id: Scalars['ID']['input']; }; + +export type QueryUsersArgs = { + filtering?: InputMaybe>; + pagination?: InputMaybe; + search?: InputMaybe; + sorting?: InputMaybe>; +}; + export enum Sex { Female = 'FEMALE', Male = 'MALE', Unknown = 'UNKNOWN' } +export enum SortDirection { + Asc = 'ASC', + Desc = 'DESC' +} + +export type SortInput = { + column: Scalars['String']['input']; + columnType?: ColumnType; + direction: SortDirection; + propertyDefinitionId?: InputMaybe; +}; + export type Subscription = { __typename?: 'Subscription'; locationNodeCreated: Scalars['ID']['output']; @@ -601,10 +757,19 @@ export type GetMyTasksQueryVariables = Exact<{ [key: string]: never; }>; export type GetMyTasksQuery = { __typename?: 'Query', me?: { __typename?: 'UserType', id: string, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null }> } | null }; -export type GetOverviewDataQueryVariables = Exact<{ [key: string]: never; }>; +export type GetOverviewDataQueryVariables = Exact<{ + recentPatientsFiltering?: InputMaybe | FilterInput>; + recentPatientsSorting?: InputMaybe | SortInput>; + recentPatientsPagination?: InputMaybe; + recentPatientsSearch?: InputMaybe; + recentTasksFiltering?: InputMaybe | FilterInput>; + recentTasksSorting?: InputMaybe | SortInput>; + recentTasksPagination?: InputMaybe; + recentTasksSearch?: InputMaybe; +}>; -export type GetOverviewDataQuery = { __typename?: 'Query', recentPatients: Array<{ __typename?: 'PatientType', id: string, name: string, sex: Sex, birthdate: any, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, tasks: Array<{ __typename?: 'TaskType', updateDate?: any | null }> }>, recentTasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, priority?: string | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, patient: { __typename?: 'PatientType', id: string, name: string, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } }> }; +export type GetOverviewDataQuery = { __typename?: 'Query', recentPatientsTotal: number, recentTasksTotal: number, recentPatients: Array<{ __typename?: 'PatientType', id: string, name: string, sex: Sex, birthdate: any, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, tasks: Array<{ __typename?: 'TaskType', updateDate?: any | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> }>, recentTasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, priority?: string | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, patient: { __typename?: 'PatientType', id: string, name: string, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> }> }; export type GetPatientQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -617,12 +782,14 @@ export type GetPatientsQueryVariables = Exact<{ locationId?: InputMaybe; rootLocationIds?: InputMaybe | Scalars['ID']['input']>; states?: InputMaybe | PatientState>; - limit?: InputMaybe; - offset?: InputMaybe; + filtering?: InputMaybe | FilterInput>; + sorting?: InputMaybe | SortInput>; + pagination?: InputMaybe; + search?: InputMaybe; }>; -export type GetPatientsQuery = { __typename?: 'Query', patients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', name: string } }> }> }; +export type GetPatientsQuery = { __typename?: 'Query', patientsTotal: number, patients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> }> }; export type GetTaskQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -635,12 +802,14 @@ export type GetTasksQueryVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; assigneeId?: InputMaybe; assigneeTeamId?: InputMaybe; - limit?: InputMaybe; - offset?: InputMaybe; + filtering?: InputMaybe | FilterInput>; + sorting?: InputMaybe | SortInput>; + pagination?: InputMaybe; + search?: InputMaybe; }>; -export type GetTasksQuery = { __typename?: 'Query', tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }; +export type GetTasksQuery = { __typename?: 'Query', tasksTotal: number, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> }> }; export type GetUserQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -1057,8 +1226,13 @@ export const useGetMyTasksQuery = < )}; export const GetOverviewDataDocument = ` - query GetOverviewData { - recentPatients(limit: 5) { + query GetOverviewData($recentPatientsFiltering: [FilterInput!], $recentPatientsSorting: [SortInput!], $recentPatientsPagination: PaginationInput, $recentPatientsSearch: FullTextSearchInput, $recentTasksFiltering: [FilterInput!], $recentTasksSorting: [SortInput!], $recentTasksPagination: PaginationInput, $recentTasksSearch: FullTextSearchInput) { + recentPatients( + filtering: $recentPatientsFiltering + sorting: $recentPatientsSorting + pagination: $recentPatientsPagination + search: $recentPatientsSearch + ) { id name sex @@ -1075,8 +1249,36 @@ export const GetOverviewDataDocument = ` tasks { updateDate } + properties { + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + } } - recentTasks(limit: 10) { + recentPatientsTotal( + filtering: $recentPatientsFiltering + sorting: $recentPatientsSorting + search: $recentPatientsSearch + ) + recentTasks( + filtering: $recentTasksFiltering + sorting: $recentTasksSorting + pagination: $recentTasksPagination + search: $recentTasksSearch + ) { id title description @@ -1104,7 +1306,30 @@ export const GetOverviewDataDocument = ` } } } + properties { + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + } } + recentTasksTotal( + filtering: $recentTasksFiltering + sorting: $recentTasksSorting + search: $recentTasksSearch + ) } `; @@ -1267,13 +1492,15 @@ export const useGetPatientQuery = < )}; export const GetPatientsDocument = ` - query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $limit: Int, $offset: Int) { + query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $filtering: [FilterInput!], $sorting: [SortInput!], $pagination: PaginationInput, $search: FullTextSearchInput) { patients( locationNodeId: $locationId rootLocationIds: $rootLocationIds states: $states - limit: $limit - offset: $offset + filtering: $filtering + sorting: $sorting + pagination: $pagination + search: $search ) { id name @@ -1395,11 +1622,31 @@ export const GetPatientsDocument = ` } properties { definition { + id name + description + fieldType + isActive + allowedEntities + options } textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues } } + patientsTotal( + locationNodeId: $locationId + rootLocationIds: $rootLocationIds + states: $states + filtering: $filtering + sorting: $sorting + search: $search + ) } `; @@ -1485,13 +1732,15 @@ export const useGetTaskQuery = < )}; export const GetTasksDocument = ` - query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $limit: Int, $offset: Int) { + query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $filtering: [FilterInput!], $sorting: [SortInput!], $pagination: PaginationInput, $search: FullTextSearchInput) { tasks( rootLocationIds: $rootLocationIds assigneeId: $assigneeId assigneeTeamId: $assigneeTeamId - limit: $limit - offset: $offset + filtering: $filtering + sorting: $sorting + pagination: $pagination + search: $search ) { id title @@ -1539,7 +1788,33 @@ export const GetTasksDocument = ` title kind } + properties { + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + } } + tasksTotal( + rootLocationIds: $rootLocationIds + assigneeId: $assigneeId + assigneeTeamId: $assigneeTeamId + filtering: $filtering + sorting: $sorting + search: $search + ) } `; diff --git a/web/api/graphql/GetOverviewData.graphql b/web/api/graphql/GetOverviewData.graphql index b2429bcb..1680b377 100644 --- a/web/api/graphql/GetOverviewData.graphql +++ b/web/api/graphql/GetOverviewData.graphql @@ -1,5 +1,5 @@ -query GetOverviewData { - recentPatients(limit: 5) { +query GetOverviewData($recentPatientsFiltering: [FilterInput!], $recentPatientsSorting: [SortInput!], $recentPatientsPagination: PaginationInput, $recentPatientsSearch: FullTextSearchInput, $recentTasksFiltering: [FilterInput!], $recentTasksSorting: [SortInput!], $recentTasksPagination: PaginationInput, $recentTasksSearch: FullTextSearchInput) { + recentPatients(filtering: $recentPatientsFiltering, sorting: $recentPatientsSorting, pagination: $recentPatientsPagination, search: $recentPatientsSearch) { id name sex @@ -16,8 +16,27 @@ query GetOverviewData { tasks { updateDate } + properties { + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + } } - recentTasks(limit: 10) { + recentPatientsTotal(filtering: $recentPatientsFiltering, sorting: $recentPatientsSorting, search: $recentPatientsSearch) + recentTasks(filtering: $recentTasksFiltering, sorting: $recentTasksSorting, pagination: $recentTasksPagination, search: $recentTasksSearch) { id title description @@ -45,5 +64,24 @@ query GetOverviewData { } } } + properties { + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + } } + recentTasksTotal(filtering: $recentTasksFiltering, sorting: $recentTasksSorting, search: $recentTasksSearch) } diff --git a/web/api/graphql/GetPatients.graphql b/web/api/graphql/GetPatients.graphql index 68435959..7bae64ec 100644 --- a/web/api/graphql/GetPatients.graphql +++ b/web/api/graphql/GetPatients.graphql @@ -1,5 +1,5 @@ -query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $limit: Int, $offset: Int) { - patients(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states, limit: $limit, offset: $offset) { +query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $filtering: [FilterInput!], $sorting: [SortInput!], $pagination: PaginationInput, $search: FullTextSearchInput) { + patients(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states, filtering: $filtering, sorting: $sorting, pagination: $pagination, search: $search) { id name firstname @@ -120,9 +120,22 @@ query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientSta } properties { definition { + id name + description + fieldType + isActive + allowedEntities + options } textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues } } + patientsTotal(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states, filtering: $filtering, sorting: $sorting, search: $search) } diff --git a/web/api/graphql/GetTasks.graphql b/web/api/graphql/GetTasks.graphql index 50c6a0ab..b431d06e 100644 --- a/web/api/graphql/GetTasks.graphql +++ b/web/api/graphql/GetTasks.graphql @@ -1,5 +1,5 @@ -query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $limit: Int, $offset: Int) { - tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId, limit: $limit, offset: $offset) { +query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $filtering: [FilterInput!], $sorting: [SortInput!], $pagination: PaginationInput, $search: FullTextSearchInput) { + tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId, filtering: $filtering, sorting: $sorting, pagination: $pagination, search: $search) { id title description @@ -46,6 +46,25 @@ query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $l title kind } + properties { + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + } } + tasksTotal(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId, filtering: $filtering, sorting: $sorting, search: $search) } diff --git a/web/components/patients/PatientList.tsx b/web/components/patients/PatientList.tsx index 4065a551..efd1dec1 100644 --- a/web/components/patients/PatientList.tsx +++ b/web/components/patients/PatientList.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useCallback } from 'react' import { Chip, FillerCell, Button, SearchBar, ProgressIndicator, Tooltip, Checkbox, Drawer, Visibility, TableProvider, TableDisplay, TablePagination, TableColumnSwitcher } from '@helpwave/hightide' import { PlusIcon, Table as TableIcon, LayoutGrid, Printer } from 'lucide-react' -import { GetPatientsDocument, Sex, PatientState, type GetPatientsQuery, type TaskType } from '@/api/gql/generated' +import { GetPatientsDocument, Sex, PatientState, type GetPatientsQuery, type TaskType, useGetPropertyDefinitionsQuery, PropertyEntity, ColumnType, type FullTextSearchInput, FieldType } from '@/api/gql/generated' import { usePaginatedGraphQLQuery } from '@/hooks/usePaginatedQuery' import { PatientDetailView } from '@/components/patients/PatientDetailView' import { SmartDate } from '@/utils/date' @@ -25,6 +25,7 @@ export type PatientViewModel = { sex: Sex, state: PatientState, tasks: TaskType[], + properties?: GetPatientsQuery['patients'][0]['properties'], } const STORAGE_KEY_SHOW_ALL_PATIENTS = 'patient-show-all-states' @@ -83,21 +84,32 @@ export const PatientList = forwardRef(({ initi const patientStates = showAllPatients ? allPatientStates : (acceptedStates ?? [PatientState.Admitted]) - const { data: patientsData, refetch } = usePaginatedGraphQLQuery({ - queryKey: ['GetPatients', { rootLocationIds: effectiveRootLocationIds, states: patientStates }], + const searchInput: FullTextSearchInput | undefined = searchQuery + ? { + searchText: searchQuery, + includeProperties: true, + } + : undefined + + const { data: patientsData, refetch, totalCount } = usePaginatedGraphQLQuery({ + queryKey: ['GetPatients', { rootLocationIds: effectiveRootLocationIds, states: patientStates, search: searchQuery }], document: GetPatientsDocument, baseVariables: { rootLocationIds: effectiveRootLocationIds && effectiveRootLocationIds.length > 0 ? effectiveRootLocationIds : undefined, - states: patientStates + states: patientStates, + search: searchInput, }, pageSize: 50, extractItems: (result) => result.patients, + extractTotalCount: (result) => result.patientsTotal ?? undefined, mode: 'infinite', enabled: !isPrinting, refetchOnWindowFocus: !isPrinting, refetchOnMount: true, }) + const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() + useEffect(() => { const handleBeforePrint = () => setIsPrinting(true) const handleAfterPrint = () => setIsPrinting(false) @@ -114,7 +126,7 @@ export const PatientList = forwardRef(({ initi const patients: PatientViewModel[] = useMemo(() => { if (!patientsData || patientsData.length === 0) return [] - let data = patientsData.map(p => ({ + return patientsData.map(p => ({ id: p.id, name: p.name, firstname: p.firstname, @@ -125,19 +137,10 @@ export const PatientList = forwardRef(({ initi position: p.position, openTasksCount: p.tasks?.filter(t => !t.done).length ?? 0, closedTasksCount: p.tasks?.filter(t => t.done).length ?? 0, - tasks: [] + tasks: [], + properties: p.properties ?? [], })) - - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase() - data = data.filter(p => - p.name.toLowerCase().includes(lowerQuery) || - p.firstname.toLowerCase().includes(lowerQuery) || - p.lastname.toLowerCase().includes(lowerQuery)) - } - - return data - }, [patientsData, searchQuery]) + }, [patientsData]) useImperativeHandle(ref, () => ({ openCreate: () => { @@ -193,6 +196,183 @@ export const PatientList = forwardRef(({ initi window.print() } + const patientPropertyColumns = useMemo[]>(() => { + if (!propertyDefinitionsData?.propertyDefinitions) return [] + + const patientProperties = propertyDefinitionsData.propertyDefinitions.filter( + def => def.isActive && def.allowedEntities.includes(PropertyEntity.Patient) + ) + + return patientProperties.map(prop => { + const columnId = `property_${prop.id}` + + const getFilterFn = () => { + switch (prop.fieldType) { + case FieldType.FieldTypeCheckbox: + return 'boolean' + case FieldType.FieldTypeDate: + case FieldType.FieldTypeDateTime: + return 'date' + case FieldType.FieldTypeNumber: + return 'number' + case FieldType.FieldTypeSelect: + case FieldType.FieldTypeMultiSelect: + return 'tags' + default: + return 'text' + } + } + + const extractOptionIndex = (value: string): number | null => { + const match = value.match(/-opt-(\d+)$/) + return match && match[1] ? parseInt(match[1], 10) : null + } + + return { + id: columnId, + header: prop.name, + accessorFn: (row) => { + const property = row.properties?.find( + p => p.definition.id === prop.id + ) + if (!property) return null + if (prop.fieldType === FieldType.FieldTypeMultiSelect) { + return property.multiSelectValues ?? null + } + return ( + property.textValue ?? + property.numberValue ?? + property.booleanValue ?? + property.dateValue ?? + property.dateTimeValue ?? + property.selectValue ?? + null + ) + }, + cell: ({ row }) => { + const property = row.original.properties?.find( + p => p.definition.id === prop.id + ) + if (!property) return + + if (typeof property.booleanValue === 'boolean') { + return ( + + {property.booleanValue + ? translation('yes') + : translation('no')} + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeDate && + property.dateValue + ) { + return ( + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeDateTime && + property.dateTimeValue + ) { + return ( + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeSelect && + property.selectValue + ) { + const selectValue = property.selectValue + const optionIndex = extractOptionIndex(selectValue) + if ( + optionIndex !== null && + optionIndex >= 0 && + optionIndex < prop.options.length + ) { + return ( + + {prop.options[optionIndex]} + + ) + } + return {selectValue} + } + + if ( + prop.fieldType === FieldType.FieldTypeMultiSelect && + property.multiSelectValues && + property.multiSelectValues.length > 0 + ) { + return ( +
+ {property.multiSelectValues + .filter((val): val is string => val !== null && val !== undefined) + .map((val, idx) => { + const optionIndex = extractOptionIndex(val) + const optionText = + optionIndex !== null && + optionIndex >= 0 && + optionIndex < prop.options.length + ? prop.options[optionIndex] + : val + return ( + + {optionText} + + ) + })} +
+ ) + } + + if ( + property.textValue !== null && + property.textValue !== undefined + ) { + return {property.textValue.toString()} + } + + if ( + property.numberValue !== null && + property.numberValue !== undefined + ) { + return {property.numberValue.toString()} + } + + return + }, + meta: { + columnType: ColumnType.Property, + propertyDefinitionId: prop.id, + fieldType: prop.fieldType, + ...(getFilterFn() === 'tags' && { + filterData: { + tags: prop.options.map((opt, idx) => ({ + label: opt, + tag: `${prop.id}-opt-${idx}`, + })), + }, + }), + }, + minSize: 150, + size: 200, + maxSize: 300, + filterFn: getFilterFn(), + } as ColumnDef + }) + }, [propertyDefinitionsData, translation]) + const columns = useMemo[]>(() => [ { id: 'name', @@ -318,7 +498,8 @@ export const PatientList = forwardRef(({ initi size: 150, maxSize: 200, }, - ], [allPatientStates, translation]) + ...patientPropertyColumns, + ], [allPatientStates, translation, patientPropertyColumns]) const onRowClick = useCallback((row: Row) => handleEdit(row.original), [handleEdit]) const fillerRowCell = useCallback(() => (), []) @@ -334,6 +515,7 @@ export const PatientList = forwardRef(({ initi pageSize: 25, } }} + pageCount={totalCount ? Math.ceil(totalCount / 25) : undefined} >
diff --git a/web/components/tables/RecentPatientsTable.tsx b/web/components/tables/RecentPatientsTable.tsx index 5603bf2a..b6d44b19 100644 --- a/web/components/tables/RecentPatientsTable.tsx +++ b/web/components/tables/RecentPatientsTable.tsx @@ -3,9 +3,10 @@ import type { ColumnDef, Row } from '@tanstack/react-table' import type { GetOverviewDataQuery } from '@/api/gql/generated' import { useCallback, useMemo } from 'react' import type { TableProps } from '@helpwave/hightide' -import { FillerCell, Table, TableColumnSwitcher, Tooltip } from '@helpwave/hightide' +import { Chip, FillerCell, Table, TableColumnSwitcher, Tooltip } from '@helpwave/hightide' import { SmartDate } from '@/utils/date' import { LocationChips } from '@/components/patients/LocationChips' +import { useGetPropertyDefinitionsQuery, PropertyEntity, ColumnType, FieldType } from '@/api/gql/generated' type PatientViewModel = GetOverviewDataQuery['recentPatients'][0] @@ -20,6 +21,185 @@ export const RecentPatientsTable = ({ ...props }: RecentPatientsTableProps) => { const translation = useTasksTranslation() + const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() + + const patientPropertyColumns = useMemo[]>(() => { + if (!propertyDefinitionsData?.propertyDefinitions) return [] + + const patientProperties = propertyDefinitionsData.propertyDefinitions.filter( + def => def.isActive && def.allowedEntities.includes(PropertyEntity.Patient) + ) + + return patientProperties.map(prop => { + const columnId = `property_${prop.id}` + + const getFilterFn = () => { + switch (prop.fieldType) { + case FieldType.FieldTypeCheckbox: + return 'boolean' + case FieldType.FieldTypeDate: + case FieldType.FieldTypeDateTime: + return 'date' + case FieldType.FieldTypeNumber: + return 'number' + case FieldType.FieldTypeSelect: + case FieldType.FieldTypeMultiSelect: + return 'tags' + default: + return 'text' + } + } + + const extractOptionIndex = (value: string): number | null => { + const match = value.match(/-opt-(\d+)$/) + return match && match[1] ? parseInt(match[1], 10) : null + } + + return { + id: columnId, + header: prop.name, + accessorFn: (row) => { + const property = row.properties?.find( + p => p.definition.id === prop.id + ) + if (!property) return null + if (prop.fieldType === FieldType.FieldTypeMultiSelect) { + return property.multiSelectValues ?? null + } + return ( + property.textValue ?? + property.numberValue ?? + property.booleanValue ?? + property.dateValue ?? + property.dateTimeValue ?? + property.selectValue ?? + null + ) + }, + cell: ({ row }) => { + const property = row.original.properties?.find( + p => p.definition.id === prop.id + ) + if (!property) return + + if (typeof property.booleanValue === 'boolean') { + return ( + + {property.booleanValue + ? translation('yes') + : translation('no')} + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeDate && + property.dateValue + ) { + return ( + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeDateTime && + property.dateTimeValue + ) { + return ( + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeSelect && + property.selectValue + ) { + const selectValue = property.selectValue + const optionIndex = extractOptionIndex(selectValue) + if ( + optionIndex !== null && + optionIndex >= 0 && + optionIndex < prop.options.length + ) { + return ( + + {prop.options[optionIndex]} + + ) + } + return {selectValue} + } + + if ( + prop.fieldType === FieldType.FieldTypeMultiSelect && + property.multiSelectValues && + property.multiSelectValues.length > 0 + ) { + return ( +
+ {property.multiSelectValues + .filter((val): val is string => val !== null && val !== undefined) + .map((val, idx) => { + const optionIndex = extractOptionIndex(val) + const optionText = + optionIndex !== null && + optionIndex >= 0 && + optionIndex < prop.options.length + ? prop.options[optionIndex] + : val + return ( + + {optionText} + + ) + })} +
+ ) + } + + if ( + property.textValue !== null && + property.textValue !== undefined + ) { + return {property.textValue.toString()} + } + + if ( + property.numberValue !== null && + property.numberValue !== undefined + ) { + return {property.numberValue.toString()} + } + + return + }, + meta: { + columnType: ColumnType.Property, + propertyDefinitionId: prop.id, + fieldType: prop.fieldType, + ...(getFilterFn() === 'tags' && { + filterData: { + tags: prop.options.map((opt, idx) => ({ + label: opt, + tag: `${prop.id}-opt-${idx}`, + })), + }, + }), + }, + minSize: 150, + size: 200, + maxSize: 300, + filterFn: getFilterFn(), + } as ColumnDef + }) + }, [propertyDefinitionsData, translation]) + const patientColumns = useMemo[]>(() => [ { id: 'name', @@ -72,8 +252,9 @@ export const RecentPatientsTable = ({ size: 200, maxSize: 200, filterFn: 'date', - } - ], [translation]) + }, + ...patientPropertyColumns, + ], [translation, patientPropertyColumns]) return ( { const translation = useTasksTranslation() + const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() + + const taskPropertyColumns = useMemo[]>(() => { + if (!propertyDefinitionsData?.propertyDefinitions) return [] + + const taskProperties = propertyDefinitionsData.propertyDefinitions.filter( + def => def.isActive && def.allowedEntities.includes(PropertyEntity.Task) + ) + + return taskProperties.map(prop => { + const columnId = `property_${prop.id}` + + const getFilterFn = () => { + switch (prop.fieldType) { + case FieldType.FieldTypeCheckbox: + return 'boolean' + case FieldType.FieldTypeDate: + case FieldType.FieldTypeDateTime: + return 'date' + case FieldType.FieldTypeNumber: + return 'number' + case FieldType.FieldTypeSelect: + case FieldType.FieldTypeMultiSelect: + return 'tags' + default: + return 'text' + } + } + + const extractOptionIndex = (value: string): number | null => { + const match = value.match(/-opt-(\d+)$/) + return match && match[1] ? parseInt(match[1], 10) : null + } + + return { + id: columnId, + header: prop.name, + accessorFn: (row) => { + const property = row.properties?.find( + p => p.definition.id === prop.id + ) + if (!property) return null + if (prop.fieldType === FieldType.FieldTypeMultiSelect) { + return property.multiSelectValues ?? null + } + return ( + property.textValue ?? + property.numberValue ?? + property.booleanValue ?? + property.dateValue ?? + property.dateTimeValue ?? + property.selectValue ?? + null + ) + }, + cell: ({ row }) => { + const property = row.original.properties?.find( + p => p.definition.id === prop.id + ) + if (!property) return + + if (typeof property.booleanValue === 'boolean') { + return ( + + {property.booleanValue + ? translation('yes') + : translation('no')} + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeDate && + property.dateValue + ) { + return ( + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeDateTime && + property.dateTimeValue + ) { + return ( + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeSelect && + property.selectValue + ) { + const selectValue = property.selectValue + const optionIndex = extractOptionIndex(selectValue) + if ( + optionIndex !== null && + optionIndex >= 0 && + optionIndex < prop.options.length + ) { + return ( + + {prop.options[optionIndex]} + + ) + } + return {selectValue} + } + + if ( + prop.fieldType === FieldType.FieldTypeMultiSelect && + property.multiSelectValues && + property.multiSelectValues.length > 0 + ) { + return ( +
+ {property.multiSelectValues + .filter((val): val is string => val !== null && val !== undefined) + .map((val, idx) => { + const optionIndex = extractOptionIndex(val) + const optionText = + optionIndex !== null && + optionIndex >= 0 && + optionIndex < prop.options.length + ? prop.options[optionIndex] + : val + return ( + + {optionText} + + ) + })} +
+ ) + } + + if ( + property.textValue !== null && + property.textValue !== undefined + ) { + return {property.textValue.toString()} + } + + if ( + property.numberValue !== null && + property.numberValue !== undefined + ) { + return {property.numberValue.toString()} + } + + return + }, + meta: { + columnType: ColumnType.Property, + propertyDefinitionId: prop.id, + fieldType: prop.fieldType, + ...(getFilterFn() === 'tags' && { + filterData: { + tags: prop.options.map((opt, idx) => ({ + label: opt, + tag: `${prop.id}-opt-${idx}`, + })), + }, + }), + }, + minSize: 150, + size: 200, + maxSize: 300, + filterFn: getFilterFn(), + } as ColumnDef + }) + }, [propertyDefinitionsData, translation]) + const taskColumns = useMemo[]>(() => [ { id: 'done', @@ -148,8 +328,9 @@ export const RecentTasksTable = ({ maxSize: 220, enableResizing: false, filterFn: 'date', - } - ], [translation, completeTask, reopenTask, onSelectPatient]) + }, + ...taskPropertyColumns, + ], [translation, completeTask, reopenTask, onSelectPatient, taskPropertyColumns]) return (
void, headerActions?: React.ReactNode, + totalCount?: number, } const isOverdue = (dueDate: Date | undefined, done: boolean): boolean => { @@ -76,12 +78,13 @@ const isCloseToDueDate = (dueDate: Date | undefined, done: boolean): boolean => const STORAGE_KEY_SHOW_DONE = 'task-show-done' -export const TaskList = forwardRef(({ tasks: initialTasks, onRefetch, showAssignee = false, initialTaskId, onInitialTaskOpened, headerActions }, ref) => { +export const TaskList = forwardRef(({ tasks: initialTasks, onRefetch, showAssignee = false, initialTaskId, onInitialTaskOpened, headerActions, totalCount }, ref) => { const translation = useTasksTranslation() const queryClient = useQueryClient() const { totalPatientsCount, user, selectedRootLocationIds } = useTasksContext() const { viewType, toggleView } = useTaskViewToggle() const [optimisticUpdates, setOptimisticUpdates] = useState>(new Map()) + const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() const { mutate: completeTask } = useCompleteTaskMutation({ onMutate: async (variables) => { const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined @@ -389,6 +392,183 @@ export const TaskList = forwardRef(({ tasks: initial setIsHandoverDialogOpen(false) } + const taskPropertyColumns = useMemo[]>(() => { + if (!propertyDefinitionsData?.propertyDefinitions) return [] + + const taskProperties = propertyDefinitionsData.propertyDefinitions.filter( + def => def.isActive && def.allowedEntities.includes(PropertyEntity.Task) + ) + + return taskProperties.map(prop => { + const columnId = `property_${prop.id}` + + const getFilterFn = () => { + switch (prop.fieldType) { + case FieldType.FieldTypeCheckbox: + return 'boolean' + case FieldType.FieldTypeDate: + case FieldType.FieldTypeDateTime: + return 'date' + case FieldType.FieldTypeNumber: + return 'number' + case FieldType.FieldTypeSelect: + case FieldType.FieldTypeMultiSelect: + return 'tags' + default: + return 'text' + } + } + + const extractOptionIndex = (value: string): number | null => { + const match = value.match(/-opt-(\d+)$/) + return match && match[1] ? parseInt(match[1], 10) : null + } + + return { + id: columnId, + header: prop.name, + accessorFn: (row) => { + const property = row.properties?.find( + p => p.definition.id === prop.id + ) + if (!property) return null + if (prop.fieldType === FieldType.FieldTypeMultiSelect) { + return property.multiSelectValues ?? null + } + return ( + property.textValue ?? + property.numberValue ?? + property.booleanValue ?? + property.dateValue ?? + property.dateTimeValue ?? + property.selectValue ?? + null + ) + }, + cell: ({ row }) => { + const property = row.original.properties?.find( + p => p.definition.id === prop.id + ) + if (!property) return + + if (typeof property.booleanValue === 'boolean') { + return ( + + {property.booleanValue + ? translation('yes') + : translation('no')} + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeDate && + property.dateValue + ) { + return ( + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeDateTime && + property.dateTimeValue + ) { + return ( + + ) + } + + if ( + prop.fieldType === FieldType.FieldTypeSelect && + property.selectValue + ) { + const selectValue = property.selectValue + const optionIndex = extractOptionIndex(selectValue) + if ( + optionIndex !== null && + optionIndex >= 0 && + optionIndex < prop.options.length + ) { + return ( + + {prop.options[optionIndex]} + + ) + } + return {selectValue} + } + + if ( + prop.fieldType === FieldType.FieldTypeMultiSelect && + property.multiSelectValues && + property.multiSelectValues.length > 0 + ) { + return ( +
+ {property.multiSelectValues + .filter((val): val is string => val !== null && val !== undefined) + .map((val, idx) => { + const optionIndex = extractOptionIndex(val) + const optionText = + optionIndex !== null && + optionIndex >= 0 && + optionIndex < prop.options.length + ? prop.options[optionIndex] + : val + return ( + + {optionText} + + ) + })} +
+ ) + } + + if ( + property.textValue !== null && + property.textValue !== undefined + ) { + return {property.textValue.toString()} + } + + if ( + property.numberValue !== null && + property.numberValue !== undefined + ) { + return {property.numberValue.toString()} + } + + return + }, + meta: { + columnType: ColumnType.Property, + propertyDefinitionId: prop.id, + fieldType: prop.fieldType, + ...(getFilterFn() === 'tags' && { + filterData: { + tags: prop.options.map((opt, idx) => ({ + label: opt, + tag: `${prop.id}-opt-${idx}`, + })), + }, + }), + }, + minSize: 150, + size: 200, + maxSize: 300, + filterFn: getFilterFn(), + } as ColumnDef + }) + }, [propertyDefinitionsData, translation]) + const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -566,9 +746,9 @@ export const TaskList = forwardRef(({ tasks: initial }) } - return cols + return [...cols, ...taskPropertyColumns] }, - [translation, completeTask, reopenTask, showAssignee, optimisticUpdates]) + [translation, completeTask, reopenTask, showAssignee, optimisticUpdates, taskPropertyColumns]) const handleToggleDone = (taskId: string, checked: boolean) => { const task = initialTasks.find(t => t.id === taskId) @@ -610,6 +790,7 @@ export const TaskList = forwardRef(({ tasks: initial }} enableMultiSort={true} onRowClick={row => setTaskDialogState({ isOpen: true, taskId: row.original.id })} + pageCount={totalCount ? Math.ceil(totalCount / 25) : undefined} >
diff --git a/web/hooks/usePaginatedQuery.ts b/web/hooks/usePaginatedQuery.ts index 2f7821ba..5f5283a7 100644 --- a/web/hooks/usePaginatedQuery.ts +++ b/web/hooks/usePaginatedQuery.ts @@ -23,6 +23,7 @@ export interface PaginatedGraphQLQueryOptions TItem[], + extractTotalCount?: (queryResult: TQueryResult) => number | undefined, mode?: PaginationMode, enabled?: boolean, refetchInterval?: number | false, @@ -42,6 +43,7 @@ export interface PaginatedQueryResult { totalPages?: number, currentPage?: number, goToPage?: (page: number) => void, + totalCount?: number, } function useInfinitePaginatedQuery>({ @@ -224,23 +226,36 @@ export function usePaginatedGraphQLQuery): PaginatedQueryResult { + const [totalCount, setTotalCount] = React.useState(undefined) + const queryFn = React.useCallback( async (page: number, variables: TVariables): Promise => { const paginatedVariables = { ...variables, - limit: pageSize, - offset: page * pageSize, + pagination: { + pageIndex: page, + pageSize: pageSize, + }, } const result = await fetcher(document, paginatedVariables)() + + if (extractTotalCount) { + const count = extractTotalCount(result) + if (count !== undefined) { + setTotalCount(count) + } + } + return extractItems(result) }, - [document, pageSize, extractItems] + [document, pageSize, extractItems, extractTotalCount] ) const result = usePaginatedQuery({ @@ -258,6 +273,7 @@ export function usePaginatedGraphQLQuery { const translation = useTasksTranslation() const { user, myTasksCount, totalPatientsCount, selectedRootLocationIds } = useTasksContext() - const { data } = useGetOverviewDataQuery(undefined, {}) + const { data } = useGetOverviewDataQuery({ + recentPatientsFiltering: undefined, + recentPatientsSorting: undefined, + recentPatientsPagination: undefined, + recentPatientsSearch: undefined, + recentTasksFiltering: undefined, + recentTasksSorting: undefined, + recentTasksPagination: undefined, + recentTasksSearch: undefined, + }, {}) const queryClient = useQueryClient() const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined diff --git a/web/pages/tasks/index.tsx b/web/pages/tasks/index.tsx index 1cda5e47..9a5862b0 100644 --- a/web/pages/tasks/index.tsx +++ b/web/pages/tasks/index.tsx @@ -5,7 +5,7 @@ import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { ContentPanel } from '@/components/layout/ContentPanel' import { TaskList, type TaskViewModel } from '@/components/tasks/TaskList' import { useMemo } from 'react' -import { GetTasksDocument, type GetTasksQuery } from '@/api/gql/generated' +import { GetTasksDocument, type GetTasksQuery, type FullTextSearchInput } from '@/api/gql/generated' import { useRouter } from 'next/router' import { useTasksContext } from '@/hooks/useTasksContext' import { usePaginatedGraphQLQuery } from '@/hooks/usePaginatedQuery' @@ -14,7 +14,7 @@ const TasksPage: NextPage = () => { const translation = useTasksTranslation() const router = useRouter() const { selectedRootLocationIds, user, myTasksCount } = useTasksContext() - const { data: tasksData, refetch } = usePaginatedGraphQLQuery({ + const { data: tasksData, refetch, totalCount } = usePaginatedGraphQLQuery({ queryKey: ['GetTasks'], document: GetTasksDocument, baseVariables: { @@ -23,6 +23,7 @@ const TasksPage: NextPage = () => { }, pageSize: 50, extractItems: (result) => result.tasks, + extractTotalCount: (result) => result.tasksTotal ?? undefined, mode: 'infinite', enabled: !!selectedRootLocationIds && !!user, refetchOnWindowFocus: true, @@ -52,6 +53,7 @@ const TasksPage: NextPage = () => { assignee: task.assignee ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl, isOnline: task.assignee.isOnline ?? null } : undefined, + properties: task.properties ?? [], })) }, [tasksData]) @@ -67,6 +69,7 @@ const TasksPage: NextPage = () => { showAssignee={false} initialTaskId={taskId} onInitialTaskOpened={() => router.replace('/tasks', undefined, { shallow: true })} + totalCount={totalCount} /> diff --git a/web/utils/tableToGraphQL.ts b/web/utils/tableToGraphQL.ts new file mode 100644 index 00000000..77a5243f --- /dev/null +++ b/web/utils/tableToGraphQL.ts @@ -0,0 +1,132 @@ +import type { ColumnDef, TableState } from '@tanstack/table-core' +import type { + FilterInput, + SortInput, + PaginationInput, + FullTextSearchInput, + FilterParameter +} from '@/api/gql/generated' +import { ColumnType, SortDirection, FilterOperator } from '@/api/gql/generated' + +export function mapTableStateToGraphQL( + tableState: TableState | undefined, + columns: ColumnDef[], + searchText: string | undefined +): { + filtering: FilterInput[] | undefined, + sorting: SortInput[] | undefined, + pagination: PaginationInput | undefined, + search: FullTextSearchInput | undefined, +} { + const filtering: FilterInput[] = [] + const sorting: SortInput[] = [] + + if (tableState?.columnFilters) { + for (const filter of tableState.columnFilters) { + const column = columns.find(col => col.id === filter.id) + if (!column) continue + + const meta = column.meta as { + columnType?: ColumnType, + propertyDefinitionId?: string, + fieldType?: string, + } | undefined + const columnType = meta?.columnType ?? ColumnType.DirectAttribute + + if (columnType === ColumnType.Property && !meta?.propertyDefinitionId) { + continue + } + + const filterValue = filter.value + if (filterValue === undefined || filterValue === null || filterValue === '') { + continue + } + + let operator: FilterOperator + let parameter: FilterParameter + + if (Array.isArray(filterValue)) { + if ( + columnType === ColumnType.Property && + meta?.fieldType === 'FIELD_TYPE_MULTI_SELECT' + ) { + operator = FilterOperator.TagsContains + } else { + operator = FilterOperator.TagsSingleContains + } + parameter = { + searchTags: filterValue, + } + } else if (typeof filterValue === 'boolean') { + operator = filterValue + ? FilterOperator.BooleanIsTrue + : FilterOperator.BooleanIsFalse + parameter = {} + } else if (typeof filterValue === 'string') { + operator = FilterOperator.TextContains + parameter = { + searchText: filterValue, + isCaseSensitive: false, + } + } else { + continue + } + + filtering.push({ + column: filter.id, + operator, + parameter, + columnType: columnType, + propertyDefinitionId: columnType === ColumnType.Property ? meta?.propertyDefinitionId : undefined, + }) + } + } + + if (tableState?.sorting) { + for (const sort of tableState.sorting) { + const column = columns.find(col => col.id === sort.id) + if (!column) continue + + const meta = column.meta as { + columnType?: ColumnType, + propertyDefinitionId?: string, + } | undefined + const columnType = meta?.columnType ?? ColumnType.DirectAttribute + + if (columnType === ColumnType.Property && !meta?.propertyDefinitionId) { + continue + } + + sorting.push({ + column: sort.id, + direction: sort.desc ? SortDirection.Desc : SortDirection.Asc, + columnType: columnType, + propertyDefinitionId: + columnType === ColumnType.Property + ? meta?.propertyDefinitionId + : undefined, + }) + } + } + + const pagination: PaginationInput | undefined = tableState?.pagination + ? { + pageIndex: tableState.pagination.pageIndex, + pageSize: tableState.pagination.pageSize ?? undefined, + } + : undefined + + const search: FullTextSearchInput | undefined = searchText + ? { + searchText: searchText, + includeProperties: true, + } + : undefined + + return { + filtering: filtering.length > 0 ? filtering : undefined, + sorting: sorting.length > 0 ? sorting : undefined, + pagination, + search, + } +}