From e83da7e265e820a52b65bbc3823fb50360cff8a4 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Tue, 6 Jan 2026 02:49:06 +0100 Subject: [PATCH 1/2] upgrade user info --- web/api/gql/generated.ts | 51 +++- web/api/graphql/GetMyTasks.graphql | 2 + web/api/graphql/GetOverviewData.graphql | 2 + web/api/graphql/GetPatient.graphql | 2 + web/api/graphql/GetPatients.graphql | 2 + web/api/graphql/GetTask.graphql | 2 + web/api/graphql/GetTasks.graphql | 2 + web/api/graphql/GetUser.graphql | 2 + web/api/graphql/GetUsers.graphql | 1 + web/api/graphql/GlobalData.graphql | 2 + web/api/graphql/TaskMutations.graphql | 8 + web/components/AuditLogTimeline.tsx | 203 ++++++++++----- web/components/AvatarStatusComponent.tsx | 2 +- web/components/UserInfoPopup.tsx | 60 +++-- web/components/layout/Page.tsx | 34 +-- .../locations/LocationSelectionDialog.tsx | 61 ++++- web/components/patients/PatientDetailView.tsx | 244 +++++++++++++++++- web/components/patients/PatientList.tsx | 16 +- web/components/tasks/AssigneeSelect.tsx | 61 +++-- web/components/tasks/TaskCardView.tsx | 134 ++++++++-- web/components/tasks/TaskDetailView.tsx | 93 ++++--- web/components/tasks/TaskList.tsx | 230 +++++++++++++---- web/hooks/useTasksContext.tsx | 5 +- web/i18n/translations.ts | 33 +-- web/locales/de-DE.arb | 15 +- web/locales/en-US.arb | 21 +- web/pages/index.tsx | 92 ++++++- web/pages/location/[id].tsx | 6 +- web/pages/patients/index.tsx | 2 +- web/pages/tasks/index.tsx | 7 +- web/pages/teams/[id].tsx | 2 +- 31 files changed, 1071 insertions(+), 326 deletions(-) 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..00bf185d 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) @@ -110,6 +109,7 @@ export const PatientDetailView = ({ { enabled: isEditMode, refetchOnMount: true, + refetchInterval: 30000, } ) @@ -138,6 +138,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 +161,34 @@ 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 + } + } } ], 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 +212,34 @@ 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 + } + } } ], 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 +320,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 +617,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 +674,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 +731,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 +788,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 +817,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 +1032,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 +1072,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 +1091,10 @@ export const PatientDetailView = ({ } } + if (isEditMode && isLoadingPatient) { + return + } + return (
{isEditMode && patientName && ( @@ -946,6 +1160,7 @@ export const PatientDetailView = ({ onToggleDone={(taskId, done) => handleToggleDone(taskId, done)} showPatient={false} showAssignee={!!(task.assignee || task.assigneeTeam)} + fullWidth={true} /> ))}
@@ -973,6 +1188,7 @@ export const PatientDetailView = ({ onToggleDone={(taskId, done) => handleToggleDone(taskId, done)} showPatient={false} showAssignee={!!(task.assignee || task.assigneeTeam)} + fullWidth={true} /> ))}
@@ -1396,7 +1612,7 @@ export const PatientDetailView = ({ {isEditMode && patientId && ( - + )} diff --git a/web/components/patients/PatientList.tsx b/web/components/patients/PatientList.tsx index 987de83a..8324ea24 100644 --- a/web/components/patients/PatientList.tsx +++ b/web/components/patients/PatientList.tsx @@ -61,8 +61,9 @@ export const PatientList = forwardRef(({ initi extractItems: (result) => result.patients, mode: 'infinite', enabled: !isPrinting, - refetchOnWindowFocus: !isPrinting, - refetchOnMount: true, + refetchOnWindowFocus: !isPrinting, + refetchOnMount: true, + refetchInterval: !isPrinting ? 30000 : false, }) useEffect(() => { @@ -134,16 +135,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 +154,7 @@ export const PatientList = forwardRef(({ initi const handleClose = () => { setIsPanelOpen(false) setSelectedPatient(undefined) + setOpenedPatientId(null) } const handlePrint = () => { @@ -356,10 +356,10 @@ export const PatientList = forwardRef(({ initi diff --git a/web/components/tasks/AssigneeSelect.tsx b/web/components/tasks/AssigneeSelect.tsx index f5bf6743..f857c7ac 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,11 @@ 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, { + refetchInterval: 30000, + }) const { data: locationsData } = useGetLocationsQuery(undefined, {}) const teams = useMemo(() => { @@ -73,7 +76,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 +86,7 @@ export const AssigneeSelect = ({ } const getDisplayAvatar = () => { - if (value === 'unassigned' || !value) return null + if (!value) return null if (value.startsWith('team:')) { return } @@ -105,11 +107,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 +122,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 +159,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 border-divider 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', className )} > @@ -156,16 +172,20 @@ export const AssigneeSelect = ({ {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 +195,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..a97b624c 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 { Button, Checkbox } from '@helpwave/hightide' +import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { Clock, User, Users } 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 } 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,104 @@ 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'] }) + 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'] }) + 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 @@ -88,8 +176,8 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA 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 +186,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 +221,8 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA >
e.stopPropagation()}> - @@ -134,9 +234,9 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA
)}
- ) } - diff --git a/web/components/tasks/TaskDetailView.tsx b/web/components/tasks/TaskDetailView.tsx index f7f4e80f..14ac79b2 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, @@ -37,6 +40,7 @@ import { } from '@helpwave/hightide' import { useTasksContext } from '@/hooks/useTasksContext' import { User } from 'lucide-react' +import { AssigneeSelect } from './AssigneeSelect' import { SidePanel } from '@/components/layout/SidePanel' import { localToUTCWithSameTime, PatientDetailView } from '@/components/patients/PatientDetailView' import { PropertyList } from '@/components/PropertyList' @@ -53,6 +57,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 +85,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 +99,8 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: }, [propertyDefinitionsData]) const { mutate: createTask, isLoading: isCreating } = useCreateTaskMutation({ - onSuccess: () => { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() onClose() }, @@ -117,7 +119,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 +127,60 @@ 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'] }) + 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'] }) + 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 +312,6 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: } const patients = patientsData?.patients || [] - const users = usersData?.users || [] if (isEditMode && isLoadingTask) { return @@ -300,9 +330,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 +419,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} + /> ) }} diff --git a/web/components/tasks/TaskList.tsx b/web/components/tasks/TaskList.tsx index 937b30cc..402e2d1d 100644 --- a/web/components/tasks/TaskList.tsx +++ b/web/components/tasks/TaskList.tsx @@ -1,11 +1,14 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect } from 'react' -import { Avatar, Button, Checkbox, CheckboxUncontrolled, ConfirmDialog, Dialog, FillerRowElement, SearchBar, Select, SelectOption, Table, Tooltip } from '@helpwave/hightide' +import { useQueryClient } from '@tanstack/react-query' +import { Button, Checkbox, CheckboxUncontrolled, ConfirmDialog, Dialog, FillerRowElement, SearchBar, Table, Tooltip } from '@helpwave/hightide' import { PlusIcon, Table as TableIcon, LayoutGrid, UserCheck, Users, Printer } from 'lucide-react' -import { useAssignTaskMutation, useCompleteTaskMutation, useGetUsersQuery, useReopenTaskMutation } from '@/api/gql/generated' +import { useAssignTaskMutation, useAssignTaskToTeamMutation, useCompleteTaskMutation, useReopenTaskMutation, type GetGlobalDataQuery } from '@/api/gql/generated' +import { AssigneeSelect } from './AssigneeSelect' import clsx from 'clsx' import { SmartDate } from '@/utils/date' import { SidePanel } from '@/components/layout/SidePanel' import { TaskDetailView } from '@/components/tasks/TaskDetailView' +import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { PatientDetailView } from '@/components/patients/PatientDetailView' import { LocationChips } from '@/components/patients/LocationChips' import { useTasksTranslation } from '@/i18n/useTasksTranslation' @@ -32,7 +35,7 @@ export type TaskViewModel = { parent?: { id: string, title: string, parent?: { id: string, title: string } | null } | null, }>, }, - assignee?: { id: string, name: string, avatarURL?: string | null }, + assignee?: { id: string, name: string, avatarURL?: string | null, isOnline?: boolean | null }, assigneeTeam?: { id: string, title: string }, done: boolean, } @@ -105,15 +108,87 @@ const STORAGE_KEY_SHOW_DONE = 'task-show-done' export const TaskList = forwardRef(({ tasks: initialTasks, onRefetch, showAssignee = false, initialTaskId, onInitialTaskOpened, headerActions }, ref) => { const translation = useTasksTranslation() - const { totalPatientsCount, user } = useTasksContext() + const queryClient = useQueryClient() + const { totalPatientsCount, user, selectedRootLocationIds } = useTasksContext() const { viewType, toggleView } = useTaskViewToggle() - const { mutate: completeTask } = useCompleteTaskMutation({ onSuccess: onRefetch }) - const { mutate: reopenTask } = useReopenTaskMutation({ onSuccess: onRefetch }) - const { mutate: assignTask } = useAssignTaskMutation({ onSuccess: onRefetch }) - const [isPrinting, setIsPrinting] = useState(false) - const { data: usersData } = useGetUsersQuery(undefined, { - refetchOnWindowFocus: !isPrinting, + const [optimisticUpdates, setOptimisticUpdates] = useState>(new Map()) + 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 }]) + 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: true } : task) + } : null + }) + } + return { previousData } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + onRefetch?.() + }, + onError: (error, variables, context) => { + if (context?.previousData) { + const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined + queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], context.previousData) + } + setOptimisticUpdates(prev => { + const next = new Map(prev) + next.delete(variables.id) + return next + }) + } }) + 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 }]) + 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: false } : task) + } : null + }) + } + return { previousData } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + onRefetch?.() + }, + onError: (error, variables, context) => { + if (context?.previousData) { + const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined + queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], context.previousData) + } + setOptimisticUpdates(prev => { + const next = new Map(prev) + next.delete(variables.id) + return next + }) + } + }) + const { mutate: assignTask } = useAssignTaskMutation({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + onRefetch?.() + } + }) + const { mutate: assignTaskToTeam } = useAssignTaskToTeamMutation({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + onRefetch?.() + } + }) + const [, setIsPrinting] = useState(false) useEffect(() => { const handleBeforePrint = () => setIsPrinting(true) @@ -181,8 +256,31 @@ export const TaskList = forwardRef(({ tasks: initial } }, [initialTaskId, initialTasks, openedTaskId, onInitialTaskOpened]) + useEffect(() => { + setOptimisticUpdates(prev => { + const next = new Map(prev) + let hasChanges = false + + for (const [taskId, optimisticDone] of next.entries()) { + const task = initialTasks.find(t => t.id === taskId) + if (task && task.done === optimisticDone) { + next.delete(taskId) + hasChanges = true + } + } + + return hasChanges ? next : prev + }) + }, [initialTasks]) + const tasks = useMemo(() => { - let data = initialTasks + let data = initialTasks.map(task => { + const optimisticDone = optimisticUpdates.get(task.id) + if (optimisticDone !== undefined) { + return { ...task, done: optimisticDone } + } + return task + }) if (!showDone) { data = data.filter(t => !t.done) @@ -206,17 +304,20 @@ export const TaskList = forwardRef(({ tasks: initial return a.dueDate.getTime() - b.dueDate.getTime() }) - }, [initialTasks, searchQuery, showDone]) + }, [initialTasks, optimisticUpdates, searchQuery, showDone]) const openTasks = useMemo(() => { - return initialTasks.filter(t => !t.done && t.assignee?.id === user?.id) - }, [initialTasks, user?.id]) - - const users = useMemo(() => { - return usersData?.users?.filter(u => u.id !== user?.id) || [] - }, [usersData, user?.id]) + const tasksWithOptimistic = initialTasks.map(task => { + const optimisticDone = optimisticUpdates.get(task.id) + if (optimisticDone !== undefined) { + return { ...task, done: optimisticDone } + } + return task + }) + return tasksWithOptimistic.filter(t => !t.done && t.assignee?.id === user?.id) + }, [initialTasks, optimisticUpdates, user?.id]) - const canHandover = openTasks.length > 0 && users.length > 0 + const canHandover = openTasks.length > 0 const handleHandoverClick = () => { if (!canHandover) { @@ -234,10 +335,23 @@ export const TaskList = forwardRef(({ tasks: initial const handleConfirmHandover = () => { if (!selectedUserId) return + const isTeam = selectedUserId.startsWith('team:') + const assigneeId = isTeam ? selectedUserId.replace('team:', '') : selectedUserId + openTasks.forEach(task => { - assignTask({ id: task.id, userId: selectedUserId }) + if (isTeam) { + assignTaskToTeam({ id: task.id, teamId: assigneeId }) + } else { + assignTask({ id: task.id, userId: assigneeId }) + } }) + queryClient.invalidateQueries({ queryKey: ['GetTask'] }) + queryClient.invalidateQueries({ queryKey: ['GetTasks'] }) + queryClient.invalidateQueries({ queryKey: ['GetPatients'] }) + queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) + queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + setIsConfirmDialogOpen(false) setSelectedUserId(null) } @@ -249,21 +363,24 @@ export const TaskList = forwardRef(({ tasks: initial id: 'done', header: translation('status'), accessorKey: 'done', - cell: ({ row }) => ( -
e.stopPropagation()}> - { - if (!checked) { - completeTask({ id: row.original.id }) - } else { - reopenTask({ id: row.original.id }) - } - }} - className={clsx('rounded-full')} - /> -
- ), + cell: ({ row }) => { + const isDone = row.original.done + return ( +
e.stopPropagation()}> + { + if (checked) { + completeTask({ id: row.original.id }) + } else { + reopenTask({ id: row.original.id }) + } + }} + className={clsx('rounded-full')} + /> +
+ ) + }, minSize: 110, size: 110, maxSize: 110, @@ -380,8 +497,9 @@ export const TaskList = forwardRef(({ tasks: initial onClick={() => setSelectedUserPopupId(assignee.id)} className="flex-row-2 items-center hover:opacity-75 transition-opacity" > - (({ tasks: initial [translation, completeTask, reopenTask, showAssignee] ) - const handleToggleDone = (taskId: string, done: boolean) => { - if (!done) { + const handleToggleDone = (taskId: string, checked: boolean) => { + const task = initialTasks.find(t => t.id === taskId) + if (!task) return + + setOptimisticUpdates(prev => { + const next = new Map(prev) + next.set(taskId, checked) + return next + }) + if (checked) { completeTask({ id: taskId }) } else { reopenTask({ id: taskId }) @@ -517,9 +643,9 @@ export const TaskList = forwardRef(({ tasks: initial />
) : ( -
+
{tasks.length === 0 ? ( -
+
{translation('noOpenTasks')}
) : ( @@ -531,7 +657,7 @@ export const TaskList = forwardRef(({ tasks: initial onClick={(t) => setTaskDialogState({ isOpen: true, taskId: t.id })} showAssignee={showAssignee} onRefetch={onRefetch} - className={getPriorityColor(task.priority)} + className={clsx('w-full', getPriorityColor(task.priority))} /> )) )} @@ -573,16 +699,13 @@ export const TaskList = forwardRef(({ tasks: initial description={translation('shiftHandoverDescription') || `Select a user to transfer all ${openTasks.length} open task${openTasks.length !== 1 ? 's' : ''} assigned to you.`} >
- + allowTeams={true} + allowUnassigned={false} + excludeUserIds={user?.id ? [user.id] : []} + />
(({ tasks: initial }} onConfirm={handleConfirmHandover} titleElement={translation('confirmShiftHandover') || 'Confirm Shift Handover'} - description={translation('confirmShiftHandoverDescription') || `Are you sure you want to transfer ${openTasks.length} open task${openTasks.length !== 1 ? 's' : ''} to ${users.find(u => u.id === selectedUserId)?.name || 'the selected user'}?`} + description={(() => { + if (!selectedUserId) return '' + const isTeam = selectedUserId.startsWith('team:') + const taskCount = openTasks.length + const taskText = taskCount !== 1 ? 'tasks' : 'task' + const recipientText = isTeam ? 'selected team' : 'selected user' + return translation('confirmShiftHandoverDescription') || `Are you sure you want to transfer ${taskCount} open ${taskText} to the ${recipientText}?` + })()} /> { enabled: !isAuthLoading && !!identity, refetchOnWindowFocus: true, refetchOnMount: true, + refetchInterval: 30000, } ) @@ -191,7 +193,8 @@ export const TasksContextProvider = ({ children }: PropsWithChildren) => { id: data.me.id, name: data.me.name, avatarUrl: data.me.avatarUrl, - organizations: data.me.organizations ?? null + organizations: data.me.organizations ?? null, + isOnline: data.me.isOnline ?? null } : undefined, myTasksCount: data?.me?.tasks?.filter(t => !t.done).length ?? 0, totalPatientsCount, diff --git a/web/i18n/translations.ts b/web/i18n/translations.ts index a97c4e67..8a45b124 100644 --- a/web/i18n/translations.ts +++ b/web/i18n/translations.ts @@ -222,6 +222,7 @@ export type TasksTranslationEntries = { 'updateLocation': string, 'updateLocationConfirmation': string, 'url': string, + 'userInformation': string, 'username': string, 'users': string, 'visibility': string, @@ -270,23 +271,23 @@ export const tasksTranslation: Translation { - return `Guten Morgen, ${name}` + return `Guten Morgen, ${name}!` }, 'dashboardWelcomeAfternoon': ({ name }): string => { - return `Guten Nachmittag, ${name}` + return `Guten Nachmittag, ${name}!` }, 'dashboardWelcomeDescription': `Hier ist, was heute passiert.`, 'dashboardWelcomeEvening': ({ name }): string => { - return `Guten Abend, ${name}` + return `Guten Abend, ${name}!` }, 'dashboardWelcomeMorning': ({ name }): string => { - return `Guten Morgen, ${name}` + return `Guten Morgen, ${name}!` }, 'dashboardWelcomeNight': ({ name }): string => { - return `Gute Nacht, ${name}` + return `Gute Nacht, ${name}!` }, 'dashboardWelcomeNoon': ({ name }): string => { - return `Guten Mittag, ${name}` + return `Guten Mittag, ${name}!` }, 'delete': `Löschen`, 'deletePatient': `Patient löschen`, @@ -573,6 +574,7 @@ export const tasksTranslation: Translation { - return `Good Morning, ${name}` + return `Good Morning, ${name}!` }, 'dashboardWelcomeAfternoon': ({ name }): string => { - return `Good Afternoon, ${name}` + return `Good Afternoon, ${name}!` }, 'dashboardWelcomeDescription': `Here is what is happening today.`, 'dashboardWelcomeEvening': ({ name }): string => { - return `Good Evening, ${name}` + return `Good Evening, ${name}!` }, 'dashboardWelcomeMorning': ({ name }): string => { - return `Good Morning, ${name}` + return `Good Morning, ${name}!` }, 'dashboardWelcomeNight': ({ name }): string => { - return `Good Night, ${name}` + return `Good Night, ${name}!` }, 'dashboardWelcomeNoon': ({ name }): string => { - return `Good Afternoon, ${name}` + return `Good Afternoon, ${name}!` }, 'delete': `Delete`, 'deletePatient': `Delete Patient`, @@ -823,15 +825,15 @@ export const tasksTranslation: Translation { - return `Add ${name}` + return `Add ${name}!` }, 'rClickToAdd': ({ name }): string => { - return `Click to add ${name}` + return `Click to add ${name}!` }, 'recentPatients': `Your Recent Patients`, 'recentTasks': `Your Recent Tasks`, 'rEdit': ({ name }): string => { - return `Update ${name}` + return `Update ${name}!` }, 'removeProperty': `Remove Property`, 'removePropertyConfirmation': `Are you sure you want to remove this property? This action cannot be undone.`, @@ -921,6 +923,7 @@ export const tasksTranslation: Translation { if (!dueDate || done) return false @@ -42,14 +45,95 @@ const getGreetingKey = (): string => { const Dashboard: NextPage = () => { const translation = useTasksTranslation() - const { user, myTasksCount, totalPatientsCount } = useTasksContext() + const { user, myTasksCount, totalPatientsCount, selectedRootLocationIds } = useTasksContext() const { data } = useGetOverviewDataQuery(undefined, {}) + const queryClient = useQueryClient() + const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined const [selectedPatientId, setSelectedPatientId] = useState(null) const [selectedTaskId, setSelectedTaskId] = useState(null) - const { mutate: completeTask } = useCompleteTaskMutation({}) - const { mutate: reopenTask } = useReopenTaskMutation({}) + const { mutate: completeTask } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(CompleteTaskDocument, variables)() + }, + optimisticUpdate: (variables) => { + const updates: Array<{ queryKey: unknown[], updateFn: (oldData: unknown) => unknown }> = [ + { + queryKey: ['GetOverviewData'], + updateFn: (oldData: unknown) => { + const data = oldData as GetOverviewDataQuery | undefined + if (!data?.recentTasks) return oldData + return { + ...data, + recentTasks: data.recentTasks.map(t => + t.id === variables.id ? { ...t, done: true } : t) + } + } + } + ] + const globalData = queryClient.getQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }]) + if (globalData?.me?.tasks) { + updates.push({ + 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 + } + } + }) + } + return updates + }, + invalidateQueries: [['GetOverviewData'], ['GetTasks'], ['GetPatients'], ['GetGlobalData']], + }) + + const { mutate: reopenTask } = useSafeMutation({ + mutationFn: async (variables) => { + return fetcher(ReopenTaskDocument, variables)() + }, + optimisticUpdate: (variables) => { + const updates: Array<{ queryKey: unknown[], updateFn: (oldData: unknown) => unknown }> = [ + { + queryKey: ['GetOverviewData'], + updateFn: (oldData: unknown) => { + const data = oldData as GetOverviewDataQuery | undefined + if (!data?.recentTasks) return oldData + return { + ...data, + recentTasks: data.recentTasks.map(t => + t.id === variables.id ? { ...t, done: false } : t) + } + } + } + ] + const globalData = queryClient.getQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }]) + if (globalData?.me?.tasks) { + updates.push({ + 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 + } + } + }) + } + return updates + }, + invalidateQueries: [['GetOverviewData'], ['GetTasks'], ['GetPatients'], ['GetGlobalData']], + }) const recentPatients = useMemo(() => data?.recentPatients ?? [], [data]) const recentTasks = useMemo(() => data?.recentTasks ?? [], [data]) diff --git a/web/pages/location/[id].tsx b/web/pages/location/[id].tsx index a3b7e1ee..957009ca 100644 --- a/web/pages/location/[id].tsx +++ b/web/pages/location/[id].tsx @@ -48,6 +48,7 @@ const LocationPage: NextPage = () => { enabled: !!id && !isTeamLocation, refetchOnWindowFocus: true, refetchOnMount: true, + refetchInterval: 30000, } ) @@ -60,6 +61,7 @@ const LocationPage: NextPage = () => { enabled: !!id && isTeamLocation, refetchOnWindowFocus: true, refetchOnMount: true, + refetchInterval: 30000, } ) @@ -82,7 +84,7 @@ const LocationPage: NextPage = () => { } : undefined, assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl } + ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl, isOnline: task.assignee.isOnline ?? null } : undefined, assigneeTeam: task.assigneeTeam ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } @@ -116,7 +118,7 @@ const LocationPage: NextPage = () => { locations: mergedLocations }, assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl } + ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl, isOnline: task.assignee.isOnline ?? null } : undefined, assigneeTeam: task.assigneeTeam ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } diff --git a/web/pages/patients/index.tsx b/web/pages/patients/index.tsx index 417f126f..7d310a63 100644 --- a/web/pages/patients/index.tsx +++ b/web/pages/patients/index.tsx @@ -11,7 +11,7 @@ const PatientsPage: NextPage = () => { const translation = useTasksTranslation() const router = useRouter() const { totalPatientsCount } = useTasksContext() - const patientId = router.query['patientId'] as string | undefined + const patientId = router.isReady ? (router.query['patientId'] as string | undefined) : undefined return ( diff --git a/web/pages/tasks/index.tsx b/web/pages/tasks/index.tsx index 67812550..69dde390 100644 --- a/web/pages/tasks/index.tsx +++ b/web/pages/tasks/index.tsx @@ -25,8 +25,9 @@ const TasksPage: NextPage = () => { extractItems: (result) => result.tasks, mode: 'infinite', enabled: !!selectedRootLocationIds && !!user, - refetchOnWindowFocus: true, - refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchInterval: 30000, }) const taskId = router.query['taskId'] as string | undefined @@ -50,7 +51,7 @@ const TasksPage: NextPage = () => { } : undefined, assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl } + ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl, isOnline: task.assignee.isOnline ?? null } : undefined, })) }, [tasksData]) diff --git a/web/pages/teams/[id].tsx b/web/pages/teams/[id].tsx index 91b3a93c..157c8e9d 100644 --- a/web/pages/teams/[id].tsx +++ b/web/pages/teams/[id].tsx @@ -65,7 +65,7 @@ const TeamPage: NextPage = () => { } : undefined, assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl } + ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl, isOnline: task.assignee.isOnline ?? null } : undefined, assigneeTeam: task.assigneeTeam ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } From f7fe9ffee3e5cccceec5d49ef846a6033a9ac4e8 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Tue, 6 Jan 2026 03:22:13 +0100 Subject: [PATCH 2/2] improve ui state mapping --- backend/api/resolvers/patient.py | 32 +++++++++--- web/components/patients/PatientDetailView.tsx | 39 +++++++++++++- web/components/patients/PatientList.tsx | 1 - web/components/tasks/AssigneeSelect.tsx | 5 +- web/components/tasks/TaskCardView.tsx | 51 ++++++++++++------- web/components/tasks/TaskDetailView.tsx | 20 +++++++- web/components/tasks/TaskList.tsx | 43 +++++++++++++--- web/hooks/useTasksContext.tsx | 1 - web/pages/location/[id].tsx | 2 - web/pages/settings/index.tsx | 5 +- web/pages/tasks/index.tsx | 1 - 11 files changed, 157 insertions(+), 43 deletions(-) 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/components/patients/PatientDetailView.tsx b/web/components/patients/PatientDetailView.tsx index 00bf185d..a15de351 100644 --- a/web/components/patients/PatientDetailView.tsx +++ b/web/components/patients/PatientDetailView.tsx @@ -109,7 +109,6 @@ export const PatientDetailView = ({ { enabled: isEditMode, refetchOnMount: true, - refetchInterval: 30000, } ) @@ -175,6 +174,25 @@ export const PatientDetailView = ({ } : 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']], @@ -226,6 +244,25 @@ export const PatientDetailView = ({ } : 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']], diff --git a/web/components/patients/PatientList.tsx b/web/components/patients/PatientList.tsx index 8324ea24..9dfb271a 100644 --- a/web/components/patients/PatientList.tsx +++ b/web/components/patients/PatientList.tsx @@ -63,7 +63,6 @@ export const PatientList = forwardRef(({ initi enabled: !isPrinting, refetchOnWindowFocus: !isPrinting, refetchOnMount: true, - refetchInterval: !isPrinting ? 30000 : false, }) useEffect(() => { diff --git a/web/components/tasks/AssigneeSelect.tsx b/web/components/tasks/AssigneeSelect.tsx index f857c7ac..9effbce3 100644 --- a/web/components/tasks/AssigneeSelect.tsx +++ b/web/components/tasks/AssigneeSelect.tsx @@ -36,7 +36,6 @@ export const AssigneeSelect = ({ const searchInputId = useMemo(() => id ? `${id}-search` : `assignee-select-search-${Math.random().toString(36).substr(2, 9)}`, [id]) const { data: usersData } = useGetUsersQuery(undefined, { - refetchInterval: 30000, }) const { data: locationsData } = useGetLocationsQuery(undefined, {}) @@ -159,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-divider 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', + '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 )} > @@ -167,7 +166,7 @@ export const AssigneeSelect = ({ {getDisplayAvatar()} {getDisplayValue()}
- + {isOpen && triggerRect && createPortal(
{ @@ -155,6 +156,7 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA pendingCheckedRef.current = null setOptimisticDone(null) await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) onRefetch?.() }, onError: (error, variables, context) => { @@ -173,6 +175,13 @@ 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 @@ -298,21 +307,29 @@ 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 && ( +
+ +
)}
diff --git a/web/components/tasks/TaskDetailView.tsx b/web/components/tasks/TaskDetailView.tsx index 14ac79b2..98566e8a 100644 --- a/web/components/tasks/TaskDetailView.tsx +++ b/web/components/tasks/TaskDetailView.tsx @@ -39,7 +39,8 @@ 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' @@ -101,6 +102,7 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: const { mutate: createTask, isLoading: isCreating } = useCreateTaskMutation({ onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) onSuccess() onClose() }, @@ -158,6 +160,7 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: 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 }] }) @@ -170,6 +173,7 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: 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 }] }) @@ -313,6 +317,13 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: const patients = patientsData?.patients || [] + 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 } @@ -538,6 +549,13 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: )} + {expectedFinishDate && ( +
+ + +
+ )} + {({ isShowingError: _1, setIsShowingError: _2, ...bag }) => (