diff --git a/backend/api/resolvers/patient.py b/backend/api/resolvers/patient.py index abd2997c..c8e9d404 100644 --- a/backend/api/resolvers/patient.py +++ b/backend/api/resolvers/patient.py @@ -14,7 +14,7 @@ from api.types.patient import PatientType from database import models from graphql import GraphQLError -from sqlalchemy import select +from sqlalchemy import desc, func, select from sqlalchemy.orm import aliased, selectinload @@ -153,25 +153,43 @@ async def recent_patients( info: Info, limit: int = 5, ) -> list[PatientType]: + auth_service = AuthorizationService(info.context.db) + accessible_location_ids = await auth_service.get_user_accessible_location_ids( + info.context.user, info.context + ) + + if not accessible_location_ids: + return [] + + max_task_update_date = ( + select( + func.max(models.Task.update_date).label("max_update_date"), + models.Task.patient_id.label("patient_id"), + ) + .group_by(models.Task.patient_id) + .subquery() + ) + query = ( - select(models.Patient) + select(models.Patient, max_task_update_date.c.max_update_date) .options( selectinload(models.Patient.assigned_locations), selectinload(models.Patient.tasks), selectinload(models.Patient.teams), ) + .outerjoin( + max_task_update_date, + models.Patient.id == max_task_update_date.c.patient_id, + ) .where(models.Patient.deleted.is_(False)) + .order_by(desc(max_task_update_date.c.max_update_date), desc(models.Patient.id)) .limit(limit) ) - auth_service = AuthorizationService(info.context.db) - accessible_location_ids = await auth_service.get_user_accessible_location_ids( - info.context.user, info.context - ) query = auth_service.filter_patients_by_access( info.context.user, query, accessible_location_ids ) result = await info.context.db.execute(query) - return result.scalars().all() + return [row[0] for row in result.all()] @strawberry.type diff --git a/web/api/gql/generated.ts b/web/api/gql/generated.ts index cfd55561..760c78ae 100644 --- a/web/api/gql/generated.ts +++ b/web/api/gql/generated.ts @@ -589,19 +589,19 @@ export type GetLocationsQuery = { __typename?: 'Query', locationNodes: Array<{ _ export type GetMyTasksQueryVariables = Exact<{ [key: string]: never; }>; -export type GetMyTasksQuery = { __typename?: 'Query', me?: { __typename?: 'UserType', id: string, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null }> } | null }; +export type GetMyTasksQuery = { __typename?: 'Query', me?: { __typename?: 'UserType', id: string, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null }> } | null }; export type GetOverviewDataQueryVariables = Exact<{ [key: string]: never; }>; -export type GetOverviewDataQuery = { __typename?: 'Query', recentPatients: Array<{ __typename?: 'PatientType', id: string, name: string, sex: Sex, birthdate: any, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, tasks: Array<{ __typename?: 'TaskType', updateDate?: any | null }> }>, recentTasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null, patient: { __typename?: 'PatientType', id: string, name: string, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } }> }; +export type GetOverviewDataQuery = { __typename?: 'Query', recentPatients: Array<{ __typename?: 'PatientType', id: string, name: string, sex: Sex, birthdate: any, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, tasks: Array<{ __typename?: 'TaskType', updateDate?: any | null }> }>, recentTasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, patient: { __typename?: 'PatientType', id: string, name: string, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } }> }; export type GetPatientQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -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 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, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> } | null }; export type GetPatientsQueryVariables = Exact<{ locationId?: InputMaybe; @@ -612,14 +612,14 @@ export type GetPatientsQueryVariables = Exact<{ }>; -export type GetPatientsQuery = { __typename?: 'Query', patients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', name: string } }> }> }; +export type GetPatientsQuery = { __typename?: 'Query', patients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', name: string } }> }> }; export type GetTaskQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type GetTaskQuery = { __typename?: 'Query', task?: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, checksum: string, patient: { __typename?: 'PatientType', id: string, name: string }, 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 GetTaskQuery = { __typename?: 'Query', task?: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, checksum: string, patient: { __typename?: 'PatientType', id: string, name: string }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> } | null }; export type GetTasksQueryVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; @@ -630,26 +630,26 @@ export type GetTasksQueryVariables = Exact<{ }>; -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 GetTasksQuery = { __typename?: 'Query', tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }; export type 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 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, lastOnline?: any | null, isOnline: boolean } | null }; export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>; -export type GetUsersQuery = { __typename?: 'Query', users: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, isOnline: boolean }> }; +export type GetUsersQuery = { __typename?: 'Query', users: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }> }; export type GetGlobalDataQueryVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; }>; -export type GetGlobalDataQuery = { __typename?: 'Query', me?: { __typename?: 'UserType', id: string, username: string, name: string, firstname?: string | null, lastname?: string | null, avatarUrl?: string | null, organizations?: string | null, rootLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType }>, tasks: Array<{ __typename?: 'TaskType', id: string, done: boolean }> } | null, wards: Array<{ __typename?: 'LocationNodeType', id: string, title: string, parentId?: string | null }>, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, parentId?: string | null }>, clinics: Array<{ __typename?: 'LocationNodeType', id: string, title: string, parentId?: string | null }>, patients: Array<{ __typename?: 'PatientType', id: string, state: PatientState, assignedLocation?: { __typename?: 'LocationNodeType', id: string } | null }>, waitingPatients: Array<{ __typename?: 'PatientType', id: string, state: PatientState }> }; +export type GetGlobalDataQuery = { __typename?: 'Query', me?: { __typename?: 'UserType', id: string, username: string, name: string, firstname?: string | null, lastname?: string | null, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean, organizations?: string | null, rootLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType }>, tasks: Array<{ __typename?: 'TaskType', id: string, done: boolean }> } | null, wards: Array<{ __typename?: 'LocationNodeType', id: string, title: string, parentId?: string | null }>, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, parentId?: string | null }>, clinics: Array<{ __typename?: 'LocationNodeType', id: string, title: string, parentId?: string | null }>, patients: Array<{ __typename?: 'PatientType', id: string, state: PatientState, assignedLocation?: { __typename?: 'LocationNodeType', id: string } | null }>, waitingPatients: Array<{ __typename?: 'PatientType', id: string, state: PatientState }> }; export type CreatePatientMutationVariables = Exact<{ data: CreatePatientInput; @@ -803,7 +803,7 @@ export type CreateTaskMutationVariables = Exact<{ }>; -export type CreateTaskMutation = { __typename?: 'Mutation', createTask: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null, patient: { __typename?: 'PatientType', id: string, name: string } } }; +export type CreateTaskMutation = { __typename?: 'Mutation', createTask: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, patient: { __typename?: 'PatientType', id: string, name: string } } }; export type UpdateTaskMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -811,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, 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 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, lastOnline?: any | null, isOnline: boolean } | 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']; @@ -819,14 +819,14 @@ export type AssignTaskMutationVariables = Exact<{ }>; -export type AssignTaskMutation = { __typename?: 'Mutation', assignTask: { __typename?: 'TaskType', id: string, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null } }; +export type AssignTaskMutation = { __typename?: 'Mutation', assignTask: { __typename?: 'TaskType', id: string, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null } }; export type UnassignTaskMutationVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type UnassignTaskMutation = { __typename?: 'Mutation', unassignTask: { __typename?: 'TaskType', id: string, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null } }; +export type UnassignTaskMutation = { __typename?: 'Mutation', unassignTask: { __typename?: 'TaskType', id: string, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null } }; export type DeleteTaskMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -1015,6 +1015,8 @@ export const GetMyTasksDocument = ` id name avatarUrl + lastOnline + isOnline } } } @@ -1068,6 +1070,8 @@ export const GetOverviewDataDocument = ` id name avatarUrl + lastOnline + isOnline } patient { id @@ -1197,6 +1201,8 @@ export const GetPatientDocument = ` id name avatarUrl + lastOnline + isOnline } assigneeTeam { id @@ -1360,6 +1366,8 @@ export const GetPatientsDocument = ` id name avatarUrl + lastOnline + isOnline } assigneeTeam { id @@ -1412,6 +1420,8 @@ export const GetTaskDocument = ` id name avatarUrl + lastOnline + isOnline } assigneeTeam { id @@ -1503,6 +1513,8 @@ export const GetTasksDocument = ` id name avatarUrl + lastOnline + isOnline } assigneeTeam { id @@ -1540,6 +1552,8 @@ export const GetUserDocument = ` lastname title avatarUrl + lastOnline + isOnline } } `; @@ -1566,6 +1580,7 @@ export const GetUsersDocument = ` id name avatarUrl + lastOnline isOnline } } @@ -1596,6 +1611,8 @@ export const GetGlobalDataDocument = ` firstname lastname avatarUrl + lastOnline + isOnline organizations rootLocations { id @@ -2069,6 +2086,8 @@ export const CreateTaskDocument = ` id name avatarUrl + lastOnline + isOnline } patient { id @@ -2111,6 +2130,8 @@ export const UpdateTaskDocument = ` id name avatarUrl + lastOnline + isOnline } properties { definition { @@ -2155,6 +2176,8 @@ export const AssignTaskDocument = ` id name avatarUrl + lastOnline + isOnline } } } @@ -2181,6 +2204,8 @@ export const UnassignTaskDocument = ` id name avatarUrl + lastOnline + isOnline } } } diff --git a/web/api/graphql/GetMyTasks.graphql b/web/api/graphql/GetMyTasks.graphql index 2342e525..e9fd0262 100644 --- a/web/api/graphql/GetMyTasks.graphql +++ b/web/api/graphql/GetMyTasks.graphql @@ -40,6 +40,8 @@ query GetMyTasks { id name avatarUrl + lastOnline + isOnline } } } diff --git a/web/api/graphql/GetOverviewData.graphql b/web/api/graphql/GetOverviewData.graphql index 4c1c6474..77b8c5e0 100644 --- a/web/api/graphql/GetOverviewData.graphql +++ b/web/api/graphql/GetOverviewData.graphql @@ -28,6 +28,8 @@ query GetOverviewData { id name avatarUrl + lastOnline + isOnline } patient { id diff --git a/web/api/graphql/GetPatient.graphql b/web/api/graphql/GetPatient.graphql index 678aaee8..f81d5470 100644 --- a/web/api/graphql/GetPatient.graphql +++ b/web/api/graphql/GetPatient.graphql @@ -92,6 +92,8 @@ query GetPatient($id: ID!) { id name avatarUrl + lastOnline + isOnline } assigneeTeam { id diff --git a/web/api/graphql/GetPatients.graphql b/web/api/graphql/GetPatients.graphql index 06fdc38b..68435959 100644 --- a/web/api/graphql/GetPatients.graphql +++ b/web/api/graphql/GetPatients.graphql @@ -109,6 +109,8 @@ query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientSta id name avatarUrl + lastOnline + isOnline } assigneeTeam { id diff --git a/web/api/graphql/GetTask.graphql b/web/api/graphql/GetTask.graphql index 76217e65..9b746b78 100644 --- a/web/api/graphql/GetTask.graphql +++ b/web/api/graphql/GetTask.graphql @@ -16,6 +16,8 @@ query GetTask($id: ID!) { id name avatarUrl + lastOnline + isOnline } assigneeTeam { id diff --git a/web/api/graphql/GetTasks.graphql b/web/api/graphql/GetTasks.graphql index 579127c7..50c6a0ab 100644 --- a/web/api/graphql/GetTasks.graphql +++ b/web/api/graphql/GetTasks.graphql @@ -38,6 +38,8 @@ query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $l id name avatarUrl + lastOnline + isOnline } assigneeTeam { id diff --git a/web/api/graphql/GetUser.graphql b/web/api/graphql/GetUser.graphql index 0fc3910a..7bc0815f 100644 --- a/web/api/graphql/GetUser.graphql +++ b/web/api/graphql/GetUser.graphql @@ -8,6 +8,8 @@ query GetUser($id: ID!) { lastname title avatarUrl + lastOnline + isOnline } } diff --git a/web/api/graphql/GetUsers.graphql b/web/api/graphql/GetUsers.graphql index c5dcf22c..579518b8 100644 --- a/web/api/graphql/GetUsers.graphql +++ b/web/api/graphql/GetUsers.graphql @@ -3,6 +3,7 @@ query GetUsers { id name avatarUrl + lastOnline isOnline } } diff --git a/web/api/graphql/GlobalData.graphql b/web/api/graphql/GlobalData.graphql index c12704eb..7a62bf47 100644 --- a/web/api/graphql/GlobalData.graphql +++ b/web/api/graphql/GlobalData.graphql @@ -6,6 +6,8 @@ query GetGlobalData($rootLocationIds: [ID!]) { firstname lastname avatarUrl + lastOnline + isOnline organizations rootLocations { id diff --git a/web/api/graphql/TaskMutations.graphql b/web/api/graphql/TaskMutations.graphql index e1323946..cdf93521 100644 --- a/web/api/graphql/TaskMutations.graphql +++ b/web/api/graphql/TaskMutations.graphql @@ -10,6 +10,8 @@ mutation CreateTask($data: CreateTaskInput!) { id name avatarUrl + lastOnline + isOnline } patient { id @@ -37,6 +39,8 @@ mutation UpdateTask($id: ID!, $data: UpdateTaskInput!) { id name avatarUrl + lastOnline + isOnline } properties { definition { @@ -66,6 +70,8 @@ mutation AssignTask($id: ID!, $userId: ID!) { id name avatarUrl + lastOnline + isOnline } } } @@ -77,6 +83,8 @@ mutation UnassignTask($id: ID!) { id name avatarUrl + lastOnline + isOnline } } } diff --git a/web/components/AuditLogTimeline.tsx b/web/components/AuditLogTimeline.tsx index a51907c1..c4339ff1 100644 --- a/web/components/AuditLogTimeline.tsx +++ b/web/components/AuditLogTimeline.tsx @@ -1,9 +1,12 @@ -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo, useEffect } 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' +import { HelpwaveLogo } from '@helpwave/hightide' +import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' +import { ChevronRight } from 'lucide-react' const GET_AUDIT_LOGS_QUERY = ` query GetAuditLogs($caseId: ID!, $limit: Int, $offset: Int) { @@ -28,6 +31,7 @@ export interface AuditLogEntry { interface AuditLogTimelineProps { caseId: string, className?: string, + enabled?: boolean, } const GET_USER_QUERY = ` @@ -36,6 +40,8 @@ const GET_USER_QUERY = ` id username name + avatarUrl + isOnline } } ` @@ -44,11 +50,20 @@ interface UserInfo { id: string, username: string, name: string, + avatarUrl: string | null, + isOnline: boolean | null, } -export const AuditLogTimeline: React.FC = ({ caseId, className }) => { +export const AuditLogTimeline: React.FC = ({ caseId, className, enabled = false }) => { const [expandedEntries, setExpandedEntries] = useState>(new Set()) const [selectedUserId, setSelectedUserId] = useState(null) + const [shouldFetch, setShouldFetch] = useState(false) + + useEffect(() => { + if (enabled && !shouldFetch) { + setShouldFetch(true) + } + }, [enabled, shouldFetch]) const { data, isLoading } = useQuery({ queryKey: ['GetAuditLogs', caseId], @@ -56,7 +71,7 @@ export const AuditLogTimeline: React.FC = ({ caseId, clas GET_AUDIT_LOGS_QUERY, { caseId } )(), - enabled: !!caseId, + enabled: !!caseId && shouldFetch, }) const auditLogs = useMemo(() => data?.auditLogs || [], [data?.auditLogs]) @@ -92,6 +107,11 @@ export const AuditLogTimeline: React.FC = ({ caseId, clas return user?.name || user?.username || userId } + const getUserInfo = (userId: string | null): UserInfo | null => { + if (!userId) return null + return usersQuery.data?.get(userId) || null + } + const toggleExpand = (index: number) => { setExpandedEntries(prev => { const next = new Set(prev) @@ -104,13 +124,6 @@ export const AuditLogTimeline: React.FC = ({ caseId, clas }) } - 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, ' ') @@ -119,82 +132,132 @@ export const AuditLogTimeline: React.FC = ({ caseId, clas .join(' ') } + const hasContext = (entry: AuditLogEntry): boolean => { + if (!entry.context) return false + try { + const parsed = JSON.parse(entry.context) + return typeof parsed === 'object' && parsed !== null && Object.keys(parsed).length > 0 + } catch { + return entry.context.length > 0 + } + } + + const handleCardClick = (index: number, e: React.MouseEvent) => { + e.stopPropagation() + const entry = auditLogs[index] + if (entry && hasContext(entry)) { + toggleExpand(index) + } + } + return (
-
+
Audit Log
{isLoading && ( -
- Loading... +
+
)} -
- {auditLogs.map((entry: AuditLogEntry, index: number) => ( -
-
-
-
-
-
- {formatActivity(entry.activity)} + {!isLoading && ( +
+ {auditLogs.map((entry: AuditLogEntry, index: number) => { + const userInfo = getUserInfo(entry.userId) + const isExpanded = expandedEntries.has(index) + const hasDetails = hasContext(entry) + + return ( +
handleCardClick(index, e)} + className={clsx( + 'p-4 rounded-lg border-2 transition-all', + 'bg-[rgba(255,255,255,1)] dark:bg-[rgba(55,65,81,1)]', + 'border-gray-300 dark:border-gray-600', + 'hover:border-primary hover:shadow-md', + hasDetails && 'cursor-pointer' + )} + > +
+
+
+ {formatActivity(entry.activity)} +
+
+ {entry.userId && userInfo && ( + <> + + + + )} + {entry.userId && !userInfo && ( +
+ {getUserName(entry.userId)} +
+ )} +
+
+ +
- {entry.userId && ( - + {hasDetails && ( +
+ +
)} -
- -
- {entry.context && (() => { + {isExpanded && entry.context && (() => { try { const parsed = JSON.parse(entry.context) - return typeof parsed === 'object' && parsed !== null && Object.keys(parsed).length > 0 + return ( +
+
+
{JSON.stringify(parsed, null, 2)}
+
+
+ ) } catch { - return entry.context.length > 0 + return ( +
+
+
{entry.context}
+
+
+ ) } - })() && ( - - )} + })()}
- {expandedEntries.has(index) && entry.context && (() => { - try { - const parsed = JSON.parse(entry.context) - return ( -
-
{JSON.stringify(parsed, null, 2)}
-
- ) - } catch { - return ( -
-
{entry.context}
-
- ) - } - })()} + ) + })} + {auditLogs.length === 0 && ( +
+ No audit logs available
-
- ))} - {!isLoading && auditLogs.length === 0 && ( -
- No audit logs available -
- )} -
+ )} +
+ )} = ({ dotSizeClasses[size], dotPositionClasses[size], dotBorderClasses[size], - showOnline ? 'bg-green-500' : 'bg-gray-400' + showOnline ? 'bg-green-600' : 'bg-red-600' )} aria-label={showOnline ? 'Online' : 'Offline'} /> diff --git a/web/components/UserInfoPopup.tsx b/web/components/UserInfoPopup.tsx index 164ff400..82c83747 100644 --- a/web/components/UserInfoPopup.tsx +++ b/web/components/UserInfoPopup.tsx @@ -3,7 +3,8 @@ import { useQuery } from '@tanstack/react-query' import { Dialog, Button, LoadingContainer } from '@helpwave/hightide' import { fetcher } from '@/api/gql/fetcher' import clsx from 'clsx' -import Image from 'next/image' +import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' const GET_USER_QUERY = ` query GetUser($id: ID!) { @@ -16,6 +17,8 @@ const GET_USER_QUERY = ` lastname title avatarUrl + lastOnline + isOnline } } ` @@ -29,6 +32,8 @@ interface UserInfo { lastname: string | null, title: string | null, avatarUrl: string | null, + lastOnline: string | null, + isOnline: boolean | null, } interface UserInfoPopupProps { @@ -38,6 +43,7 @@ interface UserInfoPopupProps { } export const UserInfoPopup: React.FC = ({ userId, isOpen, onClose }) => { + const translation = useTasksTranslation() const { data, isLoading } = useQuery({ queryKey: ['GetUser', userId], queryFn: () => fetcher<{ user: UserInfo | null }, { id: string }>( @@ -53,36 +59,42 @@ export const UserInfoPopup: React.FC = ({ userId, isOpen, on {isLoading ? ( ) : user ? (
- {user.avatarUrl && ( -
- {user.name} +
+ +
+
{user.name}
+ {user.username && ( +
@{user.username}
+ )}
- )} -
-
{user.name}
- {user.title && ( -
{user.title}
- )} - {user.username && ( -
@{user.username}
- )}
{user.email && (
Email
-
{user.email}
+ + {user.email} +
)} {(user.firstname || user.lastname) && ( @@ -93,6 +105,14 @@ export const UserInfoPopup: React.FC = ({ userId, isOpen, on
)} +
+
Status
+
+ + {user.isOnline ? 'Online' : 'Offline'} + +
+
) : (
User not found
diff --git a/web/components/layout/Page.tsx b/web/components/layout/Page.tsx index cde3d8ff..0892f593 100644 --- a/web/components/layout/Page.tsx +++ b/web/components/layout/Page.tsx @@ -6,13 +6,14 @@ import Head from 'next/head' import titleWrapper from '@/utils/titleWrapper' import Link from 'next/link' import { - Avatar, Button, Dialog, Expandable, MarkdownInterpreter, + Tooltip, useLocalStorage } from '@helpwave/hightide' +import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { getConfig } from '@/utils/config' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { UserInfoPopup } from '@/components/UserInfoPopup' @@ -355,20 +356,23 @@ export const Header = ({ onMenuClick, isMenuOpen, ...props }: HeaderProps) => {
- + + +
setIsFeedbackOpen(false)} /> diff --git a/web/components/locations/LocationSelectionDialog.tsx b/web/components/locations/LocationSelectionDialog.tsx index fb0a7a60..26403ce3 100644 --- a/web/components/locations/LocationSelectionDialog.tsx +++ b/web/components/locations/LocationSelectionDialog.tsx @@ -4,7 +4,8 @@ import { Expandable, Checkbox, Button, - SearchBar + SearchBar, + useLocalStorage } from '@helpwave/hightide' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import type { LocationNodeType } from '@/api/gql/generated' @@ -37,6 +38,11 @@ interface LocationSelectionDialogProps { useCase?: LocationPickerUseCase, } +const generateTreeSignature = (nodes: LocationNodeType[]): string => { + const sortedNodes = [...nodes].sort((a, b) => a.id.localeCompare(b.id)) + return sortedNodes.map(n => `${n.id}:${n.parentId || ''}`).join('|') +} + interface LocationTreeItemProps { node: TreeNode, selectedIds: Set, @@ -188,21 +194,59 @@ export const LocationSelectionDialog = ({ } ) + const storageKey = `location-selector-state-${useCase}` + const signatureKey = `location-selector-signature-${useCase}` + + const { + value: storedExpandedIds, + setValue: setStoredExpandedIds + } = useLocalStorage(storageKey, []) + + const { + value: storedTreeSignature, + setValue: setStoredTreeSignature + } = useLocalStorage(signatureKey, '') + const [selectedIds, setSelectedIds] = useState>(new Set(initialSelectedIds)) const [expandedIds, setExpandedIds] = useState>(new Set()) const [searchQuery, setSearchQuery] = useState('') const hasInitialized = useRef(false) + useEffect(() => { + if (isOpen && data?.locationNodes) { + const currentSignature = generateTreeSignature(data.locationNodes as LocationNodeType[]) + const treeChanged = currentSignature !== storedTreeSignature + + if (treeChanged) { + setExpandedIds(new Set()) + setStoredTreeSignature(currentSignature) + setStoredExpandedIds([]) + } else if (!hasInitialized.current) { + try { + const parsedExpandedIds = storedExpandedIds && Array.isArray(storedExpandedIds) + ? new Set(storedExpandedIds.filter((id): id is string => typeof id === 'string')) + : new Set() + setExpandedIds(parsedExpandedIds) + } catch { + setExpandedIds(new Set()) + } + hasInitialized.current = true + } + } + }, [isOpen, data?.locationNodes, storedTreeSignature, storedExpandedIds, setStoredTreeSignature, setStoredExpandedIds]) + useEffect(() => { if (isOpen) { setSelectedIds(new Set(initialSelectedIds)) - setExpandedIds(new Set()) - hasInitialized.current = true + if (!data?.locationNodes) { + setExpandedIds(new Set()) + hasInitialized.current = false + } } else { hasInitialized.current = false } - }, [isOpen, initialSelectedIds]) + }, [isOpen, initialSelectedIds, data?.locationNodes]) const matchesFilter = useMemo(() => { if (useCase === 'default') { @@ -367,16 +411,21 @@ export const LocationSelectionDialog = ({ newSet.delete(nodeId) } setExpandedIds(newSet) + setStoredExpandedIds(Array.from(newSet)) } const handleExpandAll = () => { if (!data?.locationNodes) return const allIds = data.locationNodes.map(n => n.id) - setExpandedIds(new Set(allIds)) + const newSet = new Set(allIds) + setExpandedIds(newSet) + setStoredExpandedIds(Array.from(newSet)) } const handleCollapseAll = () => { - setExpandedIds(new Set()) + const newSet = new Set() + setExpandedIds(newSet) + setStoredExpandedIds([]) } const handleConfirm = () => { diff --git a/web/components/patients/PatientDetailView.tsx b/web/components/patients/PatientDetailView.tsx index 6e2e1430..a15de351 100644 --- a/web/components/patients/PatientDetailView.tsx +++ b/web/components/patients/PatientDetailView.tsx @@ -7,15 +7,13 @@ import { PropertyEntity, Sex, type FieldType, - useCreatePatientMutation, - useDeletePatientMutation, useGetPatientQuery, useGetPropertyDefinitionsQuery } from '@/api/gql/generated' 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 { UpdatePatientDocument, type UpdatePatientMutation, type UpdatePatientMutationVariables, AdmitPatientDocument, DischargePatientDocument, WaitPatientDocument, MarkPatientDeadDocument, CreatePatientDocument, DeletePatientDocument, type AdmitPatientMutation, type DischargePatientMutation, type WaitPatientMutation, type MarkPatientDeadMutation, type CreatePatientMutation, type DeletePatientMutation, type CreatePatientMutationVariables, type DeletePatientMutationVariables, type GetPatientQuery, type GetPatientsQuery, CompleteTaskDocument, ReopenTaskDocument, type CompleteTaskMutation, type ReopenTaskMutation, type CompleteTaskMutationVariables, type ReopenTaskMutationVariables, type GetGlobalDataQuery } from '@/api/gql/generated' import { Button, Checkbox, @@ -100,6 +98,7 @@ export const PatientDetailView = ({ const translation = useTasksTranslation() const queryClient = useQueryClient() const { selectedLocationId, selectedRootLocationIds, rootLocations } = useTasksContext() + const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined const firstSelectedRootLocationId = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds[0] : undefined const [taskId, setTaskId] = useState(null) const [isCreatingTask, setIsCreatingTask] = useState(false) @@ -138,6 +137,8 @@ export const PatientDetailView = ({ return map }, [locationsData]) + const [optimisticTaskUpdates, setOptimisticTaskUpdates] = useState>(new Map()) + const { mutate: completeTask } = useSafeMutation({ mutationFn: async (variables) => { return fetcher(CompleteTaskDocument, variables)() @@ -159,12 +160,53 @@ export const PatientDetailView = ({ } } } + }, + { + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + updateFn: (oldData: unknown) => { + const data = oldData as GetGlobalDataQuery | undefined + if (!data?.me?.tasks) return oldData + return { + ...data, + me: data.me ? { + ...data.me, + tasks: data.me.tasks.map(task => task.id === variables.id ? { ...task, done: true } : task) + } : null + } + } + }, + { + queryKey: ['GetPatients'], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientsQuery | undefined + if (!data?.patients) return oldData + return { + ...data, + patients: data.patients.map(patient => { + if (patient.id === patientId && patient.tasks) { + return { + ...patient, + tasks: patient.tasks.map(task => task.id === variables.id ? { ...task, done: true } : task) + } + } + return patient + }) + } + } } ], invalidateQueries: [['GetPatient', { id: patientId }], ['GetTasks'], ['GetPatients'], ['GetOverviewData'], ['GetGlobalData']], - onSuccess: () => { + onSuccess: async () => { + await queryClient.refetchQueries({ queryKey: ['GetPatient', { id: patientId }] }) onSuccess() }, + onError: (error, variables) => { + setOptimisticTaskUpdates(prev => { + const next = new Map(prev) + next.delete(variables.id) + return next + }) + }, }) const { mutate: reopenTask } = useSafeMutation({ @@ -188,12 +230,53 @@ export const PatientDetailView = ({ } } } + }, + { + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + updateFn: (oldData: unknown) => { + const data = oldData as GetGlobalDataQuery | undefined + if (!data?.me?.tasks) return oldData + return { + ...data, + me: data.me ? { + ...data.me, + tasks: data.me.tasks.map(task => task.id === variables.id ? { ...task, done: false } : task) + } : null + } + } + }, + { + queryKey: ['GetPatients'], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientsQuery | undefined + if (!data?.patients) return oldData + return { + ...data, + patients: data.patients.map(patient => { + if (patient.id === patientId && patient.tasks) { + return { + ...patient, + tasks: patient.tasks.map(task => task.id === variables.id ? { ...task, done: false } : task) + } + } + return patient + }) + } + } } ], invalidateQueries: [['GetPatient', { id: patientId }], ['GetTasks'], ['GetPatients'], ['GetOverviewData'], ['GetGlobalData']], - onSuccess: () => { + onSuccess: async () => { + await queryClient.refetchQueries({ queryKey: ['GetPatient', { id: patientId }] }) onSuccess() }, + onError: (error, variables) => { + setOptimisticTaskUpdates(prev => { + const next = new Map(prev) + next.delete(variables.id) + return next + }) + }, }) const [formData, setFormData] = useState({ @@ -274,8 +357,47 @@ export const PatientDetailView = ({ } }, [isEditMode, firstSelectedRootLocationId, locationsData, formData.clinicId, rootLocations]) - const { mutate: createPatient, isLoading: isCreating } = useCreatePatientMutation({ - onSuccess: () => { + const { mutate: createPatient, isLoading: isCreating } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(CreatePatientDocument, variables)() + }, + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + optimisticUpdate: (variables) => [ + { + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + updateFn: (oldData: unknown) => { + const data = oldData as GetGlobalDataQuery | undefined + if (!data) return oldData + const newPatient = { + __typename: 'PatientType' as const, + id: `temp-${Date.now()}`, + name: `${variables.data.firstname} ${variables.data.lastname}`.trim(), + firstname: variables.data.firstname, + lastname: variables.data.lastname, + birthdate: variables.data.birthdate, + sex: variables.data.sex, + state: variables.data.state || PatientState.Admitted, + assignedLocation: null, + assignedLocations: [], + clinic: null, + position: null, + teams: [], + properties: [], + tasks: [], + } + return { + ...data, + patients: [...(data.patients || []), newPatient], + waitingPatients: variables.data.state === PatientState.Wait + ? [...(data.waitingPatients || []), newPatient] + : data.waitingPatients || [], + } + } + } + ], + invalidateQueries: [['GetGlobalData'], ['GetPatients'], ['GetOverviewData']], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() onClose() }, @@ -532,6 +654,24 @@ export const PatientDetailView = ({ p.id === patientId ? { ...p, state: PatientState.Admitted } : p) } } + }, + { + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + updateFn: (oldData: unknown) => { + const data = oldData as GetGlobalDataQuery | undefined + if (!data) return oldData + const existingPatient = data.patients.find(p => p.id === patientId) + const updatedPatient = existingPatient + ? { ...existingPatient, state: PatientState.Admitted } + : { __typename: 'PatientType' as const, id: patientId, state: PatientState.Admitted, assignedLocation: null } + return { + ...data, + patients: existingPatient + ? data.patients.map(p => p.id === patientId ? updatedPatient : p) + : [...data.patients, updatedPatient], + waitingPatients: data.waitingPatients.filter(p => p.id !== patientId) + } + } } ], invalidateQueries: [['GetPatients'], ['GetGlobalData']], @@ -571,6 +711,24 @@ export const PatientDetailView = ({ p.id === patientId ? { ...p, state: PatientState.Discharged } : p) } } + }, + { + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + updateFn: (oldData: unknown) => { + const data = oldData as GetGlobalDataQuery | undefined + if (!data) return oldData + const existingPatient = data.patients.find(p => p.id === patientId) + const updatedPatient = existingPatient + ? { ...existingPatient, state: PatientState.Discharged } + : { __typename: 'PatientType' as const, id: patientId, state: PatientState.Discharged, assignedLocation: null } + return { + ...data, + patients: existingPatient + ? data.patients.map(p => p.id === patientId ? updatedPatient : p) + : [...data.patients, updatedPatient], + waitingPatients: data.waitingPatients.filter(p => p.id !== patientId) + } + } } ], invalidateQueries: [['GetPatients'], ['GetGlobalData']], @@ -610,6 +768,24 @@ export const PatientDetailView = ({ p.id === patientId ? { ...p, state: PatientState.Dead } : p) } } + }, + { + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + updateFn: (oldData: unknown) => { + const data = oldData as GetGlobalDataQuery | undefined + if (!data) return oldData + const existingPatient = data.patients.find(p => p.id === patientId) + const updatedPatient = existingPatient + ? { ...existingPatient, state: PatientState.Dead } + : { __typename: 'PatientType' as const, id: patientId, state: PatientState.Dead, assignedLocation: null } + return { + ...data, + patients: existingPatient + ? data.patients.map(p => p.id === patientId ? updatedPatient : p) + : [...data.patients, updatedPatient], + waitingPatients: data.waitingPatients.filter(p => p.id !== patientId) + } + } } ], invalidateQueries: [['GetPatients'], ['GetGlobalData']], @@ -649,6 +825,27 @@ export const PatientDetailView = ({ p.id === patientId ? { ...p, state: PatientState.Wait } : p) } } + }, + { + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + updateFn: (oldData: unknown) => { + const data = oldData as GetGlobalDataQuery | undefined + if (!data) return oldData + const existingPatient = data.patients.find(p => p.id === patientId) + const isAlreadyWaiting = data.waitingPatients.some(p => p.id === patientId) + const updatedPatient = existingPatient + ? { ...existingPatient, state: PatientState.Wait } + : { __typename: 'PatientType' as const, id: patientId, state: PatientState.Wait, assignedLocation: null } + return { + ...data, + patients: existingPatient + ? data.patients.map(p => p.id === patientId ? updatedPatient : p) + : [...data.patients, updatedPatient], + waitingPatients: isAlreadyWaiting + ? data.waitingPatients + : [...data.waitingPatients, updatedPatient] + } + } } ], invalidateQueries: [['GetPatients'], ['GetGlobalData']], @@ -657,8 +854,43 @@ export const PatientDetailView = ({ }, }) - const { mutate: deletePatient } = useDeletePatientMutation({ - onSuccess: () => { + const { mutate: deletePatient } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(DeletePatientDocument, variables)() + }, + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + optimisticUpdate: (variables) => [ + { + queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], + updateFn: (oldData: unknown) => { + const data = oldData as GetGlobalDataQuery | undefined + if (!data) return oldData + return { + ...data, + patients: (data.patients || []).filter(p => p.id !== variables.id), + waitingPatients: (data.waitingPatients || []).filter(p => p.id !== variables.id), + } + } + }, + { + queryKey: ['GetPatients'], + updateFn: (oldData: unknown) => { + const data = oldData as GetPatientsQuery | undefined + if (!data?.patients) return oldData + return { + ...data, + patients: data.patients.filter(p => p.id !== variables.id), + } + } + }, + { + queryKey: ['GetPatient', { id: variables.id }], + updateFn: () => undefined, + } + ], + invalidateQueries: [['GetGlobalData'], ['GetPatients'], ['GetOverviewData']], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() onClose() }, @@ -837,7 +1069,17 @@ export const PatientDetailView = ({ }) } - const tasks = useMemo(() => patientData?.patient?.tasks || [], [patientData?.patient?.tasks]) + const tasks = useMemo(() => { + const baseTasks = patientData?.patient?.tasks || [] + return baseTasks.map(task => { + const optimisticDone = optimisticTaskUpdates.get(task.id) + if (optimisticDone !== undefined) { + return { ...task, done: optimisticDone } + } + return task + }) + }, [patientData?.patient?.tasks, optimisticTaskUpdates]) + 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 @@ -867,11 +1109,16 @@ export const PatientDetailView = ({ return new Date() }, []) - if (isEditMode && isLoadingPatient) { - return - } + useEffect(() => { + setOptimisticTaskUpdates(new Map()) + }, [patientData?.patient?.tasks]) const handleToggleDone = (taskId: string, done: boolean) => { + setOptimisticTaskUpdates(prev => { + const next = new Map(prev) + next.set(taskId, done) + return next + }) if (done) { setClosedExpanded(true) completeTask({ id: taskId }) @@ -881,6 +1128,10 @@ export const PatientDetailView = ({ } } + if (isEditMode && isLoadingPatient) { + return + } + return (
{isEditMode && patientName && ( @@ -946,6 +1197,7 @@ export const PatientDetailView = ({ onToggleDone={(taskId, done) => handleToggleDone(taskId, done)} showPatient={false} showAssignee={!!(task.assignee || task.assigneeTeam)} + fullWidth={true} /> ))}
@@ -973,6 +1225,7 @@ export const PatientDetailView = ({ onToggleDone={(taskId, done) => handleToggleDone(taskId, done)} showPatient={false} showAssignee={!!(task.assignee || task.assigneeTeam)} + fullWidth={true} /> ))}
@@ -1396,7 +1649,7 @@ export const PatientDetailView = ({ {isEditMode && patientId && ( - + )} diff --git a/web/components/patients/PatientList.tsx b/web/components/patients/PatientList.tsx index 987de83a..9dfb271a 100644 --- a/web/components/patients/PatientList.tsx +++ b/web/components/patients/PatientList.tsx @@ -61,8 +61,8 @@ export const PatientList = forwardRef(({ initi extractItems: (result) => result.patients, mode: 'infinite', enabled: !isPrinting, - refetchOnWindowFocus: !isPrinting, - refetchOnMount: true, + refetchOnWindowFocus: !isPrinting, + refetchOnMount: true, }) useEffect(() => { @@ -134,16 +134,14 @@ export const PatientList = forwardRef(({ initi }, []) useEffect(() => { - if (initialPatientId && patients.length > 0 && openedPatientId !== initialPatientId) { + if (initialPatientId && openedPatientId !== initialPatientId) { const patient = patients.find(p => p.id === initialPatientId) if (patient) { setSelectedPatient(patient) + } setIsPanelOpen(true) setOpenedPatientId(initialPatientId) onInitialPatientOpened?.() - } - } else if (!initialPatientId) { - setOpenedPatientId(null) } }, [initialPatientId, patients, openedPatientId, onInitialPatientOpened]) @@ -155,6 +153,7 @@ export const PatientList = forwardRef(({ initi const handleClose = () => { setIsPanelOpen(false) setSelectedPatient(undefined) + setOpenedPatientId(null) } const handlePrint = () => { @@ -356,10 +355,10 @@ export const PatientList = forwardRef(({ initi diff --git a/web/components/tasks/AssigneeSelect.tsx b/web/components/tasks/AssigneeSelect.tsx index f5bf6743..9effbce3 100644 --- a/web/components/tasks/AssigneeSelect.tsx +++ b/web/components/tasks/AssigneeSelect.tsx @@ -22,7 +22,7 @@ export const AssigneeSelect = ({ value, onValueChanged, allowTeams = true, - allowUnassigned = true, + allowUnassigned: _allowUnassigned = false, excludeUserIds = [], id, className, @@ -33,8 +33,10 @@ export const AssigneeSelect = ({ const triggerRef = useRef(null) const dropdownRef = useRef(null) const searchInputRef = useRef(null) + const searchInputId = useMemo(() => id ? `${id}-search` : `assignee-select-search-${Math.random().toString(36).substr(2, 9)}`, [id]) - const { data: usersData } = useGetUsersQuery(undefined, {}) + const { data: usersData } = useGetUsersQuery(undefined, { + }) const { data: locationsData } = useGetLocationsQuery(undefined, {}) const teams = useMemo(() => { @@ -73,7 +75,6 @@ export const AssigneeSelect = ({ if (!value || value === '') { return 'Choose user or team' } - if (value === 'unassigned') return translation('unassigned') if (value.startsWith('team:')) { const teamId = value.replace('team:', '') const team = teams.find(t => t.id === teamId) @@ -84,7 +85,7 @@ export const AssigneeSelect = ({ } const getDisplayAvatar = () => { - if (value === 'unassigned' || !value) return null + if (!value) return null if (value.startsWith('team:')) { return } @@ -105,11 +106,14 @@ export const AssigneeSelect = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node if ( dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && + !dropdownRef.current.contains(target) && triggerRef.current && - !triggerRef.current.contains(event.target as Node) + !triggerRef.current.contains(target) && + searchInputRef.current && + !searchInputRef.current.contains(target) ) { setIsOpen(false) } @@ -117,13 +121,24 @@ export const AssigneeSelect = ({ if (isOpen) { document.addEventListener('mousedown', handleClickOutside) - setTimeout(() => { + let attempts = 0 + const maxAttempts = 10 + const focusInput = () => { const input = searchInputRef.current?.querySelector('input') as HTMLInputElement | null if (input) { input.focus() + input.select() + } else if (attempts < maxAttempts) { + attempts++ + requestAnimationFrame(focusInput) } - }, 0) - return () => document.removeEventListener('mousedown', handleClickOutside) + } + requestAnimationFrame(() => { + requestAnimationFrame(focusInput) + }) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } } }, [isOpen]) @@ -143,7 +158,7 @@ export const AssigneeSelect = ({ type="button" onClick={() => setIsOpen(!isOpen)} className={clsx( - 'flex items-center gap-2 justify-between w-full h-10 px-3 text-left border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-on-surface hover:bg-surface-hover focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary transition-colors overflow-hidden', + 'flex items-center gap-2 justify-between w-full h-10 px-3 text-left border-2 border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-on-surface hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors overflow-hidden ml-0.5', className )} > @@ -151,21 +166,25 @@ export const AssigneeSelect = ({ {getDisplayAvatar()} {getDisplayValue()}
- + {isOpen && triggerRect && createPortal(
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + className="fixed z-[10000] mt-1 bg-white dark:bg-gray-800 border border-divider rounded-md shadow-lg min-w-[200px] max-w-[400px] max-h-[400px] flex flex-col" style={{ top: triggerRect.bottom + 4, left: triggerRect.left, width: triggerRect.width, }} > -
-
+
+
setSearchQuery(e.target.value)} @@ -175,21 +194,6 @@ export const AssigneeSelect = ({
- {allowUnassigned && ( - - )} {filteredUsers.length > 0 && ( <>
{translation('users') ?? 'Users'}
diff --git a/web/components/tasks/TaskCardView.tsx b/web/components/tasks/TaskCardView.tsx index 0f96c571..4b3e6e36 100644 --- a/web/components/tasks/TaskCardView.tsx +++ b/web/components/tasks/TaskCardView.tsx @@ -1,13 +1,16 @@ -import { Avatar, Button, CheckboxUncontrolled } from '@helpwave/hightide' -import { Clock, User, Users } from 'lucide-react' +import { Button, Checkbox } from '@helpwave/hightide' +import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' +import { Clock, User, Users, Flag } from 'lucide-react' import clsx from 'clsx' import { SmartDate } from '@/utils/date' import { LocationChips } from '@/components/patients/LocationChips' import type { TaskViewModel } from './TaskList' import { useRouter } from 'next/router' -import { useCompleteTaskMutation, useReopenTaskMutation } from '@/api/gql/generated' -import { useState } from 'react' +import { useCompleteTaskMutation, useReopenTaskMutation, type GetGlobalDataQuery } from '@/api/gql/generated' +import { useState, useEffect, useRef, useMemo } from 'react' import { UserInfoPopup } from '@/components/UserInfoPopup' +import { useQueryClient } from '@tanstack/react-query' +import { useTasksContext } from '@/hooks/useTasksContext' type FlexibleTask = { id: string, @@ -33,6 +36,7 @@ type FlexibleTask = { name: string, avatarURL?: string | null, avatarUrl?: string | null, + isOnline?: boolean | null, } | null, assigneeTeam?: { id: string, @@ -48,6 +52,7 @@ type TaskCardViewProps = { showPatient?: boolean, onRefetch?: () => void, className?: string, + fullWidth?: boolean, } const isOverdue = (dueDate: Date | undefined, done: boolean): boolean => { @@ -63,21 +68,106 @@ const isCloseToDueDate = (dueDate: Date | undefined, done: boolean): boolean => return dueTime > now && dueTime - now <= oneHour } +const getPriorityColor = (priority: string | null | undefined): string => { + if (!priority) return '' + switch (priority) { + case 'P1': + return 'border-l-4 border-l-green-500' + case 'P2': + return 'border-l-4 border-l-blue-500' + case 'P3': + return 'border-l-4 border-l-orange-500' + case 'P4': + return 'border-l-4 border-l-red-500' + default: + return '' + } +} + const toDate = (date: Date | string | null | undefined): Date | undefined => { if (!date) return undefined if (date instanceof Date) return date return new Date(date) } -export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showAssignee: _showAssignee = false, showPatient = true, onRefetch, className }: TaskCardViewProps) => { +export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showAssignee: _showAssignee = false, showPatient = true, onRefetch, className, fullWidth: _fullWidth = false }: TaskCardViewProps) => { const router = useRouter() + const queryClient = useQueryClient() + const { selectedRootLocationIds } = useTasksContext() const [selectedUserId, setSelectedUserId] = useState(null) + const [optimisticDone, setOptimisticDone] = useState(null) + const pendingCheckedRef = useRef(null) const flexibleTask = task as FlexibleTask const taskName = task.name || flexibleTask.title || '' const descriptionPreview = task.description + const displayDone = optimisticDone !== null ? optimisticDone : task.done - const { mutate: completeTask } = useCompleteTaskMutation({ onSuccess: onRefetch }) - const { mutate: reopenTask } = useReopenTaskMutation({ onSuccess: onRefetch }) + const { mutate: completeTask } = useCompleteTaskMutation({ + onMutate: async (variables) => { + const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined + await queryClient.cancelQueries({ queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }] }) + const previousData = queryClient.getQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }]) + const newDone = pendingCheckedRef.current ?? true + if (previousData?.me?.tasks) { + queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], { + ...previousData, + me: previousData.me ? { + ...previousData.me, + tasks: previousData.me.tasks.map(task => task.id === variables.id ? { ...task, done: newDone } : task) + } : null + }) + } + return { previousData } + }, + onSuccess: async () => { + pendingCheckedRef.current = null + setOptimisticDone(null) + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) + onRefetch?.() + }, + onError: (error, variables, context) => { + pendingCheckedRef.current = null + setOptimisticDone(null) + if (context?.previousData) { + const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined + queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], context.previousData) + } + } + }) + const { mutate: reopenTask } = useReopenTaskMutation({ + onMutate: async (variables) => { + const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined + await queryClient.cancelQueries({ queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }] }) + const previousData = queryClient.getQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }]) + const newDone = pendingCheckedRef.current ?? false + if (previousData?.me?.tasks) { + queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], { + ...previousData, + me: previousData.me ? { + ...previousData.me, + tasks: previousData.me.tasks.map(task => task.id === variables.id ? { ...task, done: newDone } : task) + } : null + }) + } + return { previousData } + }, + onSuccess: async () => { + pendingCheckedRef.current = null + setOptimisticDone(null) + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) + onRefetch?.() + }, + onError: (error, variables, context) => { + pendingCheckedRef.current = null + setOptimisticDone(null) + if (context?.previousData) { + const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined + queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], context.previousData) + } + } + }) const dueDate = toDate(task.dueDate) const overdue = dueDate ? isOverdue(dueDate, task.done) : false @@ -85,11 +175,18 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA const dueDateColorClass = overdue ? '!text-red-500' : closeToDue ? '!text-orange-500' : '' const assigneeAvatarUrl = task.assignee?.avatarURL || (flexibleTask.assignee?.avatarUrl) + const expectedFinishDate = useMemo(() => { + if (!dueDate || !flexibleTask.estimatedTime) return null + const finishDate = new Date(dueDate) + finishDate.setMinutes(finishDate.getMinutes() + flexibleTask.estimatedTime) + return finishDate + }, [dueDate, flexibleTask.estimatedTime]) + const borderColorClass = overdue ? 'border-red-500' : closeToDue - ? 'border-orange-500' - : 'border-neutral-300 dark:border-neutral-600' + ? 'border-orange-500' + : 'border-neutral-300 dark:border-neutral-600' const handlePatientClick = (e: React.MouseEvent) => { e.stopPropagation() @@ -98,18 +195,30 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA } } + useEffect(() => { + setOptimisticDone(null) + }, [task.done, task.id]) + const handleToggleDone = (checked: boolean) => { - if (!checked) { + if (_onToggleDone) { + _onToggleDone(task.id, checked) + return + } + pendingCheckedRef.current = checked + setOptimisticDone(checked) + if (checked) { completeTask({ id: task.id }) } else { reopenTask({ id: task.id }) } } + const priorityBorderClass = getPriorityColor(task.priority || (task as FlexibleTask).priority) + return (
onClick(task)} - className={clsx('border-2 p-5 rounded-lg text-left w-full transition-colors hover:border-primary relative bg-[rgba(255,255,255,1)] dark:bg-[rgba(55,65,81,1)] overflow-hidden cursor-pointer', borderColorClass, className)} + className={clsx('border-2 p-5 rounded-lg text-left transition-colors hover:border-primary relative bg-[rgba(255,255,255,1)] dark:bg-[rgba(55,65,81,1)] overflow-hidden cursor-pointer w-full', borderColorClass, priorityBorderClass, className)} role="button" tabIndex={0} onKeyDown={(e) => { @@ -121,8 +230,8 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA >
e.stopPropagation()}> - @@ -134,9 +243,9 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA
)}
- )} -
- {(task as FlexibleTask).estimatedTime && ( -
- - - {(task as FlexibleTask).estimatedTime! < 60 - ? `${(task as FlexibleTask).estimatedTime}m` - : `${Math.floor((task as FlexibleTask).estimatedTime! / 60)}h ${(task as FlexibleTask).estimatedTime! % 60}m`} - -
- )} - {dueDate && ( -
- - +
+
+ {(task as FlexibleTask).estimatedTime && ( +
+ + + {(task as FlexibleTask).estimatedTime! < 60 + ? `${(task as FlexibleTask).estimatedTime}m` + : `${Math.floor((task as FlexibleTask).estimatedTime! / 60)}h ${(task as FlexibleTask).estimatedTime! % 60}m`} + +
+ )} + {dueDate && ( +
+ + +
+ )} +
+ {expectedFinishDate && ( +
+ +
)}
@@ -223,4 +341,3 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA
) } - diff --git a/web/components/tasks/TaskDetailView.tsx b/web/components/tasks/TaskDetailView.tsx index f7f4e80f..98566e8a 100644 --- a/web/components/tasks/TaskDetailView.tsx +++ b/web/components/tasks/TaskDetailView.tsx @@ -6,6 +6,7 @@ import { PropertyEntity, useAssignTaskMutation, useAssignTaskToTeamMutation, + useCompleteTaskMutation, useCreateTaskMutation, useDeleteTaskMutation, useGetLocationsQuery, @@ -13,6 +14,7 @@ import { useGetPropertyDefinitionsQuery, useGetTaskQuery, useGetUsersQuery, + useReopenTaskMutation, useUnassignTaskMutation, useUnassignTaskFromTeamMutation, UpdateTaskDocument, @@ -20,6 +22,7 @@ import { type UpdateTaskMutation, type UpdateTaskMutationVariables } from '@/api/gql/generated' +import { useQueryClient } from '@tanstack/react-query' import { Button, Checkbox, @@ -36,7 +39,9 @@ import { Textarea } from '@helpwave/hightide' import { useTasksContext } from '@/hooks/useTasksContext' -import { User } from 'lucide-react' +import { User, Flag } from 'lucide-react' +import { SmartDate } from '@/utils/date' +import { AssigneeSelect } from './AssigneeSelect' import { SidePanel } from '@/components/layout/SidePanel' import { localToUTCWithSameTime, PatientDetailView } from '@/components/patients/PatientDetailView' import { PropertyList } from '@/components/PropertyList' @@ -53,6 +58,7 @@ interface TaskDetailViewProps { export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: TaskDetailViewProps) => { const translation = useTasksTranslation() + const queryClient = useQueryClient() const { selectedRootLocationIds } = useTasksContext() const [isShowingPatientDialog, setIsShowingPatientDialog] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) @@ -80,13 +86,9 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: } ) - const { data: usersData } = useGetUsersQuery(undefined, {}) - const { data: locationsData } = useGetLocationsQuery(undefined, {}) + useGetUsersQuery(undefined, {}) + useGetLocationsQuery(undefined, {}) - const teams = useMemo(() => { - if (!locationsData?.locationNodes) return [] - return locationsData.locationNodes.filter(loc => loc.kind === 'TEAM') - }, [locationsData]) const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() @@ -98,7 +100,9 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: }, [propertyDefinitionsData]) const { mutate: createTask, isLoading: isCreating } = useCreateTaskMutation({ - onSuccess: () => { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) onSuccess() onClose() }, @@ -117,7 +121,7 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: queryKey: ['GetTask', { id: taskId }], timeoutMs: 3000, immediateFields: ['assigneeId', 'assigneeTeamId'] as unknown as (keyof { id: string, data: UpdateTaskInput })[], - onChangeFields: ['priority'] as unknown as (keyof { id: string, data: UpdateTaskInput })[], + onChangeFields: ['priority', 'done'] as unknown as (keyof { id: string, data: UpdateTaskInput })[], onBlurFields: ['title', 'description'] as unknown as (keyof { id: string, data: UpdateTaskInput })[], onCloseFields: ['dueDate'] as unknown as (keyof { id: string, data: UpdateTaskInput })[], getChecksum: (data) => data?.updateTask?.checksum || null, @@ -125,31 +129,62 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: }) const { mutate: assignTask } = useAssignTaskMutation({ - onSuccess: () => { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() }, }) const { mutate: unassignTask } = useUnassignTaskMutation({ - onSuccess: () => { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() }, }) const { mutate: assignTaskToTeam } = useAssignTaskToTeamMutation({ - onSuccess: () => { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() }, }) const { mutate: unassignTaskFromTeam } = useUnassignTaskFromTeamMutation({ - onSuccess: () => { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + onSuccess() + }, + }) + + const { mutate: completeTask } = useCompleteTaskMutation({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetTask', { id: taskId }] }) + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) + const patientId = taskData?.task?.patient?.id + if (patientId) { + await queryClient.invalidateQueries({ queryKey: ['GetPatient', { id: patientId }] }) + } + onSuccess() + }, + }) + + const { mutate: reopenTask } = useReopenTaskMutation({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetTask', { id: taskId }] }) + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) + const patientId = taskData?.task?.patient?.id + if (patientId) { + await queryClient.invalidateQueries({ queryKey: ['GetPatient', { id: patientId }] }) + } onSuccess() }, }) const { mutate: deleteTask, isLoading: isDeleting } = useDeleteTaskMutation({ - onSuccess: () => { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() onClose() }, @@ -281,7 +316,13 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: } const patients = patientsData?.patients || [] - const users = usersData?.users || [] + + const expectedFinishDate = useMemo(() => { + if (!formData.dueDate || !formData.estimatedTime) return null + const finishDate = new Date(formData.dueDate) + finishDate.setMinutes(finishDate.getMinutes() + formData.estimatedTime) + return finishDate + }, [formData.dueDate, formData.estimatedTime]) if (isEditMode && isLoadingTask) { return @@ -300,9 +341,12 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: checked={formData.done || false} onCheckedChange={(checked) => { if (!taskId) return - const update: Partial = { done: checked } - handleFieldUpdate(update) updateLocalState({ done: checked }) + if (checked) { + completeTask({ id: taskId }) + } else { + reopenTask({ id: taskId }) + } }} className="rounded-full scale-125" /> @@ -386,33 +430,13 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: ? `team:${taskData.task.assigneeTeam.id}` : (formData.assigneeId || taskData?.task?.assignee?.id || 'unassigned') return ( - + allowTeams={true} + allowUnassigned={true} + /> ) }} @@ -525,6 +549,13 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: )} + {expectedFinishDate && ( +
+ + +
+ )} + {({ isShowingError: _1, setIsShowingError: _2, ...bag }) => (