From dbb5b5e8a0a36769e6e452abf55849ca3ccd32ac Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Tue, 6 Jan 2026 00:44:45 +0100 Subject: [PATCH] reimplement data layer --- backend/api/context.py | 5 +- backend/api/decorators/__init__.py | 3 + backend/api/decorators/pagination.py | 40 ++ backend/api/inputs.py | 2 + backend/api/resolvers/__init__.py | 2 + backend/api/resolvers/audit.py | 89 +++ backend/api/resolvers/base.py | 67 +- backend/api/resolvers/location.py | 5 + backend/api/resolvers/patient.py | 218 +++++- backend/api/resolvers/task.py | 183 ++++- backend/api/services/checksum.py | 11 +- backend/api/services/notifications.py | 73 +- backend/api/services/subscription.py | 125 ++++ backend/api/types/audit.py | 12 + backend/api/types/patient.py | 1 + backend/api/types/user.py | 87 ++- backend/auth.py | 2 - .../655294b10318_add_user_last_online.py | 31 + ...438_merge_patient_description_and_task_.py | 28 + .../versions/add_patient_description.py | 35 + backend/database/models/patient.py | 1 + backend/database/models/user.py | 7 +- backend/database/session.py | 22 +- web/api/auth/authService.ts | 10 +- web/api/gql/generated.ts | 240 ++++++- web/api/gql/subscriptionClient.ts | 235 ++++++ web/api/graphql/GetAuditLogs.graphql | 10 + web/api/graphql/GetLocations.graphql | 4 +- web/api/graphql/GetPatient.graphql | 1 + web/api/graphql/GetPatients.graphql | 4 +- web/api/graphql/GetTasks.graphql | 4 +- web/api/graphql/GetUser.graphql | 13 + web/api/graphql/GetUsers.graphql | 1 + web/api/graphql/GlobalData.graphql | 2 +- web/api/graphql/Subscriptions.graphql | 24 +- web/components/AuditLogTimeline.tsx | 206 ++++++ web/components/AvatarComponent.tsx | 54 ++ web/components/AvatarStatusComponent.tsx | 54 ++ web/components/AvatarWithStatus.tsx | 32 + web/components/ConflictResolutionDialog.tsx | 124 +++- web/components/ErrorDialog.tsx | 37 + web/components/FeedbackDialog.tsx | 8 +- web/components/Notifications.tsx | 1 - web/components/OnlineStatusIndicator.tsx | 44 ++ web/components/PropertyEntry.tsx | 12 +- web/components/PropertyList.tsx | 395 ++++------ web/components/UserInfoPopup.tsx | 107 +++ web/components/layout/Page.tsx | 18 +- web/components/layout/SidePanel.tsx | 1 - .../locations/LocationSelectionDialog.tsx | 3 - web/components/patients/PatientDetailView.tsx | 674 +++++++++++++----- web/components/patients/PatientList.tsx | 54 +- web/components/tasks/AssigneeSelect.tsx | 256 +++++++ web/components/tasks/TaskCardView.tsx | 21 +- web/components/tasks/TaskDetailView.tsx | 344 +++------ web/components/tasks/TaskList.tsx | 41 +- web/hooks/useAtomicMutation.ts | 243 +++++++ web/hooks/useAuth.tsx | 24 +- web/hooks/useConflictResolution.ts | 51 ++ web/hooks/useFormFieldMutation.ts | 46 ++ web/hooks/useGlobalSubscriptions.ts | 322 +++++++++ web/hooks/usePaginatedQuery.ts | 262 +++++++ web/hooks/useSafeMutation.ts | 185 +++++ web/hooks/useTasksContext.tsx | 18 +- web/hooks/useViewToggle.ts | 3 - web/i18n/translations.ts | 12 +- web/locales/de-DE.arb | 6 +- web/locales/en-US.arb | 2 + web/pages/_app.tsx | 19 +- web/pages/api/feedback.ts | 9 +- web/pages/auth/callback.tsx | 11 +- web/pages/index.tsx | 17 +- web/pages/location/[id].tsx | 5 +- web/pages/patients/index.tsx | 4 +- web/pages/tasks/index.tsx | 24 +- web/pages/teams/[id].tsx | 5 +- web/pages/wards/[id].tsx | 24 +- web/pages/wards/index.tsx | 3 +- web/providers/SubscriptionProvider.tsx | 11 + web/public/sw.js | 66 ++ web/utils/date.tsx | 9 - web/utils/graphql.ts | 94 +++ web/utils/pushNotifications.ts | 125 ++++ 83 files changed, 4731 insertions(+), 952 deletions(-) create mode 100644 backend/api/decorators/__init__.py create mode 100644 backend/api/decorators/pagination.py create mode 100644 backend/api/resolvers/audit.py create mode 100644 backend/api/types/audit.py create mode 100644 backend/database/migrations/versions/655294b10318_add_user_last_online.py create mode 100644 backend/database/migrations/versions/81ffadeee438_merge_patient_description_and_task_.py create mode 100644 backend/database/migrations/versions/add_patient_description.py create mode 100644 web/api/gql/subscriptionClient.ts create mode 100644 web/api/graphql/GetAuditLogs.graphql create mode 100644 web/api/graphql/GetUser.graphql create mode 100644 web/components/AuditLogTimeline.tsx create mode 100644 web/components/AvatarComponent.tsx create mode 100644 web/components/AvatarStatusComponent.tsx create mode 100644 web/components/AvatarWithStatus.tsx create mode 100644 web/components/ErrorDialog.tsx create mode 100644 web/components/OnlineStatusIndicator.tsx create mode 100644 web/components/UserInfoPopup.tsx create mode 100644 web/components/tasks/AssigneeSelect.tsx create mode 100644 web/hooks/useAtomicMutation.ts create mode 100644 web/hooks/useConflictResolution.ts create mode 100644 web/hooks/useFormFieldMutation.ts create mode 100644 web/hooks/useGlobalSubscriptions.ts create mode 100644 web/hooks/usePaginatedQuery.ts create mode 100644 web/hooks/useSafeMutation.ts create mode 100644 web/providers/SubscriptionProvider.tsx create mode 100644 web/public/sw.js create mode 100644 web/utils/graphql.ts create mode 100644 web/utils/pushNotifications.ts diff --git a/backend/api/context.py b/backend/api/context.py index 98b81a90..084d47c8 100644 --- a/backend/api/context.py +++ b/backend/api/context.py @@ -1,4 +1,5 @@ import asyncio +from datetime import datetime, timezone from typing import Any import strawberry @@ -107,6 +108,7 @@ async def get_context( lastname=lastname, title="User", avatar_url=picture, + last_online=datetime.now(timezone.utc), ) session.add(new_user) await session.commit() @@ -143,8 +145,9 @@ async def get_context( await session.refresh(db_user) if db_user: + db_user.last_online = datetime.now(timezone.utc) + session.add(db_user) try: - await _update_user_root_locations( session, db_user, diff --git a/backend/api/decorators/__init__.py b/backend/api/decorators/__init__.py new file mode 100644 index 00000000..5c8500d2 --- /dev/null +++ b/backend/api/decorators/__init__.py @@ -0,0 +1,3 @@ +from api.decorators.pagination import apply_pagination, paginated_query + +__all__ = ["apply_pagination", "paginated_query"] diff --git a/backend/api/decorators/pagination.py b/backend/api/decorators/pagination.py new file mode 100644 index 00000000..5e21c7d6 --- /dev/null +++ b/backend/api/decorators/pagination.py @@ -0,0 +1,40 @@ +from functools import wraps +from typing import Any, Callable, TypeVar + +from sqlalchemy import Select + +T = TypeVar("T") + + +def apply_pagination( + query: Select[Any], + limit: int | None = None, + offset: int | None = None, +) -> Select[Any]: + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + return query + + +def paginated_query( + limit_param: str = "limit", + offset_param: str = "offset", +): + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + limit = kwargs.get(limit_param) + offset = kwargs.get(offset_param) + + result = await func(*args, **kwargs) + + if isinstance(result, Select): + return apply_pagination(result, limit=limit, offset=offset) + + return result + + return wrapper + + return decorator diff --git a/backend/api/inputs.py b/backend/api/inputs.py index e8cff374..de90e510 100644 --- a/backend/api/inputs.py +++ b/backend/api/inputs.py @@ -86,6 +86,7 @@ class CreatePatientInput: ) properties: list[PropertyValueInput] | None = None state: PatientState | None = None + description: str | None = None @strawberry.input @@ -101,6 +102,7 @@ class UpdatePatientInput: team_ids: list[strawberry.ID] | None = strawberry.UNSET properties: list[PropertyValueInput] | None = None checksum: str | None = None + description: str | None = None @strawberry.input diff --git a/backend/api/resolvers/__init__.py b/backend/api/resolvers/__init__.py index 228d89eb..d944ca2c 100644 --- a/backend/api/resolvers/__init__.py +++ b/backend/api/resolvers/__init__.py @@ -1,5 +1,6 @@ import strawberry +from .audit import AuditQuery from .location import LocationMutation, LocationQuery, LocationSubscription from .patient import PatientMutation, PatientQuery, PatientSubscription from .property import PropertyDefinitionMutation, PropertyDefinitionQuery @@ -14,6 +15,7 @@ class Query( LocationQuery, PropertyDefinitionQuery, UserQuery, + AuditQuery, ): pass diff --git a/backend/api/resolvers/audit.py b/backend/api/resolvers/audit.py new file mode 100644 index 00000000..879812d8 --- /dev/null +++ b/backend/api/resolvers/audit.py @@ -0,0 +1,89 @@ +import logging +from datetime import datetime +from typing import Any + +import strawberry +from api.audit import AuditLogger +from api.context import Info +from api.types.audit import AuditLogType +from config import INFLUXDB_BUCKET, INFLUXDB_ORG, LOGGER + +logger = logging.getLogger(LOGGER) + + +@strawberry.type +class AuditQuery: + @strawberry.field + async def audit_logs( + self, + info: Info, + case_id: strawberry.ID, + limit: int | None = None, + offset: int | None = None, + ) -> list[AuditLogType]: + client = AuditLogger._get_client() + if not client: + logger.warning( + "InfluxDB client not available for audit log query" + ) + return [] + + try: + query_api = client.query_api() + + limit_clause = f"LIMIT {limit}" if limit else "" + offset_clause = f"OFFSET {offset}" if offset else "" + + query = f''' + from(bucket: "{INFLUXDB_BUCKET}") + |> range(start: 0) + |> filter(fn: (r) => r["_measurement"] == "activity") + |> filter(fn: (r) => r["case_id"] == "{case_id}") + |> sort(columns: ["_time"], desc: true) + {offset_clause} + {limit_clause} + ''' + + result = query_api.query(org=INFLUXDB_ORG, query=query) + + audit_logs: list[AuditLogType] = [] + seen_combinations: set[tuple[str, datetime]] = set() + + for table in result: + record_data: dict[str, Any] = {} + timestamp: datetime | None = None + + for record in table.records: + if timestamp is None: + timestamp = record.get_time() + + field = record.get_field() + value = record.get_value() + + if field == "context": + record_data["context"] = value + elif field == "count": + record_data["count"] = value + + case_id_value = record.values.get("case_id", "") + activity = record.values.get("activity", "") + user_id = record.values.get("user_id") + + if timestamp and case_id_value and activity: + key = (case_id_value, activity, timestamp) + if key not in seen_combinations: + seen_combinations.add(key) + audit_logs.append( + AuditLogType( + case_id=case_id_value, + activity=activity, + user_id=user_id, + timestamp=timestamp, + context=record_data.get("context"), + ) + ) + + return sorted(audit_logs, key=lambda x: x.timestamp, reverse=True) + except Exception as e: + logger.error(f"Error querying audit logs: {e}") + return [] diff --git a/backend/api/resolvers/base.py b/backend/api/resolvers/base.py index bcb98fe8..88a5a523 100644 --- a/backend/api/resolvers/base.py +++ b/backend/api/resolvers/base.py @@ -1,4 +1,6 @@ +import logging from collections.abc import AsyncGenerator +from datetime import datetime, timezone from typing import Generic, TypeVar import strawberry @@ -10,11 +12,30 @@ notify_entity_update, ) from api.services.subscription import create_redis_subscription +from database import models +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession +logger = logging.getLogger(__name__) + ModelType = TypeVar("ModelType") +async def update_user_last_online(db: AsyncSession, user_id: str | None) -> None: + if not user_id: + return + try: + await db.execute( + update(models.User) + .where(models.User.id == user_id) + .values(last_online=datetime.now(timezone.utc)) + ) + await db.commit() + except Exception as e: + logger.warning(f"Failed to update last_online for user {user_id}: {e}") + await db.rollback() + + class BaseQueryResolver(Generic[ModelType]): def __init__(self, model: type[ModelType]): self.model = model @@ -102,9 +123,18 @@ class BaseSubscriptionResolver: async def entity_created( info: Info, entity_name: str ) -> AsyncGenerator[strawberry.ID, None]: - async for entity_id in create_redis_subscription( - f"{entity_name}_created" - ): + if info.context.user: + await update_user_last_online(info.context.db, info.context.user.id) + channel = f"{entity_name}_created" + logger.info( + f"[SUBSCRIPTION] Initializing entity_created subscription: " + f"entity_name={entity_name}, channel={channel}" + ) + async for entity_id in create_redis_subscription(channel): + logger.info( + f"[SUBSCRIPTION] BaseSubscriptionResolver received entity_created event: " + f"entity_name={entity_name}, entity_id={entity_id}, channel={channel}" + ) yield entity_id @staticmethod @@ -113,16 +143,35 @@ async def entity_updated( entity_name: str, entity_id: strawberry.ID | None = None, ) -> AsyncGenerator[strawberry.ID, None]: - async for updated_id in create_redis_subscription( - f"{entity_name}_updated", entity_id - ): + if info.context.user: + await update_user_last_online(info.context.db, info.context.user.id) + channel = f"{entity_name}_updated" + logger.info( + f"[SUBSCRIPTION] Initializing entity_updated subscription: " + f"entity_name={entity_name}, entity_id={entity_id}, channel={channel}" + ) + async for updated_id in create_redis_subscription(channel, str(entity_id) if entity_id else None): + logger.info( + f"[SUBSCRIPTION] BaseSubscriptionResolver received entity_updated event: " + f"entity_name={entity_name}, updated_id={updated_id}, " + f"filter_entity_id={entity_id}, channel={channel}" + ) yield updated_id @staticmethod async def entity_deleted( info: Info, entity_name: str ) -> AsyncGenerator[strawberry.ID, None]: - async for entity_id in create_redis_subscription( - f"{entity_name}_deleted" - ): + if info.context.user: + await update_user_last_online(info.context.db, info.context.user.id) + channel = f"{entity_name}_deleted" + logger.info( + f"[SUBSCRIPTION] Initializing entity_deleted subscription: " + f"entity_name={entity_name}, channel={channel}" + ) + async for entity_id in create_redis_subscription(channel): + logger.info( + f"[SUBSCRIPTION] BaseSubscriptionResolver received entity_deleted event: " + f"entity_name={entity_name}, entity_id={entity_id}, channel={channel}" + ) yield entity_id diff --git a/backend/api/resolvers/location.py b/backend/api/resolvers/location.py index 54673d70..14894d3e 100644 --- a/backend/api/resolvers/location.py +++ b/backend/api/resolvers/location.py @@ -3,6 +3,7 @@ import strawberry from api.audit import audit_log from api.context import Info +from api.decorators.pagination import apply_pagination from api.inputs import CreateLocationNodeInput, LocationType, UpdateLocationNodeInput from api.resolvers.base import BaseMutationResolver, BaseSubscriptionResolver from api.services.authorization import AuthorizationService @@ -67,6 +68,8 @@ async def location_nodes( parent_id: strawberry.ID | None = None, recursive: bool = False, order_by_name: bool = False, + limit: int | None = None, + offset: int | None = None, ) -> list[LocationNodeType]: db = info.context.db @@ -118,6 +121,8 @@ async def location_nodes( if order_by_name: query = query.order_by(models.LocationNode.title) + query = apply_pagination(query, limit=limit, offset=offset) + result = await db.execute(query) return result.scalars().all() diff --git a/backend/api/resolvers/patient.py b/backend/api/resolvers/patient.py index a64d3f2e..abd2997c 100644 --- a/backend/api/resolvers/patient.py +++ b/backend/api/resolvers/patient.py @@ -3,11 +3,13 @@ import strawberry from api.audit import audit_log from api.context import Info +from api.decorators.pagination import apply_pagination from api.inputs import CreatePatientInput, PatientState, UpdatePatientInput from api.resolvers.base import BaseMutationResolver, BaseSubscriptionResolver from api.services.authorization import AuthorizationService from api.services.checksum import validate_checksum from api.services.location import LocationService +from api.services.notifications import notify_entity_deleted from api.services.property import PropertyService from api.types.patient import PatientType from database import models @@ -51,6 +53,8 @@ async def patients( location_node_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, states: list[PatientState] | None = None, + limit: int | None = None, + offset: int | None = None, ) -> list[PatientType]: query = select(models.Patient).options( selectinload(models.Patient.assigned_locations), @@ -70,7 +74,6 @@ async def patients( info.context.user, info.context ) - # If user has no accessible locations, return empty list if not accessible_location_ids: return [] @@ -139,6 +142,8 @@ async def patients( .distinct() ) + query = apply_pagination(query, limit=limit, offset=offset) + result = await info.context.db.execute(query) return result.scalars().all() @@ -240,6 +245,7 @@ async def create_patient( assigned_location_id=data.assigned_location_id, clinic_id=data.clinic_id, position_id=data.position_id, + description=data.description, ) if teams: @@ -320,6 +326,8 @@ async def update_patient( patient.birthdate = data.birthdate if data.sex is not None: patient.sex = data.sex.value + if data.description is not None: + patient.description = data.description location_service = PatientMutation._get_location_service(db) accessible_location_ids = await auth_service.get_user_accessible_location_ids( @@ -425,6 +433,7 @@ async def delete_patient(self, info: Info, id: strawberry.ID) -> bool: await BaseMutationResolver.update_and_notify( info, patient, models.Patient, "patient" ) + await notify_entity_deleted("patient", patient.id) return True @staticmethod @@ -497,9 +506,57 @@ async def wait_patient(self, info: Info, id: strawberry.ID) -> PatientType: class PatientSubscription(BaseSubscriptionResolver): @strawberry.subscription async def patient_created( - self, info: Info + self, + info: Info, + root_location_ids: list[strawberry.ID] | None = None, ) -> AsyncGenerator[strawberry.ID, None]: - async for patient_id in BaseSubscriptionResolver.entity_created(info, "patient"): + import logging + + from api.services.subscription import ( + patient_belongs_to_root_locations, + ) + + logger = logging.getLogger(__name__) + + root_location_ids_str = ( + [str(lid) for lid in root_location_ids] + if root_location_ids + else None + ) + + logger.info( + f"[SUBSCRIPTION] Initializing patient_created subscription: " + f"root_location_ids={root_location_ids_str}" + ) + + async for patient_id in BaseSubscriptionResolver.entity_created( + info, "patient" + ): + logger.info( + f"[SUBSCRIPTION] PatientSubscription received patient_created event: " + f"patient_id={patient_id}, root_location_ids={root_location_ids_str}" + ) + if root_location_ids_str: + belongs = await patient_belongs_to_root_locations( + info.context.db, + str(patient_id), + root_location_ids_str, + ) + if not belongs: + logger.debug( + f"[SUBSCRIPTION] PatientSubscription filtered out patient_created event " + f"(location mismatch): patient_id={patient_id}, " + f"root_location_ids={root_location_ids_str}" + ) + continue + logger.debug( + f"[SUBSCRIPTION] PatientSubscription passed location filter: " + f"patient_id={patient_id}, root_location_ids={root_location_ids_str}" + ) + logger.info( + f"[SUBSCRIPTION] PatientSubscription yielding patient_created event: " + f"patient_id={patient_id}" + ) yield patient_id @strawberry.subscription @@ -507,8 +564,56 @@ async def patient_updated( self, info: Info, patient_id: strawberry.ID | None = None, + root_location_ids: list[strawberry.ID] | None = None, ) -> AsyncGenerator[strawberry.ID, None]: - async for updated_id in BaseSubscriptionResolver.entity_updated(info, "patient", patient_id): + import logging + + from api.services.subscription import ( + patient_belongs_to_root_locations, + ) + + logger = logging.getLogger(__name__) + + root_location_ids_str = ( + [str(lid) for lid in root_location_ids] + if root_location_ids + else None + ) + + logger.info( + f"[SUBSCRIPTION] Initializing patient_updated subscription: " + f"patient_id={patient_id}, root_location_ids={root_location_ids_str}" + ) + + async for updated_id in BaseSubscriptionResolver.entity_updated( + info, "patient", patient_id + ): + logger.info( + f"[SUBSCRIPTION] PatientSubscription received patient_updated event: " + f"updated_id={updated_id}, filter_patient_id={patient_id}, " + f"root_location_ids={root_location_ids_str}" + ) + if root_location_ids_str: + belongs = await patient_belongs_to_root_locations( + info.context.db, + str(updated_id), + root_location_ids_str, + ) + if not belongs: + logger.debug( + f"[SUBSCRIPTION] PatientSubscription filtered out patient_updated event " + f"(location mismatch): updated_id={updated_id}, " + f"root_location_ids={root_location_ids_str}" + ) + continue + logger.debug( + f"[SUBSCRIPTION] PatientSubscription passed location filter: " + f"updated_id={updated_id}, root_location_ids={root_location_ids_str}" + ) + logger.info( + f"[SUBSCRIPTION] PatientSubscription yielding patient_updated event: " + f"updated_id={updated_id}" + ) yield updated_id @strawberry.subscription @@ -516,10 +621,111 @@ async def patient_state_changed( self, info: Info, patient_id: strawberry.ID | None = None, + root_location_ids: list[strawberry.ID] | None = None, ) -> AsyncGenerator[strawberry.ID, None]: - from api.services.subscription import create_redis_subscription + import logging + + from api.services.subscription import ( + create_redis_subscription, + patient_belongs_to_root_locations, + ) + + logger = logging.getLogger(__name__) + + root_location_ids_str = ( + [str(lid) for lid in root_location_ids] + if root_location_ids + else None + ) + + logger.info( + f"[SUBSCRIPTION] Initializing patient_state_changed subscription: " + f"patient_id={patient_id}, root_location_ids={root_location_ids_str}" + ) async for updated_id in create_redis_subscription( - "patient_state_changed", patient_id + "patient_state_changed", + str(patient_id) if patient_id else None, ): + logger.info( + f"[SUBSCRIPTION] PatientSubscription received patient_state_changed event: " + f"updated_id={updated_id}, filter_patient_id={patient_id}, " + f"root_location_ids={root_location_ids_str}" + ) + if root_location_ids_str: + belongs = await patient_belongs_to_root_locations( + info.context.db, + str(updated_id), + root_location_ids_str, + ) + if not belongs: + logger.debug( + f"[SUBSCRIPTION] PatientSubscription filtered out patient_state_changed event " + f"(location mismatch): updated_id={updated_id}, " + f"root_location_ids={root_location_ids_str}" + ) + continue + logger.debug( + f"[SUBSCRIPTION] PatientSubscription passed location filter: " + f"updated_id={updated_id}, root_location_ids={root_location_ids_str}" + ) + logger.info( + f"[SUBSCRIPTION] PatientSubscription yielding patient_state_changed event: " + f"updated_id={updated_id}" + ) yield updated_id + + @strawberry.subscription + async def patient_deleted( + self, + info: Info, + root_location_ids: list[strawberry.ID] | None = None, + ) -> AsyncGenerator[strawberry.ID, None]: + import logging + + from api.services.subscription import ( + patient_belongs_to_root_locations, + ) + + logger = logging.getLogger(__name__) + + root_location_ids_str = ( + [str(lid) for lid in root_location_ids] + if root_location_ids + else None + ) + + logger.info( + f"[SUBSCRIPTION] Initializing patient_deleted subscription: " + f"root_location_ids={root_location_ids_str}" + ) + + async for patient_id in BaseSubscriptionResolver.entity_deleted( + info, "patient" + ): + logger.info( + f"[SUBSCRIPTION] PatientSubscription received patient_deleted event: " + f"patient_id={patient_id}, root_location_ids={root_location_ids_str}" + ) + if root_location_ids_str: + belongs = await patient_belongs_to_root_locations( + info.context.db, + str(patient_id), + root_location_ids_str, + ) + if not belongs: + logger.debug( + f"[SUBSCRIPTION] PatientSubscription filtered out patient_deleted event " + f"(location mismatch): patient_id={patient_id}, " + f"root_location_ids={root_location_ids_str}" + ) + continue + logger.debug( + f"[SUBSCRIPTION] PatientSubscription passed location filter: " + f"patient_id={patient_id}, root_location_ids={root_location_ids_str}" + ) + logger.info( + f"[SUBSCRIPTION] PatientSubscription yielding patient_deleted event: " + f"patient_id={patient_id}" + ) + yield patient_id diff --git a/backend/api/resolvers/task.py b/backend/api/resolvers/task.py index df84a946..03189a48 100644 --- a/backend/api/resolvers/task.py +++ b/backend/api/resolvers/task.py @@ -3,6 +3,7 @@ import strawberry from api.audit import audit_log from api.context import Info +from api.decorators.pagination import apply_pagination from api.inputs import CreateTaskInput, UpdateTaskInput from api.resolvers.base import BaseMutationResolver, BaseSubscriptionResolver from api.services.authorization import AuthorizationService @@ -43,6 +44,8 @@ async def tasks( assignee_id: strawberry.ID | None = None, assignee_team_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, + limit: int | None = None, + offset: int | None = None, ) -> list[TaskType]: auth_service = AuthorizationService(info.context.db) @@ -62,6 +65,8 @@ async def tasks( if assignee_team_id: query = query.where(models.Task.assignee_team_id == assignee_team_id) + query = apply_pagination(query, limit=limit, offset=offset) + result = await info.context.db.execute(query) return result.scalars().all() @@ -159,6 +164,8 @@ async def tasks( models.Task.assignee_team_id.in_(select(team_location_cte.c.id)) ) + query = apply_pagination(query, limit=limit, offset=offset) + result = await info.context.db.execute(query) return result.scalars().all() @@ -265,7 +272,7 @@ async def create_task(self, info: Info, data: CreateTaskInput) -> TaskType: new_task, data.properties, "task" ) - return await BaseMutationResolver.create_and_notify( + task = await BaseMutationResolver.create_and_notify( info, new_task, models.Task, @@ -273,6 +280,15 @@ async def create_task(self, info: Info, data: CreateTaskInput) -> TaskType: "patient" if new_task.patient_id else None, new_task.patient_id if new_task.patient_id else None, ) + if task.patient_id: + from api.audit import AuditLogger + AuditLogger.log_activity( + case_id=task.patient_id, + activity_name="task_created", + user_id=info.context.user.id if info.context.user else None, + context={"payload": {"task_id": task.id, "task_title": task.title}}, + ) + return task @strawberry.mutation @audit_log("update_task") @@ -442,11 +458,20 @@ async def unassign_task_from_team(self, info: Info, id: strawberry.ID) -> TaskTy @strawberry.mutation @audit_log("complete_task") async def complete_task(self, info: Info, id: strawberry.ID) -> TaskType: - return await TaskMutation._update_task_field( + task = await TaskMutation._update_task_field( info, id, lambda task: setattr(task, "done", True), ) + if task.patient_id: + from api.audit import AuditLogger + AuditLogger.log_activity( + case_id=task.patient_id, + activity_name="task_completed", + user_id=info.context.user.id if info.context.user else None, + context={"payload": {"task_id": task.id, "task_title": task.title}}, + ) + return task @strawberry.mutation @audit_log("reopen_task") @@ -489,9 +514,57 @@ async def delete_task(self, info: Info, id: strawberry.ID) -> bool: class TaskSubscription(BaseSubscriptionResolver): @strawberry.subscription async def task_created( - self, info: Info + self, + info: Info, + root_location_ids: list[strawberry.ID] | None = None, ) -> AsyncGenerator[strawberry.ID, None]: - async for task_id in BaseSubscriptionResolver.entity_created(info, "task"): + import logging + + from api.services.subscription import ( + task_belongs_to_root_locations, + ) + + logger = logging.getLogger(__name__) + + root_location_ids_str = ( + [str(lid) for lid in root_location_ids] + if root_location_ids + else None + ) + + logger.info( + f"[SUBSCRIPTION] Initializing task_created subscription: " + f"root_location_ids={root_location_ids_str}" + ) + + async for task_id in BaseSubscriptionResolver.entity_created( + info, "task" + ): + logger.info( + f"[SUBSCRIPTION] TaskSubscription received task_created event: " + f"task_id={task_id}, root_location_ids={root_location_ids_str}" + ) + if root_location_ids_str: + belongs = await task_belongs_to_root_locations( + info.context.db, + str(task_id), + root_location_ids_str, + ) + if not belongs: + logger.debug( + f"[SUBSCRIPTION] TaskSubscription filtered out task_created event " + f"(location mismatch): task_id={task_id}, " + f"root_location_ids={root_location_ids_str}" + ) + continue + logger.debug( + f"[SUBSCRIPTION] TaskSubscription passed location filter: " + f"task_id={task_id}, root_location_ids={root_location_ids_str}" + ) + logger.info( + f"[SUBSCRIPTION] TaskSubscription yielding task_created event: " + f"task_id={task_id}" + ) yield task_id @strawberry.subscription @@ -499,13 +572,109 @@ async def task_updated( self, info: Info, task_id: strawberry.ID | None = None, + root_location_ids: list[strawberry.ID] | None = None, ) -> AsyncGenerator[strawberry.ID, None]: - async for updated_id in BaseSubscriptionResolver.entity_updated(info, "task", task_id): + import logging + + from api.services.subscription import ( + task_belongs_to_root_locations, + ) + + logger = logging.getLogger(__name__) + + root_location_ids_str = ( + [str(lid) for lid in root_location_ids] + if root_location_ids + else None + ) + + logger.info( + f"[SUBSCRIPTION] Initializing task_updated subscription: " + f"task_id={task_id}, root_location_ids={root_location_ids_str}" + ) + + async for updated_id in BaseSubscriptionResolver.entity_updated( + info, "task", task_id + ): + logger.info( + f"[SUBSCRIPTION] TaskSubscription received task_updated event: " + f"updated_id={updated_id}, filter_task_id={task_id}, " + f"root_location_ids={root_location_ids_str}" + ) + if root_location_ids_str: + belongs = await task_belongs_to_root_locations( + info.context.db, + str(updated_id), + root_location_ids_str, + ) + if not belongs: + logger.debug( + f"[SUBSCRIPTION] TaskSubscription filtered out task_updated event " + f"(location mismatch): updated_id={updated_id}, " + f"root_location_ids={root_location_ids_str}" + ) + continue + logger.debug( + f"[SUBSCRIPTION] TaskSubscription passed location filter: " + f"updated_id={updated_id}, root_location_ids={root_location_ids_str}" + ) + logger.info( + f"[SUBSCRIPTION] TaskSubscription yielding task_updated event: " + f"updated_id={updated_id}" + ) yield updated_id @strawberry.subscription async def task_deleted( - self, info: Info + self, + info: Info, + root_location_ids: list[strawberry.ID] | None = None, ) -> AsyncGenerator[strawberry.ID, None]: - async for task_id in BaseSubscriptionResolver.entity_deleted(info, "task"): + import logging + + from api.services.subscription import ( + task_belongs_to_root_locations, + ) + + logger = logging.getLogger(__name__) + + root_location_ids_str = ( + [str(lid) for lid in root_location_ids] + if root_location_ids + else None + ) + + logger.info( + f"[SUBSCRIPTION] Initializing task_deleted subscription: " + f"root_location_ids={root_location_ids_str}" + ) + + async for task_id in BaseSubscriptionResolver.entity_deleted( + info, "task" + ): + logger.info( + f"[SUBSCRIPTION] TaskSubscription received task_deleted event: " + f"task_id={task_id}, root_location_ids={root_location_ids_str}" + ) + if root_location_ids_str: + belongs = await task_belongs_to_root_locations( + info.context.db, + str(task_id), + root_location_ids_str, + ) + if not belongs: + logger.debug( + f"[SUBSCRIPTION] TaskSubscription filtered out task_deleted event " + f"(location mismatch): task_id={task_id}, " + f"root_location_ids={root_location_ids_str}" + ) + continue + logger.debug( + f"[SUBSCRIPTION] TaskSubscription passed location filter: " + f"task_id={task_id}, root_location_ids={root_location_ids_str}" + ) + logger.info( + f"[SUBSCRIPTION] TaskSubscription yielding task_deleted event: " + f"task_id={task_id}" + ) yield task_id diff --git a/backend/api/services/checksum.py b/backend/api/services/checksum.py index 732e9ca4..c5a431e4 100644 --- a/backend/api/services/checksum.py +++ b/backend/api/services/checksum.py @@ -1,6 +1,7 @@ from typing import Any from api.types.base import calculate_checksum_for_instance +from graphql import GraphQLError def validate_checksum( @@ -14,7 +15,13 @@ def validate_checksum( current_checksum = calculate_checksum_for_instance(entity) if provided_checksum != current_checksum: - raise Exception( + raise GraphQLError( f"CONFLICT: {entity_name} data has been modified. " - f"Expected checksum: {current_checksum}, Got: {provided_checksum}" + f"Expected checksum: {current_checksum}, Got: {provided_checksum}", + extensions={ + "code": "CONFLICT", + "expectedChecksum": current_checksum, + "gotChecksum": provided_checksum, + "entityName": entity_name, + }, ) diff --git a/backend/api/services/notifications.py b/backend/api/services/notifications.py index 77bbb92c..ff0eada1 100644 --- a/backend/api/services/notifications.py +++ b/backend/api/services/notifications.py @@ -1,21 +1,58 @@ +import logging + from database.session import publish_to_redis +logger = logging.getLogger(__name__) + async def notify_entity_update( entity_type: str, entity_id: str, related_entity_type: str | None = None, related_entity_id: str | None = None, + location_ids: list[str] | None = None, ) -> None: - await publish_to_redis(f"{entity_type}_updated", str(entity_id)) + channel = f"{entity_type}_updated" + logger.info( + f"[SUBSCRIPTION] Publishing entity update: entity_type={entity_type}, " + f"entity_id={entity_id}, channel={channel}, " + f"location_ids={location_ids}, related_entity={related_entity_type}:{related_entity_id}" + ) + await publish_to_redis(channel, str(entity_id)) + logger.info( + f"[SUBSCRIPTION] Successfully published entity update: " + f"entity_type={entity_type}, entity_id={entity_id}, channel={channel}" + ) if related_entity_type and related_entity_id: - await publish_to_redis( - f"{related_entity_type}_updated", str(related_entity_id) + related_channel = f"{related_entity_type}_updated" + logger.info( + f"[SUBSCRIPTION] Publishing related entity update: " + f"entity_type={related_entity_type}, entity_id={related_entity_id}, " + f"channel={related_channel}" + ) + await publish_to_redis(related_channel, str(related_entity_id)) + logger.info( + f"[SUBSCRIPTION] Successfully published related entity update: " + f"entity_type={related_entity_type}, entity_id={related_entity_id}, " + f"channel={related_channel}" ) -async def notify_entity_created(entity_type: str, entity_id: str) -> None: - await publish_to_redis(f"{entity_type}_created", str(entity_id)) +async def notify_entity_created( + entity_type: str, + entity_id: str, + location_ids: list[str] | None = None, +) -> None: + channel = f"{entity_type}_created" + logger.info( + f"[SUBSCRIPTION] Publishing entity creation: entity_type={entity_type}, " + f"entity_id={entity_id}, channel={channel}, location_ids={location_ids}" + ) + await publish_to_redis(channel, str(entity_id)) + logger.info( + f"[SUBSCRIPTION] Successfully published entity creation: " + f"entity_type={entity_type}, entity_id={entity_id}, channel={channel}" + ) async def notify_entity_deleted( @@ -23,9 +60,29 @@ async def notify_entity_deleted( entity_id: str, related_entity_type: str | None = None, related_entity_id: str | None = None, + location_ids: list[str] | None = None, ) -> None: - await publish_to_redis(f"{entity_type}_deleted", str(entity_id)) + channel = f"{entity_type}_deleted" + logger.info( + f"[SUBSCRIPTION] Publishing entity deletion: entity_type={entity_type}, " + f"entity_id={entity_id}, channel={channel}, location_ids={location_ids}, " + f"related_entity={related_entity_type}:{related_entity_id}" + ) + await publish_to_redis(channel, str(entity_id)) + logger.info( + f"[SUBSCRIPTION] Successfully published entity deletion: " + f"entity_type={entity_type}, entity_id={entity_id}, channel={channel}" + ) if related_entity_type and related_entity_id: - await publish_to_redis( - f"{related_entity_type}_updated", str(related_entity_id) + related_channel = f"{related_entity_type}_updated" + logger.info( + f"[SUBSCRIPTION] Publishing related entity update after deletion: " + f"entity_type={related_entity_type}, entity_id={related_entity_id}, " + f"channel={related_channel}" + ) + await publish_to_redis(related_channel, str(related_entity_id)) + logger.info( + f"[SUBSCRIPTION] Successfully published related entity update: " + f"entity_type={related_entity_type}, entity_id={related_entity_id}, " + f"channel={related_channel}" ) diff --git a/backend/api/services/subscription.py b/backend/api/services/subscription.py index 28382e22..087b2bde 100644 --- a/backend/api/services/subscription.py +++ b/backend/api/services/subscription.py @@ -1,19 +1,144 @@ +import logging from collections.abc import AsyncGenerator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from database import models from database.session import redis_client +logger = logging.getLogger(__name__) + async def create_redis_subscription( channel: str, filter_id: str | None = None, ) -> AsyncGenerator[str, None]: + logger.debug( + f"[SUBSCRIPTION] Subscribing to Redis channel: channel={channel}, filter_id={filter_id}" + ) pubsub = redis_client.pubsub() await pubsub.subscribe(channel) + logger.debug( + f"[SUBSCRIPTION] Successfully subscribed to Redis channel: channel={channel}" + ) try: async for message in pubsub.listen(): if message["type"] == "message": message_id = message["data"] + logger.debug( + f"[SUBSCRIPTION] Received message from Redis channel: " + f"channel={channel}, message_id={message_id}, filter_id={filter_id}" + ) if filter_id is None or message_id == filter_id: + logger.debug( + f"[SUBSCRIPTION] Dispatching message to resolver: " + f"channel={channel}, message_id={message_id}" + ) yield message_id + else: + logger.debug( + f"[SUBSCRIPTION] Filtered out message (filter mismatch): " + f"channel={channel}, message_id={message_id}, filter_id={filter_id}" + ) finally: + logger.debug( + f"[SUBSCRIPTION] Unsubscribing from Redis channel: channel={channel}" + ) await pubsub.close() + + +async def patient_belongs_to_root_locations( + db: AsyncSession, + patient_id: str, + root_location_ids: list[str], +) -> bool: + if not root_location_ids: + return True + + result = await db.execute( + select(models.Patient) + .where(models.Patient.id == patient_id) + .options( + selectinload(models.Patient.assigned_locations), + selectinload(models.Patient.teams), + ) + ) + patient = result.scalars().first() + + if not patient: + return False + + 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) + + result = await db.execute(select(root_cte.c.id)) + root_location_descendants = {row[0] for row in result.all()} + + if patient.clinic_id in root_location_descendants: + return True + + if ( + patient.position_id + and patient.position_id in root_location_descendants + ): + return True + + if ( + patient.assigned_location_id + and patient.assigned_location_id + in root_location_descendants + ): + return True + + if patient.assigned_locations: + for location in patient.assigned_locations: + if location.id in root_location_descendants: + return True + + if patient.teams: + for team in patient.teams: + if team.id in root_location_descendants: + return True + + return False + + +async def task_belongs_to_root_locations( + db: AsyncSession, + task_id: str, + root_location_ids: list[str], +) -> bool: + if not root_location_ids: + return True + + result = await db.execute( + select(models.Task) + .where(models.Task.id == task_id) + .options( + selectinload(models.Task.patient).selectinload( + models.Patient.assigned_locations + ), + selectinload(models.Task.patient).selectinload( + models.Patient.teams + ), + ) + ) + task = result.scalars().first() + + if not task or not task.patient: + return False + + return await patient_belongs_to_root_locations( + db, task.patient.id, root_location_ids + ) diff --git a/backend/api/types/audit.py b/backend/api/types/audit.py new file mode 100644 index 00000000..3deaf91b --- /dev/null +++ b/backend/api/types/audit.py @@ -0,0 +1,12 @@ +from datetime import datetime + +import strawberry + + +@strawberry.type +class AuditLogType: + case_id: str + activity: str + user_id: str | None + timestamp: datetime + context: str | None diff --git a/backend/api/types/patient.py b/backend/api/types/patient.py index e7646915..2e50edce 100644 --- a/backend/api/types/patient.py +++ b/backend/api/types/patient.py @@ -26,6 +26,7 @@ class PatientType: assigned_location_id: strawberry.ID | None clinic_id: strawberry.ID position_id: strawberry.ID | None + description: str | None @strawberry.field def name(self) -> str: diff --git a/backend/api/types/user.py b/backend/api/types/user.py index 85578ed6..d38fad75 100644 --- a/backend/api/types/user.py +++ b/backend/api/types/user.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone, timedelta from typing import TYPE_CHECKING, Annotated import strawberry @@ -18,6 +19,7 @@ class UserType: lastname: str | None title: str | None avatar_url: str | None + last_online: datetime | None @strawberry.field def name(self) -> str: @@ -25,6 +27,13 @@ def name(self) -> str: return f"{self.firstname} {self.lastname}" return self.username + @strawberry.field + def is_online(self) -> bool: + if not self.last_online: + return False + threshold = datetime.now(timezone.utc) - timedelta(minutes=15) + return self.last_online >= threshold + @strawberry.field def organizations(self, info) -> str | None: """Get organizations from the context""" @@ -34,12 +43,15 @@ def organizations(self, info) -> str | None: async def tasks( self, info, + root_location_ids: list[strawberry.ID] | None = None, ) -> list[Annotated["TaskType", strawberry.lazy("api.types.task")]]: from api.services.authorization import AuthorizationService 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: @@ -50,6 +62,7 @@ async def tasks( patient_teams = aliased(models.patient_teams) from sqlalchemy import select + cte = ( select(models.LocationNode.id) .where(models.LocationNode.id.in_(accessible_location_ids)) @@ -61,6 +74,26 @@ async def tasks( ) 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: + return [] + 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 + query = ( select(models.Task) .join(models.Patient, models.Task.patient_id == models.Patient.id) @@ -75,17 +108,25 @@ async def tasks( .where( models.Task.assignee_id == self.id, ( - (models.Patient.clinic_id.in_(select(cte.c.id))) + (models.Patient.clinic_id.in_(select(root_cte.c.id))) | ( models.Patient.position_id.isnot(None) - & models.Patient.position_id.in_(select(cte.c.id)) + & models.Patient.position_id.in_( + select(root_cte.c.id) + ) ) | ( models.Patient.assigned_location_id.isnot(None) - & models.Patient.assigned_location_id.in_(select(cte.c.id)) + & models.Patient.assigned_location_id.in_( + select(root_cte.c.id) + ) + ) + | ( + patient_locations.c.location_id.in_( + select(root_cte.c.id) + ) ) - | (patient_locations.c.location_id.in_(select(cte.c.id))) - | (patient_teams.c.location_id.in_(select(cte.c.id))) + | (patient_teams.c.location_id.in_(select(root_cte.c.id))) ) ) .distinct() @@ -98,32 +139,41 @@ async def tasks( async def root_locations( self, info, - ) -> list[Annotated["LocationNodeType", strawberry.lazy("api.types.location")]]: + ) -> list[ + Annotated["LocationNodeType", strawberry.lazy("api.types.location")] + ]: import logging logger = logging.getLogger(__name__) - # First check what's in user_root_locations table user_root_check = await info.context.db.execute( select(models.user_root_locations.c.location_id).where( models.user_root_locations.c.user_id == self.id ) ) - user_root_location_ids = [row[0] for row in user_root_check.all()] - logger.info(f"User {self.id} has {len(user_root_location_ids)} entries in user_root_locations: {user_root_location_ids}") + user_root_location_ids = [ + row[0] for row in user_root_check.all() + ] + logger.info( + f"User {self.id} has {len(user_root_location_ids)} " + f"entries in user_root_locations: {user_root_location_ids}" + ) result = await info.context.db.execute( select(models.LocationNode) .join( models.user_root_locations, - models.LocationNode.id == models.user_root_locations.c.location_id, + models.LocationNode.id + == models.user_root_locations.c.location_id, ) .where(models.user_root_locations.c.user_id == self.id) .distinct() ) locations = result.scalars().all() - logger.info(f"User {self.id} root_locations query returned {len(locations)} locations: {[loc.id for loc in locations]}") + logger.info( + f"User {self.id} root_locations query returned " + f"{len(locations)} locations: {[loc.id for loc in locations]}" + ) - # If we have user_root_locations entries but no locations returned, check if locations exist if user_root_location_ids and not locations: location_check = await info.context.db.execute( select(models.LocationNode).where( @@ -132,9 +182,12 @@ async def root_locations( ) existing_locations = location_check.scalars().all() logger.warning( - f"User {self.id} has {len(user_root_location_ids)} root location IDs but query returned empty. " - f"Checking if locations exist: {[loc.id for loc in existing_locations]} " - f"with parent_ids: {[loc.parent_id for loc in existing_locations]}" + f"User {self.id} has {len(user_root_location_ids)} " + f"root location IDs but query returned empty. " + f"Checking if locations exist: " + f"{[loc.id for loc in existing_locations]} " + f"with parent_ids: " + f"{[loc.parent_id for loc in existing_locations]}" ) return locations diff --git a/backend/auth.py b/backend/auth.py index 43e9b7c3..ab15ac10 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -45,8 +45,6 @@ def get_user_payload(connection: HTTPConnection) -> Optional[dict]: def get_public_key(token: str) -> Any: - global jwks_cache # noqa: F824 - try: header = jwt.get_unverified_header(token) kid = header.get("kid") diff --git a/backend/database/migrations/versions/655294b10318_add_user_last_online.py b/backend/database/migrations/versions/655294b10318_add_user_last_online.py new file mode 100644 index 00000000..b53fe533 --- /dev/null +++ b/backend/database/migrations/versions/655294b10318_add_user_last_online.py @@ -0,0 +1,31 @@ +"""add_user_last_online + +Revision ID: 655294b10318 +Revises: 81ffadeee438 +Create Date: 2026-01-05 23:43:26.978266 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '655294b10318' +down_revision: Union[str, Sequence[str], None] = ('0de3078888ba', '81ffadeee438') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "users", + sa.Column("last_online", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("users", "last_online") diff --git a/backend/database/migrations/versions/81ffadeee438_merge_patient_description_and_task_.py b/backend/database/migrations/versions/81ffadeee438_merge_patient_description_and_task_.py new file mode 100644 index 00000000..6b44a7a5 --- /dev/null +++ b/backend/database/migrations/versions/81ffadeee438_merge_patient_description_and_task_.py @@ -0,0 +1,28 @@ +"""merge patient description and task assignee team + +Revision ID: 81ffadeee438 +Revises: add_patient_description, add_task_assignee_team +Create Date: 2026-01-01 21:39:27.052533 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '81ffadeee438' +down_revision: Union[str, Sequence[str], None] = ('add_patient_description', 'add_task_assignee_team') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/backend/database/migrations/versions/add_patient_description.py b/backend/database/migrations/versions/add_patient_description.py new file mode 100644 index 00000000..1f4c20aa --- /dev/null +++ b/backend/database/migrations/versions/add_patient_description.py @@ -0,0 +1,35 @@ +"""Add description field to patients. + +Revision ID: add_patient_description +Revises: add_patient_deleted +Create Date: 2025-01-15 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "add_patient_description" +down_revision: Union[str, Sequence[str], None] = "add_patient_deleted" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "patients", + sa.Column("description", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("patients", "description") + + + + + + + diff --git a/backend/database/models/patient.py b/backend/database/models/patient.py index 6501d5f9..4791a650 100644 --- a/backend/database/models/patient.py +++ b/backend/database/models/patient.py @@ -54,6 +54,7 @@ class Patient(Base): ForeignKey("location_nodes.id"), nullable=True, ) + description: Mapped[str | None] = mapped_column(String, nullable=True) assigned_locations: Mapped[list[LocationNode]] = relationship( "LocationNode", diff --git a/backend/database/models/user.py b/backend/database/models/user.py index 67b9caca..f8b0f0a8 100644 --- a/backend/database/models/user.py +++ b/backend/database/models/user.py @@ -1,10 +1,11 @@ from __future__ import annotations import uuid +from datetime import datetime from typing import TYPE_CHECKING from database.models.base import Base -from sqlalchemy import Column, ForeignKey, String, Table +from sqlalchemy import Column, DateTime, ForeignKey, String, Table from sqlalchemy.orm import Mapped, mapped_column, relationship if TYPE_CHECKING: @@ -37,6 +38,10 @@ class User(Base): nullable=True, default="https://cdn.helpwave.de/boringavatar.svg", ) + last_online: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) tasks: Mapped[list[Task]] = relationship("Task", back_populates="assignee") root_locations: Mapped[list[LocationNode]] = relationship( diff --git a/backend/database/session.py b/backend/database/session.py index 09252e1c..e9c11a93 100644 --- a/backend/database/session.py +++ b/backend/database/session.py @@ -20,17 +20,31 @@ async def publish_to_redis(channel: str, message: str) -> None: try: - logger.info( - f"Publishing to Redis: channel={channel}, message={message}" + logger.debug( + f"[SUBSCRIPTION] Publishing to Redis: channel={channel}, message={message}" ) await redis_client.publish(channel, message) logger.debug( - f"Successfully published to Redis: channel={channel}, message={message}" + f"[SUBSCRIPTION] Successfully published to Redis: channel={channel}, message={message}" + ) + except RuntimeError as e: + error_str = str(e) + if "Event loop is closed" in error_str or "attached to a different loop" in error_str: + logger.warning( + f"[SUBSCRIPTION] Skipping Redis publish due to event loop issue: channel={channel}, message={message}, error={error_str}" + ) + return + logger.error( + f"[SUBSCRIPTION] Failed to publish to Redis: channel={channel}, message={message}, error={error_str}", + exc_info=True ) + raise except Exception as e: logger.error( - f"Failed to publish to Redis: channel={channel}, message={message}, error={e}" + f"[SUBSCRIPTION] Failed to publish to Redis: channel={channel}, message={message}, error={e}", + exc_info=True ) + raise async def get_db_session() -> AsyncGenerator[AsyncSession, None]: diff --git a/web/api/auth/authService.ts b/web/api/auth/authService.ts index 30e6b5f7..e6622a46 100644 --- a/web/api/auth/authService.ts +++ b/web/api/auth/authService.ts @@ -120,8 +120,7 @@ export const renewToken = async (): Promise => { 2, (error) => isAuthenticationServerUnavailable(error) ) - } catch (error) { - console.warn('Silent token renewal failed:', error) + } catch { return null } } @@ -161,7 +160,6 @@ export const restoreSession = async (): Promise => { } if (user.expired) { - console.debug('Access token expired, attempting silent refresh...') const refreshedUser = await renewToken() const result = refreshedUser ?? undefined lastRestoreSessionResult = result @@ -173,10 +171,8 @@ export const restoreSession = async (): Promise => { lastRestoreSessionTime = now return user } catch (error) { - if (isAuthenticationServerUnavailable(error)) { - console.debug('Authentication server not ready yet, session restoration will retry:', error) - } else { - console.warn('Session restoration failed:', error) + if (!isAuthenticationServerUnavailable(error)) { + throw error } lastRestoreSessionResult = undefined lastRestoreSessionTime = now diff --git a/web/api/gql/generated.ts b/web/api/gql/generated.ts index de8682dc..cfd55561 100644 --- a/web/api/gql/generated.ts +++ b/web/api/gql/generated.ts @@ -18,6 +18,15 @@ export type Scalars = { DateTime: { input: any; output: any; } }; +export type AuditLogType = { + __typename?: 'AuditLogType'; + activity: Scalars['String']['output']; + caseId: Scalars['String']['output']; + context?: Maybe; + timestamp: Scalars['DateTime']['output']; + userId?: Maybe; +}; + export type CreateLocationNodeInput = { kind: LocationType; parentId?: InputMaybe; @@ -29,6 +38,7 @@ export type CreatePatientInput = { assignedLocationIds?: InputMaybe>; birthdate: Scalars['Date']['input']; clinicId: Scalars['ID']['input']; + description?: InputMaybe; firstname: Scalars['String']['input']; lastname: Scalars['String']['input']; positionId?: InputMaybe; @@ -253,6 +263,7 @@ export type PatientType = { checksum: Scalars['String']['output']; clinic: LocationNodeType; clinicId: Scalars['ID']['output']; + description?: Maybe; firstname: Scalars['String']['output']; id: Scalars['ID']['output']; lastname: Scalars['String']['output']; @@ -312,6 +323,7 @@ export type PropertyValueType = { export type Query = { __typename?: 'Query'; + auditLogs: Array; locationNode?: Maybe; locationNodes: Array; locationRoots: Array; @@ -328,6 +340,13 @@ export type Query = { }; +export type QueryAuditLogsArgs = { + caseId: Scalars['ID']['input']; + limit?: InputMaybe; + offset?: InputMaybe; +}; + + export type QueryLocationNodeArgs = { id: Scalars['ID']['input']; }; @@ -335,6 +354,8 @@ export type QueryLocationNodeArgs = { export type QueryLocationNodesArgs = { kind?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; orderByName?: Scalars['Boolean']['input']; parentId?: InputMaybe; recursive?: Scalars['Boolean']['input']; @@ -348,7 +369,9 @@ export type QueryPatientArgs = { export type QueryPatientsArgs = { + limit?: InputMaybe; locationNodeId?: InputMaybe; + offset?: InputMaybe; rootLocationIds?: InputMaybe>; states?: InputMaybe>; }; @@ -372,6 +395,8 @@ export type QueryTaskArgs = { export type QueryTasksArgs = { assigneeId?: InputMaybe; assigneeTeamId?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; patientId?: InputMaybe; rootLocationIds?: InputMaybe>; }; @@ -393,6 +418,7 @@ export type Subscription = { locationNodeDeleted: Scalars['ID']['output']; locationNodeUpdated: Scalars['ID']['output']; patientCreated: Scalars['ID']['output']; + patientDeleted: Scalars['ID']['output']; patientStateChanged: Scalars['ID']['output']; patientUpdated: Scalars['ID']['output']; taskCreated: Scalars['ID']['output']; @@ -406,17 +432,40 @@ export type SubscriptionLocationNodeUpdatedArgs = { }; +export type SubscriptionPatientCreatedArgs = { + rootLocationIds?: InputMaybe>; +}; + + +export type SubscriptionPatientDeletedArgs = { + rootLocationIds?: InputMaybe>; +}; + + export type SubscriptionPatientStateChangedArgs = { patientId?: InputMaybe; + rootLocationIds?: InputMaybe>; }; export type SubscriptionPatientUpdatedArgs = { patientId?: InputMaybe; + rootLocationIds?: InputMaybe>; +}; + + +export type SubscriptionTaskCreatedArgs = { + rootLocationIds?: InputMaybe>; +}; + + +export type SubscriptionTaskDeletedArgs = { + rootLocationIds?: InputMaybe>; }; export type SubscriptionTaskUpdatedArgs = { + rootLocationIds?: InputMaybe>; taskId?: InputMaybe; }; @@ -460,6 +509,7 @@ export type UpdatePatientInput = { birthdate?: InputMaybe; checksum?: InputMaybe; clinicId?: InputMaybe; + description?: InputMaybe; firstname?: InputMaybe; lastname?: InputMaybe; positionId?: InputMaybe; @@ -496,6 +546,8 @@ export type UserType = { email?: Maybe; firstname?: Maybe; id: Scalars['ID']['output']; + isOnline: Scalars['Boolean']['output']; + lastOnline?: Maybe; lastname?: Maybe; name: Scalars['String']['output']; organizations?: Maybe; @@ -505,6 +557,20 @@ export type UserType = { username: Scalars['String']['output']; }; + +export type UserTypeTasksArgs = { + rootLocationIds?: InputMaybe>; +}; + +export type GetAuditLogsQueryVariables = Exact<{ + caseId: Scalars['ID']['input']; + limit?: InputMaybe; + offset?: InputMaybe; +}>; + + +export type GetAuditLogsQuery = { __typename?: 'Query', auditLogs: Array<{ __typename?: 'AuditLogType', caseId: string, activity: string, userId?: string | null, timestamp: any, context?: string | null }> }; + export type GetLocationNodeQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -512,7 +578,10 @@ export type GetLocationNodeQueryVariables = Exact<{ export type GetLocationNodeQuery = { __typename?: 'Query', locationNode?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parentId?: string | null, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parentId?: string | null, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parentId?: string | null, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parentId?: string | null, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parentId?: string | null } | null } | null } | null } | null } | null }; -export type GetLocationsQueryVariables = Exact<{ [key: string]: never; }>; +export type GetLocationsQueryVariables = Exact<{ + limit?: InputMaybe; + offset?: InputMaybe; +}>; export type GetLocationsQuery = { __typename?: 'Query', locationNodes: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parentId?: string | null }> }; @@ -532,12 +601,14 @@ export type GetPatientQueryVariables = Exact<{ }>; -export type GetPatientQuery = { __typename?: 'Query', patient?: { __typename?: 'PatientType', id: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, checksum: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string }>, 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, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | 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 } }> } | null }; +export type GetPatientQuery = { __typename?: 'Query', patient?: { __typename?: 'PatientType', id: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, description?: string | null, checksum: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string }>, 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, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | 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 } }> } | null }; export type GetPatientsQueryVariables = Exact<{ locationId?: InputMaybe; rootLocationIds?: InputMaybe | Scalars['ID']['input']>; states?: InputMaybe | PatientState>; + limit?: InputMaybe; + offset?: InputMaybe; }>; @@ -554,15 +625,24 @@ export type GetTasksQueryVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; assigneeId?: InputMaybe; assigneeTeamId?: InputMaybe; + limit?: InputMaybe; + offset?: 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 } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }; +export type GetUserQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type GetUserQuery = { __typename?: 'Query', user?: { __typename?: 'UserType', id: string, username: string, name: string, email?: string | null, firstname?: string | null, lastname?: string | null, title?: string | null, avatarUrl?: string | null } | null }; + export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>; -export type GetUsersQuery = { __typename?: 'Query', users: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null }> }; +export type GetUsersQuery = { __typename?: 'Query', users: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, isOnline: boolean }> }; export type GetGlobalDataQueryVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; @@ -584,7 +664,7 @@ export type UpdatePatientMutationVariables = Exact<{ }>; -export type UpdatePatientMutation = { __typename?: 'Mutation', updatePatient: { __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, checksum: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType }> } }; +export type UpdatePatientMutation = { __typename?: 'Mutation', updatePatient: { __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, checksum: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType }>, 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 AdmitPatientMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -656,13 +736,16 @@ export type GetPropertiesForSubjectQueryVariables = Exact<{ export type GetPropertiesForSubjectQuery = { __typename?: 'Query', propertyDefinitions: Array<{ __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }> }; -export type PatientCreatedSubscriptionVariables = Exact<{ [key: string]: never; }>; +export type PatientCreatedSubscriptionVariables = Exact<{ + rootLocationIds?: InputMaybe | Scalars['ID']['input']>; +}>; export type PatientCreatedSubscription = { __typename?: 'Subscription', patientCreated: string }; export type PatientUpdatedSubscriptionVariables = Exact<{ patientId?: InputMaybe; + rootLocationIds?: InputMaybe | Scalars['ID']['input']>; }>; @@ -670,24 +753,30 @@ export type PatientUpdatedSubscription = { __typename?: 'Subscription', patientU export type PatientStateChangedSubscriptionVariables = Exact<{ patientId?: InputMaybe; + rootLocationIds?: InputMaybe | Scalars['ID']['input']>; }>; export type PatientStateChangedSubscription = { __typename?: 'Subscription', patientStateChanged: string }; -export type TaskCreatedSubscriptionVariables = Exact<{ [key: string]: never; }>; +export type TaskCreatedSubscriptionVariables = Exact<{ + rootLocationIds?: InputMaybe | Scalars['ID']['input']>; +}>; export type TaskCreatedSubscription = { __typename?: 'Subscription', taskCreated: string }; export type TaskUpdatedSubscriptionVariables = Exact<{ taskId?: InputMaybe; + rootLocationIds?: InputMaybe | Scalars['ID']['input']>; }>; export type TaskUpdatedSubscription = { __typename?: 'Subscription', taskUpdated: string }; -export type TaskDeletedSubscriptionVariables = Exact<{ [key: string]: never; }>; +export type TaskDeletedSubscriptionVariables = Exact<{ + rootLocationIds?: InputMaybe | Scalars['ID']['input']>; +}>; export type TaskDeletedSubscription = { __typename?: 'Subscription', taskDeleted: string }; @@ -722,7 +811,7 @@ export type UpdateTaskMutationVariables = Exact<{ }>; -export type UpdateTaskMutation = { __typename?: 'Mutation', updateTask: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, updateDate?: any | null, checksum: string, patient: { __typename?: 'PatientType', id: string, name: string }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null } }; +export type UpdateTaskMutation = { __typename?: 'Mutation', updateTask: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, updateDate?: any | null, checksum: string, patient: { __typename?: 'PatientType', id: string, name: string }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: 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 AssignTaskMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -777,6 +866,34 @@ export type UnassignTaskFromTeamMutation = { __typename?: 'Mutation', unassignTa +export const GetAuditLogsDocument = ` + query GetAuditLogs($caseId: ID!, $limit: Int, $offset: Int) { + auditLogs(caseId: $caseId, limit: $limit, offset: $offset) { + caseId + activity + userId + timestamp + context + } +} + `; + +export const useGetAuditLogsQuery = < + TData = GetAuditLogsQuery, + TError = unknown + >( + variables: GetAuditLogsQueryVariables, + options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } + ) => { + + return useQuery( + { + queryKey: ['GetAuditLogs', variables], + queryFn: fetcher(GetAuditLogsDocument, variables), + ...options + } + )}; + export const GetLocationNodeDocument = ` query GetLocationNode($id: ID!) { locationNode(id: $id) { @@ -829,8 +946,8 @@ export const useGetLocationNodeQuery = < )}; export const GetLocationsDocument = ` - query GetLocations { - locationNodes { + query GetLocations($limit: Int, $offset: Int) { + locationNodes(limit: $limit, offset: $offset) { id title kind @@ -994,6 +1111,7 @@ export const GetPatientDocument = ` birthdate sex state + description checksum assignedLocation { id @@ -1125,11 +1243,13 @@ export const useGetPatientQuery = < )}; export const GetPatientsDocument = ` - query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!]) { + query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $limit: Int, $offset: Int) { patients( locationNodeId: $locationId rootLocationIds: $rootLocationIds states: $states + limit: $limit + offset: $offset ) { id name @@ -1337,11 +1457,13 @@ export const useGetTaskQuery = < )}; export const GetTasksDocument = ` - query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID) { + query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $limit: Int, $offset: Int) { tasks( rootLocationIds: $rootLocationIds assigneeId: $assigneeId assigneeTeamId: $assigneeTeamId + limit: $limit + offset: $offset ) { id title @@ -1407,12 +1529,44 @@ export const useGetTasksQuery = < } )}; +export const GetUserDocument = ` + query GetUser($id: ID!) { + user(id: $id) { + id + username + name + email + firstname + lastname + title + avatarUrl + } +} + `; + +export const useGetUserQuery = < + TData = GetUserQuery, + TError = unknown + >( + variables: GetUserQueryVariables, + options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } + ) => { + + return useQuery( + { + queryKey: ['GetUser', variables], + queryFn: fetcher(GetUserDocument, variables), + ...options + } + )}; + export const GetUsersDocument = ` query GetUsers { users { id name avatarUrl + isOnline } } `; @@ -1448,7 +1602,7 @@ export const GetGlobalDataDocument = ` title kind } - tasks { + tasks(rootLocationIds: $rootLocationIds) { id done } @@ -1582,6 +1736,24 @@ export const UpdatePatientDocument = ` title kind } + properties { + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + } } } `; @@ -1840,33 +2012,33 @@ export const useGetPropertiesForSubjectQuery = < )}; export const PatientCreatedDocument = ` - subscription PatientCreated { - patientCreated + subscription PatientCreated($rootLocationIds: [ID!]) { + patientCreated(rootLocationIds: $rootLocationIds) } `; export const PatientUpdatedDocument = ` - subscription PatientUpdated($patientId: ID) { - patientUpdated(patientId: $patientId) + subscription PatientUpdated($patientId: ID, $rootLocationIds: [ID!]) { + patientUpdated(patientId: $patientId, rootLocationIds: $rootLocationIds) } `; export const PatientStateChangedDocument = ` - subscription PatientStateChanged($patientId: ID) { - patientStateChanged(patientId: $patientId) + subscription PatientStateChanged($patientId: ID, $rootLocationIds: [ID!]) { + patientStateChanged(patientId: $patientId, rootLocationIds: $rootLocationIds) } `; export const TaskCreatedDocument = ` - subscription TaskCreated { - taskCreated + subscription TaskCreated($rootLocationIds: [ID!]) { + taskCreated(rootLocationIds: $rootLocationIds) } `; export const TaskUpdatedDocument = ` - subscription TaskUpdated($taskId: ID) { - taskUpdated(taskId: $taskId) + subscription TaskUpdated($taskId: ID, $rootLocationIds: [ID!]) { + taskUpdated(taskId: $taskId, rootLocationIds: $rootLocationIds) } `; export const TaskDeletedDocument = ` - subscription TaskDeleted { - taskDeleted + subscription TaskDeleted($rootLocationIds: [ID!]) { + taskDeleted(rootLocationIds: $rootLocationIds) } `; export const LocationNodeUpdatedDocument = ` @@ -1940,6 +2112,24 @@ export const UpdateTaskDocument = ` name avatarUrl } + properties { + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + } } } `; diff --git a/web/api/gql/subscriptionClient.ts b/web/api/gql/subscriptionClient.ts new file mode 100644 index 00000000..95feb153 --- /dev/null +++ b/web/api/gql/subscriptionClient.ts @@ -0,0 +1,235 @@ +import { getConfig } from '@/utils/config' +import { getUser } from '@/api/auth/authService' + +export type SubscriptionObserver = { + next: (value: unknown) => void + error: (error: Error) => void + complete: () => void +} + +type GraphQLWSMessage = { + id?: string + type: 'connection_init' | 'start' | 'stop' | 'connection_ack' | 'data' | 'error' | 'complete' | 'ka' + payload?: unknown +} + +class GraphQLSubscriptionClient { + private ws: WebSocket | null = null + private subscriptions = new Map() + private reconnectAttempts = 0 + private maxReconnectAttempts = 10 + private reconnectDelay = 1000 + private isConnecting = false + private messageIdCounter = 0 + private keepAliveInterval: NodeJS.Timeout | null = null + private connectionPromise: Promise | null = null + + private async connect(): Promise { + if (this.ws?.readyState === WebSocket.OPEN) { + return this.ws + } + + if (this.connectionPromise) { + return this.connectionPromise + } + + this.connectionPromise = this._connect() + try { + const ws = await this.connectionPromise + return ws + } finally { + this.connectionPromise = null + } + } + + private async _connect(): Promise { + if (this.isConnecting) { + return new Promise((resolve, reject) => { + const checkConnection = setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + clearInterval(checkConnection) + resolve(this.ws!) + } else if (this.ws?.readyState === WebSocket.CLOSED && !this.isConnecting) { + clearInterval(checkConnection) + reject(new Error('Connection failed')) + } + }, 100) + setTimeout(() => { + clearInterval(checkConnection) + reject(new Error('Connection timeout')) + }, 10000) + }) + } + + this.isConnecting = true + + try { + const config = getConfig() + const wsUrl = config.graphqlEndpoint.replace(/^http/, 'ws').replace(/^https/, 'wss') + const user = await getUser() + const token = user?.access_token + + const url = token + ? `${wsUrl}?token=${encodeURIComponent(token)}` + : wsUrl + + const ws = new WebSocket(url, 'graphql-ws') + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('WebSocket connection timeout')) + }, 10000) + + ws.onopen = () => { + clearTimeout(timeout) + this.isConnecting = false + this.reconnectAttempts = 0 + + const initMessage: GraphQLWSMessage = { + type: 'connection_init', + payload: token ? { authorization: `Bearer ${token}` } : {}, + } + ws.send(JSON.stringify(initMessage)) + + this.startKeepAlive() + resolve(ws) + } + + ws.onerror = (error) => { + clearTimeout(timeout) + this.isConnecting = false + + reject(error) + } + + ws.onclose = (event) => { + clearTimeout(timeout) + this.isConnecting = false + this.ws = null + this.stopKeepAlive() + + if (event.code !== 1000 && this.subscriptions.size > 0 && this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++ + setTimeout(() => { + this.connect().catch(() => {}) + }, this.reconnectDelay * Math.min(this.reconnectAttempts, 5)) + } + } + + ws.onmessage = (event) => { + try { + const message: GraphQLWSMessage = JSON.parse(event.data) + + if (message.type === 'connection_ack') { + return + } + + if (message.type === 'ka') { + return + } + + if (message.id) { + const observer = this.subscriptions.get(message.id) + + if (observer) { + if (message.type === 'data') { + observer.next(message.payload) + } else if (message.type === 'error') { + observer.error(new Error(String(message.payload))) + } else if (message.type === 'complete') { + observer.complete() + this.subscriptions.delete(message.id) + } + } + } + } catch (error) { + + } + } + + this.ws = ws + }) + } catch (error) { + this.isConnecting = false + throw error + } + } + + private startKeepAlive() { + this.stopKeepAlive() + this.keepAliveInterval = setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'ka' })) + } + }, 30000) + } + + private stopKeepAlive() { + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval) + this.keepAliveInterval = null + } + } + + async subscribe( + query: string, + variables: Record | undefined, + observer: SubscriptionObserver + ): Promise<() => void> { + let ws: WebSocket + try { + ws = await this.connect() + } catch (error) { + + observer.error(error as Error) + return () => {} + } + + const subscriptionId = `sub_${++this.messageIdCounter}` + + this.subscriptions.set(subscriptionId, observer) + + const sendStart = () => { + if (ws.readyState !== WebSocket.OPEN) { + return + } + const startMessage: GraphQLWSMessage = { + id: subscriptionId, + type: 'start', + payload: { + query, + variables: variables || {}, + }, + } + try { + ws.send(JSON.stringify(startMessage)) + } catch (error) { + + observer.error(error as Error) + } + } + + if (ws.readyState === WebSocket.OPEN) { + sendStart() + } else { + ws.addEventListener('open', sendStart, { once: true }) + } + + return () => { + if (ws.readyState === WebSocket.OPEN) { + const stopMessage: GraphQLWSMessage = { + id: subscriptionId, + type: 'stop', + } + try { + ws.send(JSON.stringify(stopMessage)) + } catch (error) { + + } + } + this.subscriptions.delete(subscriptionId) + } + } +} + +export const subscriptionClient = new GraphQLSubscriptionClient() diff --git a/web/api/graphql/GetAuditLogs.graphql b/web/api/graphql/GetAuditLogs.graphql new file mode 100644 index 00000000..fac92239 --- /dev/null +++ b/web/api/graphql/GetAuditLogs.graphql @@ -0,0 +1,10 @@ +query GetAuditLogs($caseId: ID!, $limit: Int, $offset: Int) { + auditLogs(caseId: $caseId, limit: $limit, offset: $offset) { + caseId + activity + userId + timestamp + context + } +} + diff --git a/web/api/graphql/GetLocations.graphql b/web/api/graphql/GetLocations.graphql index 5d45ffd5..4f014f61 100644 --- a/web/api/graphql/GetLocations.graphql +++ b/web/api/graphql/GetLocations.graphql @@ -1,5 +1,5 @@ -query GetLocations { - locationNodes { +query GetLocations($limit: Int, $offset: Int) { + locationNodes(limit: $limit, offset: $offset) { id title kind diff --git a/web/api/graphql/GetPatient.graphql b/web/api/graphql/GetPatient.graphql index e0ff074a..678aaee8 100644 --- a/web/api/graphql/GetPatient.graphql +++ b/web/api/graphql/GetPatient.graphql @@ -6,6 +6,7 @@ query GetPatient($id: ID!) { birthdate sex state + description checksum assignedLocation { id diff --git a/web/api/graphql/GetPatients.graphql b/web/api/graphql/GetPatients.graphql index 9a789992..06fdc38b 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!]) { - patients(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states) { +query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $limit: Int, $offset: Int) { + patients(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states, limit: $limit, offset: $offset) { id name firstname diff --git a/web/api/graphql/GetTasks.graphql b/web/api/graphql/GetTasks.graphql index 7952b02e..579127c7 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) { - tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId) { +query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $limit: Int, $offset: Int) { + tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId, limit: $limit, offset: $offset) { id title description diff --git a/web/api/graphql/GetUser.graphql b/web/api/graphql/GetUser.graphql new file mode 100644 index 00000000..0fc3910a --- /dev/null +++ b/web/api/graphql/GetUser.graphql @@ -0,0 +1,13 @@ +query GetUser($id: ID!) { + user(id: $id) { + id + username + name + email + firstname + lastname + title + avatarUrl + } +} + diff --git a/web/api/graphql/GetUsers.graphql b/web/api/graphql/GetUsers.graphql index 3c62663c..c5dcf22c 100644 --- a/web/api/graphql/GetUsers.graphql +++ b/web/api/graphql/GetUsers.graphql @@ -3,5 +3,6 @@ query GetUsers { id name avatarUrl + isOnline } } diff --git a/web/api/graphql/GlobalData.graphql b/web/api/graphql/GlobalData.graphql index 319978f0..c12704eb 100644 --- a/web/api/graphql/GlobalData.graphql +++ b/web/api/graphql/GlobalData.graphql @@ -12,7 +12,7 @@ query GetGlobalData($rootLocationIds: [ID!]) { title kind } - tasks { + tasks(rootLocationIds: $rootLocationIds) { id done } diff --git a/web/api/graphql/Subscriptions.graphql b/web/api/graphql/Subscriptions.graphql index 4ecc7fec..6c74c69c 100644 --- a/web/api/graphql/Subscriptions.graphql +++ b/web/api/graphql/Subscriptions.graphql @@ -1,25 +1,25 @@ -subscription PatientCreated { - patientCreated +subscription PatientCreated($rootLocationIds: [ID!]) { + patientCreated(rootLocationIds: $rootLocationIds) } -subscription PatientUpdated($patientId: ID) { - patientUpdated(patientId: $patientId) +subscription PatientUpdated($patientId: ID, $rootLocationIds: [ID!]) { + patientUpdated(patientId: $patientId, rootLocationIds: $rootLocationIds) } -subscription PatientStateChanged($patientId: ID) { - patientStateChanged(patientId: $patientId) +subscription PatientStateChanged($patientId: ID, $rootLocationIds: [ID!]) { + patientStateChanged(patientId: $patientId, rootLocationIds: $rootLocationIds) } -subscription TaskCreated { - taskCreated +subscription TaskCreated($rootLocationIds: [ID!]) { + taskCreated(rootLocationIds: $rootLocationIds) } -subscription TaskUpdated($taskId: ID) { - taskUpdated(taskId: $taskId) +subscription TaskUpdated($taskId: ID, $rootLocationIds: [ID!]) { + taskUpdated(taskId: $taskId, rootLocationIds: $rootLocationIds) } -subscription TaskDeleted { - taskDeleted +subscription TaskDeleted($rootLocationIds: [ID!]) { + taskDeleted(rootLocationIds: $rootLocationIds) } subscription LocationNodeUpdated($locationId: ID) { diff --git a/web/components/AuditLogTimeline.tsx b/web/components/AuditLogTimeline.tsx new file mode 100644 index 00000000..a51907c1 --- /dev/null +++ b/web/components/AuditLogTimeline.tsx @@ -0,0 +1,206 @@ +import React, { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { SmartDate } from '@/utils/date' +import clsx from 'clsx' +import { fetcher } from '@/api/gql/fetcher' +import { UserInfoPopup } from '@/components/UserInfoPopup' + +const GET_AUDIT_LOGS_QUERY = ` + query GetAuditLogs($caseId: ID!, $limit: Int, $offset: Int) { + auditLogs(caseId: $caseId, limit: $limit, offset: $offset) { + caseId + activity + userId + timestamp + context + } + } +` + +export interface AuditLogEntry { + caseId: string, + activity: string, + userId: string | null, + timestamp: string, + context: string | null, +} + +interface AuditLogTimelineProps { + caseId: string, + className?: string, +} + +const GET_USER_QUERY = ` + query GetUser($id: ID!) { + user(id: $id) { + id + username + name + } + } +` + +interface UserInfo { + id: string, + username: string, + name: string, +} + +export const AuditLogTimeline: React.FC = ({ caseId, className }) => { + const [expandedEntries, setExpandedEntries] = useState>(new Set()) + const [selectedUserId, setSelectedUserId] = useState(null) + + const { data, isLoading } = useQuery({ + queryKey: ['GetAuditLogs', caseId], + queryFn: () => fetcher<{ auditLogs: AuditLogEntry[] }, { caseId: string }>( + GET_AUDIT_LOGS_QUERY, + { caseId } + )(), + enabled: !!caseId, + }) + + const auditLogs = useMemo(() => data?.auditLogs || [], [data?.auditLogs]) + + const uniqueUserIds = useMemo(() => { + return Array.from(new Set(auditLogs.map(log => log.userId).filter(Boolean) as string[])) + }, [auditLogs]) + + const usersQuery = useQuery({ + queryKey: ['GetUsers', uniqueUserIds], + queryFn: async () => { + const validUserIds = uniqueUserIds.filter((id): id is string => !!id) + const userPromises = validUserIds.map(userId => + fetcher<{ user: UserInfo | null }, { id: string }>( + GET_USER_QUERY, + { id: userId } + )()) + const results = await Promise.all(userPromises) + const userMap = new Map() + results.forEach((result, index) => { + if (result.user && validUserIds[index]) { + userMap.set(validUserIds[index], result.user) + } + }) + return userMap + }, + enabled: uniqueUserIds.length > 0, + }) + + const getUserName = (userId: string | null): string => { + if (!userId) return 'Unknown User' + const user = usersQuery.data?.get(userId) + return user?.name || user?.username || userId + } + + const toggleExpand = (index: number) => { + setExpandedEntries(prev => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + } + + const getActivityColor = (activity: string): string => { + if (activity.includes('create')) return 'bg-positive/20 text-positive border-positive/40' + if (activity.includes('update')) return 'bg-primary/20 text-primary border-primary/40' + if (activity.includes('delete')) return 'bg-negative/20 text-negative border-negative/40' + return 'bg-secondary/20 text-secondary border-secondary/40' + } + + const formatActivity = (activity: string): string => { + return activity + .replace(/_/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + } + + return ( +
+
+ Audit Log +
+ {isLoading && ( +
+ Loading... +
+ )} +
+ {auditLogs.map((entry: AuditLogEntry, index: number) => ( +
+
+
+
+
+
+ {formatActivity(entry.activity)} +
+ {entry.userId && ( + + )} +
+ +
+
+ {entry.context && (() => { + try { + const parsed = JSON.parse(entry.context) + return typeof parsed === 'object' && parsed !== null && Object.keys(parsed).length > 0 + } catch { + return entry.context.length > 0 + } + })() && ( + + )} +
+ {expandedEntries.has(index) && entry.context && (() => { + try { + const parsed = JSON.parse(entry.context) + return ( +
+
{JSON.stringify(parsed, null, 2)}
+
+ ) + } catch { + return ( +
+
{entry.context}
+
+ ) + } + })()} +
+
+ ))} + {!isLoading && auditLogs.length === 0 && ( +
+ No audit logs available +
+ )} +
+ setSelectedUserId(null)} + /> +
+ ) +} + diff --git a/web/components/AvatarComponent.tsx b/web/components/AvatarComponent.tsx new file mode 100644 index 00000000..7a2b3632 --- /dev/null +++ b/web/components/AvatarComponent.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { Avatar, type AvatarProps } from '@helpwave/hightide' +import clsx from 'clsx' + +interface AvatarStatusComponentProps extends AvatarProps { + isOnline?: boolean | null, +} + +export const AvatarStatusComponent: React.FC = ({ + isOnline, + className, + ...avatarProps +}) => { + const size = avatarProps.size || 'md' + const dotSizeClasses = { + sm: 'w-3 h-3', + md: 'w-3.5 h-3.5', + lg: 'w-4 h-4', + xl: 'w-5 h-5', + } + + const dotPositionClasses = { + sm: 'bottom-0 right-0', + md: 'bottom-0 right-0', + lg: 'bottom-0 right-0', + xl: 'bottom-0 right-0', + } + + const dotBorderClasses = { + sm: 'border-[1.5px]', + md: 'border-2', + lg: 'border-2', + xl: 'border-2', + } + + const showOnline = isOnline === true + + return ( +
+ +
+
+ ) +} + diff --git a/web/components/AvatarStatusComponent.tsx b/web/components/AvatarStatusComponent.tsx new file mode 100644 index 00000000..7a2b3632 --- /dev/null +++ b/web/components/AvatarStatusComponent.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { Avatar, type AvatarProps } from '@helpwave/hightide' +import clsx from 'clsx' + +interface AvatarStatusComponentProps extends AvatarProps { + isOnline?: boolean | null, +} + +export const AvatarStatusComponent: React.FC = ({ + isOnline, + className, + ...avatarProps +}) => { + const size = avatarProps.size || 'md' + const dotSizeClasses = { + sm: 'w-3 h-3', + md: 'w-3.5 h-3.5', + lg: 'w-4 h-4', + xl: 'w-5 h-5', + } + + const dotPositionClasses = { + sm: 'bottom-0 right-0', + md: 'bottom-0 right-0', + lg: 'bottom-0 right-0', + xl: 'bottom-0 right-0', + } + + const dotBorderClasses = { + sm: 'border-[1.5px]', + md: 'border-2', + lg: 'border-2', + xl: 'border-2', + } + + const showOnline = isOnline === true + + return ( +
+ +
+
+ ) +} + diff --git a/web/components/AvatarWithStatus.tsx b/web/components/AvatarWithStatus.tsx new file mode 100644 index 00000000..bba076d5 --- /dev/null +++ b/web/components/AvatarWithStatus.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Avatar, type AvatarProps } from '@helpwave/hightide' +import { OnlineStatusIndicator } from './OnlineStatusIndicator' +import clsx from 'clsx' + +interface AvatarWithStatusProps extends AvatarProps { + isOnline?: boolean | null, + showStatus?: boolean, +} + +export const AvatarWithStatus: React.FC = ({ + isOnline, + showStatus = true, + className, + ...avatarProps +}) => { + const size = avatarProps.size || 'md' + const indicatorSize = size === 'sm' ? 'sm' : size === 'lg' ? 'lg' : 'md' + const positionOffset = size === 'sm' ? 'bottom-0 right-0' : size === 'lg' ? 'bottom-0.5 right-0.5' : 'bottom-0 right-0' + + return ( +
+ + {showStatus && ( +
+ +
+ )} +
+ ) +} + diff --git a/web/components/ConflictResolutionDialog.tsx b/web/components/ConflictResolutionDialog.tsx index bf3db692..06f55649 100644 --- a/web/components/ConflictResolutionDialog.tsx +++ b/web/components/ConflictResolutionDialog.tsx @@ -1,20 +1,60 @@ +import React, { useState } from 'react' import { Dialog, Button } from '@helpwave/hightide' import { useTasksTranslation } from '@/i18n/useTasksTranslation' +export type ConflictResolutionChoice = 'keep-local' | 'use-server' | 'merge' + +interface ConflictField { + field: string, + localValue: unknown, + serverValue: unknown, +} + interface ConflictResolutionDialogProps { isOpen: boolean, onClose: () => void, - onResolve: () => void, + onResolve: (choice: ConflictResolutionChoice) => void, message: string, + fields?: ConflictField[], + localData?: Record, + serverData?: Record, } export const ConflictResolutionDialog = ({ isOpen, onClose, onResolve, - message + message, + fields = [], + localData, + serverData, }: ConflictResolutionDialogProps) => { const translation = useTasksTranslation() + const [selectedChoice, setSelectedChoice] = useState(null) + + const handleResolve = (choice: ConflictResolutionChoice) => { + setSelectedChoice(choice) + onResolve(choice) + } + + const formatValue = (value: unknown): string => { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (typeof value === 'object') { + return JSON.stringify(value, null, 2) + } + return String(value) + } + + const detectedFields = fields.length > 0 + ? fields + : localData && serverData + ? Object.keys({ ...localData, ...serverData }).map(field => ({ + field, + localValue: localData[field], + serverValue: serverData[field], + })).filter(f => JSON.stringify(f.localValue) !== JSON.stringify(f.serverValue)) + : [] return ( -
- - +
+ {detectedFields.length > 0 && ( +
+
+ Conflicting Fields: +
+
+ {detectedFields.map((field, index) => ( +
+
{field.field}
+
+
+
+ Your Changes: +
+
+ {formatValue(field.localValue)} +
+
+
+
+ Server Value: +
+
+ {formatValue(field.serverValue)} +
+
+
+
+ ))} +
+
+ )} + +
+ + + + +
) diff --git a/web/components/ErrorDialog.tsx b/web/components/ErrorDialog.tsx new file mode 100644 index 00000000..110dc2d0 --- /dev/null +++ b/web/components/ErrorDialog.tsx @@ -0,0 +1,37 @@ +import { Dialog, Button } from '@helpwave/hightide' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' + +interface ErrorDialogProps { + isOpen: boolean, + onClose: () => void, + message?: string, + title?: string, +} + +export const ErrorDialog = ({ + isOpen, + onClose, + message, + title, +}: ErrorDialogProps) => { + const translation = useTasksTranslation() + + return ( + +
+ +
+
+ ) +} + diff --git a/web/components/FeedbackDialog.tsx b/web/components/FeedbackDialog.tsx index 2ab168c9..b48566d5 100644 --- a/web/components/FeedbackDialog.tsx +++ b/web/components/FeedbackDialog.tsx @@ -99,12 +99,10 @@ export const FeedbackDialog = ({ isOpen, onClose, hideUrl = false }: FeedbackDia return } if (event.error === 'aborted' || event.error === 'network') { - console.error('Speech recognition error:', event.error) isRecordingRef.current = false setIsRecording(false) return } - console.error('Speech recognition error:', event.error) } recognition.onend = () => { @@ -190,11 +188,9 @@ export const FeedbackDialog = ({ isOpen, onClose, hideUrl = false }: FeedbackDia setFeedback('') setIsAnonymous(false) onClose() - } else { - console.error('Failed to submit feedback') } - } catch (error) { - console.error('Error submitting feedback:', error) + } catch { + void 0 } } diff --git a/web/components/Notifications.tsx b/web/components/Notifications.tsx index 97b49b2e..76e86361 100644 --- a/web/components/Notifications.tsx +++ b/web/components/Notifications.tsx @@ -24,7 +24,6 @@ export const Notifications = () => { const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) const { data, refetch } = useGetOverviewDataQuery(undefined, { - refetchInterval: 10000, refetchOnWindowFocus: true, }) diff --git a/web/components/OnlineStatusIndicator.tsx b/web/components/OnlineStatusIndicator.tsx new file mode 100644 index 00000000..b7a0b088 --- /dev/null +++ b/web/components/OnlineStatusIndicator.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import clsx from 'clsx' + +interface OnlineStatusIndicatorProps { + isOnline: boolean | null | undefined, + size?: 'sm' | 'md' | 'lg', + className?: string, +} + +export const OnlineStatusIndicator: React.FC = ({ + isOnline, + size = 'md', + className, +}) => { + const sizeClasses = { + sm: 'w-2 h-2', + md: 'w-2.5 h-2.5', + lg: 'w-3 h-3', + } + + const borderClasses = { + sm: 'border-[1.5px]', + md: 'border-2', + lg: 'border-2', + } + + if (isOnline === null || isOnline === undefined) { + return null + } + + return ( +
+ ) +} + diff --git a/web/components/PropertyEntry.tsx b/web/components/PropertyEntry.tsx index bcda1a7b..3d45d325 100644 --- a/web/components/PropertyEntry.tsx +++ b/web/components/PropertyEntry.tsx @@ -50,10 +50,12 @@ export const PropertyEntry = ({ case 'text': return ( onChange({ ...value, textValue })} - onEditComplete={textValue => onEditComplete({ ...value, textValue })} + name={commonProps.name} + readOnly={commonProps.readOnly} + value={value.textValue ?? ''} + onChange={textValue => onChange({ ...value, textValue: textValue ?? '' })} + onEditComplete={textValue => onEditComplete({ ...value, textValue: textValue ?? '' })} + onRemove={onRemove} /> ) case 'number': @@ -131,7 +133,7 @@ export const PropertyEntry = ({ ) default: - console.error(`Unimplemented property type used for PropertyEntry: ${fieldType}`) + return <> } } diff --git a/web/components/PropertyList.tsx b/web/components/PropertyList.tsx index c575952e..ef0ca66c 100644 --- a/web/components/PropertyList.tsx +++ b/web/components/PropertyList.tsx @@ -1,6 +1,6 @@ import { LoadingAndErrorComponent, LoadingAnimation, Menu, MenuItem, ConfirmDialog, Button } from '@helpwave/hightide' import { Plus } from 'lucide-react' -import React, { useMemo, useState, useEffect, useRef } from 'react' +import React, { useMemo, useState, useEffect } from 'react' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { PropertyEntry } from '@/components/PropertyEntry' import { useGetPropertyDefinitionsQuery, FieldType, PropertyEntity } from '@/api/gql/generated' @@ -51,151 +51,6 @@ export type AttachedProperty = { value: PropertyValue, } -export const exampleProperties: Property[] = [ - { - id: 'p1', - subjectType: 'patient', - fieldType: 'text', - name: 'Allergies', - isArchived: false, - }, - { - id: 'p2', - subjectType: 'patient', - fieldType: 'number', - name: 'Height (cm)', - isArchived: false, - }, - { - id: 'p3', - subjectType: 'patient', - fieldType: 'number', - name: 'Weight (kg)', - isArchived: false, - }, - { - id: 'p4', - subjectType: 'patient', - fieldType: 'checkbox', - name: 'Smoker', - isArchived: false, - }, - { - id: 'p5', - subjectType: 'patient', - fieldType: 'date', - name: 'Date of Admission', - isArchived: false, - }, - { - id: 'p6', - subjectType: 'patient', - fieldType: 'dateTime', - name: 'Last Checkup', - isArchived: false, - }, - { - id: 'p7', - subjectType: 'patient', - fieldType: 'singleSelect', - name: 'Blood Type', - isArchived: false, - selectData: { - isAllowingFreetext: false, - options: [ - { id: 'bt-a', name: 'A', isCustom: false }, - { id: 'bt-b', name: 'B', isCustom: false }, - { id: 'bt-ab', name: 'AB', isCustom: false }, - { id: 'bt-o', name: 'O', isCustom: false }, - ], - }, - }, - { - id: 'p8', - subjectType: 'patient', - fieldType: 'multiSelect', - name: 'Chronic Conditions', - isArchived: false, - selectData: { - isAllowingFreetext: true, - options: [ - { id: 'cc-diabetes', name: 'Diabetes', isCustom: false }, - { id: 'cc-asthma', name: 'Asthma', isCustom: false }, - ], - }, - }, - { - id: 'p9', - subjectType: 'patient', - fieldType: 'text', - name: 'Primary Physician', - isArchived: false, - }, - { - id: 'p10', - subjectType: 'patient', - fieldType: 'checkbox', - name: 'Requires Isolation', - isArchived: false, - }, - { - id: 'p11', - subjectType: 'patient', - fieldType: 'text', - name: 'Insurance Number', - isArchived: false, - }, -] - -export const exampleAttachedProperties: AttachedProperty[] = [ - { - property: exampleProperties[0]!, - subjectId: 'patient-1', - value: { textValue: 'Penicillin' }, - }, - { - property: exampleProperties[1]!, - subjectId: 'patient-1', - value: { numberValue: 175 }, - }, - { - property: exampleProperties[2]!, - subjectId: 'patient-1', - value: { numberValue: 72 }, - }, - { - property: exampleProperties[3]!, - subjectId: 'patient-1', - value: { boolValue: false }, - }, - { - property: exampleProperties[4]!, - subjectId: 'patient-1', - value: { dateValue: new Date('2025-01-10') }, - }, - { - property: exampleProperties[5]!, - subjectId: 'patient-1', - value: { dateTimeValue: new Date('2025-12-01T09:30:00Z') }, - }, - { - property: exampleProperties[6]!, - subjectId: 'patient-1', - value: { singleSelectValue: 'bt-o' }, - }, - { - property: exampleProperties[7]!, - subjectId: 'patient-1', - value: { multiSelectValue: ['cc-diabetes'] }, - }, - { - property: exampleProperties[8]!, - subjectId: 'patient-1', - value: { textValue: 'Dr. Smith' }, - }, -] - - export type PropertyListProps = { subjectId: string, subjectType: PropertySubjectType, @@ -217,7 +72,7 @@ export type PropertyListProps = { selectValue?: string | null, multiSelectValues?: string[] | null, }>, - onPropertyValueChange?: (definitionId: string, value: PropertyValue) => void, + onPropertyValueChange?: (definitionId: string, value: PropertyValue | null) => void, fullWidthAddButton?: boolean, } @@ -247,6 +102,10 @@ export const PropertyList = ({ fullWidthAddButton = false, }: PropertyListProps) => { const translation = useTasksTranslation() + const [localPropertyValues, setLocalPropertyValues] = useState>(new Map()) + const [propertyToRemove, setPropertyToRemove] = useState(null) + const [removedPropertyIds, setRemovedPropertyIds] = useState>(new Set()) + const [pendingAdditions, setPendingAdditions] = useState>(new Map()) const { data: propertyDefinitionsData, isLoading: isLoadingDefinitions, isError: isErrorDefinitions } = useGetPropertyDefinitionsQuery() @@ -293,7 +152,7 @@ export const PropertyList = ({ id: def.id, name: def.name, description: def.description || undefined, - subjectType: mapSubjectTypeFromBackend(def.allowedEntities[0] as PropertyEntity || PropertyEntity.Patient), + subjectType: mapSubjectTypeFromBackend((def.allowedEntities && def.allowedEntities[0] ? def.allowedEntities[0] as PropertyEntity : PropertyEntity.Patient)), fieldType: mapFieldTypeFromBackend(def.fieldType as FieldType), isArchived: !def.isActive, selectData: (def.fieldType === FieldType.FieldTypeSelect || def.fieldType === FieldType.FieldTypeMultiSelect) && def.options.length > 0 ? { @@ -308,7 +167,7 @@ export const PropertyList = ({ } const value: PropertyValue = { - textValue: pv.textValue || undefined, + textValue: pv.textValue ?? undefined, numberValue: pv.numberValue || undefined, boolValue: pv.booleanValue || undefined, dateValue: pv.dateValue ? (() => { @@ -331,53 +190,131 @@ export const PropertyList = ({ }).sort((a, b) => a.property.name.localeCompare(b.property.name)) }, [propertyValues, subjectId]) - const [localPropertyValues, setLocalPropertyValues] = useState>(new Map()) - const [propertyToRemove, setPropertyToRemove] = useState(null) - const previousAttachedPropertiesRef = useRef>(new Map()) - useEffect(() => { + const currentPropertyIds = new Set(attachedProperties.map(ap => ap.property.id)) + setRemovedPropertyIds(prev => { + const next = new Set(prev) + prev.forEach(id => { + if (!currentPropertyIds.has(id)) { + next.delete(id) + } + }) + return next + }) + setPendingAdditions(prev => { + const next = new Map(prev) + prev.forEach((_, id) => { + if (currentPropertyIds.has(id)) { + next.delete(id) + } + }) + return next + }) + }, [attachedProperties]) + + const displayedProperties = useMemo(() => { + const attachedPropertyIds = new Set(attachedProperties.map(ap => ap.property.id)) + const attached = attachedProperties + .filter(ap => !removedPropertyIds.has(ap.property.id)) + .map(ap => { + const localValue = localPropertyValues.get(ap.property.id) + return { + ...ap, + value: localValue || ap.value, + } + }) + + const pending = Array.from(pendingAdditions.values()) + .filter(pa => !removedPropertyIds.has(pa.property.id) && !attachedPropertyIds.has(pa.property.id)) + .map(pa => ({ + property: pa.property, + subjectId, + value: localPropertyValues.get(pa.property.id) || pa.value, + })) + + const allProperties = [...attached, ...pending] + const uniqueProperties = Array.from( + new Map(allProperties.map(prop => [prop.property.id, prop])).values() + ) + return uniqueProperties.sort((a, b) => a.property.name.localeCompare(b.property.name)) + }, [attachedProperties, localPropertyValues, removedPropertyIds, pendingAdditions, subjectId]) + + const handlePropertyChange = (propertyId: string, value: PropertyValue) => { setLocalPropertyValues(prev => { const newMap = new Map(prev) - const currentAttachedMap = new Map() + newMap.set(propertyId, value) + return newMap + }) - attachedProperties.forEach(ap => { - currentAttachedMap.set(ap.property.id, ap.value) - }) + if (onPropertyValueChange) { + onPropertyValueChange(propertyId, value) + } + } - currentAttachedMap.forEach((attachedValue, propertyId) => { - const localValue = newMap.get(propertyId) - const previousAttachedValue = previousAttachedPropertiesRef.current.get(propertyId) + const handlePropertyRemove = (propertyId: string) => { + setRemovedPropertyIds(prev => new Set(prev).add(propertyId)) + setLocalPropertyValues(prev => { + const newMap = new Map(prev) + newMap.delete(propertyId) + return newMap + }) - if (!localValue) { - newMap.set(propertyId, attachedValue) - } else if (previousAttachedValue) { - const localMatchesPrevious = JSON.stringify(localValue) === JSON.stringify(previousAttachedValue) - if (localMatchesPrevious) { - newMap.set(propertyId, attachedValue) + if (onPropertyValueChange) { + onPropertyValueChange(propertyId, null) + } + setPropertyToRemove(null) + } + + const handleRemoveConfirm = () => { + if (propertyToRemove) { + handlePropertyRemove(propertyToRemove) + } + } + + const handleAddProperty = (property: Property) => { + const getDefaultValue = (): PropertyValue => { + switch (property.fieldType) { + case 'text': + return { textValue: '' } + case 'number': + return { numberValue: undefined } + case 'checkbox': + return { boolValue: false } + case 'date': + case 'dateTime': + return {} + case 'singleSelect': + return property.selectData?.options && property.selectData.options.length > 0 + ? { singleSelectValue: property.selectData.options[0]?.id || undefined } + : {} + case 'multiSelect': + return { multiSelectValue: [] } + default: + return {} } } - }) - previousAttachedPropertiesRef.current = currentAttachedMap - return newMap + const defaultValue = getDefaultValue() + setRemovedPropertyIds(prev => { + const next = new Set(prev) + next.delete(property.id) + return next }) - }, [attachedProperties]) + setPendingAdditions(prev => { + const next = new Map(prev) + next.set(property.id, { property, value: defaultValue }) + return next + }) + handlePropertyChange(property.id, defaultValue) + } const isLoading = isLoadingDefinitions const isError = isErrorDefinitions - const handleRemoveConfirm = () => { - if (propertyToRemove && onPropertyValueChange) { - const emptyValue: PropertyValue = {} - onPropertyValueChange(propertyToRemove, emptyValue) - setLocalPropertyValues(prev => { - const newMap = new Map(prev) - newMap.delete(propertyToRemove) - return newMap - }) - } - setPropertyToRemove(null) - } + const unattachedProperties = availableProperties.filter(prop => + !attachedProperties.some(attached => attached.property.id === prop.id) && + !pendingAdditions.has(prop.id)) + const hasUnattachedProperties = unattachedProperties.length > 0 return (
- {attachedProperties.map((attachedProperty, index) => { - const localValue = localPropertyValues.get(attachedProperty.property.id) || attachedProperty.value - - return ( + {displayedProperties.map((attachedProperty) => ( { - if (onPropertyValueChange) { - onPropertyValueChange(attachedProperty.property.id, value) - } + onEditComplete={value => { + handlePropertyChange(attachedProperty.property.id, value) }} onRemove={() => { setPropertyToRemove(attachedProperty.property.id) }} /> - ) - })} + ))} {fullWidthAddButton && (
- {availableProperties.length > 0 ? ( + {hasUnattachedProperties ? ( trigger={({ toggleOpen }, ref) => (
)} - {!fullWidthAddButton && ( - availableProperties.length > 0 && ( + {!fullWidthAddButton && hasUnattachedProperties && ( trigger={({ toggleOpen }, ref) => (
} > - {availableProperties - .filter(prop => !attachedProperties.some(attached => attached.property.id === prop.id)) - .map(property => { - const getDefaultValue = (): PropertyValue => { - switch (property.fieldType) { - case 'text': - return { textValue: '' } - case 'number': - return { numberValue: undefined } - case 'checkbox': - return { boolValue: false } - case 'date': - case 'dateTime': - return {} - case 'singleSelect': - return property.selectData?.options && property.selectData.options.length > 0 - ? { singleSelectValue: property.selectData.options[0]?.id || undefined } - : {} - case 'multiSelect': - return { multiSelectValue: [] } - default: - return {} - } - } - - return ( + {unattachedProperties.map(property => ( { - if (onPropertyValueChange) { - const defaultValue = getDefaultValue() - onPropertyValueChange(property.id, defaultValue) - } + handleAddProperty(property) close() }} className="rounded-md cursor-pointer" > {property.name} - ) - })} + ))} )} - ) )}
void, +} + +export const UserInfoPopup: React.FC = ({ userId, isOpen, onClose }) => { + const { data, isLoading } = useQuery({ + queryKey: ['GetUser', userId], + queryFn: () => fetcher<{ user: UserInfo | null }, { id: string }>( + GET_USER_QUERY, + { id: userId! } + )(), + enabled: isOpen && !!userId, + }) + + const user = data?.user + + return ( + + {isLoading ? ( + + ) : user ? ( +
+ {user.avatarUrl && ( +
+ {user.name} +
+ )} +
+
{user.name}
+ {user.title && ( +
{user.title}
+ )} + {user.username && ( +
@{user.username}
+ )} +
+ {user.email && ( +
+
Email
+
{user.email}
+
+ )} + {(user.firstname || user.lastname) && ( +
+
Full Name
+
+ {[user.firstname, user.lastname].filter(Boolean).join(' ') || 'N/A'} +
+
+ )} +
+ ) : ( +
User not found
+ )} +
+ +
+
+ ) +} diff --git a/web/components/layout/Page.tsx b/web/components/layout/Page.tsx index ac82ee92..cde3d8ff 100644 --- a/web/components/layout/Page.tsx +++ b/web/components/layout/Page.tsx @@ -15,6 +15,7 @@ import { } from '@helpwave/hightide' import { getConfig } from '@/utils/config' import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { UserInfoPopup } from '@/components/UserInfoPopup' import clsx from 'clsx' import { Building2, @@ -154,7 +155,7 @@ export const SurveyModal = () => { } } - setupSurvey().catch(console.error) + setupSurvey().catch(() => {}) }, [config.onboardingSurveyUrl, config.weeklySurveyUrl, user?.id, onboardingSurveyCompleted, weeklySurveyLastCompleted, surveyLastDismissed, isSurveyOpen]) const handleDismiss = () => { @@ -214,7 +215,6 @@ const RootLocationSelector = ({ className, onSelect }: RootLocationSelectorProps {}, { enabled: !!selectedRootLocationIds && selectedRootLocationIds.length > 0, - refetchInterval: 30000, refetchOnWindowFocus: true, } ) @@ -318,6 +318,7 @@ export const Header = ({ onMenuClick, isMenuOpen, ...props }: HeaderProps) => { const router = useRouter() const { user } = useTasksContext() const [isFeedbackOpen, setIsFeedbackOpen] = useState(false) + const [selectedUserId, setSelectedUserId] = useState(null) return ( <> @@ -354,7 +355,10 @@ export const Header = ({ onMenuClick, isMenuOpen, ...props }: HeaderProps) => {
-
+
+
setIsFeedbackOpen(false)} /> + setSelectedUserId(null)} + /> ) } @@ -393,7 +402,6 @@ const SidebarLink = ({ children, ...props }: SidebarLinkProps) => { ) } - type SidebarProps = HTMLAttributes & { isOpen?: boolean, onClose?: () => void, diff --git a/web/components/layout/SidePanel.tsx b/web/components/layout/SidePanel.tsx index 239ad81c..7adbe085 100644 --- a/web/components/layout/SidePanel.tsx +++ b/web/components/layout/SidePanel.tsx @@ -26,7 +26,6 @@ export const SidePanel = ({ isOpen, onClose, title, children }: SidePanelProps) } const zIndex = useZIndexRegister(isOpen) - useFocusTrap({ active: isOpen, container: ref, diff --git a/web/components/locations/LocationSelectionDialog.tsx b/web/components/locations/LocationSelectionDialog.tsx index 114630de..fb0a7a60 100644 --- a/web/components/locations/LocationSelectionDialog.tsx +++ b/web/components/locations/LocationSelectionDialog.tsx @@ -184,7 +184,6 @@ export const LocationSelectionDialog = ({ {}, { enabled: isOpen, - refetchInterval: 15000, refetchOnWindowFocus: true, } ) @@ -195,7 +194,6 @@ export const LocationSelectionDialog = ({ const hasInitialized = useRef(false) - useEffect(() => { if (isOpen) { setSelectedIds(new Set(initialSelectedIds)) @@ -354,7 +352,6 @@ export const LocationSelectionDialog = ({ newSet.clear() } - newSet.add(node.id) } else { newSet.delete(node.id) diff --git a/web/components/patients/PatientDetailView.tsx b/web/components/patients/PatientDetailView.tsx index e6bae779..6e2e1430 100644 --- a/web/components/patients/PatientDetailView.tsx +++ b/web/components/patients/PatientDetailView.tsx @@ -1,23 +1,21 @@ import { useEffect, useState, useMemo } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import type { CreatePatientInput, LocationNodeType, UpdatePatientInput } from '@/api/gql/generated' import { PatientState, PropertyEntity, Sex, - useAdmitPatientMutation, - useCompleteTaskMutation, + type FieldType, useCreatePatientMutation, useDeletePatientMutation, - useDischargePatientMutation, useGetPatientQuery, - useGetPropertyDefinitionsQuery, - useMarkPatientDeadMutation, - useReopenTaskMutation, - useUpdatePatientMutation, - useWaitPatientMutation + useGetPropertyDefinitionsQuery } from '@/api/gql/generated' -import { useQueryClient } from '@tanstack/react-query' +import { useAtomicMutation } from '@/hooks/useAtomicMutation' +import { useSafeMutation } from '@/hooks/useSafeMutation' +import { fetcher } from '@/api/gql/fetcher' +import { UpdatePatientDocument, type UpdatePatientMutation, type UpdatePatientMutationVariables, AdmitPatientDocument, DischargePatientDocument, WaitPatientDocument, MarkPatientDeadDocument, type AdmitPatientMutation, type DischargePatientMutation, type WaitPatientMutation, type MarkPatientDeadMutation, type GetPatientQuery, type GetPatientsQuery, CompleteTaskDocument, ReopenTaskDocument, type CompleteTaskMutation, type ReopenTaskMutation, type CompleteTaskMutationVariables, type ReopenTaskMutationVariables } from '@/api/gql/generated' import { Button, Checkbox, @@ -32,11 +30,13 @@ import { SelectOption, Tab, TabView, + Textarea, Tooltip } from '@helpwave/hightide' import { useTasksContext } from '@/hooks/useTasksContext' import { CheckCircle2, ChevronDown, Circle, PlusIcon, XIcon, Building2, Locate, Users } from 'lucide-react' import { PatientStateChip } from '@/components/patients/PatientStateChip' +import { AuditLogTimeline } from '@/components/AuditLogTimeline' import { LocationChips } from '@/components/patients/LocationChips' import { LocationSelectionDialog } from '@/components/locations/LocationSelectionDialog' import clsx from 'clsx' @@ -47,6 +47,7 @@ import { formatLocationPath, formatLocationPathFromId } from '@/utils/location' import { useGetLocationsQuery } from '@/api/gql/generated' import type { PropertyValueInput } from '@/api/gql/generated' import { PropertyList } from '@/components/PropertyList' +import { ErrorDialog } from '@/components/ErrorDialog' type ExtendedCreatePatientInput = CreatePatientInput & { clinicId?: string, @@ -54,12 +55,6 @@ type ExtendedCreatePatientInput = CreatePatientInput & { teamIds?: string[], } -type ExtendedUpdatePatientInput = UpdatePatientInput & { - clinicId?: string, - positionId?: string, - teamIds?: string[], -} - const toISODate = (d: Date | string | null | undefined): string | null => { if (!d) return null const date = typeof d === 'string' ? new Date(d) : d @@ -89,7 +84,6 @@ const getDefaultBirthdate = () => { return toISODate(d) } - interface PatientDetailViewProps { patientId?: string, onClose: () => void, @@ -104,19 +98,17 @@ export const PatientDetailView = ({ initialCreateData = {} }: PatientDetailViewProps) => { const translation = useTasksTranslation() + const queryClient = useQueryClient() const { selectedLocationId, selectedRootLocationIds, rootLocations } = useTasksContext() const firstSelectedRootLocationId = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds[0] : undefined - const queryClient = useQueryClient() const [taskId, setTaskId] = useState(null) const [isCreatingTask, setIsCreatingTask] = useState(false) const isEditMode = !!patientId - const { data: patientData, isLoading: isLoadingPatient, refetch } = useGetPatientQuery( + const { data: patientData, isLoading: isLoadingPatient } = useGetPatientQuery( { id: patientId! }, { enabled: isEditMode, - refetchInterval: 3000, - refetchOnWindowFocus: true, refetchOnMount: true, } ) @@ -124,7 +116,6 @@ export const PatientDetailView = ({ const { data: locationsData } = useGetLocationsQuery( undefined, { - refetchInterval: 10000, refetchOnWindowFocus: true, } ) @@ -147,8 +138,63 @@ export const PatientDetailView = ({ return map }, [locationsData]) - const { mutate: completeTask } = useCompleteTaskMutation({ onSuccess: () => refetch() }) - const { mutate: reopenTask } = useReopenTaskMutation({ onSuccess: () => refetch() }) + const { mutate: completeTask } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(CompleteTaskDocument, variables)() + }, + queryKey: ['GetPatient', { id: patientId }], + optimisticUpdate: (variables) => [ + { + queryKey: ['GetPatient', { id: patientId }], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientQuery | undefined + if (!data?.patient) return oldData + return { + ...data, + patient: { + ...data.patient, + tasks: data.patient.tasks?.map(task => ( + task.id === variables.id ? { ...task, done: true } : task + )) || [] + } + } + } + } + ], + invalidateQueries: [['GetPatient', { id: patientId }], ['GetTasks'], ['GetPatients'], ['GetOverviewData'], ['GetGlobalData']], + onSuccess: () => { + onSuccess() + }, + }) + + const { mutate: reopenTask } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(ReopenTaskDocument, variables)() + }, + queryKey: ['GetPatient', { id: patientId }], + optimisticUpdate: (variables) => [ + { + queryKey: ['GetPatient', { id: patientId }], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientQuery | undefined + if (!data?.patient) return oldData + return { + ...data, + patient: { + ...data.patient, + tasks: data.patient.tasks?.map(task => ( + task.id === variables.id ? { ...task, done: false } : task + )) || [] + } + } + } + } + ], + invalidateQueries: [['GetPatient', { id: patientId }], ['GetTasks'], ['GetPatients'], ['GetOverviewData'], ['GetGlobalData']], + onSuccess: () => { + onSuccess() + }, + }) const [formData, setFormData] = useState({ firstname: '', @@ -158,6 +204,7 @@ export const PatientDetailView = ({ birthdate: getDefaultBirthdate(), state: PatientState.Wait, clinicId: undefined, + description: null, ...initialCreateData, } as CreatePatientInput & { clinicId?: string, positionId?: string, teamIds?: string[] }) const [isWaiting, setIsWaiting] = useState(false) @@ -170,8 +217,7 @@ export const PatientDetailView = ({ const [isMarkDeadDialogOpen, setIsMarkDeadDialogOpen] = useState(false) const [isDischargeDialogOpen, setIsDischargeDialogOpen] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [isLocationChangeConfirmOpen, setIsLocationChangeConfirmOpen] = useState(false) - const [pendingLocationUpdate, setPendingLocationUpdate] = useState<(() => void) | null>(null) + const [errorDialog, setErrorDialog] = useState<{ isOpen: boolean, message?: string }>({ isOpen: false }) const [firstnameError, setFirstnameError] = useState(null) const [lastnameError, setLastnameError] = useState(null) @@ -182,7 +228,7 @@ export const PatientDetailView = ({ useEffect(() => { if (patientData?.patient) { const patient = patientData.patient - const { firstname, lastname, sex, birthdate, assignedLocations, clinic, position, teams } = patient + const { firstname, lastname, sex, birthdate, assignedLocations, clinic, position, teams, description } = patient setFormData(prev => ({ ...prev, firstname, @@ -193,6 +239,7 @@ export const PatientDetailView = ({ clinicId: clinic?.id || undefined, positionId: position?.id || undefined, teamIds: teams?.map(t => t.id) || undefined, + description: description || null, } as CreatePatientInput & { clinicId?: string, positionId?: string, teamIds?: string[] })) setSelectedClinic(clinic ? (clinic as LocationNodeType) : null) setSelectedPosition(position ? (position as LocationNodeType) : null) @@ -229,81 +276,399 @@ export const PatientDetailView = ({ const { mutate: createPatient, isLoading: isCreating } = useCreatePatientMutation({ onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() onClose() - } + }, + onError: (error) => { + setErrorDialog({ isOpen: true, message: error instanceof Error ? error.message : 'Failed to create patient' }) + }, }) - const { mutate: updatePatient } = useUpdatePatientMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) - onSuccess() - refetch() - } + const { updateField, flush } = useAtomicMutation({ + mutationFn: async (variables) => { + return fetcher( + UpdatePatientDocument, + variables + )() + }, + queryKey: ['GetPatient', { id: patientId }], + timeoutMs: 3000, + immediateFields: ['clinicId', 'positionId', 'teamIds', 'properties'] as unknown as (keyof { id: string, data: UpdatePatientInput })[], + onChangeFields: ['sex'] as unknown as (keyof { id: string, data: UpdatePatientInput })[], + onBlurFields: ['firstname', 'lastname', 'description', 'birthdate'] as unknown as (keyof { id: string, data: UpdatePatientInput })[], + onCloseFields: ['birthdate'] as unknown as (keyof { id: string, data: UpdatePatientInput })[], + getChecksum: (data) => data?.updatePatient?.checksum || null, + invalidateQueries: [ + ['GetPatient', { id: patientId }], + ['GetPatients'], + ['GetOverviewData'], + ['GetGlobalData'] + ], + optimisticUpdate: (variables) => { + const updates: Array<{ queryKey: unknown[], updateFn: (oldData: unknown) => unknown }> = [] + type PatientType = NonNullable>>['patient']> + + const updatePatientInQuery = (patient: PatientType, updateData: Partial) => { + if (!patient) return patient + + const updated: typeof patient = { ...patient } + + if (updateData.firstname !== undefined) { + updated.firstname = updateData.firstname || '' + } + if (updateData.lastname !== undefined) { + updated.lastname = updateData.lastname || '' + } + if (updateData.sex !== undefined && updateData.sex !== null) { + updated.sex = updateData.sex + } + if (updateData.birthdate !== undefined) { + updated.birthdate = updateData.birthdate || null + } + if (updateData.description !== undefined) { + updated.description = updateData.description + } + if (updateData.clinicId !== undefined) { + if (updateData.clinicId === null || updateData.clinicId === undefined) { + updated.clinic = null as unknown as typeof patient.clinic + } else { + const clinicLocation = locationsData?.locationNodes?.find(loc => loc.id === updateData.clinicId) + if (clinicLocation) { + updated.clinic = { + ...clinicLocation, + __typename: 'LocationNodeType' as const, + } as typeof patient.clinic + } + } + } + if (updateData.positionId !== undefined) { + if (updateData.positionId === null) { + updated.position = null as typeof patient.position + } else { + const positionLocation = locationsData?.locationNodes?.find(loc => loc.id === updateData.positionId) + if (positionLocation) { + updated.position = { + ...positionLocation, + __typename: 'LocationNodeType' as const, + } as typeof patient.position + } + } + } + if (updateData.teamIds !== undefined) { + const teamLocations = locationsData?.locationNodes?.filter(loc => updateData.teamIds?.includes(loc.id)) || [] + updated.teams = teamLocations.map(team => ({ + ...team, + __typename: 'LocationNodeType' as const, + })) as typeof patient.teams + } + if (updateData.properties !== undefined && updateData.properties !== null) { + const propertyMap = new Map(updateData.properties.map(p => [p.definitionId, p])) + const existingPropertyIds = new Set( + patient.properties?.map(p => p.definition?.id).filter(Boolean) || [] + ) + const newPropertyIds = new Set(updateData.properties.map(p => p.definitionId)) + + const existingProperties = patient.properties + ? patient.properties + .filter(p => newPropertyIds.has(p.definition?.id)) + .map(p => { + const newProp = propertyMap.get(p.definition?.id) + if (!newProp) return p + return { + ...p, + textValue: newProp.textValue ?? p.textValue, + numberValue: newProp.numberValue ?? p.numberValue, + booleanValue: newProp.booleanValue ?? p.booleanValue, + dateValue: newProp.dateValue ?? p.dateValue, + dateTimeValue: newProp.dateTimeValue ?? p.dateTimeValue, + selectValue: newProp.selectValue ?? p.selectValue, + multiSelectValues: newProp.multiSelectValues ?? p.multiSelectValues, + } + }) + : [] + const newProperties = updateData.properties + .filter(p => !existingPropertyIds.has(p.definitionId)) + .map(p => { + const existingProperty = patient?.properties?.find(ep => ep.definition?.id === p.definitionId) + return { + __typename: 'PropertyValueType' as const, + definition: existingProperty?.definition || { + __typename: 'PropertyDefinitionType' as const, + id: p.definitionId, + name: '', + description: null, + fieldType: 'TEXT' as FieldType, + isActive: true, + allowedEntities: [], + options: [], + }, + textValue: p.textValue, + numberValue: p.numberValue, + booleanValue: p.booleanValue, + dateValue: p.dateValue, + dateTimeValue: p.dateTimeValue, + selectValue: p.selectValue, + multiSelectValues: p.multiSelectValues, + } + }) + updated.properties = [...existingProperties, ...newProperties] + } + + return updated + } + + updates.push({ + queryKey: ['GetPatient', { id: patientId }], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientQuery | undefined + if (!data?.patient) return oldData + const updatedPatient = updatePatientInQuery(data.patient, variables.data || {}) + return { + ...data, + patient: updatedPatient + } + } + }) + + const allGetPatientsQueries = queryClient.getQueryCache().getAll() + .filter(query => { + const key = query.queryKey + return Array.isArray(key) && key[0] === 'GetPatients' + }) + + for (const query of allGetPatientsQueries) { + updates.push({ + queryKey: [...query.queryKey] as unknown[], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientsQuery | undefined + if (!data?.patients) return oldData + const patientIndex = data.patients.findIndex(p => p.id === patientId) + if (patientIndex === -1) return oldData + const patient = data.patients[patientIndex] + if (!patient) return oldData + const updatedPatient = updatePatientInQuery(patient as unknown as PatientType, variables.data || {}) + if (!updatedPatient) return oldData + const updatedName = updatedPatient.firstname && updatedPatient.lastname + ? `${updatedPatient.firstname} ${updatedPatient.lastname}`.trim() + : updatedPatient.firstname || updatedPatient.lastname || patient.name || '' + const updateData = variables.data || {} + const updatedPatientForList: typeof data.patients[0] = { + ...patient, + firstname: updateData.firstname !== undefined ? (updateData.firstname || '') : patient.firstname, + lastname: updateData.lastname !== undefined ? (updateData.lastname || '') : patient.lastname, + name: updatedName, + sex: updateData.sex !== undefined && updateData.sex !== null ? updateData.sex : patient.sex, + birthdate: updateData.birthdate !== undefined ? (updateData.birthdate || null) : patient.birthdate, + ...('description' in patient && { description: updateData.description !== undefined ? updateData.description : (patient as unknown as PatientType & { description?: string | null }).description }), + clinic: updateData.clinicId !== undefined + ? (updateData.clinicId + ? (locationsData?.locationNodes?.find(loc => loc.id === updateData.clinicId) as typeof patient.clinic || patient.clinic) + : (null as unknown as typeof patient.clinic)) + : patient.clinic, + position: updateData.positionId !== undefined + ? (updateData.positionId + ? (locationsData?.locationNodes?.find(loc => loc.id === updateData.positionId) as typeof patient.position || patient.position) + : (null as unknown as typeof patient.position)) + : patient.position, + teams: updateData.teamIds !== undefined + ? (locationsData?.locationNodes?.filter(loc => updateData.teamIds?.includes(loc.id)).map(team => team as typeof patient.teams[0]) || patient.teams) + : patient.teams, + properties: updateData.properties !== undefined && updateData.properties !== null + ? (updatedPatient.properties || patient.properties) + : patient.properties, + } + return { + ...data, + patients: [ + ...data.patients.slice(0, patientIndex), + updatedPatientForList, + ...data.patients.slice(patientIndex + 1) + ] + } + } + }) + } + + return updates + }, }) - const { mutate: admitPatient } = useAdmitPatientMutation({ + useEffect(() => { + return () => { + if (isEditMode) { + flush() + } + } + }, [isEditMode, flush]) + + const { mutate: admitPatient } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(AdmitPatientDocument, variables)() + }, + queryKey: ['GetPatient', { id: patientId }], + optimisticUpdate: () => [ + { + queryKey: ['GetPatient', { id: patientId }], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientQuery | undefined + if (!data?.patient) return oldData + return { + ...data, + patient: { + ...data.patient, + state: PatientState.Admitted + } + } + } + }, + { + queryKey: ['GetPatients'], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientsQuery | undefined + if (!data?.patients) return oldData + return { + ...data, + patients: data.patients.map(p => + p.id === patientId ? { ...p, state: PatientState.Admitted } : p) + } + } + } + ], + invalidateQueries: [['GetPatients'], ['GetGlobalData']], onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() - refetch() - } + }, }) - const { mutate: dischargePatient } = useDischargePatientMutation({ + const { mutate: dischargePatient } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(DischargePatientDocument, variables)() + }, + queryKey: ['GetPatient', { id: patientId }], + optimisticUpdate: () => [ + { + queryKey: ['GetPatient', { id: patientId }], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientQuery | undefined + if (!data?.patient) return oldData + return { + ...data, + patient: { + ...data.patient, + state: PatientState.Discharged + } + } + } + }, + { + queryKey: ['GetPatients'], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientsQuery | undefined + if (!data?.patients) return oldData + return { + ...data, + patients: data.patients.map(p => + p.id === patientId ? { ...p, state: PatientState.Discharged } : p) + } + } + } + ], + invalidateQueries: [['GetPatients'], ['GetGlobalData']], onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() - refetch() - } + }, }) - const { mutate: markPatientDead } = useMarkPatientDeadMutation({ + const { mutate: markPatientDead } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(MarkPatientDeadDocument, variables)() + }, + queryKey: ['GetPatient', { id: patientId }], + optimisticUpdate: () => [ + { + queryKey: ['GetPatient', { id: patientId }], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientQuery | undefined + if (!data?.patient) return oldData + return { + ...data, + patient: { + ...data.patient, + state: PatientState.Dead + } + } + } + }, + { + queryKey: ['GetPatients'], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientsQuery | undefined + if (!data?.patients) return oldData + return { + ...data, + patients: data.patients.map(p => + p.id === patientId ? { ...p, state: PatientState.Dead } : p) + } + } + } + ], + invalidateQueries: [['GetPatients'], ['GetGlobalData']], onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() - refetch() - } + }, }) - const { mutate: waitPatient } = useWaitPatientMutation({ + const { mutate: waitPatient } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(WaitPatientDocument, variables)() + }, + queryKey: ['GetPatient', { id: patientId }], + optimisticUpdate: () => [ + { + queryKey: ['GetPatient', { id: patientId }], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientQuery | undefined + if (!data?.patient) return oldData + return { + ...data, + patient: { + ...data.patient, + state: PatientState.Wait + } + } + } + }, + { + queryKey: ['GetPatients'], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientsQuery | undefined + if (!data?.patients) return oldData + return { + ...data, + patients: data.patients.map(p => + p.id === patientId ? { ...p, state: PatientState.Wait } : p) + } + } + } + ], + invalidateQueries: [['GetPatients'], ['GetGlobalData']], onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() - refetch() - } + }, }) const { mutate: deletePatient } = useDeletePatientMutation({ onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() onClose() - } + }, }) - const persistChanges = (updates: Partial) => { + const handleFieldUpdate = (updates: Partial, triggerType?: 'onChange' | 'onBlur' | 'onClose') => { + if (!isEditMode || !patientId) return if (updates.firstname !== undefined && !updates.firstname?.trim()) return if (updates.lastname !== undefined && !updates.lastname?.trim()) return - - const cleanedUpdates: Partial = { ...updates } - if (cleanedUpdates.clinicId === '' || cleanedUpdates.clinicId === undefined) { - delete cleanedUpdates.clinicId - } - if (cleanedUpdates.positionId === '' || cleanedUpdates.positionId === undefined) { - cleanedUpdates.positionId = null - } - if (cleanedUpdates.teamIds === undefined) { - delete cleanedUpdates.teamIds - } - - if (isEditMode && patientId && Object.keys(cleanedUpdates).length > 0) { - updatePatient({ - id: patientId, - data: cleanedUpdates as UpdatePatientInput - }) - } + updateField({ id: patientId, data: updates }, triggerType) } const updateLocalState = (updates: Partial) => { @@ -406,38 +771,19 @@ export const PatientDetailView = ({ const handleClinicSelect = (locations: LocationNodeType[]) => { const clinic = locations[0] - const currentPositionId = formData.positionId if (clinic) { setSelectedClinic(clinic) - updateLocalState({ - clinicId: clinic.id, - positionId: currentPositionId - } as Partial) + updateLocalState({ clinicId: clinic.id } as Partial) if (isEditMode) { - const updateFn = () => { - persistChanges({ clinicId: clinic.id } as Partial) - setIsLocationChangeConfirmOpen(false) - setPendingLocationUpdate(null) - } - setPendingLocationUpdate(() => updateFn) - setIsLocationChangeConfirmOpen(true) + handleFieldUpdate({ clinicId: clinic.id }) } else { validateClinic(clinic) } } else { setSelectedClinic(null) - updateLocalState({ - clinicId: undefined, - positionId: currentPositionId - } as Partial) + updateLocalState({ clinicId: undefined } as Partial) if (isEditMode) { - const updateFn = () => { - persistChanges({ clinicId: undefined } as Partial) - setIsLocationChangeConfirmOpen(false) - setPendingLocationUpdate(null) - } - setPendingLocationUpdate(() => updateFn) - setIsLocationChangeConfirmOpen(true) + handleFieldUpdate({ clinicId: undefined }) } else { validateClinic(null) } @@ -451,29 +797,13 @@ export const PatientDetailView = ({ setSelectedPosition(position) updateLocalState({ positionId: position.id } as Partial) if (isEditMode) { - const updateFn = () => { - persistChanges({ positionId: position.id } as Partial) - setIsLocationChangeConfirmOpen(false) - setPendingLocationUpdate(null) - } - setPendingLocationUpdate(() => updateFn) - setIsLocationChangeConfirmOpen(true) - } else { - persistChanges({ positionId: position.id } as Partial) + handleFieldUpdate({ positionId: position.id }) } } else { setSelectedPosition(null) updateLocalState({ positionId: undefined } as Partial) if (isEditMode) { - const updateFn = () => { - persistChanges({ positionId: undefined } as Partial) - setIsLocationChangeConfirmOpen(false) - setPendingLocationUpdate(null) - } - setPendingLocationUpdate(() => updateFn) - setIsLocationChangeConfirmOpen(true) - } else { - persistChanges({ positionId: undefined } as Partial) + handleFieldUpdate({ positionId: null }) } } setIsPositionDialogOpen(false) @@ -484,23 +814,7 @@ export const PatientDetailView = ({ const teamIds = locations.map(loc => loc.id) updateLocalState({ teamIds } as Partial) if (isEditMode) { - const updateFn = () => { - const updates: Partial = { - teamIds, - ...(formData.positionId ? { positionId: formData.positionId } : {}) - } - persistChanges(updates) - setIsLocationChangeConfirmOpen(false) - setPendingLocationUpdate(null) - } - setPendingLocationUpdate(() => updateFn) - setIsLocationChangeConfirmOpen(true) - } else { - const updates: Partial = { - teamIds, - ...(formData.positionId ? { positionId: formData.positionId } : {}) - } - persistChanges(updates) + handleFieldUpdate({ teamIds }) } setIsTeamsDialogOpen(false) } @@ -523,9 +837,9 @@ export const PatientDetailView = ({ }) } - const tasks = patientData?.patient?.tasks || [] - const openTasks = sortByDueDate(tasks.filter(t => !t.done)) - const closedTasks = sortByDueDate(tasks.filter(t => t.done)) + const tasks = useMemo(() => patientData?.patient?.tasks || [], [patientData?.patient?.tasks]) + const openTasks = useMemo(() => sortByDueDate(tasks.filter(t => !t.done)), [tasks]) + const closedTasks = useMemo(() => sortByDueDate(tasks.filter(t => t.done)), [tasks]) const totalTasks = openTasks.length + closedTasks.length const taskProgress = totalTasks === 0 ? 0 : openTasks.length / totalTasks @@ -559,8 +873,10 @@ export const PatientDetailView = ({ const handleToggleDone = (taskId: string, done: boolean) => { if (done) { + setClosedExpanded(true) completeTask({ id: taskId }) } else { + setOpenExpanded(true) reopenTask({ id: taskId }) } } @@ -703,9 +1019,15 @@ export const PatientDetailView = ({ multiSelectValues: p.multiSelectValues, })) || [] + if (value === null) { + const updatedProperties = existingProperties.filter(p => p.definitionId !== definitionId) + handleFieldUpdate({ properties: updatedProperties }) + return + } + const propertyInput: PropertyValueInput = { definitionId, - textValue: value.textValue || null, + textValue: value.textValue !== undefined ? (value.textValue !== null && value.textValue.trim() !== '' ? value.textValue : '') : null, numberValue: value.numberValue ?? null, booleanValue: value.boolValue ?? null, dateValue: value.dateValue && !isNaN(value.dateValue.getTime()) ? value.dateValue.toISOString().split('T')[0] : null, @@ -719,12 +1041,7 @@ export const PatientDetailView = ({ propertyInput, ] - updatePatient({ - id: patientId!, - data: { - properties: updatedProperties, - }, - }) + handleFieldUpdate({ properties: updatedProperties }) }} />
@@ -753,7 +1070,9 @@ export const PatientDetailView = ({ }} onBlur={() => { validateFirstname(formData.firstname || '') - persistChanges({ firstname: formData.firstname }) + if (isEditMode) { + handleFieldUpdate({ firstname: formData.firstname }, 'onBlur') + } }} /> )} @@ -778,7 +1097,9 @@ export const PatientDetailView = ({ }} onBlur={() => { validateLastname(formData.lastname || '') - persistChanges({ lastname: formData.lastname }) + if (isEditMode) { + handleFieldUpdate({ lastname: formData.lastname }, 'onBlur') + } }} /> )} @@ -812,13 +1133,16 @@ export const PatientDetailView = ({ const utcDate = localToUTCWithSameTime(date) const isoDate = utcDate ? toISODate(utcDate) : null updateLocalState({ birthdate: isoDate }) - persistChanges({ birthdate: isoDate }) + if (isEditMode) { + handleFieldUpdate({ birthdate: isoDate }, 'onClose') + } } }} onRemove={() => { updateLocalState({ birthdate: null }) - persistChanges({ birthdate: null }) - if (!isEditMode) { + if (isEditMode) { + handleFieldUpdate({ birthdate: null }) + } else { validateBirthdate(null) } }} @@ -826,7 +1150,6 @@ export const PatientDetailView = ({ )} - { updateLocalState({ sex: value as Sex }) - persistChanges({ sex: value as Sex }) + if (isEditMode) { + handleFieldUpdate({ sex: value as Sex }, 'onChange') + } validateSex(value as Sex) }} > @@ -851,6 +1176,22 @@ export const PatientDetailView = ({ )} + + {({ isShowingError: _1, setIsShowingError: _2, ...bag }) => ( +