From b358389a8d43ea15474d261f60e0bddb96bedd81 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Tue, 6 Jan 2026 23:23:38 +0100 Subject: [PATCH 1/3] add last online status --- web/components/UserInfoPopup.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/components/UserInfoPopup.tsx b/web/components/UserInfoPopup.tsx index 82c83747..68207063 100644 --- a/web/components/UserInfoPopup.tsx +++ b/web/components/UserInfoPopup.tsx @@ -5,6 +5,7 @@ import { fetcher } from '@/api/gql/fetcher' import clsx from 'clsx' import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { SmartDate } from '@/utils/date' const GET_USER_QUERY = ` query GetUser($id: ID!) { @@ -112,6 +113,11 @@ export const UserInfoPopup: React.FC = ({ userId, isOpen, on {user.isOnline ? 'Online' : 'Offline'} + {user.lastOnline && ( +
+ +
+ )} ) : ( From 570831a6ebdfe9d20152f76c5c06ac3b3947bdbe Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Wed, 7 Jan 2026 00:02:25 +0100 Subject: [PATCH 2/3] switch to asignee select popup --- web/components/tasks/AssigneeSelect.tsx | 207 ++++++++++++------------ web/components/tasks/TaskDetailView.tsx | 124 +++++++++++--- web/components/tasks/TaskList.tsx | 102 ++++++++---- web/i18n/translations.ts | 27 ++++ web/locales/de-DE.arb | 13 ++ web/locales/en-US.arb | 13 ++ 6 files changed, 332 insertions(+), 154 deletions(-) diff --git a/web/components/tasks/AssigneeSelect.tsx b/web/components/tasks/AssigneeSelect.tsx index 9effbce3..a1aebba9 100644 --- a/web/components/tasks/AssigneeSelect.tsx +++ b/web/components/tasks/AssigneeSelect.tsx @@ -1,11 +1,10 @@ import { useState, useMemo, useRef, useEffect } from 'react' -import { SearchBar } from '@helpwave/hightide' +import { SearchBar, Dialog } from '@helpwave/hightide' import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { Users, ChevronDown } from 'lucide-react' import { useGetUsersQuery, useGetLocationsQuery } from '@/api/gql/generated' import clsx from 'clsx' -import { createPortal } from 'react-dom' interface AssigneeSelectProps { value: string, @@ -15,6 +14,9 @@ interface AssigneeSelectProps { excludeUserIds?: string[], id?: string, className?: string, + onClose?: () => void, + forceOpen?: boolean, + dialogTitle?: string, [key: string]: unknown, } @@ -26,14 +28,20 @@ export const AssigneeSelect = ({ excludeUserIds = [], id, className, + onClose, + forceOpen = false, + dialogTitle, }: AssigneeSelectProps) => { const translation = useTasksTranslation() const [searchQuery, setSearchQuery] = useState('') const [isOpen, setIsOpen] = useState(false) - const triggerRef = useRef(null) - const dropdownRef = useRef(null) const searchInputRef = useRef(null) - const searchInputId = useMemo(() => id ? `${id}-search` : `assignee-select-search-${Math.random().toString(36).substr(2, 9)}`, [id]) + + useEffect(() => { + if (forceOpen) { + setIsOpen(true) + } + }, [forceOpen]) const { data: usersData } = useGetUsersQuery(undefined, { }) @@ -71,56 +79,21 @@ export const AssigneeSelect = ({ return teams.filter(team => team.title.toLowerCase().includes(lowerQuery)) }, [teams, searchQuery]) - const getDisplayValue = () => { - if (!value || value === '') { - return 'Choose user or team' - } + const getSelectedUser = () => { + if (!value || value === '') return null if (value.startsWith('team:')) { const teamId = value.replace('team:', '') const team = teams.find(t => t.id === teamId) - return team?.title || value + return team ? { type: 'team' as const, name: team.title, id: team.id } : null } const user = users.find(u => u.id === value) - return user?.name || value + return user ? { type: 'user' as const, name: user.name, id: user.id, user } : null } - const getDisplayAvatar = () => { - if (!value) return null - if (value.startsWith('team:')) { - return - } - const user = users.find(u => u.id === value) - if (!user) return null - return ( - - ) - } + const selectedItem = getSelectedUser() useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node - if ( - dropdownRef.current && - !dropdownRef.current.contains(target) && - triggerRef.current && - !triggerRef.current.contains(target) && - searchInputRef.current && - !searchInputRef.current.contains(target) - ) { - setIsOpen(false) - } - } - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside) let attempts = 0 const maxAttempts = 10 const focusInput = () => { @@ -136,9 +109,6 @@ export const AssigneeSelect = ({ requestAnimationFrame(() => { requestAnimationFrame(focusInput) }) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } } }, [isOpen]) @@ -146,67 +116,105 @@ export const AssigneeSelect = ({ onValueChanged(selectedValue) setIsOpen(false) setSearchQuery('') + if (onClose) { + onClose() + } } - const triggerRect = triggerRef.current?.getBoundingClientRect() + const handleInputClick = () => { + setIsOpen(true) + if (selectedItem) { + setSearchQuery(selectedItem.name) + } + } + + const handleClose = () => { + setIsOpen(false) + setSearchQuery('') + if (onClose) { + onClose() + } + } + + const showSearchBar = !selectedItem return ( <> - - {isOpen && triggerRect && createPortal( -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - className="fixed z-[10000] mt-1 bg-white dark:bg-gray-800 border border-divider rounded-md shadow-lg min-w-[200px] max-w-[400px] max-h-[400px] flex flex-col" - style={{ - top: triggerRect.bottom + 4, - left: triggerRect.left, - width: triggerRect.width, - }} - > -
-
- setSearchQuery(e.target.value)} - onSearch={() => null} - className="w-full" - /> +
+ {showSearchBar ? ( + { + setSearchQuery(e.target.value) + }} + onSearch={() => null} + onClick={handleInputClick} + onFocus={handleInputClick} + className="w-full" + /> + ) : ( +
+
+ {selectedItem.type === 'team' ? ( + + ) : ( + + )} + {selectedItem.name} +
+
+ )} +
+
+ +
+
+ setSearchQuery(e.target.value)} + onSearch={() => null} + className="w-full" + />
-
+
{filteredUsers.length > 0 && ( <> -
{translation('users') ?? 'Users'}
+
{translation('users') ?? 'Users'}
{filteredUsers.map(u => (
, - document.body - )} +
+
) } + diff --git a/web/components/tasks/TaskDetailView.tsx b/web/components/tasks/TaskDetailView.tsx index 98566e8a..f2891f69 100644 --- a/web/components/tasks/TaskDetailView.tsx +++ b/web/components/tasks/TaskDetailView.tsx @@ -18,6 +18,7 @@ import { useUnassignTaskMutation, useUnassignTaskFromTeamMutation, UpdateTaskDocument, + type GetTaskQuery, type PropertyValueInput, type UpdateTaskMutation, type UpdateTaskMutationVariables @@ -26,7 +27,6 @@ import { useQueryClient } from '@tanstack/react-query' import { Button, Checkbox, - ConfirmDialog, DateTimeInput, FormElementWrapper, Input, @@ -61,7 +61,6 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: const queryClient = useQueryClient() const { selectedRootLocationIds } = useTasksContext() const [isShowingPatientDialog, setIsShowingPatientDialog] = useState(false) - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [errorDialog, setErrorDialog] = useState<{ isOpen: boolean, message?: string }>({ isOpen: false }) const isEditMode = !!taskId @@ -86,8 +85,8 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: } ) - useGetUsersQuery(undefined, {}) - useGetLocationsQuery(undefined, {}) + const { data: usersData } = useGetUsersQuery(undefined, {}) + const { data: locationsData } = useGetLocationsQuery(undefined, {}) const { data: propertyDefinitionsData } = useGetPropertyDefinitionsQuery() @@ -129,6 +128,35 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: }) const { mutate: assignTask } = useAssignTaskMutation({ + onMutate: async (variables) => { + if (!taskId) return + await queryClient.cancelQueries({ queryKey: ['GetTask', { id: taskId }] }) + const previousData = queryClient.getQueryData(['GetTask', { id: taskId }]) + const user = usersData?.users?.find(u => u.id === variables.userId) + if (previousData?.task && user) { + queryClient.setQueryData(['GetTask', { id: taskId }], { + ...previousData, + task: { + ...previousData.task, + assignee: { + __typename: 'UserType' as const, + id: user.id, + name: user.name, + avatarUrl: user.avatarUrl, + lastOnline: user.lastOnline, + isOnline: user.isOnline ?? false, + }, + assigneeTeam: null, + }, + }) + } + return { previousData } + }, + onError: (_error, _variables, context) => { + if (context?.previousData && taskId) { + queryClient.setQueryData(['GetTask', { id: taskId }], context.previousData) + } + }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() @@ -136,6 +164,27 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: }) const { mutate: unassignTask } = useUnassignTaskMutation({ + onMutate: async () => { + if (!taskId) return + await queryClient.cancelQueries({ queryKey: ['GetTask', { id: taskId }] }) + const previousData = queryClient.getQueryData(['GetTask', { id: taskId }]) + if (previousData?.task) { + queryClient.setQueryData(['GetTask', { id: taskId }], { + ...previousData, + task: { + ...previousData.task, + assignee: null, + assigneeTeam: null, + }, + }) + } + return { previousData } + }, + onError: (_error, _variables, context) => { + if (context?.previousData && taskId) { + queryClient.setQueryData(['GetTask', { id: taskId }], context.previousData) + } + }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() @@ -143,6 +192,33 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: }) const { mutate: assignTaskToTeam } = useAssignTaskToTeamMutation({ + onMutate: async (variables) => { + if (!taskId) return + await queryClient.cancelQueries({ queryKey: ['GetTask', { id: taskId }] }) + const previousData = queryClient.getQueryData(['GetTask', { id: taskId }]) + const team = locationsData?.locationNodes?.find(loc => loc.id === variables.teamId && loc.kind === 'TEAM') + if (previousData?.task && team) { + queryClient.setQueryData(['GetTask', { id: taskId }], { + ...previousData, + task: { + ...previousData.task, + assignee: null, + assigneeTeam: { + __typename: 'LocationNodeType' as const, + id: team.id, + title: team.title, + kind: team.kind, + }, + }, + }) + } + return { previousData } + }, + onError: (_error, _variables, context) => { + if (context?.previousData && taskId) { + queryClient.setQueryData(['GetTask', { id: taskId }], context.previousData) + } + }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() @@ -150,6 +226,27 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: }) const { mutate: unassignTaskFromTeam } = useUnassignTaskFromTeamMutation({ + onMutate: async () => { + if (!taskId) return + await queryClient.cancelQueries({ queryKey: ['GetTask', { id: taskId }] }) + const previousData = queryClient.getQueryData(['GetTask', { id: taskId }]) + if (previousData?.task) { + queryClient.setQueryData(['GetTask', { id: taskId }], { + ...previousData, + task: { + ...previousData.task, + assignee: null, + assigneeTeam: null, + }, + }) + } + return { previousData } + }, + onError: (_error, _variables, context) => { + if (context?.previousData && taskId) { + queryClient.setQueryData(['GetTask', { id: taskId }], context.previousData) + } + }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) onSuccess() @@ -576,7 +673,11 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: {isEditMode && taskId && (
setIsDeleteDialogOpen(true)} + onClick={() => { + if (taskId) { + deleteTask({ id: taskId }) + } + }} isLoading={isDeleting} color="negative" coloringStyle="outline" @@ -681,19 +782,6 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }:
)} - setIsDeleteDialogOpen(false)} - onConfirm={() => { - if (taskId) { - deleteTask({ id: taskId }) - } - setIsDeleteDialogOpen(false) - }} - titleElement={translation('delete')} - description={translation('deleteTaskConfirmation')} - confirmType="negative" - /> setErrorDialog({ isOpen: false })} diff --git a/web/components/tasks/TaskList.tsx b/web/components/tasks/TaskList.tsx index 484bae42..639f9854 100644 --- a/web/components/tasks/TaskList.tsx +++ b/web/components/tasks/TaskList.tsx @@ -1,8 +1,8 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Button, Checkbox, ConfirmDialog, Dialog, FillerRowElement, SearchBar, Table, Tooltip } from '@helpwave/hightide' +import { Button, Checkbox, ConfirmDialog, FillerRowElement, SearchBar, Table, Tooltip } from '@helpwave/hightide' import { PlusIcon, Table as TableIcon, LayoutGrid, UserCheck, Users, Printer } from 'lucide-react' -import { useAssignTaskMutation, useAssignTaskToTeamMutation, useCompleteTaskMutation, useReopenTaskMutation, type GetGlobalDataQuery } from '@/api/gql/generated' +import { useAssignTaskMutation, useAssignTaskToTeamMutation, useCompleteTaskMutation, useReopenTaskMutation, useGetUsersQuery, useGetLocationsQuery, type GetGlobalDataQuery } from '@/api/gql/generated' import { AssigneeSelect } from './AssigneeSelect' import clsx from 'clsx' import { SmartDate } from '@/utils/date' @@ -341,10 +341,34 @@ export const TaskList = forwardRef(({ tasks: initial const canHandover = openTasks.length > 0 + const { data: usersData } = useGetUsersQuery(undefined, {}) + const { data: locationsData } = useGetLocationsQuery(undefined, {}) + + const teams = useMemo(() => { + if (!locationsData?.locationNodes) return [] + return locationsData.locationNodes.filter(loc => loc.kind === 'TEAM') + }, [locationsData]) + + const users = useMemo(() => { + return usersData?.users || [] + }, [usersData]) + + const getSelectedUserOrTeam = useMemo(() => { + if (!selectedUserId) return null + if (selectedUserId.startsWith('team:')) { + const teamId = selectedUserId.replace('team:', '') + const team = teams.find(t => t.id === teamId) + return team ? { type: 'team' as const, name: team.title, id: team.id } : null + } + const user = users.find(u => u.id === selectedUserId) + return user ? { type: 'user' as const, name: user.name, id: user.id, user } : null + }, [selectedUserId, teams, users]) + const handleHandoverClick = () => { if (!canHandover) { return } + setSelectedUserId(null) setIsHandoverDialogOpen(true) } @@ -355,7 +379,17 @@ export const TaskList = forwardRef(({ tasks: initial } const handleConfirmHandover = () => { - if (!selectedUserId) return + if (!selectedUserId) { + console.warn('No selectedUserId for handover') + return + } + + if (openTasks.length === 0) { + console.warn('No open tasks to handover') + setIsConfirmDialogOpen(false) + setSelectedUserId(null) + return + } const isTeam = selectedUserId.startsWith('team:') const assigneeId = isTeam ? selectedUserId.replace('team:', '') : selectedUserId @@ -718,42 +752,40 @@ export const TaskList = forwardRef(({ tasks: initial /> )} - { setIsHandoverDialogOpen(false) - setSelectedUserId(null) + if (!isConfirmDialogOpen) { + setSelectedUserId(null) + } }} - titleElement={translation('shiftHandover') || 'Shift Handover'} - description={translation('shiftHandoverDescription') || `Select a user to transfer all ${openTasks.length} open task${openTasks.length !== 1 ? 's' : ''} assigned to you.`} - > -
- -
-
- { - setIsConfirmDialogOpen(false) - setSelectedUserId(null) - }} - onConfirm={handleConfirmHandover} - titleElement={translation('confirmShiftHandover') || 'Confirm Shift Handover'} - description={(() => { - if (!selectedUserId) return '' - const isTeam = selectedUserId.startsWith('team:') - const taskCount = openTasks.length - const taskText = taskCount !== 1 ? 'tasks' : 'task' - const recipientText = isTeam ? 'selected team' : 'selected user' - return translation('confirmShiftHandoverDescription') || `Are you sure you want to transfer ${taskCount} open ${taskText} to the ${recipientText}?` - })()} + forceOpen={isHandoverDialogOpen} + dialogTitle={translation('shiftHandover') || 'Shift Handover'} /> + {isConfirmDialogOpen && selectedUserId && ( + { + setIsConfirmDialogOpen(false) + setSelectedUserId(null) + }} + onConfirm={() => { + if (selectedUserId) { + handleConfirmHandover() + } + }} + titleElement={translation('confirmShiftHandover') || 'Confirm Shift Handover'} + description={getSelectedUserOrTeam && openTasks.length > 0 ? translation('confirmShiftHandoverDescriptionWithName', { + taskCount: openTasks.length, + name: getSelectedUserOrTeam.name + }) : (translation('confirmShiftHandoverDescription') || 'Are you sure you want to transfer all open tasks?')} + /> + )} string, 'conflictDetected': string, 'create': string, 'createdAt': string, @@ -113,6 +114,7 @@ export type TasksTranslationEntries = { 'noNotifications': string, 'noOpenTasks': string, 'noPatient': string, + 'noResultsFound': string, 'nOrganization': (values: { count: number }) => string, 'notAssigned': string, 'notes': string, @@ -172,6 +174,7 @@ export type TasksTranslationEntries = { 'rooms': string, 'save': string, 'searchLocations': string, + 'searchUsersOrTeams': string, 'security': string, 'selectAll': string, 'selectAssignee': string, @@ -263,6 +266,16 @@ export const tasksTranslation: Translation { + let _out: string = '' + _out += `Sind Sie sicher, dass Sie ` + _out += TranslationGen.resolvePlural(taskCount, { + '=1': `${taskCount} offene Aufgabe`, + 'other': `${taskCount} offene Aufgaben`, + }) + _out += ` an ${name} übertragen möchten?` + return _out + }, 'conflictDetected': `Konflikt erkannt`, 'create': `Erstellen`, 'createdAt': `Erstellt am`, @@ -372,6 +385,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolvePlural(count, { '=1': `${count} Organisation`, @@ -493,6 +507,7 @@ export const tasksTranslation: Translation { + let _out: string = '' + _out += `Are you sure you want to transfer ` + _out += TranslationGen.resolvePlural(taskCount, { + '=1': `${taskCount} open task`, + 'other': `${taskCount} open tasks`, + }) + _out += ` to ${name}?` + return _out + }, 'conflictDetected': `Conflict Detected`, 'create': `Create`, 'createdAt': `Created at`, @@ -722,6 +747,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolvePlural(count, { '=1': `${count} Organization`, @@ -842,6 +868,7 @@ export const tasksTranslation: Translation Date: Wed, 7 Jan 2026 00:17:59 +0100 Subject: [PATCH 3/3] add user profile picture upload --- .gitignore | 4 + backend/api/inputs.py | 5 + backend/api/resolvers/__init__.py | 3 +- backend/api/resolvers/user.py | 30 ++++++ docker-compose.dev.yml | 1 + docker-compose.yml | 2 + web/Dockerfile | 5 +- web/api/gql/generated.ts | 47 +++++++++ web/api/graphql/UserMutations.graphql | 15 +++ web/package-lock.json | 69 ++++++++++++-- web/package.json | 3 + web/pages/settings/index.tsx | 131 ++++++++++++++++++++++++-- 12 files changed, 295 insertions(+), 20 deletions(-) create mode 100644 web/api/graphql/UserMutations.graphql diff --git a/.gitignore b/.gitignore index c19d1581..1800467e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ htmlcov/ # feedback **/feedback/ feedback/ + +# profile pictures +**/profile/ +profile/ diff --git a/backend/api/inputs.py b/backend/api/inputs.py index de90e510..d5f326a3 100644 --- a/backend/api/inputs.py +++ b/backend/api/inputs.py @@ -165,3 +165,8 @@ class UpdatePropertyDefinitionInput: options: list[str] | None = None is_active: bool | None = None allowed_entities: list[PropertyEntity] | None = None + + +@strawberry.input +class UpdateProfilePictureInput: + avatar_url: str diff --git a/backend/api/resolvers/__init__.py b/backend/api/resolvers/__init__.py index d944ca2c..b053198d 100644 --- a/backend/api/resolvers/__init__.py +++ b/backend/api/resolvers/__init__.py @@ -5,7 +5,7 @@ from .patient import PatientMutation, PatientQuery, PatientSubscription from .property import PropertyDefinitionMutation, PropertyDefinitionQuery from .task import TaskMutation, TaskQuery, TaskSubscription -from .user import UserQuery +from .user import UserMutation, UserQuery @strawberry.type @@ -26,6 +26,7 @@ class Mutation( TaskMutation, PropertyDefinitionMutation, LocationMutation, + UserMutation, ): pass diff --git a/backend/api/resolvers/user.py b/backend/api/resolvers/user.py index 01e75c6b..e9879676 100644 --- a/backend/api/resolvers/user.py +++ b/backend/api/resolvers/user.py @@ -1,7 +1,10 @@ import strawberry from api.context import Info +from api.inputs import UpdateProfilePictureInput +from api.resolvers.base import BaseMutationResolver from api.types.user import UserType from database import models +from graphql import GraphQLError from sqlalchemy import select @@ -22,3 +25,30 @@ async def users(self, info: Info) -> list[UserType]: @strawberry.field def me(self, info: Info) -> UserType | None: return info.context.user + + +@strawberry.type +class UserMutation(BaseMutationResolver[models.User]): + @strawberry.mutation + async def update_profile_picture( + self, + info: Info, + data: UpdateProfilePictureInput, + ) -> UserType: + if not info.context.user: + raise GraphQLError( + "Authentication required. Please log in to update your profile picture.", + extensions={"code": "UNAUTHENTICATED"}, + ) + + user = info.context.user + user.avatar_url = data.avatar_url + + await BaseMutationResolver.update_and_notify( + info, + user, + models.User, + "user", + ) + + return user diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index dbec163d..0d9b11ac 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -102,6 +102,7 @@ services: RUNTIME_CLIENT_ID: "tasks-web" volumes: - "./feedback:/feedback" + - "./profile:/profile" depends_on: - backend diff --git a/docker-compose.yml b/docker-compose.yml index c2c01597..7442fccf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,6 +94,7 @@ services: RUNTIME_CLIENT_ID: "tasks-web" volumes: - "./feedback:/feedback" + - "profile-data:/profile" depends_on: - backend @@ -128,3 +129,4 @@ volumes: keycloak-data: postgres-data: influxdb-data: + profile-data: diff --git a/web/Dockerfile b/web/Dockerfile index 7f05d2b1..145023f6 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -23,9 +23,12 @@ RUN apk add --no-cache libcap=2.77-r0 && \ addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 tasks && \ mkdir -p /feedback && \ - chown tasks:nodejs /feedback + mkdir -p /profile && \ + chown tasks:nodejs /feedback && \ + chown tasks:nodejs /profile ENV FEEDBACK_DIR=/feedback +ENV PROFILE_PICTURE_DIRECTORY=/profile COPY --from=builder --chown=tasks:nodejs /app/public ./public COPY --from=builder --chown=tasks:nodejs /app/build/standalone ./ diff --git a/web/api/gql/generated.ts b/web/api/gql/generated.ts index 760c78ae..ce8c5933 100644 --- a/web/api/gql/generated.ts +++ b/web/api/gql/generated.ts @@ -125,6 +125,7 @@ export type Mutation = { unassignTaskFromTeam: TaskType; updateLocationNode: LocationNodeType; updatePatient: PatientType; + updateProfilePicture: UserType; updatePropertyDefinition: PropertyDefinitionType; updateTask: TaskType; waitPatient: PatientType; @@ -230,6 +231,11 @@ export type MutationUpdatePatientArgs = { }; +export type MutationUpdateProfilePictureArgs = { + data: UpdateProfilePictureInput; +}; + + export type MutationUpdatePropertyDefinitionArgs = { data: UpdatePropertyDefinitionInput; id: Scalars['ID']['input']; @@ -518,6 +524,10 @@ export type UpdatePatientInput = { teamIds?: InputMaybe>; }; +export type UpdateProfilePictureInput = { + avatarUrl: Scalars['String']['input']; +}; + export type UpdatePropertyDefinitionInput = { allowedEntities?: InputMaybe>; description?: InputMaybe; @@ -864,6 +874,13 @@ export type UnassignTaskFromTeamMutationVariables = Exact<{ export type UnassignTaskFromTeamMutation = { __typename?: 'Mutation', unassignTaskFromTeam: { __typename?: 'TaskType', id: string, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null } }; +export type UpdateProfilePictureMutationVariables = Exact<{ + data: UpdateProfilePictureInput; +}>; + + +export type UpdateProfilePictureMutation = { __typename?: 'Mutation', updateProfilePicture: { __typename?: 'UserType', id: string, username: string, name: string, email?: string | null, firstname?: string | null, lastname?: string | null, title?: string | null, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } }; + export const GetAuditLogsDocument = ` @@ -2340,3 +2357,33 @@ export const useUnassignTaskFromTeamMutation = < ...options } )}; + +export const UpdateProfilePictureDocument = ` + mutation UpdateProfilePicture($data: UpdateProfilePictureInput!) { + updateProfilePicture(data: $data) { + id + username + name + email + firstname + lastname + title + avatarUrl + lastOnline + isOnline + } +} + `; + +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/graphql/UserMutations.graphql b/web/api/graphql/UserMutations.graphql new file mode 100644 index 00000000..c6c5acba --- /dev/null +++ b/web/api/graphql/UserMutations.graphql @@ -0,0 +1,15 @@ +mutation UpdateProfilePicture($data: UpdateProfilePictureInput!) { + updateProfilePicture(data: $data) { + id + username + name + email + firstname + lastname + title + avatarUrl + lastOnline + isOnline + } +} + diff --git a/web/package-lock.json b/web/package-lock.json index c01a7a2c..8b3352b2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,8 +18,10 @@ "@tanstack/react-query": "4.36.1", "@tanstack/react-query-devtools": "4.36.1", "@tanstack/react-table": "8.21.3", + "@types/formidable": "^3.4.6", "clsx": "2.1.1", "dotenv": "17.2.3", + "formidable": "^3.5.4", "lucide-react": "0.468.0", "next": "16.0.10", "next-runtime-env": "3.3.0", @@ -27,6 +29,7 @@ "postcss": "8.5.3", "react": "18.3.1", "react-dom": "18.3.1", + "sharp": "^0.34.5", "tailwindcss": "4.1.17", "typescript": "5.7.2", "zod": "3.25.73" @@ -2560,7 +2563,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -3576,6 +3578,18 @@ "node": ">= 10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3614,6 +3628,15 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -4838,6 +4861,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/formidable": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.6.tgz", + "integrity": "sha512-LI4Hk+KNsM5q7br4oMVoaWeb+gUqJpz1N8+Y2Q6Cz9cVH33ybahRKUWaRmMboVlkwSbOUGgwc/pEkS7yMSzoWg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4849,7 +4881,6 @@ "version": "20.17.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.10.tgz", "integrity": "sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -5486,7 +5517,6 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, "license": "MIT" }, "node_modules/async-function": { @@ -6321,6 +6351,16 @@ "node": ">=0.10" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -7138,6 +7178,23 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9928,7 +9985,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -10736,7 +10792,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -10780,7 +10835,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -10790,7 +10844,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -11567,7 +11620,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/unixify": { @@ -11887,7 +11939,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/web/package.json b/web/package.json index b2b1eee4..caab296e 100644 --- a/web/package.json +++ b/web/package.json @@ -22,8 +22,10 @@ "@tanstack/react-query": "4.36.1", "@tanstack/react-query-devtools": "4.36.1", "@tanstack/react-table": "8.21.3", + "@types/formidable": "^3.4.6", "clsx": "2.1.1", "dotenv": "17.2.3", + "formidable": "^3.5.4", "lucide-react": "0.468.0", "next": "16.0.10", "next-runtime-env": "3.3.0", @@ -31,6 +33,7 @@ "postcss": "8.5.3", "react": "18.3.1", "react-dom": "18.3.1", + "sharp": "^0.34.5", "tailwindcss": "4.1.17", "typescript": "5.7.2", "zod": "3.25.73" diff --git a/web/pages/settings/index.tsx b/web/pages/settings/index.tsx index f8a55532..ba709303 100644 --- a/web/pages/settings/index.tsx +++ b/web/pages/settings/index.tsx @@ -1,5 +1,5 @@ import type { NextPage } from 'next' -import { useState } from 'react' +import { useState, useRef } from 'react' import { Page } from '@/components/layout/Page' import titleWrapper from '@/utils/titleWrapper' import { useTasksTranslation } from '@/i18n/useTasksTranslation' @@ -16,7 +16,7 @@ import { import type { HightideTranslationLocales, ThemeType } from '@helpwave/hightide' import { useTasksContext } from '@/hooks/useTasksContext' import { useAuth } from '@/hooks/useAuth' -import { LogOut, MonitorCog, MoonIcon, SunIcon, Trash2, ClipboardList, Shield, TableProperties, Building2, MessageSquareText } from 'lucide-react' +import { LogOut, MonitorCog, MoonIcon, SunIcon, Trash2, ClipboardList, Shield, TableProperties, Building2, MessageSquareText, Upload, X } from 'lucide-react' import { useRouter } from 'next/router' import clsx from 'clsx' import { removeUser } from '@/api/auth/authService' @@ -58,6 +58,10 @@ const SettingsPage: NextPage = () => { const config = getConfig() const router = useRouter() const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const fileInputRef = useRef(null) const { setValue: setOnboardingSurveyCompleted @@ -95,6 +99,73 @@ const SettingsPage: NextPage = () => { } } + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + if (!file.type.startsWith('image/')) { + alert('Please select an image file') + return + } + setSelectedFile(file) + const url = URL.createObjectURL(file) + setPreviewUrl(url) + } + } + + const handleRemoveFile = () => { + setSelectedFile(null) + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + setPreviewUrl(null) + } + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const handleUpload = async () => { + if (!selectedFile || !user?.id) return + + setIsUploading(true) + try { + const { getUser } = await import('@/api/auth/authService') + const authUser = await getUser() + const token = authUser?.access_token + + if (!token) { + throw new Error('Not authenticated') + } + + const formData = new FormData() + formData.append('file', selectedFile) + + const response = await fetch('/api/profile/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Upload failed') + } + + await response.json() + + queryClient.invalidateQueries({ queryKey: ['GetGlobalData'] }) + queryClient.invalidateQueries({ queryKey: ['GetUser'] }) + queryClient.invalidateQueries() + + handleRemoveFile() + } catch (error) { + alert(error instanceof Error ? error.message : 'Failed to upload profile picture') + } finally { + setIsUploading(false) + } + } + return ( { >
- -
+
+ + {previewUrl && ( + + )} +
+
{user?.name} {user?.id} +
+ + + {selectedFile && ( + + )} +