From a14534b22e88771b9b7d9c64b366fe1567a34b56 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Sun, 22 Mar 2026 13:12:41 +0100 Subject: [PATCH] add suggestion ui --- backend/database/models/property.py | 2 +- web/components/FeedbackToast.tsx | 26 +++ web/components/patients/PatientDetailView.tsx | 36 ++- web/components/patients/PatientTasksView.tsx | 55 ++++- .../patients/SystemSuggestionModal.tsx | 220 ++++++++++++++++++ web/components/tables/PatientList.tsx | 84 ++++++- web/components/tables/TaskList.tsx | 13 +- web/components/tasks/TaskCardView.tsx | 9 +- web/context/SystemSuggestionTasksContext.tsx | 125 ++++++++++ web/data/mockPatients.ts | 91 ++++++++ web/data/mockSystemSuggestions.ts | 70 ++++++ web/pages/_app.tsx | 13 +- web/types/systemSuggestion.ts | 39 ++++ 13 files changed, 753 insertions(+), 30 deletions(-) create mode 100644 web/components/FeedbackToast.tsx create mode 100644 web/components/patients/SystemSuggestionModal.tsx create mode 100644 web/context/SystemSuggestionTasksContext.tsx create mode 100644 web/data/mockPatients.ts create mode 100644 web/data/mockSystemSuggestions.ts create mode 100644 web/types/systemSuggestion.ts diff --git a/backend/database/models/property.py b/backend/database/models/property.py index 680b934b..ffe60ea6 100644 --- a/backend/database/models/property.py +++ b/backend/database/models/property.py @@ -69,4 +69,4 @@ class PropertyValue(Base): String, nullable=True, ) - user_value: Mapped[str | None] = mapped_column(String, nullable=True) + user_value: Mapped[str | None] = mapped_column(String, nullable=True) \ No newline at end of file diff --git a/web/components/FeedbackToast.tsx b/web/components/FeedbackToast.tsx new file mode 100644 index 00000000..640abe08 --- /dev/null +++ b/web/components/FeedbackToast.tsx @@ -0,0 +1,26 @@ +import { Chip } from '@helpwave/hightide' +import { useSystemSuggestionTasksOptional } from '@/context/SystemSuggestionTasksContext' + +export function FeedbackToast() { + const ctx = useSystemSuggestionTasksOptional() + const toast = ctx?.toast ?? null + + if (!toast) return null + + return ( +
+ + {toast.message} + +
+ ) +} diff --git a/web/components/patients/PatientDetailView.tsx b/web/components/patients/PatientDetailView.tsx index 3fbd1899..475e4b83 100644 --- a/web/components/patients/PatientDetailView.tsx +++ b/web/components/patients/PatientDetailView.tsx @@ -3,12 +3,14 @@ import { useTasksTranslation } from '@/i18n/useTasksTranslation' import type { CreatePatientInput, PropertyValueInput } from '@/api/gql/generated' import { usePatient } from '@/data' import { + Button, ProgressIndicator, TabList, TabPanel, TabSwitcher, - Tooltip + Tooltip, } from '@helpwave/hightide' +import { Sparkles } from 'lucide-react' import { PatientStateChip } from '@/components/patients/PatientStateChip' import { LocationChips } from '@/components/locations/LocationChips' import { PatientTasksView } from './PatientTasksView' @@ -16,6 +18,8 @@ import { PatientDataEditor } from './PatientDataEditor' import { AuditLogTimeline } from '@/components/AuditLogTimeline' import { PropertyList, type PropertyValue } from '../tables/PropertyList' import { useUpdatePatient } from '@/data' +import { getAdherenceByPatientId, getSuggestionByPatientId } from '@/data/mockSystemSuggestions' +import type { SystemSuggestion } from '@/types/systemSuggestion' export const toISODate = (d: Date | string | null | undefined): string | null => { if (!d) return null @@ -48,13 +52,15 @@ interface PatientDetailViewProps { onClose: () => void, onSuccess: () => void, initialCreateData?: Partial, + onOpenSystemSuggestion?: (suggestion: SystemSuggestion, patientName: string) => void, } export const PatientDetailView = ({ patientId, onClose, onSuccess, - initialCreateData = {} + initialCreateData = {}, + onOpenSystemSuggestion, }: PatientDetailViewProps) => { const translation = useTasksTranslation() @@ -142,6 +148,11 @@ export const PatientDetailView = ({ return [] }, [patientData?.position, patientData?.assignedLocations]) + const adherence = patientId ? getAdherenceByPatientId(patientId) : 'unknown' + const systemSuggestion = patientId ? getSuggestionByPatientId(patientId) : null + const adherenceDotClass = adherence === 'adherent' ? 'bg-positive' : adherence === 'non_adherent' ? 'bg-negative' : 'bg-warning' + const adherenceLabel = adherence === 'adherent' ? 'Treatment standard adherent' : adherence === 'non_adherent' ? 'Treatment is not adherent with standards.' : 'In Progress' + const adherenceTooltip = adherenceLabel return (
@@ -177,6 +188,27 @@ export const PatientDetailView = ({ )}
)} + {isEditMode && patientId && ( +
+
+ Analysis + {adherenceLabel} + + + +
+ +
+ )} diff --git a/web/components/patients/PatientTasksView.tsx b/web/components/patients/PatientTasksView.tsx index 2ab446fb..4607a188 100644 --- a/web/components/patients/PatientTasksView.tsx +++ b/web/components/patients/PatientTasksView.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react' +import { useState, useMemo, useEffect, useCallback } from 'react' import { Button, Drawer, ExpandableContent, ExpandableHeader, ExpandableRoot } from '@helpwave/hightide' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { CheckCircle2, ChevronDown, Circle, PlusIcon } from 'lucide-react' @@ -7,6 +7,7 @@ import clsx from 'clsx' import type { GetPatientQuery } from '@/api/gql/generated' import { TaskDetailView } from '@/components/tasks/TaskDetailView' import { useCompleteTask, useReopenTask } from '@/data' +import { useCreatedTasksForPatient, useSystemSuggestionTasksOptional } from '@/context/SystemSuggestionTasksContext' interface PatientTasksViewProps { patientId: string, @@ -14,12 +15,14 @@ interface PatientTasksViewProps { onSuccess?: () => void, } -const sortByDueDate = (tasks: T[]): T[] => { +const sortByDueDate = (tasks: T[]): T[] => { return [...tasks].sort((a, b) => { + const aTime = a.dueDate ? new Date(a.dueDate).getTime() : 0 + const bTime = b.dueDate ? new Date(b.dueDate).getTime() : 0 if (!a.dueDate && !b.dueDate) return 0 if (!a.dueDate) return 1 if (!b.dueDate) return -1 - return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime() + return aTime - bTime }) } @@ -35,8 +38,10 @@ export const PatientTasksView = ({ const [completeTask] = useCompleteTask() const [reopenTask] = useReopenTask() + const createdTasks = useCreatedTasksForPatient(patientId) + const suggestionTasksContext = useSystemSuggestionTasksOptional() - const tasks = useMemo(() => { + const apiTasksWithOptimistic = useMemo(() => { const baseTasks = patientData?.patient?.tasks || [] return baseTasks.map(task => { const optimisticDone = optimisticTaskUpdates.get(task.id) @@ -47,14 +52,35 @@ export const PatientTasksView = ({ }) }, [patientData?.patient?.tasks, optimisticTaskUpdates]) - const openTasks = useMemo(() => sortByDueDate(tasks.filter(t => !t.done)), [tasks]) - const closedTasks = useMemo(() => sortByDueDate(tasks.filter(t => t.done)), [tasks]) + const mergedCreatedTasks = useMemo(() => { + return createdTasks.map(t => ({ + id: t.id, + title: t.title, + name: t.title, + description: t.description ?? undefined, + done: t.done, + dueDate: t.dueDate ?? undefined, + updateDate: t.updateDate, + priority: t.priority ?? undefined, + estimatedTime: t.estimatedTime ?? undefined, + assignee: t.assignedTo === 'me' ? { id: 'me', name: 'Me', avatarUrl: null, lastOnline: null, isOnline: false } : undefined, + assigneeTeam: undefined, + machineGenerated: true as const, + source: 'systemSuggestion' as const, + })) + }, [createdTasks]) - useEffect(() => { - setOptimisticTaskUpdates(new Map()) - }, [patientData?.patient?.tasks]) + const tasks = useMemo( + () => [...apiTasksWithOptimistic, ...mergedCreatedTasks], + [apiTasksWithOptimistic, mergedCreatedTasks] + ) - const handleToggleDone = (taskId: string, done: boolean) => { + const handleToggleDone = useCallback((taskId: string, done: boolean) => { + const isCreated = mergedCreatedTasks.some(t => t.id === taskId) + if (isCreated && suggestionTasksContext) { + suggestionTasksContext.setCreatedTaskDone(patientId, taskId, done) + return + } setOptimisticTaskUpdates(prev => { const next = new Map(prev) next.set(taskId, done) @@ -85,7 +111,14 @@ export const PatientTasksView = ({ }, }) } - } + }, [mergedCreatedTasks, suggestionTasksContext, patientId, completeTask, reopenTask, onSuccess]) + + const openTasks = useMemo(() => sortByDueDate(tasks.filter(t => !t.done)), [tasks]) + const closedTasks = useMemo(() => sortByDueDate(tasks.filter(t => t.done)), [tasks]) + + useEffect(() => { + setOptimisticTaskUpdates(new Map()) + }, [patientData?.patient?.tasks]) return ( <> diff --git a/web/components/patients/SystemSuggestionModal.tsx b/web/components/patients/SystemSuggestionModal.tsx new file mode 100644 index 00000000..76617ada --- /dev/null +++ b/web/components/patients/SystemSuggestionModal.tsx @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Button, + Checkbox, + Chip, + Dialog, + FocusTrapWrapper, + TabList, + TabPanel, + TabSwitcher, +} from '@helpwave/hightide' +import { ArrowRight, BookCheck, Workflow } from 'lucide-react' +import type { GuidelineAdherenceStatus } from '@/types/systemSuggestion' +import type { SystemSuggestion } from '@/types/systemSuggestion' +import { useSystemSuggestionTasks } from '@/context/SystemSuggestionTasksContext' + +type SystemSuggestionModalProps = { + isOpen: boolean + onClose: () => void + suggestion: SystemSuggestion + patientName?: string +} + +const ADHERENCE_LABEL: Record = { + adherent: 'Adherent', + non_adherent: 'Not adherent', + unknown: 'In Progress', +} + +function adherenceToChipColor(status: GuidelineAdherenceStatus): 'positive' | 'negative' | 'warning' { + switch (status) { + case 'adherent': + return 'positive' + case 'non_adherent': + return 'negative' + default: + return 'warning' + } +} + +export function SystemSuggestionModal({ + isOpen, + onClose, + suggestion, + patientName, +}: SystemSuggestionModalProps) { + const [selectedIds, setSelectedIds] = useState>(() => new Set(suggestion.suggestedTasks.map((t) => t.id))) + const [activeTabId, setActiveTabId] = useState(undefined) + + useEffect(() => { + if (isOpen) { + setSelectedIds(new Set(suggestion.suggestedTasks.map((t) => t.id))) + setActiveTabId(undefined) + } + }, [isOpen, suggestion.suggestedTasks]) + + const { addCreatedTasks, showToast } = useSystemSuggestionTasks() + + const toggleTask = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const selectedItems = useMemo( + () => suggestion.suggestedTasks.filter((t) => selectedIds.has(t.id)), + [suggestion.suggestedTasks, selectedIds] + ) + + const handleCreate = useCallback(() => { + addCreatedTasks(suggestion.patientId, selectedItems, false) + showToast('Tasks created') + onClose() + }, [suggestion.patientId, selectedItems, addCreatedTasks, showToast, onClose]) + + const handleCreateAndAssign = useCallback(() => { + addCreatedTasks(suggestion.patientId, selectedItems, true) + showToast('Tasks created and assigned') + onClose() + }, [suggestion.patientId, selectedItems, addCreatedTasks, showToast, onClose]) + + return ( + + +
+ setActiveTabId(id ?? undefined)} + initialActiveId="Suggestion" + > + + +
+
+
Guideline adherence
+
+ + {ADHERENCE_LABEL[suggestion.adherenceStatus]} + +
+

{suggestion.reasonSummary}

+
+ +
+
Suggested tasks
+
+ {suggestion.suggestedTasks.map((task) => ( + + ))} +
+
+
+ +
+ + + +
+
+ + +
+
+
Explanation
+

+ {suggestion.explanation.details} +

+
+
+
Model
+
+
+
+ + GIVE_FLUIDS_AFTER_INITIAL_BOLUS + +
+
+ + Response +
+
+
+ + SIGNS_OF_HYPOPERFUSION_PERSIST + +
+
+
+
+
References
+
+ + +
+
+
+
+
+
+
+
+ ) +} diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx index 06ad2fbd..c37acec5 100644 --- a/web/components/tables/PatientList.tsx +++ b/web/components/tables/PatientList.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useCallback, useRef } from 'react' import { Chip, FillerCell, HelpwaveLogo, LoadingContainer, SearchBar, ProgressIndicator, Tooltip, Drawer, TableProvider, TableDisplay, TableColumnSwitcher, TablePagination, IconButton, useLocale } from '@helpwave/hightide' -import { PlusIcon } from 'lucide-react' +import { PlusIcon, Sparkles } from 'lucide-react' import { Sex, PatientState, type GetPatientsQuery, type TaskType, PropertyEntity, type FullTextSearchInput, type LocationType } from '@/api/gql/generated' import { usePropertyDefinitions, usePatientsPaginated, useRefreshingEntityIds } from '@/data' import { PatientDetailView } from '@/components/patients/PatientDetailView' @@ -15,6 +15,10 @@ import { getPropertyColumnsForEntity } from '@/utils/propertyColumn' import { useStorageSyncedTableState } from '@/hooks/useTableState' import { usePropertyColumnVisibility } from '@/hooks/usePropertyColumnVisibility' import { columnFiltersToFilterInput, paginationStateToPaginationInput, sortingStateToSortInput } from '@/utils/tableStateToApi' +import { getAdherenceByPatientId, getSuggestionByPatientId, DUMMY_SUGGESTION } from '@/data/mockSystemSuggestions' +import { MOCK_PATIENTS } from '@/data/mockPatients' +import { SystemSuggestionModal } from '@/components/patients/SystemSuggestionModal' +import type { SystemSuggestion } from '@/types/systemSuggestion' export type PatientViewModel = { id: string, @@ -64,6 +68,9 @@ export const PatientList = forwardRef(({ initi const [selectedPatient, setSelectedPatient] = useState(undefined) const [searchQuery, setSearchQuery] = useState('') const [openedPatientId, setOpenedPatientId] = useState(null) + const [suggestionModalOpen, setSuggestionModalOpen] = useState(false) + const [suggestionModalSuggestion, setSuggestionModalSuggestion] = useState(null) + const [suggestionModalPatientName, setSuggestionModalPatientName] = useState('') const { pagination, @@ -152,23 +159,28 @@ export const PatientList = forwardRef(({ initi }) }, [patientsData]) + const displayPatients: PatientViewModel[] = useMemo( + () => [...MOCK_PATIENTS, ...patients], + [patients] + ) + useImperativeHandle(ref, () => ({ openCreate: () => { setSelectedPatient(undefined) setIsPanelOpen(true) }, openPatient: (patientId: string) => { - const patient = patients.find(p => p.id === patientId) + const patient = displayPatients.find(p => p.id === patientId) if (patient) { setSelectedPatient(patient) setIsPanelOpen(true) } } - }), [patients]) + }), [displayPatients]) useEffect(() => { if (initialPatientId && openedPatientId !== initialPatientId) { - const patient = patients.find(p => p.id === initialPatientId) + const patient = displayPatients.find(p => p.id === initialPatientId) if (patient) { setSelectedPatient(patient) } @@ -176,7 +188,7 @@ export const PatientList = forwardRef(({ initi setOpenedPatientId(initialPatientId) onInitialPatientOpened?.() } - }, [initialPatientId, patients, openedPatientId, onInitialPatientOpened]) + }, [initialPatientId, displayPatients, openedPatientId, onInitialPatientOpened]) const handleEdit = useCallback((patient: PatientViewModel) => { setSelectedPatient(patient) @@ -202,12 +214,55 @@ export const PatientList = forwardRef(({ initi const rowLoadingCell = useMemo(() => , []) + const openSuggestionModal = useCallback((suggestion: SystemSuggestion, patientName: string) => { + setSuggestionModalSuggestion(suggestion) + setSuggestionModalPatientName(patientName) + setSuggestionModalOpen(true) + }, []) + + const closeSuggestionModal = useCallback(() => { + setSuggestionModalOpen(false) + }, []) + const columns = useMemo[]>(() => [ { id: 'name', header: translation('name'), accessorKey: 'name', - cell: ({ row }) => (refreshingPatientIds.has(row.original.id) ? rowLoadingCell : row.original.name), + cell: ({ row }) => { + if (refreshingPatientIds.has(row.original.id)) return rowLoadingCell + const adherence = getAdherenceByPatientId(row.original.id) + const suggestion = getSuggestionByPatientId(row.original.id) + const dotClass = adherence === 'adherent' ? 'bg-positive' : adherence === 'non_adherent' ? 'bg-negative' : 'bg-warning' + const adherenceTooltip = adherence === 'adherent' ? 'Adherent' : adherence === 'non_adherent' ? 'Not adherent' : 'In Progress' + return ( + <> + {row.original.name} +
+ + + + {row.original.name} + {suggestion && ( + + { + e.stopPropagation() + openSuggestionModal(suggestion, row.original.name) + }} + className="shrink-0 text-[var(--color-blue-200)] hover:text-[var(--color-blue-500)]" + > + + + + )} +
+ + ) + }, minSize: 200, size: 250, maxSize: 300, @@ -393,14 +448,14 @@ export const PatientList = forwardRef(({ initi refreshingPatientIds.has(params.row.original.id) ? rowLoadingCell : (col.cell as (p: unknown) => React.ReactNode)(params) : undefined, })), - ], [translation, allPatientStates, patientPropertyColumns, refreshingPatientIds, rowLoadingCell, dateFormat]) + ], [translation, allPatientStates, patientPropertyColumns, refreshingPatientIds, rowLoadingCell, dateFormat, openSuggestionModal]) const onRowClick = useCallback((row: Row) => handleEdit(row.original), [handleEdit]) const fillerRowCell = useCallback(() => (), []) return ( (({ initi onSortingChange={setSorting} onColumnFiltersChange={setFilters} enableMultiSort={true} - pageCount={stableTotalCount != null ? Math.ceil(stableTotalCount / pagination.pageSize) : -1} + pageCount={stableTotalCount != null ? Math.ceil((stableTotalCount + MOCK_PATIENTS.length) / pagination.pageSize) : -1} >
@@ -473,8 +528,19 @@ export const PatientList = forwardRef(({ initi patientId={selectedPatient?.id ?? openedPatientId ?? undefined} onClose={handleClose} onSuccess={refetch} + onOpenSystemSuggestion={(suggestion, patientName) => { + setSuggestionModalSuggestion(suggestion) + setSuggestionModalPatientName(patientName) + setSuggestionModalOpen(true) + }} /> +
) diff --git a/web/components/tables/TaskList.tsx b/web/components/tables/TaskList.tsx index 72945ded..c346e058 100644 --- a/web/components/tables/TaskList.tsx +++ b/web/components/tables/TaskList.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useRef, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Button, Checkbox, ConfirmDialog, FillerCell, HelpwaveLogo, IconButton, LoadingContainer, SearchBar, Select, SelectOption, TableColumnSwitcher, TableDisplay, TablePagination, TableProvider } from '@helpwave/hightide' +import { Button, Checkbox, Chip, ConfirmDialog, FillerCell, HelpwaveLogo, IconButton, LoadingContainer, SearchBar, Select, SelectOption, TableColumnSwitcher, TableDisplay, TablePagination, TableProvider } from '@helpwave/hightide' import { PlusIcon, UserCheck, Users } from 'lucide-react' import type { TaskPriority, GetTasksQuery } from '@/api/gql/generated' import { PropertyEntity } from '@/api/gql/generated' @@ -44,6 +44,9 @@ export type TaskViewModel = { assigneeTeam?: { id: string, title: string }, done: boolean, properties?: GetTasksQuery['tasks'][0]['properties'], + machineGenerated?: boolean, + source?: 'manual' | 'systemSuggestion', + assignedTo?: 'me' | null, } export type TaskListRef = { @@ -389,12 +392,18 @@ export const TaskList = forwardRef(({ tasks: initial accessorKey: 'name', cell: ({ row }) => { if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell + const showSystemBadge = row.original.machineGenerated || row.original.source === 'systemSuggestion' return ( -
+
{row.original.priority && (
)} {row.original.name} + {showSystemBadge && ( + + System + + )}
) }, diff --git a/web/components/tasks/TaskCardView.tsx b/web/components/tasks/TaskCardView.tsx index 1e681b60..72a722be 100644 --- a/web/components/tasks/TaskCardView.tsx +++ b/web/components/tasks/TaskCardView.tsx @@ -1,4 +1,4 @@ -import { Button, Checkbox } from '@helpwave/hightide' +import { Button, Checkbox, Chip } from '@helpwave/hightide' import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { Clock, User, Users, Flag } from 'lucide-react' import clsx from 'clsx' @@ -42,6 +42,8 @@ type FlexibleTask = { id: string, title: string, } | null, + machineGenerated?: boolean, + source?: 'manual' | 'systemSuggestion', } type TaskCardViewProps = { @@ -206,6 +208,11 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA > {taskName}
+ {((task as FlexibleTask).machineGenerated || (task as FlexibleTask).source === 'systemSuggestion') && ( + + System + + )}
{task.assigneeTeam && (
diff --git a/web/context/SystemSuggestionTasksContext.tsx b/web/context/SystemSuggestionTasksContext.tsx new file mode 100644 index 00000000..8d056311 --- /dev/null +++ b/web/context/SystemSuggestionTasksContext.tsx @@ -0,0 +1,125 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import type { MachineGeneratedTask } from '@/types/systemSuggestion' +import type { SuggestedTaskItem } from '@/types/systemSuggestion' + +type ToastState = { message: string } | null + +type SystemSuggestionTasksContextValue = { + getCreatedTasksForPatient: (patientId: string) => MachineGeneratedTask[] + addCreatedTasks: ( + patientId: string, + items: SuggestedTaskItem[], + assignToMe?: boolean + ) => void + setCreatedTaskDone: (patientId: string, taskId: string, done: boolean) => void + toast: ToastState + showToast: (message: string) => void + clearToast: () => void +} + +const SystemSuggestionTasksContext = createContext(null) + +const TOAST_DURATION_MS = 3000 + +function generateTaskId(): string { + return `suggestion-created-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +export function SystemSuggestionTasksProvider({ children }: { children: ReactNode }) { + const [createdByPatientId, setCreatedByPatientId] = useState>({}) + const [toast, setToast] = useState(null) + + const getCreatedTasksForPatient = useCallback((patientId: string): MachineGeneratedTask[] => { + return createdByPatientId[patientId] ?? [] + }, [createdByPatientId]) + + const addCreatedTasks = useCallback( + (patientId: string, items: SuggestedTaskItem[], assignToMe?: boolean) => { + const now = new Date() + const newTasks: MachineGeneratedTask[] = items.map((item) => ({ + id: generateTaskId(), + title: item.title, + description: item.description ?? null, + done: false, + patientId, + machineGenerated: true, + source: 'systemSuggestion', + assignedTo: assignToMe ? 'me' : null, + updateDate: now, + dueDate: null, + priority: null, + estimatedTime: null, + })) + setCreatedByPatientId((prev) => { + const existing = prev[patientId] ?? [] + return { ...prev, [patientId]: [...existing, ...newTasks] } + }) + }, + [] + ) + + const setCreatedTaskDone = useCallback((patientId: string, taskId: string, done: boolean) => { + setCreatedByPatientId((prev) => { + const list = prev[patientId] ?? [] + const next = list.map((t) => (t.id === taskId ? { ...t, done } : t)) + return { ...prev, [patientId]: next } + }) + }, []) + + const showToast = useCallback((message: string) => { + setToast({ message }) + }, []) + + const clearToast = useCallback(() => { + setToast(null) + }, []) + + useEffect(() => { + if (!toast) return + const t = setTimeout(() => setToast(null), TOAST_DURATION_MS) + return () => clearTimeout(t) + }, [toast]) + + const value = useMemo( + () => ({ + getCreatedTasksForPatient, + addCreatedTasks, + setCreatedTaskDone, + toast, + showToast, + clearToast, + }), + [getCreatedTasksForPatient, addCreatedTasks, setCreatedTaskDone, toast, showToast, clearToast] + ) + + return ( + + {children} + + ) +} + +export function useSystemSuggestionTasks(): SystemSuggestionTasksContextValue { + const ctx = useContext(SystemSuggestionTasksContext) + if (!ctx) { + throw new Error('useSystemSuggestionTasks must be used within SystemSuggestionTasksProvider') + } + return ctx +} + +export function useSystemSuggestionTasksOptional(): SystemSuggestionTasksContextValue | null { + return useContext(SystemSuggestionTasksContext) +} + +export function useCreatedTasksForPatient(patientId: string): MachineGeneratedTask[] { + const ctx = useSystemSuggestionTasksOptional() + return ctx ? ctx.getCreatedTasksForPatient(patientId) : [] +} diff --git a/web/data/mockPatients.ts b/web/data/mockPatients.ts new file mode 100644 index 00000000..4971daef --- /dev/null +++ b/web/data/mockPatients.ts @@ -0,0 +1,91 @@ +import { PatientState, Sex } from '@/api/gql/generated' +import type { PatientViewModel } from '@/components/tables/PatientList' + +export const MOCK_PATIENT_A_ID = 'mock-patient-a' +export const MOCK_PATIENT_B_ID = 'mock-patient-b' +export const MOCK_PATIENT_C_ID = 'mock-patient-c' +export const MOCK_PATIENT_D_ID = 'mock-patient-d' +export const MOCK_PATIENT_E_ID = 'mock-patient-e' + +const mockBirthdateA = new Date(1965, 2, 15) +const mockBirthdateB = new Date(1970, 8, 1) +const mockBirthdateC = new Date(1980, 5, 20) +const mockBirthdateD = new Date(1975, 11, 8) +const mockBirthdateE = new Date(1988, 1, 14) + +export const MOCK_PATIENT_A: PatientViewModel = { + id: MOCK_PATIENT_A_ID, + name: 'Patient A', + firstname: 'Patient', + lastname: 'A', + position: null, + openTasksCount: 0, + closedTasksCount: 0, + birthdate: mockBirthdateA, + sex: Sex.Male, + state: PatientState.Admitted, + tasks: [], + properties: [], +} + +export const MOCK_PATIENT_B: PatientViewModel = { + id: MOCK_PATIENT_B_ID, + name: 'Patient B', + firstname: 'Patient', + lastname: 'B', + position: null, + openTasksCount: 0, + closedTasksCount: 0, + birthdate: mockBirthdateB, + sex: Sex.Female, + state: PatientState.Admitted, + tasks: [], + properties: [], +} + +export const MOCK_PATIENT_C: PatientViewModel = { + id: MOCK_PATIENT_C_ID, + name: 'Patient C', + firstname: 'Patient', + lastname: 'C', + position: null, + openTasksCount: 0, + closedTasksCount: 0, + birthdate: mockBirthdateC, + sex: Sex.Female, + state: PatientState.Admitted, + tasks: [], + properties: [], +} + +export const MOCK_PATIENT_D: PatientViewModel = { + id: MOCK_PATIENT_D_ID, + name: 'Patient D', + firstname: 'Patient', + lastname: 'D', + position: null, + openTasksCount: 0, + closedTasksCount: 0, + birthdate: mockBirthdateD, + sex: Sex.Male, + state: PatientState.Admitted, + tasks: [], + properties: [], +} + +export const MOCK_PATIENT_E: PatientViewModel = { + id: MOCK_PATIENT_E_ID, + name: 'Patient E', + firstname: 'Patient', + lastname: 'E', + position: null, + openTasksCount: 0, + closedTasksCount: 0, + birthdate: mockBirthdateE, + sex: Sex.Male, + state: PatientState.Admitted, + tasks: [], + properties: [], +} + +export const MOCK_PATIENTS: PatientViewModel[] = [MOCK_PATIENT_A, MOCK_PATIENT_B, MOCK_PATIENT_C, MOCK_PATIENT_D, MOCK_PATIENT_E] diff --git a/web/data/mockSystemSuggestions.ts b/web/data/mockSystemSuggestions.ts new file mode 100644 index 00000000..867b55a6 --- /dev/null +++ b/web/data/mockSystemSuggestions.ts @@ -0,0 +1,70 @@ +import type { GuidelineAdherenceStatus, SystemSuggestion } from '@/types/systemSuggestion' +import { MOCK_PATIENT_A_ID, MOCK_PATIENT_B_ID, MOCK_PATIENT_C_ID, MOCK_PATIENT_D_ID, MOCK_PATIENT_E_ID } from '@/data/mockPatients' + +export const MOCK_SUGGESTION_FOR_PATIENT_A: SystemSuggestion = { + id: 'mock-suggestion-a', + patientId: MOCK_PATIENT_A_ID, + adherenceStatus: 'non_adherent', + reasonSummary: 'Current treatment plan does not align with guideline recommendations for this condition. Recommended tasks address screening and follow-up intervals that have been missed.', + suggestedTasks: [ + { id: 'sug-1', title: 'Schedule guideline-recommended screening', description: 'Book lab and imaging per protocol.' }, + { id: 'sug-2', title: 'Document treatment rationale', description: 'Record clinical reasoning for any deviation from guidelines.' }, + { id: 'sug-3', title: 'Plan follow-up within 4 weeks', description: 'Set reminder for next review date.' }, + ], + explanation: { + details: 'The recommendation is shown because de-facto treatment of this patient is not adherent with the de-jure models. The suggested tasks are derived from evidence-based protocols to improve adherence and outcomes.', + references: [ + { title: 'Clinical guideline (PDF)', url: 'https://example.com/guideline.pdf' }, + { title: 'Supporting literature', url: 'https://example.com/literature' }, + ], + }, + createdAt: new Date().toISOString(), +} + +export const MOCK_SUGGESTION_FOR_PATIENT_E: SystemSuggestion = { + id: 'mock-suggestion-e', + patientId: MOCK_PATIENT_E_ID, + adherenceStatus: 'adherent', + reasonSummary: 'Guideline targets are met. Optional follow-up tasks may help maintain adherence and document progress.', + suggestedTasks: [ + { id: 'sug-e1', title: 'Optional: Schedule next routine review', description: 'Book follow-up within 6 months.' }, + { id: 'sug-e2', title: 'Optional: Update care plan summary', description: 'Keep documentation in sync with current status.' }, + ], + explanation: { + details: 'The recommendation is shown because de-facto treatment is not fully aligned with de-jure models. The suggested tasks are derived as optional improvements to support ongoing adherence and documentation.', + references: [ + { title: 'Follow-up protocol', url: 'https://example.com/follow-up.pdf' }, + ], + }, + createdAt: new Date().toISOString(), +} + +const adherenceByPatientId: Record = { + [MOCK_PATIENT_A_ID]: 'non_adherent', + [MOCK_PATIENT_B_ID]: 'adherent', + [MOCK_PATIENT_C_ID]: 'adherent', + [MOCK_PATIENT_D_ID]: 'adherent', + [MOCK_PATIENT_E_ID]: 'adherent', +} + +const suggestionByPatientId: Record = { + [MOCK_PATIENT_A_ID]: MOCK_SUGGESTION_FOR_PATIENT_A, + [MOCK_PATIENT_E_ID]: MOCK_SUGGESTION_FOR_PATIENT_E, +} + +export function getAdherenceByPatientId(patientId: string): GuidelineAdherenceStatus { + return adherenceByPatientId[patientId] ?? 'unknown' +} + +export function getSuggestionByPatientId(patientId: string): SystemSuggestion | null { + return suggestionByPatientId[patientId] ?? null +} + +export const DUMMY_SUGGESTION: SystemSuggestion = { + id: 'dummy', + patientId: '', + adherenceStatus: 'unknown', + reasonSummary: '', + suggestedTasks: [], + explanation: { details: '', references: [] }, +} diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index b2195b1e..e288a164 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -14,6 +14,8 @@ import { TasksContextProvider } from '@/hooks/useTasksContext' import { ApolloProviderWithData } from '@/providers/ApolloProviderWithData' import { ConflictProvider } from '@/providers/ConflictProvider' import { SubscriptionProvider } from '@/providers/SubscriptionProvider' +import { SystemSuggestionTasksProvider } from '@/context/SystemSuggestionTasksContext' +import { FeedbackToast } from '@/components/FeedbackToast' import { InstallPrompt } from '@/components/pwa/InstallPrompt' import { registerServiceWorker, requestNotificationPermission } from '@/utils/pushNotifications' import { useEffect } from 'react' @@ -77,10 +79,13 @@ function MyApp({ - - - - + + + + + + + diff --git a/web/types/systemSuggestion.ts b/web/types/systemSuggestion.ts new file mode 100644 index 00000000..f9816650 --- /dev/null +++ b/web/types/systemSuggestion.ts @@ -0,0 +1,39 @@ +export type GuidelineAdherenceStatus = 'adherent' | 'non_adherent' | 'unknown' + +export type SuggestedTaskItem = { + id: string + title: string + description?: string +} + +export type SystemSuggestionExplanation = { + details: string + references: Array<{ title: string; url: string }> +} + +export type SystemSuggestion = { + id: string + patientId: string + adherenceStatus: GuidelineAdherenceStatus + reasonSummary: string + suggestedTasks: SuggestedTaskItem[] + explanation: SystemSuggestionExplanation + createdAt?: string +} + +export type TaskSource = 'manual' | 'systemSuggestion' + +export type MachineGeneratedTask = { + id: string + title: string + description?: string | null + done: boolean + patientId: string + machineGenerated: true + source: 'systemSuggestion' + assignedTo?: 'me' | null + updateDate: Date + dueDate?: Date | null + priority?: string | null + estimatedTime?: number | null +}