diff --git a/backend/api/context.py b/backend/api/context.py index 084d47c8..21e11554 100644 --- a/backend/api/context.py +++ b/backend/api/context.py @@ -1,9 +1,10 @@ import asyncio +import logging from datetime import datetime, timezone from typing import Any import strawberry -from auth import get_user_payload +from auth import get_token_from_connection_params, get_user_payload, verify_token from database.models.location import LocationNode, location_organizations from database.models.user import User, user_root_locations from database.session import get_db_session @@ -16,6 +17,8 @@ from starlette.requests import HTTPConnection from strawberry.fastapi import BaseContext +logger = logging.getLogger(__name__) + class LockedAsyncSession: def __init__(self, session: AsyncSession, lock: asyncio.Lock): @@ -64,6 +67,112 @@ def __init__(self, db: AsyncSession, user: "User | None" = None, organizations: Info = strawberry.Info[Context, Any] +def _organizations_from_payload(user_payload: dict) -> str | None: + organizations_raw = user_payload.get("organization") + if not organizations_raw: + return None + if isinstance(organizations_raw, list): + org_list = [str(org) for org in organizations_raw if org] + return ",".join(org_list) if org_list else None + return str(organizations_raw) + + +async def _resolve_user_from_payload( + session: AsyncSession | LockedAsyncSession, + user_payload: dict, +) -> "User | None": + user_id = user_payload.get("sub") + if not user_id: + return None + username = user_payload.get("preferred_username") or user_payload.get("name") + firstname = user_payload.get("given_name") + lastname = user_payload.get("family_name") + email = user_payload.get("email") + picture = user_payload.get("picture") + organizations = _organizations_from_payload(user_payload) + + result = await session.execute(select(User).where(User.id == user_id)) + db_user = result.scalars().first() + + if not db_user: + try: + new_user = User( + id=user_id, + username=username, + email=email, + firstname=firstname, + lastname=lastname, + title="User", + avatar_url=picture, + last_online=datetime.now(timezone.utc), + ) + session.add(new_user) + await session.commit() + await session.refresh(new_user) + db_user = new_user + except IntegrityError: + await session.rollback() + result = await session.execute( + select(User).where(User.id == user_id), + ) + db_user = result.scalars().first() + except Exception as e: + await session.rollback() + raise GraphQLError( + "Failed to create user. Please contact an administrator if you believe this is an error.", + extensions={"code": "INTERNAL_SERVER_ERROR"}, + ) from e + + if db_user and ( + db_user.username != username + or db_user.firstname != firstname + or db_user.lastname != lastname + or db_user.email != email + or db_user.avatar_url != picture + ): + db_user.username = username + db_user.firstname = firstname + db_user.lastname = lastname + db_user.email = email + if picture: + db_user.avatar_url = picture + session.add(db_user) + await session.commit() + await session.refresh(db_user) + + if db_user: + db_user.last_online = datetime.now(timezone.utc) + session.add(db_user) + try: + await _update_user_root_locations( + session, + db_user, + organizations, + ) + except Exception as e: + raise GraphQLError( + "Failed to update user root locations. Please contact an administrator if you believe this is an error.", + extensions={"code": "INTERNAL_SERVER_ERROR"}, + ) from e + + return db_user + + +async def get_user_from_connection_params( + connection_params: dict | None, + session: AsyncSession | LockedAsyncSession, +) -> "User | None": + token = get_token_from_connection_params(connection_params) + if not token: + return None + try: + user_payload = verify_token(token) + except Exception as e: + logger.warning("WebSocket auth failed for token: %s", e) + return None + return await _resolve_user_from_payload(session, user_payload) + + async def get_context( connection: HTTPConnection, session=Depends(get_db_session), @@ -73,97 +182,14 @@ async def get_context( organizations = None if user_payload: - user_id = user_payload.get("sub") - username = user_payload.get("preferred_username") or user_payload.get( - "name", - ) - firstname = user_payload.get("given_name") - lastname = user_payload.get("family_name") - email = user_payload.get("email") - picture = user_payload.get("picture") - - organizations_raw = user_payload.get("organization") - organizations = None - if organizations_raw: - if isinstance(organizations_raw, list): - org_list = [str(org) for org in organizations_raw if org] - if org_list: - organizations = ",".join(org_list) - else: - organizations = str(organizations_raw) if organizations_raw else None - - if user_id: - result = await session.execute( - select(User).where(User.id == user_id), - ) - db_user = result.scalars().first() - - if not db_user: - try: - new_user = User( - id=user_id, - username=username, - email=email, - firstname=firstname, - lastname=lastname, - title="User", - avatar_url=picture, - last_online=datetime.now(timezone.utc), - ) - session.add(new_user) - await session.commit() - await session.refresh(new_user) - db_user = new_user - except IntegrityError: - await session.rollback() - result = await session.execute( - select(User).where(User.id == user_id), - ) - db_user = result.scalars().first() - except Exception as e: - await session.rollback() - raise GraphQLError( - "Failed to create user. Please contact an administrator if you believe this is an error.", - extensions={"code": "INTERNAL_SERVER_ERROR"}, - ) from e - - if db_user and ( - db_user.username != username - or db_user.firstname != firstname - or db_user.lastname != lastname - or db_user.email != email - or db_user.avatar_url != picture - ): - db_user.username = username - db_user.firstname = firstname - db_user.lastname = lastname - db_user.email = email - if picture: - db_user.avatar_url = picture - session.add(db_user) - await session.commit() - await session.refresh(db_user) - - if db_user: - db_user.last_online = datetime.now(timezone.utc) - session.add(db_user) - try: - await _update_user_root_locations( - session, - db_user, - organizations, - ) - except Exception as e: - raise GraphQLError( - "Failed to update user root locations. Please contact an administrator if you believe this is an error.", - extensions={"code": "INTERNAL_SERVER_ERROR"}, - ) from e + organizations = _organizations_from_payload(user_payload) + db_user = await _resolve_user_from_payload(session, user_payload) return Context(db=session, user=db_user, organizations=organizations) async def _update_user_root_locations( - session: AsyncSession, + session: AsyncSession | LockedAsyncSession, user: User, organizations: str | None, ) -> None: diff --git a/backend/api/router.py b/backend/api/router.py index ef97e22a..c106ec30 100644 --- a/backend/api/router.py +++ b/backend/api/router.py @@ -3,8 +3,23 @@ from fastapi.responses import HTMLResponse from strawberry.fastapi import GraphQLRouter +from api.context import get_user_from_connection_params + class AuthedGraphQLRouter(GraphQLRouter): + async def on_ws_connect(self, context): + if ( + hasattr(context, "connection_params") + and context.connection_params + and hasattr(context, "db") + ): + user = await get_user_from_connection_params( + context.connection_params, context.db + ) + if user is not None: + context.user = user + return await super().on_ws_connect(context) + async def render_graphql_ide(self, request: Request) -> HTMLResponse: response = await super().render_graphql_ide(request) diff --git a/backend/auth.py b/backend/auth.py index 56568aa6..7f821f61 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -128,7 +128,23 @@ def verify_token(token: str) -> dict: raise Exception(f"{e!s}") +def get_token_from_connection_params(connection_params: dict | None) -> str | None: + if not connection_params or not isinstance(connection_params, dict): + return None + auth = connection_params.get("authorization") + if not auth or not isinstance(auth, str): + return None + parts = auth.split() + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + return None + + def get_token_source(connection: HTTPConnection) -> str | None: + if hasattr(connection, "connection_params") and connection.connection_params: + token = get_token_from_connection_params(connection.connection_params) + if token: + return token auth_header = connection.headers.get("authorization") if auth_header: parts = auth_header.split() diff --git a/web/api/gql/generated.ts b/web/api/gql/generated.ts index 5ae37a3e..8a89465c 100644 --- a/web/api/gql/generated.ts +++ b/web/api/gql/generated.ts @@ -1,5 +1,3 @@ -import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; -import { fetcher } from './fetcher'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -1064,21 +1062,6 @@ export const GetAuditLogsDocument = ` } `; -export const useGetAuditLogsQuery = < - TData = GetAuditLogsQuery, - TError = unknown - >( - variables: GetAuditLogsQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: ['GetAuditLogs', variables], - queryFn: fetcher(GetAuditLogsDocument, variables), - ...options - } - )}; export const GetLocationNodeDocument = ` query GetLocationNode($id: ID!) { @@ -1115,21 +1098,6 @@ export const GetLocationNodeDocument = ` } `; -export const useGetLocationNodeQuery = < - TData = GetLocationNodeQuery, - TError = unknown - >( - variables: GetLocationNodeQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: ['GetLocationNode', variables], - queryFn: fetcher(GetLocationNodeDocument, variables), - ...options - } - )}; export const GetLocationsDocument = ` query GetLocations($limit: Int, $offset: Int) { @@ -1142,21 +1110,6 @@ export const GetLocationsDocument = ` } `; -export const useGetLocationsQuery = < - TData = GetLocationsQuery, - TError = unknown - >( - variables?: GetLocationsQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: variables === undefined ? ['GetLocations'] : ['GetLocations', variables], - queryFn: fetcher(GetLocationsDocument, variables), - ...options - } - )}; export const GetMyTasksDocument = ` query GetMyTasks { @@ -1209,21 +1162,6 @@ export const GetMyTasksDocument = ` } `; -export const useGetMyTasksQuery = < - TData = GetMyTasksQuery, - TError = unknown - >( - variables?: GetMyTasksQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: variables === undefined ? ['GetMyTasks'] : ['GetMyTasks', variables], - queryFn: fetcher(GetMyTasksDocument, variables), - ...options - } - )}; export const GetOverviewDataDocument = ` query GetOverviewData($recentPatientsFiltering: [FilterInput!], $recentPatientsSorting: [SortInput!], $recentPatientsPagination: PaginationInput, $recentPatientsSearch: FullTextSearchInput, $recentTasksFiltering: [FilterInput!], $recentTasksSorting: [SortInput!], $recentTasksPagination: PaginationInput, $recentTasksSearch: FullTextSearchInput) { @@ -1333,21 +1271,6 @@ export const GetOverviewDataDocument = ` } `; -export const useGetOverviewDataQuery = < - TData = GetOverviewDataQuery, - TError = unknown - >( - variables?: GetOverviewDataQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: variables === undefined ? ['GetOverviewData'] : ['GetOverviewData', variables], - queryFn: fetcher(GetOverviewDataDocument, variables), - ...options - } - )}; export const GetPatientDocument = ` query GetPatient($id: ID!) { @@ -1475,21 +1398,6 @@ export const GetPatientDocument = ` } `; -export const useGetPatientQuery = < - TData = GetPatientQuery, - TError = unknown - >( - variables: GetPatientQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: ['GetPatient', variables], - queryFn: fetcher(GetPatientDocument, variables), - ...options - } - )}; export const GetPatientsDocument = ` query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $filtering: [FilterInput!], $sorting: [SortInput!], $pagination: PaginationInput, $search: FullTextSearchInput) { @@ -1650,21 +1558,6 @@ export const GetPatientsDocument = ` } `; -export const useGetPatientsQuery = < - TData = GetPatientsQuery, - TError = unknown - >( - variables?: GetPatientsQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: variables === undefined ? ['GetPatients'] : ['GetPatients', variables], - queryFn: fetcher(GetPatientsDocument, variables), - ...options - } - )}; export const GetTaskDocument = ` query GetTask($id: ID!) { @@ -1715,21 +1608,6 @@ export const GetTaskDocument = ` } `; -export const useGetTaskQuery = < - TData = GetTaskQuery, - TError = unknown - >( - variables: GetTaskQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: ['GetTask', variables], - queryFn: fetcher(GetTaskDocument, variables), - ...options - } - )}; export const GetTasksDocument = ` query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $filtering: [FilterInput!], $sorting: [SortInput!], $pagination: PaginationInput, $search: FullTextSearchInput) { @@ -1818,21 +1696,6 @@ export const GetTasksDocument = ` } `; -export const useGetTasksQuery = < - TData = GetTasksQuery, - TError = unknown - >( - variables?: GetTasksQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: variables === undefined ? ['GetTasks'] : ['GetTasks', variables], - queryFn: fetcher(GetTasksDocument, variables), - ...options - } - )}; export const GetUserDocument = ` query GetUser($id: ID!) { @@ -1851,21 +1714,6 @@ export const GetUserDocument = ` } `; -export const useGetUserQuery = < - TData = GetUserQuery, - TError = unknown - >( - variables: GetUserQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: ['GetUser', variables], - queryFn: fetcher(GetUserDocument, variables), - ...options - } - )}; export const GetUsersDocument = ` query GetUsers { @@ -1879,21 +1727,6 @@ export const GetUsersDocument = ` } `; -export const useGetUsersQuery = < - TData = GetUsersQuery, - TError = unknown - >( - variables?: GetUsersQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: variables === undefined ? ['GetUsers'] : ['GetUsers', variables], - queryFn: fetcher(GetUsersDocument, variables), - ...options - } - )}; export const GetGlobalDataDocument = ` query GetGlobalData($rootLocationIds: [ID!]) { @@ -1946,21 +1779,6 @@ export const GetGlobalDataDocument = ` } `; -export const useGetGlobalDataQuery = < - TData = GetGlobalDataQuery, - TError = unknown - >( - variables?: GetGlobalDataQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: variables === undefined ? ['GetGlobalData'] : ['GetGlobalData', variables], - queryFn: fetcher(GetGlobalDataDocument, variables), - ...options - } - )}; export const CreatePatientDocument = ` mutation CreatePatient($data: CreatePatientInput!) { @@ -1999,18 +1817,6 @@ export const CreatePatientDocument = ` } `; -export const useCreatePatientMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['CreatePatient'], - mutationFn: (variables?: CreatePatientMutationVariables) => fetcher(CreatePatientDocument, variables)(), - ...options - } - )}; export const UpdatePatientDocument = ` mutation UpdatePatient($id: ID!, $data: UpdatePatientInput!) { @@ -2068,18 +1874,6 @@ export const UpdatePatientDocument = ` } `; -export const useUpdatePatientMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['UpdatePatient'], - mutationFn: (variables?: UpdatePatientMutationVariables) => fetcher(UpdatePatientDocument, variables)(), - ...options - } - )}; export const AdmitPatientDocument = ` mutation AdmitPatient($id: ID!) { @@ -2090,18 +1884,6 @@ export const AdmitPatientDocument = ` } `; -export const useAdmitPatientMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['AdmitPatient'], - mutationFn: (variables?: AdmitPatientMutationVariables) => fetcher(AdmitPatientDocument, variables)(), - ...options - } - )}; export const DischargePatientDocument = ` mutation DischargePatient($id: ID!) { @@ -2112,18 +1894,6 @@ export const DischargePatientDocument = ` } `; -export const useDischargePatientMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['DischargePatient'], - mutationFn: (variables?: DischargePatientMutationVariables) => fetcher(DischargePatientDocument, variables)(), - ...options - } - )}; export const MarkPatientDeadDocument = ` mutation MarkPatientDead($id: ID!) { @@ -2134,18 +1904,6 @@ export const MarkPatientDeadDocument = ` } `; -export const useMarkPatientDeadMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['MarkPatientDead'], - mutationFn: (variables?: MarkPatientDeadMutationVariables) => fetcher(MarkPatientDeadDocument, variables)(), - ...options - } - )}; export const WaitPatientDocument = ` mutation WaitPatient($id: ID!) { @@ -2156,18 +1914,6 @@ export const WaitPatientDocument = ` } `; -export const useWaitPatientMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['WaitPatient'], - mutationFn: (variables?: WaitPatientMutationVariables) => fetcher(WaitPatientDocument, variables)(), - ...options - } - )}; export const DeletePatientDocument = ` mutation DeletePatient($id: ID!) { @@ -2175,18 +1921,6 @@ export const DeletePatientDocument = ` } `; -export const useDeletePatientMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['DeletePatient'], - mutationFn: (variables?: DeletePatientMutationVariables) => fetcher(DeletePatientDocument, variables)(), - ...options - } - )}; export const CreatePropertyDefinitionDocument = ` mutation CreatePropertyDefinition($data: CreatePropertyDefinitionInput!) { @@ -2202,18 +1936,6 @@ export const CreatePropertyDefinitionDocument = ` } `; -export const useCreatePropertyDefinitionMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['CreatePropertyDefinition'], - mutationFn: (variables?: CreatePropertyDefinitionMutationVariables) => fetcher(CreatePropertyDefinitionDocument, variables)(), - ...options - } - )}; export const UpdatePropertyDefinitionDocument = ` mutation UpdatePropertyDefinition($id: ID!, $data: UpdatePropertyDefinitionInput!) { @@ -2229,18 +1951,6 @@ export const UpdatePropertyDefinitionDocument = ` } `; -export const useUpdatePropertyDefinitionMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['UpdatePropertyDefinition'], - mutationFn: (variables?: UpdatePropertyDefinitionMutationVariables) => fetcher(UpdatePropertyDefinitionDocument, variables)(), - ...options - } - )}; export const DeletePropertyDefinitionDocument = ` mutation DeletePropertyDefinition($id: ID!) { @@ -2248,18 +1958,6 @@ export const DeletePropertyDefinitionDocument = ` } `; -export const useDeletePropertyDefinitionMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['DeletePropertyDefinition'], - mutationFn: (variables?: DeletePropertyDefinitionMutationVariables) => fetcher(DeletePropertyDefinitionDocument, variables)(), - ...options - } - )}; export const GetPropertyDefinitionsDocument = ` query GetPropertyDefinitions { @@ -2275,21 +1973,6 @@ export const GetPropertyDefinitionsDocument = ` } `; -export const useGetPropertyDefinitionsQuery = < - TData = GetPropertyDefinitionsQuery, - TError = unknown - >( - variables?: GetPropertyDefinitionsQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: variables === undefined ? ['GetPropertyDefinitions'] : ['GetPropertyDefinitions', variables], - queryFn: fetcher(GetPropertyDefinitionsDocument, variables), - ...options - } - )}; export const GetPropertiesForSubjectDocument = ` query GetPropertiesForSubject($subjectId: ID!, $subjectType: PropertyEntity!) { @@ -2305,21 +1988,6 @@ export const GetPropertiesForSubjectDocument = ` } `; -export const useGetPropertiesForSubjectQuery = < - TData = GetPropertiesForSubjectQuery, - TError = unknown - >( - variables: GetPropertiesForSubjectQueryVariables, - options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } - ) => { - - return useQuery( - { - queryKey: ['GetPropertiesForSubject', variables], - queryFn: fetcher(GetPropertiesForSubjectDocument, variables), - ...options - } - )}; export const PatientCreatedDocument = ` subscription PatientCreated($rootLocationIds: [ID!]) { @@ -2390,18 +2058,6 @@ export const CreateTaskDocument = ` } `; -export const useCreateTaskMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['CreateTask'], - mutationFn: (variables?: CreateTaskMutationVariables) => fetcher(CreateTaskDocument, variables)(), - ...options - } - )}; export const UpdateTaskDocument = ` mutation UpdateTask($id: ID!, $data: UpdateTaskInput!) { @@ -2448,18 +2104,6 @@ export const UpdateTaskDocument = ` } `; -export const useUpdateTaskMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['UpdateTask'], - mutationFn: (variables?: UpdateTaskMutationVariables) => fetcher(UpdateTaskDocument, variables)(), - ...options - } - )}; export const AssignTaskDocument = ` mutation AssignTask($id: ID!, $userId: ID!) { @@ -2476,18 +2120,6 @@ export const AssignTaskDocument = ` } `; -export const useAssignTaskMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['AssignTask'], - mutationFn: (variables?: AssignTaskMutationVariables) => fetcher(AssignTaskDocument, variables)(), - ...options - } - )}; export const UnassignTaskDocument = ` mutation UnassignTask($id: ID!) { @@ -2504,18 +2136,6 @@ export const UnassignTaskDocument = ` } `; -export const useUnassignTaskMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['UnassignTask'], - mutationFn: (variables?: UnassignTaskMutationVariables) => fetcher(UnassignTaskDocument, variables)(), - ...options - } - )}; export const DeleteTaskDocument = ` mutation DeleteTask($id: ID!) { @@ -2523,18 +2143,6 @@ export const DeleteTaskDocument = ` } `; -export const useDeleteTaskMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['DeleteTask'], - mutationFn: (variables?: DeleteTaskMutationVariables) => fetcher(DeleteTaskDocument, variables)(), - ...options - } - )}; export const CompleteTaskDocument = ` mutation CompleteTask($id: ID!) { @@ -2546,18 +2154,6 @@ export const CompleteTaskDocument = ` } `; -export const useCompleteTaskMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['CompleteTask'], - mutationFn: (variables?: CompleteTaskMutationVariables) => fetcher(CompleteTaskDocument, variables)(), - ...options - } - )}; export const ReopenTaskDocument = ` mutation ReopenTask($id: ID!) { @@ -2569,18 +2165,6 @@ export const ReopenTaskDocument = ` } `; -export const useReopenTaskMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['ReopenTask'], - mutationFn: (variables?: ReopenTaskMutationVariables) => fetcher(ReopenTaskDocument, variables)(), - ...options - } - )}; export const AssignTaskToTeamDocument = ` mutation AssignTaskToTeam($id: ID!, $teamId: ID!) { @@ -2595,18 +2179,6 @@ export const AssignTaskToTeamDocument = ` } `; -export const useAssignTaskToTeamMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['AssignTaskToTeam'], - mutationFn: (variables?: AssignTaskToTeamMutationVariables) => fetcher(AssignTaskToTeamDocument, variables)(), - ...options - } - )}; export const UnassignTaskFromTeamDocument = ` mutation UnassignTaskFromTeam($id: ID!) { @@ -2621,18 +2193,6 @@ export const UnassignTaskFromTeamDocument = ` } `; -export const useUnassignTaskFromTeamMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['UnassignTaskFromTeam'], - mutationFn: (variables?: UnassignTaskFromTeamMutationVariables) => fetcher(UnassignTaskFromTeamDocument, variables)(), - ...options - } - )}; export const UpdateProfilePictureDocument = ` mutation UpdateProfilePicture($data: UpdateProfilePictureInput!) { @@ -2651,15 +2211,3 @@ export const UpdateProfilePictureDocument = ` } `; -export const useUpdateProfilePictureMutation = < - TError = unknown, - TContext = unknown - >(options?: UseMutationOptions) => { - - return useMutation( - { - mutationKey: ['UpdateProfilePicture'], - mutationFn: (variables?: UpdateProfilePictureMutationVariables) => fetcher(UpdateProfilePictureDocument, variables)(), - ...options - } - )}; diff --git a/web/api/mutations/patients/admitPatient.plan.ts b/web/api/mutations/patients/admitPatient.plan.ts new file mode 100644 index 00000000..78577c22 --- /dev/null +++ b/web/api/mutations/patients/admitPatient.plan.ts @@ -0,0 +1,50 @@ +import { parse } from 'graphql' +import type { ApolloCache } from '@apollo/client/cache' +import { GetPatientDocument, type GetPatientQuery } from '@/api/gql/generated' +import { PatientState } from '@/api/gql/generated' +import { registerOptimisticPlan } from '@/data/mutations/registry' +import type { OptimisticPlan, OptimisticPatch } from '@/data/mutations/types' + +type AdmitPatientVariables = { id: string, clientMutationId?: string } + +export const admitPatientOptimisticPlanKey = 'AdmitPatient' + +export const admitPatientOptimisticPlan: OptimisticPlan = { + getPatches(variables): OptimisticPatch[] { + const snapshotRef: { current: GetPatientQuery | null } = { current: null } + const patientId = variables.id + const doc = parse(GetPatientDocument) + + return [ + { + apply(cache: ApolloCache, vars: unknown): void { + const v = vars as AdmitPatientVariables + const existing = cache.readQuery({ + query: doc, + variables: { id: v.id }, + }) + snapshotRef.current = existing ?? null + const id = cache.identify({ __typename: 'PatientType', id: patientId }) + cache.modify({ + id, + fields: { + state: () => PatientState.Admitted, + }, + }) + }, + rollback(cache: ApolloCache, vars: unknown): void { + const v = vars as AdmitPatientVariables + const previous = snapshotRef.current + if (!previous) return + cache.writeQuery({ + query: doc, + variables: { id: v.id }, + data: previous, + }) + }, + }, + ] + }, +} + +registerOptimisticPlan(admitPatientOptimisticPlanKey, admitPatientOptimisticPlan as OptimisticPlan) diff --git a/web/api/mutations/patients/dischargePatient.plan.ts b/web/api/mutations/patients/dischargePatient.plan.ts new file mode 100644 index 00000000..0bf5f5eb --- /dev/null +++ b/web/api/mutations/patients/dischargePatient.plan.ts @@ -0,0 +1,50 @@ +import { parse } from 'graphql' +import type { ApolloCache } from '@apollo/client/cache' +import { GetPatientDocument, type GetPatientQuery } from '@/api/gql/generated' +import { PatientState } from '@/api/gql/generated' +import { registerOptimisticPlan } from '@/data/mutations/registry' +import type { OptimisticPlan, OptimisticPatch } from '@/data/mutations/types' + +type DischargePatientVariables = { id: string, clientMutationId?: string } + +export const dischargePatientOptimisticPlanKey = 'DischargePatient' + +export const dischargePatientOptimisticPlan: OptimisticPlan = { + getPatches(variables): OptimisticPatch[] { + const snapshotRef: { current: GetPatientQuery | null } = { current: null } + const patientId = variables.id + const doc = parse(GetPatientDocument) + + return [ + { + apply(cache: ApolloCache, vars: unknown): void { + const v = vars as DischargePatientVariables + const existing = cache.readQuery({ + query: doc, + variables: { id: v.id }, + }) + snapshotRef.current = existing ?? null + const id = cache.identify({ __typename: 'PatientType', id: patientId }) + cache.modify({ + id, + fields: { + state: () => PatientState.Discharged, + }, + }) + }, + rollback(cache: ApolloCache, vars: unknown): void { + const v = vars as DischargePatientVariables + const previous = snapshotRef.current + if (!previous) return + cache.writeQuery({ + query: doc, + variables: { id: v.id }, + data: previous, + }) + }, + }, + ] + }, +} + +registerOptimisticPlan(dischargePatientOptimisticPlanKey, dischargePatientOptimisticPlan as OptimisticPlan) diff --git a/web/api/mutations/patients/markPatientDead.plan.ts b/web/api/mutations/patients/markPatientDead.plan.ts new file mode 100644 index 00000000..19c7fe24 --- /dev/null +++ b/web/api/mutations/patients/markPatientDead.plan.ts @@ -0,0 +1,50 @@ +import { parse } from 'graphql' +import type { ApolloCache } from '@apollo/client/cache' +import { GetPatientDocument, type GetPatientQuery } from '@/api/gql/generated' +import { PatientState } from '@/api/gql/generated' +import { registerOptimisticPlan } from '@/data/mutations/registry' +import type { OptimisticPlan, OptimisticPatch } from '@/data/mutations/types' + +type MarkPatientDeadVariables = { id: string, clientMutationId?: string } + +export const markPatientDeadOptimisticPlanKey = 'MarkPatientDead' + +export const markPatientDeadOptimisticPlan: OptimisticPlan = { + getPatches(variables): OptimisticPatch[] { + const snapshotRef: { current: GetPatientQuery | null } = { current: null } + const patientId = variables.id + const doc = parse(GetPatientDocument) + + return [ + { + apply(cache: ApolloCache, vars: unknown): void { + const v = vars as MarkPatientDeadVariables + const existing = cache.readQuery({ + query: doc, + variables: { id: v.id }, + }) + snapshotRef.current = existing ?? null + const id = cache.identify({ __typename: 'PatientType', id: patientId }) + cache.modify({ + id, + fields: { + state: () => PatientState.Dead, + }, + }) + }, + rollback(cache: ApolloCache, vars: unknown): void { + const v = vars as MarkPatientDeadVariables + const previous = snapshotRef.current + if (!previous) return + cache.writeQuery({ + query: doc, + variables: { id: v.id }, + data: previous, + }) + }, + }, + ] + }, +} + +registerOptimisticPlan(markPatientDeadOptimisticPlanKey, markPatientDeadOptimisticPlan as OptimisticPlan) diff --git a/web/api/mutations/patients/updatePatient.plan.ts b/web/api/mutations/patients/updatePatient.plan.ts new file mode 100644 index 00000000..86e5e450 --- /dev/null +++ b/web/api/mutations/patients/updatePatient.plan.ts @@ -0,0 +1,65 @@ +import { parse } from 'graphql' +import type { ApolloCache } from '@apollo/client/cache' +import { + GetPatientDocument, + type GetPatientQuery, + type UpdatePatientInput +} from '@/api/gql/generated' +import { registerOptimisticPlan } from '@/data/mutations/registry' +import type { OptimisticPlan, OptimisticPatch } from '@/data/mutations/types' + +type UpdatePatientVariables = { + id: string, + data: UpdatePatientInput, + clientMutationId?: string, +} + +export const updatePatientOptimisticPlanKey = 'UpdatePatient' + +export const updatePatientOptimisticPlan: OptimisticPlan = { + getPatches(variables): OptimisticPatch[] { + const snapshotRef: { current: GetPatientQuery | null } = { current: null } + const patientId = variables.id + const data = variables.data + const doc = parse(GetPatientDocument) + + return [ + { + apply(cache: ApolloCache, vars: unknown): void { + const v = vars as UpdatePatientVariables + const existing = cache.readQuery({ + query: doc, + variables: { id: v.id }, + }) + snapshotRef.current = existing ?? null + const id = cache.identify({ __typename: 'PatientType', id: patientId }) + cache.modify({ + id, + fields: { + firstname: (prev: string) => + data.firstname !== undefined ? data.firstname ?? '' : prev, + lastname: (prev: string) => + data.lastname !== undefined ? data.lastname ?? '' : prev, + birthdate: (prev: unknown) => + data.birthdate !== undefined ? data.birthdate : prev, + sex: (prev: string | null) => + (data.sex !== undefined ? data.sex : prev) ?? '', + }, + }) + }, + rollback(cache: ApolloCache, vars: unknown): void { + const v = vars as UpdatePatientVariables + const previous = snapshotRef.current + if (!previous) return + cache.writeQuery({ + query: doc, + variables: { id: v.id }, + data: previous, + }) + }, + }, + ] + }, +} + +registerOptimisticPlan(updatePatientOptimisticPlanKey, updatePatientOptimisticPlan as OptimisticPlan) diff --git a/web/api/mutations/patients/waitPatient.plan.ts b/web/api/mutations/patients/waitPatient.plan.ts new file mode 100644 index 00000000..e0ca6634 --- /dev/null +++ b/web/api/mutations/patients/waitPatient.plan.ts @@ -0,0 +1,50 @@ +import { parse } from 'graphql' +import type { ApolloCache } from '@apollo/client/cache' +import { GetPatientDocument, type GetPatientQuery } from '@/api/gql/generated' +import { PatientState } from '@/api/gql/generated' +import { registerOptimisticPlan } from '@/data/mutations/registry' +import type { OptimisticPlan, OptimisticPatch } from '@/data/mutations/types' + +type WaitPatientVariables = { id: string, clientMutationId?: string } + +export const waitPatientOptimisticPlanKey = 'WaitPatient' + +export const waitPatientOptimisticPlan: OptimisticPlan = { + getPatches(variables): OptimisticPatch[] { + const snapshotRef: { current: GetPatientQuery | null } = { current: null } + const patientId = variables.id + const doc = parse(GetPatientDocument) + + return [ + { + apply(cache: ApolloCache, vars: unknown): void { + const v = vars as WaitPatientVariables + const existing = cache.readQuery({ + query: doc, + variables: { id: v.id }, + }) + snapshotRef.current = existing ?? null + const id = cache.identify({ __typename: 'PatientType', id: patientId }) + cache.modify({ + id, + fields: { + state: () => PatientState.Wait, + }, + }) + }, + rollback(cache: ApolloCache, vars: unknown): void { + const v = vars as WaitPatientVariables + const previous = snapshotRef.current + if (!previous) return + cache.writeQuery({ + query: doc, + variables: { id: v.id }, + data: previous, + }) + }, + }, + ] + }, +} + +registerOptimisticPlan(waitPatientOptimisticPlanKey, waitPatientOptimisticPlan as OptimisticPlan) diff --git a/web/api/mutations/tasks/completeTask.plan.ts b/web/api/mutations/tasks/completeTask.plan.ts new file mode 100644 index 00000000..e1e326cc --- /dev/null +++ b/web/api/mutations/tasks/completeTask.plan.ts @@ -0,0 +1,49 @@ +import { parse } from 'graphql' +import type { ApolloCache } from '@apollo/client/cache' +import { GetTaskDocument, type GetTaskQuery } from '@/api/gql/generated' +import { registerOptimisticPlan } from '@/data/mutations/registry' +import type { OptimisticPlan, OptimisticPatch } from '@/data/mutations/types' + +type CompleteTaskVariables = { id: string, clientMutationId?: string } + +export const completeTaskOptimisticPlanKey = 'CompleteTask' + +export const completeTaskOptimisticPlan: OptimisticPlan = { + getPatches(variables): OptimisticPatch[] { + const snapshotRef: { current: GetTaskQuery | null } = { current: null } + const taskId = variables.id + const doc = parse(GetTaskDocument) + + return [ + { + apply(cache: ApolloCache, vars: unknown): void { + const v = vars as CompleteTaskVariables + const existing = cache.readQuery({ + query: doc, + variables: { id: v.id }, + }) + snapshotRef.current = existing ?? null + const id = cache.identify({ __typename: 'TaskType', id: taskId }) + cache.modify({ + id, + fields: { + done: () => true, + }, + }) + }, + rollback(cache: ApolloCache, vars: unknown): void { + const v = vars as CompleteTaskVariables + const previous = snapshotRef.current + if (!previous) return + cache.writeQuery({ + query: doc, + variables: { id: v.id }, + data: previous, + }) + }, + }, + ] + }, +} + +registerOptimisticPlan(completeTaskOptimisticPlanKey, completeTaskOptimisticPlan as OptimisticPlan) diff --git a/web/api/mutations/tasks/reopenTask.plan.ts b/web/api/mutations/tasks/reopenTask.plan.ts new file mode 100644 index 00000000..b084b835 --- /dev/null +++ b/web/api/mutations/tasks/reopenTask.plan.ts @@ -0,0 +1,49 @@ +import { parse } from 'graphql' +import type { ApolloCache } from '@apollo/client/cache' +import { GetTaskDocument, type GetTaskQuery } from '@/api/gql/generated' +import { registerOptimisticPlan } from '@/data/mutations/registry' +import type { OptimisticPlan, OptimisticPatch } from '@/data/mutations/types' + +type ReopenTaskVariables = { id: string, clientMutationId?: string } + +export const reopenTaskOptimisticPlanKey = 'ReopenTask' + +export const reopenTaskOptimisticPlan: OptimisticPlan = { + getPatches(variables): OptimisticPatch[] { + const snapshotRef: { current: GetTaskQuery | null } = { current: null } + const taskId = variables.id + const doc = parse(GetTaskDocument) + + return [ + { + apply(cache: ApolloCache, vars: unknown): void { + const v = vars as ReopenTaskVariables + const existing = cache.readQuery({ + query: doc, + variables: { id: v.id }, + }) + snapshotRef.current = existing ?? null + const id = cache.identify({ __typename: 'TaskType', id: taskId }) + cache.modify({ + id, + fields: { + done: () => false, + }, + }) + }, + rollback(cache: ApolloCache, vars: unknown): void { + const v = vars as ReopenTaskVariables + const previous = snapshotRef.current + if (!previous) return + cache.writeQuery({ + query: doc, + variables: { id: v.id }, + data: previous, + }) + }, + }, + ] + }, +} + +registerOptimisticPlan(reopenTaskOptimisticPlanKey, reopenTaskOptimisticPlan as OptimisticPlan) diff --git a/web/api/mutations/tasks/updateTask.plan.ts b/web/api/mutations/tasks/updateTask.plan.ts new file mode 100644 index 00000000..a9a447bc --- /dev/null +++ b/web/api/mutations/tasks/updateTask.plan.ts @@ -0,0 +1,70 @@ +import { parse } from 'graphql' +import type { ApolloCache } from '@apollo/client/cache' +import { + GetTaskDocument, + type GetTaskQuery, + type UpdateTaskInput +} from '@/api/gql/generated' +import { registerOptimisticPlan } from '@/data/mutations/registry' +import type { OptimisticPlan, OptimisticPatch } from '@/data/mutations/types' + +type UpdateTaskVariables = { + id: string, + data: UpdateTaskInput, + clientMutationId?: string, +} + +export const updateTaskOptimisticPlanKey = 'UpdateTask' + +export const updateTaskOptimisticPlan: OptimisticPlan = { + getPatches(variables): OptimisticPatch[] { + const snapshotRef: { current: GetTaskQuery | null } = { current: null } + const taskId = variables.id + const data = variables.data + const doc = parse(GetTaskDocument) + + return [ + { + apply(cache: ApolloCache, vars: unknown): void { + const v = vars as UpdateTaskVariables + const existing = cache.readQuery({ + query: doc, + variables: { id: v.id }, + }) + snapshotRef.current = existing ?? null + + const id = cache.identify({ __typename: 'TaskType', id: taskId }) + cache.modify({ + id, + fields: { + title: (prev: string) => + data.title !== undefined ? data.title ?? '' : prev, + description: (prev: string | null) => + data.description !== undefined ? data.description : prev, + done: (prev: boolean) => + data.done !== undefined ? data.done ?? false : prev, + dueDate: (prev: string | null) => + data.dueDate !== undefined ? data.dueDate ?? null : prev, + priority: (prev: string | null) => + data.priority !== undefined ? data.priority : prev, + estimatedTime: (prev: number | null) => + data.estimatedTime !== undefined ? data.estimatedTime : prev, + }, + }) + }, + rollback(cache: ApolloCache, vars: unknown): void { + const v = vars as UpdateTaskVariables + const previous = snapshotRef.current + if (!previous) return + cache.writeQuery({ + query: doc, + variables: { id: v.id }, + data: previous, + }) + }, + }, + ] + }, +} + +registerOptimisticPlan(updateTaskOptimisticPlanKey, updateTaskOptimisticPlan as OptimisticPlan) diff --git a/web/api/optimistic-updates/GetPatient.ts b/web/api/optimistic-updates/GetPatient.ts deleted file mode 100644 index b4549d11..00000000 --- a/web/api/optimistic-updates/GetPatient.ts +++ /dev/null @@ -1,784 +0,0 @@ -import { useSafeMutation } from '@/hooks/useSafeMutation' -import { fetcher } from '@/api/gql/fetcher' -import { CompleteTaskDocument, ReopenTaskDocument, CreatePatientDocument, AdmitPatientDocument, DischargePatientDocument, DeletePatientDocument, WaitPatientDocument, MarkPatientDeadDocument, UpdatePatientDocument, type CompleteTaskMutation, type CompleteTaskMutationVariables, type ReopenTaskMutation, type ReopenTaskMutationVariables, type CreatePatientMutation, type CreatePatientMutationVariables, type AdmitPatientMutation, type DischargePatientMutation, type DeletePatientMutation, type DeletePatientMutationVariables, type WaitPatientMutation, type MarkPatientDeadMutation, type UpdatePatientMutation, type UpdatePatientMutationVariables, type UpdatePatientInput, PatientState, type FieldType } from '@/api/gql/generated' -import type { GetPatientQuery, GetPatientsQuery, GetGlobalDataQuery } from '@/api/gql/generated' -import { useTasksContext } from '@/hooks/useTasksContext' -import { useQueryClient } from '@tanstack/react-query' - -interface UseOptimisticCompleteTaskMutationParams { - id: string, - onSuccess?: (data: CompleteTaskMutation, variables: CompleteTaskMutationVariables) => void, - onError?: (error: Error, variables: CompleteTaskMutationVariables) => void, -} - -export function useOptimisticCompleteTaskMutation({ - id, - onSuccess, - onError, -}: UseOptimisticCompleteTaskMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(CompleteTaskDocument, variables)() - }, - optimisticUpdate: (variables) => [ - { - queryKey: ['GetPatient', { id }], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientQuery | undefined - if (!data?.patient) return oldData - return { - ...data, - patient: { - ...data.patient, - tasks: data.patient.tasks?.map(task => ( - task.id === variables.id ? { ...task, done: true } : task - )) || [] - } - } - } - }, - { - 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 === id && patient.tasks) { - return { - ...patient, - tasks: patient.tasks.map(task => task.id === variables.id ? { ...task, done: true } : task) - } - } - return patient - }) - } - } - }, - { - 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 - } - } - }, - ], - affectedQueryKeys: [['GetPatient', { id }], ['GetTasks'], ['GetPatients'], ['GetOverviewData'], ['GetGlobalData']], - onSuccess, - onError, - }) -} - -interface UseOptimisticReopenTaskMutationParams { - id: string, - onSuccess?: (data: ReopenTaskMutation, variables: ReopenTaskMutationVariables) => void, - onError?: (error: Error, variables: ReopenTaskMutationVariables) => void, -} - -export function useOptimisticReopenTaskMutation({ - id, - onSuccess, - onError, -}: UseOptimisticReopenTaskMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(ReopenTaskDocument, variables)() - }, - optimisticUpdate: (variables) => [ - { - queryKey: ['GetPatient', { id }], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientQuery | undefined - if (!data?.patient) return oldData - return { - ...data, - patient: { - ...data.patient, - tasks: data.patient.tasks?.map(task => ( - task.id === variables.id ? { ...task, done: false } : task - )) || [] - } - } - } - }, - { - 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 === id && patient.tasks) { - return { - ...patient, - tasks: patient.tasks.map(task => task.id === variables.id ? { ...task, done: false } : task) - } - } - return patient - }) - } - } - }, - { - 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 - } - } - }, - ], - affectedQueryKeys: [['GetPatient', { id }], ['GetTasks'], ['GetPatients'], ['GetOverviewData'], ['GetGlobalData']], - onSuccess, - onError, - }) -} - -interface UseOptimisticCreatePatientMutationParams { - onSuccess?: (data: CreatePatientMutation, variables: CreatePatientMutationVariables) => void, - onError?: (error: Error, variables: CreatePatientMutationVariables) => void, - onMutate?: () => void, - onSettled?: () => void, -} - -export function useOptimisticCreatePatientMutation({ - onMutate, - onSettled, - onSuccess, - onError, -}: UseOptimisticCreatePatientMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(CreatePatientDocument, variables)() - }, - 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 || [], - } - } - } - ], - affectedQueryKeys: [['GetGlobalData'], ['GetPatients'], ['GetOverviewData']], - onSuccess, - onError, - onMutate, - onSettled, - }) -} - -interface UseOptimisticAdmitPatientMutationParams { - id: string, - onSuccess?: (data: AdmitPatientMutation, variables: { id: string }) => void, - onError?: (error: Error, variables: { id: string }) => void, -} - -export function useOptimisticAdmitPatientMutation({ - id, - onSuccess, - onError, -}: UseOptimisticAdmitPatientMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(AdmitPatientDocument, variables)() - }, - optimisticUpdate: () => [ - { - queryKey: ['GetPatient', { id }], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientQuery | undefined - if (!data?.patient) return oldData - return { - ...data, - patient: { - ...data.patient, - state: PatientState.Admitted - } - } - } - }, - { - queryKey: ['GetPatients'], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientsQuery | undefined - if (!data?.patients) return oldData - return { - ...data, - patients: data.patients.map(p => - p.id === id ? { ...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 === id) - const updatedPatient = existingPatient - ? { ...existingPatient, state: PatientState.Admitted } - : { __typename: 'PatientType' as const, id, state: PatientState.Admitted, assignedLocation: null } - return { - ...data, - patients: existingPatient - ? data.patients.map(p => p.id === id ? updatedPatient : p) - : [...data.patients, updatedPatient], - waitingPatients: data.waitingPatients.filter(p => p.id !== id) - } - } - } - ], - affectedQueryKeys: [['GetPatients'], ['GetGlobalData']], - onSuccess, - onError, - }) -} - -interface UseOptimisticDischargePatientMutationParams { - id: string, - onSuccess?: (data: DischargePatientMutation, variables: { id: string }) => void, - onError?: (error: Error, variables: { id: string }) => void, -} - -export function useOptimisticDischargePatientMutation({ - id, - onSuccess, - onError, -}: UseOptimisticDischargePatientMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(DischargePatientDocument, variables)() - }, - optimisticUpdate: () => [ - { - queryKey: ['GetPatient', { id }], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientQuery | undefined - if (!data?.patient) return oldData - return { - ...data, - patient: { - ...data.patient, - state: PatientState.Discharged - } - } - } - }, - { - queryKey: ['GetPatients'], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientsQuery | undefined - if (!data?.patients) return oldData - return { - ...data, - patients: data.patients.map(p => - p.id === id ? { ...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 === id) - const updatedPatient = existingPatient - ? { ...existingPatient, state: PatientState.Discharged } - : { __typename: 'PatientType' as const, id, state: PatientState.Discharged, assignedLocation: null } - return { - ...data, - patients: existingPatient - ? data.patients.map(p => p.id === id ? updatedPatient : p) - : [...data.patients, updatedPatient], - waitingPatients: data.waitingPatients.filter(p => p.id !== id) - } - } - } - ], - affectedQueryKeys: [['GetPatients'], ['GetGlobalData']], - onSuccess, - onError, - }) -} - -interface UseOptimisticDeletePatientMutationParams { - onSuccess?: (data: DeletePatientMutation, variables: DeletePatientMutationVariables) => void, - onError?: (error: Error, variables: DeletePatientMutationVariables) => void, -} - -export function useOptimisticDeletePatientMutation({ - onSuccess, - onError, -}: UseOptimisticDeletePatientMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(DeletePatientDocument, variables)() - }, - 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, - } - ], - affectedQueryKeys: [['GetGlobalData'], ['GetPatients'], ['GetOverviewData']], - onSuccess, - onError, - }) -} - -interface UseOptimisticWaitPatientMutationParams { - id: string, - onSuccess?: (data: WaitPatientMutation, variables: { id: string }) => void, - onError?: (error: Error, variables: { id: string }) => void, -} - -export function useOptimisticWaitPatientMutation({ - id, - onSuccess, - onError, -}: UseOptimisticWaitPatientMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(WaitPatientDocument, variables)() - }, - optimisticUpdate: () => [ - { - queryKey: ['GetPatient', { id }], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientQuery | undefined - if (!data?.patient) return oldData - return { - ...data, - patient: { - ...data.patient, - state: PatientState.Wait - } - } - } - }, - { - queryKey: ['GetPatients'], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientsQuery | undefined - if (!data?.patients) return oldData - return { - ...data, - patients: data.patients.map(p => - p.id === id ? { ...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 === id) - const isAlreadyWaiting = data.waitingPatients.some(p => p.id === id) - const updatedPatient = existingPatient - ? { ...existingPatient, state: PatientState.Wait } - : { __typename: 'PatientType' as const, id, state: PatientState.Wait, assignedLocation: null } - return { - ...data, - patients: existingPatient - ? data.patients.map(p => p.id === id ? updatedPatient : p) - : [...data.patients, updatedPatient], - waitingPatients: isAlreadyWaiting - ? data.waitingPatients - : [...data.waitingPatients, updatedPatient] - } - } - } - ], - affectedQueryKeys: [['GetPatients'], ['GetGlobalData']], - onSuccess, - onError, - }) -} - -interface UseOptimisticMarkPatientDeadMutationParams { - id: string, - onSuccess?: (data: MarkPatientDeadMutation, variables: { id: string }) => void, - onError?: (error: Error, variables: { id: string }) => void, -} - -export function useOptimisticMarkPatientDeadMutation({ - id, - onSuccess, - onError, -}: UseOptimisticMarkPatientDeadMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(MarkPatientDeadDocument, variables)() - }, - optimisticUpdate: () => [ - { - queryKey: ['GetPatient', { id }], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientQuery | undefined - if (!data?.patient) return oldData - return { - ...data, - patient: { - ...data.patient, - state: PatientState.Dead - } - } - } - }, - { - queryKey: ['GetPatients'], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientsQuery | undefined - if (!data?.patients) return oldData - return { - ...data, - patients: data.patients.map(p => - p.id === id ? { ...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 === id) - const updatedPatient = existingPatient - ? { ...existingPatient, state: PatientState.Dead } - : { __typename: 'PatientType' as const, id, state: PatientState.Dead, assignedLocation: null } - return { - ...data, - patients: existingPatient - ? data.patients.map(p => p.id === id ? updatedPatient : p) - : [...data.patients, updatedPatient], - waitingPatients: data.waitingPatients.filter(p => p.id !== id) - } - } - } - ], - affectedQueryKeys: [['GetPatients'], ['GetGlobalData']], - onSuccess, - onError, - }) -} - -interface UseOptimisticUpdatePatientMutationParams { - id: string, - onSuccess?: (data: UpdatePatientMutation, variables: UpdatePatientMutationVariables) => void, - onError?: (error: Error, variables: UpdatePatientMutationVariables) => void, -} - -export function useOptimisticUpdatePatientMutation({ - id, - onSuccess, - onError, -}: UseOptimisticUpdatePatientMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - const queryClient = useQueryClient() - - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(UpdatePatientDocument, variables)() - }, - optimisticUpdate: (variables) => { - const updateData = variables.data || {} - const locationsData = queryClient.getQueryData(['GetLocations']) as { locationNodes?: Array<{ id: string, title: string, kind: string, parentId?: string | null }> } | undefined - type PatientType = NonNullable>>['patient']> - - const updatePatientInQuery = (patient: PatientType, updateData: Partial) => { - if (!patient) return patient - - const updated: typeof patient = { ...patient } - - if (updateData.firstname !== undefined) { - updated.firstname = updateData.firstname || '' - } - if (updateData.lastname !== undefined) { - updated.lastname = updateData.lastname || '' - } - if (updateData.sex !== undefined && updateData.sex !== null) { - updated.sex = updateData.sex - } - if (updateData.birthdate !== undefined) { - updated.birthdate = updateData.birthdate || null - } - if (updateData.description !== undefined) { - updated.description = updateData.description - } - if (updateData.clinicId !== undefined) { - if (updateData.clinicId === null || updateData.clinicId === undefined) { - updated.clinic = null as unknown as typeof patient.clinic - } else { - const clinicLocation = locationsData?.locationNodes?.find(loc => loc.id === updateData.clinicId) - if (clinicLocation) { - updated.clinic = { - ...clinicLocation, - __typename: 'LocationNodeType' as const, - } as typeof patient.clinic - } - } - } - if (updateData.positionId !== undefined) { - if (updateData.positionId === null) { - updated.position = null as typeof patient.position - } else { - const positionLocation = locationsData?.locationNodes?.find(loc => loc.id === updateData.positionId) - if (positionLocation) { - updated.position = { - ...positionLocation, - __typename: 'LocationNodeType' as const, - } as typeof patient.position - } - } - } - if (updateData.teamIds !== undefined) { - const teamLocations = locationsData?.locationNodes?.filter(loc => updateData.teamIds?.includes(loc.id)) || [] - updated.teams = teamLocations.map(team => ({ - ...team, - __typename: 'LocationNodeType' as const, - })) as typeof patient.teams - } - if (updateData.properties !== undefined && updateData.properties !== null) { - const propertyMap = new Map(updateData.properties.map(p => [p.definitionId, p])) - const existingPropertyIds = new Set( - patient.properties?.map(p => p.definition?.id).filter(Boolean) || [] - ) - const newPropertyIds = new Set(updateData.properties.map(p => p.definitionId)) - - const existingProperties = patient.properties - ? patient.properties - .filter(p => newPropertyIds.has(p.definition?.id)) - .map(p => { - const newProp = propertyMap.get(p.definition?.id) - if (!newProp) return p - return { - ...p, - textValue: newProp.textValue ?? p.textValue, - numberValue: newProp.numberValue ?? p.numberValue, - booleanValue: newProp.booleanValue ?? p.booleanValue, - dateValue: newProp.dateValue ?? p.dateValue, - dateTimeValue: newProp.dateTimeValue ?? p.dateTimeValue, - selectValue: newProp.selectValue ?? p.selectValue, - multiSelectValues: newProp.multiSelectValues ?? p.multiSelectValues, - } - }) - : [] - const newProperties = updateData.properties - .filter(p => !existingPropertyIds.has(p.definitionId)) - .map(p => { - const existingProperty = patient?.properties?.find(ep => ep.definition?.id === p.definitionId) - return { - __typename: 'PropertyValueType' as const, - definition: existingProperty?.definition || { - __typename: 'PropertyDefinitionType' as const, - id: p.definitionId, - name: '', - description: null, - fieldType: 'TEXT' as FieldType, - isActive: true, - allowedEntities: [], - options: [], - }, - textValue: p.textValue, - numberValue: p.numberValue, - booleanValue: p.booleanValue, - dateValue: p.dateValue, - dateTimeValue: p.dateTimeValue, - selectValue: p.selectValue, - multiSelectValues: p.multiSelectValues, - } - }) - updated.properties = [...existingProperties, ...newProperties] - } - - return updated - } - - const updates: Array<{ queryKey: unknown[], updateFn: (oldData: unknown) => unknown }> = [] - - updates.push({ - queryKey: ['GetPatient', { id }], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientQuery | undefined - if (!data?.patient) return oldData - const updatedPatient = updatePatientInQuery(data.patient, updateData) - return { - ...data, - patient: updatedPatient - } - } - }) - - const allGetPatientsQueries = queryClient.getQueryCache().getAll() - .filter(query => { - const key = query.queryKey - return Array.isArray(key) && key[0] === 'GetPatients' - }) - - for (const query of allGetPatientsQueries) { - updates.push({ - queryKey: [...query.queryKey] as unknown[], - updateFn: (oldData: unknown) => { - const data = oldData as GetPatientsQuery | undefined - if (!data?.patients) return oldData - const patientIndex = data.patients.findIndex(p => p.id === id) - if (patientIndex === -1) return oldData - const patient = data.patients[patientIndex] - if (!patient) return oldData - const updatedPatient = updatePatientInQuery(patient as unknown as PatientType, updateData) - if (!updatedPatient) return oldData - const updatedName = updatedPatient.firstname && updatedPatient.lastname - ? `${updatedPatient.firstname} ${updatedPatient.lastname}`.trim() - : updatedPatient.firstname || updatedPatient.lastname || patient.name || '' - const updatedPatientForList: typeof data.patients[0] = { - ...patient, - firstname: updateData.firstname !== undefined ? (updateData.firstname || '') : patient.firstname, - lastname: updateData.lastname !== undefined ? (updateData.lastname || '') : patient.lastname, - name: updatedName, - sex: updateData.sex !== undefined && updateData.sex !== null ? updateData.sex : patient.sex, - birthdate: updateData.birthdate !== undefined ? (updateData.birthdate || null) : patient.birthdate, - ...('description' in patient && { description: updateData.description !== undefined ? updateData.description : (patient as unknown as PatientType & { description?: string | null }).description }), - clinic: updateData.clinicId !== undefined - ? (updateData.clinicId - ? (locationsData?.locationNodes?.find(loc => loc.id === updateData.clinicId) as typeof patient.clinic || patient.clinic) - : (null as unknown as typeof patient.clinic)) - : patient.clinic, - position: updateData.positionId !== undefined - ? (updateData.positionId - ? (locationsData?.locationNodes?.find(loc => loc.id === updateData.positionId) as typeof patient.position || patient.position) - : (null as unknown as typeof patient.position)) - : patient.position, - teams: updateData.teamIds !== undefined - ? (locationsData?.locationNodes?.filter(loc => updateData.teamIds?.includes(loc.id)).map(team => team as typeof patient.teams[0]) || patient.teams) - : patient.teams, - properties: updateData.properties !== undefined && updateData.properties !== null - ? (updatedPatient.properties || patient.properties) - : patient.properties, - } - return { - ...data, - patients: [ - ...data.patients.slice(0, patientIndex), - updatedPatientForList, - ...data.patients.slice(patientIndex + 1) - ] - } - } - }) - } - - updates.push({ - 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 === id) - if (!existingPatient) return oldData - const updatedPatient = updatePatientInQuery(existingPatient as unknown as PatientType, updateData) - return { - ...data, - patients: data.patients.map(p => p.id === id ? updatedPatient as typeof existingPatient : p) - } - } - }) - - updates.push({ - queryKey: ['GetOverviewData'], - updateFn: (oldData: unknown) => { - return oldData - } - }) - - return updates - }, - affectedQueryKeys: [ - ['GetPatient', { id }], - ['GetPatients'], - ['GetOverviewData'], - ['GetGlobalData'] - ], - onSuccess, - onError, - }) -} diff --git a/web/api/optimistic-updates/GetTask.ts b/web/api/optimistic-updates/GetTask.ts deleted file mode 100644 index 21739b71..00000000 --- a/web/api/optimistic-updates/GetTask.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { useSafeMutation } from '@/hooks/useSafeMutation' -import { fetcher } from '@/api/gql/fetcher' -import { UpdateTaskDocument, type UpdateTaskMutation, type UpdateTaskMutationVariables, type UpdateTaskInput, type FieldType } from '@/api/gql/generated' -import type { GetTaskQuery, GetTasksQuery, GetGlobalDataQuery } from '@/api/gql/generated' -import { useTasksContext } from '@/hooks/useTasksContext' -import { useQueryClient } from '@tanstack/react-query' - -interface UseOptimisticUpdateTaskMutationParams { - id: string, - onSuccess?: (data: UpdateTaskMutation, variables: UpdateTaskMutationVariables) => void, - onError?: (error: Error, variables: UpdateTaskMutationVariables) => void, -} - -export function useOptimisticUpdateTaskMutation({ - id, - onSuccess, - onError, -}: UseOptimisticUpdateTaskMutationParams) { - const { selectedRootLocationIds } = useTasksContext() - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - const queryClient = useQueryClient() - - return useSafeMutation({ - mutationFn: async (variables) => { - return fetcher(UpdateTaskDocument, variables)() - }, - optimisticUpdate: (variables) => { - const updateData = variables.data || {} - const locationsData = queryClient.getQueryData(['GetLocations']) as { locationNodes?: Array<{ id: string, title: string, kind: string, parentId?: string | null }> } | undefined - const usersData = queryClient.getQueryData(['GetUsers']) as { users?: Array<{ id: string, name: string, avatarUrl?: string | null, lastOnline?: unknown, isOnline?: boolean }> } | undefined - type TaskType = NonNullable>>['task']> - - const updateTaskInQuery = (task: TaskType, updateData: Partial) => { - if (!task) return task - - const updated: typeof task = { ...task } - - if (updateData.title !== undefined) { - updated.title = updateData.title || '' - } - if (updateData.description !== undefined) { - updated.description = updateData.description - } - if (updateData.done !== undefined) { - updated.done = updateData.done ?? false - } - if (updateData.dueDate !== undefined) { - updated.dueDate = updateData.dueDate || null - } - if (updateData.priority !== undefined) { - updated.priority = updateData.priority - } - if (updateData.estimatedTime !== undefined) { - updated.estimatedTime = updateData.estimatedTime - } - if (updateData.assigneeId !== undefined) { - if (updateData.assigneeId === null || updateData.assigneeId === undefined) { - updated.assignee = null as typeof task.assignee - } else { - const user = usersData?.users?.find(u => u.id === updateData.assigneeId) - if (user) { - updated.assignee = { - __typename: 'UserType' as const, - id: user.id, - name: user.name, - avatarUrl: user.avatarUrl, - lastOnline: user.lastOnline, - isOnline: user.isOnline ?? false, - } as typeof task.assignee - } - } - } - if (updateData.assigneeTeamId !== undefined) { - if (updateData.assigneeTeamId === null || updateData.assigneeTeamId === undefined) { - updated.assigneeTeam = null as typeof task.assigneeTeam - } else { - const teamLocation = locationsData?.locationNodes?.find(loc => loc.id === updateData.assigneeTeamId) - if (teamLocation) { - updated.assigneeTeam = { - ...teamLocation, - __typename: 'LocationNodeType' as const, - } as typeof task.assigneeTeam - } - } - } - if (updateData.properties !== undefined && updateData.properties !== null) { - const propertyMap = new Map(updateData.properties.map(p => [p.definitionId, p])) - const existingPropertyIds = new Set( - task.properties?.map(p => p.definition?.id).filter(Boolean) || [] - ) - const newPropertyIds = new Set(updateData.properties.map(p => p.definitionId)) - - const existingProperties = task.properties - ? task.properties - .filter(p => newPropertyIds.has(p.definition?.id)) - .map(p => { - const newProp = propertyMap.get(p.definition?.id) - if (!newProp) return p - return { - ...p, - textValue: newProp.textValue ?? p.textValue, - numberValue: newProp.numberValue ?? p.numberValue, - booleanValue: newProp.booleanValue ?? p.booleanValue, - dateValue: newProp.dateValue ?? p.dateValue, - dateTimeValue: newProp.dateTimeValue ?? p.dateTimeValue, - selectValue: newProp.selectValue ?? p.selectValue, - multiSelectValues: newProp.multiSelectValues ?? p.multiSelectValues, - } - }) - : [] - const newProperties = updateData.properties - .filter(p => !existingPropertyIds.has(p.definitionId)) - .map(p => { - const existingProperty = task?.properties?.find(ep => ep.definition?.id === p.definitionId) - return { - __typename: 'PropertyValueType' as const, - definition: existingProperty?.definition || { - __typename: 'PropertyDefinitionType' as const, - id: p.definitionId, - name: '', - description: null, - fieldType: 'TEXT' as FieldType, - isActive: true, - allowedEntities: [], - options: [], - }, - textValue: p.textValue, - numberValue: p.numberValue, - booleanValue: p.booleanValue, - dateValue: p.dateValue, - dateTimeValue: p.dateTimeValue, - selectValue: p.selectValue, - multiSelectValues: p.multiSelectValues, - } - }) - updated.properties = [...existingProperties, ...newProperties] - } - - return updated - } - - const updates: Array<{ queryKey: unknown[], updateFn: (oldData: unknown) => unknown }> = [] - - updates.push({ - queryKey: ['GetTask', { id }], - updateFn: (oldData: unknown) => { - const data = oldData as GetTaskQuery | undefined - if (!data?.task) return oldData - const updatedTask = updateTaskInQuery(data.task, updateData) - return { - ...data, - task: updatedTask - } - } - }) - - const allGetTasksQueries = queryClient.getQueryCache().getAll() - .filter(query => { - const key = query.queryKey - return Array.isArray(key) && key[0] === 'GetTasks' - }) - - for (const query of allGetTasksQueries) { - updates.push({ - queryKey: [...query.queryKey] as unknown[], - updateFn: (oldData: unknown) => { - const data = oldData as GetTasksQuery | undefined - if (!data?.tasks) return oldData - const taskIndex = data.tasks.findIndex(t => t.id === id) - if (taskIndex === -1) return oldData - const task = data.tasks[taskIndex] - if (!task) return oldData - const updatedTask = updateTaskInQuery(task as unknown as TaskType, updateData) - if (!updatedTask) return oldData - const updatedTaskForList: typeof data.tasks[0] = { - ...task, - title: updateData.title !== undefined ? (updateData.title || '') : task.title, - description: updateData.description !== undefined ? updateData.description : task.description, - done: updateData.done !== undefined ? (updateData.done ?? false) : task.done, - dueDate: updateData.dueDate !== undefined ? (updateData.dueDate || null) : task.dueDate, - priority: updateData.priority !== undefined ? updateData.priority : task.priority, - estimatedTime: updateData.estimatedTime !== undefined ? updateData.estimatedTime : task.estimatedTime, - assignee: updateData.assigneeId !== undefined - ? (updateData.assigneeId - ? (usersData?.users?.find(u => u.id === updateData.assigneeId) ? { - __typename: 'UserType' as const, - id: updateData.assigneeId, - name: usersData.users.find(u => u.id === updateData.assigneeId)!.name, - avatarUrl: usersData.users.find(u => u.id === updateData.assigneeId)!.avatarUrl, - lastOnline: usersData.users.find(u => u.id === updateData.assigneeId)!.lastOnline, - isOnline: usersData.users.find(u => u.id === updateData.assigneeId)!.isOnline ?? false, - } as typeof task.assignee : task.assignee) - : (null as typeof task.assignee)) - : task.assignee, - assigneeTeam: updateData.assigneeTeamId !== undefined - ? (updateData.assigneeTeamId - ? (locationsData?.locationNodes?.find(loc => loc.id === updateData.assigneeTeamId) ? { - __typename: 'LocationNodeType' as const, - id: updateData.assigneeTeamId, - title: locationsData.locationNodes!.find(loc => loc.id === updateData.assigneeTeamId)!.title, - kind: locationsData.locationNodes!.find(loc => loc.id === updateData.assigneeTeamId)!.kind, - } as typeof task.assigneeTeam : task.assigneeTeam) - : (null as typeof task.assigneeTeam)) - : task.assigneeTeam, - } - return { - ...data, - tasks: [ - ...data.tasks.slice(0, taskIndex), - updatedTaskForList, - ...data.tasks.slice(taskIndex + 1) - ] - } - } - }) - } - - updates.push({ - queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], - updateFn: (oldData: unknown) => { - const data = oldData as GetGlobalDataQuery | undefined - if (!data) return oldData - const existingTask = data.me?.tasks?.find(t => t.id === id) - if (!existingTask) return oldData - const updatedTask = updateTaskInQuery(existingTask as unknown as TaskType, updateData) - return { - ...data, - me: data.me ? { - ...data.me, - tasks: data.me.tasks?.map(t => t.id === id ? updatedTask as typeof existingTask : t) || [] - } : null - } - } - }) - - updates.push({ - queryKey: ['GetOverviewData'], - updateFn: (oldData: unknown) => { - return oldData - } - }) - - return updates - }, - affectedQueryKeys: [ - ['GetTask', { id }], - ['GetTasks'], - ['GetOverviewData'], - ['GetGlobalData'] - ], - onSuccess, - onError, - }) -} diff --git a/web/codegen.ts b/web/codegen.ts index c9746a70..a8a5e65e 100644 --- a/web/codegen.ts +++ b/web/codegen.ts @@ -7,18 +7,7 @@ const config: CodegenConfig = { documents: 'api/graphql/**/*.graphql', generates: { 'api/gql/generated.ts': { - plugins: [ - 'typescript', - 'typescript-operations', - 'typescript-react-query', - ], - config: { - fetcher: { - func: './fetcher#fetcher', - isReactHook: false, - }, - reactQueryVersion: 5, - }, + plugins: ['typescript', 'typescript-operations'], }, }, } diff --git a/web/components/CenteredLoadingLogo.tsx b/web/components/CenteredLoadingLogo.tsx new file mode 100644 index 00000000..c75803a2 --- /dev/null +++ b/web/components/CenteredLoadingLogo.tsx @@ -0,0 +1,15 @@ +import type { ReactElement } from 'react' +import { HelpwaveLogo } from '@helpwave/hightide' + +export function CenteredLoadingLogo(): ReactElement { + return ( +
+ +
+ ) +} diff --git a/web/components/ConnectionStatusIndicator.tsx b/web/components/ConnectionStatusIndicator.tsx new file mode 100644 index 00000000..5aae90bf --- /dev/null +++ b/web/components/ConnectionStatusIndicator.tsx @@ -0,0 +1,39 @@ +import type { ReactElement } from 'react' +import { Wifi, WifiOff, Loader2 } from 'lucide-react' +import { Tooltip, Button } from '@helpwave/hightide' +import { useConnectionStatus } from '@/hooks/useConnectionStatus' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' + +export function ConnectionStatusIndicator(): ReactElement { + const status = useConnectionStatus() + const translation = useTasksTranslation() + + const tooltip = + status === 'connected' + ? translation('connectionConnected') ?? 'Connected' + : status === 'connecting' + ? translation('connectionConnecting') ?? 'Connecting…' + : translation('connectionDisconnected') ?? 'Disconnected' + + return ( + + + + ) +} diff --git a/web/components/Notifications.tsx b/web/components/Notifications.tsx index 60447b41..04992d43 100644 --- a/web/components/Notifications.tsx +++ b/web/components/Notifications.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react' import { Button, Chip, PopUp, PopUpContext, PopUpOpener, PopUpRoot, Tooltip, useLocalStorage, Visibility } from '@helpwave/hightide' import { Bell } from 'lucide-react' -import { useGetOverviewDataQuery } from '@/api/gql/generated' +import { useOverviewData } from '@/data' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { SmartDate } from '@/utils/date' import { useRouter } from 'next/router' @@ -21,9 +21,7 @@ export const Notifications = () => { const translation = useTasksTranslation() const router = useRouter() const { user } = useTasksContext() - const { data, refetch } = useGetOverviewDataQuery(undefined, { - refetchOnWindowFocus: true, - }) + const { data, refetch } = useOverviewData() const { value: readNotificationsRaw, diff --git a/web/components/layout/ContentPanel.tsx b/web/components/layout/ContentPanel.tsx index db3e4e15..a1092953 100644 --- a/web/components/layout/ContentPanel.tsx +++ b/web/components/layout/ContentPanel.tsx @@ -15,7 +15,7 @@ export const ContentPanel = ({ ...props }: ContentPanelProps) => { return ( -
+

{titleElement}

diff --git a/web/components/layout/Page.tsx b/web/components/layout/Page.tsx index e8579c14..e2da71a8 100644 --- a/web/components/layout/Page.tsx +++ b/web/components/layout/Page.tsx @@ -34,10 +34,11 @@ import { MessageSquare } from 'lucide-react' import { Notifications } from '@/components/Notifications' +import { ConnectionStatusIndicator } from '@/components/ConnectionStatusIndicator' import { TasksLogo } from '@/components/TasksLogo' import { useRouter } from 'next/router' import { useTasksContext } from '@/hooks/useTasksContext' -import { useGetLocationsQuery } from '@/api/gql/generated' +import { useLocations } from '@/data' import { hashString } from '@/utils/hash' import { useSwipeGesture } from '@/hooks/useSwipeGesture' import { LocationSelectionDialog } from '@/components/locations/LocationSelectionDialog' @@ -216,11 +217,10 @@ const RootLocationSelector = ({ className, onSelect }: RootLocationSelectorProps const [isLocationPickerOpen, setIsLocationPickerOpen] = useState(false) const [selectedLocationsCache, setSelectedLocationsCache] = useState>([]) - const { data: locationsData } = useGetLocationsQuery( + const { data: locationsData } = useLocations( {}, { - enabled: !!selectedRootLocationIds && selectedRootLocationIds.length > 0, - refetchOnWindowFocus: true, + skip: !selectedRootLocationIds || selectedRootLocationIds.length === 0, } ) @@ -335,7 +335,7 @@ export const Header = ({ onMenuClick, isMenuOpen, ...props }: HeaderProps) => { props.className )} > -
+
+
)} - {patientData?.patient?.state && ( - + {patientData?.state && ( + )}
@@ -189,7 +184,7 @@ export const PatientDetailView = ({ @@ -201,7 +196,7 @@ export const PatientDetailView = ({ subjectId={patientId} subjectType="patient" fullWidthAddButton={true} - propertyValues={patientData?.patient?.properties} + propertyValues={patientData?.properties} onPropertyValueChange={handlePropertyValueChange} /> diff --git a/web/components/patients/PatientTasksView.tsx b/web/components/patients/PatientTasksView.tsx index 8da93821..9a4b1323 100644 --- a/web/components/patients/PatientTasksView.tsx +++ b/web/components/patients/PatientTasksView.tsx @@ -6,7 +6,7 @@ import { TaskCardView } from '@/components/tasks/TaskCardView' import clsx from 'clsx' import type { GetPatientQuery } from '@/api/gql/generated' import { TaskDetailView } from '@/components/tasks/TaskDetailView' -import { useOptimisticCompleteTaskMutation, useOptimisticReopenTaskMutation } from '@/api/optimistic-updates/GetPatient' +import { useCompleteTask, useReopenTask } from '@/data' interface PatientTasksViewProps { patientId: string, @@ -33,33 +33,8 @@ export const PatientTasksView = ({ const [isCreatingTask, setIsCreatingTask] = useState(false) const [optimisticTaskUpdates, setOptimisticTaskUpdates] = useState>(new Map()) - const { mutate: completeTask } = useOptimisticCompleteTaskMutation({ - id: patientId, - onSuccess: async () => { - onSuccess?.() - }, - onError: (error, variables) => { - setOptimisticTaskUpdates(prev => { - const next = new Map(prev) - next.delete(variables.id) - return next - }) - }, - }) - - const { mutate: reopenTask } = useOptimisticReopenTaskMutation({ - id: patientId, - onSuccess: async () => { - onSuccess?.() - }, - onError: (error, variables) => { - setOptimisticTaskUpdates(prev => { - const next = new Map(prev) - next.delete(variables.id) - return next - }) - }, - }) + const [completeTask] = useCompleteTask() + const [reopenTask] = useReopenTask() const tasks = useMemo(() => { const baseTasks = patientData?.patient?.tasks || [] @@ -86,9 +61,29 @@ export const PatientTasksView = ({ return next }) if (done) { - completeTask({ id: taskId }) + completeTask({ + variables: { id: taskId }, + onCompleted: () => onSuccess?.(), + onError: () => { + setOptimisticTaskUpdates(prev => { + const next = new Map(prev) + next.delete(taskId) + return next + }) + }, + }) } else { - reopenTask({ id: taskId }) + reopenTask({ + variables: { id: taskId }, + onCompleted: () => onSuccess?.(), + onError: () => { + setOptimisticTaskUpdates(prev => { + const next = new Map(prev) + next.delete(taskId) + return next + }) + }, + }) } } diff --git a/web/components/properties/PropertyDetailView.tsx b/web/components/properties/PropertyDetailView.tsx index b7108e9f..803f20af 100644 --- a/web/components/properties/PropertyDetailView.tsx +++ b/web/components/properties/PropertyDetailView.tsx @@ -16,12 +16,8 @@ import type { Property, PropertyFieldType, PropertySelectOption, PropertySubject import { propertyFieldTypeList, propertySubjectTypeList } from '@/components/tables/PropertyList' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { PlusIcon, XIcon } from 'lucide-react' -import { - useCreatePropertyDefinitionMutation, - useUpdatePropertyDefinitionMutation, - FieldType, - PropertyEntity -} from '@/api/gql/generated' +import { FieldType, PropertyEntity } from '@/api/gql/generated' +import { useCreatePropertyDefinition, useUpdatePropertyDefinition } from '@/data' interface PropertyDetailViewProps { id?: string, @@ -75,32 +71,8 @@ export const PropertyDetailView = ({ isCustom: false, }) - const [isCreating, setIsCreating] = useState(false) - const { mutate: createProperty } = useCreatePropertyDefinitionMutation({ - onMutate: () => { - setIsCreating(true) - }, - onSettled: () => { - setIsCreating(false) - }, - onSuccess: () => { - onSuccess() - onClose() - } - }) - - const [isUpdating, setIsUpdating] = useState(false) - const { mutate: updateProperty } = useUpdatePropertyDefinitionMutation({ - onMutate: () => { - setIsUpdating(true) - }, - onSettled: () => { - setIsUpdating(false) - }, - onSuccess: () => { - onSuccess() - } - }) + const [createProperty, { loading: isCreating }] = useCreatePropertyDefinition() + const [updateProperty, { loading: isUpdating }] = useUpdatePropertyDefinition() const persist = (updates: Partial) => { if (!isEditMode || !id) return @@ -118,8 +90,8 @@ export const PropertyDetailView = ({ } updateProperty({ - id, - data: backendUpdates + variables: { id, data: backendUpdates }, + onCompleted: () => onSuccess(), }) } @@ -149,7 +121,13 @@ export const PropertyDetailView = ({ isActive: !values.isArchived, } - createProperty({ data: createData }) + createProperty({ + variables: { data: createData }, + onCompleted: () => { + onSuccess() + onClose() + }, + }) }, validators: { name: (value) => { diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx index d7a3036e..fc2720d7 100644 --- a/web/components/tables/PatientList.tsx +++ b/web/components/tables/PatientList.tsx @@ -1,8 +1,8 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useCallback } from 'react' -import { Chip, FillerCell, Button, SearchBar, ProgressIndicator, Tooltip, Checkbox, Drawer, Visibility, TableProvider, TableDisplay, TablePagination, TableColumnSwitcher } from '@helpwave/hightide' +import { Chip, FillerCell, Button, LoadingContainer, SearchBar, ProgressIndicator, Tooltip, Checkbox, Drawer, Visibility, TableProvider, TableDisplay, TablePagination, TableColumnSwitcher } from '@helpwave/hightide' import { PlusIcon, Table as TableIcon, LayoutGrid, Printer } from 'lucide-react' -import { GetPatientsDocument, Sex, PatientState, type GetPatientsQuery, type TaskType, useGetPropertyDefinitionsQuery, PropertyEntity, type FullTextSearchInput } from '@/api/gql/generated' -import { usePaginatedGraphQLQuery } from '@/hooks/usePaginatedQuery' +import { Sex, PatientState, type GetPatientsQuery, type TaskType, PropertyEntity, type FullTextSearchInput } from '@/api/gql/generated' +import { usePropertyDefinitions, usePatientsPaginated, useRefreshingEntityIds } from '@/data' import { PatientDetailView } from '@/components/patients/PatientDetailView' import { SmartDate } from '@/utils/date' import { LocationChips } from '@/components/patients/LocationChips' @@ -31,6 +31,7 @@ export type PatientViewModel = { } const STORAGE_KEY_SHOW_ALL_PATIENTS = 'patient-show-all-states' +const DEFAULT_PATIENT_STATES: PatientState[] = [PatientState.Admitted] const STORAGE_KEY_COLUMN_VISIBILITY = 'patient-list-column-visibility' const STORAGE_KEY_COLUMN_FILTERS = 'patient-list-column-filters' const STORAGE_KEY_COLUMN_SORTING = 'patient-list-column-sorting' @@ -52,6 +53,7 @@ type PatientListProps = { export const PatientList = forwardRef(({ initialPatientId, onInitialPatientOpened, acceptedStates, rootLocationIds, locationId }, ref) => { const translation = useTasksTranslation() const { selectedRootLocationIds } = useTasksContext() + const { refreshingPatientIds } = useRefreshingEntityIds() const effectiveRootLocationIds = rootLocationIds ?? selectedRootLocationIds const { viewType, toggleView } = usePatientViewToggle() const [isPanelOpen, setIsPanelOpen] = useState(false) @@ -91,7 +93,7 @@ export const PatientList = forwardRef(({ initi defaultValue: {} }) - const [isPrinting, setIsPrinting] = useState(false) + const [, setIsPrinting] = useState(false) const handleShowAllPatientsChange = (checked: boolean) => { setShowAllPatients(() => { @@ -109,7 +111,7 @@ export const PatientList = forwardRef(({ initi PatientState.Wait, ], []) - const patientStates = showAllPatients ? allPatientStates : (acceptedStates ?? [PatientState.Admitted]) + const patientStates = showAllPatients ? allPatientStates : (acceptedStates ?? DEFAULT_PATIENT_STATES) const searchInput: FullTextSearchInput | undefined = searchQuery ? { @@ -118,25 +120,17 @@ export const PatientList = forwardRef(({ initi } : undefined - const { data: patientsData, refetch, totalCount } = usePaginatedGraphQLQuery({ - queryKey: ['GetPatients', { locationId, rootLocationIds: effectiveRootLocationIds, states: patientStates, search: searchQuery }], - document: GetPatientsDocument, - baseVariables: { + const { data: patientsData, refetch, totalCount } = usePatientsPaginated( + { locationId: locationId || undefined, rootLocationIds: !locationId && effectiveRootLocationIds && effectiveRootLocationIds.length > 0 ? effectiveRootLocationIds : undefined, states: patientStates, search: searchInput, }, - pageSize: 50, - extractItems: (result) => result.patients, - extractTotalCount: (result) => result.patientsTotal ?? undefined, - mode: 'infinite', - enabled: !isPrinting, - refetchOnWindowFocus: !isPrinting, - refetchOnMount: true, - }) + { pageSize: 50 } + ) - const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() + const { data: propertyDefinitionsData } = usePropertyDefinitions() useEffect(() => { if (propertyDefinitionsData?.propertyDefinitions) { @@ -253,11 +247,14 @@ export const PatientList = forwardRef(({ initi createPropertyColumn(prop)) }, [propertyDefinitionsData]) + const rowLoadingCell = useMemo(() => , []) + const columns = useMemo[]>(() => [ { id: 'name', header: translation('name'), accessorKey: 'name', + cell: ({ row }) => (refreshingPatientIds.has(row.original.id) ? rowLoadingCell : row.original.name), minSize: 200, size: 250, maxSize: 300, @@ -267,9 +264,8 @@ export const PatientList = forwardRef(({ initi id: 'state', header: translation('status'), accessorFn: ({ state }) => [state], - cell: ({ row }) => ( - - ), + cell: ({ row }) => + refreshingPatientIds.has(row.original.id) ? rowLoadingCell : , minSize: 120, size: 144, maxSize: 180, @@ -285,6 +281,7 @@ export const PatientList = forwardRef(({ initi header: translation('sex'), accessorFn: ({ sex }) => [sex], cell: ({ row }) => { + if (refreshingPatientIds.has(row.original.id)) return rowLoadingCell const sex = row.original.sex const colorClass = sex === Sex.Male ? '!gender-male' @@ -327,9 +324,10 @@ export const PatientList = forwardRef(({ initi id: 'position', header: translation('location'), accessorFn: ({ position }) => position?.title, - cell: ({ row }) => ( - - ), + cell: ({ row }) => + refreshingPatientIds.has(row.original.id) ? rowLoadingCell : ( + + ), minSize: 200, size: 250, maxSize: 400, @@ -339,11 +337,10 @@ export const PatientList = forwardRef(({ initi id: 'birthdate', header: translation('birthdate'), accessorKey: 'birthdate', - cell: ({ row }) => { - return ( + cell: ({ row }) => + refreshingPatientIds.has(row.original.id) ? rowLoadingCell : ( - ) - }, + ), minSize: 200, size: 200, maxSize: 200, @@ -357,6 +354,7 @@ export const PatientList = forwardRef(({ initi return total === 0 ? 0 : closedTasksCount / total }, cell: ({ row }) => { + if (refreshingPatientIds.has(row.original.id)) return rowLoadingCell const { openTasksCount, closedTasksCount } = row.original const total = openTasksCount + closedTasksCount const progress = total === 0 ? 0 : closedTasksCount / total @@ -378,8 +376,14 @@ export const PatientList = forwardRef(({ initi size: 150, maxSize: 200, }, - ...patientPropertyColumns, - ], [allPatientStates, translation, patientPropertyColumns]) + ...patientPropertyColumns.map((col) => ({ + ...col, + cell: col.cell + ? (params: { row: { original: PatientViewModel } }) => + refreshingPatientIds.has(params.row.original.id) ? rowLoadingCell : (col.cell as (p: unknown) => React.ReactNode)(params) + : undefined, + })), + ], [allPatientStates, translation, patientPropertyColumns, refreshingPatientIds, rowLoadingCell]) const onRowClick = useCallback((row: Row) => handleEdit(row.original), [handleEdit]) const fillerRowCell = useCallback(() => (), []) diff --git a/web/components/tables/PropertyList.tsx b/web/components/tables/PropertyList.tsx index b473e2fa..75a8b0a4 100644 --- a/web/components/tables/PropertyList.tsx +++ b/web/components/tables/PropertyList.tsx @@ -3,7 +3,8 @@ import { Plus } from 'lucide-react' import { useMemo, useState, useEffect } from 'react' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { PropertyEntry } from '@/components/properties/PropertyEntry' -import { useGetPropertyDefinitionsQuery, FieldType, PropertyEntity } from '@/api/gql/generated' +import { FieldType, PropertyEntity } from '@/api/gql/generated' +import { usePropertyDefinitions } from '@/data' @@ -131,7 +132,8 @@ export const PropertyList = ({ const [removedPropertyIds, setRemovedPropertyIds] = useState>(new Set()) const [pendingAdditions, setPendingAdditions] = useState>(new Map()) - const { data: propertyDefinitionsData, isLoading: isLoadingDefinitions, isError: isErrorDefinitions } = useGetPropertyDefinitionsQuery() + const { data: propertyDefinitionsData, loading: isLoadingDefinitions, error: errorDefinitions } = usePropertyDefinitions() + const isErrorDefinitions = !!errorDefinitions const availableProperties = useMemo(() => { if (!propertyDefinitionsData?.propertyDefinitions) return [] diff --git a/web/components/tables/RecentPatientsTable.tsx b/web/components/tables/RecentPatientsTable.tsx index 14b1060a..6d881379 100644 --- a/web/components/tables/RecentPatientsTable.tsx +++ b/web/components/tables/RecentPatientsTable.tsx @@ -6,7 +6,8 @@ import type { TableProps } from '@helpwave/hightide' import { FillerCell, Table, TableColumnSwitcher, Tooltip } from '@helpwave/hightide' import { SmartDate } from '@/utils/date' import { LocationChips } from '@/components/patients/LocationChips' -import { useGetPropertyDefinitionsQuery, PropertyEntity } from '@/api/gql/generated' +import { PropertyEntity } from '@/api/gql/generated' +import { usePropertyDefinitions } from '@/data' import { createPropertyColumn } from '@/utils/propertyColumn' import { useStateWithLocalStorage } from '@/hooks/useStateWithLocalStorage' @@ -28,7 +29,7 @@ export const RecentPatientsTable = ({ ...props }: RecentPatientsTableProps) => { const translation = useTasksTranslation() - const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() + const { data: propertyDefinitionsData } = usePropertyDefinitions() const [pagination, setPagination] = useStateWithLocalStorage({ key: STORAGE_KEY_COLUMN_PAGINATION, diff --git a/web/components/tables/RecentTasksTable.tsx b/web/components/tables/RecentTasksTable.tsx index 36071479..d2c15d45 100644 --- a/web/components/tables/RecentTasksTable.tsx +++ b/web/components/tables/RecentTasksTable.tsx @@ -9,7 +9,8 @@ import { ArrowRightIcon } from 'lucide-react' import { SmartDate } from '@/utils/date' import { DueDateUtils } from '@/utils/dueDate' import { PriorityUtils } from '@/utils/priority' -import { useGetPropertyDefinitionsQuery, PropertyEntity } from '@/api/gql/generated' +import { PropertyEntity } from '@/api/gql/generated' +import { usePropertyDefinitions } from '@/data' import { createPropertyColumn } from '@/utils/propertyColumn' import { useStateWithLocalStorage } from '@/hooks/useStateWithLocalStorage' @@ -38,7 +39,7 @@ export const RecentTasksTable = ({ ...props }: RecentTasksTableProps) => { const translation = useTasksTranslation() - const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() + const { data: propertyDefinitionsData } = usePropertyDefinitions() const [pagination, setPagination] = useStateWithLocalStorage({ key: STORAGE_KEY_COLUMN_PAGINATION, diff --git a/web/components/tables/TaskList.tsx b/web/components/tables/TaskList.tsx index 9b1b640a..5633e18f 100644 --- a/web/components/tables/TaskList.tsx +++ b/web/components/tables/TaskList.tsx @@ -1,9 +1,10 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useRef, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Button, Checkbox, ConfirmDialog, FillerCell, SearchBar, TableColumnSwitcher, TableDisplay, TablePagination, TableProvider, Tooltip, useLocalStorage, Visibility } from '@helpwave/hightide' +import { Button, Checkbox, ConfirmDialog, FillerCell, LoadingContainer, SearchBar, TableColumnSwitcher, TableDisplay, TablePagination, TableProvider, Tooltip, useLocalStorage, Visibility } from '@helpwave/hightide' import { PlusIcon, Table as TableIcon, LayoutGrid, UserCheck, Users, Printer } from 'lucide-react' import type { TaskPriority, GetTasksQuery } from '@/api/gql/generated' -import { useAssignTaskMutation, useAssignTaskToTeamMutation, useCompleteTaskMutation, useReopenTaskMutation, useGetUsersQuery, useGetLocationsQuery, useGetPropertyDefinitionsQuery, PropertyEntity, type GetGlobalDataQuery } from '@/api/gql/generated' +import { PropertyEntity } from '@/api/gql/generated' +import { useAssignTask, useAssignTaskToTeam, useCompleteTask, useReopenTask, useUsers, useLocations, usePropertyDefinitions, useRefreshingEntityIds } from '@/data' import { AssigneeSelectDialog } from '@/components/tasks/AssigneeSelectDialog' import clsx from 'clsx' import { SmartDate } from '@/utils/date' @@ -113,10 +114,11 @@ export const TaskList = forwardRef(({ tasks: initial }) const queryClient = useQueryClient() - const { totalPatientsCount, user, selectedRootLocationIds } = useTasksContext() + const { totalPatientsCount, user } = useTasksContext() + const { refreshingTaskIds } = useRefreshingEntityIds() const { viewType, toggleView } = useTaskViewToggle() const [optimisticUpdates, setOptimisticUpdates] = useState>(new Map()) - const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() + const { data: propertyDefinitionsData } = usePropertyDefinitions() useEffect(() => { if (propertyDefinitionsData?.propertyDefinitions) { @@ -135,104 +137,10 @@ export const TaskList = forwardRef(({ tasks: initial } } }, [propertyDefinitionsData, columnVisibility, setColumnVisibility]) - 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 - }) - } - setOptimisticUpdates(prev => { - const next = new Map(prev) - next.set(variables.id, true) - return next - }) - return { previousData } - }, - onSuccess: async () => { - setOptimisticUpdates(prev => { - const next = new Map(prev) - return next - }) - await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) - await queryClient.invalidateQueries({ queryKey: ['GetTasks'] }) - await queryClient.invalidateQueries({ queryKey: ['GetPatients'] }) - 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 - }) - } - setOptimisticUpdates(prev => { - const next = new Map(prev) - next.set(variables.id, false) - return next - }) - return { previousData } - }, - onSuccess: async () => { - setOptimisticUpdates(prev => { - const next = new Map(prev) - return next - }) - await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) - await queryClient.invalidateQueries({ queryKey: ['GetTasks'] }) - await queryClient.invalidateQueries({ queryKey: ['GetPatients'] }) - 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 [completeTask] = useCompleteTask() + const [reopenTask] = useReopenTask() + const [assignTask] = useAssignTask() + const [assignTaskToTeam] = useAssignTaskToTeam() const [selectedPatientId, setSelectedPatientId] = useState(null) const [selectedUserPopupId, setSelectedUserPopupId] = useState(null) @@ -332,8 +240,8 @@ export const TaskList = forwardRef(({ tasks: initial const canHandover = openTasks.length > 0 - const { data: usersData } = useGetUsersQuery(undefined, {}) - const { data: locationsData } = useGetLocationsQuery(undefined, {}) + const { data: usersData } = useUsers() + const { data: locationsData } = useLocations() const teams = useMemo(() => { if (!locationsData?.locationNodes) return [] @@ -392,13 +300,17 @@ export const TaskList = forwardRef(({ tasks: initial openTasks.forEach(task => { if (isTeam) { - assignTaskToTeam({ id: task.id, teamId: assigneeId }) + assignTaskToTeam({ + variables: { id: task.id, teamId: assigneeId }, + onCompleted: () => onRefetch?.(), + }) } else { - assignTask({ id: task.id, userId: assigneeId }) + assignTask({ + variables: { id: task.id, userId: assigneeId }, + onCompleted: () => onRefetch?.(), + }) } }) - - queryClient.invalidateQueries({ queryKey: ['GetTask'] }) queryClient.invalidateQueries({ queryKey: ['GetTasks'] }) queryClient.invalidateQueries({ queryKey: ['GetPatients'] }) queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) @@ -419,6 +331,8 @@ export const TaskList = forwardRef(({ tasks: initial }, [taskProperties]) + const rowLoadingCell = useMemo(() => , []) + const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -426,6 +340,7 @@ export const TaskList = forwardRef(({ tasks: initial header: translation('done'), accessorKey: 'done', cell: ({ row }) => { + if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell const task = row.original const optimisticDone = optimisticUpdates.get(task.id) const displayDone = optimisticDone !== undefined ? optimisticDone : task.done @@ -439,9 +354,15 @@ export const TaskList = forwardRef(({ tasks: initial return next }) if (checked) { - completeTask({ id: task.id }) + completeTask({ + variables: { id: task.id }, + onCompleted: () => onRefetch?.(), + }) } else { - reopenTask({ id: task.id }) + reopenTask({ + variables: { id: task.id }, + onCompleted: () => onRefetch?.(), + }) } }} onClick={(e) => e.stopPropagation()} @@ -459,6 +380,7 @@ export const TaskList = forwardRef(({ tasks: initial header: translation('title'), accessorKey: 'name', cell: ({ row }) => { + if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell return (
{row.original.priority && ( @@ -477,6 +399,7 @@ export const TaskList = forwardRef(({ tasks: initial header: translation('dueDate'), accessorKey: 'dueDate', cell: ({ row }) => { + if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell if (!row.original.dueDate) return - const overdue = isOverdue(row.original.dueDate, row.original.done) const closeToDue = isCloseToDueDate(row.original.dueDate, row.original.done) @@ -505,6 +428,7 @@ export const TaskList = forwardRef(({ tasks: initial header: translation('patient'), accessorFn: ({ patient }) => patient?.name, cell: ({ row }) => { + if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell const data = row.original if (!data.patient) { return ( @@ -596,9 +520,19 @@ export const TaskList = forwardRef(({ tasks: initial }) } - return [...cols, ...taskPropertyColumns] + const colsWithRefreshing = [ + ...cols, + ...taskPropertyColumns.map((col) => ({ + ...col, + cell: col.cell + ? (params: { row: { original: TaskViewModel } }) => + refreshingTaskIds.has(params.row.original.id) ? rowLoadingCell : (col.cell as (p: unknown) => React.ReactNode)(params) + : undefined, + })), + ] + return colsWithRefreshing }, - [translation, completeTask, reopenTask, showAssignee, optimisticUpdates, taskPropertyColumns]) + [translation, completeTask, reopenTask, showAssignee, optimisticUpdates, taskPropertyColumns, refreshingTaskIds, rowLoadingCell]) const handleToggleDone = (taskId: string, checked: boolean) => { const task = initialTasks.find(t => t.id === taskId) @@ -610,9 +544,15 @@ export const TaskList = forwardRef(({ tasks: initial return next }) if (checked) { - completeTask({ id: taskId }) + completeTask({ + variables: { id: taskId }, + onCompleted: () => onRefetch?.(), + }) } else { - reopenTask({ id: taskId }) + reopenTask({ + variables: { id: taskId }, + onCompleted: () => onRefetch?.(), + }) } } diff --git a/web/components/tasks/AssigneeSelect.tsx b/web/components/tasks/AssigneeSelect.tsx index e6a445d6..36f438cd 100644 --- a/web/components/tasks/AssigneeSelect.tsx +++ b/web/components/tasks/AssigneeSelect.tsx @@ -3,7 +3,7 @@ import { PropsUtil, Visibility } from '@helpwave/hightide' import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { Users, ChevronDown, Info, SearchIcon } from 'lucide-react' -import { useGetUsersQuery, useGetLocationsQuery } from '@/api/gql/generated' +import { useUsers, useLocations } from '@/data' import clsx from 'clsx' import { AssigneeSelectDialog } from './AssigneeSelectDialog' import { UserInfoPopup } from '../UserInfoPopup' @@ -33,9 +33,8 @@ export const AssigneeSelect = ({ const [selectedUserPopupState, setSelectedUserPopupState] = useState<{ isOpen: boolean, userId: string | null }>({ isOpen: false, userId: null }) - const { data: usersData } = useGetUsersQuery(undefined, { - }) - const { data: locationsData } = useGetLocationsQuery(undefined, {}) + const { data: usersData } = useUsers() + const { data: locationsData } = useLocations() const teams = useMemo(() => { if (!locationsData?.locationNodes) return [] diff --git a/web/components/tasks/AssigneeSelectDialog.tsx b/web/components/tasks/AssigneeSelectDialog.tsx index 930ad37c..5c7aa689 100644 --- a/web/components/tasks/AssigneeSelectDialog.tsx +++ b/web/components/tasks/AssigneeSelectDialog.tsx @@ -3,7 +3,7 @@ import { SearchBar, Dialog } from '@helpwave/hightide' import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { Users, Info } from 'lucide-react' -import { useGetUsersQuery, useGetLocationsQuery } from '@/api/gql/generated' +import { useUsers, useLocations } from '@/data' import clsx from 'clsx' interface AssigneeSelectDialogProps { @@ -33,9 +33,8 @@ export const AssigneeSelectDialog = ({ const [searchQuery, setSearchQuery] = useState('') const searchInputRef = useRef(null) - const { data: usersData } = useGetUsersQuery(undefined, { - }) - const { data: locationsData } = useGetLocationsQuery(undefined, {}) + const { data: usersData } = useUsers() + const { data: locationsData } = useLocations() const teams = useMemo(() => { if (!locationsData?.locationNodes) return [] diff --git a/web/components/tasks/TaskCardView.tsx b/web/components/tasks/TaskCardView.tsx index dd39e42a..6cdf51d8 100644 --- a/web/components/tasks/TaskCardView.tsx +++ b/web/components/tasks/TaskCardView.tsx @@ -7,11 +7,9 @@ import { LocationChips } from '@/components/patients/LocationChips' import type { TaskViewModel } from '@/components/tables/TaskList' import { useRouter } from 'next/router' import type { TaskPriority } from '@/api/gql/generated' -import { useCompleteTaskMutation, useReopenTaskMutation, type GetGlobalDataQuery } from '@/api/gql/generated' +import { useCompleteTask, useReopenTask } from '@/data' import { useState, useEffect, useRef, useMemo } from 'react' import { UserInfoPopup } from '@/components/UserInfoPopup' -import { useQueryClient } from '@tanstack/react-query' -import { useTasksContext } from '@/hooks/useTasksContext' import { PriorityUtils } from '@/utils/priority' type FlexibleTask = { @@ -78,8 +76,6 @@ const toDate = (date: Date | string | null | undefined): Date | undefined => { 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) @@ -88,72 +84,8 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA const descriptionPreview = task.description const displayDone = optimisticDone !== null ? optimisticDone : task.done - const { mutate: completeTask } = useCompleteTaskMutation({ - onMutate: async (variables) => { - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - await queryClient.cancelQueries({ queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }] }) - const previousData = queryClient.getQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }]) - const newDone = pendingCheckedRef.current ?? true - if (previousData?.me?.tasks) { - queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], { - ...previousData, - me: previousData.me ? { - ...previousData.me, - tasks: previousData.me.tasks.map(task => task.id === variables.id ? { ...task, done: newDone } : task) - } : null - }) - } - return { previousData } - }, - onSuccess: async () => { - pendingCheckedRef.current = null - setOptimisticDone(null) - await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) - await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) - onRefetch?.() - }, - onError: (error, variables, context) => { - pendingCheckedRef.current = null - setOptimisticDone(null) - if (context?.previousData) { - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], context.previousData) - } - } - }) - const { mutate: reopenTask } = useReopenTaskMutation({ - onMutate: async (variables) => { - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - await queryClient.cancelQueries({ queryKey: ['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }] }) - const previousData = queryClient.getQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }]) - const newDone = pendingCheckedRef.current ?? false - if (previousData?.me?.tasks) { - queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], { - ...previousData, - me: previousData.me ? { - ...previousData.me, - tasks: previousData.me.tasks.map(task => task.id === variables.id ? { ...task, done: newDone } : task) - } : null - }) - } - return { previousData } - }, - onSuccess: async () => { - pendingCheckedRef.current = null - setOptimisticDone(null) - await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) - await queryClient.invalidateQueries({ queryKey: ['GetOverviewData'] }) - onRefetch?.() - }, - onError: (error, variables, context) => { - pendingCheckedRef.current = null - setOptimisticDone(null) - if (context?.previousData) { - const selectedRootLocationIdsForQuery = selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined - queryClient.setQueryData(['GetGlobalData', { rootLocationIds: selectedRootLocationIdsForQuery }], context.previousData) - } - } - }) + const [completeTask] = useCompleteTask() + const [reopenTask] = useReopenTask() const dueDate = toDate(task.dueDate) const overdue = dueDate ? isOverdue(dueDate, task.done) : false @@ -187,9 +119,31 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA pendingCheckedRef.current = checked setOptimisticDone(checked) if (checked) { - completeTask({ id: task.id }) + completeTask({ + variables: { id: task.id }, + onCompleted: () => { + pendingCheckedRef.current = null + setOptimisticDone(null) + onRefetch?.() + }, + onError: () => { + pendingCheckedRef.current = null + setOptimisticDone(null) + }, + }) } else { - reopenTask({ id: task.id }) + reopenTask({ + variables: { id: task.id }, + onCompleted: () => { + pendingCheckedRef.current = null + setOptimisticDone(null) + onRefetch?.() + }, + onError: () => { + pendingCheckedRef.current = null + setOptimisticDone(null) + }, + }) } } diff --git a/web/components/tasks/TaskDataEditor.tsx b/web/components/tasks/TaskDataEditor.tsx index a76ce6a1..77b33c4b 100644 --- a/web/components/tasks/TaskDataEditor.tsx +++ b/web/components/tasks/TaskDataEditor.tsx @@ -1,13 +1,8 @@ import { useEffect, useState, useMemo } from 'react' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import type { CreateTaskInput, UpdateTaskInput, TaskPriority } from '@/api/gql/generated' -import { - PatientState, - useCreateTaskMutation, - useDeleteTaskMutation, - useGetPatientsQuery, - useGetTaskQuery -} from '@/api/gql/generated' +import { PatientState } from '@/api/gql/generated' +import { useCreateTask, useDeleteTask, usePatients, useTask, useUpdateTask, useRefreshingEntityIds } from '@/data' import type { FormFieldDataHandling } from '@helpwave/hightide' import { Button, @@ -26,12 +21,12 @@ import { Visibility, FormObserver } from '@helpwave/hightide' +import { CenteredLoadingLogo } from '@/components/CenteredLoadingLogo' import { useTasksContext } from '@/hooks/useTasksContext' import { User, Flag } from 'lucide-react' import { SmartDate } from '@/utils/date' import { AssigneeSelect } from './AssigneeSelect' import { localToUTCWithSameTime, PatientDetailView } from '@/components/patients/PatientDetailView' -import { useOptimisticUpdateTaskMutation } from '@/api/optimistic-updates/GetTask' import { ErrorDialog } from '@/components/ErrorDialog' import clsx from 'clsx' import { PriorityUtils } from '@/utils/priority' @@ -61,62 +56,37 @@ export const TaskDataEditor = ({ const isEditMode = id !== null const taskId = id + const { refreshingTaskIds } = useRefreshingEntityIds() - const { data: taskData, isLoading: isLoadingTask } = useGetTaskQuery( - { id: taskId! }, - { - enabled: isEditMode, - refetchOnMount: true, - } + const { data: taskData, loading: isLoadingTask } = useTask( + taskId ?? '', + { skip: !isEditMode } ) - const { data: patientsData } = useGetPatientsQuery( + const { data: patientsData } = usePatients( { rootLocationIds: selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined, states: [PatientState.Admitted, PatientState.Wait], }, - { - enabled: !isEditMode, - } + { skip: isEditMode } ) - const [isCreating, setIsCreating] = useState(false) - const { mutate: createTask } = useCreateTaskMutation({ - onMutate: () => { - setIsCreating(true) - }, - onSettled: () => { - setIsCreating(false) - }, - onSuccess: async () => { - onSuccess?.() - onClose?.() - }, - onError: (error) => { - setErrorDialog({ isOpen: true, message: error instanceof Error ? error.message : 'Failed to create task' }) - }, - }) - - const { mutate: updateTask } = useOptimisticUpdateTaskMutation({ - id: taskId!, - onSuccess: () => { - onSuccess?.() - }, - }) + const [createTask, { loading: isCreating }] = useCreateTask() + const [updateTaskMutate] = useUpdateTask() + const updateTask = (vars: { id: string, data: UpdateTaskInput }) => { + updateTaskMutate({ + variables: vars, + onCompleted: () => onSuccess?.(), + onError: (err) => { + setErrorDialog({ + isOpen: true, + message: err instanceof Error ? err.message : 'Update failed', + }) + }, + }).catch(() => {}) + } - const [isDeleting, setIsDeleting] = useState(false) - const { mutate: deleteTask } = useDeleteTaskMutation({ - onMutate: () => { - setIsDeleting(true) - }, - onSettled: () => { - setIsDeleting(false) - }, - onSuccess: () => { - onSuccess?.() - onClose?.() - }, - }) + const [deleteTask, { loading: isDeleting }] = useDeleteTask() const form = useCreateForm({ initialValues: { @@ -132,17 +102,26 @@ export const TaskDataEditor = ({ }, onFormSubmit: (values) => { createTask({ - data: { - title: values.title, - patientId: values.patientId, - description: values.description, - assigneeId: values.assigneeId, - assigneeTeamId: values.assigneeTeamId, - dueDate: values.dueDate ? localToUTCWithSameTime(values.dueDate)?.toISOString() : null, - priority: (values.priority as TaskPriority | null) || undefined, - estimatedTime: values.estimatedTime, - properties: values.properties - } as CreateTaskInput & { priority?: TaskPriority | null, estimatedTime?: number | null } + variables: { + data: { + title: values.title, + patientId: values.patientId, + description: values.description, + assigneeId: values.assigneeId, + assigneeTeamId: values.assigneeTeamId, + dueDate: values.dueDate ? localToUTCWithSameTime(values.dueDate)?.toISOString() : null, + priority: (values.priority as TaskPriority | null) || undefined, + estimatedTime: values.estimatedTime, + properties: values.properties + } as CreateTaskInput & { priority?: TaskPriority | null, estimatedTime?: number | null } + }, + onCompleted: () => { + onSuccess?.() + onClose?.() + }, + onError: (error) => { + setErrorDialog({ isOpen: true, message: error instanceof Error ? error.message : 'Failed to create task' }) + }, }) }, validators: { @@ -160,27 +139,36 @@ export const TaskDataEditor = ({ }, }, onValidUpdate: (_, updates) => { - if (isEditMode && taskId) { - const data: UpdateTaskInput = { - title: updates?.title, - description: updates?.description, - dueDate: updates?.dueDate ? localToUTCWithSameTime(updates.dueDate)?.toISOString() : undefined, - priority: updates?.priority as TaskPriority | null | undefined, - estimatedTime: updates?.estimatedTime, - done: updates?.done, - assigneeId: updates?.assigneeId, - assigneeTeamId: updates?.assigneeTeamId, - } - updateTask({ id: taskId, data }) + if (!isEditMode || !taskId || !taskData) return + const data: UpdateTaskInput = { + title: updates?.title, + description: updates?.description, + dueDate: updates?.dueDate ? localToUTCWithSameTime(updates.dueDate)?.toISOString() : undefined, + priority: updates?.priority as TaskPriority | null | undefined, + estimatedTime: updates?.estimatedTime, + done: updates?.done, + assigneeId: updates?.assigneeId, + assigneeTeamId: updates?.assigneeTeamId, } + const current = taskData + const sameTitle = (data.title ?? current.title) === current.title + const sameDescription = (data.description ?? current.description ?? '') === (current.description ?? '') + const sameDueDate = (data.dueDate ?? current.dueDate ?? null) === (current.dueDate ?? null) + const samePriority = (data.priority ?? current.priority ?? null) === (current.priority ?? null) + const sameEstimatedTime = (data.estimatedTime ?? current.estimatedTime ?? null) === (current.estimatedTime ?? null) + const sameDone = (data.done ?? current.done) === current.done + const sameAssigneeId = (data.assigneeId ?? current.assignee?.id ?? null) === (current.assignee?.id ?? null) + const sameAssigneeTeamId = (data.assigneeTeamId ?? current.assigneeTeam?.id ?? null) === (current.assigneeTeam?.id ?? null) + if (sameTitle && sameDescription && sameDueDate && samePriority && sameEstimatedTime && sameDone && sameAssigneeId && sameAssigneeTeamId) return + updateTask({ id: taskId, data }) } }) const { update: updateForm } = form useEffect(() => { - if (taskData?.task && isEditMode) { - const task = taskData.task + if (taskData && isEditMode) { + const task = taskData updateForm(prev => ({ ...prev, title: task.title, @@ -196,7 +184,7 @@ export const TaskDataEditor = ({ } else if (initialPatientId && !taskId) { updateForm(prev => ({ ...prev, patientId: initialPatientId })) } - }, [taskData?.task, isEditMode, initialPatientId, taskId, updateForm]) + }, [taskData, isEditMode, initialPatientId, taskId, updateForm]) const patients = patientsData?.patients || [] @@ -210,7 +198,7 @@ export const TaskDataEditor = ({ }, [dueDate, estimatedTime]) if (isEditMode && isLoadingTask) { - return + return } const priorities = [ @@ -220,8 +208,16 @@ export const TaskDataEditor = ({ { value: 'P4', label: translation('priority', { priority: 'P4' }) }, ] + const isRefreshing = isEditMode && taskId != null && refreshingTaskIds.has(taskId) + return ( <> + {isRefreshing && ( +
+ + {translation('refreshing')} +
+ )} { event.preventDefault(); form.submit() }}>
@@ -287,7 +283,7 @@ export const TaskDataEditor = ({ className="w-fit" > - { taskData?.task?.patient?.name} + { taskData?.patient?.name}
) @@ -411,7 +407,13 @@ export const TaskDataEditor = ({