From a6bc2b6318036fba43a0ea1141eb612ce987f2df Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Sun, 28 Dec 2025 18:32:12 +0100 Subject: [PATCH 1/3] Add patient delete team assignment and feedback features --- .gitignore | 4 + backend/api/inputs.py | 2 + backend/api/resolvers/patient.py | 22 +- backend/api/resolvers/task.py | 64 ++++- backend/api/types/task.py | 15 ++ .../versions/add_patient_deleted_field.py | 29 ++ .../versions/add_task_assignee_team.py | 37 +++ backend/database/models/patient.py | 3 +- backend/database/models/task.py | 9 + docker-compose.yml | 3 + web/Dockerfile | 6 +- web/api/gql/generated.ts | 149 ++++++++++- web/api/graphql/GetPatient.graphql | 5 + web/api/graphql/GetPatients.graphql | 5 + web/api/graphql/GetTask.graphql | 6 + web/api/graphql/GetTasks.graphql | 9 +- web/api/graphql/PatientMutations.graphql | 4 + web/api/graphql/TaskMutations.graphql | 22 ++ web/components/FeedbackDialog.tsx | 247 ++++++++++++++++++ web/components/layout/Page.tsx | 89 ++++--- web/components/patients/PatientDetailView.tsx | 43 ++- web/components/tasks/TaskCardView.tsx | 36 ++- web/components/tasks/TaskDetailView.tsx | 201 ++++++++++---- web/components/tasks/TaskList.tsx | 49 +++- web/i18n/translations.ts | 61 ++++- web/locales/de-DE.arb | 21 +- web/locales/en-US.arb | 21 +- web/pages/api/feedback.ts | 77 ++++++ web/pages/location/[id].tsx | 79 +++++- web/pages/settings/index.tsx | 26 +- web/pages/teams/[id].tsx | 92 ++++--- 31 files changed, 1260 insertions(+), 176 deletions(-) create mode 100644 backend/database/migrations/versions/add_patient_deleted_field.py create mode 100644 backend/database/migrations/versions/add_task_assignee_team.py create mode 100644 web/components/FeedbackDialog.tsx create mode 100644 web/pages/api/feedback.ts diff --git a/.gitignore b/.gitignore index fd70cdb7..c19d1581 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ coverage.xml .coverage htmlcov/ + +# feedback +**/feedback/ +feedback/ diff --git a/backend/api/inputs.py b/backend/api/inputs.py index f8f48917..e8cff374 100644 --- a/backend/api/inputs.py +++ b/backend/api/inputs.py @@ -110,6 +110,7 @@ class CreateTaskInput: description: str | None = None due_date: datetime | None = None assignee_id: strawberry.ID | None = None + assignee_team_id: strawberry.ID | None = None previous_task_ids: list[strawberry.ID] | None = None properties: list[PropertyValueInput] | None = None priority: TaskPriority | None = None @@ -123,6 +124,7 @@ class UpdateTaskInput: done: bool | None = None due_date: datetime | None = strawberry.UNSET assignee_id: strawberry.ID | None = None + assignee_team_id: strawberry.ID | None = strawberry.UNSET previous_task_ids: list[strawberry.ID] | None = None properties: list[PropertyValueInput] | None = None checksum: str | None = None diff --git a/backend/api/resolvers/patient.py b/backend/api/resolvers/patient.py index cb3357f0..1ec99f84 100644 --- a/backend/api/resolvers/patient.py +++ b/backend/api/resolvers/patient.py @@ -27,6 +27,7 @@ async def patient( result = await info.context.db.execute( select(models.Patient) .where(models.Patient.id == id) + .where(models.Patient.deleted == False) .options( selectinload(models.Patient.assigned_locations), selectinload(models.Patient.tasks), @@ -55,7 +56,7 @@ async def patients( selectinload(models.Patient.assigned_locations), selectinload(models.Patient.tasks), selectinload(models.Patient.teams), - ) + ).where(models.Patient.deleted == False) if states: state_values = [s.value for s in states] @@ -151,6 +152,7 @@ async def recent_patients( selectinload(models.Patient.tasks), selectinload(models.Patient.teams), ) + .where(models.Patient.deleted == False) .limit(limit) ) auth_service = AuthorizationService(info.context.db) @@ -395,19 +397,29 @@ async def update_patient( @strawberry.mutation @audit_log("delete_patient") async def delete_patient(self, info: Info, id: strawberry.ID) -> bool: - repo = BaseMutationResolver.get_repository(info.context.db, models.Patient) - patient = await repo.get_by_id(id) + db = info.context.db + result = await db.execute( + select(models.Patient) + .where(models.Patient.id == id) + .where(models.Patient.deleted == False) + .options( + selectinload(models.Patient.assigned_locations), + selectinload(models.Patient.teams), + ), + ) + patient = result.scalars().first() if not patient: return False - auth_service = AuthorizationService(info.context.db) + auth_service = AuthorizationService(db) if not await auth_service.can_access_patient(info.context.user, patient, info.context): raise GraphQLError( "Forbidden: You do not have access to this patient", extensions={"code": "FORBIDDEN"}, ) - await BaseMutationResolver.delete_entity( + patient.deleted = True + await BaseMutationResolver.update_and_notify( info, patient, models.Patient, "patient" ) return True diff --git a/backend/api/resolvers/task.py b/backend/api/resolvers/task.py index b32ba533..beddcaaa 100644 --- a/backend/api/resolvers/task.py +++ b/backend/api/resolvers/task.py @@ -41,6 +41,7 @@ async def tasks( info: Info, patient_id: strawberry.ID | None = None, assignee_id: strawberry.ID | None = None, + assignee_team_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, ) -> list[TaskType]: auth_service = AuthorizationService(info.context.db) @@ -58,6 +59,8 @@ async def tasks( if assignee_id: query = query.where(models.Task.assignee_id == assignee_id) + if assignee_team_id: + query = query.where(models.Task.assignee_team_id == assignee_team_id) result = await info.context.db.execute(query) return result.scalars().all() @@ -134,6 +137,8 @@ async def tasks( if assignee_id: query = query.where(models.Task.assignee_id == assignee_id) + if assignee_team_id: + query = query.where(models.Task.assignee_team_id == assignee_team_id) result = await info.context.db.execute(query) return result.scalars().all() @@ -218,11 +223,18 @@ async def create_task(self, info: Info, data: CreateTaskInput) -> TaskType: extensions={"code": "FORBIDDEN"}, ) + if data.assignee_id and data.assignee_team_id: + raise GraphQLError( + "Cannot assign both a user and a team. Please assign either a user or a team.", + extensions={"code": "BAD_REQUEST"}, + ) + new_task = models.Task( title=data.title, description=data.description, patient_id=data.patient_id, assignee_id=data.assignee_id, + assignee_team_id=data.assignee_team_id if not data.assignee_id else None, due_date=normalize_datetime_to_utc(data.due_date), priority=data.priority.value if data.priority else None, estimated_time=data.estimated_time, @@ -292,6 +304,19 @@ async def update_task( if data.estimated_time is not strawberry.UNSET: task.estimated_time = data.estimated_time + if data.assignee_id is not None and data.assignee_team_id is not strawberry.UNSET and data.assignee_team_id is not None: + raise GraphQLError( + "Cannot assign both a user and a team. Please assign either a user or a team.", + extensions={"code": "BAD_REQUEST"}, + ) + + if data.assignee_id is not None: + task.assignee_id = data.assignee_id + task.assignee_team_id = None + elif data.assignee_team_id is not strawberry.UNSET: + task.assignee_team_id = data.assignee_team_id + task.assignee_id = None + if data.properties is not None: property_service = TaskMutation._get_property_service(db) await property_service.process_properties( @@ -348,7 +373,10 @@ async def assign_task( return await TaskMutation._update_task_field( info, id, - lambda task: setattr(task, "assignee_id", user_id), + lambda task: ( + setattr(task, "assignee_id", user_id), + setattr(task, "assignee_team_id", None) + ), ) @strawberry.mutation @@ -357,7 +385,39 @@ async def unassign_task(self, info: Info, id: strawberry.ID) -> TaskType: return await TaskMutation._update_task_field( info, id, - lambda task: setattr(task, "assignee_id", None), + lambda task: ( + setattr(task, "assignee_id", None), + setattr(task, "assignee_team_id", None) + ), + ) + + @strawberry.mutation + @audit_log("assign_task_to_team") + async def assign_task_to_team( + self, + info: Info, + id: strawberry.ID, + team_id: strawberry.ID, + ) -> TaskType: + return await TaskMutation._update_task_field( + info, + id, + lambda task: ( + setattr(task, "assignee_id", None), + setattr(task, "assignee_team_id", team_id) + ), + ) + + @strawberry.mutation + @audit_log("unassign_task_from_team") + async def unassign_task_from_team(self, info: Info, id: strawberry.ID) -> TaskType: + return await TaskMutation._update_task_field( + info, + id, + lambda task: ( + setattr(task, "assignee_id", None), + setattr(task, "assignee_team_id", None) + ), ) @strawberry.mutation diff --git a/backend/api/types/task.py b/backend/api/types/task.py index f7c445d4..0489d1fb 100644 --- a/backend/api/types/task.py +++ b/backend/api/types/task.py @@ -24,6 +24,7 @@ class TaskType: creation_date: datetime update_date: datetime | None assignee_id: strawberry.ID | None + assignee_team_id: strawberry.ID | None patient_id: strawberry.ID priority: str | None estimated_time: int | None @@ -41,6 +42,20 @@ async def assignee( ) return result.scalars().first() + @strawberry.field + async def assignee_team( + self, + info: Info, + ) -> Annotated["LocationNodeType", strawberry.lazy("api.types.location")] | None: + from api.types.location import LocationNodeType + + if not self.assignee_team_id: + return None + result = await info.context.db.execute( + select(models.LocationNode).where(models.LocationNode.id == self.assignee_team_id), + ) + return result.scalars().first() + @strawberry.field async def patient( self, diff --git a/backend/database/migrations/versions/add_patient_deleted_field.py b/backend/database/migrations/versions/add_patient_deleted_field.py new file mode 100644 index 00000000..c6bc5da2 --- /dev/null +++ b/backend/database/migrations/versions/add_patient_deleted_field.py @@ -0,0 +1,29 @@ +"""Add deleted field to patients for soft delete + +Revision ID: add_patient_deleted +Revises: add_task_priority_time +Create Date: 2025-12-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "add_patient_deleted" +down_revision: Union[str, Sequence[str], None] = "add_task_priority_time" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "patients", + sa.Column("deleted", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade() -> None: + op.drop_column("patients", "deleted") + diff --git a/backend/database/migrations/versions/add_task_assignee_team.py b/backend/database/migrations/versions/add_task_assignee_team.py new file mode 100644 index 00000000..82239349 --- /dev/null +++ b/backend/database/migrations/versions/add_task_assignee_team.py @@ -0,0 +1,37 @@ +"""Add assignee_team_id to tasks for team assignment + +Revision ID: add_task_assignee_team +Revises: add_patient_deleted +Create Date: 2025-12-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "add_task_assignee_team" +down_revision: Union[str, Sequence[str], None] = "add_patient_deleted" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column("assignee_team_id", sa.String(), nullable=True), + ) + op.create_foreign_key( + "fk_tasks_assignee_team_id", + "tasks", + "location_nodes", + ["assignee_team_id"], + ["id"], + ) + + +def downgrade() -> None: + op.drop_constraint("fk_tasks_assignee_team_id", "tasks", type_="foreignkey") + op.drop_column("tasks", "assignee_team_id") + diff --git a/backend/database/models/patient.py b/backend/database/models/patient.py index 4f19338c..6501d5f9 100644 --- a/backend/database/models/patient.py +++ b/backend/database/models/patient.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from database.models.base import Base -from sqlalchemy import Column, ForeignKey, String, Table +from sqlalchemy import Boolean, Column, ForeignKey, String, Table from sqlalchemy.orm import Mapped, mapped_column, relationship if TYPE_CHECKING: @@ -41,6 +41,7 @@ class Patient(Base): birthdate: Mapped[date] = mapped_column() sex: Mapped[str] = mapped_column(String) state: Mapped[str] = mapped_column(String, default="WAIT") + deleted: Mapped[bool] = mapped_column(Boolean, default=False) assigned_location_id: Mapped[str | None] = mapped_column( ForeignKey("location_nodes.id"), nullable=True, diff --git a/backend/database/models/task.py b/backend/database/models/task.py index 79d8e642..54961151 100644 --- a/backend/database/models/task.py +++ b/backend/database/models/task.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship if TYPE_CHECKING: + from .location import LocationNode from .patient import Patient from .property import PropertyValue from .user import User @@ -43,6 +44,10 @@ class Task(Base): ForeignKey("users.id"), nullable=True, ) + assignee_team_id: Mapped[str | None] = mapped_column( + ForeignKey("location_nodes.id"), + nullable=True, + ) patient_id: Mapped[str] = mapped_column(ForeignKey("patients.id")) priority: Mapped[str | None] = mapped_column(String, nullable=True) estimated_time: Mapped[int | None] = mapped_column(Integer, nullable=True) @@ -51,6 +56,10 @@ class Task(Base): "User", back_populates="tasks", ) + assignee_team: Mapped["LocationNode | None"] = relationship( + "LocationNode", + foreign_keys=[assignee_team_id], + ) patient: Mapped[Patient] = relationship("Patient", back_populates="tasks") properties: Mapped[list[PropertyValue]] = relationship( "PropertyValue", diff --git a/docker-compose.yml b/docker-compose.yml index ff097eeb..fcd9ca16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,9 @@ services: RUNTIME_REDIRECT_URI: "http://localhost/auth/callback" RUNTIME_POST_LOGOUT_REDIRECT_URI: "http://localhost/" RUNTIME_CLIENT_ID: "tasks-web" + FEEDBACK_DIR: "/feedback" + volumes: + - "./feedback:/feedback" depends_on: - backend diff --git a/web/Dockerfile b/web/Dockerfile index 2868a6c1..f58c1082 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -21,7 +21,11 @@ ENV HOSTNAME="0.0.0.0" RUN apk add --no-cache libcap=2.77-r0 && \ setcap 'cap_net_bind_service=+ep' /usr/local/bin/node && \ addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nextjs + adduser --system --uid 1001 nextjs && \ + mkdir -p /feedback && \ + chown nextjs:nodejs /feedback + +ENV FEEDBACK_DIR=/feedback COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/build/standalone ./ diff --git a/web/api/gql/generated.ts b/web/api/gql/generated.ts index 85059d8a..de8682dc 100644 --- a/web/api/gql/generated.ts +++ b/web/api/gql/generated.ts @@ -49,6 +49,7 @@ export type CreatePropertyDefinitionInput = { export type CreateTaskInput = { assigneeId?: InputMaybe; + assigneeTeamId?: InputMaybe; description?: InputMaybe; dueDate?: InputMaybe; estimatedTime?: InputMaybe; @@ -97,6 +98,7 @@ export type Mutation = { __typename?: 'Mutation'; admitPatient: PatientType; assignTask: TaskType; + assignTaskToTeam: TaskType; completeTask: TaskType; createLocationNode: LocationNodeType; createPatient: PatientType; @@ -110,6 +112,7 @@ export type Mutation = { markPatientDead: PatientType; reopenTask: TaskType; unassignTask: TaskType; + unassignTaskFromTeam: TaskType; updateLocationNode: LocationNodeType; updatePatient: PatientType; updatePropertyDefinition: PropertyDefinitionType; @@ -129,6 +132,12 @@ export type MutationAssignTaskArgs = { }; +export type MutationAssignTaskToTeamArgs = { + id: Scalars['ID']['input']; + teamId: Scalars['ID']['input']; +}; + + export type MutationCompleteTaskArgs = { id: Scalars['ID']['input']; }; @@ -194,6 +203,11 @@ export type MutationUnassignTaskArgs = { }; +export type MutationUnassignTaskFromTeamArgs = { + id: Scalars['ID']['input']; +}; + + export type MutationUpdateLocationNodeArgs = { data: UpdateLocationNodeInput; id: Scalars['ID']['input']; @@ -357,6 +371,7 @@ export type QueryTaskArgs = { export type QueryTasksArgs = { assigneeId?: InputMaybe; + assigneeTeamId?: InputMaybe; patientId?: InputMaybe; rootLocationIds?: InputMaybe>; }; @@ -416,6 +431,8 @@ export type TaskType = { __typename?: 'TaskType'; assignee?: Maybe; assigneeId?: Maybe; + assigneeTeam?: Maybe; + assigneeTeamId?: Maybe; checksum: Scalars['String']['output']; creationDate: Scalars['DateTime']['output']; description?: Maybe; @@ -461,6 +478,7 @@ export type UpdatePropertyDefinitionInput = { export type UpdateTaskInput = { assigneeId?: InputMaybe; + assigneeTeamId?: InputMaybe; checksum?: InputMaybe; description?: InputMaybe; done?: InputMaybe; @@ -514,7 +532,7 @@ export type GetPatientQueryVariables = Exact<{ }>; -export type GetPatientQuery = { __typename?: 'Query', patient?: { __typename?: 'PatientType', id: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, checksum: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> } | null }; +export type GetPatientQuery = { __typename?: 'Query', patient?: { __typename?: 'PatientType', id: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, checksum: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> } | null }; export type GetPatientsQueryVariables = Exact<{ locationId?: InputMaybe; @@ -523,22 +541,23 @@ export type GetPatientsQueryVariables = Exact<{ }>; -export type GetPatientsQuery = { __typename?: 'Query', patients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', name: string } }> }> }; +export type GetPatientsQuery = { __typename?: 'Query', patients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', name: string } }> }> }; export type GetTaskQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type GetTaskQuery = { __typename?: 'Query', task?: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, checksum: string, patient: { __typename?: 'PatientType', id: string, name: string }, assignee?: { __typename?: 'UserType', id: string, name: string } | null, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> } | null }; +export type GetTaskQuery = { __typename?: 'Query', task?: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, checksum: string, patient: { __typename?: 'PatientType', id: string, name: string }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, properties: Array<{ __typename?: 'PropertyValueType', textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array } }> } | null }; export type GetTasksQueryVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; assigneeId?: InputMaybe; + assigneeTeamId?: InputMaybe; }>; -export type GetTasksQuery = { __typename?: 'Query', tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null }> }; +export type GetTasksQuery = { __typename?: 'Query', tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }; export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>; @@ -595,6 +614,13 @@ export type WaitPatientMutationVariables = Exact<{ export type WaitPatientMutation = { __typename?: 'Mutation', waitPatient: { __typename?: 'PatientType', id: string, state: PatientState } }; +export type DeletePatientMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DeletePatientMutation = { __typename?: 'Mutation', deletePatient: boolean }; + export type CreatePropertyDefinitionMutationVariables = Exact<{ data: CreatePropertyDefinitionInput; }>; @@ -734,6 +760,21 @@ export type ReopenTaskMutationVariables = Exact<{ export type ReopenTaskMutation = { __typename?: 'Mutation', reopenTask: { __typename?: 'TaskType', id: string, done: boolean, updateDate?: any | null } }; +export type AssignTaskToTeamMutationVariables = Exact<{ + id: Scalars['ID']['input']; + teamId: Scalars['ID']['input']; +}>; + + +export type AssignTaskToTeamMutation = { __typename?: 'Mutation', assignTaskToTeam: { __typename?: 'TaskType', id: string, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null } }; + +export type UnassignTaskFromTeamMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type UnassignTaskFromTeamMutation = { __typename?: 'Mutation', unassignTaskFromTeam: { __typename?: 'TaskType', id: string, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null } }; + export const GetLocationNodeDocument = ` @@ -1039,6 +1080,11 @@ export const GetPatientDocument = ` name avatarUrl } + assigneeTeam { + id + title + kind + } } properties { definition { @@ -1195,6 +1241,11 @@ export const GetPatientsDocument = ` name avatarUrl } + assigneeTeam { + id + title + kind + } } properties { definition { @@ -1240,6 +1291,12 @@ export const GetTaskDocument = ` assignee { id name + avatarUrl + } + assigneeTeam { + id + title + kind } properties { definition { @@ -1280,8 +1337,12 @@ export const useGetTaskQuery = < )}; export const GetTasksDocument = ` - query GetTasks($rootLocationIds: [ID!], $assigneeId: ID) { - tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId) { + query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID) { + tasks( + rootLocationIds: $rootLocationIds + assigneeId: $assigneeId + assigneeTeamId: $assigneeTeamId + ) { id title description @@ -1321,6 +1382,11 @@ export const GetTasksDocument = ` name avatarUrl } + assigneeTeam { + id + title + kind + } } } `; @@ -1621,6 +1687,25 @@ export const useWaitPatientMutation = < } )}; +export const DeletePatientDocument = ` + mutation DeletePatient($id: ID!) { + deletePatient(id: $id) +} + `; + +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!) { createPropertyDefinition(data: $data) { @@ -1988,3 +2073,55 @@ export const useReopenTaskMutation = < ...options } )}; + +export const AssignTaskToTeamDocument = ` + mutation AssignTaskToTeam($id: ID!, $teamId: ID!) { + assignTaskToTeam(id: $id, teamId: $teamId) { + id + assigneeTeam { + id + title + kind + } + } +} + `; + +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!) { + unassignTaskFromTeam(id: $id) { + id + assigneeTeam { + id + title + kind + } + } +} + `; + +export const useUnassignTaskFromTeamMutation = < + TError = unknown, + TContext = unknown + >(options?: UseMutationOptions) => { + + return useMutation( + { + mutationKey: ['UnassignTaskFromTeam'], + mutationFn: (variables?: UnassignTaskFromTeamMutationVariables) => fetcher(UnassignTaskFromTeamDocument, variables)(), + ...options + } + )}; diff --git a/web/api/graphql/GetPatient.graphql b/web/api/graphql/GetPatient.graphql index 258e2dad..e0ff074a 100644 --- a/web/api/graphql/GetPatient.graphql +++ b/web/api/graphql/GetPatient.graphql @@ -92,6 +92,11 @@ query GetPatient($id: ID!) { name avatarUrl } + assigneeTeam { + id + title + kind + } } properties { definition { diff --git a/web/api/graphql/GetPatients.graphql b/web/api/graphql/GetPatients.graphql index 1fbff928..9a789992 100644 --- a/web/api/graphql/GetPatients.graphql +++ b/web/api/graphql/GetPatients.graphql @@ -110,6 +110,11 @@ query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientSta name avatarUrl } + assigneeTeam { + id + title + kind + } } properties { definition { diff --git a/web/api/graphql/GetTask.graphql b/web/api/graphql/GetTask.graphql index 12c14aaa..76217e65 100644 --- a/web/api/graphql/GetTask.graphql +++ b/web/api/graphql/GetTask.graphql @@ -15,6 +15,12 @@ query GetTask($id: ID!) { assignee { id name + avatarUrl + } + assigneeTeam { + id + title + kind } properties { definition { diff --git a/web/api/graphql/GetTasks.graphql b/web/api/graphql/GetTasks.graphql index ffcff1c1..7952b02e 100644 --- a/web/api/graphql/GetTasks.graphql +++ b/web/api/graphql/GetTasks.graphql @@ -1,5 +1,5 @@ -query GetTasks($rootLocationIds: [ID!], $assigneeId: ID) { - tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId) { +query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID) { + tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId) { id title description @@ -39,6 +39,11 @@ query GetTasks($rootLocationIds: [ID!], $assigneeId: ID) { name avatarUrl } + assigneeTeam { + id + title + kind + } } } diff --git a/web/api/graphql/PatientMutations.graphql b/web/api/graphql/PatientMutations.graphql index 097c178f..8c3c9d32 100644 --- a/web/api/graphql/PatientMutations.graphql +++ b/web/api/graphql/PatientMutations.graphql @@ -96,3 +96,7 @@ mutation WaitPatient($id: ID!) { state } } + +mutation DeletePatient($id: ID!) { + deletePatient(id: $id) +} diff --git a/web/api/graphql/TaskMutations.graphql b/web/api/graphql/TaskMutations.graphql index 9e6d30f0..034aff4a 100644 --- a/web/api/graphql/TaskMutations.graphql +++ b/web/api/graphql/TaskMutations.graphql @@ -82,3 +82,25 @@ mutation ReopenTask($id: ID!) { updateDate } } + +mutation AssignTaskToTeam($id: ID!, $teamId: ID!) { + assignTaskToTeam(id: $id, teamId: $teamId) { + id + assigneeTeam { + id + title + kind + } + } +} + +mutation UnassignTaskFromTeam($id: ID!) { + unassignTaskFromTeam(id: $id) { + id + assigneeTeam { + id + title + kind + } + } +} diff --git a/web/components/FeedbackDialog.tsx b/web/components/FeedbackDialog.tsx new file mode 100644 index 00000000..25a0e670 --- /dev/null +++ b/web/components/FeedbackDialog.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect, useRef } from 'react' +import { Dialog, Button, Textarea, FormElementWrapper, Checkbox } from '@helpwave/hightide' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { useTasksContext } from '@/hooks/useTasksContext' +import { Mic, Pause } from 'lucide-react' +import clsx from 'clsx' + +interface FeedbackDialogProps { + isOpen: boolean, + onClose: () => void, + hideUrl?: boolean, +} + +interface SpeechRecognitionEvent { + resultIndex: number, + results: Array>, +} + +interface SpeechRecognitionErrorEvent { + error: string, +} + +interface SpeechRecognitionInstance { + continuous: boolean, + interimResults: boolean, + lang: string, + start: () => void, + stop: () => void, + onresult: ((event: SpeechRecognitionEvent) => void) | null, + onerror: ((event: SpeechRecognitionErrorEvent) => void) | null, + onend: (() => void) | null, +} + +export const FeedbackDialog = ({ isOpen, onClose, hideUrl = false }: FeedbackDialogProps) => { + const translation = useTasksTranslation() + 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) + + useEffect(() => { + if (typeof window !== 'undefined') { + const win = window as unknown as { + SpeechRecognition?: new () => SpeechRecognitionInstance, + webkitSpeechRecognition?: new () => SpeechRecognitionInstance, + } + const SpeechRecognition = win.SpeechRecognition || win.webkitSpeechRecognition + if (SpeechRecognition) { + setIsSupported(true) + const recognition = new SpeechRecognition() + recognition.continuous = true + recognition.interimResults = true + recognition.lang = navigator.language || 'en-US' + + recognition.onresult = (event: SpeechRecognitionEvent) => { + let transcript = '' + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i] + if (result && result[0]) { + transcript += result[0].transcript + } + } + setFeedback(prev => prev + transcript) + } + + recognition.onerror = (event: SpeechRecognitionErrorEvent) => { + if (event.error === 'no-speech') { + return + } + if (event.error === 'aborted' || event.error === 'network') { + console.error('Speech recognition error:', event.error) + isRecordingRef.current = false + setIsRecording(false) + return + } + console.error('Speech recognition error:', event.error) + } + + recognition.onend = () => { + if (isRecordingRef.current) { + try { + recognitionRef.current?.start() + } catch { + setIsRecording(false) + isRecordingRef.current = false + } + } else { + setIsRecording(false) + } + } + + recognitionRef.current = recognition + } + } + }, []) + + const handleToggleRecording = () => { + if (!recognitionRef.current) return + + if (isRecording) { + isRecordingRef.current = false + recognitionRef.current.stop() + setIsRecording(false) + } else { + isRecordingRef.current = true + recognitionRef.current.start() + setIsRecording(true) + } + } + + useEffect(() => { + if (!isOpen) { + setFeedback('') + setIsAnonymous(false) + if (recognitionRef.current && isRecording) { + isRecordingRef.current = false + recognitionRef.current.stop() + 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, + } + + if (!hideUrl) { + feedbackData.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) { + setFeedback('') + setIsAnonymous(false) + onClose() + } else { + console.error('Failed to submit feedback') + } + } catch (error) { + console.error('Error submitting feedback:', error) + } + } + + return ( + +
+ {!hideUrl && ( + + {() => ( +
+ {typeof window !== 'undefined' ? window.location.href : ''} +
+ )} +
+ )} + + + {() => ( +
+ + + {translation('submitAnonymously') ?? 'Submit anonymously'} + +
+ )} +
+ + + {() => ( +
+