From 469b43753e827461be3376b25e40c96b5f9cb5d6 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:10:29 +0100 Subject: [PATCH 01/19] adopt: hightide version 0.6.5 --- web/api/optimistic-updates/GetPatient.ts | 784 ++++++++ web/api/optimistic-updates/GetTask.ts | 254 +++ web/components/AuditLogTimeline.tsx | 1 - web/components/AvatarComponent.tsx | 23 +- web/components/AvatarStatusComponent.tsx | 25 +- web/components/ConflictResolutionDialog.tsx | 8 +- web/components/FeedbackDialog.tsx | 298 +-- web/components/Notifications.tsx | 74 +- web/components/PropertyEntry.tsx | 192 +- web/components/PropertyList.tsx | 190 +- web/components/UserInfoPopup.tsx | 3 +- web/components/layout/Page.tsx | 119 +- web/components/layout/SidePanel.tsx | 78 - .../locations/LocationSelectionDialog.tsx | 28 +- web/components/patients/LocationChips.tsx | 4 +- web/components/patients/PatientCardView.tsx | 4 +- web/components/patients/PatientDataEditor.tsx | 688 +++++++ web/components/patients/PatientDetailView.tsx | 1724 +---------------- web/components/patients/PatientList.tsx | 26 +- web/components/patients/PatientStateChip.tsx | 22 +- web/components/patients/PatientTasksView.tsx | 184 ++ .../properties/PropertyDetailView.tsx | 671 ++++--- web/components/pwa/InstallPrompt.tsx | 3 +- web/components/tasks/AssigneeSelect.tsx | 17 +- web/components/tasks/AssigneeSelectDialog.tsx | 1 - web/components/tasks/TaskCardView.tsx | 29 +- web/components/tasks/TaskDataEditor.tsx | 495 +++++ web/components/tasks/TaskDetailView.tsx | 802 +------- web/components/tasks/TaskList.tsx | 102 +- web/eslint.config.js | 6 +- web/globals.css | 18 +- web/hooks/useAtomicMutation.ts | 243 --- web/hooks/useAuth.tsx | 8 +- web/hooks/useFormFieldMutation.ts | 46 - web/hooks/usePaginatedQuery.ts | 3 +- web/hooks/useSafeMutation.ts | 40 +- web/i18n/translations.ts | 13 + web/locales/de-DE.arb | 10 + web/locales/en-US.arb | 10 + web/package-lock.json | 332 ++-- web/package.json | 27 +- web/pages/_app.tsx | 67 +- web/pages/index.tsx | 41 +- web/pages/location/[id].tsx | 24 +- web/pages/properties/index.tsx | 19 +- web/pages/settings/index.tsx | 27 +- web/pages/teams/[id].tsx | 14 +- web/pages/wards/[id].tsx | 20 +- web/postcss.config.mjs | 1 + web/style/colors.css | 6 +- 50 files changed, 3839 insertions(+), 3985 deletions(-) create mode 100644 web/api/optimistic-updates/GetPatient.ts create mode 100644 web/api/optimistic-updates/GetTask.ts delete mode 100644 web/components/layout/SidePanel.tsx create mode 100644 web/components/patients/PatientDataEditor.tsx create mode 100644 web/components/patients/PatientTasksView.tsx create mode 100644 web/components/tasks/TaskDataEditor.tsx delete mode 100644 web/hooks/useAtomicMutation.ts delete mode 100644 web/hooks/useFormFieldMutation.ts diff --git a/web/api/optimistic-updates/GetPatient.ts b/web/api/optimistic-updates/GetPatient.ts new file mode 100644 index 00000000..b4549d11 --- /dev/null +++ b/web/api/optimistic-updates/GetPatient.ts @@ -0,0 +1,784 @@ +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 new file mode 100644 index 00000000..21739b71 --- /dev/null +++ b/web/api/optimistic-updates/GetTask.ts @@ -0,0 +1,254 @@ +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/components/AuditLogTimeline.tsx b/web/components/AuditLogTimeline.tsx index c4339ff1..634a545a 100644 --- a/web/components/AuditLogTimeline.tsx +++ b/web/components/AuditLogTimeline.tsx @@ -189,7 +189,6 @@ export const AuditLogTimeline: React.FC = ({ caseId, clas <> = ({ className, ...avatarProps }) => { - const size = avatarProps.size || 'md' - const dotSizeClasses = { - sm: 'w-3 h-3', - md: 'w-3.5 h-3.5', - lg: 'w-4 h-4', - xl: 'w-5 h-5', + const size = avatarProps.size || 'sm' + const dotSizeClasses: Record, string> = { + xs: 'w-3 h-3', + sm: 'w-3.5 h-3.5', + md: 'w-4 h-4', + lg: 'w-5 h-5', } - const dotPositionClasses = { + const dotPositionClasses: Record, string> = { + xs: 'bottom-0 right-0', sm: 'bottom-0 right-0', md: 'bottom-0 right-0', lg: 'bottom-0 right-0', - xl: 'bottom-0 right-0', } - const dotBorderClasses = { - sm: 'border-[1.5px]', + const dotBorderClasses: Record, string> = { + xs: 'border-[1.5px]', + sm: 'border-2', md: 'border-2', lg: 'border-2', - xl: 'border-2', } const showOnline = isOnline === true diff --git a/web/components/AvatarStatusComponent.tsx b/web/components/AvatarStatusComponent.tsx index e94ba135..a39e670a 100644 --- a/web/components/AvatarStatusComponent.tsx +++ b/web/components/AvatarStatusComponent.tsx @@ -1,4 +1,5 @@ import React from 'react' +import type { AvatarSize } from '@helpwave/hightide' import { Avatar, type AvatarProps } from '@helpwave/hightide' import clsx from 'clsx' @@ -11,26 +12,26 @@ export const AvatarStatusComponent: React.FC = ({ className, ...avatarProps }) => { - const size = avatarProps.size || 'md' - const dotSizeClasses = { - sm: 'w-3 h-3', - md: 'w-3.5 h-3.5', - lg: 'w-4 h-4', - xl: 'w-5 h-5', + const size = avatarProps.size || 'sm' + const dotSizeClasses: Record, string> = { + xs: 'w-3 h-3', + sm: 'w-3.5 h-3.5', + md: 'w-4 h-4', + lg: 'w-5 h-5', } - const dotPositionClasses = { + const dotPositionClasses: Record, string> = { + xs: 'bottom-0 right-0', sm: 'bottom-0 right-0', md: 'bottom-0 right-0', lg: 'bottom-0 right-0', - xl: 'bottom-0 right-0', } - const dotBorderClasses = { - sm: 'border-[1.5px]', + const dotBorderClasses: Record, string> = { + xs: 'border-[1.5px]', + sm: 'border-2', md: 'border-2', lg: 'border-2', - xl: 'border-2', } const showOnline = isOnline === true @@ -40,7 +41,7 @@ export const AvatarStatusComponent: React.FC = ({
({ - field, - localValue: localData[field], - serverValue: serverData[field], - })).filter(f => JSON.stringify(f.localValue) !== JSON.stringify(f.serverValue)) + field, + localValue: localData[field], + serverValue: serverData[field], + })).filter(f => JSON.stringify(f.localValue) !== JSON.stringify(f.serverValue)) : [] return ( diff --git a/web/components/FeedbackDialog.tsx b/web/components/FeedbackDialog.tsx index b48566d5..ebdb00a4 100644 --- a/web/components/FeedbackDialog.tsx +++ b/web/components/FeedbackDialog.tsx @@ -1,9 +1,8 @@ -import { useState, useEffect, useRef } from 'react' -import { Dialog, Button, Textarea, FormElementWrapper, Checkbox } from '@helpwave/hightide' +import { useState, useEffect, useRef, useMemo } from 'react' +import { Dialog, Button, Textarea, FormField, FormProvider, Checkbox, useCreateForm, useTranslatedValidators } from '@helpwave/hightide' import { useTasksTranslation, useLocale } from '@/i18n/useTasksTranslation' import { useTasksContext } from '@/hooks/useTasksContext' import { Mic, Pause } from 'lucide-react' -import clsx from 'clsx' interface FeedbackDialogProps { isOpen: boolean, @@ -11,6 +10,12 @@ interface FeedbackDialogProps { hideUrl?: boolean, } +type FeedbackFormValues = { + url?: string, + feedback: string, + isAnonymous: boolean, +} + interface SpeechRecognitionEvent { resultIndex: number, results: Array>, @@ -35,14 +40,76 @@ export const FeedbackDialog = ({ isOpen, onClose, hideUrl = false }: FeedbackDia const translation = useTasksTranslation() const { locale } = useLocale() const { user } = useTasksContext() - const [feedback, setFeedback] = useState('') const [isRecording, setIsRecording] = useState(false) const [isSupported, setIsSupported] = useState(false) - const [isAnonymous, setIsAnonymous] = useState(false) const recognitionRef = useRef(null) const isRecordingRef = useRef(false) const finalTranscriptRef = useRef('') const lastFinalLengthRef = useRef(0) + const validators = useTranslatedValidators() + + + + const form = useCreateForm({ + initialValues: { + url: typeof window !== 'undefined' ? window.location.href : '', + feedback: '', + isAnonymous: false, + }, + validators:{ + feedback: validators.notEmpty + }, + onFormSubmit: async (values) => { + if (!values.feedback.trim()) return + + const feedbackData: { + url?: string, + feedback: string, + timestamp: string, + username: string, + userId?: string, + } = { + feedback: values.feedback.trim(), + timestamp: new Date().toISOString(), + username: values.isAnonymous ? 'Anonymous' : (user?.name || 'Unknown User'), + userId: values.isAnonymous ? undefined : user?.id, + } + + if (!hideUrl) { + feedbackData.url = values.url || (typeof window !== 'undefined' ? window.location.href : '') + } + + try { + const response = await fetch('/api/feedback', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(feedbackData), + }) + + if (response.ok) { + form.update(prev => ({ + ...prev, + feedback: '', + isAnonymous: false, + })) + onClose() + } + } catch { + void 0 + } + }, + }) + + const { update: updateForm, getValue: getFormValue } = form + + const submissionName = useMemo(() => { + if(getFormValue('isAnonymous')) { + return translation('anonymous') + } + return user?.name || 'Unknown User' + }, [getFormValue, translation, user?.name]) useEffect(() => { if (typeof window !== 'undefined') { @@ -91,7 +158,7 @@ export const FeedbackDialog = ({ isOpen, onClose, hideUrl = false }: FeedbackDia ? finalTranscriptRef.current.trim() + '\n\n' + interimTranscript : (finalTranscriptRef.current + interimTranscript).trim() - setFeedback(displayText) + updateForm(prev => ({ ...prev, feedback: displayText })) } recognition.onerror = (event: SpeechRecognitionErrorEvent) => { @@ -117,13 +184,13 @@ export const FeedbackDialog = ({ isOpen, onClose, hideUrl = false }: FeedbackDia setIsRecording(false) } lastFinalLengthRef.current = finalTranscriptRef.current.length - setFeedback(finalTranscriptRef.current.trim()) + updateForm(prev => ({ ...prev, feedback: finalTranscriptRef.current.trim() })) } recognitionRef.current = recognition } } - }, [locale]) + }, [locale, updateForm]) const handleToggleRecording = () => { if (!recognitionRef.current) return @@ -143,8 +210,10 @@ export const FeedbackDialog = ({ isOpen, onClose, hideUrl = false }: FeedbackDia useEffect(() => { if (!isOpen) { - setFeedback('') - setIsAnonymous(false) + updateForm(prev => ({ + ...prev, + feedback: '', + })) finalTranscriptRef.current = '' lastFinalLengthRef.current = 0 if (recognitionRef.current && isRecording) { @@ -153,123 +222,112 @@ export const FeedbackDialog = ({ isOpen, onClose, hideUrl = false }: FeedbackDia setIsRecording(false) } } - }, [isOpen, isRecording]) - - const handleSubmit = async () => { - if (!feedback.trim()) return - - const feedbackData: { - url?: string, - feedback: string, - timestamp: string, - username: string, - userId?: string, - } = { - feedback: feedback.trim(), - timestamp: new Date().toISOString(), - username: isAnonymous ? 'Anonymous' : (user?.name || 'Unknown User'), - userId: user?.id, - } + }, [isOpen, isRecording, updateForm]) - if (!hideUrl) { - feedbackData.url = typeof window !== 'undefined' ? window.location.href : '' + useEffect(() => { + if (isOpen && user) { + const isAnonymous = getFormValue('isAnonymous') + updateForm(prev => ({ + ...prev, + username: isAnonymous ? 'Anonymous' : (user.name || 'Unknown User'), + userId: isAnonymous ? undefined : user.id, + })) } - - try { - const response = await fetch('/api/feedback', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(feedbackData), - }) - - if (response.ok) { - setFeedback('') - setIsAnonymous(false) - onClose() - } - } catch { - void 0 - } - } + }, [isOpen, user, updateForm, getFormValue]) return ( - -
- {!hideUrl && ( - - {() => ( -
- {typeof window !== 'undefined' ? window.location.href : ''} -
+ + +
{ event.preventDefault(); form.submit() }}> +
+ {!hideUrl && ( + + name="url" + label={translation('url')} + > + {({ dataProps }) => ( +
+ {dataProps.value || (typeof window !== 'undefined' ? window.location.href : '')} +
+ )} + )} - - )} - - - {() => ( -
- - - {translation('submitAnonymously') ?? 'Submit anonymously'} - -
- )} -
- - - {() => ( -
-