From d2e75313cb4f1c2795711c9ebadce7fda6b7a81b Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Thu, 9 Apr 2026 18:44:27 +0200 Subject: [PATCH 1/2] add discard confirm dialog for drawer --- web/components/patients/PatientDataEditor.tsx | 18 +++++- web/components/patients/PatientDetailView.tsx | 3 + web/components/patients/PatientTasksView.tsx | 41 ++++++++++--- web/components/tables/PatientList.tsx | 28 ++++++++- web/components/tables/TaskList.tsx | 30 +++++++++- web/components/tasks/TaskDataEditor.tsx | 18 +++++- web/components/tasks/TaskDetailView.tsx | 4 +- web/hooks/useCreateDraftDirty.ts | 57 +++++++++++++++++++ web/i18n/translations.ts | 21 +++++++ web/locales/de-DE.arb | 3 + web/locales/en-US.arb | 3 + web/locales/es-ES.arb | 3 + web/locales/fr-FR.arb | 3 + web/locales/nl-NL.arb | 3 + web/locales/pt-BR.arb | 3 + web/utils/createDraftSnapshots.ts | 55 ++++++++++++++++++ 16 files changed, 277 insertions(+), 16 deletions(-) create mode 100644 web/hooks/useCreateDraftDirty.ts create mode 100644 web/utils/createDraftSnapshots.ts diff --git a/web/components/patients/PatientDataEditor.tsx b/web/components/patients/PatientDataEditor.tsx index f91d059e..3050ff09 100644 --- a/web/components/patients/PatientDataEditor.tsx +++ b/web/components/patients/PatientDataEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react' +import { useState, useMemo, useEffect, useCallback } from 'react' import type { FormFieldDataHandling } from '@helpwave/hightide' import { FormProvider, Input, DateTimeInput, Select, SelectOption, Textarea, Checkbox, Button, ConfirmDialog, LoadingContainer, useCreateForm, FormField, Visibility, useFormObserverKey, IconButton } from '@helpwave/hightide' import { CenteredLoadingLogo } from '@/components/CenteredLoadingLogo' @@ -22,6 +22,8 @@ import { } from '@/data' import { useTasksContext } from '@/hooks/useTasksContext' import { ErrorDialog } from '@/components/ErrorDialog' +import { useCreateDraftDirty } from '@/hooks/useCreateDraftDirty' +import { serializePatientCreateDraft } from '@/utils/createDraftSnapshots' type PatientFormValues = Omit & { clinic: NonNullable['clinic'] | null, @@ -34,6 +36,7 @@ interface PatientDataEditorProps { initialCreateData?: Partial, onSuccess?: () => void, onClose?: () => void, + onCreateDraftDirtyChange?: (dirty: boolean) => void, } const getDefaultBirthdate = () => { @@ -59,6 +62,7 @@ export const PatientDataEditor = ({ initialCreateData = {}, onSuccess, onClose, + onCreateDraftDirtyChange, }: PatientDataEditorProps) => { const translation = useTasksTranslation() const { selectedLocationId, selectedRootLocationIds, rootLocations } = useTasksContext() @@ -207,6 +211,18 @@ export const PatientDataEditor = ({ const { store, update: updateForm } = form + const serializePatientDraft = useCallback( + (values: PatientFormValues) => serializePatientCreateDraft(values), + [] + ) + + useCreateDraftDirty({ + enabled: !isEditMode && onCreateDraftDirtyChange != null, + store, + serialize: serializePatientDraft, + onDirtyChange: onCreateDraftDirtyChange, + }) + useEffect(() => { if (patientData) { const patient = patientData diff --git a/web/components/patients/PatientDetailView.tsx b/web/components/patients/PatientDetailView.tsx index 0c76e2b7..a8e9124a 100644 --- a/web/components/patients/PatientDetailView.tsx +++ b/web/components/patients/PatientDetailView.tsx @@ -53,6 +53,7 @@ interface PatientDetailViewProps { onSuccess: () => void, initialCreateData?: Partial, onOpenSystemSuggestion?: (suggestion: SystemSuggestion, patientName: string) => void, + onCreateDraftDirtyChange?: (dirty: boolean) => void, } export const PatientDetailView = ({ @@ -61,6 +62,7 @@ export const PatientDetailView = ({ onSuccess, initialCreateData = {}, onOpenSystemSuggestion, + onCreateDraftDirtyChange, }: PatientDetailViewProps) => { const translation = useTasksTranslation() @@ -240,6 +242,7 @@ export const PatientDetailView = ({ initialCreateData={initialCreateData} onSuccess={onSuccess} onClose={onClose} + onCreateDraftDirtyChange={isEditMode ? undefined : onCreateDraftDirtyChange} /> diff --git a/web/components/patients/PatientTasksView.tsx b/web/components/patients/PatientTasksView.tsx index 92d3c447..d2e79daf 100644 --- a/web/components/patients/PatientTasksView.tsx +++ b/web/components/patients/PatientTasksView.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect, useCallback } from 'react' -import { Button, Drawer, ExpandableContent, ExpandableHeader, ExpandableRoot } from '@helpwave/hightide' +import { Button, ConfirmDialog, Drawer, ExpandableContent, ExpandableHeader, ExpandableRoot } from '@helpwave/hightide' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { CheckCircle2, ChevronDown, Circle, Combine, PlusIcon } from 'lucide-react' import { TaskCardView } from '@/components/tasks/TaskCardView' @@ -34,6 +34,8 @@ export const PatientTasksView = ({ const translation = useTasksTranslation() const [taskId, setTaskId] = useState(null) const [isCreatingTask, setIsCreatingTask] = useState(false) + const [isCreateTaskDraftDirty, setIsCreateTaskDraftDirty] = useState(false) + const [isDiscardTaskCreateOpen, setIsDiscardTaskCreateOpen] = useState(false) const [loadPresetOpen, setLoadPresetOpen] = useState(false) const [optimisticTaskUpdates, setOptimisticTaskUpdates] = useState>(new Map()) @@ -41,6 +43,23 @@ export const PatientTasksView = ({ const [reopenTask] = useReopenTask() const initialPatientName = `${patientData?.patient?.firstname ?? ''} ${patientData?.patient?.lastname ?? ''}`.trim() + const isTaskCreateDrawer = isCreatingTask && !taskId + + const closeTaskDrawer = useCallback(() => { + setTaskId(null) + setIsCreatingTask(false) + setIsCreateTaskDraftDirty(false) + setIsDiscardTaskCreateOpen(false) + }, []) + + const requestCloseTaskDrawer = useCallback(() => { + if (isTaskCreateDrawer && isCreateTaskDraftDirty) { + setIsDiscardTaskCreateOpen(true) + return + } + closeTaskDrawer() + }, [isTaskCreateDrawer, isCreateTaskDraftDirty, closeTaskDrawer]) + const apiTasksWithOptimistic = useMemo(() => { const baseTasks = patientData?.patient?.tasks || [] return baseTasks.map(task => { @@ -177,10 +196,7 @@ export const PatientTasksView = ({ /> { - setTaskId(null) - setIsCreatingTask(false) - }} + onClose={requestCloseTaskDrawer} alignment="right" titleElement={taskId ? translation('editTask') : translation('createTask')} description={undefined} @@ -192,12 +208,19 @@ export const PatientTasksView = ({ onListSync={() => { onSuccess?.() }} - onClose={() => { - setTaskId(null) - setIsCreatingTask(false) - }} + onClose={requestCloseTaskDrawer} + onCreateDraftDirtyChange={isTaskCreateDrawer ? setIsCreateTaskDraftDirty : undefined} /> + setIsDiscardTaskCreateOpen(false)} + onConfirm={closeTaskDrawer} + titleElement={translation('discardDraftTitle')} + description={translation('discardDraftMessage')} + confirmType="negative" + buttonOverwrites={[{}, {}, { text: translation('discard') }]} + /> ) } diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx index 8989ee00..e6dc3987 100644 --- a/web/components/tables/PatientList.tsx +++ b/web/components/tables/PatientList.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useCallback, useRef, type ReactNode } from 'react' import { useMutation } from '@apollo/client/react' import type { IdentifierFilterValue, FilterListItem, FilterListPopUpBuilderProps } from '@helpwave/hightide' -import { Chip, FillerCell, HelpwaveLogo, LoadingContainer, SearchBar, ProgressIndicator, Tooltip, Drawer, TableProvider, TableDisplay, TableColumnSwitcher, IconButton, useLocale, FilterList, SortingList, Button, ExpansionIcon, Visibility } from '@helpwave/hightide' +import { Chip, ConfirmDialog, FillerCell, HelpwaveLogo, LoadingContainer, SearchBar, ProgressIndicator, Tooltip, Drawer, TableProvider, TableDisplay, TableColumnSwitcher, IconButton, useLocale, FilterList, SortingList, Button, ExpansionIcon, Visibility } from '@helpwave/hightide' import clsx from 'clsx' import { LayoutGrid, PlusIcon, Table2 } from 'lucide-react' import type { LocationType } from '@/api/gql/generated' @@ -219,6 +219,8 @@ export const PatientList = forwardRef(({ initi ]) const [isSaveViewDialogOpen, setIsSaveViewDialogOpen] = useState(false) + const [isCreatePatientDraftDirty, setIsCreatePatientDraftDirty] = useState(false) + const [isDiscardPatientCreateOpen, setIsDiscardPatientCreateOpen] = useState(false) const [suggestionModalOpen, setSuggestionModalOpen] = useState(false) const [suggestionModalSuggestion, setSuggestionModalSuggestion] = useState(null) @@ -482,10 +484,22 @@ export const PatientList = forwardRef(({ initi setIsPanelOpen(true) }, []) - const handleClose = () => { + const isPatientCreateMode = !selectedPatient && !openedPatientId + + const performPatientDrawerClose = useCallback(() => { setIsPanelOpen(false) setSelectedPatient(undefined) setOpenedPatientId(null) + setIsCreatePatientDraftDirty(false) + setIsDiscardPatientCreateOpen(false) + }, []) + + const handleClose = () => { + if (isPanelOpen && isPatientCreateMode && isCreatePatientDraftDirty) { + setIsDiscardPatientCreateOpen(true) + return + } + performPatientDrawerClose() } const patientPropertyColumns = useMemo[]>( @@ -1081,8 +1095,18 @@ export const PatientList = forwardRef(({ initi onPatientUpdated?.() }} onOpenSystemSuggestion={openSuggestionModal} + onCreateDraftDirtyChange={isPatientCreateMode ? setIsCreatePatientDraftDirty : undefined} /> + setIsDiscardPatientCreateOpen(false)} + onConfirm={performPatientDrawerClose} + titleElement={translation('discardDraftTitle')} + description={translation('discardDraftMessage')} + confirmType="negative" + buttonOverwrites={[{}, {}, { text: translation('discard') }]} + /> (({ tasks: initial const [isHandoverDialogOpen, setIsHandoverDialogOpen] = useState(false) const [selectedUserId, setSelectedUserId] = useState(null) const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false) + const [isCreateTaskDraftDirty, setIsCreateTaskDraftDirty] = useState(false) + const [isDiscardTaskCreateOpen, setIsDiscardTaskCreateOpen] = useState(false) const isOpeningConfirmDialogRef = useRef(false) const [isShowFilters, setIsShowFilters] = useState(false) const [isShowSorting, setIsShowSorting] = useState(false) @@ -834,6 +836,20 @@ export const TaskList = forwardRef(({ tasks: initial const hasOpenDrawer = taskDialogState.isOpen || selectedPatientId != null const hasFilterPanelOpen = isShowFilters || isShowSorting + const closeTaskDrawer = useCallback(() => { + setTaskDialogState({ isOpen: false }) + setIsCreateTaskDraftDirty(false) + setIsDiscardTaskCreateOpen(false) + }, []) + + const requestCloseTaskDrawer = useCallback(() => { + if (taskDialogState.isOpen && !taskDialogState.taskId && isCreateTaskDraftDirty) { + setIsDiscardTaskCreateOpen(true) + return + } + closeTaskDrawer() + }, [taskDialogState.isOpen, taskDialogState.taskId, isCreateTaskDraftDirty, closeTaskDrawer]) + useEffect(() => { if (typeof document === 'undefined') return if (isMobileIOS && hasOpenDrawer) { @@ -1017,14 +1033,24 @@ export const TaskList = forwardRef(({ tasks: initial titleElement={taskDialogState.taskId ? translation('editTask') : translation('createTask')} description={undefined} isOpen={taskDialogState.isOpen} - onClose={() => setTaskDialogState({ isOpen: false })} + onClose={requestCloseTaskDrawer} > setTaskDialogState({ isOpen: false })} + onClose={requestCloseTaskDrawer} onListSync={onRefetch} + onCreateDraftDirtyChange={taskDialogState.isOpen && !taskDialogState.taskId ? setIsCreateTaskDraftDirty : undefined} /> + setIsDiscardTaskCreateOpen(false)} + onConfirm={closeTaskDrawer} + titleElement={translation('discardDraftTitle')} + description={translation('discardDraftMessage')} + confirmType="negative" + buttonOverwrites={[{}, {}, { text: translation('discard') }]} + /> void, onClose?: () => void, + onCreateDraftDirtyChange?: (dirty: boolean) => void, presetRowEditor?: PresetRowEditorConfig | null, } @@ -71,6 +74,7 @@ export const TaskDataEditor = ({ initialPatientName, onListSync, onClose, + onCreateDraftDirtyChange, presetRowEditor, }: TaskDataEditorProps) => { const translation = useTasksTranslation() @@ -203,6 +207,18 @@ export const TaskDataEditor = ({ const { update: updateForm } = form + const serializeTaskDraft = useCallback( + (values: TaskFormValues) => serializeTaskCreateDraft(values), + [] + ) + + useCreateDraftDirty({ + enabled: !isEditMode && !isPresetRowMode && onCreateDraftDirtyChange != null, + store: form.store, + serialize: serializeTaskDraft, + onDirtyChange: onCreateDraftDirtyChange, + }) + useEffect(() => { if (!isPresetRowMode || !presetRowEditor) return updateForm(prev => ({ diff --git a/web/components/tasks/TaskDetailView.tsx b/web/components/tasks/TaskDetailView.tsx index 4ab41405..c37407d9 100644 --- a/web/components/tasks/TaskDetailView.tsx +++ b/web/components/tasks/TaskDetailView.tsx @@ -17,9 +17,10 @@ interface TaskDetailViewProps { onListSync?: () => void, initialPatientId?: string, initialPatientName?: string, + onCreateDraftDirtyChange?: (dirty: boolean) => void, } -export const TaskDetailView = ({ taskId, onClose, onListSync, initialPatientId, initialPatientName }: TaskDetailViewProps) => { +export const TaskDetailView = ({ taskId, onClose, onListSync, initialPatientId, initialPatientName, onCreateDraftDirtyChange }: TaskDetailViewProps) => { const translation = useTasksTranslation() const isEditMode = !!taskId @@ -99,6 +100,7 @@ export const TaskDetailView = ({ taskId, onClose, onListSync, initialPatientId, initialPatientName={initialPatientName} onListSync={onListSync} onClose={onClose} + onCreateDraftDirtyChange={isEditMode ? undefined : onCreateDraftDirtyChange} /> = { + enabled: boolean, + store: FormStore, + serialize: (values: T) => string, + onDirtyChange?: (dirty: boolean) => void, +} + +export function useCreateDraftDirty({ + enabled, + store, + serialize, + onDirtyChange, +}: UseCreateDraftDirtyParams): void { + const baselineRef = useRef(null) + const baselineReadyRef = useRef(false) + + useEffect(() => { + if (!enabled || !onDirtyChange) { + onDirtyChange?.(false) + baselineRef.current = null + baselineReadyRef.current = false + return + } + + onDirtyChange(false) + baselineReadyRef.current = false + baselineRef.current = null + + const captureBaseline = (): void => { + const snapshot = serialize(store.getAllValues()) + baselineRef.current = snapshot + baselineReadyRef.current = true + onDirtyChange(serialize(store.getAllValues()) !== baselineRef.current) + } + + const timerId = window.setTimeout(captureBaseline, BASELINE_CAPTURE_MS) + + const unsub = store.subscribe('ALL', () => { + if (!baselineReadyRef.current || baselineRef.current === null) { + return + } + onDirtyChange(serialize(store.getAllValues()) !== baselineRef.current) + }) + + return () => { + window.clearTimeout(timerId) + unsub() + baselineReadyRef.current = false + baselineRef.current = null + } + }, [enabled, store, serialize, onDirtyChange]) +} diff --git a/web/i18n/translations.ts b/web/i18n/translations.ts index 84169381..9621960e 100644 --- a/web/i18n/translations.ts +++ b/web/i18n/translations.ts @@ -67,6 +67,9 @@ export type TasksTranslationEntries = { 'descriptionPlaceholder': string, 'deselectAll': string, 'developmentAndPreviewInstance': string, + 'discard': string, + 'discardDraftMessage': string, + 'discardDraftTitle': string, 'discardViewChanges': string, 'dischargePatient': string, 'dischargePatientConfirmation': string, @@ -369,6 +372,9 @@ export const tasksTranslation: Translation t.id).sort(), + }) +} + +export type TaskCreateDraftSnapshotSource = { + title?: string, + description?: string | null, + patientId?: string | null, + assigneeIds?: string[] | null, + assigneeTeamId?: string | null, + dueDate?: Date | null, + priority?: string | null, + estimatedTime?: number | null, + done?: boolean, +} + +export function serializeTaskCreateDraft(values: TaskCreateDraftSnapshotSource): string { + return JSON.stringify({ + title: values.title, + description: values.description ?? '', + patientId: values.patientId || '', + assigneeIds: [...(values.assigneeIds ?? [])].sort(), + assigneeTeamId: values.assigneeTeamId ?? null, + dueDate: values.dueDate ? values.dueDate.getTime() : null, + priority: values.priority ?? null, + estimatedTime: values.estimatedTime ?? null, + done: values.done, + }) +} From 76549fd38eebaa8d2e048fd90c5092f2cce22bdc Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Thu, 9 Apr 2026 18:48:02 +0200 Subject: [PATCH 2/2] fix button overflow on patient data editor --- web/components/patients/PatientDataEditor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/patients/PatientDataEditor.tsx b/web/components/patients/PatientDataEditor.tsx index 3050ff09..1344f697 100644 --- a/web/components/patients/PatientDataEditor.tsx +++ b/web/components/patients/PatientDataEditor.tsx @@ -564,12 +564,13 @@ export const PatientDataEditor = ({ {isEditMode && patientId && patientData && ( -
+
{patientData.state !== PatientState.Dead && ( @@ -578,6 +579,7 @@ export const PatientDataEditor = ({ onClick={() => setIsDeleteDialogOpen(true)} color="negative" coloringStyle="outline" + className="w-full min-w-0 max-w-full whitespace-normal text-center leading-snug h-auto min-h-11 py-2.5 sm:w-auto sm:shrink" > {translation('deletePatient') ?? 'Delete Patient'}