From a14534b22e88771b9b7d9c64b366fe1567a34b56 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Sun, 22 Mar 2026 13:12:41 +0100 Subject: [PATCH 1/2] 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 +} From 7b4257438a2d572e8701acee40ad154fb70991ba Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Tue, 7 Apr 2026 20:53:56 +0200 Subject: [PATCH 2/2] add presets --- .github/dependabot.yml | 42 + .github/workflows/backend-tests.yml | 2 +- .github/workflows/build-docker-backend.yml | 4 +- .github/workflows/build-docker-proxy.yml | 4 +- .github/workflows/build-docker-simulator.yml | 4 +- .github/workflows/build-docker-web.yml | 4 +- .github/workflows/build-web.yml | 4 +- .github/workflows/e2e-tests.yml | 4 +- .github/workflows/lint-python.yml | 4 +- .github/workflows/tests.yml | 18 +- backend/README.md | 2 + backend/api/decorators/__init__.py | 14 - backend/api/decorators/filter_sort.py | 727 -------- backend/api/decorators/full_text_search.py | 116 -- backend/api/inputs.py | 160 +- backend/api/query/__init__.py | 0 backend/api/query/adapters/patient.py | 514 ++++++ backend/api/query/adapters/task.py | 620 +++++++ backend/api/query/adapters/user.py | 149 ++ backend/api/query/context.py | 14 + backend/api/query/dedupe_select.py | 14 + backend/api/query/engine.py | 86 + backend/api/query/enums.py | 55 + backend/api/query/execute.py | 91 + backend/api/query/field_ops.py | 211 +++ backend/api/query/graphql_types.py | 46 + backend/api/query/inputs.py | 40 + backend/api/query/metadata_service.py | 279 +++ backend/api/query/patient_location_scope.py | 71 + backend/api/query/property_sql.py | 61 + backend/api/query/registry.py | 38 + backend/api/query/sql_expr.py | 33 + backend/api/resolvers/__init__.py | 8 + backend/api/resolvers/patient.py | 187 +- backend/api/resolvers/query_metadata.py | 14 + backend/api/resolvers/saved_view.py | 205 +++ backend/api/resolvers/task.py | 650 ++++--- backend/api/resolvers/task_preset.py | 248 +++ backend/api/resolvers/user.py | 24 +- backend/api/services/authorization.py | 38 +- backend/api/services/subscription.py | 23 +- backend/api/services/task_graph.py | 255 +++ backend/api/types/saved_view.py | 47 + backend/api/types/task.py | 31 +- backend/api/types/task_preset.py | 78 + backend/api/types/user.py | 51 +- ...ba_merge_location_type_enum_and_remove_.py | 2 - ...438_merge_patient_description_and_task_.py | 2 - .../add_saved_view_related_columns.py | 55 + .../versions/add_saved_views_table.py | 38 + .../add_task_assignees_optional_patient.py | 82 + .../versions/add_task_presets_table.py | 51 + .../versions/add_task_source_task_preset.py | 40 + .../merge_saved_views_and_task_assignees.py | 25 + backend/database/models/__init__.py | 4 +- backend/database/models/patient.py | 1 - backend/database/models/property.py | 2 +- backend/database/models/saved_view.py | 51 + backend/database/models/task.py | 28 +- backend/database/models/task_preset.py | 50 + backend/database/models/user.py | 10 +- backend/requirements.txt | 2 +- backend/schema.graphql | 568 ++++++ backend/tests/unit/test_task_graph.py | 48 + docs/VIEWS_ARCHITECTURE.md | 79 + simulator/requirements.txt | 4 +- tests/package-lock.json | 24 +- tests/package.json | 2 +- web/api/gql/generated.ts | 661 +++++-- web/api/graphql/GetMyTasks.graphql | 2 +- web/api/graphql/GetOverviewData.graphql | 25 +- web/api/graphql/GetPatient.graphql | 3 +- web/api/graphql/GetPatients.graphql | 9 +- web/api/graphql/GetTask.graphql | 3 +- web/api/graphql/GetTasks.graphql | 106 +- web/api/graphql/QueryableFields.graphql | 24 + web/api/graphql/SavedView.graphql | 98 ++ web/api/graphql/TaskMutations.graphql | 39 +- web/api/graphql/TaskPresetMutations.graphql | 49 + web/api/graphql/TaskPresetQueries.graphql | 45 + web/api/mutations/tasks/assignTask.plan.ts | 8 +- .../mutations/tasks/assignTaskToTeam.plan.ts | 2 +- web/api/mutations/tasks/updateTask.plan.ts | 53 +- web/codegen.ts | 12 +- web/components/Date/DateDisplay.tsx | 27 +- web/components/Notifications.tsx | 2 +- web/components/common/ExpandableTextBlock.tsx | 76 + web/components/layout/Page.tsx | 74 +- .../patients/LoadTaskPresetDialog.tsx | 158 ++ web/components/patients/PatientCardView.tsx | 15 +- web/components/patients/PatientDataEditor.tsx | 4 +- web/components/patients/PatientDetailView.tsx | 23 +- web/components/patients/PatientStateChip.tsx | 2 +- web/components/patients/PatientTasksView.tsx | 63 +- .../patients/SystemSuggestionModal.tsx | 100 +- web/components/properties/PropertyCell.tsx | 4 +- .../properties/PropertyDetailView.tsx | 22 +- web/components/properties/PropertyEntry.tsx | 16 +- .../tables/AssigneeFilterActiveLabel.tsx | 85 + web/components/tables/FilterPreviewMedia.tsx | 48 + .../tables/LocationFilterActiveLabel.tsx | 82 + .../tables/LocationSubtreeFilterPopUp.tsx | 187 ++ web/components/tables/PatientList.tsx | 959 +++++++--- web/components/tables/RecentPatientsTable.tsx | 155 -- web/components/tables/RecentTasksTable.tsx | 228 --- web/components/tables/TaskList.tsx | 1054 +++++++---- .../tables/TaskRowRefreshingGate.tsx | 32 + .../tables/UserSelectFilterPopUp.tsx | 184 ++ web/components/tasks/AssigneeSelect.tsx | 24 +- web/components/tasks/AssigneeSelectDialog.tsx | 74 +- web/components/tasks/TaskCardView.tsx | 259 +-- web/components/tasks/TaskDataEditor.tsx | 420 +++-- web/components/tasks/TaskDetailView.tsx | 11 +- .../tasks/TaskPresetSourceDialog.tsx | 76 + .../views/PatientViewTasksPanel.tsx | 376 ++++ web/components/views/SaveViewActionsMenu.tsx | 78 + web/components/views/SaveViewDialog.tsx | 114 ++ .../views/SavedViewEntityTypeChip.tsx | 31 + .../views/TaskViewPatientsPanel.tsx | 134 ++ web/context/SystemSuggestionTasksContext.tsx | 14 +- web/data/cache/policies.ts | 50 +- web/data/hooks/index.ts | 8 + web/data/hooks/queryHelpers.ts | 4 +- web/data/hooks/useApplyTaskGraph.ts | 14 + web/data/hooks/useAssignTask.ts | 20 +- web/data/hooks/useCreateTaskPreset.ts | 14 + web/data/hooks/useDeleteTaskPreset.ts | 14 + web/data/hooks/usePaginatedEntityQuery.ts | 36 +- web/data/hooks/usePatientsPaginated.ts | 14 +- web/data/hooks/useQueryableFields.ts | 14 + web/data/hooks/useSavedViews.ts | 45 + web/data/hooks/useTaskPreset.ts | 17 + web/data/hooks/useTaskPresets.ts | 13 + web/data/hooks/useTasksPaginated.ts | 12 +- web/data/hooks/useUnassignTask.ts | 12 +- web/data/hooks/useUpdateTaskPreset.ts | 14 + web/data/index.ts | 9 + .../useApolloGlobalSubscriptions.ts | 14 +- web/globals.css | 14 +- web/hooks/useAccumulatedPagination.ts | 50 + web/hooks/useAuth.tsx | 2 +- web/hooks/useDeferredColumnOrderChange.ts | 46 + web/hooks/usePropertyColumnVisibility.ts | 64 +- web/hooks/useStableSerializedList.ts | 19 + web/hooks/useTableState.ts | 133 +- web/hooks/useTasksContext.tsx | 2 + web/i18n/translations.ts | 639 ++++++- web/locales/de-DE.arb | 102 +- web/locales/en-US.arb | 104 +- web/locales/es-ES.arb | 86 +- web/locales/fr-FR.arb | 86 +- web/locales/nl-NL.arb | 86 +- web/locales/pt-BR.arb | 86 +- web/package-lock.json | 1562 +++-------------- web/package.json | 18 +- web/pages/auth/callback.tsx | 2 +- web/pages/index.tsx | 97 +- web/pages/location/[id].tsx | 29 +- web/pages/settings/index.tsx | 58 +- web/pages/settings/task-presets.tsx | 722 ++++++++ web/pages/settings/views.tsx | 304 ++++ web/pages/tasks/index.tsx | 181 +- web/pages/view/[uid].tsx | 583 ++++++ web/postcss.config.mjs | 7 +- web/providers/ApolloProviderWithData.tsx | 26 +- web/schema.graphql | 490 ++++++ web/style/colors.css | 4 +- web/style/table-printing.css | 7 +- web/types/systemSuggestion.ts | 48 +- web/utils/columnOrder.ts | 29 + web/utils/hightideDateFormat.ts | 57 + web/utils/listPaging.ts | 1 + ...overviewRecentPatientToPatientViewModel.ts | 26 + .../overviewRecentTaskToTaskViewModel.ts | 51 + web/utils/propertyColumn.tsx | 15 +- web/utils/propertyFilterMapping.ts | 4 +- web/utils/queryableFilterList.tsx | 102 ++ web/utils/savedViewsCache.ts | 43 + web/utils/tableStateToApi.ts | 300 ++-- web/utils/taskGraph.ts | 74 + web/utils/viewDefinition.ts | 218 +++ web/utils/virtualDerivedTableState.ts | 464 +++++ 182 files changed, 15497 insertions(+), 4676 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 backend/api/decorators/filter_sort.py delete mode 100644 backend/api/decorators/full_text_search.py create mode 100644 backend/api/query/__init__.py create mode 100644 backend/api/query/adapters/patient.py create mode 100644 backend/api/query/adapters/task.py create mode 100644 backend/api/query/adapters/user.py create mode 100644 backend/api/query/context.py create mode 100644 backend/api/query/dedupe_select.py create mode 100644 backend/api/query/engine.py create mode 100644 backend/api/query/enums.py create mode 100644 backend/api/query/execute.py create mode 100644 backend/api/query/field_ops.py create mode 100644 backend/api/query/graphql_types.py create mode 100644 backend/api/query/inputs.py create mode 100644 backend/api/query/metadata_service.py create mode 100644 backend/api/query/patient_location_scope.py create mode 100644 backend/api/query/property_sql.py create mode 100644 backend/api/query/registry.py create mode 100644 backend/api/query/sql_expr.py create mode 100644 backend/api/resolvers/query_metadata.py create mode 100644 backend/api/resolvers/saved_view.py create mode 100644 backend/api/resolvers/task_preset.py create mode 100644 backend/api/services/task_graph.py create mode 100644 backend/api/types/saved_view.py create mode 100644 backend/api/types/task_preset.py create mode 100644 backend/database/migrations/versions/add_saved_view_related_columns.py create mode 100644 backend/database/migrations/versions/add_saved_views_table.py create mode 100644 backend/database/migrations/versions/add_task_assignees_optional_patient.py create mode 100644 backend/database/migrations/versions/add_task_presets_table.py create mode 100644 backend/database/migrations/versions/add_task_source_task_preset.py create mode 100644 backend/database/migrations/versions/merge_saved_views_and_task_assignees.py create mode 100644 backend/database/models/saved_view.py create mode 100644 backend/database/models/task_preset.py create mode 100644 backend/schema.graphql create mode 100644 backend/tests/unit/test_task_graph.py create mode 100644 docs/VIEWS_ARCHITECTURE.md create mode 100644 web/api/graphql/QueryableFields.graphql create mode 100644 web/api/graphql/SavedView.graphql create mode 100644 web/api/graphql/TaskPresetMutations.graphql create mode 100644 web/api/graphql/TaskPresetQueries.graphql create mode 100644 web/components/common/ExpandableTextBlock.tsx create mode 100644 web/components/patients/LoadTaskPresetDialog.tsx create mode 100644 web/components/tables/AssigneeFilterActiveLabel.tsx create mode 100644 web/components/tables/FilterPreviewMedia.tsx create mode 100644 web/components/tables/LocationFilterActiveLabel.tsx create mode 100644 web/components/tables/LocationSubtreeFilterPopUp.tsx delete mode 100644 web/components/tables/RecentPatientsTable.tsx delete mode 100644 web/components/tables/RecentTasksTable.tsx create mode 100644 web/components/tables/TaskRowRefreshingGate.tsx create mode 100644 web/components/tables/UserSelectFilterPopUp.tsx create mode 100644 web/components/tasks/TaskPresetSourceDialog.tsx create mode 100644 web/components/views/PatientViewTasksPanel.tsx create mode 100644 web/components/views/SaveViewActionsMenu.tsx create mode 100644 web/components/views/SaveViewDialog.tsx create mode 100644 web/components/views/SavedViewEntityTypeChip.tsx create mode 100644 web/components/views/TaskViewPatientsPanel.tsx create mode 100644 web/data/hooks/useApplyTaskGraph.ts create mode 100644 web/data/hooks/useCreateTaskPreset.ts create mode 100644 web/data/hooks/useDeleteTaskPreset.ts create mode 100644 web/data/hooks/useQueryableFields.ts create mode 100644 web/data/hooks/useSavedViews.ts create mode 100644 web/data/hooks/useTaskPreset.ts create mode 100644 web/data/hooks/useTaskPresets.ts create mode 100644 web/data/hooks/useUpdateTaskPreset.ts create mode 100644 web/hooks/useAccumulatedPagination.ts create mode 100644 web/hooks/useDeferredColumnOrderChange.ts create mode 100644 web/hooks/useStableSerializedList.ts create mode 100644 web/pages/settings/task-presets.tsx create mode 100644 web/pages/settings/views.tsx create mode 100644 web/pages/view/[uid].tsx create mode 100644 web/schema.graphql create mode 100644 web/utils/columnOrder.ts create mode 100644 web/utils/hightideDateFormat.ts create mode 100644 web/utils/listPaging.ts create mode 100644 web/utils/overviewRecentPatientToPatientViewModel.ts create mode 100644 web/utils/overviewRecentTaskToTaskViewModel.ts create mode 100644 web/utils/queryableFilterList.tsx create mode 100644 web/utils/savedViewsCache.ts create mode 100644 web/utils/taskGraph.ts create mode 100644 web/utils/viewDefinition.ts create mode 100644 web/utils/virtualDerivedTableState.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a2c11d2e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,42 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: /web + schedule: + interval: weekly + groups: + web: + patterns: + - "*" + + - package-ecosystem: npm + directory: /tests + schedule: + interval: weekly + groups: + e2e: + patterns: + - "*" + + - package-ecosystem: pip + directory: /backend + schedule: + interval: weekly + groups: + backend: + patterns: + - "*" + + - package-ecosystem: pip + directory: /simulator + schedule: + interval: weekly + groups: + simulator: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 786d2e2c..cfa04a28 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -41,7 +41,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' - name: Install dependencies diff --git a/.github/workflows/build-docker-backend.yml b/.github/workflows/build-docker-backend.yml index 78534eb7..e5050659 100644 --- a/.github/workflows/build-docker-backend.yml +++ b/.github/workflows/build-docker-backend.yml @@ -31,7 +31,7 @@ jobs: - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -39,7 +39,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: backend push: true diff --git a/.github/workflows/build-docker-proxy.yml b/.github/workflows/build-docker-proxy.yml index 054d44dc..9d0b1c61 100644 --- a/.github/workflows/build-docker-proxy.yml +++ b/.github/workflows/build-docker-proxy.yml @@ -31,7 +31,7 @@ jobs: - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -39,7 +39,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: proxy push: true diff --git a/.github/workflows/build-docker-simulator.yml b/.github/workflows/build-docker-simulator.yml index 410d4e3a..6872b8a4 100644 --- a/.github/workflows/build-docker-simulator.yml +++ b/.github/workflows/build-docker-simulator.yml @@ -31,7 +31,7 @@ jobs: - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -39,7 +39,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: simulator push: true diff --git a/.github/workflows/build-docker-web.yml b/.github/workflows/build-docker-web.yml index 3dbbbed9..5dd1360b 100644 --- a/.github/workflows/build-docker-web.yml +++ b/.github/workflows/build-docker-web.yml @@ -31,7 +31,7 @@ jobs: - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -39,7 +39,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: web push: true diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 7dbc157f..049b44bf 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -31,7 +31,7 @@ jobs: scope: "@helpwave" - name: Setup npm cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-cache-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }} @@ -45,7 +45,7 @@ jobs: run: npm run build - name: Upload Next.js build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: next-build path: web/.next diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index ce763aa9..1a1bba6d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -40,7 +40,7 @@ jobs: with: node-version: '20' - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' - name: Install backend dependencies @@ -105,7 +105,7 @@ jobs: CI: true - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report path: playwright-report/ diff --git a/.github/workflows/lint-python.yml b/.github/workflows/lint-python.yml index 8054ab16..87ab6acd 100644 --- a/.github/workflows/lint-python.yml +++ b/.github/workflows/lint-python.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -31,7 +31,7 @@ jobs: run: pip install flake8 - name: Run flake8 on backend - run: flake8 backend --ignore=E501,W503 --exclude=venv,__pycache__,migrations + run: flake8 backend --ignore=E501,W503 --exclude=venv,test_venv,test_env,__pycache__,migrations - name: Run flake8 on simulator run: flake8 simulator --ignore=E501,W503 --exclude=venv,__pycache__ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 30d6473c..89b9d7cc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -107,12 +107,12 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Cache pip packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('backend/requirements.txt') }} @@ -197,7 +197,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -345,7 +345,7 @@ jobs: - name: Upload Playwright report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report path: tests/playwright-report/ @@ -353,7 +353,7 @@ jobs: - name: Upload server logs if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: server-logs path: | @@ -368,7 +368,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -395,7 +395,7 @@ jobs: - name: Upload build artifacts if: success() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: frontend-build path: web/build diff --git a/backend/README.md b/backend/README.md index a35edd61..4c416449 100644 --- a/backend/README.md +++ b/backend/README.md @@ -72,6 +72,8 @@ alembic downgrade -1 alembic revision --autogenerate -m "description" ``` +If `alembic heads` shows more than one head, run `alembic upgrade head` after pulling so merge revisions are applied. After upgrades that add tables (`task_assignees`, etc.), `alembic upgrade head` must succeed before the API can query those tables. + ## Docker The backend is containerized and available as: diff --git a/backend/api/decorators/__init__.py b/backend/api/decorators/__init__.py index ef535125..3090f26e 100644 --- a/backend/api/decorators/__init__.py +++ b/backend/api/decorators/__init__.py @@ -1,20 +1,6 @@ -from api.decorators.filter_sort import ( - apply_filtering, - apply_sorting, - filtered_and_sorted_query, -) -from api.decorators.full_text_search import ( - apply_full_text_search, - full_text_search_query, -) from api.decorators.pagination import apply_pagination, paginated_query __all__ = [ "apply_pagination", "paginated_query", - "apply_sorting", - "apply_filtering", - "filtered_and_sorted_query", - "apply_full_text_search", - "full_text_search_query", ] diff --git a/backend/api/decorators/filter_sort.py b/backend/api/decorators/filter_sort.py deleted file mode 100644 index 7545f8ad..00000000 --- a/backend/api/decorators/filter_sort.py +++ /dev/null @@ -1,727 +0,0 @@ -from datetime import date as date_type -from functools import wraps -from typing import Any, Callable, TypeVar - -import strawberry -from api.decorators.pagination import apply_pagination -from api.inputs import ( - ColumnType, - FilterInput, - FilterOperator, - PaginationInput, - SortDirection, - SortInput, -) -from database import models -from database.models.base import Base -from sqlalchemy import Select, and_, func, or_, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import aliased - -T = TypeVar("T") - - -async def get_property_field_types( - db: AsyncSession, - filtering: list[FilterInput] | None, - sorting: list[SortInput] | None, -) -> dict[str, str]: - property_def_ids: set[str] = set() - if filtering: - for f in filtering: - if ( - f.column_type == ColumnType.PROPERTY - and f.property_definition_id - ): - property_def_ids.add(f.property_definition_id) - if sorting: - for s in sorting: - if ( - s.column_type == ColumnType.PROPERTY - and s.property_definition_id - ): - property_def_ids.add(s.property_definition_id) - if not property_def_ids: - return {} - result = await db.execute( - select(models.PropertyDefinition).where( - models.PropertyDefinition.id.in_(property_def_ids) - ) - ) - prop_defs = result.scalars().all() - return {str(p.id): p.field_type for p in prop_defs} - - -def detect_entity_type(model_class: type[Base]) -> str | None: - if model_class == models.Patient: - return "patient" - if model_class == models.Task: - return "task" - return None - - -def get_property_value_column(field_type: str) -> str: - field_type_mapping = { - "FIELD_TYPE_TEXT": "text_value", - "FIELD_TYPE_NUMBER": "number_value", - "FIELD_TYPE_CHECKBOX": "boolean_value", - "FIELD_TYPE_DATE": "date_value", - "FIELD_TYPE_DATE_TIME": "date_time_value", - "FIELD_TYPE_SELECT": "select_value", - "FIELD_TYPE_MULTI_SELECT": "multi_select_values", - "FIELD_TYPE_USER": "user_value", - } - return field_type_mapping.get(field_type, "text_value") - - -def get_property_join_alias( - query: Select[Any], - model_class: type[Base], - property_definition_id: str, - field_type: str, -) -> Any: - entity_type = detect_entity_type(model_class) - if not entity_type: - raise ValueError( - f"Unsupported entity type for property filtering: {model_class}" - ) - - property_alias = aliased(models.PropertyValue) - value_column = get_property_value_column(field_type) - - if entity_type == "patient": - join_condition = and_( - property_alias.patient_id == model_class.id, - property_alias.definition_id == property_definition_id, - ) - else: - join_condition = and_( - property_alias.task_id == model_class.id, - property_alias.definition_id == property_definition_id, - ) - - query = query.outerjoin(property_alias, join_condition) - return query, property_alias, getattr(property_alias, value_column) - - -def apply_sorting( - query: Select[Any], - sorting: list[SortInput] | None, - model_class: type[Base], - property_field_types: dict[str, str] | None = None, -) -> Select[Any]: - if not sorting: - return query - - order_by_clauses = [] - property_field_types = property_field_types or {} - - for sort_input in sorting: - if sort_input.column_type == ColumnType.DIRECT_ATTRIBUTE: - try: - column = getattr(model_class, sort_input.column) - if sort_input.direction == SortDirection.DESC: - order_by_clauses.append(column.desc()) - else: - order_by_clauses.append(column.asc()) - except AttributeError: - continue - - elif sort_input.column_type == ColumnType.PROPERTY: - if not sort_input.property_definition_id: - continue - - field_type = property_field_types.get( - sort_input.property_definition_id, - "FIELD_TYPE_TEXT" - ) - query, property_alias, value_column = ( - get_property_join_alias( - query, - model_class, - sort_input.property_definition_id, - field_type, - ) - ) - - if sort_input.direction == SortDirection.DESC: - order_by_clauses.append(value_column.desc().nulls_last()) - else: - order_by_clauses.append(value_column.asc().nulls_first()) - - if order_by_clauses: - query = query.order_by(*order_by_clauses) - - return query - - -def apply_text_filter( - column: Any, operator: FilterOperator, parameter: Any -) -> Any: - search_text = parameter.search_text - if search_text is None: - return None - - is_case_sensitive = parameter.is_case_sensitive - - if is_case_sensitive: - if operator == FilterOperator.TEXT_EQUALS: - return column.like(search_text) - if operator == FilterOperator.TEXT_NOT_EQUALS: - return ~column.like(search_text) - if operator == FilterOperator.TEXT_NOT_WHITESPACE: - return func.trim(column) != "" - if operator == FilterOperator.TEXT_CONTAINS: - return column.like(f"%{search_text}%") - if operator == FilterOperator.TEXT_NOT_CONTAINS: - return ~column.like(f"%{search_text}%") - if operator == FilterOperator.TEXT_STARTS_WITH: - return column.like(f"{search_text}%") - if operator == FilterOperator.TEXT_ENDS_WITH: - return column.like(f"%{search_text}") - else: - if operator == FilterOperator.TEXT_EQUALS: - return column.ilike(search_text) - if operator == FilterOperator.TEXT_NOT_EQUALS: - return ~column.ilike(search_text) - if operator == FilterOperator.TEXT_NOT_WHITESPACE: - return func.trim(column) != "" - if operator == FilterOperator.TEXT_CONTAINS: - return column.ilike(f"%{search_text}%") - if operator == FilterOperator.TEXT_NOT_CONTAINS: - return ~column.ilike(f"%{search_text}%") - if operator == FilterOperator.TEXT_STARTS_WITH: - return column.ilike(f"{search_text}%") - if operator == FilterOperator.TEXT_ENDS_WITH: - return column.ilike(f"%{search_text}") - - return None - - -def apply_number_filter( - column: Any, operator: FilterOperator, parameter: Any -) -> Any: - compare_value = parameter.compare_value - min_value = parameter.min - max_value = parameter.max - - if operator == FilterOperator.NUMBER_EQUALS: - if compare_value is not None: - return column == compare_value - elif operator == FilterOperator.NUMBER_NOT_EQUALS: - if compare_value is not None: - return column != compare_value - elif operator == FilterOperator.NUMBER_GREATER_THAN: - if compare_value is not None: - return column > compare_value - elif operator == FilterOperator.NUMBER_GREATER_THAN_OR_EQUAL: - if compare_value is not None: - return column >= compare_value - elif operator == FilterOperator.NUMBER_LESS_THAN: - if compare_value is not None: - return column < compare_value - elif operator == FilterOperator.NUMBER_LESS_THAN_OR_EQUAL: - if compare_value is not None: - return column <= compare_value - elif operator == FilterOperator.NUMBER_BETWEEN: - if min_value is not None and max_value is not None: - return column.between(min_value, max_value) - elif operator == FilterOperator.NUMBER_NOT_BETWEEN: - if min_value is not None and max_value is not None: - return ~column.between(min_value, max_value) - - return None - - -def normalize_date_for_comparison(date_value: Any) -> Any: - return date_value - - -def apply_date_filter( - column: Any, operator: FilterOperator, parameter: Any -) -> Any: - compare_date = parameter.compare_date - min_date = parameter.min_date - max_date = parameter.max_date - - if operator == FilterOperator.DATE_EQUALS: - if compare_date is not None: - if isinstance(compare_date, date_type): - return func.date(column) == compare_date - return column == compare_date - elif operator == FilterOperator.DATE_NOT_EQUALS: - if compare_date is not None: - if isinstance(compare_date, date_type): - return func.date(column) != compare_date - return column != compare_date - elif operator == FilterOperator.DATE_GREATER_THAN: - if compare_date is not None: - if isinstance(compare_date, date_type): - return func.date(column) > compare_date - return column > compare_date - elif operator == FilterOperator.DATE_GREATER_THAN_OR_EQUAL: - if compare_date is not None: - if isinstance(compare_date, date_type): - return func.date(column) >= compare_date - return column >= compare_date - elif operator == FilterOperator.DATE_LESS_THAN: - if compare_date is not None: - if isinstance(compare_date, date_type): - return func.date(column) < compare_date - return column < compare_date - elif operator == FilterOperator.DATE_LESS_THAN_OR_EQUAL: - if compare_date is not None: - if isinstance(compare_date, date_type): - return func.date(column) <= compare_date - return column <= compare_date - elif operator == FilterOperator.DATE_BETWEEN: - if min_date is not None and max_date is not None: - if isinstance(min_date, date_type) and isinstance(max_date, date_type): - return func.date(column).between(min_date, max_date) - return column.between(min_date, max_date) - elif operator == FilterOperator.DATE_NOT_BETWEEN: - if min_date is not None and max_date is not None: - if isinstance(min_date, date_type) and isinstance(max_date, date_type): - return ~func.date(column).between(min_date, max_date) - return ~column.between(min_date, max_date) - - return None - - -def apply_datetime_filter( - column: Any, operator: FilterOperator, parameter: Any -) -> Any: - compare_date_time = parameter.compare_date_time - min_date_time = parameter.min_date_time - max_date_time = parameter.max_date_time - - if operator == FilterOperator.DATETIME_EQUALS: - if compare_date_time is not None: - return column == compare_date_time - elif operator == FilterOperator.DATETIME_NOT_EQUALS: - if compare_date_time is not None: - return column != compare_date_time - elif operator == FilterOperator.DATETIME_GREATER_THAN: - if compare_date_time is not None: - return column > compare_date_time - elif operator == FilterOperator.DATETIME_GREATER_THAN_OR_EQUAL: - if compare_date_time is not None: - return column >= compare_date_time - elif operator == FilterOperator.DATETIME_LESS_THAN: - if compare_date_time is not None: - return column < compare_date_time - elif operator == FilterOperator.DATETIME_LESS_THAN_OR_EQUAL: - if compare_date_time is not None: - return column <= compare_date_time - elif operator == FilterOperator.DATETIME_BETWEEN: - if min_date_time is not None and max_date_time is not None: - return column.between(min_date_time, max_date_time) - elif operator == FilterOperator.DATETIME_NOT_BETWEEN: - if min_date_time is not None and max_date_time is not None: - return ~column.between(min_date_time, max_date_time) - - return None - - -def apply_boolean_filter( - column: Any, operator: FilterOperator, parameter: Any -) -> Any: - if operator == FilterOperator.BOOLEAN_IS_TRUE: - return column.is_(True) - if operator == FilterOperator.BOOLEAN_IS_FALSE: - return column.is_(False) - return None - - -def apply_tags_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: - search_tags = parameter.search_tags - if not search_tags: - return None - - if operator == FilterOperator.TAGS_EQUALS: - tags_str = ",".join(sorted(search_tags)) - return column == tags_str - if operator == FilterOperator.TAGS_NOT_EQUALS: - tags_str = ",".join(sorted(search_tags)) - return column != tags_str - if operator == FilterOperator.TAGS_CONTAINS: - conditions = [] - for tag in search_tags: - conditions.append(column.contains(tag)) - return or_(*conditions) - if operator == FilterOperator.TAGS_NOT_CONTAINS: - conditions = [] - for tag in search_tags: - conditions.append(~column.contains(tag)) - return and_(*conditions) - - return None - - -def apply_tags_single_filter( - column: Any, operator: FilterOperator, parameter: Any -) -> Any: - search_tags = parameter.search_tags - if not search_tags: - return None - - if operator == FilterOperator.TAGS_SINGLE_EQUALS: - if len(search_tags) == 1: - return column == search_tags[0] - if operator == FilterOperator.TAGS_SINGLE_NOT_EQUALS: - if len(search_tags) == 1: - return column != search_tags[0] - if operator == FilterOperator.TAGS_SINGLE_CONTAINS: - conditions = [] - for tag in search_tags: - conditions.append(column == tag) - return or_(*conditions) - if operator == FilterOperator.TAGS_SINGLE_NOT_CONTAINS: - conditions = [] - for tag in search_tags: - conditions.append(column != tag) - return and_(*conditions) - - return None - - -def apply_null_filter( - column: Any, operator: FilterOperator, parameter: Any -) -> Any: - if operator == FilterOperator.IS_NULL: - return column.is_(None) - if operator == FilterOperator.IS_NOT_NULL: - return column.isnot(None) - return None - - -def apply_filtering( - query: Select[Any], - filtering: list[FilterInput] | None, - model_class: type[Base], - property_field_types: dict[str, str] | None = None, -) -> Select[Any]: - if not filtering: - return query - - filter_conditions = [] - property_field_types = property_field_types or {} - - for filter_input in filtering: - condition = None - - if filter_input.column_type == ColumnType.DIRECT_ATTRIBUTE: - try: - column = getattr(model_class, filter_input.column) - except AttributeError: - continue - - operator = filter_input.operator - parameter = filter_input.parameter - - if operator in [ - FilterOperator.TEXT_EQUALS, - FilterOperator.TEXT_NOT_EQUALS, - FilterOperator.TEXT_NOT_WHITESPACE, - FilterOperator.TEXT_CONTAINS, - FilterOperator.TEXT_NOT_CONTAINS, - FilterOperator.TEXT_STARTS_WITH, - FilterOperator.TEXT_ENDS_WITH, - ]: - condition = apply_text_filter(column, operator, parameter) - - elif operator in [ - FilterOperator.NUMBER_EQUALS, - FilterOperator.NUMBER_NOT_EQUALS, - FilterOperator.NUMBER_GREATER_THAN, - FilterOperator.NUMBER_GREATER_THAN_OR_EQUAL, - FilterOperator.NUMBER_LESS_THAN, - FilterOperator.NUMBER_LESS_THAN_OR_EQUAL, - FilterOperator.NUMBER_BETWEEN, - FilterOperator.NUMBER_NOT_BETWEEN, - ]: - condition = apply_number_filter(column, operator, parameter) - - elif operator in [ - FilterOperator.DATE_EQUALS, - FilterOperator.DATE_NOT_EQUALS, - FilterOperator.DATE_GREATER_THAN, - FilterOperator.DATE_GREATER_THAN_OR_EQUAL, - FilterOperator.DATE_LESS_THAN, - FilterOperator.DATE_LESS_THAN_OR_EQUAL, - FilterOperator.DATE_BETWEEN, - FilterOperator.DATE_NOT_BETWEEN, - ]: - condition = apply_date_filter(column, operator, parameter) - - elif operator in [ - FilterOperator.DATETIME_EQUALS, - FilterOperator.DATETIME_NOT_EQUALS, - FilterOperator.DATETIME_GREATER_THAN, - FilterOperator.DATETIME_GREATER_THAN_OR_EQUAL, - FilterOperator.DATETIME_LESS_THAN, - FilterOperator.DATETIME_LESS_THAN_OR_EQUAL, - FilterOperator.DATETIME_BETWEEN, - FilterOperator.DATETIME_NOT_BETWEEN, - ]: - condition = apply_datetime_filter(column, operator, parameter) - - elif operator in [ - FilterOperator.BOOLEAN_IS_TRUE, - FilterOperator.BOOLEAN_IS_FALSE, - ]: - condition = apply_boolean_filter(column, operator, parameter) - - elif operator in [ - FilterOperator.IS_NULL, - FilterOperator.IS_NOT_NULL, - ]: - condition = apply_null_filter(column, operator, parameter) - - elif filter_input.column_type == ColumnType.PROPERTY: - if not filter_input.property_definition_id: - continue - - field_type = property_field_types.get( - filter_input.property_definition_id, - "FIELD_TYPE_TEXT" - ) - query, property_alias, value_column = get_property_join_alias( - query, model_class, filter_input.property_definition_id, field_type - ) - - operator = filter_input.operator - parameter = filter_input.parameter - - if operator in [ - FilterOperator.TEXT_EQUALS, - FilterOperator.TEXT_NOT_EQUALS, - FilterOperator.TEXT_NOT_WHITESPACE, - FilterOperator.TEXT_CONTAINS, - FilterOperator.TEXT_NOT_CONTAINS, - FilterOperator.TEXT_STARTS_WITH, - FilterOperator.TEXT_ENDS_WITH, - ]: - condition = apply_text_filter( - value_column, operator, parameter - ) - - elif operator in [ - FilterOperator.NUMBER_EQUALS, - FilterOperator.NUMBER_NOT_EQUALS, - FilterOperator.NUMBER_GREATER_THAN, - FilterOperator.NUMBER_GREATER_THAN_OR_EQUAL, - FilterOperator.NUMBER_LESS_THAN, - FilterOperator.NUMBER_LESS_THAN_OR_EQUAL, - FilterOperator.NUMBER_BETWEEN, - FilterOperator.NUMBER_NOT_BETWEEN, - ]: - condition = apply_number_filter(value_column, operator, parameter) - - elif operator in [ - FilterOperator.DATE_EQUALS, - FilterOperator.DATE_NOT_EQUALS, - FilterOperator.DATE_GREATER_THAN, - FilterOperator.DATE_GREATER_THAN_OR_EQUAL, - FilterOperator.DATE_LESS_THAN, - FilterOperator.DATE_LESS_THAN_OR_EQUAL, - FilterOperator.DATE_BETWEEN, - FilterOperator.DATE_NOT_BETWEEN, - ]: - condition = apply_date_filter( - value_column, operator, parameter - ) - - elif operator in [ - FilterOperator.DATETIME_EQUALS, - FilterOperator.DATETIME_NOT_EQUALS, - FilterOperator.DATETIME_GREATER_THAN, - FilterOperator.DATETIME_GREATER_THAN_OR_EQUAL, - FilterOperator.DATETIME_LESS_THAN, - FilterOperator.DATETIME_LESS_THAN_OR_EQUAL, - FilterOperator.DATETIME_BETWEEN, - FilterOperator.DATETIME_NOT_BETWEEN, - ]: - condition = apply_datetime_filter(value_column, operator, parameter) - - elif operator in [ - FilterOperator.BOOLEAN_IS_TRUE, - FilterOperator.BOOLEAN_IS_FALSE, - ]: - condition = apply_boolean_filter( - value_column, operator, parameter - ) - - elif operator in [ - FilterOperator.TAGS_EQUALS, - FilterOperator.TAGS_NOT_EQUALS, - FilterOperator.TAGS_CONTAINS, - FilterOperator.TAGS_NOT_CONTAINS, - ]: - condition = apply_tags_filter( - value_column, operator, parameter - ) - - elif operator in [ - FilterOperator.TAGS_SINGLE_EQUALS, - FilterOperator.TAGS_SINGLE_NOT_EQUALS, - FilterOperator.TAGS_SINGLE_CONTAINS, - FilterOperator.TAGS_SINGLE_NOT_CONTAINS, - ]: - condition = apply_tags_single_filter(value_column, operator, parameter) - - elif operator in [ - FilterOperator.IS_NULL, - FilterOperator.IS_NOT_NULL, - ]: - condition = apply_null_filter( - value_column, operator, parameter - ) - - if condition is not None: - filter_conditions.append(condition) - - if filter_conditions: - query = query.where(and_(*filter_conditions)) - - return query - - -def filtered_and_sorted_query( - filtering_param: str = "filtering", - sorting_param: str = "sorting", - pagination_param: str = "pagination", -): - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - filtering: list[FilterInput] | None = kwargs.get(filtering_param) - sorting: list[SortInput] | None = kwargs.get(sorting_param) - pagination: PaginationInput | None = kwargs.get(pagination_param) - - result = await func(*args, **kwargs) - - if not isinstance(result, Select): - return result - - model_class = result.column_descriptions[0]["entity"] - if not model_class: - if isinstance(result, Select): - for arg in args: - if ( - hasattr(arg, "context") - and hasattr(arg.context, "db") - ): - db = arg.context.db - query_result = await db.execute(result) - return query_result.scalars().all() - else: - info = kwargs.get("info") - if ( - info - and hasattr(info, "context") - and hasattr(info.context, "db") - ): - db = info.context.db - query_result = await db.execute(result) - return query_result.scalars().all() - return result - - property_field_types: dict[str, str] = {} - - if filtering or sorting: - property_def_ids = set() - if filtering: - for f in filtering: - if ( - f.column_type == ColumnType.PROPERTY - and f.property_definition_id - ): - property_def_ids.add(f.property_definition_id) - if sorting: - for s in sorting: - if ( - s.column_type == ColumnType.PROPERTY - and s.property_definition_id - ): - property_def_ids.add(s.property_definition_id) - - if property_def_ids: - for arg in args: - if ( - hasattr(arg, "context") - and hasattr(arg.context, "db") - ): - db = arg.context.db - prop_defs_result = await db.execute( - select(models.PropertyDefinition).where( - models.PropertyDefinition.id.in_(property_def_ids) - ) - ) - prop_defs = prop_defs_result.scalars().all() - property_field_types = { - str(prop_def.id): prop_def.field_type - for prop_def in prop_defs - } - break - else: - info = kwargs.get("info") - if ( - info - and hasattr(info, "context") - and hasattr(info.context, "db") - ): - db = info.context.db - prop_defs_result = await db.execute( - select(models.PropertyDefinition).where( - models.PropertyDefinition.id.in_(property_def_ids) - ) - ) - prop_defs = prop_defs_result.scalars().all() - property_field_types = { - str(prop_def.id): prop_def.field_type - for prop_def in prop_defs - } - - if filtering: - result = apply_filtering( - result, filtering, model_class, property_field_types - ) - - if sorting: - result = apply_sorting( - result, sorting, model_class, property_field_types - ) - - if pagination and pagination is not strawberry.UNSET: - page_index = pagination.page_index - page_size = pagination.page_size - if page_size: - offset = page_index * page_size - result = apply_pagination(result, limit=page_size, offset=offset) - - if isinstance(result, Select): - for arg in args: - if ( - hasattr(arg, "context") - and hasattr(arg.context, "db") - ): - db = arg.context.db - query_result = await db.execute(result) - return query_result.scalars().all() - else: - info = kwargs.get("info") - if ( - info - and hasattr(info, "context") - and hasattr(info.context, "db") - ): - db = info.context.db - query_result = await db.execute(result) - return query_result.scalars().all() - - return result - - return wrapper - - return decorator diff --git a/backend/api/decorators/full_text_search.py b/backend/api/decorators/full_text_search.py deleted file mode 100644 index b251996d..00000000 --- a/backend/api/decorators/full_text_search.py +++ /dev/null @@ -1,116 +0,0 @@ -from functools import wraps -from typing import Any, Callable, TypeVar - -import strawberry -from api.inputs import FullTextSearchInput -from database import models -from database.models.base import Base -from sqlalchemy import Select, String, and_, inspect, or_ -from sqlalchemy.orm import aliased - -T = TypeVar("T") - - -def detect_entity_type(model_class: type[Base]) -> str | None: - if model_class == models.Patient: - return "patient" - if model_class == models.Task: - return "task" - return None - - -def get_text_columns_from_model(model_class: type[Base]) -> list[str]: - mapper = inspect(model_class) - text_columns = [] - for column in mapper.columns: - if isinstance(column.type, String): - text_columns.append(column.key) - return text_columns - - -def apply_full_text_search( - query: Select[Any], - search_input: FullTextSearchInput, - model_class: type[Base], -) -> Select[Any]: - if not search_input.search_text or not search_input.search_text.strip(): - return query - - search_text = search_input.search_text.strip() - search_pattern = f"%{search_text}%" - - search_conditions = [] - - columns_to_search = search_input.search_columns - if columns_to_search is None: - columns_to_search = get_text_columns_from_model(model_class) - - for column_name in columns_to_search: - try: - column = getattr(model_class, column_name) - search_conditions.append(column.ilike(search_pattern)) - except AttributeError: - continue - - if search_input.include_properties: - entity_type = detect_entity_type(model_class) - if entity_type: - property_alias = aliased(models.PropertyValue) - - if entity_type == "patient": - join_condition = property_alias.patient_id == model_class.id - else: - join_condition = property_alias.task_id == model_class.id - - if search_input.property_definition_ids: - property_filter = and_( - property_alias.text_value.ilike(search_pattern), - property_alias.definition_id.in_( - search_input.property_definition_ids - ), - ) - else: - property_filter = ( - property_alias.text_value.ilike(search_pattern) - ) - - query = query.outerjoin(property_alias, join_condition) - search_conditions.append(property_filter) - - if not search_conditions: - return query - - combined_condition = or_(*search_conditions) - query = query.where(combined_condition) - - if search_input.include_properties: - query = query.distinct() - - return query - - -def full_text_search_query(search_param: str = "search"): - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - search_input: FullTextSearchInput | None = kwargs.get(search_param) - - result = await func(*args, **kwargs) - - if not isinstance(result, Select): - return result - - if not search_input or search_input is strawberry.UNSET: - return result - - model_class = result.column_descriptions[0]["entity"] - if not model_class: - return result - - result = apply_full_text_search(result, search_input, model_class) - - return result - - return wrapper - - return decorator diff --git a/backend/api/inputs.py b/backend/api/inputs.py index 204a80fc..098a715e 100644 --- a/backend/api/inputs.py +++ b/backend/api/inputs.py @@ -110,10 +110,10 @@ class UpdatePatientInput: @strawberry.input class CreateTaskInput: title: str - patient_id: strawberry.ID + patient_id: strawberry.ID | None = None description: str | None = None due_date: datetime | None = None - assignee_id: strawberry.ID | None = None + assignee_ids: list[strawberry.ID] | None = None assignee_team_id: strawberry.ID | None = None previous_task_ids: list[strawberry.ID] | None = None properties: list[PropertyValueInput] | None = None @@ -124,10 +124,11 @@ class CreateTaskInput: @strawberry.input class UpdateTaskInput: title: str | None = None + patient_id: strawberry.ID | None = strawberry.UNSET description: str | None = None done: bool | None = None due_date: datetime | None = strawberry.UNSET - assignee_id: strawberry.ID | None = None + assignee_ids: list[strawberry.ID] | None = strawberry.UNSET assignee_team_id: strawberry.ID | None = strawberry.UNSET previous_task_ids: list[strawberry.ID] | None = None properties: list[PropertyValueInput] | None = None @@ -180,109 +181,94 @@ class SortDirection(Enum): DESC = "DESC" +@strawberry.input +class PaginationInput: + page_index: int = 0 + page_size: int | None = None + + @strawberry.enum -class FilterOperator(Enum): - TEXT_EQUALS = "TEXT_EQUALS" - TEXT_NOT_EQUALS = "TEXT_NOT_EQUALS" - TEXT_NOT_WHITESPACE = "TEXT_NOT_WHITESPACE" - TEXT_CONTAINS = "TEXT_CONTAINS" - TEXT_NOT_CONTAINS = "TEXT_NOT_CONTAINS" - TEXT_STARTS_WITH = "TEXT_STARTS_WITH" - TEXT_ENDS_WITH = "TEXT_ENDS_WITH" - NUMBER_EQUALS = "NUMBER_EQUALS" - NUMBER_NOT_EQUALS = "NUMBER_NOT_EQUALS" - NUMBER_GREATER_THAN = "NUMBER_GREATER_THAN" - NUMBER_GREATER_THAN_OR_EQUAL = "NUMBER_GREATER_THAN_OR_EQUAL" - NUMBER_LESS_THAN = "NUMBER_LESS_THAN" - NUMBER_LESS_THAN_OR_EQUAL = "NUMBER_LESS_THAN_OR_EQUAL" - NUMBER_BETWEEN = "NUMBER_BETWEEN" - NUMBER_NOT_BETWEEN = "NUMBER_NOT_BETWEEN" - DATE_EQUALS = "DATE_EQUALS" - DATE_NOT_EQUALS = "DATE_NOT_EQUALS" - DATE_GREATER_THAN = "DATE_GREATER_THAN" - DATE_GREATER_THAN_OR_EQUAL = "DATE_GREATER_THAN_OR_EQUAL" - DATE_LESS_THAN = "DATE_LESS_THAN" - DATE_LESS_THAN_OR_EQUAL = "DATE_LESS_THAN_OR_EQUAL" - DATE_BETWEEN = "DATE_BETWEEN" - DATE_NOT_BETWEEN = "DATE_NOT_BETWEEN" - DATETIME_EQUALS = "DATETIME_EQUALS" - DATETIME_NOT_EQUALS = "DATETIME_NOT_EQUALS" - DATETIME_GREATER_THAN = "DATETIME_GREATER_THAN" - DATETIME_GREATER_THAN_OR_EQUAL = "DATETIME_GREATER_THAN_OR_EQUAL" - DATETIME_LESS_THAN = "DATETIME_LESS_THAN" - DATETIME_LESS_THAN_OR_EQUAL = "DATETIME_LESS_THAN_OR_EQUAL" - DATETIME_BETWEEN = "DATETIME_BETWEEN" - DATETIME_NOT_BETWEEN = "DATETIME_NOT_BETWEEN" - BOOLEAN_IS_TRUE = "BOOLEAN_IS_TRUE" - BOOLEAN_IS_FALSE = "BOOLEAN_IS_FALSE" - TAGS_EQUALS = "TAGS_EQUALS" - TAGS_NOT_EQUALS = "TAGS_NOT_EQUALS" - TAGS_CONTAINS = "TAGS_CONTAINS" - TAGS_NOT_CONTAINS = "TAGS_NOT_CONTAINS" - TAGS_SINGLE_EQUALS = "TAGS_SINGLE_EQUALS" - TAGS_SINGLE_NOT_EQUALS = "TAGS_SINGLE_NOT_EQUALS" - TAGS_SINGLE_CONTAINS = "TAGS_SINGLE_CONTAINS" - TAGS_SINGLE_NOT_CONTAINS = "TAGS_SINGLE_NOT_CONTAINS" - IS_NULL = "IS_NULL" - IS_NOT_NULL = "IS_NOT_NULL" +class SavedViewEntityType(Enum): + TASK = "task" + PATIENT = "patient" + + +@strawberry.enum +class SavedViewVisibility(Enum): + PRIVATE = "private" + LINK_SHARED = "link_shared" + + +@strawberry.input +class CreateSavedViewInput: + name: str + base_entity_type: SavedViewEntityType + filter_definition: str + sort_definition: str + parameters: str + related_filter_definition: str = "{}" + related_sort_definition: str = "{}" + related_parameters: str = "{}" + visibility: SavedViewVisibility = SavedViewVisibility.LINK_SHARED + + +@strawberry.input +class UpdateSavedViewInput: + name: str | None = None + filter_definition: str | None = None + sort_definition: str | None = None + parameters: str | None = None + related_filter_definition: str | None = None + related_sort_definition: str | None = None + related_parameters: str | None = None + visibility: SavedViewVisibility | None = None @strawberry.enum -class ColumnType(Enum): - DIRECT_ATTRIBUTE = "DIRECT_ATTRIBUTE" - PROPERTY = "PROPERTY" +class TaskPresetScope(Enum): + PERSONAL = "PERSONAL" + GLOBAL = "GLOBAL" @strawberry.input -class FilterParameter: - search_text: str | None = None - is_case_sensitive: bool = False - compare_value: float | None = None - min: float | None = None - max: float | None = None - compare_date: date | None = None - min_date: date | None = None - max_date: date | None = None - compare_date_time: datetime | None = None - min_date_time: datetime | None = None - max_date_time: datetime | None = None - search_tags: list[str] | None = None - property_definition_id: str | None = None +class TaskGraphNodeInput: + node_id: str + title: str + description: str | None = None + priority: TaskPriority | None = None + estimated_time: int | None = None @strawberry.input -class SortInput: - column: str - direction: SortDirection - column_type: ColumnType = ColumnType.DIRECT_ATTRIBUTE - property_definition_id: str | None = None +class TaskGraphEdgeInput: + from_node_id: str + to_node_id: str @strawberry.input -class FilterInput: - column: str - operator: FilterOperator - parameter: FilterParameter - column_type: ColumnType = ColumnType.DIRECT_ATTRIBUTE - property_definition_id: str | None = None +class TaskGraphInput: + nodes: list[TaskGraphNodeInput] + edges: list[TaskGraphEdgeInput] @strawberry.input -class PaginationInput: - page_index: int = 0 - page_size: int | None = None +class CreateTaskPresetInput: + name: str + key: str | None = None + scope: TaskPresetScope + graph: TaskGraphInput @strawberry.input -class QueryOptionsInput: - sorting: list[SortInput] | None = None - filtering: list[FilterInput] | None = None - pagination: PaginationInput | None = None +class UpdateTaskPresetInput: + name: str | None = None + key: str | None = None + graph: TaskGraphInput | None = None @strawberry.input -class FullTextSearchInput: - search_text: str - search_columns: list[str] | None = None - include_properties: bool = False - property_definition_ids: list[str] | None = None +class ApplyTaskGraphInput: + patient_id: strawberry.ID + preset_id: strawberry.ID | None = None + graph: TaskGraphInput | None = None + assign_to_current_user: bool = False diff --git a/backend/api/query/__init__.py b/backend/api/query/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/query/adapters/patient.py b/backend/api/query/adapters/patient.py new file mode 100644 index 00000000..d153e613 --- /dev/null +++ b/backend/api/query/adapters/patient.py @@ -0,0 +1,514 @@ +from typing import Any + +from sqlalchemy import Select, and_, case, func, or_ +from sqlalchemy.orm import aliased + +from api.context import Info +from api.errors import raise_forbidden +from api.inputs import PatientState, Sex, SortDirection +from api.query.enums import ( + QueryOperator, + QueryableFieldKind, + QueryableValueType, + ReferenceFilterMode, +) +from api.query.field_ops import apply_ops_to_column +from api.query.graphql_types import ( + QueryableChoiceMeta, + QueryableField, + QueryableRelationMeta, + sort_directions_for, +) +from api.query.inputs import QueryFilterClauseInput, QuerySearchInput, QuerySortClauseInput +from api.query.property_sql import join_property_value +from api.query.patient_location_scope import ( + apply_patient_subtree_filter_from_cte, + build_location_descendants_cte, +) +from api.query.sql_expr import location_title_expr, patient_display_name_expr +from database import models + + +def _state_order_case() -> Any: + return case( + (models.Patient.state == PatientState.WAIT.value, 0), + (models.Patient.state == PatientState.ADMITTED.value, 1), + (models.Patient.state == PatientState.DISCHARGED.value, 2), + (models.Patient.state == PatientState.DEAD.value, 3), + else_=4, + ) + + +def _ensure_position_join(query: Select[Any], ctx: dict[str, Any]) -> tuple[Select[Any], Any]: + if "position_node" in ctx: + return query, ctx["position_node"] + ln = aliased(models.LocationNode) + ctx["position_node"] = ln + query = query.outerjoin(ln, models.Patient.position_id == ln.id) + return query, ln + + +def _parse_property_key(field_key: str) -> str | None: + if not field_key.startswith("property_"): + return None + return field_key.removeprefix("property_") + + +LOCATION_SORT_KEY_KINDS: dict[str, tuple[str, ...]] = { + "location-CLINIC": ("CLINIC", "PRACTICE"), + "location-WARD": ("WARD",), + "location-ROOM": ("ROOM",), + "location-BED": ("BED",), +} + + +LOCATION_SORT_KEY_LABELS: dict[str, str] = { + "location-CLINIC": "Clinic", + "location-WARD": "Ward", + "location-ROOM": "Room", + "location-BED": "Bed", +} + + +def _ensure_position_lineage_joins( + query: Select[Any], ctx: dict[str, Any] +) -> tuple[Select[Any], list[Any]]: + if "position_lineage_nodes" in ctx: + return query, ctx["position_lineage_nodes"] + query, position_node = _ensure_position_join(query, ctx) + lineage_nodes: list[Any] = [position_node] + for depth in range(1, 8): + parent_node = aliased(models.LocationNode, name=f"position_parent_{depth}") + query = query.outerjoin(parent_node, lineage_nodes[-1].parent_id == parent_node.id) + lineage_nodes.append(parent_node) + ctx["position_lineage_nodes"] = lineage_nodes + return query, lineage_nodes + + +def _location_title_for_kind(lineage_nodes: list[Any], target_kinds: tuple[str, ...]) -> Any: + candidates = [ + case( + (node.kind.in_(target_kinds), location_title_expr(node)), + else_=None, + ) + for node in lineage_nodes + ] + return func.coalesce(*candidates) + + +def apply_patient_filter_clause( + query: Select[Any], + clause: QueryFilterClauseInput, + ctx: dict[str, Any], + property_field_types: dict[str, str], + info: Info | None = None, +) -> Select[Any]: + key = clause.field_key + op = clause.operator + val = clause.value + + prop_id = _parse_property_key(key) + if prop_id: + ft = property_field_types.get(prop_id, "FIELD_TYPE_TEXT") + query, _pa, col = join_property_value( + query, models.Patient, prop_id, ft, "patient" + ) + ctx["needs_distinct"] = True + if ft == "FIELD_TYPE_MULTI_SELECT": + cond = apply_ops_to_column(col, op, val) + elif ft == "FIELD_TYPE_DATE": + cond = apply_ops_to_column(col, op, val, as_date=True) + elif ft == "FIELD_TYPE_DATE_TIME": + cond = apply_ops_to_column(col, op, val, as_datetime=True) + elif ft == "FIELD_TYPE_CHECKBOX": + if op == QueryOperator.EQ and val and val.bool_value is not None: + cond = col == val.bool_value + else: + cond = apply_ops_to_column(col, op, val) + elif ft == "FIELD_TYPE_USER": + cond = apply_ops_to_column(col, op, val) + elif ft == "FIELD_TYPE_SELECT": + cond = apply_ops_to_column(col, op, val) + else: + cond = apply_ops_to_column(col, op, val) + if cond is not None: + query = query.where(cond) + return query + + if key == "firstname": + c = apply_ops_to_column(models.Patient.firstname, op, val) + if c is not None: + query = query.where(c) + return query + if key == "lastname": + c = apply_ops_to_column(models.Patient.lastname, op, val) + if c is not None: + query = query.where(c) + return query + if key == "name": + expr = patient_display_name_expr(models.Patient) + c = apply_ops_to_column(expr, op, val) + if c is not None: + query = query.where(c) + return query + if key == "state": + c = apply_ops_to_column(models.Patient.state, op, val) + if c is not None: + query = query.where(c) + return query + if key == "sex": + c = apply_ops_to_column(models.Patient.sex, op, val) + if c is not None: + query = query.where(c) + return query + if key == "birthdate": + c = apply_ops_to_column(models.Patient.birthdate, op, val, as_date=True) + if c is not None: + query = query.where(c) + return query + if key == "description": + c = apply_ops_to_column(models.Patient.description, op, val) + if c is not None: + query = query.where(c) + return query + if key in LOCATION_SORT_KEY_KINDS: + query, lineage_nodes = _ensure_position_lineage_joins(query, ctx) + expr = _location_title_for_kind( + lineage_nodes, LOCATION_SORT_KEY_KINDS[key] + ) + c = apply_ops_to_column(expr, op, val) + if c is not None: + query = query.where(c) + return query + if key == "position": + if op in (QueryOperator.EQ, QueryOperator.IN) and val: + has_uuid = (val.uuid_value is not None and val.uuid_value != "") or ( + val.uuid_values is not None and len(val.uuid_values) > 0 + ) + if has_uuid: + if not info or not hasattr(info, "context"): + return query.where(False) + accessible = getattr(info.context, "_accessible_location_ids", None) + if accessible is None: + return query.where(False) + if op == QueryOperator.EQ: + if not val.uuid_value: + return query + lid = val.uuid_value + if lid not in accessible: + raise_forbidden() + filter_cte = build_location_descendants_cte( + [lid], cte_name="filter_loc_subtree" + ) + ctx["needs_distinct"] = True + return apply_patient_subtree_filter_from_cte(query, filter_cte) + if op == QueryOperator.IN: + ids: list[str] = [] + if val.uuid_values: + ids = [x for x in val.uuid_values if x in accessible] + elif val.uuid_value and val.uuid_value in accessible: + ids = [val.uuid_value] + if not ids: + return query.where(False) + filter_cte = build_location_descendants_cte( + ids, cte_name="filter_loc_subtree_m" + ) + ctx["needs_distinct"] = True + return apply_patient_subtree_filter_from_cte(query, filter_cte) + query, ln = _ensure_position_join(query, ctx) + expr = location_title_expr(ln) + if op in ( + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + ): + c = apply_ops_to_column(expr, op, val) + if c is not None: + query = query.where(c) + return query + if op in (QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL): + c = apply_ops_to_column(models.Patient.position_id, op, None) + if c is not None: + query = query.where(c) + return query + return query + + return query + + +def apply_patient_sorts( + query: Select[Any], + sorts: list[QuerySortClauseInput] | None, + ctx: dict[str, Any], + property_field_types: dict[str, str], +) -> Select[Any]: + if not sorts: + return query.order_by(models.Patient.id.asc()) + + order_parts: list[Any] = [] + + for s in sorts: + key = s.field_key + desc_order = s.direction == SortDirection.DESC + prop_id = _parse_property_key(key) + if prop_id: + ft = property_field_types.get(prop_id, "FIELD_TYPE_TEXT") + query, _pa, col = join_property_value( + query, models.Patient, prop_id, ft, "patient" + ) + if desc_order: + order_parts.append(col.desc().nulls_last()) + else: + order_parts.append(col.asc().nulls_first()) + continue + + if key == "firstname": + order_parts.append( + models.Patient.firstname.desc() + if desc_order + else models.Patient.firstname.asc() + ) + elif key == "lastname": + order_parts.append( + models.Patient.lastname.desc() + if desc_order + else models.Patient.lastname.asc() + ) + elif key == "name": + expr = patient_display_name_expr(models.Patient) + order_parts.append( + expr.desc().nulls_last() if desc_order else expr.asc().nulls_first() + ) + elif key == "state": + order_parts.append( + _state_order_case().desc() + if desc_order + else _state_order_case().asc() + ) + elif key == "sex": + order_parts.append( + models.Patient.sex.desc() if desc_order else models.Patient.sex.asc() + ) + elif key == "birthdate": + order_parts.append( + models.Patient.birthdate.desc().nulls_last() + if desc_order + else models.Patient.birthdate.asc().nulls_first() + ) + elif key == "description": + order_parts.append( + models.Patient.description.desc().nulls_last() + if desc_order + else models.Patient.description.asc().nulls_first() + ) + elif key == "position": + query, ln = _ensure_position_join(query, ctx) + t = location_title_expr(ln) + order_parts.append( + t.desc().nulls_last() if desc_order else t.asc().nulls_first() + ) + elif key in LOCATION_SORT_KEY_KINDS: + query, lineage_nodes = _ensure_position_lineage_joins(query, ctx) + t = _location_title_for_kind(lineage_nodes, LOCATION_SORT_KEY_KINDS[key]) + order_parts.append( + t.desc().nulls_last() if desc_order else t.asc().nulls_first() + ) + + if not order_parts: + return query.order_by(models.Patient.id.asc()) + return query.order_by(*order_parts, models.Patient.id.asc()) + + +def apply_patient_search( + query: Select[Any], + search: QuerySearchInput | None, + ctx: dict[str, Any], +) -> Select[Any]: + if not search or not search.search_text or not search.search_text.strip(): + return query + pattern = f"%{search.search_text.strip()}%" + expr = patient_display_name_expr(models.Patient) + parts: list[Any] = [ + models.Patient.firstname.ilike(pattern), + models.Patient.lastname.ilike(pattern), + expr.ilike(pattern), + models.Patient.description.ilike(pattern), + ] + if search.include_properties: + pv = aliased(models.PropertyValue) + query = query.outerjoin( + pv, + and_( + pv.patient_id == models.Patient.id, + pv.text_value.isnot(None), + ), + ) + parts.append(pv.text_value.ilike(pattern)) + ctx["needs_distinct"] = True + query = query.where(or_(*parts)) + return query + + +def build_patient_queryable_fields_static() -> list[QueryableField]: + str_ops = [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + QueryOperator.IN, + QueryOperator.NOT_IN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + date_ops = [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.GT, + QueryOperator.GTE, + QueryOperator.LT, + QueryOperator.LTE, + QueryOperator.BETWEEN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + choice_ops = [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.IN, + QueryOperator.NOT_IN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + states = [ + PatientState.WAIT, + PatientState.ADMITTED, + PatientState.DISCHARGED, + PatientState.DEAD, + ] + state_keys = [s.value for s in states] + state_labels = [s.value for s in states] + + sex_keys = [Sex.MALE.value, Sex.FEMALE.value, Sex.UNKNOWN.value] + sex_labels = ["Male", "Female", "Unknown"] + + return [ + QueryableField( + key="name", + label="Name", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + QueryableField( + key="firstname", + label="First name", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + QueryableField( + key="lastname", + label="Last name", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + QueryableField( + key="state", + label="State", + kind=QueryableFieldKind.CHOICE, + value_type=QueryableValueType.STRING, + allowed_operators=choice_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + choice=QueryableChoiceMeta( + option_keys=state_keys, + option_labels=state_labels, + ), + ), + QueryableField( + key="sex", + label="Sex", + kind=QueryableFieldKind.CHOICE, + value_type=QueryableValueType.STRING, + allowed_operators=choice_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + choice=QueryableChoiceMeta( + option_keys=sex_keys, + option_labels=sex_labels, + ), + ), + QueryableField( + key="birthdate", + label="Birthdate", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.DATE, + allowed_operators=date_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ), + QueryableField( + key="description", + label="Description", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + QueryableField( + key="position", + label="Location", + kind=QueryableFieldKind.REFERENCE, + value_type=QueryableValueType.UUID, + allowed_operators=[ + QueryOperator.EQ, + QueryOperator.IN, + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ], + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + relation=QueryableRelationMeta( + target_entity="LocationNode", + id_field_key="id", + label_field_key="title", + allowed_filter_modes=[ + ReferenceFilterMode.ID, + ReferenceFilterMode.LABEL, + ], + ), + ), + *[ + QueryableField( + key=key, + label=label, + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ) + for key, label in LOCATION_SORT_KEY_LABELS.items() + ], + ] diff --git a/backend/api/query/adapters/task.py b/backend/api/query/adapters/task.py new file mode 100644 index 00000000..43e95195 --- /dev/null +++ b/backend/api/query/adapters/task.py @@ -0,0 +1,620 @@ +from typing import Any + +from sqlalchemy import Select, and_, case, exists, func, or_, select +from sqlalchemy.orm import aliased + +from api.context import Info +from api.inputs import SortDirection +from api.query.enums import ( + QueryOperator, + QueryableFieldKind, + QueryableValueType, + ReferenceFilterMode, +) +from api.query.field_ops import apply_ops_to_column +from api.query.graphql_types import ( + QueryableChoiceMeta, + QueryableField, + QueryableRelationMeta, + sort_directions_for, +) +from api.query.inputs import ( + QueryFilterClauseInput, + QuerySearchInput, + QuerySortClauseInput, +) +from api.query.property_sql import join_property_value +from api.query.sql_expr import location_title_expr, patient_display_name_expr, user_display_label_expr +from database import models + + +def _prio_order_case() -> Any: + return case( + (models.Task.priority == "P1", 1), + (models.Task.priority == "P2", 2), + (models.Task.priority == "P3", 3), + (models.Task.priority == "P4", 4), + else_=99, + ) + + +def _assignee_label_exists(op: QueryOperator, val: Any) -> Any: + u = aliased(models.User) + label_expr = user_display_label_expr(u) + label_condition = apply_ops_to_column(label_expr, op, val) + if label_condition is None: + return None + return exists( + select(1) + .select_from(models.task_assignees.join(u, models.task_assignees.c.user_id == u.id)) + .where( + models.task_assignees.c.task_id == models.Task.id, + label_condition, + ) + ) + + +def _assignee_label_sort_expr() -> Any: + u = aliased(models.User) + return ( + select(func.min(user_display_label_expr(u))) + .select_from(models.task_assignees.join(u, models.task_assignees.c.user_id == u.id)) + .where(models.task_assignees.c.task_id == models.Task.id) + .scalar_subquery() + ) + + +def _patient_label_expr() -> Any: + p = aliased(models.Patient) + return ( + select(patient_display_name_expr(p)) + .where(p.id == models.Task.patient_id) + .scalar_subquery() + ) + + +def _patient_label_exists(op: QueryOperator, val: Any) -> Any: + p = aliased(models.Patient) + expr = patient_display_name_expr(p) + condition = apply_ops_to_column(expr, op, val) + if condition is None: + return None + return exists( + select(1).where( + p.id == models.Task.patient_id, + condition, + ) + ) + + +def _patient_label_ilike_exists(pattern: str) -> Any: + p = aliased(models.Patient) + return exists( + select(1).where( + p.id == models.Task.patient_id, + patient_display_name_expr(p).ilike(pattern), + ) + ) + + +def _assignee_label_ilike_exists(pattern: str) -> Any: + u = aliased(models.User) + return exists( + select(1) + .select_from(models.task_assignees.join(u, models.task_assignees.c.user_id == u.id)) + .where( + models.task_assignees.c.task_id == models.Task.id, + user_display_label_expr(u).ilike(pattern), + ) + ) + + +def _ensure_team_join(query: Select[Any], ctx: dict[str, Any]) -> tuple[Select[Any], Any]: + if "assignee_team" in ctx: + return query, ctx["assignee_team"] + ln = aliased(models.LocationNode) + ctx["assignee_team"] = ln + query = query.outerjoin(ln, models.Task.assignee_team_id == ln.id) + return query, ln + + +def _parse_property_key(field_key: str) -> str | None: + if not field_key.startswith("property_"): + return None + return field_key.removeprefix("property_") + + +def apply_task_filter_clause( + query: Select[Any], + clause: QueryFilterClauseInput, + ctx: dict[str, Any], + property_field_types: dict[str, str], + info: Info | None = None, +) -> Select[Any]: + key = clause.field_key + op = clause.operator + val = clause.value + + prop_id = _parse_property_key(key) + if prop_id: + ft = property_field_types.get(prop_id, "FIELD_TYPE_TEXT") + ent = "task" + query, _pa, col = join_property_value( + query, models.Task, prop_id, ft, ent + ) + ctx["needs_distinct"] = True + ctx.setdefault("property_joins", set()).add(prop_id) + if ft == "FIELD_TYPE_MULTI_SELECT": + if op in ( + QueryOperator.IN, + QueryOperator.ANY_IN, + QueryOperator.ALL_IN, + QueryOperator.NONE_IN, + ): + cond = apply_ops_to_column(col, op, val) + else: + cond = apply_ops_to_column(col, op, val, as_date=False) + elif ft == "FIELD_TYPE_DATE": + cond = apply_ops_to_column(col, op, val, as_date=True) + elif ft == "FIELD_TYPE_DATE_TIME": + cond = apply_ops_to_column(col, op, val, as_datetime=True) + elif ft == "FIELD_TYPE_CHECKBOX": + if op == QueryOperator.EQ and val and val.bool_value is not None: + cond = col == val.bool_value + else: + cond = apply_ops_to_column(col, op, val) + elif ft == "FIELD_TYPE_USER": + cond = apply_ops_to_column(col, op, val) + elif ft == "FIELD_TYPE_SELECT": + cond = apply_ops_to_column(col, op, val) + else: + cond = apply_ops_to_column(col, op, val) + if cond is not None: + query = query.where(cond) + return query + + if key == "title": + c = apply_ops_to_column(models.Task.title, op, val) + if c is not None: + query = query.where(c) + return query + if key == "description": + c = apply_ops_to_column(models.Task.description, op, val) + if c is not None: + query = query.where(c) + return query + if key == "done": + if op == QueryOperator.EQ and val and val.bool_value is not None: + query = query.where(models.Task.done == val.bool_value) + elif op == QueryOperator.IS_NULL: + query = query.where(models.Task.done.is_(None)) + elif op == QueryOperator.IS_NOT_NULL: + query = query.where(models.Task.done.isnot(None)) + return query + if key == "dueDate": + c = apply_ops_to_column(models.Task.due_date, op, val, as_datetime=True) + if c is not None: + query = query.where(c) + return query + if key == "priority": + c = apply_ops_to_column(models.Task.priority, op, val) + if c is not None: + query = query.where(c) + return query + if key == "estimatedTime": + c = apply_ops_to_column(models.Task.estimated_time, op, val) + if c is not None: + query = query.where(c) + return query + if key == "creationDate": + c = apply_ops_to_column(models.Task.creation_date, op, val, as_datetime=True) + if c is not None: + query = query.where(c) + return query + if key == "updateDate": + c = apply_ops_to_column(models.Task.update_date, op, val, as_datetime=True) + if c is not None: + query = query.where(c) + return query + + if key == "assignee": + if op in (QueryOperator.EQ, QueryOperator.IN) and val and ( + val.uuid_value or val.uuid_values + ): + if val.uuid_value: + query = query.where( + exists( + select(1).where( + models.task_assignees.c.task_id == models.Task.id, + models.task_assignees.c.user_id == val.uuid_value, + ) + ) + ) + elif val.uuid_values: + query = query.where( + exists( + select(1).where( + models.task_assignees.c.task_id == models.Task.id, + models.task_assignees.c.user_id.in_(val.uuid_values), + ) + ) + ) + elif op in ( + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + ): + c = _assignee_label_exists(op, val) + if c is not None: + query = query.where(c) + elif op in (QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL): + has_assignees = exists( + select(1).where(models.task_assignees.c.task_id == models.Task.id) + ) + if op == QueryOperator.IS_NULL: + query = query.where(~has_assignees) + else: + query = query.where(has_assignees) + return query + + if key == "assigneeTeam": + query, ln = _ensure_team_join(query, ctx) + expr = location_title_expr(ln) + if op in (QueryOperator.EQ, QueryOperator.IN) and val and val.uuid_value: + query = query.where(models.Task.assignee_team_id == val.uuid_value) + elif op in ( + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + ): + c = apply_ops_to_column(expr, op, val) + if c is not None: + query = query.where(c) + elif op in (QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL): + c = apply_ops_to_column(models.Task.assignee_team_id, op, None) + if c is not None: + query = query.where(c) + return query + + if key == "patient": + if op in (QueryOperator.EQ, QueryOperator.IN) and val and ( + val.uuid_value or val.uuid_values + ): + if val.uuid_value: + query = query.where(models.Task.patient_id == val.uuid_value) + elif val.uuid_values: + query = query.where(models.Task.patient_id.in_(val.uuid_values)) + elif op in (QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL): + c = apply_ops_to_column(models.Task.patient_id, op, None) + if c is not None: + query = query.where(c) + elif op in ( + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + ): + c = _patient_label_exists(op, val) + if c is not None: + query = query.where(c) + return query + + return query + + +def apply_task_sorts( + query: Select[Any], + sorts: list[QuerySortClauseInput] | None, + ctx: dict[str, Any], + property_field_types: dict[str, str], +) -> Select[Any]: + if not sorts: + return query.order_by(models.Task.id.asc()) + + order_parts: list[Any] = [] + + for s in sorts: + key = s.field_key + desc_order = s.direction == SortDirection.DESC + prop_id = _parse_property_key(key) + if prop_id: + ft = property_field_types.get(prop_id, "FIELD_TYPE_TEXT") + query, _pa, col = join_property_value( + query, models.Task, prop_id, ft, "task" + ) + if desc_order: + order_parts.append(col.desc().nulls_last()) + else: + order_parts.append(col.asc().nulls_first()) + continue + + if key == "title": + order_parts.append( + models.Task.title.desc() if desc_order else models.Task.title.asc() + ) + elif key == "description": + order_parts.append( + models.Task.description.desc() + if desc_order + else models.Task.description.asc() + ) + elif key == "done": + order_parts.append( + models.Task.done.desc() if desc_order else models.Task.done.asc() + ) + elif key == "dueDate": + order_parts.append( + models.Task.due_date.desc().nulls_last() + if desc_order + else models.Task.due_date.asc().nulls_first() + ) + elif key == "priority": + order_parts.append( + _prio_order_case().desc() + if desc_order + else _prio_order_case().asc() + ) + elif key == "estimatedTime": + order_parts.append( + models.Task.estimated_time.desc().nulls_last() + if desc_order + else models.Task.estimated_time.asc().nulls_first() + ) + elif key == "creationDate": + order_parts.append( + models.Task.creation_date.desc().nulls_last() + if desc_order + else models.Task.creation_date.asc().nulls_first() + ) + elif key == "updateDate": + order_parts.append( + models.Task.update_date.desc().nulls_last() + if desc_order + else models.Task.update_date.asc().nulls_first() + ) + elif key == "assignee": + label = _assignee_label_sort_expr() + order_parts.append( + label.desc().nulls_last() if desc_order else label.asc().nulls_first() + ) + elif key == "assigneeTeam": + query, ln = _ensure_team_join(query, ctx) + t = location_title_expr(ln) + order_parts.append( + t.desc().nulls_last() if desc_order else t.asc().nulls_first() + ) + elif key == "patient": + expr = _patient_label_expr() + order_parts.append( + expr.desc().nulls_last() if desc_order else expr.asc().nulls_first() + ) + + if not order_parts: + return query.order_by(models.Task.id.asc()) + return query.order_by(*order_parts, models.Task.id.asc()) + + +def apply_task_search( + query: Select[Any], + search: QuerySearchInput | None, + ctx: dict[str, Any], +) -> Select[Any]: + if not search or not search.search_text or not search.search_text.strip(): + return query + pattern = f"%{search.search_text.strip()}%" + parts: list[Any] = [ + models.Task.title.ilike(pattern), + models.Task.description.ilike(pattern), + _patient_label_ilike_exists(pattern), + _assignee_label_ilike_exists(pattern), + ] + if search.include_properties: + pv = aliased(models.PropertyValue) + query = query.outerjoin( + pv, + and_( + pv.task_id == models.Task.id, + pv.text_value.isnot(None), + ), + ) + parts.append(pv.text_value.ilike(pattern)) + ctx["needs_distinct"] = True + query = query.where(or_(*parts)) + return query + + +def build_task_queryable_fields_static() -> list[QueryableField]: + prio_ops = [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.IN, + QueryOperator.NOT_IN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + ref_ops = [ + QueryOperator.EQ, + QueryOperator.IN, + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + str_ops = [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + QueryOperator.IN, + QueryOperator.NOT_IN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + num_ops = [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.GT, + QueryOperator.GTE, + QueryOperator.LT, + QueryOperator.LTE, + QueryOperator.BETWEEN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + dt_ops = [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.GT, + QueryOperator.GTE, + QueryOperator.LT, + QueryOperator.LTE, + QueryOperator.BETWEEN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + bool_ops = [QueryOperator.EQ, QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL] + + return [ + QueryableField( + key="title", + label="Title", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + QueryableField( + key="description", + label="Description", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + QueryableField( + key="done", + label="Done", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.BOOLEAN, + allowed_operators=bool_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ), + QueryableField( + key="dueDate", + label="Due date", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.DATETIME, + allowed_operators=dt_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ), + QueryableField( + key="priority", + label="Priority", + kind=QueryableFieldKind.CHOICE, + value_type=QueryableValueType.STRING, + allowed_operators=prio_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + choice=QueryableChoiceMeta( + option_keys=["P1", "P2", "P3", "P4"], + option_labels=["P1", "P2", "P3", "P4"], + ), + ), + QueryableField( + key="estimatedTime", + label="Estimated time", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.NUMBER, + allowed_operators=num_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ), + QueryableField( + key="creationDate", + label="Creation date", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.DATETIME, + allowed_operators=dt_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ), + QueryableField( + key="updateDate", + label="Update date", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.DATETIME, + allowed_operators=dt_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ), + QueryableField( + key="patient", + label="Patient", + kind=QueryableFieldKind.REFERENCE, + value_type=QueryableValueType.UUID, + allowed_operators=ref_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + relation=QueryableRelationMeta( + target_entity="Patient", + id_field_key="id", + label_field_key="name", + allowed_filter_modes=[ + ReferenceFilterMode.ID, + ReferenceFilterMode.LABEL, + ], + ), + ), + QueryableField( + key="assignee", + label="Assignee", + kind=QueryableFieldKind.REFERENCE, + value_type=QueryableValueType.UUID, + allowed_operators=ref_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + relation=QueryableRelationMeta( + target_entity="User", + id_field_key="id", + label_field_key="name", + allowed_filter_modes=[ + ReferenceFilterMode.ID, + ReferenceFilterMode.LABEL, + ], + ), + ), + QueryableField( + key="assigneeTeam", + label="Assignee team", + kind=QueryableFieldKind.REFERENCE, + value_type=QueryableValueType.UUID, + allowed_operators=ref_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + relation=QueryableRelationMeta( + target_entity="LocationNode", + id_field_key="id", + label_field_key="title", + allowed_filter_modes=[ + ReferenceFilterMode.ID, + ReferenceFilterMode.LABEL, + ], + ), + ), + ] diff --git a/backend/api/query/adapters/user.py b/backend/api/query/adapters/user.py new file mode 100644 index 00000000..2fbc51d8 --- /dev/null +++ b/backend/api/query/adapters/user.py @@ -0,0 +1,149 @@ +from typing import Any + +from sqlalchemy import Select, or_ + +from api.context import Info +from api.inputs import SortDirection +from api.query.enums import QueryOperator, QueryableFieldKind, QueryableValueType +from api.query.field_ops import apply_ops_to_column +from api.query.graphql_types import QueryableField, sort_directions_for +from api.query.inputs import QueryFilterClauseInput, QuerySearchInput, QuerySortClauseInput +from api.query.sql_expr import user_display_label_expr +from database import models + + +def apply_user_filter_clause( + query: Select[Any], + clause: QueryFilterClauseInput, + ctx: dict[str, Any], + property_field_types: dict[str, str], + info: Info | None = None, +) -> Select[Any]: + key = clause.field_key + op = clause.operator + val = clause.value + + if key == "username": + c = apply_ops_to_column(models.User.username, op, val) + if c is not None: + query = query.where(c) + return query + if key == "email": + c = apply_ops_to_column(models.User.email, op, val) + if c is not None: + query = query.where(c) + return query + if key == "firstname": + c = apply_ops_to_column(models.User.firstname, op, val) + if c is not None: + query = query.where(c) + return query + if key == "lastname": + c = apply_ops_to_column(models.User.lastname, op, val) + if c is not None: + query = query.where(c) + return query + if key == "name": + expr = user_display_label_expr(models.User) + c = apply_ops_to_column(expr, op, val) + if c is not None: + query = query.where(c) + return query + return query + + +def apply_user_sorts( + query: Select[Any], + sorts: list[QuerySortClauseInput] | None, + ctx: dict[str, Any], + property_field_types: dict[str, str], +) -> Select[Any]: + if not sorts: + return query.order_by(models.User.id.asc()) + + order_parts: list[Any] = [] + for s in sorts: + key = s.field_key + desc_order = s.direction == SortDirection.DESC + if key == "username": + order_parts.append( + models.User.username.desc() + if desc_order + else models.User.username.asc() + ) + elif key == "email": + order_parts.append( + models.User.email.desc().nulls_last() + if desc_order + else models.User.email.asc().nulls_first() + ) + elif key == "name": + expr = user_display_label_expr(models.User) + order_parts.append( + expr.desc().nulls_last() if desc_order else expr.asc().nulls_first() + ) + order_parts.append(models.User.id.asc()) + return query.order_by(*order_parts) + + +def apply_user_search( + query: Select[Any], + search: QuerySearchInput | None, + ctx: dict[str, Any], +) -> Select[Any]: + if not search or not search.search_text or not search.search_text.strip(): + return query + pattern = f"%{search.search_text.strip()}%" + expr = user_display_label_expr(models.User) + query = query.where( + or_( + models.User.username.ilike(pattern), + models.User.email.ilike(pattern), + expr.ilike(pattern), + ) + ) + return query + + +def build_user_queryable_fields_static() -> list[QueryableField]: + str_ops = [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + return [ + QueryableField( + key="username", + label="Username", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + QueryableField( + key="email", + label="Email", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + QueryableField( + key="name", + label="Name", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + ), + ] diff --git a/backend/api/query/context.py b/backend/api/query/context.py new file mode 100644 index 00000000..e5b1bc6c --- /dev/null +++ b/backend/api/query/context.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import DeclarativeBase + + +@dataclass +class QueryCompileContext: + db: AsyncSession + root_model: type[DeclarativeBase] + entity_key: str + aliases: dict[str, Any] = field(default_factory=dict) + needs_distinct: bool = False diff --git a/backend/api/query/dedupe_select.py b/backend/api/query/dedupe_select.py new file mode 100644 index 00000000..de384988 --- /dev/null +++ b/backend/api/query/dedupe_select.py @@ -0,0 +1,14 @@ +from typing import Any + +from sqlalchemy import Select, select + + +def dedupe_orm_select_by_root_id(stmt: Select[Any], root_model: type) -> Select[Any]: + opts = getattr(stmt, "_with_options", None) or () + stmt_flat = stmt.order_by(None).limit(None).offset(None) + pk = root_model.id + ids_sq = stmt_flat.with_only_columns(pk).distinct().scalar_subquery() + out = select(root_model).where(root_model.id.in_(ids_sq)) + for opt in opts: + out = out.options(opt) + return out diff --git a/backend/api/query/engine.py b/backend/api/query/engine.py new file mode 100644 index 00000000..e8b81b47 --- /dev/null +++ b/backend/api/query/engine.py @@ -0,0 +1,86 @@ +from typing import Any + +import strawberry +from sqlalchemy import Select + +from api.context import Info +from api.inputs import PaginationInput +from api.query.inputs import QueryFilterClauseInput, QuerySearchInput, QuerySortClauseInput +from api.query.dedupe_select import dedupe_orm_select_by_root_id +from api.query.property_sql import load_property_field_types +from api.query.registry import get_entity_handler + + +def _property_ids_from_filters( + filters: list[QueryFilterClauseInput] | None, +) -> set[str]: + ids: set[str] = set() + if not filters: + return ids + for f in filters: + if f.field_key.startswith("property_"): + ids.add(f.field_key.removeprefix("property_")) + return ids + + +def _property_ids_from_sorts( + sorts: list[QuerySortClauseInput] | None, +) -> set[str]: + ids: set[str] = set() + if not sorts: + return ids + for s in sorts: + if s.field_key.startswith("property_"): + ids.add(s.field_key.removeprefix("property_")) + return ids + + +async def apply_unified_query( + stmt: Select[Any], + *, + entity: str, + db: Any, + filters: list[QueryFilterClauseInput] | None, + sorts: list[QuerySortClauseInput] | None, + search: QuerySearchInput | None, + pagination: PaginationInput | None, + for_count: bool = False, + info: Info | None = None, +) -> Select[Any]: + handler = get_entity_handler(entity) + if not handler: + return stmt + + prop_ids = _property_ids_from_filters(filters) | _property_ids_from_sorts(sorts) + property_field_types = await load_property_field_types(db, prop_ids) + + ctx: dict[str, Any] = {"needs_distinct": False} + + for clause in filters or []: + stmt = handler["apply_filter"]( + stmt, clause, ctx, property_field_types, info=info + ) + + if search is not None and search is not strawberry.UNSET: + text = (search.search_text or "").strip() + if text: + stmt = handler["apply_search"](stmt, search, ctx) + + if ctx.get("needs_distinct"): + stmt = dedupe_orm_select_by_root_id(stmt, handler["root_model"]) + ctx["needs_distinct"] = False + + if not for_count: + stmt = handler["apply_sorts"](stmt, sorts, ctx, property_field_types) + + if ( + not for_count + and pagination is not None + and pagination is not strawberry.UNSET + ): + page_size = pagination.page_size + if page_size: + offset = pagination.page_index * page_size + stmt = stmt.offset(offset).limit(page_size) + + return stmt diff --git a/backend/api/query/enums.py b/backend/api/query/enums.py new file mode 100644 index 00000000..cb099161 --- /dev/null +++ b/backend/api/query/enums.py @@ -0,0 +1,55 @@ +from enum import Enum + +import strawberry + + +@strawberry.enum +class QueryOperator(Enum): + EQ = "EQ" + NEQ = "NEQ" + GT = "GT" + GTE = "GTE" + LT = "LT" + LTE = "LTE" + BETWEEN = "BETWEEN" + IN = "IN" + NOT_IN = "NOT_IN" + CONTAINS = "CONTAINS" + STARTS_WITH = "STARTS_WITH" + ENDS_WITH = "ENDS_WITH" + IS_NULL = "IS_NULL" + IS_NOT_NULL = "IS_NOT_NULL" + ANY_EQ = "ANY_EQ" + ANY_IN = "ANY_IN" + ALL_IN = "ALL_IN" + NONE_IN = "NONE_IN" + IS_EMPTY = "IS_EMPTY" + IS_NOT_EMPTY = "IS_NOT_EMPTY" + + +@strawberry.enum +class QueryableFieldKind(Enum): + SCALAR = "SCALAR" + PROPERTY = "PROPERTY" + REFERENCE = "REFERENCE" + REFERENCE_LIST = "REFERENCE_LIST" + CHOICE = "CHOICE" + CHOICE_LIST = "CHOICE_LIST" + + +@strawberry.enum +class QueryableValueType(Enum): + STRING = "STRING" + NUMBER = "NUMBER" + BOOLEAN = "BOOLEAN" + DATE = "DATE" + DATETIME = "DATETIME" + UUID = "UUID" + STRING_LIST = "STRING_LIST" + UUID_LIST = "UUID_LIST" + + +@strawberry.enum +class ReferenceFilterMode(Enum): + ID = "ID" + LABEL = "LABEL" diff --git a/backend/api/query/execute.py b/backend/api/query/execute.py new file mode 100644 index 00000000..fe3f88f4 --- /dev/null +++ b/backend/api/query/execute.py @@ -0,0 +1,91 @@ +from functools import wraps +from typing import Any, Callable + +import strawberry +from sqlalchemy import Select, func, select + +from api.context import Info +from api.inputs import PaginationInput +from api.query.engine import apply_unified_query +from api.query.inputs import QueryFilterClauseInput, QuerySearchInput, QuerySortClauseInput + + +def unified_list_query( + entity: str, + *, + default_sorts_when_empty: list[QuerySortClauseInput] | None = None, +): + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + filters: list[QueryFilterClauseInput] | None = kwargs.get("filters") + sorts: list[QuerySortClauseInput] | None = kwargs.get("sorts") + if (not sorts) and default_sorts_when_empty: + sorts = list(default_sorts_when_empty) + search: QuerySearchInput | None = kwargs.get("search") + pagination: PaginationInput | None = kwargs.get("pagination") + + result = await func(*args, **kwargs) + + if not isinstance(result, Select): + return result + + info: Info | None = kwargs.get("info") + if not info: + for a in args: + if hasattr(a, "context"): + info = a + break + if not info or not hasattr(info, "context"): + return result + + stmt = await apply_unified_query( + result, + entity=entity, + db=info.context.db, + filters=filters, + sorts=sorts, + search=search, + pagination=pagination, + for_count=False, + info=info, + ) + + db = info.context.db + query_result = await db.execute(stmt) + return query_result.scalars().all() + + return wrapper + + return decorator + + +async def count_unified_query( + stmt: Select[Any], + *, + entity: str, + db: Any, + filters: list[QueryFilterClauseInput] | None, + sorts: list[QuerySortClauseInput] | None, + search: QuerySearchInput | None, + info: Info | None = None, +) -> int: + stmt = await apply_unified_query( + stmt, + entity=entity, + db=db, + filters=filters, + sorts=sorts, + search=search, + pagination=None, + for_count=True, + info=info, + ) + subquery = stmt.subquery() + count_query = select(func.count(func.distinct(subquery.c.id))) + result = await db.execute(count_query) + return result.scalar() or 0 + + +def is_unset(value: Any) -> bool: + return value is strawberry.UNSET or value is None diff --git a/backend/api/query/field_ops.py b/backend/api/query/field_ops.py new file mode 100644 index 00000000..e6a63d18 --- /dev/null +++ b/backend/api/query/field_ops.py @@ -0,0 +1,211 @@ +from typing import Any + +from sqlalchemy import String, and_, cast, func, not_, or_ +from sqlalchemy.sql import ColumnElement + +from api.query.enums import QueryOperator +from api.query.inputs import QueryFilterValueInput + + +def _str_norm(v: QueryFilterValueInput) -> str | None: + if v.string_value is not None: + return v.string_value + return None + + +def apply_ops_to_column( + column: Any, + operator: QueryOperator, + value: QueryFilterValueInput | None, + *, + as_date: bool = False, + as_datetime: bool = False, +) -> ColumnElement[bool] | None: + if operator in (QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL): + if operator == QueryOperator.IS_NULL: + return column.is_(None) + return column.isnot(None) + + if value is None and operator not in ( + QueryOperator.IS_EMPTY, + QueryOperator.IS_NOT_EMPTY, + ): + return None + + if operator == QueryOperator.IS_EMPTY: + return or_(column.is_(None), cast(column, String) == "") + if operator == QueryOperator.IS_NOT_EMPTY: + return and_(column.isnot(None), cast(column, String) != "") + + if as_date: + return _apply_date_ops(column, operator, value) + if as_datetime: + return _apply_datetime_ops(column, operator, value) + + if operator == QueryOperator.EQ: + if value is None: + return None + if value.uuid_value is not None: + return column == value.uuid_value + if value.string_value is not None: + return column == value.string_value + if value.float_value is not None: + return column == value.float_value + if value.bool_value is not None: + return column == value.bool_value + if value.date_value is not None: + return column == value.date_value + return None + + if operator == QueryOperator.NEQ: + if value is None: + return None + if value.uuid_value is not None: + return column != value.uuid_value + if value.string_value is not None: + return column != value.string_value + if value.float_value is not None: + return column != value.float_value + if value.bool_value is not None: + return column != value.bool_value + return None + + if operator == QueryOperator.GT: + return _cmp(column, value, lambda c, x: c > x) + if operator == QueryOperator.GTE: + return _cmp(column, value, lambda c, x: c >= x) + if operator == QueryOperator.LT: + return _cmp(column, value, lambda c, x: c < x) + if operator == QueryOperator.LTE: + return _cmp(column, value, lambda c, x: c <= x) + + if operator == QueryOperator.BETWEEN: + if value is None: + return None + if value.date_min is not None and value.date_max is not None: + return func.date(column).between(value.date_min, value.date_max) + if value.float_min is not None and value.float_max is not None: + return column.between(value.float_min, value.float_max) + return None + + if operator == QueryOperator.IN: + if value and value.string_values: + return column.in_(value.string_values) + if value and value.uuid_values: + return column.in_(value.uuid_values) + return None + + if operator == QueryOperator.NOT_IN: + if value and value.string_values: + return column.notin_(value.string_values) + if value and value.uuid_values: + return column.notin_(value.uuid_values) + return None + + if operator == QueryOperator.CONTAINS: + s = _str_norm(value) if value else None + if s is None: + return None + return column.ilike(f"%{s}%") + if operator == QueryOperator.STARTS_WITH: + s = _str_norm(value) if value else None + if s is None: + return None + return column.ilike(f"{s}%") + if operator == QueryOperator.ENDS_WITH: + s = _str_norm(value) if value else None + if s is None: + return None + return column.ilike(f"%{s}") + + if operator == QueryOperator.ANY_EQ: + if value and value.string_values: + return or_(*[column == t for t in value.string_values]) + return None + + if operator in (QueryOperator.ANY_IN, QueryOperator.ALL_IN, QueryOperator.NONE_IN): + return _apply_multi_select_ops(column, operator, value) + + return None + + +def _cmp(column: Any, value: QueryFilterValueInput | None, pred) -> ColumnElement[bool] | None: + if value is None: + return None + if value.float_value is not None: + return pred(column, value.float_value) + if value.date_value is not None: + return pred(column, value.date_value) + return None + + +def _apply_date_ops( + column: Any, operator: QueryOperator, value: QueryFilterValueInput | None +) -> ColumnElement[bool] | None: + if value is None: + return None + dc = func.date(column) + if ( + operator == QueryOperator.BETWEEN + and value.date_min is not None + and value.date_max is not None + ): + return dc.between(value.date_min, value.date_max) + if operator == QueryOperator.IN and value.string_values: + return dc.in_(value.string_values) + if value.date_value is not None: + d = value.date_value.date() + if operator == QueryOperator.EQ: + return dc == d + if operator == QueryOperator.NEQ: + return dc != d + if operator == QueryOperator.GT: + return dc > d + if operator == QueryOperator.GTE: + return dc >= d + if operator == QueryOperator.LT: + return dc < d + if operator == QueryOperator.LTE: + return dc <= d + return None + + +def _apply_datetime_ops( + column: Any, operator: QueryOperator, value: QueryFilterValueInput | None +) -> ColumnElement[bool] | None: + if value is None: + return None + if operator == QueryOperator.EQ and value.date_value is not None: + return column == value.date_value + if operator == QueryOperator.NEQ and value.date_value is not None: + return column != value.date_value + if operator == QueryOperator.GT and value.date_value is not None: + return column > value.date_value + if operator == QueryOperator.GTE and value.date_value is not None: + return column >= value.date_value + if operator == QueryOperator.LT and value.date_value is not None: + return column < value.date_value + if operator == QueryOperator.LTE and value.date_value is not None: + return column <= value.date_value + if ( + operator == QueryOperator.BETWEEN + and value.date_min is not None + and value.date_max is not None + ): + return func.date(column).between(value.date_min, value.date_max) + return None + + +def _apply_multi_select_ops( + column: Any, operator: QueryOperator, value: QueryFilterValueInput | None +) -> ColumnElement[bool] | None: + if value is None or not value.string_values: + return None + tags = value.string_values + if operator == QueryOperator.ANY_IN: + return or_(*[column.contains(tag) for tag in tags]) + if operator == QueryOperator.ALL_IN: + return and_(*[column.contains(tag) for tag in tags]) + if operator == QueryOperator.NONE_IN: + return and_(*[not_(column.contains(tag)) for tag in tags]) + return None diff --git a/backend/api/query/graphql_types.py b/backend/api/query/graphql_types.py new file mode 100644 index 00000000..3464b19d --- /dev/null +++ b/backend/api/query/graphql_types.py @@ -0,0 +1,46 @@ +import strawberry + +from api.inputs import SortDirection +from api.query.enums import ( + QueryOperator, + QueryableFieldKind, + QueryableValueType, + ReferenceFilterMode, +) + + +def sort_directions_for(sortable: bool) -> list[SortDirection]: + return [SortDirection.ASC, SortDirection.DESC] if sortable else [] + + +@strawberry.type +class QueryableRelationMeta: + target_entity: str + id_field_key: str + label_field_key: str + allowed_filter_modes: list[ReferenceFilterMode] + + +@strawberry.type +class QueryableChoiceMeta: + option_keys: list[str] + option_labels: list[str] + + +@strawberry.type +class QueryableField: + key: str + label: str + kind: QueryableFieldKind + value_type: QueryableValueType + allowed_operators: list[QueryOperator] + sortable: bool + sort_directions: list[SortDirection] + searchable: bool + relation: QueryableRelationMeta | None = None + choice: QueryableChoiceMeta | None = None + property_definition_id: str | None = None + + @strawberry.field + def filterable(self) -> bool: + return len(self.allowed_operators) > 0 diff --git a/backend/api/query/inputs.py b/backend/api/query/inputs.py new file mode 100644 index 00000000..2180ab66 --- /dev/null +++ b/backend/api/query/inputs.py @@ -0,0 +1,40 @@ +from datetime import date, datetime + +import strawberry + +from api.inputs import SortDirection +from api.query.enums import QueryOperator + + +@strawberry.input +class QueryFilterValueInput: + string_value: str | None = None + string_values: list[str] | None = None + float_value: float | None = None + float_min: float | None = None + float_max: float | None = None + bool_value: bool | None = None + date_value: datetime | None = None + date_min: date | None = None + date_max: date | None = None + uuid_value: str | None = None + uuid_values: list[str] | None = None + + +@strawberry.input +class QueryFilterClauseInput: + field_key: str + operator: QueryOperator + value: QueryFilterValueInput | None = None + + +@strawberry.input +class QuerySortClauseInput: + field_key: str + direction: SortDirection + + +@strawberry.input +class QuerySearchInput: + search_text: str | None = None + include_properties: bool = False diff --git a/backend/api/query/metadata_service.py b/backend/api/query/metadata_service.py new file mode 100644 index 00000000..14375f33 --- /dev/null +++ b/backend/api/query/metadata_service.py @@ -0,0 +1,279 @@ +from typing import Any + +from sqlalchemy import select + +from api.query.adapters import patient as patient_adapters +from api.query.adapters import task as task_adapters +from api.query.adapters import user as user_adapters +from api.query.enums import ( + QueryOperator, + QueryableFieldKind, + QueryableValueType, + ReferenceFilterMode, +) +from api.query.graphql_types import ( + QueryableChoiceMeta, + QueryableField, + QueryableRelationMeta, + sort_directions_for, +) +from api.query.registry import PATIENT, TASK, USER +from database import models + + +def _str_ops() -> list[QueryOperator]: + return [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.CONTAINS, + QueryOperator.STARTS_WITH, + QueryOperator.ENDS_WITH, + QueryOperator.IN, + QueryOperator.NOT_IN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + + +def _num_ops() -> list[QueryOperator]: + return [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.GT, + QueryOperator.GTE, + QueryOperator.LT, + QueryOperator.LTE, + QueryOperator.BETWEEN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + + +def _date_ops() -> list[QueryOperator]: + return [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.GT, + QueryOperator.GTE, + QueryOperator.LT, + QueryOperator.LTE, + QueryOperator.BETWEEN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + + +def _dt_ops() -> list[QueryOperator]: + return _date_ops() + + +def _bool_ops() -> list[QueryOperator]: + return [ + QueryOperator.EQ, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + + +def _choice_ops() -> list[QueryOperator]: + return [ + QueryOperator.EQ, + QueryOperator.NEQ, + QueryOperator.IN, + QueryOperator.NOT_IN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + + +def _multi_choice_ops() -> list[QueryOperator]: + return [ + QueryOperator.ANY_IN, + QueryOperator.ALL_IN, + QueryOperator.NONE_IN, + QueryOperator.IS_EMPTY, + QueryOperator.IS_NOT_EMPTY, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + + +def _user_ref_ops() -> list[QueryOperator]: + return [ + QueryOperator.EQ, + QueryOperator.IN, + QueryOperator.IS_NULL, + QueryOperator.IS_NOT_NULL, + ] + + +def _property_definition_to_field(p: models.PropertyDefinition) -> QueryableField: + ft = p.field_type + key = f"property_{p.id}" + name = p.name + raw_opts = (p.options or "").strip() + option_labels = [x.strip() for x in raw_opts.split(",") if x.strip()] if raw_opts else [] + option_keys = list(option_labels) + + if ft == "FIELD_TYPE_TEXT": + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.PROPERTY, + value_type=QueryableValueType.STRING, + allowed_operators=_str_ops(), + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + property_definition_id=str(p.id), + ) + if ft == "FIELD_TYPE_NUMBER": + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.PROPERTY, + value_type=QueryableValueType.NUMBER, + allowed_operators=_num_ops(), + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + property_definition_id=str(p.id), + ) + if ft == "FIELD_TYPE_CHECKBOX": + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.PROPERTY, + value_type=QueryableValueType.BOOLEAN, + allowed_operators=_bool_ops(), + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + property_definition_id=str(p.id), + ) + if ft == "FIELD_TYPE_DATE": + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.PROPERTY, + value_type=QueryableValueType.DATE, + allowed_operators=_date_ops(), + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + property_definition_id=str(p.id), + ) + if ft == "FIELD_TYPE_DATE_TIME": + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.PROPERTY, + value_type=QueryableValueType.DATETIME, + allowed_operators=_dt_ops(), + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + property_definition_id=str(p.id), + ) + if ft == "FIELD_TYPE_SELECT": + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.CHOICE, + value_type=QueryableValueType.STRING, + allowed_operators=_choice_ops(), + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + property_definition_id=str(p.id), + choice=QueryableChoiceMeta( + option_keys=option_keys, + option_labels=option_labels, + ), + ) + if ft == "FIELD_TYPE_MULTI_SELECT": + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.CHOICE_LIST, + value_type=QueryableValueType.STRING_LIST, + allowed_operators=_multi_choice_ops(), + sortable=False, + sort_directions=sort_directions_for(False), + searchable=False, + property_definition_id=str(p.id), + choice=QueryableChoiceMeta( + option_keys=option_keys, + option_labels=option_labels, + ), + ) + if ft == "FIELD_TYPE_USER": + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.REFERENCE, + value_type=QueryableValueType.UUID, + allowed_operators=_user_ref_ops(), + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + property_definition_id=str(p.id), + relation=QueryableRelationMeta( + target_entity="User", + id_field_key="id", + label_field_key="name", + allowed_filter_modes=[ReferenceFilterMode.ID], + ), + ) + return QueryableField( + key=key, + label=name, + kind=QueryableFieldKind.PROPERTY, + value_type=QueryableValueType.STRING, + allowed_operators=_str_ops(), + sortable=True, + sort_directions=sort_directions_for(True), + searchable=True, + property_definition_id=str(p.id), + ) + + +async def load_queryable_fields( + db: Any, entity: str +) -> list[QueryableField]: + e = entity.strip() + if e == TASK: + base = task_adapters.build_task_queryable_fields_static() + prop_rows = ( + await db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.is_active.is_(True), + ) + ) + ).scalars().all() + extra = [] + for p in prop_rows: + ents = (p.allowed_entities or "").split(",") + if "TASK" not in [x.strip() for x in ents if x.strip()]: + continue + extra.append(_property_definition_to_field(p)) + return base + extra + if e == PATIENT: + base = patient_adapters.build_patient_queryable_fields_static() + prop_rows = ( + await db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.is_active.is_(True), + ) + ) + ).scalars().all() + extra = [] + for p in prop_rows: + ents = (p.allowed_entities or "").split(",") + if "PATIENT" not in [x.strip() for x in ents if x.strip()]: + continue + extra.append(_property_definition_to_field(p)) + return base + extra + if e == USER: + return user_adapters.build_user_queryable_fields_static() + return [] diff --git a/backend/api/query/patient_location_scope.py b/backend/api/query/patient_location_scope.py new file mode 100644 index 00000000..d3bcc04a --- /dev/null +++ b/backend/api/query/patient_location_scope.py @@ -0,0 +1,71 @@ +from typing import Any + +from sqlalchemy import Select, select +from sqlalchemy.orm import aliased + +from database import models + + +def build_location_descendants_cte( + seed_ids: list[str], + *, + cte_name: str = "location_descendants", +) -> Any: + if not seed_ids: + raise ValueError("seed_ids must not be empty") + if len(seed_ids) == 1: + anchor = select(models.LocationNode.id).where( + models.LocationNode.id == seed_ids[0] + ) + else: + anchor = select(models.LocationNode.id).where( + models.LocationNode.id.in_(seed_ids) + ) + filter_cte = anchor.cte(name=cte_name, recursive=True) + children = select(models.LocationNode.id).join( + filter_cte, + models.LocationNode.parent_id == filter_cte.c.id, + ) + return filter_cte.union_all(children) + + +def apply_patient_subtree_filter_from_cte( + query: Select[Any], + filter_cte: Any, +) -> Select[Any]: + patient_locations_filter = aliased(models.patient_locations) + patient_teams_filter = aliased(models.patient_teams) + + return ( + query.outerjoin( + patient_locations_filter, + models.Patient.id == patient_locations_filter.c.patient_id, + ) + .outerjoin( + patient_teams_filter, + models.Patient.id == patient_teams_filter.c.patient_id, + ) + .where( + (models.Patient.clinic_id.in_(select(filter_cte.c.id))) + | ( + models.Patient.position_id.isnot(None) + & models.Patient.position_id.in_(select(filter_cte.c.id)) + ) + | ( + models.Patient.assigned_location_id.isnot(None) + & models.Patient.assigned_location_id.in_( + select(filter_cte.c.id) + ) + ) + | ( + patient_locations_filter.c.location_id.in_( + select(filter_cte.c.id) + ) + ) + | ( + patient_teams_filter.c.location_id.in_( + select(filter_cte.c.id) + ) + ) + ) + ) diff --git a/backend/api/query/property_sql.py b/backend/api/query/property_sql.py new file mode 100644 index 00000000..0be5e223 --- /dev/null +++ b/backend/api/query/property_sql.py @@ -0,0 +1,61 @@ +from typing import Any + +from sqlalchemy import Select, and_, select +from sqlalchemy.orm import aliased + +from database import models + + +def property_value_column_for_field_type(field_type: str) -> str: + mapping = { + "FIELD_TYPE_TEXT": "text_value", + "FIELD_TYPE_NUMBER": "number_value", + "FIELD_TYPE_CHECKBOX": "boolean_value", + "FIELD_TYPE_DATE": "date_value", + "FIELD_TYPE_DATE_TIME": "date_time_value", + "FIELD_TYPE_SELECT": "select_value", + "FIELD_TYPE_MULTI_SELECT": "multi_select_values", + "FIELD_TYPE_USER": "user_value", + } + return mapping.get(field_type, "text_value") + + +def join_property_value( + query: Select[Any], + root_model: type, + property_definition_id: str, + field_type: str, + entity: str, +) -> tuple[Select[Any], Any, Any]: + property_alias = aliased(models.PropertyValue) + value_column = getattr( + property_alias, property_value_column_for_field_type(field_type) + ) + + if entity == "patient": + join_condition = and_( + property_alias.patient_id == root_model.id, + property_alias.definition_id == property_definition_id, + ) + else: + join_condition = and_( + property_alias.task_id == root_model.id, + property_alias.definition_id == property_definition_id, + ) + + query = query.outerjoin(property_alias, join_condition) + return query, property_alias, value_column + + +async def load_property_field_types( + db: Any, definition_ids: set[str] +) -> dict[str, str]: + if not definition_ids: + return {} + result = await db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(definition_ids) + ) + ) + rows = result.scalars().all() + return {str(p.id): p.field_type for p in rows} diff --git a/backend/api/query/registry.py b/backend/api/query/registry.py new file mode 100644 index 00000000..0108bb94 --- /dev/null +++ b/backend/api/query/registry.py @@ -0,0 +1,38 @@ +from typing import Any + +from api.query.adapters import patient as patient_adapters +from api.query.adapters import task as task_adapters +from api.query.adapters import user as user_adapters +from database import models + +EntityHandler = dict[str, Any] + +TASK = "Task" +PATIENT = "Patient" +USER = "User" + + +ENTITY_REGISTRY: dict[str, EntityHandler] = { + TASK: { + "root_model": models.Task, + "apply_filter": task_adapters.apply_task_filter_clause, + "apply_sorts": task_adapters.apply_task_sorts, + "apply_search": task_adapters.apply_task_search, + }, + PATIENT: { + "root_model": models.Patient, + "apply_filter": patient_adapters.apply_patient_filter_clause, + "apply_sorts": patient_adapters.apply_patient_sorts, + "apply_search": patient_adapters.apply_patient_search, + }, + USER: { + "root_model": models.User, + "apply_filter": user_adapters.apply_user_filter_clause, + "apply_sorts": user_adapters.apply_user_sorts, + "apply_search": user_adapters.apply_user_search, + }, +} + + +def get_entity_handler(entity: str) -> EntityHandler | None: + return ENTITY_REGISTRY.get(entity) diff --git a/backend/api/query/sql_expr.py b/backend/api/query/sql_expr.py new file mode 100644 index 00000000..9c0bf5f4 --- /dev/null +++ b/backend/api/query/sql_expr.py @@ -0,0 +1,33 @@ +from typing import Any + +from sqlalchemy import String, and_, case, cast, func + + +def user_display_label_expr(user_table: Any) -> Any: + return cast( + func.coalesce( + case( + ( + and_( + user_table.firstname.isnot(None), + user_table.lastname.isnot(None), + ), + user_table.firstname + " " + user_table.lastname, + ), + else_=None, + ), + user_table.username, + ), + String, + ) + + +def patient_display_name_expr(patient_table: Any) -> Any: + return cast( + func.trim(patient_table.firstname + " " + patient_table.lastname), + String, + ) + + +def location_title_expr(location_table: Any) -> Any: + return cast(location_table.title, String) diff --git a/backend/api/resolvers/__init__.py b/backend/api/resolvers/__init__.py index b053198d..8a050e1b 100644 --- a/backend/api/resolvers/__init__.py +++ b/backend/api/resolvers/__init__.py @@ -4,7 +4,10 @@ from .location import LocationMutation, LocationQuery, LocationSubscription from .patient import PatientMutation, PatientQuery, PatientSubscription from .property import PropertyDefinitionMutation, PropertyDefinitionQuery +from .query_metadata import QueryMetadataQuery +from .saved_view import SavedViewMutation, SavedViewQuery from .task import TaskMutation, TaskQuery, TaskSubscription +from .task_preset import TaskPresetMutation, TaskPresetQuery from .user import UserMutation, UserQuery @@ -12,10 +15,13 @@ class Query( PatientQuery, TaskQuery, + TaskPresetQuery, LocationQuery, PropertyDefinitionQuery, UserQuery, AuditQuery, + QueryMetadataQuery, + SavedViewQuery, ): pass @@ -24,9 +30,11 @@ class Query( class Mutation( PatientMutation, TaskMutation, + TaskPresetMutation, PropertyDefinitionMutation, LocationMutation, UserMutation, + SavedViewMutation, ): pass diff --git a/backend/api/resolvers/patient.py b/backend/api/resolvers/patient.py index 946e77b7..29e2f6b0 100644 --- a/backend/api/resolvers/patient.py +++ b/backend/api/resolvers/patient.py @@ -3,23 +3,15 @@ import strawberry from api.audit import audit_log from api.context import Info -from api.decorators.filter_sort import ( - apply_filtering, - apply_sorting, - filtered_and_sorted_query, - get_property_field_types, -) -from api.decorators.full_text_search import ( - apply_full_text_search, - full_text_search_query, -) -from api.inputs import ( - FilterInput, - FullTextSearchInput, - PaginationInput, - SortInput, -) from api.inputs import CreatePatientInput, PatientState, UpdatePatientInput +from api.inputs import PaginationInput +from api.query.execute import count_unified_query, is_unset, unified_list_query +from api.query.inputs import ( + QueryFilterClauseInput, + QuerySearchInput, + QuerySortClauseInput, +) +from api.query.registry import PATIENT from api.resolvers.base import BaseMutationResolver, BaseSubscriptionResolver from api.services.authorization import AuthorizationService from api.services.checksum import validate_checksum @@ -28,6 +20,11 @@ from api.services.property import PropertyService from api.types.patient import PatientType from api.errors import raise_forbidden +from api.query.dedupe_select import dedupe_orm_select_by_root_id +from api.query.patient_location_scope import ( + apply_patient_subtree_filter_from_cte, + build_location_descendants_cte, +) from database import models from sqlalchemy import func, select from sqlalchemy.orm import aliased, selectinload @@ -74,16 +71,10 @@ async def _build_patients_base_query( if location_node_id: if location_node_id not in accessible_location_ids: raise_forbidden() - filter_cte = ( - select(models.LocationNode.id) - .where(models.LocationNode.id == location_node_id) - .cte(name="location_descendants", recursive=True) - ) - children = select(models.LocationNode.id).join( - filter_cte, - models.LocationNode.parent_id == filter_cte.c.id, + filter_cte = build_location_descendants_cte( + [str(location_node_id)], + cte_name="location_descendants", ) - filter_cte = filter_cte.union_all(children) elif root_location_ids: valid_root_location_ids = [ lid for lid in root_location_ids if lid in accessible_location_ids @@ -91,55 +82,15 @@ async def _build_patients_base_query( if not valid_root_location_ids: return query.where(False), [] root_location_ids = valid_root_location_ids - filter_cte = ( - select(models.LocationNode.id) - .where(models.LocationNode.id.in_(root_location_ids)) - .cte(name="root_location_descendants", recursive=True) + filter_cte = build_location_descendants_cte( + [str(lid) for lid in root_location_ids], + cte_name="root_location_descendants", ) - root_children = select(models.LocationNode.id).join( - filter_cte, models.LocationNode.parent_id == filter_cte.c.id - ) - filter_cte = filter_cte.union_all(root_children) if filter_cte is not None: - patient_locations_filter = aliased(models.patient_locations) - patient_teams_filter = aliased(models.patient_teams) - - query = ( - query.outerjoin( - patient_locations_filter, - models.Patient.id == patient_locations_filter.c.patient_id, - ) - .outerjoin( - patient_teams_filter, - models.Patient.id == patient_teams_filter.c.patient_id, - ) - .where( - (models.Patient.clinic_id.in_(select(filter_cte.c.id))) - | ( - models.Patient.position_id.isnot(None) - & models.Patient.position_id.in_( - select(filter_cte.c.id) - ) - ) - | ( - models.Patient.assigned_location_id.isnot(None) - & models.Patient.assigned_location_id.in_( - select(filter_cte.c.id) - ) - ) - | ( - patient_locations_filter.c.location_id.in_( - select(filter_cte.c.id) - ) - ) - | ( - patient_teams_filter.c.location_id.in_( - select(filter_cte.c.id) - ) - ) - ) - .distinct() + query = dedupe_orm_select_by_root_id( + apply_patient_subtree_filter_from_cte(query, filter_cte), + models.Patient, ) return query, accessible_location_ids @@ -170,18 +121,17 @@ async def patient( return patient @strawberry.field - @filtered_and_sorted_query() - @full_text_search_query() + @unified_list_query(PATIENT) async def patients( self, info: Info, location_node_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, states: list[PatientState] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, pagination: PaginationInput | None = None, - search: FullTextSearchInput | None = None, + search: QuerySearchInput | None = None, ) -> list[PatientType]: query, _ = await PatientQuery._build_patients_base_query( info, location_node_id, root_location_ids, states @@ -195,45 +145,34 @@ async def patientsTotal( location_node_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, states: list[PatientState] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, - search: FullTextSearchInput | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, + search: QuerySearchInput | None = None, ) -> int: query, _ = await PatientQuery._build_patients_base_query( info, location_node_id, root_location_ids, states ) - if search and search is not strawberry.UNSET: - query = apply_full_text_search(query, search, models.Patient) - - property_field_types = await get_property_field_types( - info.context.db, filtering, sorting + return await count_unified_query( + query, + entity=PATIENT, + db=info.context.db, + filters=filters if filters is not None and not is_unset(filters) else None, + sorts=sorts if sorts is not None and not is_unset(sorts) else None, + search=search if search is not None and not is_unset(search) else None, + info=info, ) - if filtering: - query = apply_filtering( - query, filtering, models.Patient, property_field_types - ) - if sorting: - query = apply_sorting( - query, sorting, models.Patient, property_field_types - ) - - subquery = query.subquery() - count_query = select(func.count(func.distinct(subquery.c.id))) - result = await info.context.db.execute(count_query) - return result.scalar() or 0 @strawberry.field - @filtered_and_sorted_query() - @full_text_search_query() + @unified_list_query(PATIENT) async def recent_patients( self, info: Info, root_location_ids: list[strawberry.ID] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, pagination: PaginationInput | None = None, - search: FullTextSearchInput | None = None, + search: QuerySearchInput | None = None, ) -> list[PatientType]: auth_service = AuthorizationService(info.context.db) accessible_location_ids = ( @@ -285,7 +224,7 @@ async def recent_patients( root_cte = root_cte.union_all(root_children) patient_locations_root = aliased(models.patient_locations) patient_teams_root = aliased(models.patient_teams) - query = ( + query = dedupe_orm_select_by_root_id( query.outerjoin( patient_locations_root, models.Patient.id == patient_locations_root.c.patient_id, @@ -306,8 +245,8 @@ async def recent_patients( ) | (patient_locations_root.c.location_id.in_(select(root_cte.c.id))) | (patient_teams_root.c.location_id.in_(select(root_cte.c.id))) - ) - .distinct() + ), + models.Patient, ) return query @@ -317,9 +256,9 @@ async def recentPatientsTotal( self, info: Info, root_location_ids: list[strawberry.ID] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, - search: FullTextSearchInput | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, + search: QuerySearchInput | None = None, ) -> int: auth_service = AuthorizationService(info.context.db) accessible_location_ids = ( @@ -366,7 +305,7 @@ async def recentPatientsTotal( root_cte = root_cte.union_all(root_children) patient_locations_root = aliased(models.patient_locations) patient_teams_root = aliased(models.patient_teams) - query = ( + query = dedupe_orm_select_by_root_id( query.outerjoin( patient_locations_root, models.Patient.id == patient_locations_root.c.patient_id, @@ -387,29 +326,19 @@ async def recentPatientsTotal( ) | (patient_locations_root.c.location_id.in_(select(root_cte.c.id))) | (patient_teams_root.c.location_id.in_(select(root_cte.c.id))) - ) - .distinct() + ), + models.Patient, ) - if search and search is not strawberry.UNSET: - query = apply_full_text_search(query, search, models.Patient) - - property_field_types = await get_property_field_types( - info.context.db, filtering, sorting + return await count_unified_query( + query, + entity=PATIENT, + db=info.context.db, + filters=filters if filters is not None and not is_unset(filters) else None, + sorts=sorts if sorts is not None and not is_unset(sorts) else None, + search=search if search is not None and not is_unset(search) else None, + info=info, ) - if filtering: - query = apply_filtering( - query, filtering, models.Patient, property_field_types - ) - if sorting: - query = apply_sorting( - query, sorting, models.Patient, property_field_types - ) - - subquery = query.subquery() - count_query = select(func.count(func.distinct(subquery.c.id))) - result = await info.context.db.execute(count_query) - return result.scalar() or 0 @strawberry.type diff --git a/backend/api/resolvers/query_metadata.py b/backend/api/resolvers/query_metadata.py new file mode 100644 index 00000000..0f825cf3 --- /dev/null +++ b/backend/api/resolvers/query_metadata.py @@ -0,0 +1,14 @@ +import strawberry + +from api.context import Info +from api.query.graphql_types import QueryableField +from api.query.metadata_service import load_queryable_fields + + +@strawberry.type +class QueryMetadataQuery: + @strawberry.field + async def queryable_fields( + self, info: Info, entity: str + ) -> list[QueryableField]: + return await load_queryable_fields(info.context.db, entity) diff --git a/backend/api/resolvers/saved_view.py b/backend/api/resolvers/saved_view.py new file mode 100644 index 00000000..e29b96a3 --- /dev/null +++ b/backend/api/resolvers/saved_view.py @@ -0,0 +1,205 @@ +import json + +import strawberry +from graphql import GraphQLError +from sqlalchemy import select + +from api.context import Info +from api.services.base import BaseRepository +from api.inputs import ( + CreateSavedViewInput, + SavedViewVisibility, + UpdateSavedViewInput, +) +from api.types.saved_view import SavedViewType +from database import models + + +def _require_user(info: Info) -> models.User: + user = info.context.user + if not user: + raise GraphQLError("Authentication required") + return user + + +@strawberry.type +class SavedViewQuery: + @strawberry.field + async def saved_view(self, info: Info, id: strawberry.ID) -> SavedViewType | None: + db = info.context.db + result = await db.execute( + select(models.SavedView).where(models.SavedView.id == str(id)) + ) + row = result.scalars().first() + if not row: + return None + uid = info.context.user.id if info.context.user else None + if row.owner_user_id != uid and row.visibility != SavedViewVisibility.LINK_SHARED.value: + raise GraphQLError("Not found or access denied") + return SavedViewType.from_model(row, current_user_id=uid) + + @strawberry.field + async def my_saved_views(self, info: Info) -> list[SavedViewType]: + user = _require_user(info) + db = info.context.db + result = await db.execute( + select(models.SavedView) + .where(models.SavedView.owner_user_id == user.id) + .order_by(models.SavedView.updated_at.desc()) + ) + rows = result.scalars().all() + return [SavedViewType.from_model(r, current_user_id=user.id) for r in rows] + + +@strawberry.type +class SavedViewMutation: + @strawberry.mutation + async def create_saved_view( + self, + info: Info, + data: CreateSavedViewInput, + ) -> SavedViewType: + user = _require_user(info) + for blob, label in ( + (data.filter_definition, "filter_definition"), + (data.sort_definition, "sort_definition"), + (data.parameters, "parameters"), + (data.related_filter_definition, "related_filter_definition"), + (data.related_sort_definition, "related_sort_definition"), + (data.related_parameters, "related_parameters"), + ): + try: + json.loads(blob) + except json.JSONDecodeError as e: + raise GraphQLError(f"Invalid JSON in {label}") from e + + row = models.SavedView( + name=data.name.strip(), + base_entity_type=data.base_entity_type.value, + filter_definition=data.filter_definition, + sort_definition=data.sort_definition, + parameters=data.parameters, + related_filter_definition=data.related_filter_definition, + related_sort_definition=data.related_sort_definition, + related_parameters=data.related_parameters, + owner_user_id=user.id, + visibility=data.visibility.value, + ) + info.context.db.add(row) + await info.context.db.commit() + await info.context.db.refresh(row) + return SavedViewType.from_model(row, current_user_id=user.id) + + @strawberry.mutation + async def update_saved_view( + self, + info: Info, + id: strawberry.ID, + data: UpdateSavedViewInput, + ) -> SavedViewType: + user = _require_user(info) + db = info.context.db + result = await db.execute( + select(models.SavedView).where(models.SavedView.id == str(id)) + ) + row = result.scalars().first() + if not row: + raise GraphQLError("View not found") + if row.owner_user_id != user.id: + raise GraphQLError("Forbidden") + + if data.name is not None: + row.name = data.name.strip() + if data.filter_definition is not None: + try: + json.loads(data.filter_definition) + except json.JSONDecodeError as e: + raise GraphQLError("Invalid JSON in filter_definition") from e + row.filter_definition = data.filter_definition + if data.sort_definition is not None: + try: + json.loads(data.sort_definition) + except json.JSONDecodeError as e: + raise GraphQLError("Invalid JSON in sort_definition") from e + row.sort_definition = data.sort_definition + if data.parameters is not None: + try: + json.loads(data.parameters) + except json.JSONDecodeError as e: + raise GraphQLError("Invalid JSON in parameters") from e + row.parameters = data.parameters + if data.related_filter_definition is not None: + try: + json.loads(data.related_filter_definition) + except json.JSONDecodeError as e: + raise GraphQLError("Invalid JSON in related_filter_definition") from e + row.related_filter_definition = data.related_filter_definition + if data.related_sort_definition is not None: + try: + json.loads(data.related_sort_definition) + except json.JSONDecodeError as e: + raise GraphQLError("Invalid JSON in related_sort_definition") from e + row.related_sort_definition = data.related_sort_definition + if data.related_parameters is not None: + try: + json.loads(data.related_parameters) + except json.JSONDecodeError as e: + raise GraphQLError("Invalid JSON in related_parameters") from e + row.related_parameters = data.related_parameters + if data.visibility is not None: + row.visibility = data.visibility.value + + await db.commit() + await db.refresh(row) + return SavedViewType.from_model(row, current_user_id=user.id) + + @strawberry.mutation + async def delete_saved_view(self, info: Info, id: strawberry.ID) -> bool: + user = _require_user(info) + db = info.context.db + result = await db.execute( + select(models.SavedView).where(models.SavedView.id == str(id)) + ) + row = result.scalars().first() + if not row: + return False + if row.owner_user_id != user.id: + raise GraphQLError("Forbidden") + repo = BaseRepository(db, models.SavedView) + await repo.delete(row) + return True + + @strawberry.mutation + async def duplicate_saved_view( + self, + info: Info, + id: strawberry.ID, + name: str, + ) -> SavedViewType: + user = _require_user(info) + db = info.context.db + result = await db.execute( + select(models.SavedView).where(models.SavedView.id == str(id)) + ) + src = result.scalars().first() + if not src: + raise GraphQLError("View not found") + if src.owner_user_id != user.id and src.visibility != SavedViewVisibility.LINK_SHARED.value: + raise GraphQLError("Not found or access denied") + + clone = models.SavedView( + name=name.strip(), + base_entity_type=src.base_entity_type, + filter_definition=src.filter_definition, + sort_definition=src.sort_definition, + parameters=src.parameters, + related_filter_definition=src.related_filter_definition, + related_sort_definition=src.related_sort_definition, + related_parameters=src.related_parameters, + owner_user_id=user.id, + visibility=SavedViewVisibility.PRIVATE.value, + ) + db.add(clone) + await db.commit() + await db.refresh(clone) + return SavedViewType.from_model(clone, current_user_id=user.id) diff --git a/backend/api/resolvers/task.py b/backend/api/resolvers/task.py index cc41c29d..ae7a4616 100644 --- a/backend/api/resolvers/task.py +++ b/backend/api/resolvers/task.py @@ -1,40 +1,74 @@ from collections.abc import AsyncGenerator +from typing import Any import strawberry from api.audit import audit_log from api.context import Info -from api.decorators.filter_sort import ( - apply_filtering, - apply_sorting, - filtered_and_sorted_query, - get_property_field_types, -) -from api.decorators.full_text_search import ( - apply_full_text_search, - full_text_search_query, -) from api.errors import raise_forbidden from api.inputs import ( + ApplyTaskGraphInput, CreateTaskInput, - FilterInput, - FullTextSearchInput, PaginationInput, PatientState, - SortInput, + SortDirection, UpdateTaskInput, ) +from api.query.execute import count_unified_query, is_unset, unified_list_query +from api.query.inputs import ( + QueryFilterClauseInput, + QuerySearchInput, + QuerySortClauseInput, +) +from api.query.registry import TASK from api.resolvers.base import BaseMutationResolver, BaseSubscriptionResolver from api.services.authorization import AuthorizationService from api.services.checksum import validate_checksum from api.services.datetime import normalize_datetime_to_utc from api.services.property import PropertyService +from api.services.task_graph import ( + apply_task_graph_to_patient, + graph_dict_from_preset_inputs, + insert_task_dependencies, + replace_incoming_task_dependencies, + validate_task_graph_dict, +) from api.types.task import TaskType from database import models +from database.models.task_preset import TaskPresetScope as DbTaskPresetScope from graphql import GraphQLError -from sqlalchemy import desc, func, select +from sqlalchemy import and_, exists, or_, select from sqlalchemy.orm import aliased, selectinload +def _assignee_match_clause(user_id: strawberry.ID | None): + if not user_id: + return None + return exists( + select(1).where( + models.task_assignees.c.task_id == models.Task.id, + models.task_assignees.c.user_id == user_id, + ) + ) + + +def _patient_visibility_clause(location_cte, patient_locations, patient_teams): + return ( + (models.Patient.clinic_id.in_(select(location_cte.c.id))) + | ( + models.Patient.position_id.isnot(None) + & models.Patient.position_id.in_(select(location_cte.c.id)) + ) + | ( + models.Patient.assigned_location_id.isnot(None) + & models.Patient.assigned_location_id.in_( + select(location_cte.c.id), + ) + ) + | (patient_locations.c.location_id.in_(select(location_cte.c.id))) + | (patient_teams.c.location_id.in_(select(location_cte.c.id))) + ) + + @strawberry.type class TaskQuery: @strawberry.field @@ -46,22 +80,22 @@ async def task(self, info: Info, id: strawberry.ID) -> TaskType | None: selectinload(models.Task.patient).selectinload( models.Patient.assigned_locations, ), + selectinload(models.Task.assignees), ), ) task = result.scalars().first() - if task and task.patient: + if task: auth_service = AuthorizationService(info.context.db) - if not await auth_service.can_access_patient( + if not await auth_service.can_access_task( info.context.user, - task.patient, + task, info.context, ): raise_forbidden() return task @strawberry.field - @filtered_and_sorted_query() - @full_text_search_query() + @unified_list_query(TASK) async def tasks( self, info: Info, @@ -69,10 +103,10 @@ async def tasks( assignee_id: strawberry.ID | None = None, assignee_team_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, pagination: PaginationInput | None = None, - search: FullTextSearchInput | None = None, + search: QuerySearchInput | None = None, ) -> list[TaskType]: auth_service = AuthorizationService(info.context.db) @@ -90,12 +124,20 @@ async def tasks( selectinload(models.Task.patient).selectinload( models.Patient.assigned_locations, ), + selectinload(models.Task.assignees), ) .where(models.Task.patient_id == patient_id) ) if assignee_id: - query = query.where(models.Task.assignee_id == assignee_id) + query = query.where( + exists( + select(1).where( + models.task_assignees.c.task_id == models.Task.id, + models.task_assignees.c.user_id == assignee_id, + ) + ) + ) if assignee_team_id: query = query.where( models.Task.assignee_team_id == assignee_team_id, @@ -164,14 +206,25 @@ async def tasks( ) team_location_cte = team_location_cte.union_all(team_children) + viewer_assignee_clause = _assignee_match_clause( + info.context.user.id if info.context.user else None + ) + no_patient_scope_clause = models.Task.assignee_team_id.in_(select(root_cte.c.id)) + if viewer_assignee_clause is not None: + no_patient_scope_clause = or_( + viewer_assignee_clause, + no_patient_scope_clause, + ) + query = ( select(models.Task) .options( selectinload(models.Task.patient).selectinload( models.Patient.assigned_locations, ), + selectinload(models.Task.assignees), ) - .join(models.Patient, models.Task.patient_id == models.Patient.id) + .outerjoin(models.Patient, models.Task.patient_id == models.Patient.id) .outerjoin( patient_locations, models.Patient.id == patient_locations.c.patient_id, @@ -181,30 +234,32 @@ async def tasks( models.Patient.id == patient_teams.c.patient_id, ) .where( - (models.Patient.clinic_id.in_(select(root_cte.c.id))) - | ( - models.Patient.position_id.isnot(None) - & models.Patient.position_id.in_(select(root_cte.c.id)) - ) - | ( - models.Patient.assigned_location_id.isnot(None) - & models.Patient.assigned_location_id.in_( - select(root_cte.c.id), + ( + and_( + models.Task.patient_id.isnot(None), + _patient_visibility_clause(root_cte, patient_locations, patient_teams), + models.Patient.state.notin_( + [PatientState.DISCHARGED.value, PatientState.DEAD.value] + ), ) ) - | (patient_locations.c.location_id.in_(select(root_cte.c.id))) - | (patient_teams.c.location_id.in_(select(root_cte.c.id))), - ) - .where( - models.Patient.state.notin_( - [PatientState.DISCHARGED.value, PatientState.DEAD.value] + | and_( + models.Task.patient_id.is_(None), + no_patient_scope_clause, ) ) .distinct() ) if assignee_id: - query = query.where(models.Task.assignee_id == assignee_id) + query = query.where( + exists( + select(1).where( + models.task_assignees.c.task_id == models.Task.id, + models.task_assignees.c.user_id == assignee_id, + ) + ) + ) if assignee_team_id: query = query.where( models.Task.assignee_team_id.in_( @@ -222,9 +277,9 @@ async def tasksTotal( assignee_id: strawberry.ID | None = None, assignee_team_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, - search: FullTextSearchInput | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, + search: QuerySearchInput | None = None, ) -> int: auth_service = AuthorizationService(info.context.db) @@ -241,7 +296,14 @@ async def tasksTotal( ) if assignee_id: - query = query.where(models.Task.assignee_id == assignee_id) + query = query.where( + exists( + select(1).where( + models.task_assignees.c.task_id == models.Task.id, + models.task_assignees.c.user_id == assignee_id, + ) + ) + ) if assignee_team_id: query = query.where( models.Task.assignee_team_id == assignee_team_id, @@ -308,9 +370,19 @@ async def tasksTotal( ) team_location_cte = team_location_cte.union_all(team_children) + viewer_assignee_clause = _assignee_match_clause( + info.context.user.id if info.context.user else None + ) + no_patient_scope_clause = models.Task.assignee_team_id.in_(select(root_cte.c.id)) + if viewer_assignee_clause is not None: + no_patient_scope_clause = or_( + viewer_assignee_clause, + no_patient_scope_clause, + ) + query = ( select(models.Task) - .join( + .outerjoin( models.Patient, models.Task.patient_id == models.Patient.id, ) @@ -323,34 +395,36 @@ async def tasksTotal( models.Patient.id == patient_teams.c.patient_id, ) .where( - (models.Patient.clinic_id.in_(select(root_cte.c.id))) - | ( - models.Patient.position_id.isnot(None) - & models.Patient.position_id.in_(select(root_cte.c.id)) - ) - | ( - models.Patient.assigned_location_id.isnot(None) - & models.Patient.assigned_location_id.in_( - select(root_cte.c.id), - ) - ) - | ( - patient_locations.c.location_id.in_( - select(root_cte.c.id), + ( + and_( + models.Task.patient_id.isnot(None), + _patient_visibility_clause( + root_cte, + patient_locations, + patient_teams, + ), + models.Patient.state.notin_( + [PatientState.DISCHARGED.value, PatientState.DEAD.value] + ), ) ) - | (patient_teams.c.location_id.in_(select(root_cte.c.id))), - ) - .where( - models.Patient.state.notin_( - [PatientState.DISCHARGED.value, PatientState.DEAD.value] + | and_( + models.Task.patient_id.is_(None), + no_patient_scope_clause, ) ) .distinct() ) if assignee_id: - query = query.where(models.Task.assignee_id == assignee_id) + query = query.where( + exists( + select(1).where( + models.task_assignees.c.task_id == models.Task.id, + models.task_assignees.c.user_id == assignee_id, + ) + ) + ) if assignee_team_id: query = query.where( models.Task.assignee_team_id.in_( @@ -358,45 +432,34 @@ async def tasksTotal( ), ) - if search and search is not strawberry.UNSET: - query = apply_full_text_search(query, search, models.Task) - - property_field_types = await get_property_field_types( - info.context.db, - filtering, - sorting, + return await count_unified_query( + query, + entity=TASK, + db=info.context.db, + filters=filters if filters is not None and not is_unset(filters) else None, + sorts=sorts if sorts is not None and not is_unset(sorts) else None, + search=search if search is not None and not is_unset(search) else None, + info=info, ) - if filtering: - query = apply_filtering( - query, - filtering, - models.Task, - property_field_types, - ) - if sorting: - query = apply_sorting( - query, - sorting, - models.Task, - property_field_types, - ) - - subquery = query.subquery() - count_query = select(func.count(func.distinct(subquery.c.id))) - result = await info.context.db.execute(count_query) - return result.scalar() or 0 @strawberry.field - @filtered_and_sorted_query() - @full_text_search_query() + @unified_list_query( + TASK, + default_sorts_when_empty=[ + QuerySortClauseInput( + field_key="updateDate", + direction=SortDirection.DESC, + ) + ], + ) async def recent_tasks( self, info: Info, root_location_ids: list[strawberry.ID] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, pagination: PaginationInput | None = None, - search: FullTextSearchInput | None = None, + search: QuerySearchInput | None = None, ) -> list[TaskType]: auth_service = AuthorizationService(info.context.db) accessible_location_ids = ( @@ -446,14 +509,25 @@ async def recent_tasks( else: location_cte = cte + viewer_assignee_clause = _assignee_match_clause( + info.context.user.id if info.context.user else None + ) + no_patient_scope_clause = models.Task.assignee_team_id.in_(select(location_cte.c.id)) + if viewer_assignee_clause is not None: + no_patient_scope_clause = or_( + viewer_assignee_clause, + no_patient_scope_clause, + ) + query = ( select(models.Task) .options( selectinload(models.Task.patient).selectinload( models.Patient.assigned_locations, ), + selectinload(models.Task.assignees), ) - .join(models.Patient, models.Task.patient_id == models.Patient.id) + .outerjoin(models.Patient, models.Task.patient_id == models.Patient.id) .outerjoin( patient_locations, models.Patient.id == patient_locations.c.patient_id, @@ -463,36 +537,27 @@ async def recent_tasks( models.Patient.id == patient_teams.c.patient_id, ) .where( - (models.Patient.clinic_id.in_(select(location_cte.c.id))) - | ( - models.Patient.position_id.isnot(None) - & models.Patient.position_id.in_(select(location_cte.c.id)) - ) - | ( - models.Patient.assigned_location_id.isnot(None) - & models.Patient.assigned_location_id.in_( - select(location_cte.c.id), - ) - ) - | ( - patient_locations.c.location_id.in_( - select(location_cte.c.id), + ( + and_( + models.Task.patient_id.isnot(None), + _patient_visibility_clause( + location_cte, + patient_locations, + patient_teams, + ), + models.Patient.state.notin_( + [PatientState.DISCHARGED.value, PatientState.DEAD.value] + ), ) ) - | (patient_teams.c.location_id.in_(select(location_cte.c.id))), - ) - .where( - models.Patient.state.notin_( - [PatientState.DISCHARGED.value, PatientState.DEAD.value] + | and_( + models.Task.patient_id.is_(None), + no_patient_scope_clause, ) ) .distinct() ) - default_sorting = sorting is None or len(sorting) == 0 - if default_sorting: - query = query.order_by(desc(models.Task.update_date)) - return query @strawberry.field @@ -500,9 +565,9 @@ async def recentTasksTotal( self, info: Info, root_location_ids: list[strawberry.ID] | None = None, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, - search: FullTextSearchInput | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, + search: QuerySearchInput | None = None, ) -> int: auth_service = AuthorizationService(info.context.db) accessible_location_ids = ( @@ -552,9 +617,19 @@ async def recentTasksTotal( else: location_cte = cte + viewer_assignee_clause = _assignee_match_clause( + info.context.user.id if info.context.user else None + ) + no_patient_scope_clause = models.Task.assignee_team_id.in_(select(location_cte.c.id)) + if viewer_assignee_clause is not None: + no_patient_scope_clause = or_( + viewer_assignee_clause, + no_patient_scope_clause, + ) + query = ( select(models.Task) - .join(models.Patient, models.Task.patient_id == models.Patient.id) + .outerjoin(models.Patient, models.Task.patient_id == models.Patient.id) .outerjoin( patient_locations, models.Patient.id == patient_locations.c.patient_id, @@ -564,59 +639,36 @@ async def recentTasksTotal( models.Patient.id == patient_teams.c.patient_id, ) .where( - (models.Patient.clinic_id.in_(select(location_cte.c.id))) - | ( - models.Patient.position_id.isnot(None) - & models.Patient.position_id.in_(select(location_cte.c.id)) - ) - | ( - models.Patient.assigned_location_id.isnot(None) - & models.Patient.assigned_location_id.in_( - select(location_cte.c.id), - ) - ) - | ( - patient_locations.c.location_id.in_( - select(location_cte.c.id), + ( + and_( + models.Task.patient_id.isnot(None), + _patient_visibility_clause( + location_cte, + patient_locations, + patient_teams, + ), + models.Patient.state.notin_( + [PatientState.DISCHARGED.value, PatientState.DEAD.value] + ), ) ) - | (patient_teams.c.location_id.in_(select(location_cte.c.id))), - ) - .where( - models.Patient.state.notin_( - [PatientState.DISCHARGED.value, PatientState.DEAD.value] + | and_( + models.Task.patient_id.is_(None), + no_patient_scope_clause, ) ) .distinct() ) - if search and search is not strawberry.UNSET: - query = apply_full_text_search(query, search, models.Task) - - property_field_types = await get_property_field_types( - info.context.db, - filtering, - sorting, + return await count_unified_query( + query, + entity=TASK, + db=info.context.db, + filters=filters if filters is not None and not is_unset(filters) else None, + sorts=sorts if sorts is not None and not is_unset(sorts) else None, + search=search if search is not None and not is_unset(search) else None, + info=info, ) - if filtering: - query = apply_filtering( - query, - filtering, - models.Task, - property_field_types, - ) - if sorting: - query = apply_sorting( - query, - sorting, - models.Task, - property_field_types, - ) - - subquery = query.subquery() - count_query = select(func.count(func.distinct(subquery.c.id))) - result = await info.context.db.execute(count_query) - return result.scalar() or 0 @strawberry.type @@ -625,31 +677,62 @@ class TaskMutation(BaseMutationResolver[models.Task]): def _get_property_service(db) -> PropertyService: return PropertyService(db) + @staticmethod + async def _users_by_ids(info: Info, user_ids: list[strawberry.ID] | None) -> list[models.User]: + if not user_ids: + return [] + result = await info.context.db.execute( + select(models.User).where(models.User.id.in_(user_ids)) + ) + users = result.scalars().all() + if len(users) != len(set(str(user_id) for user_id in user_ids)): + raise GraphQLError( + "One or more assignee users were not found.", + extensions={"code": "BAD_REQUEST"}, + ) + return users + + @staticmethod + def _validate_task_scope( + patient_id: strawberry.ID | None, + assignee_count: int, + assignee_team_id: strawberry.ID | None, + ) -> None: + if assignee_count > 0 and assignee_team_id is not None: + raise GraphQLError( + "Cannot assign both users and a team. Please assign either users or a team.", + extensions={"code": "BAD_REQUEST"}, + ) + if patient_id is None and assignee_count == 0 and assignee_team_id is None: + raise GraphQLError( + "Task must have a patient, assignees, or an assignee team.", + extensions={"code": "BAD_REQUEST"}, + ) + @strawberry.mutation @audit_log("create_task") async def create_task(self, info: Info, data: CreateTaskInput) -> TaskType: auth_service = AuthorizationService(info.context.db) - if not await auth_service.can_access_patient_id( + if data.patient_id and not await auth_service.can_access_patient_id( info.context.user, data.patient_id, info.context, ): raise_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"}, - ) + assignees = await TaskMutation._users_by_ids(info, data.assignee_ids) + TaskMutation._validate_task_scope( + data.patient_id, + len(assignees), + data.assignee_team_id, + ) 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 - ), + assignees=assignees, + assignee_team_id=(data.assignee_team_id if len(assignees) == 0 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, @@ -684,6 +767,14 @@ async def create_task(self, info: Info, data: CreateTaskInput) -> TaskType: "payload": {"task_id": task.id, "task_title": task.title}, }, ) + if data.previous_task_ids: + await insert_task_dependencies( + info.context.db, + task.id, + [str(x) for x in data.previous_task_ids], + str(task.patient_id), + ) + await info.context.db.commit() return task @strawberry.mutation @@ -702,20 +793,20 @@ async def update_task( selectinload(models.Task.patient).selectinload( models.Patient.assigned_locations, ), + selectinload(models.Task.assignees), ), ) task = result.scalars().first() if not task: raise Exception("Task not found") - if task.patient: - auth_service = AuthorizationService(db) - if not await auth_service.can_access_patient( - info.context.user, - task.patient, - info.context, - ): - raise_forbidden() + auth_service = AuthorizationService(db) + if not await auth_service.can_access_task( + info.context.user, + task, + info.context, + ): + raise_forbidden() if data.checksum: validate_checksum(task, data.checksum, "Task") @@ -724,6 +815,14 @@ async def update_task( task.title = data.title if data.description is not None: task.description = data.description + if data.patient_id is not strawberry.UNSET: + if data.patient_id and not await auth_service.can_access_patient_id( + info.context.user, + data.patient_id, + info.context, + ): + raise_forbidden() + task.patient_id = data.patient_id if data.done is not None: task.done = data.done @@ -740,22 +839,27 @@ 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"}, - ) + next_assignees = task.assignees + if data.assignee_ids is not strawberry.UNSET: + next_assignees = await TaskMutation._users_by_ids(info, data.assignee_ids) + task.assignees = next_assignees - 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: + next_assignee_team_id = task.assignee_team_id + if data.assignee_team_id is not strawberry.UNSET: + next_assignee_team_id = data.assignee_team_id task.assignee_team_id = data.assignee_team_id - task.assignee_id = None + if data.assignee_team_id is not None: + task.assignees = [] + next_assignees = [] + elif data.assignee_ids is not strawberry.UNSET and len(next_assignees) > 0: + task.assignee_team_id = None + next_assignee_team_id = None + + TaskMutation._validate_task_scope( + task.patient_id, + len(next_assignees), + next_assignee_team_id, + ) if data.properties is not None: property_service = TaskMutation._get_property_service(db) @@ -765,7 +869,7 @@ async def update_task( "task", ) - return await BaseMutationResolver.update_and_notify( + result = await BaseMutationResolver.update_and_notify( info, task, models.Task, @@ -773,6 +877,15 @@ async def update_task( "patient", task.patient_id, ) + if data.previous_task_ids is not None: + await replace_incoming_task_dependencies( + info.context.db, + str(id), + [str(x) for x in data.previous_task_ids], + str(task.patient_id), + ) + await info.context.db.commit() + return result @staticmethod async def _update_task_field( @@ -788,22 +901,27 @@ async def _update_task_field( selectinload(models.Task.patient).selectinload( models.Patient.assigned_locations, ), + selectinload(models.Task.assignees), ), ) task = result.scalars().first() if not task: raise Exception("Task not found") - if task.patient: - auth_service = AuthorizationService(db) - if not await auth_service.can_access_patient( - info.context.user, - task.patient, - info.context, - ): - raise_forbidden() + auth_service = AuthorizationService(db) + if not await auth_service.can_access_task( + info.context.user, + task, + info.context, + ): + raise_forbidden() field_updater(task) + TaskMutation._validate_task_scope( + task.patient_id, + len(task.assignees), + task.assignee_team_id, + ) await BaseMutationResolver.update_and_notify( info, task, @@ -815,31 +933,46 @@ async def _update_task_field( return task @strawberry.mutation - @audit_log("assign_task") - async def assign_task( + @audit_log("add_task_assignee") + async def add_task_assignee( self, info: Info, id: strawberry.ID, user_id: strawberry.ID, ) -> TaskType: + user_result = await info.context.db.execute( + select(models.User).where(models.User.id == user_id) + ) + user = user_result.scalars().first() + if user is None: + raise GraphQLError( + "Assignee user was not found.", + extensions={"code": "BAD_REQUEST"}, + ) return await TaskMutation._update_task_field( info, id, lambda task: ( - setattr(task, "assignee_id", user_id), setattr(task, "assignee_team_id", None), + task.assignees.append(user) if user not in task.assignees else None, ), ) @strawberry.mutation - @audit_log("unassign_task") - async def unassign_task(self, info: Info, id: strawberry.ID) -> TaskType: + @audit_log("remove_task_assignee") + async def remove_task_assignee( + self, + info: Info, + id: strawberry.ID, + user_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), + lambda task: setattr( + task, + "assignees", + [assignee for assignee in task.assignees if assignee.id != user_id], ), ) @@ -855,8 +988,8 @@ async def assign_task_to_team( info, id, lambda task: ( - setattr(task, "assignee_id", None), setattr(task, "assignee_team_id", team_id), + setattr(task, "assignees", []), ), ) @@ -871,7 +1004,6 @@ async def unassign_task_from_team( info, id, lambda task: ( - setattr(task, "assignee_id", None), setattr(task, "assignee_team_id", None), ), ) @@ -906,6 +1038,68 @@ async def reopen_task(self, info: Info, id: strawberry.ID) -> TaskType: lambda task: setattr(task, "done", False), ) + @strawberry.mutation + @audit_log("apply_task_graph") + async def apply_task_graph( + self, + info: Info, + data: ApplyTaskGraphInput, + ) -> list[TaskType]: + user = info.context.user + if not user: + raise GraphQLError( + "Not authenticated", + extensions={"code": "UNAUTHENTICATED"}, + ) + auth_service = AuthorizationService(info.context.db) + if not await auth_service.can_access_patient_id( + user, + data.patient_id, + info.context, + ): + raise_forbidden() + has_preset = data.preset_id is not None + has_graph = data.graph is not None + if has_preset == has_graph: + raise GraphQLError( + "Provide exactly one of presetId or graph", + extensions={"code": "BAD_REQUEST"}, + ) + graph_dict: dict[str, Any] + if data.preset_id: + pr = await info.context.db.execute( + select(models.TaskPreset).where( + models.TaskPreset.id == data.preset_id, + ), + ) + preset = pr.scalars().first() + if not preset: + raise GraphQLError( + "Preset not found", + extensions={"code": "NOT_FOUND"}, + ) + if ( + preset.scope == DbTaskPresetScope.PERSONAL.value + and preset.owner_user_id != user.id + ): + raise_forbidden() + graph_dict = preset.graph_json + else: + graph_dict = graph_dict_from_preset_inputs( + data.graph.nodes, + data.graph.edges, + ) + validate_task_graph_dict(graph_dict) + assignee_id = user.id if data.assign_to_current_user else None + source_preset_id = str(data.preset_id) if data.preset_id else None + return await apply_task_graph_to_patient( + info.context.db, + str(data.patient_id), + graph_dict, + assignee_id, + source_preset_id, + ) + @strawberry.mutation @audit_log("delete_task") async def delete_task(self, info: Info, id: strawberry.ID) -> bool: @@ -917,20 +1111,20 @@ async def delete_task(self, info: Info, id: strawberry.ID) -> bool: selectinload(models.Task.patient).selectinload( models.Patient.assigned_locations, ), + selectinload(models.Task.assignees), ), ) task = result.scalars().first() if not task: return False - if task.patient: - auth_service = AuthorizationService(db) - if not await auth_service.can_access_patient( - info.context.user, - task.patient, - info.context, - ): - raise_forbidden() + auth_service = AuthorizationService(db) + if not await auth_service.can_access_task( + info.context.user, + task, + info.context, + ): + raise_forbidden() patient_id = task.patient_id await BaseMutationResolver.delete_entity( diff --git a/backend/api/resolvers/task_preset.py b/backend/api/resolvers/task_preset.py new file mode 100644 index 00000000..eb606573 --- /dev/null +++ b/backend/api/resolvers/task_preset.py @@ -0,0 +1,248 @@ +import re +import uuid + +import strawberry +from api.context import Info +from api.errors import raise_forbidden +from api.inputs import CreateTaskPresetInput, UpdateTaskPresetInput +from api.services.task_graph import ( + graph_dict_from_preset_inputs, + validate_task_graph_dict, +) +from api.types.task_preset import TaskPresetType, task_preset_type_from_model +from database import models +from database.models.task_preset import TaskPresetScope as DbTaskPresetScope +from graphql import GraphQLError +from sqlalchemy import and_, or_, select + + +def _slugify(name: str) -> str: + s = re.sub(r"[^a-zA-Z0-9]+", "-", name.strip()).lower().strip("-") + return s or "preset" + + +async def _key_is_available( + db, + key: str, + exclude_id: str | None = None, +) -> bool: + q = select(models.TaskPreset).where(models.TaskPreset.key == key) + if exclude_id: + q = q.where(models.TaskPreset.id != exclude_id) + r = await db.execute(q) + return r.scalars().first() is None + + +async def _generate_unique_key(db, name: str) -> str: + base = f"{_slugify(name)}-{uuid.uuid4().hex[:8]}" + if await _key_is_available(db, base): + return base + for _ in range(20): + candidate = f"{_slugify(name)}-{uuid.uuid4().hex[:8]}" + if await _key_is_available(db, candidate): + return candidate + raise GraphQLError( + "Could not allocate a unique preset key", + extensions={"code": "BAD_REQUEST"}, + ) + + +def _can_edit_preset( + preset: models.TaskPreset, + user_id: str, +) -> bool: + if preset.scope == DbTaskPresetScope.PERSONAL.value: + return preset.owner_user_id == user_id + return True + + +def _can_delete_preset( + preset: models.TaskPreset, + user_id: str, +) -> bool: + if preset.scope == DbTaskPresetScope.PERSONAL.value: + return preset.owner_user_id == user_id + return True + + +@strawberry.type +class TaskPresetQuery: + @strawberry.field + async def task_presets(self, info: Info) -> list[TaskPresetType]: + user = info.context.user + if not user: + raise GraphQLError( + "Not authenticated", + extensions={"code": "UNAUTHENTICATED"}, + ) + q = ( + select(models.TaskPreset) + .where( + or_( + models.TaskPreset.scope == DbTaskPresetScope.GLOBAL.value, + and_( + models.TaskPreset.scope == DbTaskPresetScope.PERSONAL.value, + models.TaskPreset.owner_user_id == user.id, + ), + ), + ) + .order_by(models.TaskPreset.name) + ) + r = await info.context.db.execute(q) + rows = r.scalars().all() + return [task_preset_type_from_model(p) for p in rows] + + @strawberry.field + async def task_preset( + self, + info: Info, + id: strawberry.ID, + ) -> TaskPresetType | None: + user = info.context.user + if not user: + raise GraphQLError( + "Not authenticated", + extensions={"code": "UNAUTHENTICATED"}, + ) + r = await info.context.db.execute( + select(models.TaskPreset).where(models.TaskPreset.id == id), + ) + preset = r.scalars().first() + if not preset: + return None + if preset.scope == DbTaskPresetScope.PERSONAL.value and preset.owner_user_id != user.id: + raise_forbidden() + return task_preset_type_from_model(preset) + + @strawberry.field + async def task_preset_by_key( + self, + info: Info, + key: str, + ) -> TaskPresetType | None: + user = info.context.user + if not user: + raise GraphQLError( + "Not authenticated", + extensions={"code": "UNAUTHENTICATED"}, + ) + r = await info.context.db.execute( + select(models.TaskPreset).where(models.TaskPreset.key == key), + ) + preset = r.scalars().first() + if not preset: + return None + if preset.scope == DbTaskPresetScope.PERSONAL.value and preset.owner_user_id != user.id: + raise_forbidden() + return task_preset_type_from_model(preset) + + +@strawberry.type +class TaskPresetMutation: + @strawberry.mutation + async def create_task_preset( + self, + info: Info, + data: CreateTaskPresetInput, + ) -> TaskPresetType: + user = info.context.user + if not user: + raise GraphQLError( + "Not authenticated", + extensions={"code": "UNAUTHENTICATED"}, + ) + graph_dict = graph_dict_from_preset_inputs(data.graph.nodes, data.graph.edges) + validate_task_graph_dict(graph_dict) + scope_val = data.scope.value + if scope_val == DbTaskPresetScope.PERSONAL.value: + owner_id = user.id + else: + owner_id = None + if data.key: + if not await _key_is_available(info.context.db, data.key): + raise GraphQLError( + "Preset key already exists", + extensions={"code": "BAD_REQUEST"}, + ) + key = data.key + else: + key = await _generate_unique_key(info.context.db, data.name) + preset = models.TaskPreset( + name=data.name, + key=key, + scope=scope_val, + owner_user_id=owner_id, + graph_json=graph_dict, + ) + info.context.db.add(preset) + await info.context.db.commit() + await info.context.db.refresh(preset) + return task_preset_type_from_model(preset) + + @strawberry.mutation + async def update_task_preset( + self, + info: Info, + id: strawberry.ID, + data: UpdateTaskPresetInput, + ) -> TaskPresetType: + user = info.context.user + if not user: + raise GraphQLError( + "Not authenticated", + extensions={"code": "UNAUTHENTICATED"}, + ) + r = await info.context.db.execute( + select(models.TaskPreset).where(models.TaskPreset.id == id), + ) + preset = r.scalars().first() + if not preset: + raise GraphQLError( + "Preset not found", + extensions={"code": "NOT_FOUND"}, + ) + if not _can_edit_preset(preset, user.id): + raise_forbidden() + if data.key is not None: + if not await _key_is_available(info.context.db, data.key, str(id)): + raise GraphQLError( + "Preset key already exists", + extensions={"code": "BAD_REQUEST"}, + ) + preset.key = data.key + if data.name is not None: + preset.name = data.name + if data.graph is not None: + graph_dict = graph_dict_from_preset_inputs(data.graph.nodes, data.graph.edges) + validate_task_graph_dict(graph_dict) + preset.graph_json = graph_dict + await info.context.db.commit() + await info.context.db.refresh(preset) + return task_preset_type_from_model(preset) + + @strawberry.mutation + async def delete_task_preset( + self, + info: Info, + id: strawberry.ID, + ) -> bool: + user = info.context.user + if not user: + raise GraphQLError( + "Not authenticated", + extensions={"code": "UNAUTHENTICATED"}, + ) + r = await info.context.db.execute( + select(models.TaskPreset).where(models.TaskPreset.id == id), + ) + preset = r.scalars().first() + if not preset: + raise GraphQLError( + "Preset not found", + extensions={"code": "NOT_FOUND"}, + ) + if not _can_delete_preset(preset, user.id): + raise_forbidden() + await info.context.db.delete(preset) + await info.context.db.commit() + return True diff --git a/backend/api/resolvers/user.py b/backend/api/resolvers/user.py index e4d97525..4b9ac92a 100644 --- a/backend/api/resolvers/user.py +++ b/backend/api/resolvers/user.py @@ -1,14 +1,13 @@ import strawberry from api.context import Info -from api.decorators.filter_sort import filtered_and_sorted_query -from api.decorators.full_text_search import full_text_search_query -from api.inputs import ( - FilterInput, - FullTextSearchInput, - PaginationInput, - SortInput, - UpdateProfilePictureInput, +from api.inputs import PaginationInput, UpdateProfilePictureInput +from api.query.execute import unified_list_query +from api.query.inputs import ( + QueryFilterClauseInput, + QuerySearchInput, + QuerySortClauseInput, ) +from api.query.registry import USER from api.resolvers.base import BaseMutationResolver from api.types.user import UserType from database import models @@ -26,15 +25,14 @@ async def user(self, info: Info, id: strawberry.ID) -> UserType | None: return result.scalars().first() @strawberry.field - @filtered_and_sorted_query() - @full_text_search_query() + @unified_list_query(USER) async def users( self, info: Info, - filtering: list[FilterInput] | None = None, - sorting: list[SortInput] | None = None, + filters: list[QueryFilterClauseInput] | None = None, + sorts: list[QuerySortClauseInput] | None = None, pagination: PaginationInput | None = None, - search: FullTextSearchInput | None = None, + search: QuerySearchInput | None = None, ) -> list[UserType]: query = select(models.User) return query diff --git a/backend/api/services/authorization.py b/backend/api/services/authorization.py index 88449675..998bdd8e 100644 --- a/backend/api/services/authorization.py +++ b/backend/api/services/authorization.py @@ -122,6 +122,35 @@ async def can_access_patient_id( return await self.can_access_patient(user, patient, context) + async def can_access_task( + self, + user: models.User | None, + task: models.Task, + context=None, + ) -> bool: + if not user: + return False + + if task.patient_id: + if task.patient is not None: + return await self.can_access_patient(user, task.patient, context) + return await self.can_access_patient_id(user, task.patient_id, context) + + result = await self.db.execute( + select(models.task_assignees.c.user_id).where( + models.task_assignees.c.task_id == task.id, + models.task_assignees.c.user_id == user.id, + ) + ) + if result.first() is not None: + return True + + if task.assignee_team_id: + accessible_location_ids = await self.get_user_accessible_location_ids(user, context) + return task.assignee_team_id in accessible_location_ids + + return False + def filter_patients_by_access( self, user: models.User | None, query, accessible_location_ids: set[str] | None = None ): @@ -148,7 +177,7 @@ def filter_patients_by_access( patient_locations = aliased(models.patient_locations) patient_teams = aliased(models.patient_teams) - return ( + expanded = ( query.outerjoin( patient_locations, models.Patient.id == patient_locations.c.patient_id, @@ -170,5 +199,10 @@ def filter_patients_by_access( | (patient_locations.c.location_id.in_(select(cte.c.id))) | (patient_teams.c.location_id.in_(select(cte.c.id))) ) - .distinct() ) + opts = getattr(expanded, "_with_options", None) or () + ids_sq = expanded.with_only_columns(models.Patient.id).distinct().scalar_subquery() + out = select(models.Patient).where(models.Patient.id.in_(ids_sq)) + for opt in opts: + out = out.options(opt) + return out diff --git a/backend/api/services/subscription.py b/backend/api/services/subscription.py index 4a44757e..fd773ead 100644 --- a/backend/api/services/subscription.py +++ b/backend/api/services/subscription.py @@ -136,12 +136,29 @@ async def task_belongs_to_root_locations( ) task = result.scalars().first() - if not task or not task.patient: + if not task: return False - return await patient_belongs_to_root_locations( - db, task.patient.id, root_location_ids + if task.patient: + return await patient_belongs_to_root_locations( + db, task.patient.id, root_location_ids + ) + + if not task.assignee_team_id: + return False + + root_cte = ( + select(models.LocationNode.id) + .where(models.LocationNode.id.in_(root_location_ids)) + .cte(name="root_location_descendants", recursive=True) + ) + root_children = select(models.LocationNode.id).join( + root_cte, models.LocationNode.parent_id == root_cte.c.id ) + root_cte = root_cte.union(root_children) + result = await db.execute(select(root_cte.c.id)) + root_location_descendants = {row[0] for row in result.all()} + return task.assignee_team_id in root_location_descendants async def subscribe_with_location_filter( diff --git a/backend/api/services/task_graph.py b/backend/api/services/task_graph.py new file mode 100644 index 00000000..fa5d7d2a --- /dev/null +++ b/backend/api/services/task_graph.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from typing import Any + +from api.services.notifications import notify_entity_created, notify_entity_update +from database import models +from graphql import GraphQLError +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + + +def validate_task_graph_dict(graph: dict[str, Any]) -> None: + nodes_raw = graph.get("nodes") + edges_raw = graph.get("edges") + if not isinstance(nodes_raw, list) or len(nodes_raw) == 0: + raise GraphQLError( + "Task graph must contain at least one node", + extensions={"code": "BAD_REQUEST"}, + ) + if not isinstance(edges_raw, list): + raise GraphQLError( + "Task graph edges must be a list", + extensions={"code": "BAD_REQUEST"}, + ) + node_ids: set[str] = set() + for i, n in enumerate(nodes_raw): + if not isinstance(n, dict): + raise GraphQLError( + f"Invalid node at index {i}", + extensions={"code": "BAD_REQUEST"}, + ) + nid = n.get("id") + if not nid or not isinstance(nid, str): + raise GraphQLError( + "Each node requires a string id", + extensions={"code": "BAD_REQUEST"}, + ) + if nid in node_ids: + raise GraphQLError( + f"Duplicate node id: {nid}", + extensions={"code": "BAD_REQUEST"}, + ) + node_ids.add(nid) + title = n.get("title") + if not title or not isinstance(title, str): + raise GraphQLError( + f"Node {nid} requires a non-empty title", + extensions={"code": "BAD_REQUEST"}, + ) + for i, e in enumerate(edges_raw): + if not isinstance(e, dict): + raise GraphQLError( + f"Invalid edge at index {i}", + extensions={"code": "BAD_REQUEST"}, + ) + f_id = e.get("from") + t_id = e.get("to") + if not f_id or not t_id: + raise GraphQLError( + "Each edge requires from and to node ids", + extensions={"code": "BAD_REQUEST"}, + ) + if f_id not in node_ids or t_id not in node_ids: + raise GraphQLError( + "Edge references unknown node id", + extensions={"code": "BAD_REQUEST"}, + ) + if f_id == t_id: + raise GraphQLError( + "Self-referential task dependency is not allowed", + extensions={"code": "BAD_REQUEST"}, + ) + _assert_acyclic(node_ids, edges_raw) + + +def _assert_acyclic(node_ids: set[str], edges_raw: list[dict[str, Any]]) -> None: + adj: dict[str, list[str]] = defaultdict(list) + indeg: dict[str, int] = {nid: 0 for nid in node_ids} + for e in edges_raw: + f_id = e["from"] + t_id = e["to"] + adj[f_id].append(t_id) + indeg[t_id] += 1 + q = deque([nid for nid in node_ids if indeg[nid] == 0]) + visited = 0 + while q: + u = q.popleft() + visited += 1 + for v in adj[u]: + indeg[v] -= 1 + if indeg[v] == 0: + q.append(v) + if visited != len(node_ids): + raise GraphQLError( + "Task graph contains a cycle", + extensions={"code": "BAD_REQUEST"}, + ) + + +async def insert_task_dependencies( + db: AsyncSession, + next_task_id: str, + previous_task_ids: list[str], + patient_id: str, +) -> None: + if not previous_task_ids: + return + seen: list[str] = [] + dup: set[str] = set() + for pid in previous_task_ids: + if pid in dup: + continue + dup.add(pid) + seen.append(pid) + if pid == next_task_id: + raise GraphQLError( + "Task cannot depend on itself", + extensions={"code": "BAD_REQUEST"}, + ) + res_prev = await db.execute( + select(models.Task).where(models.Task.id == pid), + ) + prev = res_prev.scalars().first() + if not prev: + raise GraphQLError( + "Previous task not found", + extensions={"code": "BAD_REQUEST"}, + ) + if prev.patient_id != patient_id: + raise GraphQLError( + "Previous task must belong to the same patient", + extensions={"code": "BAD_REQUEST"}, + ) + for pid in seen: + await db.execute( + insert(models.task_dependencies).values( + previous_task_id=pid, + next_task_id=next_task_id, + ), + ) + + +async def replace_incoming_task_dependencies( + db: AsyncSession, + next_task_id: str, + previous_task_ids: list[str] | None, + patient_id: str, +) -> None: + if previous_task_ids is None: + return + await db.execute( + delete(models.task_dependencies).where( + models.task_dependencies.c.next_task_id == next_task_id, + ), + ) + await insert_task_dependencies(db, next_task_id, previous_task_ids, patient_id) + + +def graph_dict_from_preset_inputs( + nodes: list[Any], + edges: list[Any], +) -> dict[str, Any]: + return { + "nodes": [ + { + "id": n.node_id, + "title": n.title, + "description": n.description, + "priority": n.priority.value if getattr(n, "priority", None) else None, + "estimated_time": getattr(n, "estimated_time", None), + } + for n in nodes + ], + "edges": [ + {"from": e.from_node_id, "to": e.to_node_id} for e in edges + ], + } + + +async def apply_task_graph_to_patient( + db: AsyncSession, + patient_id: str, + graph: dict[str, Any], + assignee_id: str | None, + source_task_preset_id: str | None = None, +) -> list[models.Task]: + validate_task_graph_dict(graph) + nodes_raw = graph["nodes"] + edges_raw = graph["edges"] + assignees: list[models.User] = [] + if assignee_id: + ur = await db.execute( + select(models.User).where(models.User.id == assignee_id), + ) + assignee_user = ur.scalars().first() + if assignee_user: + assignees = [assignee_user] + temp_to_task: dict[str, str] = {} + created: list[models.Task] = [] + for n in nodes_raw: + nid = n["id"] + title = n["title"] + description = n.get("description") + priority = n.get("priority") + estimated_time = n.get("estimated_time") + task = models.Task( + title=title, + description=description if isinstance(description, str) else None, + patient_id=patient_id, + source_task_preset_id=source_task_preset_id, + assignees=assignees, + assignee_team_id=None, + due_date=None, + priority=priority if isinstance(priority, str) else None, + estimated_time=estimated_time if isinstance(estimated_time, int) else None, + ) + db.add(task) + created.append(task) + await db.flush() + for n, task in zip(nodes_raw, created, strict=True): + nid = n["id"] + tid = task.id + if not tid: + raise GraphQLError( + "Task id was not assigned after insert", + extensions={"code": "INTERNAL_ERROR"}, + ) + temp_to_task[nid] = tid + dep_key: set[tuple[str, str]] = set() + dep_rows: list[dict[str, str]] = [] + for e in edges_raw: + prev_real = temp_to_task[e["from"]] + next_real = temp_to_task[e["to"]] + key = (prev_real, next_real) + if key in dep_key: + continue + dep_key.add(key) + dep_rows.append( + {"previous_task_id": prev_real, "next_task_id": next_real}, + ) + for row in dep_rows: + await db.execute(insert(models.task_dependencies).values(row)) + await db.commit() + task_ids = [t.id for t in created] + res = await db.execute( + select(models.Task).where(models.Task.id.in_(task_ids)), + ) + tasks = list(res.scalars().all()) + order = {tid: i for i, tid in enumerate(task_ids)} + tasks.sort(key=lambda t: order.get(t.id, 0)) + for task in tasks: + await notify_entity_created("task", task.id) + await notify_entity_update("patient", patient_id) + return tasks diff --git a/backend/api/types/saved_view.py b/backend/api/types/saved_view.py new file mode 100644 index 00000000..a2cc7791 --- /dev/null +++ b/backend/api/types/saved_view.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import strawberry + +from api.inputs import SavedViewEntityType, SavedViewVisibility +from database.models.saved_view import SavedView as SavedViewModel + + +@strawberry.type(name="SavedView") +class SavedViewType: + id: strawberry.ID + name: str + base_entity_type: SavedViewEntityType + filter_definition: str + sort_definition: str + parameters: str + related_filter_definition: str + related_sort_definition: str + related_parameters: str + owner_user_id: strawberry.ID + visibility: SavedViewVisibility + created_at: str + updated_at: str + is_owner: bool + + @staticmethod + def from_model( + row: SavedViewModel, + *, + current_user_id: str | None, + ) -> "SavedViewType": + return SavedViewType( + id=strawberry.ID(row.id), + name=row.name, + base_entity_type=SavedViewEntityType(row.base_entity_type), + filter_definition=row.filter_definition, + sort_definition=row.sort_definition, + parameters=row.parameters, + related_filter_definition=row.related_filter_definition, + related_sort_definition=row.related_sort_definition, + related_parameters=row.related_parameters, + owner_user_id=strawberry.ID(row.owner_user_id), + visibility=SavedViewVisibility(row.visibility), + created_at=row.created_at.isoformat() if row.created_at else "", + updated_at=row.updated_at.isoformat() if row.updated_at else "", + is_owner=current_user_id is not None and row.owner_user_id == current_user_id, + ) diff --git a/backend/api/types/task.py b/backend/api/types/task.py index 6699be02..a99c20bf 100644 --- a/backend/api/types/task.py +++ b/backend/api/types/task.py @@ -6,8 +6,10 @@ from api.types.base import calculate_checksum_for_instance from api.types.property import PropertyValueType from database import models +from sqlalchemy import inspect as sa_inspect from sqlalchemy import select from sqlalchemy.orm import selectinload +from sqlalchemy.orm.attributes import NO_VALUE if TYPE_CHECKING: from api.types.location import LocationNodeType @@ -24,24 +26,30 @@ class TaskType: due_date: datetime | None creation_date: datetime update_date: datetime | None - assignee_id: strawberry.ID | None assignee_team_id: strawberry.ID | None - patient_id: strawberry.ID + patient_id: strawberry.ID | None + source_task_preset_id: strawberry.ID | None priority: str | None estimated_time: int | None @strawberry.field - async def assignee( + async def assignees( self, info: Info, - ) -> Annotated["UserType", strawberry.lazy("api.types.user")] | None: - - if not self.assignee_id: - return None + ) -> list[Annotated["UserType", strawberry.lazy("api.types.user")]]: + try: + state = sa_inspect(self) + attr = state.attrs.assignees + if attr.loaded_value is not NO_VALUE: + return list(attr.value) + except Exception: + pass result = await info.context.db.execute( - select(models.User).where(models.User.id == self.assignee_id), + select(models.User) + .join(models.task_assignees, models.task_assignees.c.user_id == models.User.id) + .where(models.task_assignees.c.task_id == self.id), ) - return result.scalars().first() + return result.scalars().all() @strawberry.field async def assignee_team( @@ -59,8 +67,9 @@ async def assignee_team( async def patient( self, info: Info, - ) -> Annotated["PatientType", strawberry.lazy("api.types.patient")]: - + ) -> Annotated["PatientType", strawberry.lazy("api.types.patient")] | None: + if not self.patient_id: + return None result = await info.context.db.execute( select(models.Patient).where(models.Patient.id == self.patient_id), ) diff --git a/backend/api/types/task_preset.py b/backend/api/types/task_preset.py new file mode 100644 index 00000000..6c5d37be --- /dev/null +++ b/backend/api/types/task_preset.py @@ -0,0 +1,78 @@ +from typing import Any + +import strawberry + + +@strawberry.type +class TaskGraphNodeType: + id: str + title: str + description: str | None + priority: str | None + estimated_time: int | None + + +@strawberry.type +class TaskGraphEdgeType: + from_id: str + to_id: str + + +@strawberry.type +class TaskGraphType: + nodes: list[TaskGraphNodeType] + edges: list[TaskGraphEdgeType] + + +def task_graph_type_from_dict(graph: dict[str, Any]) -> TaskGraphType: + nodes_raw = graph.get("nodes") or [] + edges_raw = graph.get("edges") or [] + nodes: list[TaskGraphNodeType] = [] + for n in nodes_raw: + if not isinstance(n, dict): + continue + nodes.append( + TaskGraphNodeType( + id=str(n.get("id", "")), + title=str(n.get("title", "")), + description=n.get("description") if isinstance(n.get("description"), str) else None, + priority=n.get("priority") if isinstance(n.get("priority"), str) else None, + estimated_time=n.get("estimated_time") if isinstance(n.get("estimated_time"), int) else None, + ), + ) + edges: list[TaskGraphEdgeType] = [] + for e in edges_raw: + if not isinstance(e, dict): + continue + edges.append( + TaskGraphEdgeType( + from_id=str(e.get("from", "")), + to_id=str(e.get("to", "")), + ), + ) + return TaskGraphType(nodes=nodes, edges=edges) + + +@strawberry.type +class TaskPresetType: + id: strawberry.ID + name: str + key: str + scope: str + owner_user_id: strawberry.ID | None + _graph_json: strawberry.Private[dict[str, Any]] + + @strawberry.field + def graph(self) -> TaskGraphType: + return task_graph_type_from_dict(self._graph_json) + + +def task_preset_type_from_model(p: Any) -> TaskPresetType: + return TaskPresetType( + id=p.id, + name=p.name, + key=p.key, + scope=p.scope, + owner_user_id=p.owner_user_id, + _graph_json=p.graph_json, + ) diff --git a/backend/api/types/user.py b/backend/api/types/user.py index 37e52fae..373ac3b3 100644 --- a/backend/api/types/user.py +++ b/backend/api/types/user.py @@ -97,7 +97,11 @@ async def tasks( query = ( select(models.Task) - .join(models.Patient, models.Task.patient_id == models.Patient.id) + .join( + models.task_assignees, + models.task_assignees.c.task_id == models.Task.id, + ) + .outerjoin(models.Patient, models.Task.patient_id == models.Patient.id) .outerjoin( patient_locations, models.Patient.id == patient_locations.c.patient_id, @@ -107,32 +111,35 @@ async def tasks( models.Patient.id == patient_teams.c.patient_id, ) .where( - models.Task.assignee_id == self.id, + models.task_assignees.c.user_id == self.id, ( - (models.Patient.clinic_id.in_(select(root_cte.c.id))) - | ( - models.Patient.position_id.isnot(None) - & models.Patient.position_id.in_( - select(root_cte.c.id) - ) - ) + models.Task.patient_id.is_(None) | ( - models.Patient.assigned_location_id.isnot(None) - & models.Patient.assigned_location_id.in_( - select(root_cte.c.id) + ( + (models.Patient.clinic_id.in_(select(root_cte.c.id))) + | ( + models.Patient.position_id.isnot(None) + & models.Patient.position_id.in_( + select(root_cte.c.id) + ) + ) + | ( + models.Patient.assigned_location_id.isnot(None) + & models.Patient.assigned_location_id.in_( + select(root_cte.c.id) + ) + ) + | ( + patient_locations.c.location_id.in_( + select(root_cte.c.id) + ) + ) + | (patient_teams.c.location_id.in_(select(root_cte.c.id))) ) - ) - | ( - patient_locations.c.location_id.in_( - select(root_cte.c.id) + & models.Patient.state.notin_( + [PatientState.DISCHARGED.value, PatientState.DEAD.value] ) ) - | (patient_teams.c.location_id.in_(select(root_cte.c.id))) - ) - ) - .where( - models.Patient.state.notin_( - [PatientState.DISCHARGED.value, PatientState.DEAD.value] ) ) .distinct() diff --git a/backend/database/migrations/versions/0de3078888ba_merge_location_type_enum_and_remove_.py b/backend/database/migrations/versions/0de3078888ba_merge_location_type_enum_and_remove_.py index 1b31f782..df72ddeb 100644 --- a/backend/database/migrations/versions/0de3078888ba_merge_location_type_enum_and_remove_.py +++ b/backend/database/migrations/versions/0de3078888ba_merge_location_type_enum_and_remove_.py @@ -7,8 +7,6 @@ """ from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. diff --git a/backend/database/migrations/versions/81ffadeee438_merge_patient_description_and_task_.py b/backend/database/migrations/versions/81ffadeee438_merge_patient_description_and_task_.py index 6b44a7a5..f84853b1 100644 --- a/backend/database/migrations/versions/81ffadeee438_merge_patient_description_and_task_.py +++ b/backend/database/migrations/versions/81ffadeee438_merge_patient_description_and_task_.py @@ -7,8 +7,6 @@ """ from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. diff --git a/backend/database/migrations/versions/add_saved_view_related_columns.py b/backend/database/migrations/versions/add_saved_view_related_columns.py new file mode 100644 index 00000000..04b1fc6e --- /dev/null +++ b/backend/database/migrations/versions/add_saved_view_related_columns.py @@ -0,0 +1,55 @@ +"""Add related_* columns for opposite-tab table state on saved_views. + +Revision ID: add_saved_view_related_columns +Revises: merge_saved_views_task_assignees +Create Date: 2026-04-05 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "add_saved_view_related_columns" +down_revision: Union[str, Sequence[str], None] = "merge_saved_views_task_assignees" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "saved_views", + sa.Column( + "related_filter_definition", + sa.Text(), + nullable=False, + server_default="{}", + ), + ) + op.add_column( + "saved_views", + sa.Column( + "related_sort_definition", + sa.Text(), + nullable=False, + server_default="{}", + ), + ) + op.add_column( + "saved_views", + sa.Column( + "related_parameters", + sa.Text(), + nullable=False, + server_default="{}", + ), + ) + op.alter_column("saved_views", "related_filter_definition", server_default=None) + op.alter_column("saved_views", "related_sort_definition", server_default=None) + op.alter_column("saved_views", "related_parameters", server_default=None) + + +def downgrade() -> None: + op.drop_column("saved_views", "related_parameters") + op.drop_column("saved_views", "related_sort_definition") + op.drop_column("saved_views", "related_filter_definition") diff --git a/backend/database/migrations/versions/add_saved_views_table.py b/backend/database/migrations/versions/add_saved_views_table.py new file mode 100644 index 00000000..82cc9e00 --- /dev/null +++ b/backend/database/migrations/versions/add_saved_views_table.py @@ -0,0 +1,38 @@ +"""Add saved_views table for persistent user views. + +Revision ID: add_saved_views_table +Revises: add_property_value_user_value +Create Date: 2026-02-10 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "add_saved_views_table" +down_revision: Union[str, Sequence[str], None] = "add_property_value_user_value" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "saved_views", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("base_entity_type", sa.String(), nullable=False), + sa.Column("filter_definition", sa.Text(), nullable=False), + sa.Column("sort_definition", sa.Text(), nullable=False), + sa.Column("parameters", sa.Text(), nullable=False), + sa.Column("owner_user_id", sa.String(), nullable=False), + sa.Column("visibility", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("saved_views") diff --git a/backend/database/migrations/versions/add_task_assignees_optional_patient.py b/backend/database/migrations/versions/add_task_assignees_optional_patient.py new file mode 100644 index 00000000..f26ccded --- /dev/null +++ b/backend/database/migrations/versions/add_task_assignees_optional_patient.py @@ -0,0 +1,82 @@ +"""Add task_assignees table and make task patient optional. + +Revision ID: task_assignees_opt_patient +Revises: add_patient_deleted +Create Date: 2026-03-23 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "task_assignees_opt_patient" +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 _drop_fk_for_column(table_name: str, column_name: str) -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + for fk in inspector.get_foreign_keys(table_name): + constrained_columns = fk.get("constrained_columns", []) + if column_name in constrained_columns and fk.get("name"): + op.drop_constraint(fk["name"], table_name, type_="foreignkey") + break + + +def upgrade() -> None: + op.create_table( + "task_assignees", + sa.Column("task_id", sa.String(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["task_id"], ["tasks.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("task_id", "user_id"), + ) + + op.execute( + sa.text( + """ + INSERT INTO task_assignees (task_id, user_id) + SELECT id, assignee_id + FROM tasks + WHERE assignee_id IS NOT NULL + """ + ) + ) + + _drop_fk_for_column("tasks", "assignee_id") + op.drop_column("tasks", "assignee_id") + op.alter_column("tasks", "patient_id", existing_type=sa.String(), nullable=True) + + +def downgrade() -> None: + op.add_column("tasks", sa.Column("assignee_id", sa.String(), nullable=True)) + op.create_foreign_key( + "tasks_assignee_id_fkey", + "tasks", + "users", + ["assignee_id"], + ["id"], + ) + + op.execute( + sa.text( + """ + UPDATE tasks + SET assignee_id = sub.user_id + FROM ( + SELECT task_id, MIN(user_id) AS user_id + FROM task_assignees + GROUP BY task_id + ) AS sub + WHERE tasks.id = sub.task_id + """ + ) + ) + + op.alter_column("tasks", "patient_id", existing_type=sa.String(), nullable=False) + op.drop_table("task_assignees") diff --git a/backend/database/migrations/versions/add_task_presets_table.py b/backend/database/migrations/versions/add_task_presets_table.py new file mode 100644 index 00000000..5d66be4f --- /dev/null +++ b/backend/database/migrations/versions/add_task_presets_table.py @@ -0,0 +1,51 @@ +"""Add task_presets table. + +Revision ID: add_task_presets +Revises: add_saved_view_related_columns +Create Date: 2026-04-07 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "add_task_presets" +down_revision: Union[str, Sequence[str], None] = "add_saved_view_related_columns" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "task_presets", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("key", sa.String(), nullable=False), + sa.Column("scope", sa.String(length=32), nullable=False), + sa.Column("owner_user_id", sa.String(), nullable=True), + sa.Column( + "graph_json", + postgresql.JSON(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("creation_date", sa.DateTime(), nullable=False), + sa.Column("update_date", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["owner_user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_task_presets_key"), + "task_presets", + ["key"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_task_presets_key"), table_name="task_presets") + op.drop_table("task_presets") diff --git a/backend/database/migrations/versions/add_task_source_task_preset.py b/backend/database/migrations/versions/add_task_source_task_preset.py new file mode 100644 index 00000000..e9d8ccd9 --- /dev/null +++ b/backend/database/migrations/versions/add_task_source_task_preset.py @@ -0,0 +1,40 @@ +"""Add source_task_preset_id to tasks. + +Revision ID: add_task_source_preset +Revises: add_task_presets +Create Date: 2026-04-07 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "add_task_source_preset" +down_revision: Union[str, Sequence[str], None] = "add_task_presets" +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("source_task_preset_id", sa.String(), nullable=True), + ) + op.create_foreign_key( + "fk_tasks_source_task_preset_id", + "tasks", + "task_presets", + ["source_task_preset_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_tasks_source_task_preset_id", + "tasks", + type_="foreignkey", + ) + op.drop_column("tasks", "source_task_preset_id") diff --git a/backend/database/migrations/versions/merge_saved_views_and_task_assignees.py b/backend/database/migrations/versions/merge_saved_views_and_task_assignees.py new file mode 100644 index 00000000..686c153e --- /dev/null +++ b/backend/database/migrations/versions/merge_saved_views_and_task_assignees.py @@ -0,0 +1,25 @@ +"""Merge migration heads: saved_views and task_assignees branches. + +Revision ID: merge_saved_views_task_assignees +Revises: add_saved_views_table, task_assignees_opt_patient +Create Date: 2026-03-23 + +""" + +from typing import Sequence, Union + +revision: str = "merge_saved_views_task_assignees" +down_revision: Union[str, Sequence[str], None] = ( + "add_saved_views_table", + "task_assignees_opt_patient", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/database/models/__init__.py b/backend/database/models/__init__.py index 8108c8c7..9fee6038 100644 --- a/backend/database/models/__init__.py +++ b/backend/database/models/__init__.py @@ -1,6 +1,8 @@ from .user import User, user_root_locations # noqa: F401 from .location import LocationNode, location_organizations # noqa: F401 from .patient import Patient, patient_locations, patient_teams # noqa: F401 -from .task import Task, task_dependencies # noqa: F401 +from .task import Task, task_assignees, task_dependencies # noqa: F401 from .property import PropertyDefinition, PropertyValue # noqa: F401 from .scaffold import ScaffoldImportState # noqa: F401 +from .saved_view import SavedView # noqa: F401 +from .task_preset import TaskPreset, TaskPresetScope # noqa: F401 diff --git a/backend/database/models/patient.py b/backend/database/models/patient.py index 4791a650..9474966a 100644 --- a/backend/database/models/patient.py +++ b/backend/database/models/patient.py @@ -84,7 +84,6 @@ class Patient(Base): tasks: Mapped[list[Task]] = relationship( "Task", back_populates="patient", - cascade="all, delete-orphan", ) properties: Mapped[list[PropertyValue]] = relationship( "PropertyValue", diff --git a/backend/database/models/property.py b/backend/database/models/property.py index ffe60ea6..680b934b 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) \ No newline at end of file + user_value: Mapped[str | None] = mapped_column(String, nullable=True) diff --git a/backend/database/models/saved_view.py b/backend/database/models/saved_view.py new file mode 100644 index 00000000..858f5c73 --- /dev/null +++ b/backend/database/models/saved_view.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from database.models.base import Base +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from .user import User + + +class SavedView(Base): + """ + Persistent user-defined view: saved filters, sort, scope (parameters), and entity type. + filter_definition / sort_definition / parameters store JSON as text (SQLite + Postgres compatible). + """ + + __tablename__ = "saved_views" + + id: Mapped[str] = mapped_column( + String, + primary_key=True, + default=lambda: str(uuid.uuid4()), + ) + name: Mapped[str] = mapped_column(String, nullable=False) + base_entity_type: Mapped[str] = mapped_column( + String, nullable=False + ) # 'task' | 'patient' + filter_definition: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + sort_definition: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + parameters: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + related_filter_definition: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + related_sort_definition: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + related_parameters: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + owner_user_id: Mapped[str] = mapped_column( + String, ForeignKey("users.id"), nullable=False + ) + visibility: Mapped[str] = mapped_column( + String, nullable=False, default="private" + ) # 'private' | 'link_shared' + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + owner: Mapped["User"] = relationship("User", back_populates="saved_views") diff --git a/backend/database/models/task.py b/backend/database/models/task.py index 54961151..0d631b18 100644 --- a/backend/database/models/task.py +++ b/backend/database/models/task.py @@ -12,6 +12,7 @@ from .location import LocationNode from .patient import Patient from .property import PropertyValue + from .task_preset import TaskPreset from .user import User task_dependencies = Table( @@ -21,6 +22,13 @@ Column("next_task_id", ForeignKey("tasks.id"), primary_key=True), ) +task_assignees = Table( + "task_assignees", + Base.metadata, + Column("task_id", ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), + Column("user_id", ForeignKey("users.id"), primary_key=True), +) + class Task(Base): __tablename__ = "tasks" @@ -40,27 +48,33 @@ class Task(Base): default=datetime.now, onupdate=datetime.now, ) - assignee_id: Mapped[str | None] = mapped_column( - 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")) + patient_id: Mapped[str | None] = mapped_column(ForeignKey("patients.id"), nullable=True) + source_task_preset_id: Mapped[str | None] = mapped_column( + ForeignKey("task_presets.id", ondelete="SET NULL"), + nullable=True, + ) priority: Mapped[str | None] = mapped_column(String, nullable=True) estimated_time: Mapped[int | None] = mapped_column(Integer, nullable=True) - assignee: Mapped[User | None] = relationship( + assignees: Mapped[list[User]] = relationship( "User", + secondary=task_assignees, back_populates="tasks", ) assignee_team: Mapped["LocationNode | None"] = relationship( "LocationNode", foreign_keys=[assignee_team_id], ) - patient: Mapped[Patient] = relationship("Patient", back_populates="tasks") + patient: Mapped[Patient | None] = relationship("Patient", back_populates="tasks") + source_task_preset: Mapped["TaskPreset | None"] = relationship( + "TaskPreset", + foreign_keys=[source_task_preset_id], + back_populates="tasks", + ) properties: Mapped[list[PropertyValue]] = relationship( "PropertyValue", back_populates="task", diff --git a/backend/database/models/task_preset.py b/backend/database/models/task_preset.py new file mode 100644 index 00000000..a0e0e699 --- /dev/null +++ b/backend/database/models/task_preset.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import enum +import uuid +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from database.models.base import Base +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from database.models.task import Task + from database.models.user import User + + +class TaskPresetScope(str, enum.Enum): + PERSONAL = "PERSONAL" + GLOBAL = "GLOBAL" + + +class TaskPreset(Base): + __tablename__ = "task_presets" + + id: Mapped[str] = mapped_column( + String, + primary_key=True, + default=lambda: str(uuid.uuid4()), + ) + name: Mapped[str] = mapped_column(String) + key: Mapped[str] = mapped_column(String, unique=True, index=True) + scope: Mapped[str] = mapped_column(String(32)) + owner_user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), + nullable=True, + ) + graph_json: Mapped[dict[str, Any]] = mapped_column(JSON) + creation_date: Mapped[datetime] = mapped_column(default=datetime.now) + update_date: Mapped[datetime | None] = mapped_column( + nullable=True, + default=datetime.now, + onupdate=datetime.now, + ) + + owner: Mapped[User | None] = relationship("User") + tasks: Mapped[list["Task"]] = relationship( + "Task", + back_populates="source_task_preset", + ) diff --git a/backend/database/models/user.py b/backend/database/models/user.py index f8b0f0a8..a894badf 100644 --- a/backend/database/models/user.py +++ b/backend/database/models/user.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from .location import LocationNode + from .saved_view import SavedView from .task import Task user_root_locations = Table( @@ -43,7 +44,14 @@ class User(Base): nullable=True, ) - tasks: Mapped[list[Task]] = relationship("Task", back_populates="assignee") + tasks: Mapped[list[Task]] = relationship( + "Task", + secondary="task_assignees", + back_populates="assignees", + ) + saved_views: Mapped[list["SavedView"]] = relationship( + "SavedView", back_populates="owner" + ) root_locations: Mapped[list[LocationNode]] = relationship( "LocationNode", secondary=user_root_locations, diff --git a/backend/requirements.txt b/backend/requirements.txt index 45bbcfd0..d135fb3c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,7 +4,7 @@ fastapi==0.124.4 python-dotenv==1.2.1 python-jose[cryptography]==3.5.0 redis==7.1.0 -requests==2.32.5 +requests==2.33.0 sqlalchemy==2.0.45 strawberry-graphql[fastapi]==0.287.3 uvicorn[standard]==0.38.0 diff --git a/backend/schema.graphql b/backend/schema.graphql new file mode 100644 index 00000000..3ca3375f --- /dev/null +++ b/backend/schema.graphql @@ -0,0 +1,568 @@ +input ApplyTaskGraphInput { + patientId: ID! + presetId: ID = null + graph: TaskGraphInput = null + assignToCurrentUser: Boolean! = false +} + +type AuditLogType { + caseId: String! + activity: String! + userId: String + timestamp: DateTime! + context: String +} + +input CreateLocationNodeInput { + title: String! + kind: LocationType! + parentId: ID = null +} + +input CreatePatientInput { + firstname: String! + lastname: String! + birthdate: Date! + sex: Sex! + assignedLocationId: ID = null + assignedLocationIds: [ID!] = null + clinicId: ID! + positionId: ID = null + teamIds: [ID!] = null + properties: [PropertyValueInput!] = null + state: PatientState = null + description: String = null +} + +input CreatePropertyDefinitionInput { + name: String! + fieldType: FieldType! + allowedEntities: [PropertyEntity!]! + description: String = null + options: [String!] = null + isActive: Boolean! = true +} + +input CreateSavedViewInput { + name: String! + baseEntityType: SavedViewEntityType! + filterDefinition: String! + sortDefinition: String! + parameters: String! + relatedFilterDefinition: String! = "{}" + relatedSortDefinition: String! = "{}" + relatedParameters: String! = "{}" + visibility: SavedViewVisibility! = LINK_SHARED +} + +input CreateTaskInput { + title: String! + patientId: ID = null + description: String = null + dueDate: DateTime = null + assigneeIds: [ID!] = null + assigneeTeamId: ID = null + previousTaskIds: [ID!] = null + properties: [PropertyValueInput!] = null + priority: TaskPriority = null + estimatedTime: Int = null +} + +input CreateTaskPresetInput { + name: String! + key: String = null + scope: TaskPresetScope! + graph: TaskGraphInput! +} + +"""Date (isoformat)""" +scalar Date + +"""Date with time (isoformat)""" +scalar DateTime + +enum FieldType { + FIELD_TYPE_UNSPECIFIED + FIELD_TYPE_TEXT + FIELD_TYPE_NUMBER + FIELD_TYPE_CHECKBOX + FIELD_TYPE_DATE + FIELD_TYPE_DATE_TIME + FIELD_TYPE_SELECT + FIELD_TYPE_MULTI_SELECT + FIELD_TYPE_USER +} + +type LocationNodeType { + id: ID! + title: String! + kind: LocationType! + parentId: ID + parent: LocationNodeType + children: [LocationNodeType!]! + patients: [PatientType!]! + organizationIds: [String!]! +} + +enum LocationType { + HOSPITAL + PRACTICE + CLINIC + TEAM + WARD + ROOM + BED + OTHER +} + +type Mutation { + createPatient(data: CreatePatientInput!): PatientType! + updatePatient(id: ID!, data: UpdatePatientInput!): PatientType! + deletePatient(id: ID!): Boolean! + admitPatient(id: ID!): PatientType! + dischargePatient(id: ID!): PatientType! + markPatientDead(id: ID!): PatientType! + waitPatient(id: ID!): PatientType! + createTask(data: CreateTaskInput!): TaskType! + updateTask(id: ID!, data: UpdateTaskInput!): TaskType! + addTaskAssignee(id: ID!, userId: ID!): TaskType! + removeTaskAssignee(id: ID!, userId: ID!): TaskType! + assignTaskToTeam(id: ID!, teamId: ID!): TaskType! + unassignTaskFromTeam(id: ID!): TaskType! + completeTask(id: ID!): TaskType! + reopenTask(id: ID!): TaskType! + applyTaskGraph(data: ApplyTaskGraphInput!): [TaskType!]! + deleteTask(id: ID!): Boolean! + createTaskPreset(data: CreateTaskPresetInput!): TaskPresetType! + updateTaskPreset(id: ID!, data: UpdateTaskPresetInput!): TaskPresetType! + deleteTaskPreset(id: ID!): Boolean! + createPropertyDefinition(data: CreatePropertyDefinitionInput!): PropertyDefinitionType! + updatePropertyDefinition(id: ID!, data: UpdatePropertyDefinitionInput!): PropertyDefinitionType! + deletePropertyDefinition(id: ID!): Boolean! + createLocationNode(data: CreateLocationNodeInput!): LocationNodeType! + updateLocationNode(id: ID!, data: UpdateLocationNodeInput!): LocationNodeType! + deleteLocationNode(id: ID!): Boolean! + updateProfilePicture(data: UpdateProfilePictureInput!): UserType! + createSavedView(data: CreateSavedViewInput!): SavedView! + updateSavedView(id: ID!, data: UpdateSavedViewInput!): SavedView! + deleteSavedView(id: ID!): Boolean! + duplicateSavedView(id: ID!, name: String!): SavedView! +} + +input PaginationInput { + pageIndex: Int! = 0 + pageSize: Int = null +} + +enum PatientState { + WAIT + ADMITTED + DISCHARGED + DEAD +} + +type PatientType { + id: ID! + firstname: String! + lastname: String! + birthdate: Date! + sex: Sex! + state: PatientState! + assignedLocationId: ID + clinicId: ID! + positionId: ID + description: String + name: String! + age: Int! + assignedLocation: LocationNodeType + assignedLocations: [LocationNodeType!]! + clinic: LocationNodeType! + position: LocationNodeType + teams: [LocationNodeType!]! + tasks(done: Boolean = null): [TaskType!]! + properties: [PropertyValueType!]! + checksum: String! +} + +type PropertyDefinitionType { + id: ID! + name: String! + description: String + fieldType: FieldType! + isActive: Boolean! + options: [String!]! + allowedEntities: [PropertyEntity!]! +} + +enum PropertyEntity { + PATIENT + TASK +} + +input PropertyValueInput { + definitionId: ID! + textValue: String = null + numberValue: Float = null + booleanValue: Boolean = null + dateValue: Date = null + dateTimeValue: DateTime = null + selectValue: String = null + multiSelectValues: [String!] = null + userValue: String = null +} + +type PropertyValueType { + id: ID! + definition: PropertyDefinitionType! + textValue: String + numberValue: Float + booleanValue: Boolean + dateValue: Date + dateTimeValue: DateTime + selectValue: String + userValue: String + multiSelectValues: [String!] + user: UserType + team: LocationNodeType +} + +type Query { + patient(id: ID!): PatientType + patients(locationNodeId: ID = null, rootLocationIds: [ID!] = null, states: [PatientState!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [PatientType!]! + patientsTotal(locationNodeId: ID = null, rootLocationIds: [ID!] = null, states: [PatientState!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, search: QuerySearchInput = null): Int! + recentPatients(rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [PatientType!]! + recentPatientsTotal(rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, search: QuerySearchInput = null): Int! + task(id: ID!): TaskType + tasks(patientId: ID = null, assigneeId: ID = null, assigneeTeamId: ID = null, rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [TaskType!]! + tasksTotal(patientId: ID = null, assigneeId: ID = null, assigneeTeamId: ID = null, rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, search: QuerySearchInput = null): Int! + recentTasks(rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [TaskType!]! + recentTasksTotal(rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, search: QuerySearchInput = null): Int! + taskPresets: [TaskPresetType!]! + taskPreset(id: ID!): TaskPresetType + taskPresetByKey(key: String!): TaskPresetType + locationRoots: [LocationNodeType!]! + locationNode(id: ID!): LocationNodeType + locationNodes(kind: LocationType = null, search: String = null, parentId: ID = null, recursive: Boolean! = false, orderByName: Boolean! = false, limit: Int = null, offset: Int = null): [LocationNodeType!]! + propertyDefinitions: [PropertyDefinitionType!]! + user(id: ID!): UserType + users(filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [UserType!]! + me: UserType + auditLogs(caseId: ID!, limit: Int = null, offset: Int = null): [AuditLogType!]! + queryableFields(entity: String!): [QueryableField!]! + savedView(id: ID!): SavedView + mySavedViews: [SavedView!]! +} + +input QueryFilterClauseInput { + fieldKey: String! + operator: QueryOperator! + value: QueryFilterValueInput = null +} + +input QueryFilterValueInput { + stringValue: String = null + stringValues: [String!] = null + floatValue: Float = null + floatMin: Float = null + floatMax: Float = null + boolValue: Boolean = null + dateValue: DateTime = null + dateMin: Date = null + dateMax: Date = null + uuidValue: String = null + uuidValues: [String!] = null +} + +enum QueryOperator { + EQ + NEQ + GT + GTE + LT + LTE + BETWEEN + IN + NOT_IN + CONTAINS + STARTS_WITH + ENDS_WITH + IS_NULL + IS_NOT_NULL + ANY_EQ + ANY_IN + ALL_IN + NONE_IN + IS_EMPTY + IS_NOT_EMPTY +} + +input QuerySearchInput { + searchText: String = null + includeProperties: Boolean! = false +} + +input QuerySortClauseInput { + fieldKey: String! + direction: SortDirection! +} + +type QueryableChoiceMeta { + optionKeys: [String!]! + optionLabels: [String!]! +} + +type QueryableField { + key: String! + label: String! + kind: QueryableFieldKind! + valueType: QueryableValueType! + allowedOperators: [QueryOperator!]! + sortable: Boolean! + sortDirections: [SortDirection!]! + searchable: Boolean! + relation: QueryableRelationMeta + choice: QueryableChoiceMeta + propertyDefinitionId: String + filterable: Boolean! +} + +enum QueryableFieldKind { + SCALAR + PROPERTY + REFERENCE + REFERENCE_LIST + CHOICE + CHOICE_LIST +} + +type QueryableRelationMeta { + targetEntity: String! + idFieldKey: String! + labelFieldKey: String! + allowedFilterModes: [ReferenceFilterMode!]! +} + +enum QueryableValueType { + STRING + NUMBER + BOOLEAN + DATE + DATETIME + UUID + STRING_LIST + UUID_LIST +} + +enum ReferenceFilterMode { + ID + LABEL +} + +type SavedView { + id: ID! + name: String! + baseEntityType: SavedViewEntityType! + filterDefinition: String! + sortDefinition: String! + parameters: String! + relatedFilterDefinition: String! + relatedSortDefinition: String! + relatedParameters: String! + ownerUserId: ID! + visibility: SavedViewVisibility! + createdAt: String! + updatedAt: String! + isOwner: Boolean! +} + +enum SavedViewEntityType { + TASK + PATIENT +} + +enum SavedViewVisibility { + PRIVATE + LINK_SHARED +} + +enum Sex { + MALE + FEMALE + UNKNOWN +} + +enum SortDirection { + ASC + DESC +} + +type Subscription { + patientCreated(rootLocationIds: [ID!] = null): ID! + patientUpdated(patientId: ID = null, rootLocationIds: [ID!] = null): ID! + patientStateChanged(patientId: ID = null, rootLocationIds: [ID!] = null): ID! + patientDeleted(rootLocationIds: [ID!] = null): ID! + taskCreated(rootLocationIds: [ID!] = null): ID! + taskUpdated(taskId: ID = null, rootLocationIds: [ID!] = null): ID! + taskDeleted(rootLocationIds: [ID!] = null): ID! + locationNodeCreated: ID! + locationNodeUpdated(locationId: ID = null): ID! + locationNodeDeleted: ID! +} + +input TaskGraphEdgeInput { + fromNodeId: String! + toNodeId: String! +} + +type TaskGraphEdgeType { + fromId: String! + toId: String! +} + +input TaskGraphInput { + nodes: [TaskGraphNodeInput!]! + edges: [TaskGraphEdgeInput!]! +} + +input TaskGraphNodeInput { + nodeId: String! + title: String! + description: String = null + priority: TaskPriority = null + estimatedTime: Int = null +} + +type TaskGraphNodeType { + id: String! + title: String! + description: String + priority: String + estimatedTime: Int +} + +type TaskGraphType { + nodes: [TaskGraphNodeType!]! + edges: [TaskGraphEdgeType!]! +} + +enum TaskPresetScope { + PERSONAL + GLOBAL +} + +type TaskPresetType { + id: ID! + name: String! + key: String! + scope: String! + ownerUserId: ID + graph: TaskGraphType! +} + +enum TaskPriority { + P1 + P2 + P3 + P4 +} + +type TaskType { + id: ID! + title: String! + description: String + done: Boolean! + dueDate: DateTime + creationDate: DateTime! + updateDate: DateTime + assigneeTeamId: ID + patientId: ID + sourceTaskPresetId: ID + priority: String + estimatedTime: Int + assignees: [UserType!]! + assigneeTeam: LocationNodeType + patient: PatientType + properties: [PropertyValueType!]! + checksum: String! +} + +input UpdateLocationNodeInput { + title: String = null + kind: LocationType = null + parentId: ID = null +} + +input UpdatePatientInput { + firstname: String = null + lastname: String = null + birthdate: Date = null + sex: Sex = null + assignedLocationId: ID = null + assignedLocationIds: [ID!] = null + clinicId: ID = null + positionId: ID + teamIds: [ID!] + properties: [PropertyValueInput!] = null + checksum: String = null + description: String = null +} + +input UpdateProfilePictureInput { + avatarUrl: String! +} + +input UpdatePropertyDefinitionInput { + name: String = null + description: String = null + options: [String!] = null + isActive: Boolean = null + allowedEntities: [PropertyEntity!] = null +} + +input UpdateSavedViewInput { + name: String = null + filterDefinition: String = null + sortDefinition: String = null + parameters: String = null + relatedFilterDefinition: String = null + relatedSortDefinition: String = null + relatedParameters: String = null + visibility: SavedViewVisibility = null +} + +input UpdateTaskInput { + title: String = null + patientId: ID + description: String = null + done: Boolean = null + dueDate: DateTime + assigneeIds: [ID!] + assigneeTeamId: ID + previousTaskIds: [ID!] = null + properties: [PropertyValueInput!] = null + checksum: String = null + priority: TaskPriority + estimatedTime: Int +} + +input UpdateTaskPresetInput { + name: String = null + key: String = null + graph: TaskGraphInput = null +} + +type UserType { + id: ID! + username: String! + email: String + firstname: String + lastname: String + title: String + avatarUrl: String + lastOnline: DateTime + name: String! + isOnline: Boolean! + organizations: String + tasks(rootLocationIds: [ID!] = null): [TaskType!]! + rootLocations: [LocationNodeType!]! +} \ No newline at end of file diff --git a/backend/tests/unit/test_task_graph.py b/backend/tests/unit/test_task_graph.py new file mode 100644 index 00000000..3f751bed --- /dev/null +++ b/backend/tests/unit/test_task_graph.py @@ -0,0 +1,48 @@ +import pytest +from graphql import GraphQLError + +from api.services.task_graph import validate_task_graph_dict + + +def test_validate_empty_nodes_raises() -> None: + with pytest.raises(GraphQLError): + validate_task_graph_dict({"nodes": [], "edges": []}) + + +def test_validate_cycle_raises() -> None: + with pytest.raises(GraphQLError) as exc: + validate_task_graph_dict( + { + "nodes": [ + {"id": "a", "title": "A"}, + {"id": "b", "title": "B"}, + ], + "edges": [ + {"from": "a", "to": "b"}, + {"from": "b", "to": "a"}, + ], + }, + ) + assert "cycle" in str(exc.value).lower() + + +def test_validate_self_edge_raises() -> None: + with pytest.raises(GraphQLError): + validate_task_graph_dict( + { + "nodes": [{"id": "a", "title": "A"}], + "edges": [{"from": "a", "to": "a"}], + }, + ) + + +def test_validate_linear_ok() -> None: + validate_task_graph_dict( + { + "nodes": [ + {"id": "a", "title": "A"}, + {"id": "b", "title": "B"}, + ], + "edges": [{"from": "a", "to": "b"}], + }, + ) diff --git a/docs/VIEWS_ARCHITECTURE.md b/docs/VIEWS_ARCHITECTURE.md new file mode 100644 index 00000000..a21af7b8 --- /dev/null +++ b/docs/VIEWS_ARCHITECTURE.md @@ -0,0 +1,79 @@ +# Saved views (persistent views) + +## Concept + +A **SavedView** stores a named configuration for list screens: + +| Field | Purpose | +|--------|---------| +| `filterDefinition` | JSON string: column filters (same wire format as `useStorageSyncedTableState` filters). | +| `sortDefinition` | JSON string: TanStack `SortingState` array. | +| `parameters` | JSON string: **scope** and cross-entity context — `rootLocationIds`, `locationId`, `searchQuery` (patient), `assigneeId` (task / my tasks). | +| `baseEntityType` | `PATIENT` or `TASK` — primary tab when opening `/view/:uid`. | +| `visibility` | `PRIVATE` or `LINK_SHARED` (share by link / UID). | + +Location is **not** a separate route anymore for saved views: it is encoded in `parameters` (`rootLocationIds`, `locationId`). + +## Cross-entity model + +- **Patient view** + - **Patients tab**: `PatientList` hydrated from `filterDefinition` / `sortDefinition` / parameters. + - **Tasks tab**: `PatientViewTasksPanel` runs the **same patient query** (`usePatients` with identical filters/sort/scope) and flattens tasks from those patients — the task universe is *derived from the patient universe*, not an ad-hoc client filter. + +- **Task view** + - **Tasks tab**: `useTasksPaginated` with filters from the view + scope from parameters (`rootLocationIds`, `assigneeId`). + - **Patients tab**: `TaskViewPatientsPanel` runs **`useTasks` without pagination** with the same task filters/sort/scope and builds **distinct patients** from `tasks[].patient`. + +## GraphQL (examples) + +```graphql +query { + savedView(id: "…") { + id + name + baseEntityType + filterDefinition + sortDefinition + parameters + isOwner + visibility + } +} + +mutation { + createSavedView(data: { + name: "ICU patients" + baseEntityType: PATIENT + filterDefinition: "[]" + sortDefinition: "[]" + parameters: "{\"rootLocationIds\":[\"…\"],\"locationId\":null,\"searchQuery\":\"\"}" + visibility: PRIVATE + }) { id } +} +``` + +```graphql +mutation { + duplicateSavedView(id: "…", name: "Copy of shared view") { id } +} +``` + +## Frontend entry points + +| Area | Path / component | +|------|-------------------| +| Open view | `/view/[uid]` | +| Save from patients | `PatientList` → `SaveViewDialog` | +| Save from my tasks | `/tasks` → `SaveViewDialog` | +| Sidebar | `Page` → expandable **Saved views** + link to settings | +| Manage | `/settings/views` (table: open, rename, share link, duplicate, delete) | + +## Migrations + +Apply Alembic migration `add_saved_views_table` (or your project’s revision chain) so the `saved_views` table exists before using the API. + +## Follow-ups + +- **Update view** from UI (owner edits in place → `updateSavedView`) instead of only “save as new”. +- **Share visibility** UI (`LINK_SHARED`) and server checks are already modeled; expose in settings. +- **Redirect** `/location/[id]` → a default view or keep both during transition. diff --git a/simulator/requirements.txt b/simulator/requirements.txt index d2c6cdac..0250528f 100644 --- a/simulator/requirements.txt +++ b/simulator/requirements.txt @@ -1,2 +1,2 @@ -python-dotenv==1.2.1 -requests==2.32.5 +python-dotenv==1.2.2 +requests==2.33.1 diff --git a/tests/package-lock.json b/tests/package-lock.json index a8403b95..6cbfc600 100644 --- a/tests/package-lock.json +++ b/tests/package-lock.json @@ -8,17 +8,17 @@ "name": "tasks-e2e-tests", "version": "1.0.0", "devDependencies": { - "@playwright/test": "^1.48.0" + "@playwright/test": "^1.59.1" } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -43,13 +43,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -62,9 +62,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/tests/package.json b/tests/package.json index c6503314..f0200545 100644 --- a/tests/package.json +++ b/tests/package.json @@ -7,7 +7,7 @@ "test:ui": "playwright test --ui" }, "devDependencies": { - "@playwright/test": "^1.48.0" + "@playwright/test": "^1.59.1" } } diff --git a/web/api/gql/generated.ts b/web/api/gql/generated.ts index c614ec58..f6d0d5a6 100644 --- a/web/api/gql/generated.ts +++ b/web/api/gql/generated.ts @@ -13,10 +13,19 @@ export type Scalars = { Boolean: { input: boolean; output: boolean; } Int: { input: number; output: number; } Float: { input: number; output: number; } + /** Date (isoformat) */ Date: { input: any; output: any; } + /** Date with time (isoformat) */ DateTime: { input: any; output: any; } }; +export type ApplyTaskGraphInput = { + assignToCurrentUser?: Scalars['Boolean']['input']; + graph?: InputMaybe; + patientId: Scalars['ID']['input']; + presetId?: InputMaybe; +}; + export type AuditLogType = { __typename?: 'AuditLogType'; activity: Scalars['String']['output']; @@ -26,11 +35,6 @@ export type AuditLogType = { userId?: Maybe; }; -export enum ColumnType { - DirectAttribute = 'DIRECT_ATTRIBUTE', - Property = 'PROPERTY' -} - export type CreateLocationNodeInput = { kind: LocationType; parentId?: InputMaybe; @@ -61,19 +65,38 @@ export type CreatePropertyDefinitionInput = { options?: InputMaybe>; }; +export type CreateSavedViewInput = { + baseEntityType: SavedViewEntityType; + filterDefinition: Scalars['String']['input']; + name: Scalars['String']['input']; + parameters: Scalars['String']['input']; + relatedFilterDefinition?: Scalars['String']['input']; + relatedParameters?: Scalars['String']['input']; + relatedSortDefinition?: Scalars['String']['input']; + sortDefinition: Scalars['String']['input']; + visibility?: SavedViewVisibility; +}; + export type CreateTaskInput = { - assigneeId?: InputMaybe; + assigneeIds?: InputMaybe>; assigneeTeamId?: InputMaybe; description?: InputMaybe; dueDate?: InputMaybe; estimatedTime?: InputMaybe; - patientId: Scalars['ID']['input']; + patientId?: InputMaybe; previousTaskIds?: InputMaybe>; priority?: InputMaybe; properties?: InputMaybe>; title: Scalars['String']['input']; }; +export type CreateTaskPresetInput = { + graph: TaskGraphInput; + key?: InputMaybe; + name: Scalars['String']['input']; + scope: TaskPresetScope; +}; + export enum FieldType { FieldTypeCheckbox = 'FIELD_TYPE_CHECKBOX', FieldTypeDate = 'FIELD_TYPE_DATE', @@ -86,83 +109,6 @@ export enum FieldType { FieldTypeUser = 'FIELD_TYPE_USER' } -export type FilterInput = { - column: Scalars['String']['input']; - columnType?: ColumnType; - operator: FilterOperator; - parameter: FilterParameter; - propertyDefinitionId?: InputMaybe; -}; - -export enum FilterOperator { - BooleanIsFalse = 'BOOLEAN_IS_FALSE', - BooleanIsTrue = 'BOOLEAN_IS_TRUE', - DatetimeBetween = 'DATETIME_BETWEEN', - DatetimeEquals = 'DATETIME_EQUALS', - DatetimeGreaterThan = 'DATETIME_GREATER_THAN', - DatetimeGreaterThanOrEqual = 'DATETIME_GREATER_THAN_OR_EQUAL', - DatetimeLessThan = 'DATETIME_LESS_THAN', - DatetimeLessThanOrEqual = 'DATETIME_LESS_THAN_OR_EQUAL', - DatetimeNotBetween = 'DATETIME_NOT_BETWEEN', - DatetimeNotEquals = 'DATETIME_NOT_EQUALS', - DateBetween = 'DATE_BETWEEN', - DateEquals = 'DATE_EQUALS', - DateGreaterThan = 'DATE_GREATER_THAN', - DateGreaterThanOrEqual = 'DATE_GREATER_THAN_OR_EQUAL', - DateLessThan = 'DATE_LESS_THAN', - DateLessThanOrEqual = 'DATE_LESS_THAN_OR_EQUAL', - DateNotBetween = 'DATE_NOT_BETWEEN', - DateNotEquals = 'DATE_NOT_EQUALS', - IsNotNull = 'IS_NOT_NULL', - IsNull = 'IS_NULL', - NumberBetween = 'NUMBER_BETWEEN', - NumberEquals = 'NUMBER_EQUALS', - NumberGreaterThan = 'NUMBER_GREATER_THAN', - NumberGreaterThanOrEqual = 'NUMBER_GREATER_THAN_OR_EQUAL', - NumberLessThan = 'NUMBER_LESS_THAN', - NumberLessThanOrEqual = 'NUMBER_LESS_THAN_OR_EQUAL', - NumberNotBetween = 'NUMBER_NOT_BETWEEN', - NumberNotEquals = 'NUMBER_NOT_EQUALS', - TagsContains = 'TAGS_CONTAINS', - TagsEquals = 'TAGS_EQUALS', - TagsNotContains = 'TAGS_NOT_CONTAINS', - TagsNotEquals = 'TAGS_NOT_EQUALS', - TagsSingleContains = 'TAGS_SINGLE_CONTAINS', - TagsSingleEquals = 'TAGS_SINGLE_EQUALS', - TagsSingleNotContains = 'TAGS_SINGLE_NOT_CONTAINS', - TagsSingleNotEquals = 'TAGS_SINGLE_NOT_EQUALS', - TextContains = 'TEXT_CONTAINS', - TextEndsWith = 'TEXT_ENDS_WITH', - TextEquals = 'TEXT_EQUALS', - TextNotContains = 'TEXT_NOT_CONTAINS', - TextNotEquals = 'TEXT_NOT_EQUALS', - TextNotWhitespace = 'TEXT_NOT_WHITESPACE', - TextStartsWith = 'TEXT_STARTS_WITH' -} - -export type FilterParameter = { - compareDate?: InputMaybe; - compareDateTime?: InputMaybe; - compareValue?: InputMaybe; - isCaseSensitive?: Scalars['Boolean']['input']; - max?: InputMaybe; - maxDate?: InputMaybe; - maxDateTime?: InputMaybe; - min?: InputMaybe; - minDate?: InputMaybe; - minDateTime?: InputMaybe; - propertyDefinitionId?: InputMaybe; - searchTags?: InputMaybe>; - searchText?: InputMaybe; -}; - -export type FullTextSearchInput = { - includeProperties?: Scalars['Boolean']['input']; - propertyDefinitionIds?: InputMaybe>; - searchColumns?: InputMaybe>; - searchText: Scalars['String']['input']; -}; - export type LocationNodeType = { __typename?: 'LocationNodeType'; children: Array; @@ -188,40 +134,53 @@ export enum LocationType { export type Mutation = { __typename?: 'Mutation'; + addTaskAssignee: TaskType; admitPatient: PatientType; - assignTask: TaskType; + applyTaskGraph: Array; assignTaskToTeam: TaskType; completeTask: TaskType; createLocationNode: LocationNodeType; createPatient: PatientType; createPropertyDefinition: PropertyDefinitionType; + createSavedView: SavedView; createTask: TaskType; + createTaskPreset: TaskPresetType; deleteLocationNode: Scalars['Boolean']['output']; deletePatient: Scalars['Boolean']['output']; deletePropertyDefinition: Scalars['Boolean']['output']; + deleteSavedView: Scalars['Boolean']['output']; deleteTask: Scalars['Boolean']['output']; + deleteTaskPreset: Scalars['Boolean']['output']; dischargePatient: PatientType; + duplicateSavedView: SavedView; markPatientDead: PatientType; + removeTaskAssignee: TaskType; reopenTask: TaskType; - unassignTask: TaskType; unassignTaskFromTeam: TaskType; updateLocationNode: LocationNodeType; updatePatient: PatientType; updateProfilePicture: UserType; updatePropertyDefinition: PropertyDefinitionType; + updateSavedView: SavedView; updateTask: TaskType; + updateTaskPreset: TaskPresetType; waitPatient: PatientType; }; -export type MutationAdmitPatientArgs = { +export type MutationAddTaskAssigneeArgs = { id: Scalars['ID']['input']; + userId: Scalars['ID']['input']; }; -export type MutationAssignTaskArgs = { +export type MutationAdmitPatientArgs = { id: Scalars['ID']['input']; - userId: Scalars['ID']['input']; +}; + + +export type MutationApplyTaskGraphArgs = { + data: ApplyTaskGraphInput; }; @@ -251,11 +210,21 @@ export type MutationCreatePropertyDefinitionArgs = { }; +export type MutationCreateSavedViewArgs = { + data: CreateSavedViewInput; +}; + + export type MutationCreateTaskArgs = { data: CreateTaskInput; }; +export type MutationCreateTaskPresetArgs = { + data: CreateTaskPresetInput; +}; + + export type MutationDeleteLocationNodeArgs = { id: Scalars['ID']['input']; }; @@ -271,27 +240,44 @@ export type MutationDeletePropertyDefinitionArgs = { }; +export type MutationDeleteSavedViewArgs = { + id: Scalars['ID']['input']; +}; + + export type MutationDeleteTaskArgs = { id: Scalars['ID']['input']; }; +export type MutationDeleteTaskPresetArgs = { + id: Scalars['ID']['input']; +}; + + export type MutationDischargePatientArgs = { id: Scalars['ID']['input']; }; +export type MutationDuplicateSavedViewArgs = { + id: Scalars['ID']['input']; + name: Scalars['String']['input']; +}; + + export type MutationMarkPatientDeadArgs = { id: Scalars['ID']['input']; }; -export type MutationReopenTaskArgs = { +export type MutationRemoveTaskAssigneeArgs = { id: Scalars['ID']['input']; + userId: Scalars['ID']['input']; }; -export type MutationUnassignTaskArgs = { +export type MutationReopenTaskArgs = { id: Scalars['ID']['input']; }; @@ -324,12 +310,24 @@ export type MutationUpdatePropertyDefinitionArgs = { }; +export type MutationUpdateSavedViewArgs = { + data: UpdateSavedViewInput; + id: Scalars['ID']['input']; +}; + + export type MutationUpdateTaskArgs = { data: UpdateTaskInput; id: Scalars['ID']['input']; }; +export type MutationUpdateTaskPresetArgs = { + data: UpdateTaskPresetInput; + id: Scalars['ID']['input']; +}; + + export type MutationWaitPatientArgs = { id: Scalars['ID']['input']; }; @@ -426,15 +424,21 @@ export type Query = { locationNodes: Array; locationRoots: Array; me?: Maybe; + mySavedViews: Array; patient?: Maybe; patients: Array; patientsTotal: Scalars['Int']['output']; propertyDefinitions: Array; + queryableFields: Array; recentPatients: Array; recentPatientsTotal: Scalars['Int']['output']; recentTasks: Array; recentTasksTotal: Scalars['Int']['output']; + savedView?: Maybe; task?: Maybe; + taskPreset?: Maybe; + taskPresetByKey?: Maybe; + taskPresets: Array; tasks: Array; tasksTotal: Scalars['Int']['output']; user?: Maybe; @@ -471,53 +475,67 @@ export type QueryPatientArgs = { export type QueryPatientsArgs = { - filtering?: InputMaybe>; + filters?: InputMaybe>; locationNodeId?: InputMaybe; pagination?: InputMaybe; rootLocationIds?: InputMaybe>; - search?: InputMaybe; - sorting?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; states?: InputMaybe>; }; export type QueryPatientsTotalArgs = { - filtering?: InputMaybe>; + filters?: InputMaybe>; locationNodeId?: InputMaybe; rootLocationIds?: InputMaybe>; - search?: InputMaybe; - sorting?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; states?: InputMaybe>; }; +export type QueryQueryableFieldsArgs = { + entity: Scalars['String']['input']; +}; + + export type QueryRecentPatientsArgs = { - filtering?: InputMaybe>; + filters?: InputMaybe>; pagination?: InputMaybe; - search?: InputMaybe; - sorting?: InputMaybe>; + rootLocationIds?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; }; export type QueryRecentPatientsTotalArgs = { - filtering?: InputMaybe>; - search?: InputMaybe; - sorting?: InputMaybe>; + filters?: InputMaybe>; + rootLocationIds?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; }; export type QueryRecentTasksArgs = { - filtering?: InputMaybe>; + filters?: InputMaybe>; pagination?: InputMaybe; - search?: InputMaybe; - sorting?: InputMaybe>; + rootLocationIds?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; }; export type QueryRecentTasksTotalArgs = { - filtering?: InputMaybe>; - search?: InputMaybe; - sorting?: InputMaybe>; + filters?: InputMaybe>; + rootLocationIds?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; +}; + + +export type QuerySavedViewArgs = { + id: Scalars['ID']['input']; }; @@ -526,26 +544,36 @@ export type QueryTaskArgs = { }; +export type QueryTaskPresetArgs = { + id: Scalars['ID']['input']; +}; + + +export type QueryTaskPresetByKeyArgs = { + key: Scalars['String']['input']; +}; + + export type QueryTasksArgs = { assigneeId?: InputMaybe; assigneeTeamId?: InputMaybe; - filtering?: InputMaybe>; + filters?: InputMaybe>; pagination?: InputMaybe; patientId?: InputMaybe; rootLocationIds?: InputMaybe>; - search?: InputMaybe; - sorting?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; }; export type QueryTasksTotalArgs = { assigneeId?: InputMaybe; assigneeTeamId?: InputMaybe; - filtering?: InputMaybe>; + filters?: InputMaybe>; patientId?: InputMaybe; rootLocationIds?: InputMaybe>; - search?: InputMaybe; - sorting?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; }; @@ -555,12 +583,148 @@ export type QueryUserArgs = { export type QueryUsersArgs = { - filtering?: InputMaybe>; + filters?: InputMaybe>; pagination?: InputMaybe; - search?: InputMaybe; - sorting?: InputMaybe>; + search?: InputMaybe; + sorts?: InputMaybe>; +}; + +export type QueryFilterClauseInput = { + fieldKey: Scalars['String']['input']; + operator: QueryOperator; + value?: InputMaybe; +}; + +export type QueryFilterValueInput = { + boolValue?: InputMaybe; + dateMax?: InputMaybe; + dateMin?: InputMaybe; + dateValue?: InputMaybe; + floatMax?: InputMaybe; + floatMin?: InputMaybe; + floatValue?: InputMaybe; + stringValue?: InputMaybe; + stringValues?: InputMaybe>; + uuidValue?: InputMaybe; + uuidValues?: InputMaybe>; +}; + +export enum QueryOperator { + AllIn = 'ALL_IN', + AnyEq = 'ANY_EQ', + AnyIn = 'ANY_IN', + Between = 'BETWEEN', + Contains = 'CONTAINS', + EndsWith = 'ENDS_WITH', + Eq = 'EQ', + Gt = 'GT', + Gte = 'GTE', + In = 'IN', + IsEmpty = 'IS_EMPTY', + IsNotEmpty = 'IS_NOT_EMPTY', + IsNotNull = 'IS_NOT_NULL', + IsNull = 'IS_NULL', + Lt = 'LT', + Lte = 'LTE', + Neq = 'NEQ', + NoneIn = 'NONE_IN', + NotIn = 'NOT_IN', + StartsWith = 'STARTS_WITH' +} + +export type QuerySearchInput = { + includeProperties?: Scalars['Boolean']['input']; + searchText?: InputMaybe; }; +export type QuerySortClauseInput = { + direction: SortDirection; + fieldKey: Scalars['String']['input']; +}; + +export type QueryableChoiceMeta = { + __typename?: 'QueryableChoiceMeta'; + optionKeys: Array; + optionLabels: Array; +}; + +export type QueryableField = { + __typename?: 'QueryableField'; + allowedOperators: Array; + choice?: Maybe; + filterable: Scalars['Boolean']['output']; + key: Scalars['String']['output']; + kind: QueryableFieldKind; + label: Scalars['String']['output']; + propertyDefinitionId?: Maybe; + relation?: Maybe; + searchable: Scalars['Boolean']['output']; + sortDirections: Array; + sortable: Scalars['Boolean']['output']; + valueType: QueryableValueType; +}; + +export enum QueryableFieldKind { + Choice = 'CHOICE', + ChoiceList = 'CHOICE_LIST', + Property = 'PROPERTY', + Reference = 'REFERENCE', + ReferenceList = 'REFERENCE_LIST', + Scalar = 'SCALAR' +} + +export type QueryableRelationMeta = { + __typename?: 'QueryableRelationMeta'; + allowedFilterModes: Array; + idFieldKey: Scalars['String']['output']; + labelFieldKey: Scalars['String']['output']; + targetEntity: Scalars['String']['output']; +}; + +export enum QueryableValueType { + Boolean = 'BOOLEAN', + Date = 'DATE', + Datetime = 'DATETIME', + Number = 'NUMBER', + String = 'STRING', + StringList = 'STRING_LIST', + Uuid = 'UUID', + UuidList = 'UUID_LIST' +} + +export enum ReferenceFilterMode { + Id = 'ID', + Label = 'LABEL' +} + +export type SavedView = { + __typename?: 'SavedView'; + baseEntityType: SavedViewEntityType; + createdAt: Scalars['String']['output']; + filterDefinition: Scalars['String']['output']; + id: Scalars['ID']['output']; + isOwner: Scalars['Boolean']['output']; + name: Scalars['String']['output']; + ownerUserId: Scalars['ID']['output']; + parameters: Scalars['String']['output']; + relatedFilterDefinition: Scalars['String']['output']; + relatedParameters: Scalars['String']['output']; + relatedSortDefinition: Scalars['String']['output']; + sortDefinition: Scalars['String']['output']; + updatedAt: Scalars['String']['output']; + visibility: SavedViewVisibility; +}; + +export enum SavedViewEntityType { + Patient = 'PATIENT', + Task = 'TASK' +} + +export enum SavedViewVisibility { + LinkShared = 'LINK_SHARED', + Private = 'PRIVATE' +} + export enum Sex { Female = 'FEMALE', Male = 'MALE', @@ -572,13 +736,6 @@ export enum SortDirection { Desc = 'DESC' } -export type SortInput = { - column: Scalars['String']['input']; - columnType?: ColumnType; - direction: SortDirection; - propertyDefinitionId?: InputMaybe; -}; - export type Subscription = { __typename?: 'Subscription'; locationNodeCreated: Scalars['ID']['output']; @@ -636,6 +793,60 @@ export type SubscriptionTaskUpdatedArgs = { taskId?: InputMaybe; }; +export type TaskGraphEdgeInput = { + fromNodeId: Scalars['String']['input']; + toNodeId: Scalars['String']['input']; +}; + +export type TaskGraphEdgeType = { + __typename?: 'TaskGraphEdgeType'; + fromId: Scalars['String']['output']; + toId: Scalars['String']['output']; +}; + +export type TaskGraphInput = { + edges: Array; + nodes: Array; +}; + +export type TaskGraphNodeInput = { + description?: InputMaybe; + estimatedTime?: InputMaybe; + nodeId: Scalars['String']['input']; + priority?: InputMaybe; + title: Scalars['String']['input']; +}; + +export type TaskGraphNodeType = { + __typename?: 'TaskGraphNodeType'; + description?: Maybe; + estimatedTime?: Maybe; + id: Scalars['String']['output']; + priority?: Maybe; + title: Scalars['String']['output']; +}; + +export type TaskGraphType = { + __typename?: 'TaskGraphType'; + edges: Array; + nodes: Array; +}; + +export enum TaskPresetScope { + Global = 'GLOBAL', + Personal = 'PERSONAL' +} + +export type TaskPresetType = { + __typename?: 'TaskPresetType'; + graph: TaskGraphType; + id: Scalars['ID']['output']; + key: Scalars['String']['output']; + name: Scalars['String']['output']; + ownerUserId?: Maybe; + scope: Scalars['String']['output']; +}; + export enum TaskPriority { P1 = 'P1', P2 = 'P2', @@ -645,10 +856,9 @@ export enum TaskPriority { export type TaskType = { __typename?: 'TaskType'; - assignee?: Maybe; - assigneeId?: Maybe; assigneeTeam?: Maybe; assigneeTeamId?: Maybe; + assignees: Array; checksum: Scalars['String']['output']; creationDate: Scalars['DateTime']['output']; description?: Maybe; @@ -656,10 +866,11 @@ export type TaskType = { dueDate?: Maybe; estimatedTime?: Maybe; id: Scalars['ID']['output']; - patient: PatientType; - patientId: Scalars['ID']['output']; + patient?: Maybe; + patientId?: Maybe; priority?: Maybe; properties: Array; + sourceTaskPresetId?: Maybe; title: Scalars['String']['output']; updateDate?: Maybe; }; @@ -697,20 +908,38 @@ export type UpdatePropertyDefinitionInput = { options?: InputMaybe>; }; +export type UpdateSavedViewInput = { + filterDefinition?: InputMaybe; + name?: InputMaybe; + parameters?: InputMaybe; + relatedFilterDefinition?: InputMaybe; + relatedParameters?: InputMaybe; + relatedSortDefinition?: InputMaybe; + sortDefinition?: InputMaybe; + visibility?: InputMaybe; +}; + export type UpdateTaskInput = { - assigneeId?: InputMaybe; + assigneeIds?: InputMaybe>; assigneeTeamId?: InputMaybe; checksum?: InputMaybe; description?: InputMaybe; done?: InputMaybe; dueDate?: InputMaybe; estimatedTime?: InputMaybe; + patientId?: InputMaybe; previousTaskIds?: InputMaybe>; priority?: InputMaybe; properties?: InputMaybe>; title?: InputMaybe; }; +export type UpdateTaskPresetInput = { + graph?: InputMaybe; + key?: InputMaybe; + name?: InputMaybe; +}; + export type UserType = { __typename?: 'UserType'; avatarUrl?: Maybe; @@ -760,62 +989,62 @@ export type GetLocationsQuery = { __typename?: 'Query', locationNodes: Array<{ _ export type GetMyTasksQueryVariables = Exact<{ [key: string]: never; }>; -export type GetMyTasksQuery = { __typename?: 'Query', me?: { __typename?: 'UserType', id: string, 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, lastOnline?: any | null, isOnline: boolean } | null }> } | null }; +export type GetMyTasksQuery = { __typename?: 'Query', me?: { __typename?: 'UserType', id: string, 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 }> } | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }> }> } | null }; export type GetOverviewDataQueryVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; - recentPatientsFiltering?: InputMaybe | FilterInput>; - recentPatientsSorting?: InputMaybe | SortInput>; + recentPatientsFilters?: InputMaybe | QueryFilterClauseInput>; + recentPatientsSorts?: InputMaybe | QuerySortClauseInput>; recentPatientsPagination?: InputMaybe; - recentPatientsSearch?: InputMaybe; - recentTasksFiltering?: InputMaybe | FilterInput>; - recentTasksSorting?: InputMaybe | SortInput>; + recentPatientsSearch?: InputMaybe; + recentTasksFilters?: InputMaybe | QueryFilterClauseInput>; + recentTasksSorts?: InputMaybe | QuerySortClauseInput>; recentTasksPagination?: InputMaybe; - recentTasksSearch?: InputMaybe; + recentTasksSearch?: InputMaybe; }>; -export type GetOverviewDataQuery = { __typename?: 'Query', recentPatientsTotal: number, recentTasksTotal: number, recentPatients: Array<{ __typename?: 'PatientType', id: string, name: string, sex: Sex, birthdate: any, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, tasks: Array<{ __typename?: 'TaskType', updateDate?: any | null }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }>, recentTasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, priority?: string | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, patient: { __typename?: 'PatientType', id: string, name: string, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null }, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }> }; +export type GetOverviewDataQuery = { __typename?: 'Query', recentPatientsTotal: number, recentTasksTotal: number, recentPatients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, sex: Sex, birthdate: any, state: PatientState, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, tasks: Array<{ __typename?: 'TaskType', id: string, done: boolean, updateDate?: any | null }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }>, recentTasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, creationDate: any, updateDate?: any | null, priority?: string | null, estimatedTime?: number | null, sourceTaskPresetId?: string | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, patient?: { __typename?: 'PatientType', id: string, name: string, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }> }; export type GetPatientQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type GetPatientQuery = { __typename?: 'Query', patient?: { __typename?: 'PatientType', id: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, description?: string | null, 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, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> } | null }; +export type GetPatientQuery = { __typename?: 'Query', patient?: { __typename?: 'PatientType', id: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, description?: string | null, 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, sourceTaskPresetId?: string | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> } | null }; export type GetPatientsQueryVariables = Exact<{ locationId?: InputMaybe; rootLocationIds?: InputMaybe | Scalars['ID']['input']>; states?: InputMaybe | PatientState>; - filtering?: InputMaybe | FilterInput>; - sorting?: InputMaybe | SortInput>; + filters?: InputMaybe | QueryFilterClauseInput>; + sorts?: InputMaybe | QuerySortClauseInput>; pagination?: InputMaybe; - search?: InputMaybe; + search?: InputMaybe; }>; -export type GetPatientsQuery = { __typename?: 'Query', patientsTotal: number, 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, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | 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, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }> }; +export type GetPatientsQuery = { __typename?: 'Query', patientsTotal: number, 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, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | 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, sourceTaskPresetId?: string | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }> }; 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, updateDate?: any | null, patient: { __typename?: 'PatientType', id: string, name: string }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> } | 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, updateDate?: any | null, sourceTaskPresetId?: string | null, patient?: { __typename?: 'PatientType', id: string, name: string } | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> } | null }; export type GetTasksQueryVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; assigneeId?: InputMaybe; assigneeTeamId?: InputMaybe; - filtering?: InputMaybe | FilterInput>; - sorting?: InputMaybe | SortInput>; + filters?: InputMaybe | QueryFilterClauseInput>; + sorts?: InputMaybe | QuerySortClauseInput>; pagination?: InputMaybe; - search?: InputMaybe; + search?: InputMaybe; }>; -export type GetTasksQuery = { __typename?: 'Query', tasksTotal: number, 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, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null } | null } | null }> }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }> }; +export type GetTasksQuery = { __typename?: 'Query', tasksTotal: number, 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, sourceTaskPresetId?: string | null, patient?: { __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, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | 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 }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> } | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }> }; export type GetUserQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -921,6 +1150,55 @@ export type GetPropertiesForSubjectQueryVariables = Exact<{ export type GetPropertiesForSubjectQuery = { __typename?: 'Query', propertyDefinitions: Array<{ __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }> }; +export type QueryableFieldsQueryVariables = Exact<{ + entity: Scalars['String']['input']; +}>; + + +export type QueryableFieldsQuery = { __typename?: 'Query', queryableFields: Array<{ __typename?: 'QueryableField', key: string, label: string, kind: QueryableFieldKind, valueType: QueryableValueType, allowedOperators: Array, sortable: boolean, sortDirections: Array, searchable: boolean, filterable: boolean, propertyDefinitionId?: string | null, relation?: { __typename?: 'QueryableRelationMeta', targetEntity: string, idFieldKey: string, labelFieldKey: string, allowedFilterModes: Array } | null, choice?: { __typename?: 'QueryableChoiceMeta', optionKeys: Array, optionLabels: Array } | null }> }; + +export type MySavedViewsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type MySavedViewsQuery = { __typename?: 'Query', mySavedViews: Array<{ __typename?: 'SavedView', id: string, name: string, baseEntityType: SavedViewEntityType, filterDefinition: string, sortDefinition: string, parameters: string, relatedFilterDefinition: string, relatedSortDefinition: string, relatedParameters: string, ownerUserId: string, visibility: SavedViewVisibility, createdAt: string, updatedAt: string, isOwner: boolean }> }; + +export type SavedViewQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type SavedViewQuery = { __typename?: 'Query', savedView?: { __typename?: 'SavedView', id: string, name: string, baseEntityType: SavedViewEntityType, filterDefinition: string, sortDefinition: string, parameters: string, relatedFilterDefinition: string, relatedSortDefinition: string, relatedParameters: string, ownerUserId: string, visibility: SavedViewVisibility, createdAt: string, updatedAt: string, isOwner: boolean } | null }; + +export type CreateSavedViewMutationVariables = Exact<{ + data: CreateSavedViewInput; +}>; + + +export type CreateSavedViewMutation = { __typename?: 'Mutation', createSavedView: { __typename?: 'SavedView', id: string, name: string, baseEntityType: SavedViewEntityType, filterDefinition: string, sortDefinition: string, parameters: string, relatedFilterDefinition: string, relatedSortDefinition: string, relatedParameters: string, ownerUserId: string, visibility: SavedViewVisibility, createdAt: string, updatedAt: string, isOwner: boolean } }; + +export type UpdateSavedViewMutationVariables = Exact<{ + id: Scalars['ID']['input']; + data: UpdateSavedViewInput; +}>; + + +export type UpdateSavedViewMutation = { __typename?: 'Mutation', updateSavedView: { __typename?: 'SavedView', id: string, name: string, baseEntityType: SavedViewEntityType, filterDefinition: string, sortDefinition: string, parameters: string, relatedFilterDefinition: string, relatedSortDefinition: string, relatedParameters: string, ownerUserId: string, visibility: SavedViewVisibility, createdAt: string, updatedAt: string, isOwner: boolean } }; + +export type DeleteSavedViewMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DeleteSavedViewMutation = { __typename?: 'Mutation', deleteSavedView: boolean }; + +export type DuplicateSavedViewMutationVariables = Exact<{ + id: Scalars['ID']['input']; + name: Scalars['String']['input']; +}>; + + +export type DuplicateSavedViewMutation = { __typename?: 'Mutation', duplicateSavedView: { __typename?: 'SavedView', id: string, name: string, baseEntityType: SavedViewEntityType, filterDefinition: string, sortDefinition: string, parameters: string, relatedFilterDefinition: string, relatedSortDefinition: string, relatedParameters: string, ownerUserId: string, visibility: SavedViewVisibility, createdAt: string, updatedAt: string, isOwner: boolean } }; + export type PatientCreatedSubscriptionVariables = Exact<{ rootLocationIds?: InputMaybe | Scalars['ID']['input']>; }>; @@ -988,7 +1266,7 @@ export type CreateTaskMutationVariables = Exact<{ }>; -export type CreateTaskMutation = { __typename?: 'Mutation', createTask: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, patient: { __typename?: 'PatientType', id: string, name: string } } }; +export type CreateTaskMutation = { __typename?: 'Mutation', createTask: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, patient?: { __typename?: 'PatientType', id: string, name: string } | null } }; export type UpdateTaskMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -996,22 +1274,23 @@ export type UpdateTaskMutationVariables = Exact<{ }>; -export type UpdateTaskMutation = { __typename?: 'Mutation', updateTask: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, updateDate?: any | null, checksum: string, patient: { __typename?: 'PatientType', id: string, name: string }, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> } }; +export type UpdateTaskMutation = { __typename?: 'Mutation', updateTask: { __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, updateDate?: any | null, checksum: string, patient?: { __typename?: 'PatientType', id: string, name: string } | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> } }; -export type AssignTaskMutationVariables = Exact<{ +export type AddTaskAssigneeMutationVariables = Exact<{ id: Scalars['ID']['input']; userId: Scalars['ID']['input']; }>; -export type AssignTaskMutation = { __typename?: 'Mutation', assignTask: { __typename?: 'TaskType', id: string, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null } }; +export type AddTaskAssigneeMutation = { __typename?: 'Mutation', addTaskAssignee: { __typename?: 'TaskType', id: string, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }> } }; -export type UnassignTaskMutationVariables = Exact<{ +export type RemoveTaskAssigneeMutationVariables = Exact<{ id: Scalars['ID']['input']; + userId: Scalars['ID']['input']; }>; -export type UnassignTaskMutation = { __typename?: 'Mutation', unassignTask: { __typename?: 'TaskType', id: string, assignee?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null } }; +export type RemoveTaskAssigneeMutation = { __typename?: 'Mutation', removeTaskAssignee: { __typename?: 'TaskType', id: string, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }> } }; export type DeleteTaskMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -1049,6 +1328,47 @@ export type UnassignTaskFromTeamMutationVariables = Exact<{ export type UnassignTaskFromTeamMutation = { __typename?: 'Mutation', unassignTaskFromTeam: { __typename?: 'TaskType', id: string, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null } }; +export type ApplyTaskGraphMutationVariables = Exact<{ + data: ApplyTaskGraphInput; +}>; + + +export type ApplyTaskGraphMutation = { __typename?: 'Mutation', applyTaskGraph: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, updateDate?: any | null, sourceTaskPresetId?: string | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, patient?: { __typename?: 'PatientType', id: string, name: string } | null }> }; + +export type CreateTaskPresetMutationVariables = Exact<{ + data: CreateTaskPresetInput; +}>; + + +export type CreateTaskPresetMutation = { __typename?: 'Mutation', createTaskPreset: { __typename?: 'TaskPresetType', id: string, name: string, key: string, scope: string, ownerUserId?: string | null, graph: { __typename?: 'TaskGraphType', nodes: Array<{ __typename?: 'TaskGraphNodeType', id: string, title: string, description?: string | null, priority?: string | null, estimatedTime?: number | null }>, edges: Array<{ __typename?: 'TaskGraphEdgeType', fromId: string, toId: string }> } } }; + +export type UpdateTaskPresetMutationVariables = Exact<{ + id: Scalars['ID']['input']; + data: UpdateTaskPresetInput; +}>; + + +export type UpdateTaskPresetMutation = { __typename?: 'Mutation', updateTaskPreset: { __typename?: 'TaskPresetType', id: string, name: string, key: string, scope: string, ownerUserId?: string | null, graph: { __typename?: 'TaskGraphType', nodes: Array<{ __typename?: 'TaskGraphNodeType', id: string, title: string, description?: string | null, priority?: string | null, estimatedTime?: number | null }>, edges: Array<{ __typename?: 'TaskGraphEdgeType', fromId: string, toId: string }> } } }; + +export type DeleteTaskPresetMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DeleteTaskPresetMutation = { __typename?: 'Mutation', deleteTaskPreset: boolean }; + +export type TaskPresetsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type TaskPresetsQuery = { __typename?: 'Query', taskPresets: Array<{ __typename?: 'TaskPresetType', id: string, name: string, key: string, scope: string, ownerUserId?: string | null, graph: { __typename?: 'TaskGraphType', nodes: Array<{ __typename?: 'TaskGraphNodeType', id: string, title: string, description?: string | null, priority?: string | null, estimatedTime?: number | null }>, edges: Array<{ __typename?: 'TaskGraphEdgeType', fromId: string, toId: string }> } }> }; + +export type TaskPresetQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type TaskPresetQuery = { __typename?: 'Query', taskPreset?: { __typename?: 'TaskPresetType', id: string, name: string, key: string, scope: string, ownerUserId?: string | null, graph: { __typename?: 'TaskGraphType', nodes: Array<{ __typename?: 'TaskGraphNodeType', id: string, title: string, description?: string | null, priority?: string | null, estimatedTime?: number | null }>, edges: Array<{ __typename?: 'TaskGraphEdgeType', fromId: string, toId: string }> } } | null }; + export type UpdateProfilePictureMutationVariables = Exact<{ data: UpdateProfilePictureInput; }>; @@ -1060,12 +1380,12 @@ export type UpdateProfilePictureMutation = { __typename?: 'Mutation', updateProf export const GetAuditLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAuditLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"caseId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"auditLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"caseId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"caseId"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"caseId"}},{"kind":"Field","name":{"kind":"Name","value":"activity"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"context"}}]}}]}}]} as unknown as DocumentNode; export const GetLocationNodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLocationNode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"locationNode"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetLocationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLocations"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"locationNodes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}}]}}]}}]} as unknown as DocumentNode; -export const GetMyTasksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMyTasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetOverviewDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOverviewData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFiltering"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FilterInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorting"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsPagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"FullTextSearchInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFiltering"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FilterInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorting"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksPagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"FullTextSearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentPatients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filtering"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFiltering"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorting"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorting"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsPagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDate"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPatientsTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filtering"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFiltering"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorting"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorting"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}}}]},{"kind":"Field","name":{"kind":"Name","value":"recentTasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filtering"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFiltering"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorting"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorting"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksPagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentTasksTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filtering"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFiltering"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorting"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorting"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}}}]}]}}]} as unknown as DocumentNode; -export const GetPatientDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPatient"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patient"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"checksum"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetPatientsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPatients"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"states"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PatientState"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filtering"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FilterInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sorting"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"FullTextSearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationNodeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"Variable","name":{"kind":"Name","value":"states"}}},{"kind":"Argument","name":{"kind":"Name","value":"filtering"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filtering"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorting"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorting"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"patientsTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationNodeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"Variable","name":{"kind":"Name","value":"states"}}},{"kind":"Argument","name":{"kind":"Name","value":"filtering"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filtering"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorting"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorting"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}]}}]} as unknown as DocumentNode; -export const GetTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"task"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"checksum"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetTasksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTasks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filtering"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FilterInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sorting"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"FullTextSearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeTeamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"filtering"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filtering"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorting"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorting"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasksTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeTeamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"filtering"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filtering"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorting"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorting"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}]}}]} as unknown as DocumentNode; +export const GetMyTasksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMyTasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetOverviewDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOverviewData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFilters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsPagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFilters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksPagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentPatients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFilters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsPagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPatientsTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFilters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}}}]},{"kind":"Field","name":{"kind":"Name","value":"recentTasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFilters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksPagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentTasksTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFilters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}}}]}]}}]} as unknown as DocumentNode; +export const GetPatientDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPatient"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patient"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"checksum"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetPatientsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPatients"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"states"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PatientState"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationNodeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"Variable","name":{"kind":"Name","value":"states"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"patientsTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationNodeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"Variable","name":{"kind":"Name","value":"states"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}]}}]} as unknown as DocumentNode; +export const GetTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"task"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"checksum"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetTasksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTasks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeTeamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasksTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeTeamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}]}}]} as unknown as DocumentNode; export const GetUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]} as unknown as DocumentNode; export const GetUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]} as unknown as DocumentNode; export const GetGlobalDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetGlobalData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}},{"kind":"Field","name":{"kind":"Name","value":"organizations"}},{"kind":"Field","name":{"kind":"Name","value":"rootLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"done"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"wards"},"name":{"kind":"Name","value":"locationNodes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"kind"},"value":{"kind":"EnumValue","value":"WARD"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"teams"},"name":{"kind":"Name","value":"locationNodes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"kind"},"value":{"kind":"EnumValue","value":"TEAM"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"clinics"},"name":{"kind":"Name","value":"locationNodes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"kind"},"value":{"kind":"EnumValue","value":"CLINIC"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}}]}},{"kind":"Field","name":{"kind":"Name","value":"patients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"waitingPatients"},"name":{"kind":"Name","value":"patients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"ListValue","values":[{"kind":"EnumValue","value":"WAIT"}]}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"state"}}]}}]}}]} as unknown as DocumentNode; @@ -1081,6 +1401,13 @@ export const UpdatePropertyDefinitionDocument = {"kind":"Document","definitions" export const DeletePropertyDefinitionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeletePropertyDefinition"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletePropertyDefinition"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const GetPropertyDefinitionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPropertyDefinitions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"propertyDefinitions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}}]}}]} as unknown as DocumentNode; export const GetPropertiesForSubjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPropertiesForSubject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"subjectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"subjectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PropertyEntity"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"propertyDefinitions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}}]}}]} as unknown as DocumentNode; +export const QueryableFieldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"QueryableFields"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entity"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"queryableFields"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"entity"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entity"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"valueType"}},{"kind":"Field","name":{"kind":"Name","value":"allowedOperators"}},{"kind":"Field","name":{"kind":"Name","value":"sortable"}},{"kind":"Field","name":{"kind":"Name","value":"sortDirections"}},{"kind":"Field","name":{"kind":"Name","value":"searchable"}},{"kind":"Field","name":{"kind":"Name","value":"filterable"}},{"kind":"Field","name":{"kind":"Name","value":"propertyDefinitionId"}},{"kind":"Field","name":{"kind":"Name","value":"relation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"targetEntity"}},{"kind":"Field","name":{"kind":"Name","value":"idFieldKey"}},{"kind":"Field","name":{"kind":"Name","value":"labelFieldKey"}},{"kind":"Field","name":{"kind":"Name","value":"allowedFilterModes"}}]}},{"kind":"Field","name":{"kind":"Name","value":"choice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"optionKeys"}},{"kind":"Field","name":{"kind":"Name","value":"optionLabels"}}]}}]}}]}}]} as unknown as DocumentNode; +export const MySavedViewsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MySavedViews"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"mySavedViews"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"baseEntityType"}},{"kind":"Field","name":{"kind":"Name","value":"filterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"sortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}},{"kind":"Field","name":{"kind":"Name","value":"relatedFilterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedSortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedParameters"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isOwner"}}]}}]}}]} as unknown as DocumentNode; +export const SavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"baseEntityType"}},{"kind":"Field","name":{"kind":"Name","value":"filterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"sortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}},{"kind":"Field","name":{"kind":"Name","value":"relatedFilterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedSortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedParameters"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isOwner"}}]}}]}}]} as unknown as DocumentNode; +export const CreateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateSavedViewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSavedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"baseEntityType"}},{"kind":"Field","name":{"kind":"Name","value":"filterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"sortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}},{"kind":"Field","name":{"kind":"Name","value":"relatedFilterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedSortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedParameters"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isOwner"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSavedViewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSavedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"baseEntityType"}},{"kind":"Field","name":{"kind":"Name","value":"filterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"sortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}},{"kind":"Field","name":{"kind":"Name","value":"relatedFilterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedSortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedParameters"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isOwner"}}]}}]}}]} as unknown as DocumentNode; +export const DeleteSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteSavedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; +export const DuplicateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DuplicateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"duplicateSavedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"baseEntityType"}},{"kind":"Field","name":{"kind":"Name","value":"filterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"sortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}},{"kind":"Field","name":{"kind":"Name","value":"relatedFilterDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedSortDefinition"}},{"kind":"Field","name":{"kind":"Name","value":"relatedParameters"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isOwner"}}]}}]}}]} as unknown as DocumentNode; export const PatientCreatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"PatientCreated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patientCreated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}}]}]}}]} as unknown as DocumentNode; export const PatientUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"PatientUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"patientId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patientUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"patientId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"patientId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}}]}]}}]} as unknown as DocumentNode; export const PatientStateChangedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"PatientStateChanged"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"patientId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patientStateChanged"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"patientId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"patientId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}}]}]}}]} as unknown as DocumentNode; @@ -1090,13 +1417,19 @@ export const TaskDeletedDocument = {"kind":"Document","definitions":[{"kind":"Op export const LocationNodeUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LocationNodeUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"locationNodeUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}}]}]}}]} as unknown as DocumentNode; export const LocationNodeCreatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LocationNodeCreated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"locationNodeCreated"}}]}}]} as unknown as DocumentNode; export const LocationNodeDeletedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LocationNodeDeleted"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"locationNodeDeleted"}}]}}]} as unknown as DocumentNode; -export const CreateTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateTaskInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; -export const UpdateTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateTaskInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"checksum"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const AssignTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AssignTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assignTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]}}]} as unknown as DocumentNode; -export const UnassignTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UnassignTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unassignTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateTaskInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateTaskInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"checksum"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const AddTaskAssigneeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddTaskAssignee"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addTaskAssignee"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]}}]} as unknown as DocumentNode; +export const RemoveTaskAssigneeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveTaskAssignee"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeTaskAssignee"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const CompleteTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CompleteTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"completeTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}}]}}]}}]} as unknown as DocumentNode; export const ReopenTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReopenTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reopenTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}}]}}]}}]} as unknown as DocumentNode; export const AssignTaskToTeamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AssignTaskToTeam"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assignTaskToTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]} as unknown as DocumentNode; export const UnassignTaskFromTeamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UnassignTaskFromTeam"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unassignTaskFromTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ApplyTaskGraphDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ApplyTaskGraph"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApplyTaskGraphInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyTaskGraph"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateTaskPresetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateTaskPreset"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateTaskPresetInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createTaskPreset"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"scope"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"graph"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fromId"}},{"kind":"Field","name":{"kind":"Name","value":"toId"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateTaskPresetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateTaskPreset"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateTaskPresetInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTaskPreset"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"scope"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"graph"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fromId"}},{"kind":"Field","name":{"kind":"Name","value":"toId"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const DeleteTaskPresetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteTaskPreset"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteTaskPreset"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; +export const TaskPresetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TaskPresets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"taskPresets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"scope"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"graph"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fromId"}},{"kind":"Field","name":{"kind":"Name","value":"toId"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const TaskPresetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TaskPreset"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"taskPreset"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"scope"}},{"kind":"Field","name":{"kind":"Name","value":"ownerUserId"}},{"kind":"Field","name":{"kind":"Name","value":"graph"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fromId"}},{"kind":"Field","name":{"kind":"Name","value":"toId"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateProfilePictureInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/web/api/graphql/GetMyTasks.graphql b/web/api/graphql/GetMyTasks.graphql index e9fd0262..fe7d66ce 100644 --- a/web/api/graphql/GetMyTasks.graphql +++ b/web/api/graphql/GetMyTasks.graphql @@ -36,7 +36,7 @@ query GetMyTasks { } } } - assignee { + assignees { id name avatarUrl diff --git a/web/api/graphql/GetOverviewData.graphql b/web/api/graphql/GetOverviewData.graphql index cacdfb15..953c2aac 100644 --- a/web/api/graphql/GetOverviewData.graphql +++ b/web/api/graphql/GetOverviewData.graphql @@ -1,9 +1,12 @@ -query GetOverviewData($rootLocationIds: [ID!], $recentPatientsFiltering: [FilterInput!], $recentPatientsSorting: [SortInput!], $recentPatientsPagination: PaginationInput, $recentPatientsSearch: FullTextSearchInput, $recentTasksFiltering: [FilterInput!], $recentTasksSorting: [SortInput!], $recentTasksPagination: PaginationInput, $recentTasksSearch: FullTextSearchInput) { - recentPatients(rootLocationIds: $rootLocationIds, filtering: $recentPatientsFiltering, sorting: $recentPatientsSorting, pagination: $recentPatientsPagination, search: $recentPatientsSearch) { +query GetOverviewData($rootLocationIds: [ID!], $recentPatientsFilters: [QueryFilterClauseInput!], $recentPatientsSorts: [QuerySortClauseInput!], $recentPatientsPagination: PaginationInput, $recentPatientsSearch: QuerySearchInput, $recentTasksFilters: [QueryFilterClauseInput!], $recentTasksSorts: [QuerySortClauseInput!], $recentTasksPagination: PaginationInput, $recentTasksSearch: QuerySearchInput) { + recentPatients(rootLocationIds: $rootLocationIds, filters: $recentPatientsFilters, sorts: $recentPatientsSorts, pagination: $recentPatientsPagination, search: $recentPatientsSearch) { id name + firstname + lastname sex birthdate + state position { id title @@ -14,6 +17,8 @@ query GetOverviewData($rootLocationIds: [ID!], $recentPatientsFiltering: [Filter } } tasks { + id + done updateDate } properties { @@ -49,22 +54,30 @@ query GetOverviewData($rootLocationIds: [ID!], $recentPatientsFiltering: [Filter } } } - recentPatientsTotal(rootLocationIds: $rootLocationIds, filtering: $recentPatientsFiltering, sorting: $recentPatientsSorting, search: $recentPatientsSearch) - recentTasks(rootLocationIds: $rootLocationIds, filtering: $recentTasksFiltering, sorting: $recentTasksSorting, pagination: $recentTasksPagination, search: $recentTasksSearch) { + recentPatientsTotal(rootLocationIds: $rootLocationIds, filters: $recentPatientsFilters, sorts: $recentPatientsSorts, search: $recentPatientsSearch) + recentTasks(rootLocationIds: $rootLocationIds, filters: $recentTasksFilters, sorts: $recentTasksSorts, pagination: $recentTasksPagination, search: $recentTasksSearch) { id title description done dueDate + creationDate updateDate priority - assignee { + estimatedTime + sourceTaskPresetId + assignees { id name avatarUrl lastOnline isOnline } + assigneeTeam { + id + title + kind + } patient { id name @@ -111,5 +124,5 @@ query GetOverviewData($rootLocationIds: [ID!], $recentPatientsFiltering: [Filter } } } - recentTasksTotal(rootLocationIds: $rootLocationIds, filtering: $recentTasksFiltering, sorting: $recentTasksSorting, search: $recentTasksSearch) + recentTasksTotal(rootLocationIds: $rootLocationIds, filters: $recentTasksFilters, sorts: $recentTasksSorts, search: $recentTasksSearch) } diff --git a/web/api/graphql/GetPatient.graphql b/web/api/graphql/GetPatient.graphql index 1ec43314..2143484e 100644 --- a/web/api/graphql/GetPatient.graphql +++ b/web/api/graphql/GetPatient.graphql @@ -88,7 +88,8 @@ query GetPatient($id: ID!) { priority estimatedTime updateDate - assignee { + sourceTaskPresetId + assignees { id name avatarUrl diff --git a/web/api/graphql/GetPatients.graphql b/web/api/graphql/GetPatients.graphql index 1875fa4d..ec9a6ca3 100644 --- a/web/api/graphql/GetPatients.graphql +++ b/web/api/graphql/GetPatients.graphql @@ -1,5 +1,5 @@ -query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $filtering: [FilterInput!], $sorting: [SortInput!], $pagination: PaginationInput, $search: FullTextSearchInput) { - patients(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states, filtering: $filtering, sorting: $sorting, pagination: $pagination, search: $search) { +query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientState!], $filters: [QueryFilterClauseInput!], $sorts: [QuerySortClauseInput!], $pagination: PaginationInput, $search: QuerySearchInput) { + patients(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states, filters: $filters, sorts: $sorts, pagination: $pagination, search: $search) { id name firstname @@ -109,7 +109,8 @@ query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientSta estimatedTime creationDate updateDate - assignee { + sourceTaskPresetId + assignees { id name avatarUrl @@ -155,5 +156,5 @@ query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientSta } } } - patientsTotal(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states, filtering: $filtering, sorting: $sorting, search: $search) + patientsTotal(locationNodeId: $locationId, rootLocationIds: $rootLocationIds, states: $states, filters: $filters, sorts: $sorts, search: $search) } diff --git a/web/api/graphql/GetTask.graphql b/web/api/graphql/GetTask.graphql index 8c4c8e1f..7d7594d0 100644 --- a/web/api/graphql/GetTask.graphql +++ b/web/api/graphql/GetTask.graphql @@ -9,11 +9,12 @@ query GetTask($id: ID!) { estimatedTime checksum updateDate + sourceTaskPresetId patient { id name } - assignee { + assignees { id name avatarUrl diff --git a/web/api/graphql/GetTasks.graphql b/web/api/graphql/GetTasks.graphql index 7864f970..6758413a 100644 --- a/web/api/graphql/GetTasks.graphql +++ b/web/api/graphql/GetTasks.graphql @@ -1,5 +1,5 @@ -query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $filtering: [FilterInput!], $sorting: [SortInput!], $pagination: PaginationInput, $search: FullTextSearchInput) { - tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId, filtering: $filtering, sorting: $sorting, pagination: $pagination, search: $search) { +query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $filters: [QueryFilterClauseInput!], $sorts: [QuerySortClauseInput!], $pagination: PaginationInput, $search: QuerySearchInput) { + tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId, filters: $filters, sorts: $sorts, pagination: $pagination, search: $search) { id title description @@ -9,9 +9,15 @@ query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $f estimatedTime creationDate updateDate + sourceTaskPresetId patient { id name + firstname + lastname + birthdate + sex + state assignedLocation { id title @@ -21,6 +27,44 @@ query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $f } } assignedLocations { + id + title + kind + parent { + id + title + parent { + id + title + parent { + id + title + } + } + } + } + clinic { + id + title + kind + parent { + id + title + parent { + id + title + parent { + id + title + parent { + id + title + } + } + } + } + } + position { id title kind @@ -40,8 +84,61 @@ query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $f } } } + teams { + id + title + kind + parent { + id + title + parent { + id + title + parent { + id + title + parent { + id + title + } + } + } + } + } + properties { + id + definition { + id + name + description + fieldType + isActive + allowedEntities + options + } + textValue + numberValue + booleanValue + dateValue + dateTimeValue + selectValue + multiSelectValues + userValue + user { + id + name + avatarUrl + lastOnline + isOnline + } + team { + id + title + kind + } + } } - assignee { + assignees { id name avatarUrl @@ -86,6 +183,5 @@ query GetTasks($rootLocationIds: [ID!], $assigneeId: ID, $assigneeTeamId: ID, $f } } } - tasksTotal(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId, filtering: $filtering, sorting: $sorting, search: $search) + tasksTotal(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, assigneeTeamId: $assigneeTeamId, filters: $filters, sorts: $sorts, search: $search) } - diff --git a/web/api/graphql/QueryableFields.graphql b/web/api/graphql/QueryableFields.graphql new file mode 100644 index 00000000..b5b78dbc --- /dev/null +++ b/web/api/graphql/QueryableFields.graphql @@ -0,0 +1,24 @@ +query QueryableFields($entity: String!) { + queryableFields(entity: $entity) { + key + label + kind + valueType + allowedOperators + sortable + sortDirections + searchable + filterable + propertyDefinitionId + relation { + targetEntity + idFieldKey + labelFieldKey + allowedFilterModes + } + choice { + optionKeys + optionLabels + } + } +} diff --git a/web/api/graphql/SavedView.graphql b/web/api/graphql/SavedView.graphql new file mode 100644 index 00000000..1964b2b5 --- /dev/null +++ b/web/api/graphql/SavedView.graphql @@ -0,0 +1,98 @@ +query MySavedViews { + mySavedViews { + id + name + baseEntityType + filterDefinition + sortDefinition + parameters + relatedFilterDefinition + relatedSortDefinition + relatedParameters + ownerUserId + visibility + createdAt + updatedAt + isOwner + } +} + +query SavedView($id: ID!) { + savedView(id: $id) { + id + name + baseEntityType + filterDefinition + sortDefinition + parameters + relatedFilterDefinition + relatedSortDefinition + relatedParameters + ownerUserId + visibility + createdAt + updatedAt + isOwner + } +} + +mutation CreateSavedView($data: CreateSavedViewInput!) { + createSavedView(data: $data) { + id + name + baseEntityType + filterDefinition + sortDefinition + parameters + relatedFilterDefinition + relatedSortDefinition + relatedParameters + ownerUserId + visibility + createdAt + updatedAt + isOwner + } +} + +mutation UpdateSavedView($id: ID!, $data: UpdateSavedViewInput!) { + updateSavedView(id: $id, data: $data) { + id + name + baseEntityType + filterDefinition + sortDefinition + parameters + relatedFilterDefinition + relatedSortDefinition + relatedParameters + ownerUserId + visibility + createdAt + updatedAt + isOwner + } +} + +mutation DeleteSavedView($id: ID!) { + deleteSavedView(id: $id) +} + +mutation DuplicateSavedView($id: ID!, $name: String!) { + duplicateSavedView(id: $id, name: $name) { + id + name + baseEntityType + filterDefinition + sortDefinition + parameters + relatedFilterDefinition + relatedSortDefinition + relatedParameters + ownerUserId + visibility + createdAt + updatedAt + isOwner + } +} diff --git a/web/api/graphql/TaskMutations.graphql b/web/api/graphql/TaskMutations.graphql index 27bad5e6..89e4fa5c 100644 --- a/web/api/graphql/TaskMutations.graphql +++ b/web/api/graphql/TaskMutations.graphql @@ -6,7 +6,7 @@ mutation CreateTask($data: CreateTaskInput!) { done dueDate updateDate - assignee { + assignees { id name avatarUrl @@ -35,7 +35,7 @@ mutation UpdateTask($id: ID!, $data: UpdateTaskInput!) { id name } - assignee { + assignees { id name avatarUrl @@ -77,10 +77,10 @@ mutation UpdateTask($id: ID!, $data: UpdateTaskInput!) { } } -mutation AssignTask($id: ID!, $userId: ID!) { - assignTask(id: $id, userId: $userId) { +mutation AddTaskAssignee($id: ID!, $userId: ID!) { + addTaskAssignee(id: $id, userId: $userId) { id - assignee { + assignees { id name avatarUrl @@ -90,10 +90,10 @@ mutation AssignTask($id: ID!, $userId: ID!) { } } -mutation UnassignTask($id: ID!) { - unassignTask(id: $id) { +mutation RemoveTaskAssignee($id: ID!, $userId: ID!) { + removeTaskAssignee(id: $id, userId: $userId) { id - assignee { + assignees { id name avatarUrl @@ -144,3 +144,26 @@ mutation UnassignTaskFromTeam($id: ID!) { } } } + +mutation ApplyTaskGraph($data: ApplyTaskGraphInput!) { + applyTaskGraph(data: $data) { + id + title + description + done + dueDate + updateDate + sourceTaskPresetId + assignees { + id + name + avatarUrl + lastOnline + isOnline + } + patient { + id + name + } + } +} diff --git a/web/api/graphql/TaskPresetMutations.graphql b/web/api/graphql/TaskPresetMutations.graphql new file mode 100644 index 00000000..5dde5848 --- /dev/null +++ b/web/api/graphql/TaskPresetMutations.graphql @@ -0,0 +1,49 @@ +mutation CreateTaskPreset($data: CreateTaskPresetInput!) { + createTaskPreset(data: $data) { + id + name + key + scope + ownerUserId + graph { + nodes { + id + title + description + priority + estimatedTime + } + edges { + fromId + toId + } + } + } +} + +mutation UpdateTaskPreset($id: ID!, $data: UpdateTaskPresetInput!) { + updateTaskPreset(id: $id, data: $data) { + id + name + key + scope + ownerUserId + graph { + nodes { + id + title + description + priority + estimatedTime + } + edges { + fromId + toId + } + } + } +} + +mutation DeleteTaskPreset($id: ID!) { + deleteTaskPreset(id: $id) +} diff --git a/web/api/graphql/TaskPresetQueries.graphql b/web/api/graphql/TaskPresetQueries.graphql new file mode 100644 index 00000000..6deadd50 --- /dev/null +++ b/web/api/graphql/TaskPresetQueries.graphql @@ -0,0 +1,45 @@ +query TaskPresets { + taskPresets { + id + name + key + scope + ownerUserId + graph { + nodes { + id + title + description + priority + estimatedTime + } + edges { + fromId + toId + } + } + } +} + +query TaskPreset($id: ID!) { + taskPreset(id: $id) { + id + name + key + scope + ownerUserId + graph { + nodes { + id + title + description + priority + estimatedTime + } + edges { + fromId + toId + } + } + } +} diff --git a/web/api/mutations/tasks/assignTask.plan.ts b/web/api/mutations/tasks/assignTask.plan.ts index b027984a..aba7986e 100644 --- a/web/api/mutations/tasks/assignTask.plan.ts +++ b/web/api/mutations/tasks/assignTask.plan.ts @@ -28,7 +28,13 @@ export const assignTaskOptimisticPlan: OptimisticPlan = { cache.modify({ id, fields: { - assignee: () => ({ __ref: `UserType:${userId}` }), + assignees: (existing: ReadonlyArray<{ __ref: string }> | undefined = []) => { + const ref = `UserType:${userId}` + if (existing.some((entry) => entry.__ref === ref)) { + return existing + } + return [...existing, { __ref: ref }] + }, assigneeTeam: () => null, }, }) diff --git a/web/api/mutations/tasks/assignTaskToTeam.plan.ts b/web/api/mutations/tasks/assignTaskToTeam.plan.ts index 877eb796..5c5d9020 100644 --- a/web/api/mutations/tasks/assignTaskToTeam.plan.ts +++ b/web/api/mutations/tasks/assignTaskToTeam.plan.ts @@ -28,7 +28,7 @@ export const assignTaskToTeamOptimisticPlan: OptimisticPlan null, + assignees: () => [], assigneeTeam: (_existing, { toReference }) => toReference({ __typename: 'LocationNodeType', id: teamId }, true), }, diff --git a/web/api/mutations/tasks/updateTask.plan.ts b/web/api/mutations/tasks/updateTask.plan.ts index 43c687fd..7160d964 100644 --- a/web/api/mutations/tasks/updateTask.plan.ts +++ b/web/api/mutations/tasks/updateTask.plan.ts @@ -2,6 +2,7 @@ import type { ApolloCache } from '@apollo/client/cache' import type { Reference } from '@apollo/client/utilities' import { GetTaskDocument, + LocationType, type GetTaskQuery, type UpdateTaskInput } from '@/api/gql/generated' @@ -15,6 +16,44 @@ type UpdateTaskVariables = { clientMutationId?: string, } +type TaskEntity = NonNullable + +function optimisticAssignees( + assigneeIds: string[] | null | undefined, + previous: TaskEntity['assignees'] | undefined +): TaskEntity['assignees'] | undefined { + if (assigneeIds === undefined) return undefined + const prev = previous ?? [] + const ids = assigneeIds ?? [] + return ids.map((userId) => { + const found = prev.find((u) => u.id === userId) + if (found) return found + return { + __typename: 'UserType' as const, + id: userId, + name: '', + avatarUrl: null, + lastOnline: null, + isOnline: false, + } + }) +} + +function optimisticAssigneeTeam( + assigneeTeamId: string | null | undefined, + previous: TaskEntity['assigneeTeam'] | undefined +): TaskEntity['assigneeTeam'] | null | undefined { + if (assigneeTeamId === undefined) return undefined + if (assigneeTeamId === null) return null + if (previous?.id === assigneeTeamId) return previous + return { + __typename: 'LocationNodeType' as const, + id: assigneeTeamId, + title: '', + kind: LocationType.Team, + } +} + export const updateTaskOptimisticPlanKey = 'UpdateTask' export const updateTaskOptimisticPlan: OptimisticPlan = { @@ -35,7 +74,8 @@ export const updateTaskOptimisticPlan: OptimisticPlan = { snapshotRef.current = existing ?? null const id = cache.identify({ __typename: 'TaskType', id: taskId }) - const existingProps = existing?.task?.properties ?? [] + const existingTask = existing?.task + const existingProps = existingTask?.properties ?? [] const mergeProperties = (_prev: Reference | readonly unknown[]) => { if (!data.properties) return existingProps return data.properties.map((inp) => { @@ -87,6 +127,17 @@ export const updateTaskOptimisticPlan: OptimisticPlan = { estimatedTime: (prev: number | null) => data.estimatedTime !== undefined ? data.estimatedTime : prev, properties: mergeProperties, + assignees: (prev) => { + const next = optimisticAssignees(data.assigneeIds, existingTask?.assignees) + return next !== undefined ? next : prev + }, + assigneeTeam: (prev) => { + const next = optimisticAssigneeTeam( + data.assigneeTeamId, + existingTask?.assigneeTeam + ) + return next !== undefined ? next : prev + }, }, }) cache.modify({ diff --git a/web/codegen.ts b/web/codegen.ts index 92774cfb..625fed4f 100644 --- a/web/codegen.ts +++ b/web/codegen.ts @@ -1,9 +1,19 @@ import type { CodegenConfig } from '@graphql-codegen/cli' +import fs from 'fs' +import path from 'path' import 'dotenv/config' import { getConfig } from './utils/config' +const schemaFromBackend = path.join(__dirname, '../backend/schema.graphql') +const schemaFromWeb = path.join(__dirname, 'schema.graphql') +const schema = fs.existsSync(schemaFromBackend) + ? schemaFromBackend + : fs.existsSync(schemaFromWeb) + ? schemaFromWeb + : getConfig().graphqlEndpoint + const config: CodegenConfig = { - schema: getConfig().graphqlEndpoint, + schema, documents: 'api/graphql/**/*.graphql', generates: { 'api/gql/generated.ts': { diff --git a/web/components/Date/DateDisplay.tsx b/web/components/Date/DateDisplay.tsx index fc56e448..7c2a64dd 100644 --- a/web/components/Date/DateDisplay.tsx +++ b/web/components/Date/DateDisplay.tsx @@ -1,5 +1,11 @@ -import { Tooltip, useUpdatingDateString } from '@helpwave/hightide' +import { Tooltip, useLocale } from '@helpwave/hightide' import clsx from 'clsx' +import { useMemo } from 'react' +import { + formatAbsoluteHightide, + formatRelativeHightide, + type DateTimeFormat +} from '@/utils/hightideDateFormat' type DateDisplayProps = { date: Date, @@ -8,11 +14,22 @@ type DateDisplayProps = { mode?: 'relative' | 'absolute', } +function toAbsoluteFormat(showTime: boolean): DateTimeFormat { + return showTime ? 'dateTime' : 'date' +} + export const DateDisplay = ({ date, className, showTime = true, mode = 'relative' }: DateDisplayProps) => { - const { absolute, relative } = useUpdatingDateString({ - date: date ?? new Date(), - absoluteFormat: showTime ? 'dateTime' : 'date', - }) + const { locale } = useLocale() + const absoluteFormat = toAbsoluteFormat(showTime) + const absolute = useMemo( + () => (date ? formatAbsoluteHightide(date, locale, absoluteFormat) : ''), + [date, locale, absoluteFormat] + ) + const relative = useMemo( + () => (date ? formatRelativeHightide(date, locale) : ''), + [date, locale] + ) + if (!date) return null const displayString = mode === 'relative' ? relative : absolute diff --git a/web/components/Notifications.tsx b/web/components/Notifications.tsx index bafcde54..cdb1f29f 100644 --- a/web/components/Notifications.tsx +++ b/web/components/Notifications.tsx @@ -78,7 +78,7 @@ export const Notifications = () => { const recentTasks = data?.recentTasks?.slice(0, 5) || [] recentTasks.forEach((task) => { - if (task.assignee?.id === user?.id) { + if (task.assignees.some((assignee) => assignee.id === user?.id)) { return } const id = `task-${task.id}` diff --git a/web/components/common/ExpandableTextBlock.tsx b/web/components/common/ExpandableTextBlock.tsx new file mode 100644 index 00000000..0bcf3d2a --- /dev/null +++ b/web/components/common/ExpandableTextBlock.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import clsx from 'clsx' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' + +type ExpandableTextBlockProps = { + children: ReactNode, + collapsedLines?: number, + className?: string, +} + +export const ExpandableTextBlock = ({ children, collapsedLines = 4, className }: ExpandableTextBlockProps) => { + const translation = useTasksTranslation() + const contentRef = useRef(null) + const [expanded, setExpanded] = useState(false) + const [hasOverflow, setHasOverflow] = useState(false) + + useEffect(() => { + const element = contentRef.current + if (!element || expanded) { + return + } + + const updateOverflow = () => { + setHasOverflow(element.scrollHeight > element.clientHeight + 1) + } + + updateOverflow() + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(updateOverflow) + observer.observe(element) + return () => { + observer.disconnect() + } + } + + window.addEventListener('resize', updateOverflow) + return () => { + window.removeEventListener('resize', updateOverflow) + } + }, [expanded, children, collapsedLines]) + + const collapsedStyle: CSSProperties | undefined = expanded + ? undefined + : { + display: '-webkit-box', + WebkitLineClamp: collapsedLines, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + } + + return ( +
+
+ {children} +
+ {(hasOverflow || expanded) && ( + + )} +
+ ) +} diff --git a/web/components/layout/Page.tsx b/web/components/layout/Page.tsx index 0f17f84f..c0c7d1ab 100644 --- a/web/components/layout/Page.tsx +++ b/web/components/layout/Page.tsx @@ -1,7 +1,7 @@ 'use client' import type { AnchorHTMLAttributes, ComponentProps, HTMLAttributes, PropsWithChildren } from 'react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import Head from 'next/head' import titleWrapper from '@/utils/titleWrapper' import Link from 'next/link' @@ -31,12 +31,15 @@ import { Users, Menu as MenuIcon, X, - MessageSquare + MessageSquare, + Rabbit } from 'lucide-react' import { TasksLogo } from '@/components/TasksLogo' +import { usePathname } from 'next/navigation' import { useRouter } from 'next/router' import { useTasksContext } from '@/hooks/useTasksContext' -import { useLocations } from '@/data' +import { useLocations, useMySavedViews } from '@/data' +import type { MySavedViewsQuery } from '@/api/gql/generated' import { hashString } from '@/utils/hash' import { useSwipeGesture } from '@/hooks/useSwipeGesture' import { LocationSelectionDialog } from '@/components/locations/LocationSelectionDialog' @@ -414,7 +417,7 @@ export const Header = ({ onMenuClick, isMenuOpen, ...props }: HeaderProps) => { color="neutral" coloringStyle="text" onClick={onMenuClick} - className="lg:hidden" + className="min-h-11 min-w-11 lg:hidden" > {isMenuOpen ? : } @@ -495,13 +498,22 @@ export const Sidebar = ({ isOpen, onClose, ...props }: SidebarProps) => { const translation = useTasksTranslation() const locationRoute = '/location' const context = useTasksContext() + const { data: savedViewsData, loading: savedViewsLoading } = useMySavedViews() + const savedViews = (savedViewsData?.mySavedViews ?? []) as MySavedViewsQuery['mySavedViews'] + const pathname = usePathname() ?? '' + const quickAccessNavRef = useRef(null) + + useEffect(() => { + quickAccessNavRef.current?.scrollIntoView({ block: 'nearest' }) + }, [pathname]) return ( <> {isOpen && (
)}
-
+
+
+ setLoadPresetOpen(false)} + patientId={patientId} + onSuccess={onSuccess} + /> { @@ -199,9 +188,9 @@ export const PatientTasksView = ({ { + initialPatientName={isCreatingTask ? initialPatientName : undefined} + onListSync={() => { onSuccess?.() - setIsCreatingTask(false) }} onClose={() => { setTaskId(null) diff --git a/web/components/patients/SystemSuggestionModal.tsx b/web/components/patients/SystemSuggestionModal.tsx index 76617ada..205c44da 100644 --- a/web/components/patients/SystemSuggestionModal.tsx +++ b/web/components/patients/SystemSuggestionModal.tsx @@ -7,18 +7,23 @@ import { FocusTrapWrapper, TabList, TabPanel, - TabSwitcher, + 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' +import { useApplyTaskGraph } from '@/data' +import { GetPatientDocument } from '@/api/gql/generated' +import { suggestionItemsToTaskGraphInput } from '@/utils/taskGraph' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' type SystemSuggestionModalProps = { - isOpen: boolean - onClose: () => void - suggestion: SystemSuggestion - patientName?: string + isOpen: boolean, + onClose: () => void, + suggestion: SystemSuggestion, + patientName?: string, + onApplied?: () => void, } const ADHERENCE_LABEL: Record = { @@ -29,12 +34,12 @@ const ADHERENCE_LABEL: Record = { function adherenceToChipColor(status: GuidelineAdherenceStatus): 'positive' | 'negative' | 'warning' { switch (status) { - case 'adherent': - return 'positive' - case 'non_adherent': - return 'negative' - default: - return 'warning' + case 'adherent': + return 'positive' + case 'non_adherent': + return 'negative' + default: + return 'warning' } } @@ -43,6 +48,7 @@ export function SystemSuggestionModal({ onClose, suggestion, patientName, + onApplied, }: SystemSuggestionModalProps) { const [selectedIds, setSelectedIds] = useState>(() => new Set(suggestion.suggestedTasks.map((t) => t.id))) const [activeTabId, setActiveTabId] = useState(undefined) @@ -54,7 +60,9 @@ export function SystemSuggestionModal({ } }, [isOpen, suggestion.suggestedTasks]) - const { addCreatedTasks, showToast } = useSystemSuggestionTasks() + const { showToast } = useSystemSuggestionTasks() + const translation = useTasksTranslation() + const [applyTaskGraph, { loading: applyLoading }] = useApplyTaskGraph() const toggleTask = useCallback((id: string) => { setSelectedIds((prev) => { @@ -70,17 +78,61 @@ export function SystemSuggestionModal({ [suggestion.suggestedTasks, selectedIds] ) - const handleCreate = useCallback(() => { - addCreatedTasks(suggestion.patientId, selectedItems, false) - showToast('Tasks created') + const handleCreate = useCallback(async () => { + if (selectedItems.length === 0) return + const graph = suggestionItemsToTaskGraphInput(selectedItems) + await applyTaskGraph({ + variables: { + data: { + patientId: suggestion.patientId, + graph, + assignToCurrentUser: false, + }, + }, + refetchQueries: [ + { query: GetPatientDocument, variables: { id: suggestion.patientId } }, + ], + }) + showToast(translation('tasksCreatedFromPreset')) + onApplied?.() onClose() - }, [suggestion.patientId, selectedItems, addCreatedTasks, showToast, onClose]) + }, [ + applyTaskGraph, + selectedItems, + suggestion.patientId, + showToast, + translation, + onApplied, + onClose, + ]) - const handleCreateAndAssign = useCallback(() => { - addCreatedTasks(suggestion.patientId, selectedItems, true) - showToast('Tasks created and assigned') + const handleCreateAndAssign = useCallback(async () => { + if (selectedItems.length === 0) return + const graph = suggestionItemsToTaskGraphInput(selectedItems) + await applyTaskGraph({ + variables: { + data: { + patientId: suggestion.patientId, + graph, + assignToCurrentUser: true, + }, + }, + refetchQueries: [ + { query: GetPatientDocument, variables: { id: suggestion.patientId } }, + ], + }) + showToast(translation('tasksCreatedFromPreset')) + onApplied?.() onClose() - }, [suggestion.patientId, selectedItems, addCreatedTasks, showToast, onClose]) + }, [ + applyTaskGraph, + selectedItems, + suggestion.patientId, + showToast, + translation, + onApplied, + onClose, + ]) return ( diff --git a/web/components/properties/PropertyCell.tsx b/web/components/properties/PropertyCell.tsx index 2cab4cb6..170f7f1d 100644 --- a/web/components/properties/PropertyCell.tsx +++ b/web/components/properties/PropertyCell.tsx @@ -44,7 +44,7 @@ export const PropertyCell = ({ return } return ( - + ) } case FieldType.FieldTypeDateTime: { @@ -58,7 +58,7 @@ export const PropertyCell = ({ return } return ( - + ) } case FieldType.FieldTypeSelect: { diff --git a/web/components/properties/PropertyDetailView.tsx b/web/components/properties/PropertyDetailView.tsx index 54b34172..0dd9828d 100644 --- a/web/components/properties/PropertyDetailView.tsx +++ b/web/components/properties/PropertyDetailView.tsx @@ -200,10 +200,10 @@ export const PropertyDetailView = ({ return ( -
+
{ event.preventDefault(); form.submit() }} - className="flex-col-6 flex-1 overflow-y-auto" + className="flex flex-col flex-1 gap-6 overflow-y-auto sm:gap-6" > name="name" @@ -249,9 +249,7 @@ export const PropertyDetailView = ({ }} > {propertySubjectTypeList.map(v => ( - - {translation('sPropertySubjectType', { subject: v })} - + ))} )} @@ -276,9 +274,7 @@ export const PropertyDetailView = ({ }} > {propertyFieldTypeList.map(v => ( - - {translation('sPropertyType', { type: v })} - + ))} )} @@ -293,7 +289,7 @@ export const PropertyDetailView = ({ {translation('selectOptions')} -
+
formKey="selectData"> {({ value: selectData }) => { return selectData?.options.map((option) => ( @@ -449,8 +445,8 @@ export const PropertyDetailView = ({ label={translation('archiveProperty')} > {({ dataProps: { value, onValueChange }, focusableElementProps, interactionStates }) => ( -
-
+
+
{translation('archiveProperty')} @@ -476,12 +472,12 @@ export const PropertyDetailView = ({ {!isEditMode && ( -
+
diff --git a/web/components/properties/PropertyEntry.tsx b/web/components/properties/PropertyEntry.tsx index 5c2f6052..f64546f0 100644 --- a/web/components/properties/PropertyEntry.tsx +++ b/web/components/properties/PropertyEntry.tsx @@ -1,6 +1,7 @@ import { CheckboxProperty, DateProperty, + MultiSelectOption, MultiSelectProperty, NumberProperty, PropertyBase, @@ -108,11 +109,8 @@ export const PropertyEntry = ({ onEditComplete={singleSelectValue => onEditComplete({ ...value, singleSelectValue })} > {selectData?.options.map(option => ( - - {option.name} - - )) - } + + ))} ) case 'multiSelect': @@ -124,11 +122,8 @@ export const PropertyEntry = ({ onEditComplete={multiSelectValue => onEditComplete({ ...value, multiSelectValue })} > {selectData?.options.map(option => ( - - {option.name} - - )) - } + + ))} ) case 'user': @@ -152,7 +147,6 @@ export const PropertyEntry = ({ onDialogClose={userValue => { onEditComplete({ ...value, userValue: userValue || undefined }) }} - onValueClear={onValueClear} allowTeams={true} /> )} diff --git a/web/components/tables/AssigneeFilterActiveLabel.tsx b/web/components/tables/AssigneeFilterActiveLabel.tsx new file mode 100644 index 00000000..df477758 --- /dev/null +++ b/web/components/tables/AssigneeFilterActiveLabel.tsx @@ -0,0 +1,85 @@ +import type { FilterValue } from '@helpwave/hightide' +import { Chip } from '@helpwave/hightide' +import { User } from 'lucide-react' +import { useMemo } from 'react' +import { useUsers } from '@/data' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { FilterPreviewAvatar } from '@/components/tables/FilterPreviewMedia' + +export function AssigneeFilterActiveLabel({ value }: { value: FilterValue }) { + const translation = useTasksTranslation() + const { data } = useUsers() + const users = data?.users + + const content = useMemo(() => { + const param = value?.parameter ?? {} + const op = value?.operator ?? 'equals' + if (op === 'contains') { + const ids = (param.uuidValues as string[] | undefined) ?? [] + if (ids.length === 0) { + return {translation('selectAssignee')} + } + if (ids.length <= 2) { + return ( + + {ids.map(id => { + const user = users?.find(u => u.id === id) + const title = user?.name ?? id + return ( + + + {user ? ( + + ) : ( + + )} + {title} + + + ) + })} + + ) + } + return ( + + + + {ids.slice(0, 3).map(id => { + const user = users?.find(u => u.id === id) + return user ? ( + + ) : ( + + ) + })} + + + {ids.length} {translation('users')} + + + + ) + } + const uid = param.uuidValue != null ? String(param.uuidValue) : '' + if (!uid) { + return {translation('selectAssignee')} + } + const user = users?.find(u => u.id === uid) + const title = user?.name ?? uid + return ( + + + {user ? ( + + ) : ( + + )} + {title} + + + ) + }, [users, translation, value]) + + return content +} diff --git a/web/components/tables/FilterPreviewMedia.tsx b/web/components/tables/FilterPreviewMedia.tsx new file mode 100644 index 00000000..4d983f7a --- /dev/null +++ b/web/components/tables/FilterPreviewMedia.tsx @@ -0,0 +1,48 @@ +import type { LocationType } from '@/api/gql/generated' +import { LocationChips } from '@/components/locations/LocationChips' +import { Avatar } from '@helpwave/hightide' +import clsx from 'clsx' + +export type FilterPreviewLocationItem = { + id: string, + title: string, + kind?: LocationType, +} + +export function FilterPreviewAvatar({ + name, + avatarUrl, + className, +}: { + name: string, + avatarUrl?: string | null, + className?: string, +}) { + return ( + + + + ) +} + +export function FilterPreviewLocationChips({ + locations, + className, +}: { + locations: FilterPreviewLocationItem[], + className?: string, +}) { + return ( + + + + ) +} diff --git a/web/components/tables/LocationFilterActiveLabel.tsx b/web/components/tables/LocationFilterActiveLabel.tsx new file mode 100644 index 00000000..418b8d19 --- /dev/null +++ b/web/components/tables/LocationFilterActiveLabel.tsx @@ -0,0 +1,82 @@ +import type { FilterValue } from '@helpwave/hightide' +import { Chip } from '@helpwave/hightide' +import { MapPin } from 'lucide-react' +import { useMemo } from 'react' +import { useLocations } from '@/data' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { FilterPreviewLocationChips } from '@/components/tables/FilterPreviewMedia' +import type { LocationType } from '@/api/gql/generated' + +function nodeToPreviewLocation(node: { id: string, title: string, kind: LocationType }) { + return { id: node.id, title: node.title, kind: node.kind } +} + +export function LocationFilterActiveLabel({ value }: { value: FilterValue }) { + const translation = useTasksTranslation() + const { data } = useLocations() + const nodes = data?.locationNodes + + const content = useMemo(() => { + const param = value?.parameter ?? {} + const op = value?.operator ?? 'equals' + if (op === 'contains') { + const ids = (param.uuidValues as string[] | undefined) ?? [] + if (ids.length === 0) { + return {translation('selectLocation')} + } + if (ids.length <= 2) { + return ( + + {ids.map(id => { + const node = nodes?.find(n => n.id === id) + const title = node?.title ?? id + return node ? ( + + ) : ( + + + + {title} + + + ) + })} + + ) + } + return ( + + + + {ids.length} {translation('location')} + + + ) + } + const uid = param.uuidValue != null ? String(param.uuidValue) : '' + if (!uid) { + return {translation('selectLocation')} + } + const node = nodes?.find(n => n.id === uid) + const title = node?.title ?? uid + return node ? ( + + ) : ( + + + + {title} + + + ) + }, [nodes, translation, value]) + + return content +} diff --git a/web/components/tables/LocationSubtreeFilterPopUp.tsx b/web/components/tables/LocationSubtreeFilterPopUp.tsx new file mode 100644 index 00000000..a9347d3e --- /dev/null +++ b/web/components/tables/LocationSubtreeFilterPopUp.tsx @@ -0,0 +1,187 @@ +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { Button, FilterBasePopUp, type FilterListPopUpBuilderProps } from '@helpwave/hightide' +import { useId, useMemo, useState, type ReactNode } from 'react' +import { MapPin } from 'lucide-react' +import type { LocationNodeType, LocationType } from '@/api/gql/generated' +import { LocationSelectionDialog } from '@/components/locations/LocationSelectionDialog' +import { useLocations } from '@/data' +import { FilterPreviewLocationChips } from '@/components/tables/FilterPreviewMedia' + +function nodeToPreviewLocation(node: { id: string, title: string, kind: LocationType }) { + return { id: node.id, title: node.title, kind: node.kind } +} + +export const LocationSubtreeFilterPopUp = ({ value, onValueChange, onRemove, name }: FilterListPopUpBuilderProps) => { + const translation = useTasksTranslation() + const { data: locationsData } = useLocations() + const id = useId() + const [dialogOpen, setDialogOpen] = useState(false) + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'equals' + return suggestion === 'contains' ? 'contains' : 'equals' + }, [value?.operator]) + + const uuidValue = value?.parameter?.uuidValue + const uuidValues = value?.parameter?.uuidValues + const isMulti = operator === 'contains' + + const initialSelectedIds = useMemo(() => { + if (isMulti) { + const v = uuidValues + return Array.isArray(v) ? v.map(String) : [] + } + const u = uuidValue + return u != null && String(u) !== '' ? [String(u)] : [] + }, [isMulti, uuidValue, uuidValues]) + + const handleLocationsSelected = (locations: LocationNodeType[]) => { + const baseParam = value?.parameter ?? {} + const ids = locations.map(l => l.id) + if (isMulti) { + onValueChange({ + ...value, + dataType: 'singleTag', + operator: 'contains', + parameter: { ...baseParam, uuidValue: undefined, uuidValues: ids }, + }) + } else { + const first = ids[0] + onValueChange({ + ...value, + dataType: 'singleTag', + operator: 'equals', + parameter: { ...baseParam, uuidValue: first, uuidValues: undefined }, + }) + } + setDialogOpen(false) + } + + const summaryContent = useMemo((): ReactNode => { + const locationNodes = locationsData?.locationNodes + if (isMulti) { + const ids = (uuidValues as string[] | undefined) ?? [] + const n = ids.length + if (n === 0) { + return ( + <> + + {translation('selectLocation')} + + ) + } + if (n > 2) { + return ( + <> + + + {n} {translation('location')} + + + ) + } + return ( + + {ids.map(locId => { + const node = locationNodes?.find(x => x.id === locId) + return node ? ( + + ) : ( + + {locId} + + ) + })} + + ) + } + const uid = uuidValue != null && String(uuidValue) !== '' + ? String(uuidValue) + : undefined + if (!uid) { + return ( + <> + + {translation('selectLocation')} + + ) + } + const node = locationNodes?.find(n => n.id === uid) + const label = node?.title ?? translation('selectLocation') + return node ? ( + + ) : ( + <> + + {label} + + ) + }, [isMulti, locationsData?.locationNodes, uuidValue, uuidValues, translation]) + + return ( + <> + { + const baseParam = value?.parameter ?? {} + const next = newOperator === 'contains' ? 'contains' : 'equals' + if (next === 'equals') { + const u = baseParam.uuidValues + const first = Array.isArray(u) && u.length > 0 ? String(u[0]) : undefined + onValueChange({ + dataType: 'singleTag', + parameter: { ...baseParam, uuidValue: first, uuidValues: undefined }, + operator: 'equals', + }) + } else { + const u = baseParam.uuidValue + onValueChange({ + dataType: 'singleTag', + parameter: { + ...baseParam, + uuidValue: undefined, + uuidValues: u != null && String(u) !== '' ? [String(u)] : [], + }, + operator: 'contains', + }) + } + }} + onRemove={onRemove} + allowedOperators={['equals', 'contains']} + noParameterRequired={false} + > +
+ +
+ +
+
+
+ setDialogOpen(false)} + onSelect={handleLocationsSelected} + initialSelectedIds={initialSelectedIds} + multiSelect={isMulti} + useCase="default" + /> + + ) +} diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx index c37acec5..8989ee00 100644 --- a/web/components/tables/PatientList.tsx +++ b/web/components/tables/PatientList.tsx @@ -1,8 +1,12 @@ -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, 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 { 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 clsx from 'clsx' +import { LayoutGrid, PlusIcon, Table2 } from 'lucide-react' +import type { LocationType } from '@/api/gql/generated' +import { Sex, PatientState, type GetPatientsQuery, type TaskType, PropertyEntity, FieldType, type QueryableField } from '@/api/gql/generated' +import { usePropertyDefinitions, usePatientsPaginated, useQueryableFields, useRefreshingEntityIds } from '@/data' import { PatientDetailView } from '@/components/patients/PatientDetailView' import { LocationChips } from '@/components/locations/LocationChips' import { LocationChipsBySetting } from '@/components/patients/LocationChipsBySetting' @@ -10,13 +14,45 @@ import { PatientStateChip } from '@/components/patients/PatientStateChip' import { getLocationNodesByKind, type LocationKindColumn } from '@/utils/location' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { useTasksContext } from '@/hooks/useTasksContext' -import type { ColumnDef, Row, TableState } from '@tanstack/table-core' +import type { ColumnDef, ColumnFiltersState, ColumnOrderState, PaginationState, Row, SortingState, TableState, VisibilityState } from '@tanstack/table-core' 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 { getPropertyColumnIds, useColumnVisibilityWithPropertyDefaults } from '@/hooks/usePropertyColumnVisibility' +import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } from '@/utils/tableStateToApi' +import { LIST_PAGE_SIZE } from '@/utils/listPaging' +import { useAccumulatedPagination } from '@/hooks/useAccumulatedPagination' +import { DateDisplay } from '@/components/Date/DateDisplay' +import { PatientCardView } from '@/components/patients/PatientCardView' +import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems, type QueryableChoiceTagLabelResolver } from '@/utils/queryableFilterList' +import { getPropertyFilterFn as getPropertyDatatype } from '@/utils/propertyFilterMapping' +import { UserSelectFilterPopUp } from './UserSelectFilterPopUp' +import { ExpandableTextBlock } from '@/components/common/ExpandableTextBlock' +import { SaveViewDialog } from '@/components/views/SaveViewDialog' +import { SaveViewActionsMenu } from '@/components/views/SaveViewActionsMenu' +import { + MySavedViewsDocument, + SavedViewDocument, + SavedViewEntityType, + UpdateSavedViewDocument, + type UpdateSavedViewMutation, + type UpdateSavedViewMutationVariables +} from '@/api/gql/generated' +import { getParsedDocument } from '@/data/hooks/queryHelpers' +import { replaceSavedViewInMySavedViewsCache } from '@/utils/savedViewsCache' +import { useDeferredColumnOrderChange } from '@/hooks/useDeferredColumnOrderChange' +import { useStableSerializedList } from '@/hooks/useStableSerializedList' +import { columnIdsFromColumnDefs, sanitizeColumnOrderForKnownColumns } from '@/utils/columnOrder' +import { + hasActiveLocationFilter, + normalizedColumnOrderForViewCompare, + normalizedVisibilityForViewCompare, + serializeColumnFiltersForView, + serializeSortingForView, + stringifyViewParameters, + tableViewStateMatchesBaseline +} from '@/utils/viewDefinition' +import { applyVirtualDerivedPatients } from '@/utils/virtualDerivedTableState' +import type { ViewParameters } from '@/utils/viewDefinition' +import { DUMMY_SUGGESTION } from '@/data/mockSystemSuggestions' import { SystemSuggestionModal } from '@/components/patients/SystemSuggestionModal' import type { SystemSuggestion } from '@/types/systemSuggestion' @@ -44,6 +80,15 @@ const LOCATION_KIND_HEADERS: Record = { const ADMITTED_OR_WAITING_STATES: PatientState[] = [PatientState.Admitted, PatientState.Wait] +const PATIENT_CARD_PRIMARY_COLUMN_IDS = new Set([ + 'name', + 'state', + 'sex', + 'position', + 'birthdate', + 'tasks', +]) + export type PatientListRef = { openCreate: () => void, openPatient: (patientId: string) => void, @@ -55,40 +100,228 @@ type PatientListProps = { acceptedStates?: PatientState[], rootLocationIds?: string[], locationId?: string, + viewDefaultFilters?: ColumnFiltersState, + viewDefaultSorting?: SortingState, + viewDefaultSearchQuery?: string, + viewDefaultColumnVisibility?: VisibilityState, + viewDefaultColumnOrder?: ColumnOrderState, + readOnly?: boolean, + hideSaveView?: boolean, + /** When set (e.g. on `/view/:id`), overwrite updates this saved view. */ + savedViewId?: string, + onSavedViewCreated?: (id: string) => void, + onPatientUpdated?: () => void, + embedded?: boolean, + embeddedPatients?: PatientViewModel[], + embeddedOnRefetch?: () => void, + /** When set with embeddedPatients: client-side filter/sort/search on derived rows; show full toolbar. */ + derivedVirtualMode?: boolean, + /** Persist overwrite targets base view triple or related triple (opposite tab). */ + savedViewScope?: 'base' | 'related', } -export const PatientList = forwardRef(({ initialPatientId, onInitialPatientOpened, acceptedStates: _acceptedStates, rootLocationIds, locationId }, ref) => { +export const PatientList = forwardRef(({ initialPatientId, onInitialPatientOpened, acceptedStates: _acceptedStates, rootLocationIds, locationId, viewDefaultFilters, viewDefaultSorting, viewDefaultSearchQuery, viewDefaultColumnVisibility, viewDefaultColumnOrder, readOnly: _readOnly, hideSaveView, savedViewId, onSavedViewCreated, onPatientUpdated, embedded = false, embeddedPatients, embeddedOnRefetch, derivedVirtualMode = false, savedViewScope = 'base' }, ref) => { const translation = useTasksTranslation() const { locale } = useLocale() const { selectedRootLocationIds } = useTasksContext() const { refreshingPatientIds } = useRefreshingEntityIds() const { data: propertyDefinitionsData } = usePropertyDefinitions() + const { data: queryableFieldsData } = useQueryableFields('Patient') + const queryableFieldsStable = useStableSerializedList( + queryableFieldsData?.queryableFields, + (f) => ({ + key: f.key, + label: f.label, + filterable: f.filterable, + sortable: f.sortable, + sortDirections: f.sortDirections, + propertyDefinitionId: f.propertyDefinitionId, + kind: f.kind, + valueType: f.valueType, + choice: f.choice + ? { keys: f.choice.optionKeys, labels: f.choice.optionLabels } + : null, + }) + ) const effectiveRootLocationIds = rootLocationIds ?? selectedRootLocationIds const [isPanelOpen, setIsPanelOpen] = useState(false) const [selectedPatient, setSelectedPatient] = useState(undefined) - const [searchQuery, setSearchQuery] = useState('') + const [searchQuery, setSearchQuery] = useState(viewDefaultSearchQuery ?? '') const [openedPatientId, setOpenedPatientId] = useState(null) + const [isShowFilters, setIsShowFilters] = useState(false) + const [isShowSorting, setIsShowSorting] = useState(false) + + const [fetchPageIndex, setFetchPageIndex] = useState(0) + const [listLayout, setListLayout] = useState<'table' | 'card'>(() => ( + typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches ? 'card' : 'table' + )) + + useEffect(() => { + if (embedded && !derivedVirtualMode) { + setListLayout('table') + } + }, [embedded, derivedVirtualMode]) + + const showFullToolbar = !embedded || derivedVirtualMode + const useEmbeddedNoop = embedded && !derivedVirtualMode + const [sorting, setSorting] = useState(() => viewDefaultSorting ?? []) + const [filters, setFilters] = useState(() => viewDefaultFilters ?? []) + const [columnVisibility, setColumnVisibilityRaw] = useState(() => viewDefaultColumnVisibility ?? {}) + const [columnOrder, setColumnOrder] = useState(() => viewDefaultColumnOrder ?? []) + + const setColumnVisibility = useColumnVisibilityWithPropertyDefaults( + propertyDefinitionsData, + PropertyEntity.Patient, + setColumnVisibilityRaw + ) + + const baselineFilters = useMemo(() => viewDefaultFilters ?? [], [viewDefaultFilters]) + const baselineSorting = useMemo(() => viewDefaultSorting ?? [], [viewDefaultSorting]) + const baselineSearch = useMemo(() => viewDefaultSearchQuery ?? '', [viewDefaultSearchQuery]) + const baselineColumnVisibility = useMemo(() => viewDefaultColumnVisibility ?? {}, [viewDefaultColumnVisibility]) + const baselineColumnOrder = useMemo(() => viewDefaultColumnOrder ?? [], [viewDefaultColumnOrder]) + + const hasLocationFilter = useMemo( + () => hasActiveLocationFilter(filters), + [filters] + ) + + const propertyColumnIds = useMemo( + () => getPropertyColumnIds(propertyDefinitionsData, PropertyEntity.Patient), + [propertyDefinitionsData] + ) + + const persistedSavedViewContentKey = useMemo( + () => + `${serializeColumnFiltersForView(baselineFilters)}|${serializeSortingForView(baselineSorting)}|${baselineSearch}|${normalizedVisibilityForViewCompare(baselineColumnVisibility)}|${normalizedColumnOrderForViewCompare(baselineColumnOrder)}`, + [baselineFilters, baselineSorting, baselineSearch, baselineColumnVisibility, baselineColumnOrder] + ) + + useEffect(() => { + if (!savedViewId) { + return + } + setFilters(baselineFilters) + setSorting(baselineSorting) + setSearchQuery(baselineSearch) + setColumnVisibility(baselineColumnVisibility) + setColumnOrder(baselineColumnOrder) + setFetchPageIndex(0) + }, [ + savedViewId, + persistedSavedViewContentKey, + baselineFilters, + baselineSorting, + baselineSearch, + baselineColumnVisibility, + baselineColumnOrder, + setColumnVisibility, + ]) + + const [isSaveViewDialogOpen, setIsSaveViewDialogOpen] = useState(false) + const [suggestionModalOpen, setSuggestionModalOpen] = useState(false) const [suggestionModalSuggestion, setSuggestionModalSuggestion] = useState(null) - const [suggestionModalPatientName, setSuggestionModalPatientName] = useState('') + const [suggestionModalPatientName, setSuggestionModalPatientName] = useState('') - const { - pagination, - setPagination, - sorting, - setSorting, - filters, + const closeSuggestionModal = useCallback(() => { + setSuggestionModalOpen(false) + setSuggestionModalSuggestion(null) + setSuggestionModalPatientName('') + }, []) + + const openSuggestionModal = useCallback((suggestion: SystemSuggestion, patientName: string) => { + setSuggestionModalSuggestion(suggestion) + setSuggestionModalPatientName(patientName) + setSuggestionModalOpen(true) + }, []) + + const [updateSavedView, { loading: overwriteLoading }] = useMutation< + UpdateSavedViewMutation, + UpdateSavedViewMutationVariables + >(getParsedDocument(UpdateSavedViewDocument), { + awaitRefetchQueries: true, + refetchQueries: savedViewId + ? [ + { query: getParsedDocument(SavedViewDocument), variables: { id: savedViewId } }, + { query: getParsedDocument(MySavedViewsDocument) }, + ] + : [{ query: getParsedDocument(MySavedViewsDocument) }], + update(cache, { data }) { + const view = data?.updateSavedView + if (view) { + replaceSavedViewInMySavedViewsCache(cache, view) + } + }, + }) + + const handleDiscardViewChanges = useCallback(() => { + setFilters(baselineFilters) + setSorting(baselineSorting) + setSearchQuery(baselineSearch) + setColumnVisibility(baselineColumnVisibility) + setColumnOrder(baselineColumnOrder) + }, [ + baselineFilters, + baselineSorting, + baselineSearch, + baselineColumnVisibility, + baselineColumnOrder, setFilters, - columnVisibility, + setSorting, + setSearchQuery, setColumnVisibility, - } = useStorageSyncedTableState('patient-list') + setColumnOrder, + ]) - usePropertyColumnVisibility( - propertyDefinitionsData, - PropertyEntity.Patient, + const handleOverwriteSavedView = useCallback(async () => { + if (!savedViewId) return + if (savedViewScope === 'related') { + await updateSavedView({ + variables: { + id: savedViewId, + data: { + relatedFilterDefinition: serializeColumnFiltersForView(filters as ColumnFiltersState), + relatedSortDefinition: serializeSortingForView(sorting), + relatedParameters: stringifyViewParameters({ + searchQuery: searchQuery || undefined, + columnVisibility, + columnOrder, + } satisfies ViewParameters), + }, + }, + }) + return + } + await updateSavedView({ + variables: { + id: savedViewId, + data: { + filterDefinition: serializeColumnFiltersForView(filters as ColumnFiltersState), + sortDefinition: serializeSortingForView(sorting), + parameters: stringifyViewParameters({ + rootLocationIds: effectiveRootLocationIds ?? undefined, + locationId: hasLocationFilter ? undefined : (locationId ?? undefined), + searchQuery: searchQuery || undefined, + columnVisibility, + columnOrder, + } satisfies ViewParameters), + }, + }, + }) + }, [ + savedViewId, + savedViewScope, + updateSavedView, + filters, + sorting, + effectiveRootLocationIds, + hasLocationFilter, + locationId, + searchQuery, columnVisibility, - setColumnVisibility - ) + columnOrder, + ]) const allPatientStates: PatientState[] = useMemo(() => [ PatientState.Admitted, @@ -97,71 +330,125 @@ export const PatientList = forwardRef(({ initi PatientState.Wait, ], []) - const apiFiltering = useMemo(() => columnFiltersToFilterInput(filters), [filters]) + const apiFilters = useMemo(() => columnFiltersToQueryFilterClauses(filters), [filters]) const patientStates = useMemo(() => { - const stateFilter = apiFiltering.find( - f => f.column === 'state' && - (f.operator === 'TAGS_SINGLE_EQUALS' || f.operator === 'TAGS_SINGLE_CONTAINS') && - f.parameter?.searchTags != null && - f.parameter.searchTags.length > 0 - ) - if (!stateFilter?.parameter?.searchTags) return allPatientStates + const stateFilter = apiFilters.find(f => f.fieldKey === 'state') + if (!stateFilter?.value) return allPatientStates + const raw = stateFilter.value.stringValues?.length + ? stateFilter.value.stringValues + : stateFilter.value.stringValue + ? [stateFilter.value.stringValue] + : [] + if (raw.length === 0) return allPatientStates const allowed = new Set(allPatientStates as unknown as string[]) - const filtered = (stateFilter.parameter.searchTags as string[]).filter(s => allowed.has(s)) + const filtered = raw.filter(s => allowed.has(s)) return filtered.length > 0 ? (filtered as PatientState[]) : allPatientStates - }, [apiFiltering, allPatientStates]) + }, [apiFilters, allPatientStates]) - const searchInput: FullTextSearchInput | undefined = searchQuery - ? { - searchText: searchQuery, - includeProperties: true, - } - : undefined - const apiSorting = useMemo(() => sortingStateToSortInput(sorting), [sorting]) - const apiPagination = useMemo(() => paginationStateToPaginationInput(pagination), [pagination]) + const searchInput = useMemo( + () => (searchQuery + ? { searchText: searchQuery, includeProperties: true } + : undefined), + [searchQuery] + ) + const apiSorting = useMemo(() => sortingStateToQuerySortClauses(sorting), [sorting]) + const apiPagination = useMemo( + () => ({ pageIndex: fetchPageIndex, pageSize: LIST_PAGE_SIZE }), + [fetchPageIndex] + ) + + const accumulationResetKey = useMemo( + () => JSON.stringify({ + filters: apiFilters, + sorts: apiSorting, + search: searchInput, + locationId: hasLocationFilter ? undefined : (locationId || undefined), + root: effectiveRootLocationIds, + states: patientStates, + }), + [apiFilters, apiSorting, searchInput, hasLocationFilter, locationId, effectiveRootLocationIds, patientStates] + ) const lastTotalCountRef = useRef(undefined) const { data: patientsData, refetch, totalCount, loading: patientsLoading } = usePatientsPaginated( { - locationId: locationId || undefined, - rootLocationIds: !locationId && effectiveRootLocationIds && effectiveRootLocationIds.length > 0 ? effectiveRootLocationIds : undefined, + locationId: hasLocationFilter ? undefined : (locationId || undefined), + rootLocationIds: hasLocationFilter || locationId + ? undefined + : (effectiveRootLocationIds && effectiveRootLocationIds.length > 0 ? effectiveRootLocationIds : undefined), states: patientStates, - search: searchInput, }, { pagination: apiPagination, - sorting: apiSorting.length > 0 ? apiSorting : undefined, - filtering: apiFiltering.length > 0 ? apiFiltering : undefined, + sorts: apiSorting.length > 0 ? apiSorting : undefined, + filters: apiFilters.length > 0 ? apiFilters : undefined, + search: searchInput, + skip: derivedVirtualMode || (embedded && embeddedPatients !== undefined), } ) if (totalCount != null) lastTotalCountRef.current = totalCount const stableTotalCount = totalCount ?? lastTotalCountRef.current + const { accumulated: accumulatedPatientsRaw, loadMore, hasMore } = useAccumulatedPagination({ + resetKey: accumulationResetKey, + pageData: patientsData, + pageIndex: fetchPageIndex, + setPageIndex: setFetchPageIndex, + totalCount: stableTotalCount, + loading: patientsLoading, + }) + + const mapPatientRow = useCallback((p: GetPatientsQuery['patients'][0]): PatientViewModel => { + const countForAggregate = ADMITTED_OR_WAITING_STATES.includes(p.state) + return { + id: p.id, + name: p.name, + firstname: p.firstname, + lastname: p.lastname, + birthdate: new Date(p.birthdate), + sex: p.sex, + state: p.state, + position: p.position, + openTasksCount: countForAggregate ? (p.tasks?.filter(t => !t.done).length ?? 0) : 0, + closedTasksCount: countForAggregate ? (p.tasks?.filter(t => t.done).length ?? 0) : 0, + tasks: [], + properties: p.properties ?? [], + } + }, []) + + const patientsFromSource = useMemo((): PatientViewModel[] => { + if (embedded && embeddedPatients !== undefined) return embeddedPatients + if (!accumulatedPatientsRaw || accumulatedPatientsRaw.length === 0) return [] + return accumulatedPatientsRaw.map(mapPatientRow) + }, [embedded, embeddedPatients, accumulatedPatientsRaw, mapPatientRow]) + const patients: PatientViewModel[] = useMemo(() => { - if (!patientsData || patientsData.length === 0) return [] - - return patientsData.map(p => { - const countForAggregate = ADMITTED_OR_WAITING_STATES.includes(p.state) - return { - id: p.id, - name: p.name, - firstname: p.firstname, - lastname: p.lastname, - birthdate: new Date(p.birthdate), - sex: p.sex, - state: p.state, - position: p.position, - openTasksCount: countForAggregate ? (p.tasks?.filter(t => !t.done).length ?? 0) : 0, - closedTasksCount: countForAggregate ? (p.tasks?.filter(t => t.done).length ?? 0) : 0, - tasks: [], - properties: p.properties ?? [], - } - }) - }, [patientsData]) + if (derivedVirtualMode && embeddedPatients !== undefined) { + return applyVirtualDerivedPatients( + embeddedPatients, + filters as ColumnFiltersState, + sorting, + searchQuery + ) + } + return patientsFromSource + }, [ + derivedVirtualMode, + embeddedPatients, + patientsFromSource, + filters, + sorting, + searchQuery, + ]) - const displayPatients: PatientViewModel[] = useMemo( - () => [...MOCK_PATIENTS, ...patients], - [patients] + const showBlockingLoadingOverlay = patientsLoading && patients.length === 0 && !derivedVirtualMode + + const tablePagination = useMemo( + (): PaginationState => ({ + pageIndex: 0, + pageSize: Math.max(patients.length, 1), + }), + [patients.length] ) useImperativeHandle(ref, () => ({ @@ -170,17 +457,17 @@ export const PatientList = forwardRef(({ initi setIsPanelOpen(true) }, openPatient: (patientId: string) => { - const patient = displayPatients.find(p => p.id === patientId) + const patient = patients.find(p => p.id === patientId) if (patient) { setSelectedPatient(patient) setIsPanelOpen(true) } } - }), [displayPatients]) + }), [patients]) useEffect(() => { if (initialPatientId && openedPatientId !== initialPatientId) { - const patient = displayPatients.find(p => p.id === initialPatientId) + const patient = patients.find(p => p.id === initialPatientId) if (patient) { setSelectedPatient(patient) } @@ -188,7 +475,7 @@ export const PatientList = forwardRef(({ initi setOpenedPatientId(initialPatientId) onInitialPatientOpened?.() } - }, [initialPatientId, displayPatients, openedPatientId, onInitialPatientOpened]) + }, [initialPatientId, patients, openedPatientId, onInitialPatientOpened]) const handleEdit = useCallback((patient: PatientViewModel) => { setSelectedPatient(patient) @@ -202,7 +489,7 @@ export const PatientList = forwardRef(({ initi } const patientPropertyColumns = useMemo[]>( - () => getPropertyColumnsForEntity(propertyDefinitionsData, PropertyEntity.Patient), + () => getPropertyColumnsForEntity(propertyDefinitionsData, PropertyEntity.Patient, false), [propertyDefinitionsData] ) @@ -214,59 +501,15 @@ 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 }) => { - 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)]" - > - - - - )} -
- - ) - }, + cell: ({ row }) => (refreshingPatientIds.has(row.original.id) ? rowLoadingCell : row.original.name), minSize: 200, size: 250, maxSize: 300, - filterFn: 'text', }, { id: 'state', @@ -284,12 +527,6 @@ export const PatientList = forwardRef(({ initi minSize: 120, size: 144, maxSize: 180, - filterFn: 'singleTag', - meta: { - filterData: { - tags: allPatientStates.map(state => ({ label: translation('patientState', { state: state as string }), tag: state })), - } - } }, { id: 'sex', @@ -299,10 +536,10 @@ export const PatientList = forwardRef(({ initi if (refreshingPatientIds.has(row.original.id)) return rowLoadingCell const sex = row.original.sex const colorClass = sex === Sex.Male - ? '!gender-male' + ? 'gender-male' : sex === Sex.Female - ? '!gender-female' - : 'bg-gray-600 text-white' + ? 'gender-female' + : 'gender-neutral' const label = { [Sex.Male]: translation('male'), @@ -314,10 +551,10 @@ export const PatientList = forwardRef(({ initi <> {label} {label} @@ -327,16 +564,6 @@ export const PatientList = forwardRef(({ initi minSize: 160, size: 160, maxSize: 200, - filterFn: 'singleTag', - meta: { - filterData: { - tags: [ - { label: translation('male'), tag: Sex.Male }, - { label: translation('female'), tag: Sex.Female }, - { label: translation('diverse'), tag: Sex.Unknown }, - ], - } - } }, { id: 'position', @@ -354,7 +581,6 @@ export const PatientList = forwardRef(({ initi minSize: 200, size: 260, maxSize: 320, - filterFn: 'text' as const, }, ...(['CLINIC', 'WARD', 'ROOM', 'BED'] as const).map((kind): ColumnDef => ({ id: `location-${kind}`, @@ -377,7 +603,6 @@ export const PatientList = forwardRef(({ initi minSize: 160, size: 220, maxSize: 300, - filterFn: 'text' as const, })), { id: 'birthdate', @@ -406,7 +631,6 @@ export const PatientList = forwardRef(({ initi minSize: 200, size: 200, maxSize: 200, - filterFn: 'date' as const, }, { id: 'tasks', @@ -441,6 +665,33 @@ export const PatientList = forwardRef(({ initi size: 150, maxSize: 200, }, + { + id: 'updateDate', + header: translation('updated'), + accessorFn: (row) => { + const taskList = row.tasks || [] + const updateDates = taskList + .map(t => t.updateDate ? new Date(t.updateDate) : null) + .filter((d): d is Date => d !== null) + .sort((a, b) => b.getTime() - a.getTime()) + return updateDates[0] + }, + cell: ({ row }) => { + if (refreshingPatientIds.has(row.original.id)) return rowLoadingCell + const taskList = row.original.tasks || [] + const updateDates = taskList + .map(t => t.updateDate ? new Date(t.updateDate) : null) + .filter((d): d is Date => d !== null) + .sort((a, b) => b.getTime() - a.getTime()) + const d = updateDates[0] + if (!d) return + return + }, + minSize: 220, + size: 220, + maxSize: 220, + filterFn: 'date', + }, ...patientPropertyColumns.map((col) => ({ ...col, cell: col.cell @@ -448,73 +699,370 @@ 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, openSuggestionModal]) + ], [translation, patientPropertyColumns, refreshingPatientIds, rowLoadingCell, dateFormat]) + + const propertyFieldTypeByDefId = useMemo( + () => new Map(propertyDefinitionsData?.propertyDefinitions.map(d => [d.id, d.fieldType]) ?? []), + [propertyDefinitionsData] + ) + + const renderPatientCardExtras = useCallback((patient: PatientViewModel): ReactNode => { + const rows: ReactNode[] = [] + for (const col of columns) { + const id = col.id as string | undefined + if (!id || PATIENT_CARD_PRIMARY_COLUMN_IDS.has(id)) continue + if (columnVisibility[id] === false) continue + if (!col.cell) continue + const isExpandableTextProperty = id.startsWith('property_') && + propertyFieldTypeByDefId.get(id.replace('property_', '')) === FieldType.FieldTypeText + const headerLabel = typeof col.header === 'string' ? col.header : id + const cell = (col.cell as (p: { row: { original: PatientViewModel } }) => ReactNode)({ row: { original: patient } }) + const propertyId = id.startsWith('property_') ? id.replace('property_', '') : null + const propertyTextValue = propertyId + ? patient.properties?.find(property => property.definition.id === propertyId)?.textValue + : null + rows.push( +
+ {headerLabel} +
+ {isExpandableTextProperty ? ( + {propertyTextValue ?? ''} + ) : cell} +
+
+ ) + } + if (rows.length === 0) return null + return
{rows}
+ }, [columns, columnVisibility, propertyFieldTypeByDefId]) + + const resolvePatientQueryableLabel = useCallback((field: QueryableField): string => { + if (field.propertyDefinitionId) return field.label + const key = field.key === 'locationSubtree' ? 'position' : field.key + const translatedByKey: Partial> = { + 'name': translation('name'), + 'firstname': translation('firstName'), + 'lastname': translation('lastName'), + 'birthdate': translation('birthdate'), + 'sex': translation('sex'), + 'state': translation('status'), + 'position': translation('location'), + 'location-CLINIC': translation('locationClinic'), + 'location-WARD': translation('locationWard'), + 'location-ROOM': translation('locationRoom'), + 'location-BED': translation('locationBed'), + 'tasks': translation('tasks'), + 'updated': translation('updated'), + 'updateDate': translation('updated'), + 'description': translation('description'), + } + return translatedByKey[key] ?? field.label + }, [translation]) + + const resolvePatientChoiceTagLabel = useCallback((field, optionKey, backendLabel) => { + if (field.propertyDefinitionId) return backendLabel + if (field.key === 'state') return translation('patientState', { state: optionKey }) + if (field.key === 'sex') { + if (optionKey === Sex.Male) return translation('male') + if (optionKey === Sex.Female) return translation('female') + return translation('diverse') + } + return backendLabel + }, [translation]) + + const availableFilters: FilterListItem[] = useMemo(() => { + const raw = queryableFieldsStable + if (raw?.length) { + return queryableFieldsToFilterListItems( + raw, + propertyFieldTypeByDefId, + resolvePatientQueryableLabel, + resolvePatientChoiceTagLabel + ) + } + return [ + { + id: 'name', + label: translation('name'), + dataType: 'text', + tags: [], + }, + { + id: 'state', + label: translation('status'), + dataType: 'singleTag', + tags: allPatientStates.map(state => ({ label: translation('patientState', { state: state as string }), tag: state })), + }, + { + id: 'sex', + label: translation('sex'), + dataType: 'singleTag', + tags: [ + { label: translation('male'), tag: Sex.Male }, + { label: translation('female'), tag: Sex.Female }, + { label: translation('diverse'), tag: Sex.Unknown }, + ], + }, + ...(['CLINIC', 'WARD', 'ROOM', 'BED'] as const).map((kind): FilterListItem => ({ + id: `location-${kind}`, + label: translation(LOCATION_KIND_HEADERS[kind] as 'locationClinic' | 'locationWard' | 'locationRoom' | 'locationBed'), + dataType: 'text', + tags: [], + })), + { + id: 'birthdate', + label: translation('birthdate'), + dataType: 'date', + tags: [], + }, + { + id: 'tasks', + label: translation('tasks'), + dataType: 'number', + tags: [], + }, + ...propertyDefinitionsData?.propertyDefinitions.map(def => { + const dataType = getPropertyDatatype(def.fieldType) + return { + id: `property_${def.id}`, + label: def.name, + dataType, + tags: def.options.map((opt, idx) => ({ + label: opt, + tag: `${def.id}-opt-${idx}`, + })), + popUpBuilder: def.fieldType === FieldType.FieldTypeUser ? (props: FilterListPopUpBuilderProps) => () : undefined, + } + }) ?? [], + ] + }, [queryableFieldsStable, propertyFieldTypeByDefId, resolvePatientQueryableLabel, resolvePatientChoiceTagLabel, translation, allPatientStates, propertyDefinitionsData?.propertyDefinitions]) + + const availableSortItems = useMemo(() => { + const raw = queryableFieldsStable + if (raw?.length) { + return queryableFieldsToSortingListItems(raw, resolvePatientQueryableLabel) + } + return availableFilters.map(({ id, label, dataType }) => ({ id, label, dataType })) + }, [queryableFieldsStable, availableFilters, resolvePatientQueryableLabel]) + + const knownColumnIdsOrdered = useMemo( + () => columnIdsFromColumnDefs(columns), + [columns] + ) + + const embeddedDashboardColumnVisibility = useMemo((): VisibilityState | null => { + if (!embedded || derivedVirtualMode) return null + const visible = new Set(['name', 'position', 'updateDate']) + const vis: VisibilityState = {} + for (const id of knownColumnIdsOrdered) { + vis[id] = visible.has(id) + } + return vis + }, [embedded, derivedVirtualMode, knownColumnIdsOrdered]) + + const tableColumnVisibility = embedded && !derivedVirtualMode && embeddedDashboardColumnVisibility != null + ? embeddedDashboardColumnVisibility + : columnVisibility + + const sanitizedColumnOrder = useMemo( + () => sanitizeColumnOrderForKnownColumns(columnOrder, knownColumnIdsOrdered), + [columnOrder, knownColumnIdsOrdered] + ) + + const sanitizedBaselineColumnOrder = useMemo( + () => sanitizeColumnOrderForKnownColumns(baselineColumnOrder, knownColumnIdsOrdered), + [baselineColumnOrder, knownColumnIdsOrdered] + ) + + const viewMatchesBaseline = useMemo( + () => tableViewStateMatchesBaseline({ + filters: filters as ColumnFiltersState, + baselineFilters, + sorting, + baselineSorting, + searchQuery, + baselineSearch, + columnVisibility, + baselineColumnVisibility, + columnOrder: sanitizedColumnOrder, + baselineColumnOrder: sanitizedBaselineColumnOrder, + propertyColumnIds, + }), + [ + filters, + baselineFilters, + sorting, + baselineSorting, + searchQuery, + baselineSearch, + columnVisibility, + baselineColumnVisibility, + sanitizedColumnOrder, + sanitizedBaselineColumnOrder, + propertyColumnIds, + ] + ) + const hasUnsavedViewChanges = !viewMatchesBaseline + + const deferSetColumnOrder = useDeferredColumnOrderChange(setColumnOrder) + + const embeddedTableStateNoop = useCallback(() => {}, []) const onRowClick = useCallback((row: Row) => handleEdit(row.original), [handleEdit]) const fillerRowCell = useCallback(() => (), []) return ( as TableState} - onColumnVisibilityChange={setColumnVisibility} - onPaginationChange={setPagination} - onSortingChange={setSorting} - onColumnFiltersChange={setFilters} + onColumnVisibilityChange={useEmbeddedNoop ? embeddedTableStateNoop : setColumnVisibility} + onColumnOrderChange={useEmbeddedNoop ? embeddedTableStateNoop : deferSetColumnOrder} + onPaginationChange={() => {}} + onSortingChange={useEmbeddedNoop ? embeddedTableStateNoop : setSorting} + onColumnFiltersChange={useEmbeddedNoop ? embeddedTableStateNoop : setFilters} enableMultiSort={true} - pageCount={stableTotalCount != null ? Math.ceil((stableTotalCount + MOCK_PATIENTS.length) / pagination.pageSize) : -1} + enablePinning={false} + pageCount={1} + + manualPagination={true} + manualSorting={true} + manualFiltering={true} + + enableColumnFilters={false} + enableSorting={false} + enableColumnPinning={false} >
-
-
- setSearchQuery(e.target.value)} - onSearch={() => null} - /> - -
-
- { - setSelectedPatient(undefined) - setIsPanelOpen(true) - }} - color="primary" - > - - + {showFullToolbar && ( +
+
+
+ setSearchQuery(e.target.value)} + onSearch={() => null} + containerProps={{ className: 'w-full max-w-full min-w-0 sm:max-w-80' }} + /> + +
+ + +
+ + setIsSaveViewDialogOpen(true)} + onDiscard={handleDiscardViewChanges} + hideSaveAsNew={savedViewScope === 'related' && derivedVirtualMode} + /> + +
+
+ setListLayout('table')} + color={listLayout === 'table' ? 'primary' : 'neutral'} + > + + + setListLayout('card')} + color={listLayout === 'card' ? 'primary' : 'neutral'} + > + + + {!derivedVirtualMode && ( + { + setSelectedPatient(undefined) + setIsPanelOpen(true) + }} + color="primary" + > + + + )} +
+
+ {isShowFilters && ( + + )} + {isShowSorting && ( + + )}
-
-
- {patientsLoading && ( + )} +
+ {showBlockingLoadingOverlay && (
)} - - {totalCount != null && ( - +
+ +
+ {listLayout === 'card' && ( +
+ {patients.map((patient) => ( + + ))} +
+ )} + {stableTotalCount != null && hasMore && !embedded && !derivedVirtualMode && ( + )}
(({ initi { - setSuggestionModalSuggestion(suggestion) - setSuggestionModalPatientName(patientName) - setSuggestionModalOpen(true) + onSuccess={() => { + embeddedOnRefetch?.() + void refetch() + onPatientUpdated?.() }} + onOpenSystemSuggestion={openSuggestionModal} /> (({ initi onClose={closeSuggestionModal} suggestion={suggestionModalSuggestion ?? DUMMY_SUGGESTION} patientName={suggestionModalPatientName} + onApplied={() => void refetch()} /> + {savedViewScope === 'base' && ( + setIsSaveViewDialogOpen(false)} + baseEntityType={SavedViewEntityType.Patient} + filterDefinition={serializeColumnFiltersForView(filters as ColumnFiltersState)} + sortDefinition={serializeSortingForView(sorting)} + parameters={stringifyViewParameters({ + rootLocationIds: effectiveRootLocationIds ?? undefined, + locationId: hasLocationFilter ? undefined : (locationId ?? undefined), + searchQuery: searchQuery || undefined, + columnVisibility, + columnOrder, + } satisfies ViewParameters)} + presentation={savedViewId ? 'default' : 'fromSystemList'} + onCreated={onSavedViewCreated} + /> + )}
) diff --git a/web/components/tables/RecentPatientsTable.tsx b/web/components/tables/RecentPatientsTable.tsx deleted file mode 100644 index 53140c18..00000000 --- a/web/components/tables/RecentPatientsTable.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useTasksTranslation } from '@/i18n/useTasksTranslation' -import type { ColumnDef, Row, TableState } from '@tanstack/react-table' -import type { GetOverviewDataQuery } from '@/api/gql/generated' -import { useCallback, useMemo } from 'react' -import type { TableProps } from '@helpwave/hightide' -import { FillerCell, TableDisplay, TableProvider, Tooltip } from '@helpwave/hightide' -import { DateDisplay } from '@/components/Date/DateDisplay' -import { LocationChipsBySetting } from '@/components/patients/LocationChipsBySetting' -import { PropertyEntity } from '@/api/gql/generated' -import { usePropertyDefinitions } from '@/data' -import { getPropertyColumnsForEntity } from '@/utils/propertyColumn' -import { useStorageSyncedTableState } from '@/hooks/useTableState' -import { usePropertyColumnVisibility } from '@/hooks/usePropertyColumnVisibility' - -type PatientViewModel = GetOverviewDataQuery['recentPatients'][0] - -export interface RecentPatientsTableProps extends Omit, 'table'> { - patients: PatientViewModel[], - onSelectPatient: (id: string) => void, -} - -export const RecentPatientsTable = ({ - patients, - onSelectPatient, - ...props -}: RecentPatientsTableProps) => { - const translation = useTasksTranslation() - const { data: propertyDefinitionsData } = usePropertyDefinitions() - - const { - pagination, - setPagination, - sorting, - setSorting, - filters, - setFilters, - columnVisibility, - setColumnVisibility, - } = useStorageSyncedTableState('recent-patients') - - usePropertyColumnVisibility( - propertyDefinitionsData, - PropertyEntity.Patient, - columnVisibility, - setColumnVisibility - ) - - const patientPropertyColumns = useMemo[]>( - () => getPropertyColumnsForEntity(propertyDefinitionsData, PropertyEntity.Patient), - [propertyDefinitionsData] - ) - - const patientColumns = useMemo[]>(() => [ - { - id: 'name', - header: translation('name'), - accessorKey: 'name', - cell: ({ row }) => { - return ( - - {row.original.name} - - ) - }, - minSize: 160, - filterFn: 'text', - }, - { - id: 'location', - header: translation('location'), - accessorKey: 'position', - cell: ({ row }: { row: Row }) => ( - - ), - minSize: 200, - size: 260, - maxSize: 320, - filterFn: 'text' as const, - }, - { - id: 'updated', - header: translation('updated'), - accessorFn: (value) => { - const tasks = value.tasks || [] - const updateDates = tasks - .map(t => t.updateDate ? new Date(t.updateDate) : null) - .filter((d): d is Date => d !== null) - .sort((a, b) => b.getTime() - a.getTime()) - return updateDates[0] - }, - cell: ({ getValue }) => { - const date = getValue() as Date | undefined - if (!date) return - return ( - - ) - }, - minSize: 200, - size: 200, - maxSize: 200, - filterFn: 'date', - }, - ...patientPropertyColumns, - ], [translation, patientPropertyColumns]) - - const fillerRowCell = useCallback(() => , []) - const onRowClick = useCallback((row: Row) => onSelectPatient(row.original.id), [onSelectPatient]) - - const fixedPagination = useMemo(() => ({ - ...pagination, - pageSize: 5 - }), [pagination]) - - return ( -
- as TableState} - onColumnVisibilityChange={setColumnVisibility} - onPaginationChange={setPagination} - onSortingChange={setSorting} - onColumnFiltersChange={setFilters} - enableMultiSort={true} - > -
-
- {translation('recentPatients')} - {translation('patientsUpdatedRecently')} -
-
- -
-
-
-
- ) -} diff --git a/web/components/tables/RecentTasksTable.tsx b/web/components/tables/RecentTasksTable.tsx deleted file mode 100644 index cab08163..00000000 --- a/web/components/tables/RecentTasksTable.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { useTasksTranslation } from '@/i18n/useTasksTranslation' -import type { ColumnDef, Row, TableState } from '@tanstack/react-table' -import type { GetOverviewDataQuery, TaskPriority } from '@/api/gql/generated' -import { useCallback, useMemo } from 'react' -import clsx from 'clsx' -import type { TableProps } from '@helpwave/hightide' -import { Button, Checkbox, FillerCell, TableDisplay, TableProvider, Tooltip } from '@helpwave/hightide' -import { DateDisplay } from '@/components/Date/DateDisplay' -import { DueDateUtils } from '@/utils/dueDate' -import { PriorityUtils } from '@/utils/priority' -import { PropertyEntity } from '@/api/gql/generated' -import { usePropertyDefinitions } from '@/data' -import { getPropertyColumnsForEntity } from '@/utils/propertyColumn' -import { useStorageSyncedTableState } from '@/hooks/useTableState' -import { usePropertyColumnVisibility } from '@/hooks/usePropertyColumnVisibility' - -type TaskViewModel = GetOverviewDataQuery['recentTasks'][0] - -export interface RecentTasksTableProps extends Omit, 'table'> { - tasks: TaskViewModel[], - completeTask: (id: string) => void, - reopenTask: (id: string) => void, - onSelectPatient: (id: string) => void, - onSelectTask: (id: string) => void, -} - - -export const RecentTasksTable = ({ - tasks, - completeTask, - reopenTask, - onSelectPatient, - onSelectTask, - ...props -}: RecentTasksTableProps) => { - const translation = useTasksTranslation() - const { data: propertyDefinitionsData } = usePropertyDefinitions() - - const { - pagination, - setPagination, - sorting, - setSorting, - filters, - setFilters, - columnVisibility, - setColumnVisibility, - } = useStorageSyncedTableState('recent-tasks') - - usePropertyColumnVisibility( - propertyDefinitionsData, - PropertyEntity.Task, - columnVisibility, - setColumnVisibility - ) - - const taskPropertyColumns = useMemo[]>( - () => getPropertyColumnsForEntity(propertyDefinitionsData, PropertyEntity.Task), - [propertyDefinitionsData] - ) - - const taskColumns = useMemo[]>(() => [ - { - id: 'done', - header: translation('done'), - accessorKey: 'done', - cell: ({ row }) => ( - { - if (checked) { - completeTask(row.original.id) - } else { - reopenTask(row.original.id) - } - }} - onClick={(e) => e.stopPropagation()} - className={clsx('rounded-full', PriorityUtils.toCheckboxColor(row.original.priority as TaskPriority | null | undefined))} - /> - ), - minSize: 60, - size: 60, - maxSize: 60, - enableResizing: false, - enableHiding: false, - }, - { - id: 'title', - header: translation('task'), - accessorKey: 'title', - cell: ({ row }) => { - return ( - -
- {row.original.priority && ( -
- )} - {row.original.title} -
- - ) - }, - minSize: 200, - filterFn: 'text', - }, - { - id: 'patient', - header: translation('patient'), - accessorFn: (value) => value.patient?.name, - cell: ({ row }) => { - const patient = row.original.patient - if (!patient) return - - return ( - - - - ) - }, - minSize: 200, - maxSize: 400, - filterFn: 'text', - }, - { - id: 'dueDate', - header: translation('dueDate'), - accessorKey: 'dueDate', - cell: ({ row }) => { - if (!row.original.dueDate) return - - const overdue = DueDateUtils.isOverdue(row.original.dueDate, row.original.done) - const closeToDue = DueDateUtils.isCloseToDueDate(row.original.dueDate, row.original.done) - let colorClass = '' - if (overdue) { - colorClass = '!text-red-500' - } else if (closeToDue) { - colorClass = '!text-orange-500' - } - return ( - - ) - }, - minSize: 220, - size: 220, - maxSize: 220, - enableResizing: false, - filterFn: 'date', - }, - { - id: 'date', - header: translation('updated'), - accessorFn: (value) => value.updateDate ? new Date(value.updateDate) : undefined, - cell: ({ getValue }) => { - const date = getValue() as Date | undefined - if (!date) return - return ( - - ) - }, - minSize: 220, - size: 220, - maxSize: 220, - enableResizing: false, - filterFn: 'date', - }, - ...taskPropertyColumns, - ], [translation, completeTask, reopenTask, onSelectPatient, taskPropertyColumns]) - - const fillerRowCell = useCallback(() => , []) - const onRowClick = useCallback((row: Row) => onSelectTask(row.original.id), [onSelectTask]) - - const fixedPagination = useMemo(() => ({ - ...pagination, - pageSize: 5 - }), [pagination]) - - return ( -
- as TableState} - onColumnVisibilityChange={setColumnVisibility} - onPaginationChange={setPagination} - onSortingChange={setSorting} - onColumnFiltersChange={setFilters} - enableMultiSort={true} - isUsingFillerRows={true} - > -
-
- {translation('recentTasks')} - {translation('tasksUpdatedRecently')} -
-
- -
-
-
-
- ) -} diff --git a/web/components/tables/TaskList.tsx b/web/components/tables/TaskList.tsx index c346e058..a938275d 100644 --- a/web/components/tables/TaskList.tsx +++ b/web/components/tables/TaskList.tsx @@ -1,12 +1,14 @@ -import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useRef, useCallback } from 'react' +import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useRef, useCallback, memo, type ReactNode } from 'react' import { useQueryClient } from '@tanstack/react-query' -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' -import { useAssignTask, useAssignTaskToTeam, useCompleteTask, useReopenTask, useUsers, useLocations, usePropertyDefinitions, useRefreshingEntityIds } from '@/data' -import { AssigneeSelectDialog } from '@/components/tasks/AssigneeSelectDialog' +import type { FilterListItem } from '@helpwave/hightide' +import { Button, Checkbox, ConfirmDialog, FilterList, FillerCell, HelpwaveLogo, IconButton, SearchBar, TableColumnSwitcher, TableDisplay, TableProvider, SortingList, ExpansionIcon } from '@helpwave/hightide' import clsx from 'clsx' +import { LayoutGrid, PlusIcon, Table2, UserCheck, Users } from 'lucide-react' +import type { IdentifierFilterValue } from '@helpwave/hightide' +import type { TaskPriority, GetTasksQuery, QueryableField } from '@/api/gql/generated' +import { FieldType, PropertyEntity } from '@/api/gql/generated' +import { useAssignTask, useAssignTaskToTeam, useCompleteTask, useReopenTask, useUsers, useLocations, usePropertyDefinitions, useQueryableFields, useRefreshingEntityIds } from '@/data' +import { AssigneeSelectDialog } from '@/components/tasks/AssigneeSelectDialog' import { DateDisplay } from '@/components/Date/DateDisplay' import { Drawer } from '@helpwave/hightide' import { TaskDetailView } from '@/components/tasks/TaskDetailView' @@ -15,13 +17,75 @@ import { PatientDetailView } from '@/components/patients/PatientDetailView' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { useTasksContext } from '@/hooks/useTasksContext' import { UserInfoPopup } from '@/components/UserInfoPopup' -import type { ColumnDef, ColumnFiltersState, PaginationState, SortingState, TableState, VisibilityState } from '@tanstack/table-core' +import type { ColumnDef, ColumnFiltersState, ColumnOrderState, PaginationState, SortingState, TableState, VisibilityState } from '@tanstack/table-core' import type { Dispatch, SetStateAction } from 'react' +import { useDeferredColumnOrderChange } from '@/hooks/useDeferredColumnOrderChange' +import { useStableSerializedList } from '@/hooks/useStableSerializedList' +import { columnIdsFromColumnDefs, sanitizeColumnOrderForKnownColumns } from '@/utils/columnOrder' import { DueDateUtils } from '@/utils/dueDate' import { PriorityUtils } from '@/utils/priority' import { getPropertyColumnsForEntity } from '@/utils/propertyColumn' -import { useStorageSyncedTableState } from '@/hooks/useTableState' -import { usePropertyColumnVisibility } from '@/hooks/usePropertyColumnVisibility' +import { useColumnVisibilityWithPropertyDefaults } from '@/hooks/usePropertyColumnVisibility' +import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems, type QueryableChoiceTagLabelResolver } from '@/utils/queryableFilterList' +import { LIST_PAGE_SIZE } from '@/utils/listPaging' +import { TaskCardView } from '@/components/tasks/TaskCardView' +import { RefreshingTaskIdsContext, TaskRowRefreshingGate } from '@/components/tables/TaskRowRefreshingGate' +import { ExpandableTextBlock } from '@/components/common/ExpandableTextBlock' + +type TaskAssigneeTableCellProps = { + assigneeId: string, + avatarURL: string | null | undefined, + name: string, + isOnline: boolean | null, + onOpenUser: (id: string) => void, + printHiddenNameLine: string, + extraCountLabel: string | null, +} + +const TaskAssigneeTableCell = memo(function TaskAssigneeTableCell({ + assigneeId, + avatarURL, + name, + isOnline, + onOpenUser, + printHiddenNameLine, + extraCountLabel, +}: TaskAssigneeTableCellProps) { + const image = useMemo( + () => ({ + avatarUrl: avatarURL || 'https://cdn.helpwave.de/boringavatar.svg', + alt: name, + }), + [avatarURL, name] + ) + return ( + <> + {printHiddenNameLine} +
+ + {extraCountLabel != null && ( + + {extraCountLabel} + + )} +
+ + ) +}) + +function taskListDataSyncKey(tasks: TaskViewModel[]): string { + return tasks.map(t => `${t.id}:${t.done}:${t.updateDate.getTime()}`).join('\0') +} export type TaskViewModel = { id: string, @@ -42,11 +106,11 @@ export type TaskViewModel = { }, assignee?: { id: string, name: string, avatarURL?: string | null, isOnline?: boolean | null }, assigneeTeam?: { id: string, title: string }, + /** Additional user assignees beyond the first (omit when team assignment). */ + additionalAssigneeCount?: number, done: boolean, + sourceTaskPresetId?: string | null, properties?: GetTasksQuery['tasks'][0]['properties'], - machineGenerated?: boolean, - source?: 'manual' | 'systemSuggestion', - assignedTo?: 'me' | null, } export type TaskListRef = { @@ -60,14 +124,14 @@ type TaskDialogState = { } type TaskListTableState = { - pagination: PaginationState, - setPagination: Dispatch>, sorting: SortingState, setSorting: Dispatch>, filters: ColumnFiltersState, setFilters: Dispatch>, columnVisibility: VisibilityState, setColumnVisibility: Dispatch>, + columnOrder: ColumnOrderState, + setColumnOrder: Dispatch>, } type TaskListProps = { @@ -77,74 +141,67 @@ type TaskListProps = { initialTaskId?: string, onInitialTaskOpened?: () => void, headerActions?: React.ReactNode, + saveViewSlot?: React.ReactNode, totalCount?: number, loading?: boolean, - showAllTasksMode?: boolean, tableState?: TaskListTableState, + searchQuery?: string, + onSearchQueryChange?: (value: string) => void, + loadMore?: () => void, + hasMore?: boolean, + embedded?: boolean, + /** Row order and search already applied in parent (e.g. saved view derived task list). */ + virtualDerivedOrder?: boolean, } -export const TaskList = forwardRef(({ tasks: initialTasks, onRefetch, showAssignee = false, initialTaskId, onInitialTaskOpened, headerActions, totalCount, loading = false, showAllTasksMode = false, tableState: controlledTableState }, ref) => { +export const TaskList = forwardRef(({ tasks: initialTasks, onRefetch, showAssignee = false, initialTaskId, onInitialTaskOpened, headerActions, saveViewSlot, totalCount, loading = false, tableState: controlledTableState, searchQuery: searchQueryProp, onSearchQueryChange, loadMore: loadMoreProp, hasMore: hasMoreProp, embedded = false, virtualDerivedOrder = false }, ref) => { const translation = useTasksTranslation() const { data: propertyDefinitionsData } = usePropertyDefinitions() + const { data: queryableFieldsData } = useQueryableFields('Task') + const queryableFieldsStable = useStableSerializedList( + queryableFieldsData?.queryableFields, + (f) => ({ + key: f.key, + label: f.label, + filterable: f.filterable, + sortable: f.sortable, + sortDirections: f.sortDirections, + propertyDefinitionId: f.propertyDefinitionId, + kind: f.kind, + valueType: f.valueType, + choice: f.choice + ? { keys: f.choice.optionKeys, labels: f.choice.optionLabels } + : null, + }) + ) + + const [clientVisibleCount, setClientVisibleCount] = useState(LIST_PAGE_SIZE) + const [listLayout, setListLayout] = useState<'table' | 'card'>(() => ( + typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches ? 'card' : 'table' + )) + const [internalSorting, setInternalSorting] = useState(() => [ + { id: 'done', desc: false }, + { id: 'dueDate', desc: false }, + ]) + const [internalFilters, setInternalFilters] = useState([]) + const [internalColumnVisibility, setInternalColumnVisibility] = useState({}) + const [internalColumnOrder, setInternalColumnOrder] = useState([]) - const internalState = useStorageSyncedTableState('task-list', { - defaultSorting: useMemo(() => [ - { id: 'done', desc: false }, - { id: 'dueDate', desc: false }, - ], []), - }) - - const lastTotalCountRef = useRef(undefined) - if (totalCount != null) lastTotalCountRef.current = totalCount - const stableTotalCount = totalCount ?? lastTotalCountRef.current - - const pagination = controlledTableState?.pagination ?? internalState.pagination - const setPagination = controlledTableState?.setPagination ?? internalState.setPagination - const sorting = controlledTableState?.sorting ?? internalState.sorting - const setSorting = controlledTableState?.setSorting ?? internalState.setSorting - const filters = controlledTableState?.filters ?? internalState.filters - const setFilters = controlledTableState?.setFilters ?? internalState.setFilters - const columnVisibility = controlledTableState?.columnVisibility ?? internalState.columnVisibility - const setColumnVisibility = controlledTableState?.setColumnVisibility ?? internalState.setColumnVisibility - - usePropertyColumnVisibility( + const sorting = controlledTableState?.sorting ?? internalSorting + const setSorting = controlledTableState?.setSorting ?? setInternalSorting + const filters = controlledTableState?.filters ?? internalFilters + const setFilters = controlledTableState?.setFilters ?? setInternalFilters + const columnVisibility = controlledTableState?.columnVisibility ?? internalColumnVisibility + const setColumnVisibilityRaw = controlledTableState?.setColumnVisibility ?? setInternalColumnVisibility + const columnOrder = controlledTableState?.columnOrder ?? internalColumnOrder + const setColumnOrder = controlledTableState?.setColumnOrder ?? setInternalColumnOrder + + const setColumnVisibilityMerged = useColumnVisibilityWithPropertyDefaults( propertyDefinitionsData, PropertyEntity.Task, - columnVisibility, - setColumnVisibility + setColumnVisibilityRaw ) - const normalizeDoneFilterValue = useCallback((value: unknown): boolean | undefined => { - if (value === true || value === 'true' || value === 'done') return true - if (value === false || value === 'false' || value === 'undone') return false - return undefined - }, []) - const rawDoneFilterValue = filters.find(f => f.id === 'done')?.value - const storedDoneFilterValue = rawDoneFilterValue === true || rawDoneFilterValue === 'true' || rawDoneFilterValue === 'done' - ? 'done' - : rawDoneFilterValue === false || rawDoneFilterValue === 'false' || rawDoneFilterValue === 'undone' - ? 'undone' - : 'all' - const doneFilterValue = showAllTasksMode ? 'all' : storedDoneFilterValue - const setDoneFilter = useCallback((value: boolean | 'all') => { - setFilters(prev => { - const rest = prev.filter(f => f.id !== 'done') - if (value === 'all') return rest - return [...rest, { id: 'done', value }] - }) - }, [setFilters]) - const setFiltersNormalized = useCallback((updater: ColumnFiltersState | ((prev: ColumnFiltersState) => ColumnFiltersState)) => { - setFilters(prev => { - const next = typeof updater === 'function' ? updater(prev) : updater - return next.flatMap(f => { - if (f.id !== 'done') return [f] - const normalized = normalizeDoneFilterValue(f.value) - if (normalized === undefined) return [] - return [{ ...f, value: normalized }] - }) - }) - }, [setFilters, normalizeDoneFilterValue]) - const queryClient = useQueryClient() const { totalPatientsCount, user } = useTasksContext() const { refreshingTaskIds } = useRefreshingEntityIds() @@ -157,12 +214,17 @@ export const TaskList = forwardRef(({ tasks: initial const [selectedPatientId, setSelectedPatientId] = useState(null) const [selectedUserPopupId, setSelectedUserPopupId] = useState(null) const [taskDialogState, setTaskDialogState] = useState({ isOpen: false }) - const [searchQuery, setSearchQuery] = useState('') + const [internalSearchQuery, setInternalSearchQuery] = useState('') + const searchQuery = searchQueryProp !== undefined ? searchQueryProp : internalSearchQuery + const setSearchQuery = onSearchQueryChange ?? setInternalSearchQuery const [openedTaskId, setOpenedTaskId] = useState(null) const [isHandoverDialogOpen, setIsHandoverDialogOpen] = useState(false) const [selectedUserId, setSelectedUserId] = useState(null) const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false) const isOpeningConfirmDialogRef = useRef(false) + const [isShowFilters, setIsShowFilters] = useState(false) + const [isShowSorting, setIsShowSorting] = useState(false) + const [isMobileIOS, setIsMobileIOS] = useState(false) const hasPatients = (totalPatientsCount ?? 0) > 0 @@ -177,15 +239,43 @@ export const TaskList = forwardRef(({ tasks: initial } })) + const initialTaskPresent = Boolean(initialTaskId && initialTasks.some(t => t.id === initialTaskId)) + + const initialTasksSyncKey = useMemo( + () => taskListDataSyncKey(initialTasks), + [initialTasks] + ) + useEffect(() => { - if (initialTaskId && initialTasks.length > 0 && openedTaskId !== initialTaskId) { + if (initialTaskId && initialTaskPresent && openedTaskId !== initialTaskId) { setTaskDialogState({ isOpen: true, taskId: initialTaskId }) setOpenedTaskId(initialTaskId) onInitialTaskOpened?.() } else if (!initialTaskId) { setOpenedTaskId(null) } - }, [initialTaskId, initialTasks, openedTaskId, onInitialTaskOpened]) + }, [initialTaskId, initialTaskPresent, openedTaskId, onInitialTaskOpened]) + + useEffect(() => { + if (embedded) { + setListLayout('table') + } + }, [embedded]) + + useEffect(() => { + if (typeof window === 'undefined') return + const mediaQuery = window.matchMedia('(max-width: 768px)') + const ua = window.navigator.userAgent + const isIOSDevice = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && 'ontouchend' in document) + const update = () => { + setIsMobileIOS(isIOSDevice && mediaQuery.matches) + } + update() + mediaQuery.addEventListener('change', update) + return () => { + mediaQuery.removeEventListener('change', update) + } + }, []) useEffect(() => { setOptimisticUpdates(prev => { @@ -202,7 +292,9 @@ export const TaskList = forwardRef(({ tasks: initial return hasChanges ? next : prev }) - }, [initialTasks]) + }, [initialTasksSyncKey, initialTasks]) + + const isServerDriven = totalCount != null const tasks = useMemo(() => { let data = initialTasks.map(task => { @@ -213,26 +305,59 @@ export const TaskList = forwardRef(({ tasks: initial return task }) - if (searchQuery) { + if (virtualDerivedOrder) { + return data + } + + if (!isServerDriven && searchQuery) { const lowerQuery = searchQuery.toLowerCase() data = data.filter(t => t.name.toLowerCase().includes(lowerQuery) || - t.patient?.name.toLowerCase().includes(lowerQuery)) + (t.patient?.name.toLowerCase().includes(lowerQuery) ?? false)) } - return [...data].sort((a, b) => { - if (a.done !== b.done) { - return a.done ? 1 : -1 - } + if (!isServerDriven) { + return [...data].sort((a, b) => { + if (a.done !== b.done) { + return a.done ? 1 : -1 + } - if (!a.dueDate && !b.dueDate) return 0 - if (!a.dueDate) return 1 - if (!b.dueDate) return -1 + if (!a.dueDate && !b.dueDate) return 0 + if (!a.dueDate) return 1 + if (!b.dueDate) return -1 - return a.dueDate.getTime() - b.dueDate.getTime() - }) - }, [initialTasks, optimisticUpdates, searchQuery]) + return a.dueDate.getTime() - b.dueDate.getTime() + }) + } + return data + }, [initialTasks, optimisticUpdates, searchQuery, isServerDriven, virtualDerivedOrder]) + + useEffect(() => { + if (isServerDriven) return + setClientVisibleCount(LIST_PAGE_SIZE) + }, [initialTasksSyncKey, searchQuery, isServerDriven, virtualDerivedOrder]) + + const displayedTasks = useMemo(() => { + if (isServerDriven) return tasks + return tasks.slice(0, clientVisibleCount) + }, [isServerDriven, tasks, clientVisibleCount]) + const tablePagination = useMemo( + (): PaginationState => ({ + pageIndex: 0, + pageSize: Math.max(displayedTasks.length, 1), + }), + [displayedTasks.length] + ) + + const effectiveHasMore = isServerDriven ? (hasMoreProp ?? false) : tasks.length > clientVisibleCount + + const handleLoadMore = useCallback(() => { + if (isServerDriven) loadMoreProp?.() + else setClientVisibleCount(c => c + LIST_PAGE_SIZE) + }, [isServerDriven, loadMoreProp]) + + const showBlockingLoadingOverlay = loading && displayedTasks.length === 0 const openTasks = useMemo(() => { const tasksWithOptimistic = initialTasks.map(task => { @@ -333,8 +458,80 @@ export const TaskList = forwardRef(({ tasks: initial [propertyDefinitionsData] ) + const propertyFieldTypeByDefId = useMemo( + () => new Map(propertyDefinitionsData?.propertyDefinitions.map(d => [d.id, d.fieldType]) ?? []), + [propertyDefinitionsData] + ) - const rowLoadingCell = useMemo(() => , []) + const resolveTaskQueryableLabel = useCallback((field: QueryableField): string => { + if (field.propertyDefinitionId) return field.label + const translatedByKey: Partial> = { + done: translation('done'), + title: translation('title'), + name: translation('title'), + description: translation('description'), + dueDate: translation('dueDate'), + priority: translation('priorityLabel'), + patient: translation('patient'), + assignee: translation('assignedTo'), + assigneeTeam: translation('assigneeTeam'), + updated: translation('updated'), + updateDate: translation('updated'), + position: translation('location'), + state: translation('status'), + firstname: translation('firstName'), + lastname: translation('lastName'), + birthdate: translation('birthdate'), + estimatedTime: translation('estimatedTime'), + creationDate: translation('creationDate'), + } + return translatedByKey[field.key] ?? field.label + }, [translation]) + + const resolveTaskChoiceTagLabel = useCallback((field, optionKey, backendLabel) => { + if (field.propertyDefinitionId) return backendLabel + if (field.key === 'priority') return translation('priority', { priority: optionKey }) + return backendLabel + }, [translation]) + + const availableFilters: FilterListItem[] = useMemo(() => { + const raw = queryableFieldsStable + if (raw?.length) { + return queryableFieldsToFilterListItems( + raw, + propertyFieldTypeByDefId, + resolveTaskQueryableLabel, + resolveTaskChoiceTagLabel + ) + } + return [ + { id: 'title', label: translation('title'), dataType: 'text', tags: [] }, + { id: 'description', label: translation('description'), dataType: 'text', tags: [] }, + { id: 'dueDate', label: translation('dueDate'), dataType: 'date', tags: [] }, + { + id: 'priority', + label: translation('priorityLabel'), + dataType: 'singleTag', + tags: ['P1', 'P2', 'P3', 'P4'].map(p => ({ label: translation('priority', { priority: p }), tag: p })), + }, + { id: 'patient', label: translation('patient'), dataType: 'text', tags: [] }, + { id: 'assignee', label: translation('assignedTo'), dataType: 'text', tags: [] }, + ...propertyDefinitionsData?.propertyDefinitions.map(def => ({ + id: `property_${def.id}`, + label: def.name, + dataType: 'text' as const, + tags: def.options.map((opt, idx) => ({ label: opt, tag: `${def.id}-opt-${idx}` })), + })) ?? [], + ] + }, [queryableFieldsStable, propertyFieldTypeByDefId, resolveTaskQueryableLabel, resolveTaskChoiceTagLabel, translation, propertyDefinitionsData?.propertyDefinitions]) + + const availableSortItems = useMemo(() => { + const raw = queryableFieldsStable + if (raw?.length) { + return queryableFieldsToSortingListItems(raw, resolveTaskQueryableLabel) + } + return availableFilters.map(({ id, label, dataType }) => ({ id, label, dataType })) + }, [queryableFieldsStable, availableFilters, resolveTaskQueryableLabel]) const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ @@ -342,43 +539,39 @@ export const TaskList = forwardRef(({ tasks: initial id: 'done', header: () => null, accessorKey: 'done', - enableColumnFilter: true, - filterFn: (row, _columnId, filterValue: boolean | string | undefined) => { - if (filterValue === undefined || filterValue === 'all') return true - const wantDone = filterValue === true || filterValue === 'done' || filterValue === 'true' - const wantUndone = filterValue === false || filterValue === 'undone' || filterValue === 'false' - if (!wantDone && !wantUndone) return true - return wantDone ? row.getValue('done') === true : row.getValue('done') === false - }, cell: ({ row }) => { - if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell const task = row.original - const optimisticDone = optimisticUpdates.get(task.id) - const displayDone = optimisticDone !== undefined ? optimisticDone : task.done + const displayDone = task.done return ( - { - setOptimisticUpdates(prev => { - const next = new Map(prev) - next.set(task.id, checked) - return next - }) - if (checked) { - completeTask({ - variables: { id: task.id }, - onCompleted: () => onRefetch?.(), - }) - } else { - reopenTask({ - variables: { id: task.id }, - onCompleted: () => onRefetch?.(), - }) - } - }} - onClick={(e) => e.stopPropagation()} - className={clsx('not-print:rounded-full', PriorityUtils.toCheckboxColor(task.priority as TaskPriority | null | undefined))} - /> + +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + { + setOptimisticUpdates(prev => { + const next = new Map(prev) + next.set(task.id, checked) + return next + }) + if (checked) { + completeTask({ + variables: { id: task.id }, + }) + } else { + reopenTask({ + variables: { id: task.id }, + }) + } + }} + className={clsx('not-print:rounded-full', PriorityUtils.toCheckboxColor(task.priority as TaskPriority | null | undefined))} + /> +
+
) }, minSize: 60, @@ -388,25 +581,18 @@ export const TaskList = forwardRef(({ tasks: initial }, { id: 'title', - header: translation('title'), + header: embedded ? translation('task') : translation('title'), accessorKey: 'name', - cell: ({ row }) => { - if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell - const showSystemBadge = row.original.machineGenerated || row.original.source === 'systemSuggestion' - return ( -
+ cell: ({ row }) => ( + +
{row.original.priority && (
)} {row.original.name} - {showSystemBadge && ( - - System - - )}
- ) - }, + + ), minSize: 200, size: 300, filterFn: 'text', @@ -416,8 +602,13 @@ export const TaskList = forwardRef(({ tasks: initial header: translation('dueDate'), accessorKey: 'dueDate', cell: ({ row }) => { - if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell - if (!row.original.dueDate) return - + if (!row.original.dueDate) { + return ( + + - + + ) + } const overdue = DueDateUtils.isOverdue(row.original.dueDate, row.original.done) const closeToDue = DueDateUtils.isCloseToDueDate(row.original.dueDate, row.original.done) let colorClass = '' @@ -427,11 +618,13 @@ export const TaskList = forwardRef(({ tasks: initial colorClass = 'text-orange-500' } return ( - + + + ) }, minSize: 220, @@ -445,30 +638,33 @@ export const TaskList = forwardRef(({ tasks: initial header: translation('patient'), accessorFn: ({ patient }) => patient?.name, cell: ({ row }) => { - if (refreshingTaskIds.has(row.original.id)) return rowLoadingCell const data = row.original if (!data.patient) { return ( - - {translation('noPatient')} - + + + {translation('noPatient')} + + ) } return ( - <> - {data.patient?.name} - - + + <> + {data.patient?.name} + + + ) }, sortingFn: 'text', @@ -489,46 +685,49 @@ export const TaskList = forwardRef(({ tasks: initial const assigneeTeam = row.original.assigneeTeam if (!assignee && !assigneeTeam) { return ( - - {translation('notAssigned')} - + + + {translation('notAssigned')} + + ) } if (assigneeTeam) { return ( -
- - {assigneeTeam.title} -
+ +
+ + {assigneeTeam.title} +
+
) } if (assignee) { + const extra = row.original.additionalAssigneeCount ?? 0 + const printLine = `${assignee.name}${extra > 0 ? ` ${translation('additionalAssigneesCount', { count: extra })}` : ''}` return ( - <> - {assignee.name} - - + + 0 ? translation('additionalAssigneesCount', { count: extra }) : null} + /> + ) } return ( - - {translation('notAssigned')} - + + + {translation('notAssigned')} + + ) }, minSize: 200, @@ -537,95 +736,251 @@ export const TaskList = forwardRef(({ tasks: initial }) } + cols.push({ + id: 'updateDate', + header: translation('updated'), + accessorFn: (row) => row.updateDate, + cell: ({ row }) => ( + + + + ), + minSize: 220, + size: 220, + maxSize: 220, + enableResizing: false, + filterFn: 'date', + }) + const colsWithRefreshing = [ ...cols, ...taskPropertyColumns.map((col) => ({ ...col, cell: col.cell - ? (params: { row: { original: TaskViewModel } }) => - refreshingTaskIds.has(params.row.original.id) ? rowLoadingCell : (col.cell as (p: unknown) => React.ReactNode)(params) + ? (params: { row: { original: TaskViewModel } }) => ( + + {(col.cell as (p: unknown) => React.ReactNode)(params)} + + ) : undefined, })), ] return colsWithRefreshing }, - [translation, completeTask, reopenTask, showAssignee, optimisticUpdates, taskPropertyColumns, refreshingTaskIds, rowLoadingCell, onRefetch]) + [translation, completeTask, reopenTask, showAssignee, taskPropertyColumns, embedded]) - return ( - (), [])} - manualPagination={true} - initialState={{ - pagination: { - pageSize: 10, - } - }} - state={{ - columnVisibility, - pagination, - sorting, - columnFilters: showAllTasksMode ? filters.filter(f => f.id !== 'done') : filters, - } as Partial as TableState} - onColumnVisibilityChange={setColumnVisibility} - onPaginationChange={setPagination} - onSortingChange={setSorting} - onColumnFiltersChange={setFiltersNormalized} - enableMultiSort={true} - onRowClick={row => setTaskDialogState({ isOpen: true, taskId: row.original.id })} - pageCount={stableTotalCount != null ? Math.ceil(stableTotalCount / pagination.pageSize) : -1} - > -
-
-
- setSearchQuery(e.target.value)} - onSearch={() => null} - containerProps={{ className: 'max-w-80' }} - /> - -
-
- - {headerActions} - {canHandover && ( - - )} - setTaskDialogState({ isOpen: true })} - disabled={!hasPatients} - > - - + const taskCardPrimaryColumnIds = useMemo(() => { + const s = new Set(['done', 'title', 'dueDate', 'patient']) + if (showAssignee) s.add('assignee') + return s + }, [showAssignee]) + + const renderTaskCardExtras = useCallback((task: TaskViewModel): ReactNode => { + const rows: ReactNode[] = [] + for (const col of columns) { + const id = col.id as string | undefined + if (!id || taskCardPrimaryColumnIds.has(id)) continue + if (columnVisibility[id] === false) continue + if (!col.cell) continue + const isExpandableTextProperty = id.startsWith('property_') && + propertyFieldTypeByDefId.get(id.replace('property_', '')) === FieldType.FieldTypeText + const headerLabel = typeof col.header === 'string' ? col.header : id + const cell = (col.cell as (p: { row: { original: TaskViewModel } }) => ReactNode)({ row: { original: task } }) + const propertyId = id.startsWith('property_') ? id.replace('property_', '') : null + const propertyTextValue = propertyId + ? task.properties?.find(property => property.definition.id === propertyId)?.textValue + : null + rows.push( +
+ {headerLabel} +
+ {isExpandableTextProperty ? ( + {propertyTextValue ?? ''} + ) : cell}
-
- {loading && ( -
- + ) + } + if (rows.length === 0) return null + return <>{rows} + }, [columns, columnVisibility, taskCardPrimaryColumnIds, propertyFieldTypeByDefId]) + + const knownColumnIdsOrdered = useMemo( + () => columnIdsFromColumnDefs(columns), + [columns] + ) + + const embeddedDashboardColumnVisibility = useMemo((): VisibilityState | null => { + if (!embedded) return null + const visible = new Set(['done', 'title', 'patient', 'dueDate', 'assignee', 'updateDate']) + const vis: VisibilityState = {} + for (const id of knownColumnIdsOrdered) { + vis[id] = visible.has(id) + } + return vis + }, [embedded, knownColumnIdsOrdered]) + + const tableColumnVisibility = embedded && embeddedDashboardColumnVisibility != null + ? embeddedDashboardColumnVisibility + : columnVisibility + + const sanitizedColumnOrder = useMemo( + () => sanitizeColumnOrderForKnownColumns(columnOrder, knownColumnIdsOrdered), + [columnOrder, knownColumnIdsOrdered] + ) + + const deferSetColumnOrder = useDeferredColumnOrderChange(setColumnOrder) + const embeddedTableStateNoop = useCallback(() => {}, []) + const hasOpenDrawer = taskDialogState.isOpen || selectedPatientId != null + const hasFilterPanelOpen = isShowFilters || isShowSorting + + useEffect(() => { + if (typeof document === 'undefined') return + if (isMobileIOS && hasOpenDrawer) { + document.body.dataset['taskDrawerFullscreen'] = 'true' + } else { + delete document.body.dataset['taskDrawerFullscreen'] + } + + return () => { + delete document.body.dataset['taskDrawerFullscreen'] + } + }, [isMobileIOS, hasOpenDrawer]) + + return ( + + (), [])} + initialState={{ + pagination: { + pageSize: LIST_PAGE_SIZE, + } + }} + state={{ + columnVisibility: tableColumnVisibility, + columnOrder: sanitizedColumnOrder, + pagination: tablePagination, + } as Partial as TableState} + onColumnVisibilityChange={embedded ? embeddedTableStateNoop : setColumnVisibilityMerged} + onColumnOrderChange={embedded ? embeddedTableStateNoop : deferSetColumnOrder} + onPaginationChange={() => {}} + onSortingChange={embedded ? embeddedTableStateNoop : setSorting} + onColumnFiltersChange={embedded ? embeddedTableStateNoop : setFilters} + enableMultiSort={true} + enablePinning={false} + onRowClick={row => setTaskDialogState({ isOpen: true, taskId: row.original.id })} + pageCount={1} + manualPagination={true} + manualSorting={true} + manualFiltering={true} + enableColumnFilters={false} + enableSorting={false} + enableColumnPinning={false} + > +
+ {!embedded && ( +
+
+
+ setSearchQuery(e.target.value)} + onSearch={() => null} + containerProps={{ className: 'w-full max-w-full min-w-0 sm:max-w-80' }} + /> + +
+ + +
+ {saveViewSlot} +
+
+ {headerActions} + {canHandover && ( + + )} + setListLayout('table')} + color={listLayout === 'table' ? 'primary' : 'neutral'} + > + + + setListLayout('card')} + color={listLayout === 'card' ? 'primary' : 'neutral'} + > + + + setTaskDialogState({ isOpen: true })} + disabled={!hasPatients} + > + + +
+
+ {isShowFilters && ( +
+ +
+ )} + {isShowSorting && ( +
+ +
+ )}
)} - - - {totalCount != null && ( - - )} -
- setTaskDialogState({ isOpen: false })} - > - + +
+ {listLayout === 'card' && ( +
+ {displayedTasks.map((task) => ( + setTaskDialogState({ isOpen: true, taskId: task.id })} + extraContent={renderTaskCardExtras(task)} + /> + ))} +
+ )} + {effectiveHasMore && !embedded && ( + + )} +
+ setTaskDialogState({ isOpen: false })} - onSuccess={onRefetch || (() => { - })} - /> - - setSelectedPatientId(null)} - > - {!!selectedPatientId && ( - setSelectedPatientId(null)} - onSuccess={onRefetch || (() => { - })} + > + setTaskDialogState({ isOpen: false })} + onListSync={onRefetch} /> - )} - - { - setIsHandoverDialogOpen(false) - if (!isConfirmDialogOpen && !isOpeningConfirmDialogRef.current) { - setSelectedUserId(null) - } - }} - dialogTitle={translation('shiftHandover') || 'Shift Handover'} - onUserInfoClick={(userId) => setSelectedUserPopupId(userId)} - /> - {isConfirmDialogOpen && selectedUserId && ( - { - setIsConfirmDialogOpen(false) - setSelectedUserId(null) + + setSelectedPatientId(null)} + > + {!!selectedPatientId && ( + setSelectedPatientId(null)} + onSuccess={() => { + onRefetch?.() + }} + /> + )} + + { setIsHandoverDialogOpen(false) - }} - onConfirm={() => { - if (selectedUserId) { - handleConfirmHandover() + if (!isConfirmDialogOpen && !isOpeningConfirmDialogRef.current) { + setSelectedUserId(null) } }} - titleElement={translation('confirmShiftHandover') || 'Confirm Shift Handover'} - description={getSelectedUserOrTeam && openTasks.length > 0 ? translation('confirmShiftHandoverDescriptionWithName', { - taskCount: openTasks.length, - name: getSelectedUserOrTeam.name - }) : (translation('confirmShiftHandoverDescription') || 'Are you sure you want to transfer all open tasks?')} + dialogTitle={translation('shiftHandover') || 'Shift Handover'} + onUserInfoClick={(userId) => setSelectedUserPopupId(userId)} /> - )} - setSelectedUserPopupId(null)} - /> -
- + {isConfirmDialogOpen && selectedUserId && ( + { + setIsConfirmDialogOpen(false) + setSelectedUserId(null) + setIsHandoverDialogOpen(false) + }} + onConfirm={() => { + if (selectedUserId) { + handleConfirmHandover() + } + }} + titleElement={translation('confirmShiftHandover') || 'Confirm Shift Handover'} + description={getSelectedUserOrTeam && openTasks.length > 0 ? translation('confirmShiftHandoverDescriptionWithName', { + taskCount: openTasks.length, + name: getSelectedUserOrTeam.name + }) : (translation('confirmShiftHandoverDescription') || 'Are you sure you want to transfer all open tasks?')} + /> + )} + setSelectedUserPopupId(null)} + /> + +
+ + ) }) diff --git a/web/components/tables/TaskRowRefreshingGate.tsx b/web/components/tables/TaskRowRefreshingGate.tsx new file mode 100644 index 00000000..e79566e7 --- /dev/null +++ b/web/components/tables/TaskRowRefreshingGate.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, type ReactNode } from 'react' +import { Loader2 } from 'lucide-react' + +const EMPTY_TASK_IDS = new Set() + +export const RefreshingTaskIdsContext = createContext>(EMPTY_TASK_IDS) + +type TaskRowRefreshingGateProps = { + taskId: string, + children: ReactNode, +} + +export function TaskRowRefreshingGate({ taskId, children }: TaskRowRefreshingGateProps) { + const ids = useContext(RefreshingTaskIdsContext) + const refreshing = ids.has(taskId) + return ( +
+
+ {children} +
+ {refreshing && ( +
+ +
+ )} +
+ ) +} diff --git a/web/components/tables/UserSelectFilterPopUp.tsx b/web/components/tables/UserSelectFilterPopUp.tsx new file mode 100644 index 00000000..bbcd6db2 --- /dev/null +++ b/web/components/tables/UserSelectFilterPopUp.tsx @@ -0,0 +1,184 @@ +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { Button, FilterBasePopUp, type FilterListPopUpBuilderProps } from '@helpwave/hightide' +import { useId, useMemo, useState, type ReactNode } from 'react' +import { User } from 'lucide-react' +import { AssigneeSelectDialog } from '@/components/tasks/AssigneeSelectDialog' +import { UserInfoPopup } from '@/components/UserInfoPopup' +import { useUsers } from '@/data' +import { FilterPreviewAvatar } from '@/components/tables/FilterPreviewMedia' + +export const UserSelectFilterPopUp = ({ value, onValueChange, onRemove, name }: FilterListPopUpBuilderProps) => { + const translation = useTasksTranslation() + const { data: usersData } = useUsers() + const id = useId() + const [dialogOpen, setDialogOpen] = useState(false) + const [userInfoId, setUserInfoId] = useState(null) + + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'equals' + return suggestion === 'contains' ? 'contains' : 'equals' + }, [value?.operator]) + + const uuidValue = value?.parameter?.uuidValue + const uuidValues = value?.parameter?.uuidValues + const isMulti = operator === 'contains' + + const initialMultiUserIds = useMemo(() => { + if (!isMulti) return [] + const v = uuidValues + return Array.isArray(v) ? v.map(String) : [] + }, [isMulti, uuidValues]) + + const singleValueForDialog = useMemo(() => { + if (isMulti) return '' + const u = uuidValue + return u != null && String(u) !== '' ? String(u) : '' + }, [isMulti, uuidValue]) + + const summaryContent = useMemo((): ReactNode => { + const users = usersData?.users + if (isMulti) { + const ids = (uuidValues as string[] | undefined) ?? [] + const n = ids.length + if (n === 0) { + return ( + <> + + {translation('selectAssignee')} + + ) + } + return ( + <> + + {ids.slice(0, 3).map(uid => { + const user = users?.find(u => u.id === uid) + return user ? ( + + ) : ( + + ) + })} + + + {n} {translation('users')} + + + ) + } + const uid = uuidValue != null && String(uuidValue) !== '' ? String(uuidValue) : undefined + if (!uid) { + return ( + <> + + {translation('selectAssignee')} + + ) + } + const user = users?.find(u => u.id === uid) + const label = user?.name ?? translation('selectAssignee') + return ( + <> + {user ? ( + + ) : ( + + )} + {label} + + ) + }, [isMulti, usersData?.users, uuidValue, uuidValues, translation]) + + const handleSingleSelected = (selectedValue: string) => { + const baseParam = value?.parameter ?? {} + onValueChange({ + ...value, + dataType: 'singleTag', + operator: 'equals', + parameter: { ...baseParam, uuidValue: selectedValue, uuidValues: undefined }, + }) + setDialogOpen(false) + } + + const handleMultiUserIdsSelected = (ids: string[]) => { + const baseParam = value?.parameter ?? {} + onValueChange({ + ...value, + dataType: 'singleTag', + operator: 'contains', + parameter: { ...baseParam, uuidValue: undefined, uuidValues: ids }, + }) + setDialogOpen(false) + } + + return ( + <> + { + const baseParam = value?.parameter ?? {} + const next = newOperator === 'contains' ? 'contains' : 'equals' + if (next === 'equals') { + const u = baseParam.uuidValues + const first = Array.isArray(u) && u.length > 0 ? String(u[0]) : undefined + onValueChange({ + dataType: 'singleTag', + parameter: { ...baseParam, uuidValue: first, uuidValues: undefined }, + operator: 'equals', + }) + } else { + const u = baseParam.uuidValue + onValueChange({ + dataType: 'singleTag', + parameter: { + ...baseParam, + uuidValue: undefined, + uuidValues: u != null && String(u) !== '' ? [String(u)] : [], + }, + operator: 'contains', + }) + } + }} + onRemove={onRemove} + allowedOperators={['equals', 'contains']} + noParameterRequired={false} + > +
+ +
+ +
+
+
+ setDialogOpen(false)} + value={singleValueForDialog} + onValueChanged={handleSingleSelected} + multiUserSelect={isMulti} + onMultiUserIdsSelected={handleMultiUserIdsSelected} + initialMultiUserIds={initialMultiUserIds} + allowTeams={false} + allowUnassigned={false} + dialogTitle={translation('selectAssignee')} + onUserInfoClick={(userId) => setUserInfoId(userId)} + /> + setUserInfoId(null)} + /> + + ) +} diff --git a/web/components/tasks/AssigneeSelect.tsx b/web/components/tasks/AssigneeSelect.tsx index 56d21981..fa43afa4 100644 --- a/web/components/tasks/AssigneeSelect.tsx +++ b/web/components/tasks/AssigneeSelect.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useRef } from 'react' import { PropsUtil, Visibility } from '@helpwave/hightide' import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' import { useTasksTranslation } from '@/i18n/useTasksTranslation' -import { Users, ChevronDown, Info, SearchIcon, XIcon } from 'lucide-react' +import { Users, ChevronDown, Info, SearchIcon } from 'lucide-react' import { useUsers, useLocations } from '@/data' import clsx from 'clsx' import { AssigneeSelectDialog } from './AssigneeSelectDialog' @@ -12,10 +12,11 @@ interface AssigneeSelectProps { value: string, onValueChanged: (value: string) => void, onDialogClose?: (value: string) => void, - onValueClear?: () => void, allowTeams?: boolean, allowUnassigned?: boolean, excludeUserIds?: string[], + multiUserSelect?: boolean, + onMultiUserIdsSelected?: (userIds: string[]) => void, id?: string, className?: string, [key: string]: unknown, @@ -25,10 +26,11 @@ export const AssigneeSelect = ({ value, onValueChanged, onDialogClose, - onValueClear, allowTeams = true, allowUnassigned: _allowUnassigned = false, excludeUserIds = [], + multiUserSelect = false, + onMultiUserIdsSelected, id, className, }: AssigneeSelectProps) => { @@ -133,20 +135,6 @@ export const AssigneeSelect = ({ )} - {onValueClear && ( - - )}
@@ -162,6 +150,8 @@ export const AssigneeSelect = ({ isOpen={isOpen} onClose={handleClose} onUserInfoClick={(userId) => setSelectedUserPopupState({ isOpen: true, userId })} + multiUserSelect={multiUserSelect} + onMultiUserIdsSelected={onMultiUserIdsSelected} /> void, @@ -16,6 +18,9 @@ interface AssigneeSelectDialogProps { onClose: () => void, dialogTitle?: string, onUserInfoClick?: (userId: string) => void, + multiUserSelect?: boolean, + onMultiUserIdsSelected?: (userIds: string[]) => void, + initialMultiUserIds?: string[], } export const AssigneeSelectDialog = ({ @@ -28,10 +33,15 @@ export const AssigneeSelectDialog = ({ onClose, dialogTitle, onUserInfoClick, + multiUserSelect = false, + onMultiUserIdsSelected, + initialMultiUserIds, }: AssigneeSelectDialogProps) => { const translation = useTasksTranslation() const [searchQuery, setSearchQuery] = useState('') + const [pendingUserIds, setPendingUserIds] = useState>(new Set()) const searchInputRef = useRef(null) + const initialMultiIds = initialMultiUserIds ?? EMPTY_MULTI_USER_IDS const { data: usersData } = useUsers() const { data: locationsData } = useLocations() @@ -88,7 +98,16 @@ export const AssigneeSelectDialog = ({ } else { setSearchQuery('') } - }, [isOpen]) + if (isOpen && multiUserSelect) { + setPendingUserIds(prev => { + const next = new Set(initialMultiIds) + if (prev.size === next.size && [...next].every((id) => prev.has(id))) { + return prev + } + return next + }) + } + }, [isOpen, multiUserSelect, initialMultiIds]) const handleSelect = (selectedValue: string) => { onValueChanged(selectedValue) @@ -96,8 +115,30 @@ export const AssigneeSelectDialog = ({ onClose() } + const togglePendingUser = (userId: string) => { + setPendingUserIds(prev => { + const next = new Set(prev) + if (next.has(userId)) { + next.delete(userId) + } else { + next.add(userId) + } + return next + }) + } + + const handleApplyMultiUsers = () => { + if (onMultiUserIdsSelected) { + onMultiUserIdsSelected([...pendingUserIds]) + } + setSearchQuery('') + setPendingUserIds(new Set()) + onClose() + } + const handleClose = () => { setSearchQuery('') + setPendingUserIds(new Set()) onClose() } @@ -129,12 +170,19 @@ export const AssigneeSelectDialog = ({ key={u.id} className={clsx( 'w-full px-3 py-2 hover:bg-surface-hover transition-colors flex items-center gap-2 bg-surface', - value === u.id && 'bg-surface-selected' + !multiUserSelect && value === u.id && 'bg-surface-selected', + multiUserSelect && pendingUserIds.has(u.id) && 'bg-surface-selected' )} > + {multiUserSelect && ( + togglePendingUser(u.id)} + /> + )}
+ {multiUserSelect && onMultiUserIdsSelected && ( +
+ + +
+ )}
) diff --git a/web/components/tasks/TaskCardView.tsx b/web/components/tasks/TaskCardView.tsx index 72a722be..22876072 100644 --- a/web/components/tasks/TaskCardView.tsx +++ b/web/components/tasks/TaskCardView.tsx @@ -1,6 +1,6 @@ -import { Button, Checkbox, Chip } from '@helpwave/hightide' +import { Button, Checkbox, Tooltip } from '@helpwave/hightide' import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' -import { Clock, User, Users, Flag } from 'lucide-react' +import { Clock, Combine, User, Users, Flag } from 'lucide-react' import clsx from 'clsx' import { DateDisplay } from '@/components/Date/DateDisplay' import { LocationChipsBySetting } from '@/components/patients/LocationChipsBySetting' @@ -8,9 +8,12 @@ import type { TaskViewModel } from '@/components/tables/TaskList' import { useRouter } from 'next/router' import type { TaskPriority } from '@/api/gql/generated' import { useCompleteTask, useReopenTask } from '@/data' -import { useState, useEffect, useRef, useMemo } from 'react' +import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' import { UserInfoPopup } from '@/components/UserInfoPopup' import { PriorityUtils } from '@/utils/priority' +import { ExpandableTextBlock } from '@/components/common/ExpandableTextBlock' +import { TaskPresetSourceDialog } from '@/components/tasks/TaskPresetSourceDialog' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' type FlexibleTask = { id: string, @@ -42,19 +45,18 @@ type FlexibleTask = { id: string, title: string, } | null, - machineGenerated?: boolean, - source?: 'manual' | 'systemSuggestion', + sourceTaskPresetId?: string | null, } type TaskCardViewProps = { task: FlexibleTask | TaskViewModel, onToggleDone?: (taskId: string, done: boolean) => void, - onClick: (task: FlexibleTask | TaskViewModel) => void, + onClick?: (task: FlexibleTask | TaskViewModel) => void, showAssignee?: boolean, showPatient?: boolean, - onRefetch?: () => void, className?: string, fullWidth?: boolean, + extraContent?: ReactNode, } const isOverdue = (dueDate: Date | undefined, done: boolean): boolean => { @@ -76,8 +78,10 @@ const toDate = (date: Date | string | null | undefined): Date | undefined => { return new Date(date) } -export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showAssignee: _showAssignee = false, showPatient = true, onRefetch, className, fullWidth: _fullWidth = false }: TaskCardViewProps) => { +export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showAssignee: _showAssignee = false, showPatient = true, className, fullWidth: _fullWidth = false, extraContent }: TaskCardViewProps) => { + const translation = useTasksTranslation() const router = useRouter() + const [presetDialogId, setPresetDialogId] = useState(null) const [selectedUserId, setSelectedUserId] = useState(null) const [optimisticDone, setOptimisticDone] = useState(null) const pendingCheckedRef = useRef(null) @@ -94,6 +98,7 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA const closeToDue = dueDate ? isCloseToDueDate(dueDate, task.done) : false const dueDateColorClass = overdue ? '!text-red-500' : closeToDue ? '!text-orange-500' : '' const assigneeAvatarUrl = task.assignee?.avatarURL || (flexibleTask.assignee?.avatarUrl) + const isClickable = Boolean(onClick) const expectedFinishDate = useMemo(() => { if (!dueDate || !flexibleTask.estimatedTime) return null @@ -126,7 +131,6 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA onCompleted: () => { pendingCheckedRef.current = null setOptimisticDone(null) - onRefetch?.() }, onError: () => { pendingCheckedRef.current = null @@ -139,7 +143,6 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA onCompleted: () => { pendingCheckedRef.current = null setOptimisticDone(null) - onRefetch?.() }, onError: () => { pendingCheckedRef.current = null @@ -162,130 +165,159 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA P4: 'border-l-4 border-l-priority-p4', }[(task as FlexibleTask).priority as TaskPriority] ?? '' + const assigneeImage = useMemo( + () => ({ + avatarUrl: assigneeAvatarUrl || 'https://cdn.helpwave.de/boringavatar.svg', + alt: task.assignee?.name ?? '', + }), + [assigneeAvatarUrl, task.assignee?.name] + ) + return (
onClick(task)} + onClick={onClick ? () => onClick(task) : undefined} className={clsx( - 'border-2 p-4 rounded-lg text-left transition-colors hover:border-primary ', - 'relative bg-surface-variant bg-on-surface-variant overflow-hidden cursor-pointer w-full min-h-35', + 'border-2 p-4 rounded-lg text-left transition-colors', + 'relative bg-surface-variant bg-on-surface-variant w-full', + isClickable ? 'cursor-pointer hover:border-primary' : 'cursor-default', borderColorClass, priorityBorderClass, className )} - role="button" - tabIndex={0} + role={isClickable ? 'button' : undefined} + tabIndex={isClickable ? 0 : undefined} onKeyDown={(e) => { + if (!isClickable || !onClick) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onClick(task) } }} > -
-
e.stopPropagation()}> - -
-
-
-
- {(task as FlexibleTask).priority && ( -
+
+
+
e.stopPropagation()}> + +
+
+
+
+ {(task as FlexibleTask).priority && ( +
)} - /> - )} -
+ {taskName} +
+
+ {task.assigneeTeam && ( +
+ + {task.assigneeTeam.title} +
+ )} + {!task.assigneeTeam && task.assignee && ( + )} - > - {taskName}
- {((task as FlexibleTask).machineGenerated || (task as FlexibleTask).source === 'systemSuggestion') && ( - - System - + {descriptionPreview && ( + + {descriptionPreview} + )}
- {task.assigneeTeam && ( -
- - {task.assigneeTeam.title} +
+
+
+ {(task as FlexibleTask).sourceTaskPresetId && ( + + + + )} + {(task as FlexibleTask).estimatedTime && ( +
+ + + {(task as FlexibleTask).estimatedTime! < 60 + ? `${(task as FlexibleTask).estimatedTime}m` + : `${Math.floor((task as FlexibleTask).estimatedTime! / 60)}h ${(task as FlexibleTask).estimatedTime! % 60}m`} + +
+ )} + {dueDate && ( +
+ + +
+ )} +
+ {expectedFinishDate && ( +
+ +
)} - {!task.assigneeTeam && task.assignee && ( - - )}
- {descriptionPreview && ( -
{descriptionPreview}
- )}
-
- {showPatient && task.patient && ( -
- - {task.patient.locations && task.patient.locations.length > 0 && ( -
- -
- )} -
- )} -
-
- {(task as FlexibleTask).estimatedTime && ( -
- - - {(task as FlexibleTask).estimatedTime! < 60 - ? `${(task as FlexibleTask).estimatedTime}m` - : `${Math.floor((task as FlexibleTask).estimatedTime! / 60)}h ${(task as FlexibleTask).estimatedTime! % 60}m`} - -
- )} - {dueDate && ( -
- - -
- )} -
- {expectedFinishDate && ( -
- - + {showPatient && task.patient && ( +
+ + {task.patient.locations && task.patient.locations.length > 0 && ( +
+ +
+ )} +
+ )} + {extraContent && ( +
+ {extraContent}
)}
@@ -294,6 +326,11 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA isOpen={!!selectedUserId} onClose={() => setSelectedUserId(null)} /> + setPresetDialogId(null)} + />
) } diff --git a/web/components/tasks/TaskDataEditor.tsx b/web/components/tasks/TaskDataEditor.tsx index a6579059..ca5d8332 100644 --- a/web/components/tasks/TaskDataEditor.tsx +++ b/web/components/tasks/TaskDataEditor.tsx @@ -1,13 +1,12 @@ -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo, useRef } from 'react' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import type { CreateTaskInput, UpdateTaskInput, TaskPriority } from '@/api/gql/generated' import { PatientState } from '@/api/gql/generated' -import { useCreateTask, useDeleteTask, usePatients, useTask, useUpdateTask, useAssignTask, useAssignTaskToTeam, useUnassignTask, useRefreshingEntityIds } from '@/data' +import { useCreateTask, useDeleteTask, useLocations, usePatients, useTask, useUpdateTask, useUsers, useRefreshingEntityIds } from '@/data' import type { FormFieldDataHandling } from '@helpwave/hightide' import { Button, Checkbox, - DateTimeInput, FormProvider, FormField, Input, @@ -19,13 +18,17 @@ import { Drawer, useFormObserverKey, Visibility, - FormObserver + FormObserver, + FlexibleDateTimeInput, + IconButton } from '@helpwave/hightide' import { CenteredLoadingLogo } from '@/components/CenteredLoadingLogo' import { useTasksContext } from '@/hooks/useTasksContext' -import { User, Flag } from 'lucide-react' +import { User, Flag, Info, Users, XIcon } from 'lucide-react' import { DateDisplay } from '@/components/Date/DateDisplay' import { AssigneeSelect } from './AssigneeSelect' +import { AvatarStatusComponent } from '@/components/AvatarStatusComponent' +import { UserInfoPopup } from '@/components/UserInfoPopup' import { localToUTCWithSameTime, PatientDetailView } from '@/components/patients/PatientDetailView' import { ErrorDialog } from '@/components/ErrorDialog' import clsx from 'clsx' @@ -33,27 +36,50 @@ import { PriorityUtils } from '@/utils/priority' type TaskFormValues = CreateTaskInput & { done: boolean, + assigneeIds?: string[] | null, assigneeTeamId?: string | null, } +export type PresetRowSavedData = { + title: string, + description: string, + priority: TaskPriority | null, + estimatedTime: number | null, +} + +export type PresetRowEditorConfig = { + formKey: string, + title: string, + description: string, + priority: TaskPriority | null, + estimatedTime: number | null, + onSave: (data: PresetRowSavedData) => void, +} + interface TaskDataEditorProps { id: null | string, initialPatientId?: string, - onSuccess?: () => void, + initialPatientName?: string, + onListSync?: () => void, onClose?: () => void, + presetRowEditor?: PresetRowEditorConfig | null, } export const TaskDataEditor = ({ id, initialPatientId, - onSuccess, + initialPatientName, + onListSync, onClose, + presetRowEditor, }: TaskDataEditorProps) => { const translation = useTasksTranslation() const { selectedRootLocationIds } = useTasksContext() const [errorDialog, setErrorDialog] = useState<{ isOpen: boolean, message?: string }>({ isOpen: false }) const [isShowingPatientDialog, setIsShowingPatientDialog] = useState(false) + const [assigneeUserPopupId, setAssigneeUserPopupId] = useState(null) + const isPresetRowMode = presetRowEditor != null const isEditMode = id !== null const taskId = id const { refreshingTaskIds } = useRefreshingEntityIds() @@ -63,54 +89,63 @@ export const TaskDataEditor = ({ { skip: !isEditMode } ) - const { data: patientsData } = usePatients( + const { data: patientsData, refetch: refetchPatients } = usePatients( { rootLocationIds: selectedRootLocationIds && selectedRootLocationIds.length > 0 ? selectedRootLocationIds : undefined, states: [PatientState.Admitted, PatientState.Wait], }, - { skip: isEditMode } + { skip: isEditMode || isPresetRowMode } ) + const hasRetriedMissingInitialPatientRef = useRef(false) const [createTask, { loading: isCreating }] = useCreateTask() const [updateTaskMutate] = useUpdateTask() - const [assignTask] = useAssignTask() - const [assignTaskToTeam] = useAssignTaskToTeam() - const [unassignTask] = useUnassignTask() + const { data: usersData } = useUsers() + const { data: locationsData } = useLocations() const updateTask = (vars: { id: string, data: UpdateTaskInput }) => { updateTaskMutate({ variables: vars, - onCompleted: () => onSuccess?.(), onError: (err) => { setErrorDialog({ isOpen: true, message: err instanceof Error ? err.message : 'Update failed', }) }, - }).catch(() => {}) + }).catch(() => { }) } const [deleteTask, { loading: isDeleting }] = useDeleteTask() const form = useCreateForm({ initialValues: { - title: '', - description: '', + title: presetRowEditor?.title ?? '', + description: presetRowEditor?.description ?? '', patientId: initialPatientId || '', - assigneeId: null, + assigneeIds: [], assigneeTeamId: null, dueDate: null, - priority: null, - estimatedTime: null, + priority: presetRowEditor?.priority ?? null, + estimatedTime: presetRowEditor?.estimatedTime ?? null, done: false, }, onFormSubmit: (values) => { + if (presetRowEditor) { + presetRowEditor.onSave({ + title: values.title.trim(), + description: (values.description ?? '').trim(), + priority: (values.priority as TaskPriority | null) || null, + estimatedTime: values.estimatedTime ?? null, + }) + onClose?.() + return + } createTask({ variables: { data: { title: values.title, - patientId: values.patientId, + patientId: values.patientId || null, description: values.description, - assigneeId: values.assigneeId, + assigneeIds: values.assigneeIds ?? [], assigneeTeamId: values.assigneeTeamId, dueDate: values.dueDate ? localToUTCWithSameTime(values.dueDate)?.toISOString() : null, priority: (values.priority as TaskPriority | null) || undefined, @@ -119,7 +154,7 @@ export const TaskDataEditor = ({ } as CreateTaskInput & { priority?: TaskPriority | null, estimatedTime?: number | null } }, onCompleted: () => { - onSuccess?.() + onListSync?.() onClose?.() }, onError: (error) => { @@ -134,23 +169,18 @@ export const TaskDataEditor = ({ } return null }, - patientId: (value) => { - if (!value || !value.trim()) { - return translation('patient') + ' is required' - } - return null - }, }, onValidUpdate: (_, updates) => { if (!isEditMode || !taskId || !taskData) return const data: UpdateTaskInput = { title: updates?.title, + patientId: updates?.patientId === undefined ? undefined : (updates.patientId || null), description: updates?.description, dueDate: updates?.dueDate ? localToUTCWithSameTime(updates.dueDate)?.toISOString() : undefined, priority: updates?.priority as TaskPriority | null | undefined, estimatedTime: updates?.estimatedTime, done: updates?.done, - assigneeId: updates?.assigneeId, + assigneeIds: updates?.assigneeIds, assigneeTeamId: updates?.assigneeTeamId, } const current = taskData @@ -160,9 +190,13 @@ export const TaskDataEditor = ({ const samePriority = (data.priority ?? current.priority ?? null) === (current.priority ?? null) const sameEstimatedTime = (data.estimatedTime ?? current.estimatedTime ?? null) === (current.estimatedTime ?? null) const sameDone = (data.done ?? current.done) === current.done - const sameAssigneeId = (data.assigneeId ?? current.assignee?.id ?? null) === (current.assignee?.id ?? null) + const currentAssigneeIds = [...(current.assignees?.map((assignee) => assignee.id) ?? [])].sort() + const nextAssigneeIds = [...(data.assigneeIds ?? currentAssigneeIds)].sort() + const sameAssigneeIds = currentAssigneeIds.length === nextAssigneeIds.length + && currentAssigneeIds.every((assigneeId, index) => assigneeId === nextAssigneeIds[index]) const sameAssigneeTeamId = (data.assigneeTeamId ?? current.assigneeTeam?.id ?? null) === (current.assigneeTeam?.id ?? null) - if (sameTitle && sameDescription && sameDueDate && samePriority && sameEstimatedTime && sameDone && sameAssigneeId && sameAssigneeTeamId) return + const samePatientId = (data.patientId ?? current.patient?.id ?? null) === (current.patient?.id ?? null) + if (sameTitle && sameDescription && sameDueDate && samePriority && sameEstimatedTime && sameDone && samePatientId && sameAssigneeIds && sameAssigneeTeamId) return updateTask({ id: taskId, data }) } }) @@ -170,6 +204,18 @@ export const TaskDataEditor = ({ const { update: updateForm } = form useEffect(() => { + if (!isPresetRowMode || !presetRowEditor) return + updateForm(prev => ({ + ...prev, + title: presetRowEditor.title, + description: presetRowEditor.description, + priority: presetRowEditor.priority, + estimatedTime: presetRowEditor.estimatedTime, + })) + }, [isPresetRowMode, presetRowEditor, updateForm]) + + useEffect(() => { + if (isPresetRowMode) return if (taskData && isEditMode) { const task = taskData updateForm(prev => ({ @@ -177,7 +223,7 @@ export const TaskDataEditor = ({ title: task.title, description: task.description || '', patientId: task.patient?.id || '', - assigneeId: task.assignee?.id || null, + assigneeIds: task.assignees?.map((assignee) => assignee.id) ?? [], assigneeTeamId: task.assigneeTeam?.id || null, dueDate: task.dueDate ? new Date(task.dueDate) : null, priority: (task.priority as TaskPriority | null) || null, @@ -187,13 +233,52 @@ export const TaskDataEditor = ({ } else if (initialPatientId && !taskId) { updateForm(prev => ({ ...prev, patientId: initialPatientId })) } - }, [taskData, isEditMode, initialPatientId, taskId, updateForm]) + }, [taskData, isEditMode, initialPatientId, taskId, updateForm, isPresetRowMode]) + + useEffect(() => { + hasRetriedMissingInitialPatientRef.current = false + }, [initialPatientId]) + + const patients = useMemo(() => { + const list = patientsData?.patients ?? [] + if (!initialPatientId || list.some(patient => patient.id === initialPatientId)) { + return list + } + const fallbackName = initialPatientName?.trim() + if (!fallbackName) return list + return [ + { + id: initialPatientId, + name: fallbackName, + }, + ...list, + ] + }, [patientsData?.patients, initialPatientId, initialPatientName]) - const patients = patientsData?.patients || [] + useEffect(() => { + if (isEditMode || !initialPatientId || hasRetriedMissingInitialPatientRef.current) return + const hasInitialPatient = (patientsData?.patients ?? []).some(patient => patient.id === initialPatientId) + if (hasInitialPatient) return + hasRetriedMissingInitialPatientRef.current = true + refetchPatients() + }, [isEditMode, initialPatientId, patientsData?.patients, refetchPatients]) const dueDate = useFormObserverKey({ formStore: form.store, formKey: 'dueDate' })?.value ?? null const estimatedTime = useFormObserverKey({ formStore: form.store, formKey: 'estimatedTime' })?.value ?? null + const assigneeIds = useFormObserverKey({ formStore: form.store, formKey: 'assigneeIds' })?.value as string[] | null | undefined const assigneeTeamId = useFormObserverKey({ formStore: form.store, formKey: 'assigneeTeamId' })?.value as string | null | undefined + const selectedAssignees = useMemo( + () => usersData?.users?.filter((user) => (assigneeIds ?? []).includes(user.id)) ?? [], + [usersData, assigneeIds] + ) + const teams = useMemo( + () => locationsData?.locationNodes?.filter((loc) => loc.kind === 'TEAM') ?? [], + [locationsData] + ) + const selectedTeamTitle = useMemo( + () => (assigneeTeamId ? teams.find((t) => t.id === assigneeTeamId)?.title : undefined), + [assigneeTeamId, teams] + ) const expectedFinishDate = useMemo(() => { if (!dueDate || !estimatedTime) return null const finishDate = new Date(dueDate) @@ -223,8 +308,12 @@ export const TaskDataEditor = ({
)} -
{ event.preventDefault(); form.submit() }} className="flex-col-0 overflow-hidden"> -
+ { event.preventDefault(); form.submit() }} + className="flex-col-0 overflow-hidden" + noValidate={isPresetRowMode} + > +
@@ -233,7 +322,6 @@ export const TaskDataEditor = ({ id="task-done" value={done || false} onValueChange={(checked) => { - // TODO replace with form.update when it allows setting the update trigger form.store.setValue('done', checked, true) }} className={clsx('rounded-full scale-125', @@ -260,91 +348,161 @@ export const TaskDataEditor = ({
- - name="patientId" - label={translation('patient')} - required - showRequiredIndicator={!isEditMode} - > - {({ dataProps, focusableElementProps, interactionStates }) => { - return (!isEditMode) ? ( - - ) : ( -
- +
+ ) + }} + + )} - - name="assigneeId" - label={translation('assignedTo')} - > - {({ dataProps }) => ( - { - updateForm(prev => { - if (value.startsWith('team:')) { - return { ...prev, assigneeId: null, assigneeTeamId: value.replace('team:', '') } - } - return { ...prev, assigneeId: value || null, assigneeTeamId: null } - }) - if (isEditMode && taskId) { - if (!value || value === '') { - unassignTask({ - variables: { id: taskId }, - onCompleted: () => onSuccess?.(), - }).catch(() => {}) - } else if (value.startsWith('team:')) { - assignTaskToTeam({ - variables: { id: taskId, teamId: value.replace('team:', '') }, - onCompleted: () => onSuccess?.(), - }).catch(() => {}) - } else { - assignTask({ - variables: { id: taskId, userId: value }, - onCompleted: () => onSuccess?.(), - }).catch(() => {}) - } - } - }} - allowTeams={true} - allowUnassigned={true} - /> - )} - + {!isPresetRowMode && ( + + name="assigneeIds" + label={translation('assignedTo')} + > + {() => ( +
+ { + updateForm(prev => { + if (!value) { + return { ...prev, assigneeIds: [], assigneeTeamId: null } + } + if (value.startsWith('team:')) { + return { ...prev, assigneeIds: [], assigneeTeamId: value.replace('team:', '') } + } + const currentAssigneeIds = prev.assigneeIds ?? [] + if (currentAssigneeIds.includes(value)) { + return prev + } + return { ...prev, assigneeIds: [...currentAssigneeIds, value], assigneeTeamId: null } + }, isEditMode) + }} + multiUserSelect={true} + onMultiUserIdsSelected={(ids) => { + if (ids.length === 0) return + updateForm(prev => ({ + ...prev, + assigneeIds: [...new Set([...(prev.assigneeIds ?? []), ...ids])], + assigneeTeamId: null, + }), isEditMode) + }} + allowTeams={true} + allowUnassigned={true} + excludeUserIds={assigneeIds ?? []} + /> + {(selectedAssignees.length > 0 || assigneeTeamId) && ( +
+ {selectedAssignees.map((assignee) => ( +
+
+ + {assignee.name} +
+ setAssigneeUserPopupId(assignee.id)} + > + + + { + updateForm(prev => ({ + ...prev, + assigneeIds: (prev.assigneeIds ?? []).filter((id) => id !== assignee.id), + }), isEditMode) + }} + > + + +
+ ))} + {assigneeTeamId && ( +
+
+ + {selectedTeamTitle ?? translation('locationType', { type: 'TEAM' })} +
+ { + updateForm(prev => ({ ...prev, assigneeTeamId: null }), isEditMode) + }} + > + + +
+ )} +
+ )} +
+ )} + + )} - - name="dueDate" - label={translation('dueDate')} - > - {({ dataProps, focusableElementProps, interactionStates }) => ( - - )} - + {!isPresetRowMode && ( + + name="dueDate" + label={translation('dueDate')} + > + {({ dataProps, focusableElementProps, interactionStates }) => ( + + )} + + )} name="priority" @@ -362,9 +520,9 @@ export const TaskDataEditor = ({ dataProps.onEditComplete?.(priority) }} > - {translation('priorityNone')} + {priorities.map(({ value, label }) => ( - +
{label} @@ -415,7 +573,7 @@ export const TaskDataEditor = ({ {...dataProps} {...focusableElementProps} {...interactionStates} value={dataProps.value || ''} placeholder={translation('descriptionPlaceholder')} - minLength={4} + minLength={isPresetRowMode ? undefined : 4} /> )} @@ -428,7 +586,7 @@ export const TaskDataEditor = ({ deleteTask({ variables: { id: taskId }, onCompleted: () => { - onSuccess?.() + onListSync?.() onClose?.() }, }) @@ -444,7 +602,7 @@ export const TaskDataEditor = ({ )}
- {!isEditMode && ( + {!isEditMode && !isPresetRowMode && (
)} + {isPresetRowMode && ( +
+ +
+ )} setIsShowingPatientDialog(false)} - onSuccess={() => {}} + onSuccess={() => { }} /> + setAssigneeUserPopupId(null)} + /> ) diff --git a/web/components/tasks/TaskDetailView.tsx b/web/components/tasks/TaskDetailView.tsx index ec9d8ce4..4ab41405 100644 --- a/web/components/tasks/TaskDetailView.tsx +++ b/web/components/tasks/TaskDetailView.tsx @@ -14,11 +14,12 @@ import { useUpdateTask } from '@/data' interface TaskDetailViewProps { taskId: string | null, onClose: () => void, - onSuccess: () => void, + onListSync?: () => void, initialPatientId?: string, + initialPatientName?: string, } -export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: TaskDetailViewProps) => { +export const TaskDetailView = ({ taskId, onClose, onListSync, initialPatientId, initialPatientName }: TaskDetailViewProps) => { const translation = useTasksTranslation() const isEditMode = !!taskId @@ -83,9 +84,8 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: properties: propertyInputs, }, }, - onCompleted: () => onSuccess(), }) - }, [isEditMode, taskId, taskData, convertPropertyValueToInput, updateTask, onSuccess]) + }, [isEditMode, taskId, taskData, convertPropertyValueToInput, updateTask]) return ( @@ -96,7 +96,8 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: diff --git a/web/components/tasks/TaskPresetSourceDialog.tsx b/web/components/tasks/TaskPresetSourceDialog.tsx new file mode 100644 index 00000000..f57f62c1 --- /dev/null +++ b/web/components/tasks/TaskPresetSourceDialog.tsx @@ -0,0 +1,76 @@ +import { Button, Dialog } from '@helpwave/hightide' +import { ExternalLink } from 'lucide-react' +import Link from 'next/link' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { useTaskPreset } from '@/data' +import { TaskPresetScope } from '@/api/gql/generated' + +type TaskPresetSourceDialogProps = { + isOpen: boolean, + onClose: () => void, + presetId: string | null, +} + +export function TaskPresetSourceDialog({ + isOpen, + onClose, + presetId, +}: TaskPresetSourceDialogProps) { + const translation = useTasksTranslation() + const { data, loading } = useTaskPreset(presetId) + + const preset = data?.taskPreset + + return ( + +
+ {loading && ( +

{translation('loading')}

+ )} + {!loading && !preset && ( +

{translation('taskPresetSourceNotFound')}

+ )} + {!loading && preset && ( + <> +
+ {translation('taskPresetName')} + {preset.name} +
+
+ {translation('taskPresetScope')} + + {preset.scope === TaskPresetScope.Global + ? translation('taskPresetScopeGlobal') + : translation('taskPresetScopePersonal')} + +
+
+ {translation('taskPresetSourceTasksInGraph')} + {preset.graph.nodes.length} +
+ + + + + )} +
+ +
+
+
+ ) +} diff --git a/web/components/views/PatientViewTasksPanel.tsx b/web/components/views/PatientViewTasksPanel.tsx new file mode 100644 index 00000000..ebfa98ea --- /dev/null +++ b/web/components/views/PatientViewTasksPanel.tsx @@ -0,0 +1,376 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useMutation } from '@apollo/client/react' +import { Visibility } from '@helpwave/hightide' +import type { ColumnFiltersState } from '@tanstack/react-table' +import { usePatients } from '@/data' +import { PatientState } from '@/api/gql/generated' +import type { QuerySearchInput } from '@/api/gql/generated' +import { + PropertyEntity, + UpdateSavedViewDocument, + MySavedViewsDocument, + SavedViewDocument, + type UpdateSavedViewMutation, + type UpdateSavedViewMutationVariables +} from '@/api/gql/generated' +import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } from '@/utils/tableStateToApi' +import { + deserializeColumnFiltersFromView, + deserializeSortingFromView, + hasActiveLocationFilter, + parseViewParameters, + serializeColumnFiltersForView, + serializeSortingForView, + stringifyViewParameters, + tableViewStateMatchesBaseline +} from '@/utils/viewDefinition' +import type { ViewParameters } from '@/utils/viewDefinition' +import { TaskList } from '@/components/tables/TaskList' +import type { TaskViewModel } from '@/components/tables/TaskList' +import { applyVirtualDerivedTasks } from '@/utils/virtualDerivedTableState' +import { useTableState } from '@/hooks/useTableState' +import { usePropertyDefinitions } from '@/data' +import { getPropertyColumnIds, useColumnVisibilityWithPropertyDefaults } from '@/hooks/usePropertyColumnVisibility' +import { SaveViewActionsMenu } from '@/components/views/SaveViewActionsMenu' +import { getParsedDocument } from '@/data/hooks/queryHelpers' +import { replaceSavedViewInMySavedViewsCache } from '@/utils/savedViewsCache' + +const ADMITTED_OR_WAITING: PatientState[] = [PatientState.Admitted, PatientState.Wait] + +type PatientViewTasksPanelProps = { + filterDefinitionJson: string, + sortDefinitionJson: string, + parameters: ViewParameters, + relatedFilterDefinitionJson: string, + relatedSortDefinitionJson: string, + relatedParametersJson: string, + savedViewId?: string, + isOwner: boolean, + refreshVersion?: number, +} + +export function PatientViewTasksPanel({ + filterDefinitionJson, + sortDefinitionJson, + parameters, + relatedFilterDefinitionJson, + relatedSortDefinitionJson, + relatedParametersJson, + savedViewId, + isOwner, + refreshVersion, +}: PatientViewTasksPanelProps) { + const filters = deserializeColumnFiltersFromView(filterDefinitionJson) + const sorting = deserializeSortingFromView(sortDefinitionJson) + const apiFilters = useMemo(() => columnFiltersToQueryFilterClauses(filters), [filters]) + const apiSorting = useMemo(() => sortingStateToQuerySortClauses(sorting), [sorting]) + const hasLocationFilter = useMemo( + () => hasActiveLocationFilter(filters), + [filters] + ) + + const allPatientStates: PatientState[] = useMemo(() => [ + PatientState.Admitted, + PatientState.Discharged, + PatientState.Dead, + PatientState.Wait, + ], []) + + const patientStates = useMemo(() => { + const stateFilter = apiFilters.find(f => f.fieldKey === 'state') + if (!stateFilter?.value) return allPatientStates + const raw = stateFilter.value.stringValues?.length + ? stateFilter.value.stringValues + : stateFilter.value.stringValue + ? [stateFilter.value.stringValue] + : [] + if (raw.length === 0) return allPatientStates + const allowed = new Set(allPatientStates as unknown as string[]) + const filtered = raw.filter(s => allowed.has(s)) + return filtered.length > 0 ? (filtered as PatientState[]) : allPatientStates + }, [apiFilters, allPatientStates]) + + const searchInput: QuerySearchInput | undefined = parameters.searchQuery + ? { searchText: parameters.searchQuery, includeProperties: true } + : undefined + + const { data: patientsData, loading, refetch } = usePatients({ + locationId: hasLocationFilter ? undefined : parameters.locationId, + rootLocationIds: hasLocationFilter || parameters.locationId + ? undefined + : (parameters.rootLocationIds && parameters.rootLocationIds.length > 0 ? parameters.rootLocationIds : undefined), + states: patientStates, + filters: apiFilters.length > 0 ? apiFilters : undefined, + sorts: apiSorting.length > 0 ? apiSorting : undefined, + search: searchInput, + }) + + const rawTasks: TaskViewModel[] = useMemo(() => { + if (!patientsData?.patients) return [] + return patientsData.patients.flatMap(patient => { + if (!ADMITTED_OR_WAITING.includes(patient.state) || !patient.tasks) return [] + const mergedLocations = [ + ...(patient.clinic ? [patient.clinic] : []), + ...(patient.position ? [patient.position] : []), + ...(patient.teams || []) + ] + return patient.tasks.map(task => ({ + id: task.id, + name: task.title, + description: task.description || undefined, + updateDate: task.updateDate ? new Date(task.updateDate) : new Date(task.creationDate), + dueDate: task.dueDate ? new Date(task.dueDate) : undefined, + priority: task.priority || null, + estimatedTime: task.estimatedTime ?? null, + done: task.done, + patient: { + id: patient.id, + name: patient.name, + locations: mergedLocations + }, + assignee: task.assignees[0] + ? { id: task.assignees[0].id, name: task.assignees[0].name, avatarURL: task.assignees[0].avatarUrl, isOnline: task.assignees[0].isOnline ?? null } + : undefined, + assigneeTeam: task.assigneeTeam + ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } + : undefined, + additionalAssigneeCount: + !task.assigneeTeam && task.assignees.length > 1 ? task.assignees.length - 1 : 0, + sourceTaskPresetId: task.sourceTaskPresetId ?? null, + })) + }) + }, [patientsData]) + + const relatedParams = useMemo(() => parseViewParameters(relatedParametersJson), [relatedParametersJson]) + const defaultRelatedFilters = useMemo( + () => deserializeColumnFiltersFromView(relatedFilterDefinitionJson), + [relatedFilterDefinitionJson] + ) + const defaultRelatedSortingRaw = useMemo( + () => deserializeSortingFromView(relatedSortDefinitionJson), + [relatedSortDefinitionJson] + ) + const baselineSort = useMemo(() => [ + { id: 'done', desc: false }, + { id: 'dueDate', desc: false }, + ], []) + const relatedSortBaseline = useMemo( + () => (defaultRelatedSortingRaw.length > 0 ? defaultRelatedSortingRaw : baselineSort), + [defaultRelatedSortingRaw, baselineSort] + ) + const baselineSearch = relatedParams.searchQuery ?? '' + const baselineColumnVisibility = useMemo( + () => relatedParams.columnVisibility ?? {}, + [relatedParams.columnVisibility] + ) + const baselineColumnOrder = useMemo( + () => relatedParams.columnOrder ?? [], + [relatedParams.columnOrder] + ) + + const persistedRelatedContentKey = useMemo( + () => + `${relatedFilterDefinitionJson}\0${relatedSortDefinitionJson}\0${stringifyViewParameters({ + searchQuery: relatedParams.searchQuery, + columnVisibility: relatedParams.columnVisibility, + columnOrder: relatedParams.columnOrder, + })}`, + [ + relatedFilterDefinitionJson, + relatedSortDefinitionJson, + relatedParams.searchQuery, + relatedParams.columnVisibility, + relatedParams.columnOrder, + ] + ) + + const { data: propertyDefinitionsData } = usePropertyDefinitions() + const propertyColumnIds = useMemo( + () => getPropertyColumnIds(propertyDefinitionsData, PropertyEntity.Task), + [propertyDefinitionsData] + ) + + const { + sorting: relatedSorting, + setSorting: setRelatedSorting, + filters: relatedFilters, + setFilters: setRelatedFilters, + columnVisibility: relatedColumnVisibility, + setColumnVisibility: setRelatedColumnVisibilityRaw, + columnOrder: relatedColumnOrder, + setColumnOrder: setRelatedColumnOrder, + } = useTableState({ + defaultFilters: defaultRelatedFilters, + defaultSorting: relatedSortBaseline, + defaultColumnVisibility: baselineColumnVisibility, + defaultColumnOrder: baselineColumnOrder, + }) + + const setRelatedColumnVisibility = useColumnVisibilityWithPropertyDefaults( + propertyDefinitionsData, + PropertyEntity.Task, + setRelatedColumnVisibilityRaw + ) + + const [searchQuery, setSearchQuery] = useState(baselineSearch) + + useEffect(() => { + setRelatedFilters(deserializeColumnFiltersFromView(relatedFilterDefinitionJson)) + const nextSort = deserializeSortingFromView(relatedSortDefinitionJson) + setRelatedSorting(nextSort.length > 0 ? nextSort : baselineSort) + setSearchQuery(relatedParams.searchQuery ?? '') + setRelatedColumnVisibility(relatedParams.columnVisibility ?? {}) + setRelatedColumnOrder(relatedParams.columnOrder ?? []) + }, [ + persistedRelatedContentKey, + relatedFilterDefinitionJson, + relatedSortDefinitionJson, + relatedParams.searchQuery, + relatedParams.columnVisibility, + relatedParams.columnOrder, + baselineSort, + setRelatedFilters, + setRelatedSorting, + setRelatedColumnVisibility, + setRelatedColumnOrder, + ]) + + const viewMatchesRelatedBaseline = useMemo( + () => tableViewStateMatchesBaseline({ + filters: relatedFilters as ColumnFiltersState, + baselineFilters: defaultRelatedFilters, + sorting: relatedSorting, + baselineSorting: relatedSortBaseline, + searchQuery, + baselineSearch, + columnVisibility: relatedColumnVisibility, + baselineColumnVisibility, + columnOrder: relatedColumnOrder, + baselineColumnOrder, + propertyColumnIds, + }), + [ + relatedFilters, + defaultRelatedFilters, + relatedSorting, + relatedSortBaseline, + searchQuery, + baselineSearch, + relatedColumnVisibility, + baselineColumnVisibility, + relatedColumnOrder, + baselineColumnOrder, + propertyColumnIds, + ] + ) + const hasUnsavedRelatedChanges = !viewMatchesRelatedBaseline + + const [updateSavedView, { loading: overwriteLoading }] = useMutation< + UpdateSavedViewMutation, + UpdateSavedViewMutationVariables + >(getParsedDocument(UpdateSavedViewDocument), { + awaitRefetchQueries: true, + refetchQueries: savedViewId + ? [ + { query: getParsedDocument(SavedViewDocument), variables: { id: savedViewId } }, + { query: getParsedDocument(MySavedViewsDocument) }, + ] + : [{ query: getParsedDocument(MySavedViewsDocument) }], + update(cache, { data }) { + const view = data?.updateSavedView + if (view) { + replaceSavedViewInMySavedViewsCache(cache, view) + } + }, + }) + + const handleDiscardRelated = useCallback(() => { + setRelatedFilters(defaultRelatedFilters) + setRelatedSorting(relatedSortBaseline) + setSearchQuery(baselineSearch) + setRelatedColumnVisibility(baselineColumnVisibility) + setRelatedColumnOrder(baselineColumnOrder) + }, [ + baselineSearch, + baselineColumnOrder, + baselineColumnVisibility, + defaultRelatedFilters, + setRelatedColumnOrder, + setRelatedColumnVisibility, + setRelatedFilters, + setRelatedSorting, + relatedSortBaseline, + ]) + + const handleOverwriteRelated = useCallback(async () => { + if (!savedViewId) return + await updateSavedView({ + variables: { + id: savedViewId, + data: { + relatedFilterDefinition: serializeColumnFiltersForView(relatedFilters as ColumnFiltersState), + relatedSortDefinition: serializeSortingForView(relatedSorting), + relatedParameters: stringifyViewParameters({ + searchQuery: searchQuery || undefined, + columnVisibility: relatedColumnVisibility, + columnOrder: relatedColumnOrder, + }), + }, + }, + }) + }, [ + savedViewId, + updateSavedView, + relatedFilters, + relatedSorting, + searchQuery, + relatedColumnVisibility, + relatedColumnOrder, + ]) + + const displayedTasks = useMemo( + () => applyVirtualDerivedTasks(rawTasks, relatedFilters, relatedSorting, searchQuery), + [rawTasks, relatedFilters, relatedSorting, searchQuery] + ) + + useEffect(() => { + if (refreshVersion === undefined || refreshVersion <= 0) return + refetch() + }, [refreshVersion, refetch]) + + return ( + + null} + onDiscard={handleDiscardRelated} + hideSaveAsNew={true} + /> + + ) : undefined} + tableState={{ + sorting: relatedSorting, + setSorting: setRelatedSorting, + filters: relatedFilters, + setFilters: setRelatedFilters, + columnVisibility: relatedColumnVisibility, + setColumnVisibility: setRelatedColumnVisibility, + columnOrder: relatedColumnOrder, + setColumnOrder: setRelatedColumnOrder, + }} + /> + ) +} diff --git a/web/components/views/SaveViewActionsMenu.tsx b/web/components/views/SaveViewActionsMenu.tsx new file mode 100644 index 00000000..51c995a9 --- /dev/null +++ b/web/components/views/SaveViewActionsMenu.tsx @@ -0,0 +1,78 @@ +'use client' + +import { Button, Menu, MenuItem } from '@helpwave/hightide' +import { Rabbit } from 'lucide-react' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' + +export type SaveViewActionsMenuProps = { + canOverwrite: boolean, + overwriteLoading?: boolean, + onOverwrite: () => void | Promise, + onOpenSaveAsNew: () => void, + onDiscard: () => void, + hideSaveAsNew?: boolean, +} + +export function SaveViewActionsMenu({ + canOverwrite, + overwriteLoading = false, + onOverwrite, + onOpenSaveAsNew, + onDiscard, + hideSaveAsNew = false, +}: SaveViewActionsMenuProps) { + const translation = useTasksTranslation() + + return ( +
+ + {canOverwrite ? ( + ( + + )} + className="min-w-56 p-2" + options={{ + verticalAlignment: 'beforeStart', + }} + > + {({ close }) => ( + <> + { + void onOverwrite() + close() + }} + isDisabled={overwriteLoading} + className="rounded-md cursor-pointer" + > + {translation('saveViewOverwriteCurrent')} + + {!hideSaveAsNew && ( + { + onOpenSaveAsNew() + close() + }} + className="rounded-md cursor-pointer" + > + {translation('saveViewAsNew')} + + )} + + )} + + ) : ( + + )} +
+ ) +} diff --git a/web/components/views/SaveViewDialog.tsx b/web/components/views/SaveViewDialog.tsx new file mode 100644 index 00000000..5f6ed6ae --- /dev/null +++ b/web/components/views/SaveViewDialog.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useCallback, useState } from 'react' +import { useMutation } from '@apollo/client/react' +import { Button, Dialog, Input } from '@helpwave/hightide' +import type { + SavedViewEntityType } from '@/api/gql/generated' +import { + CreateSavedViewDocument, + MySavedViewsDocument, + type CreateSavedViewMutation, + type CreateSavedViewMutationVariables, + SavedViewVisibility +} from '@/api/gql/generated' +import { getParsedDocument } from '@/data/hooks/queryHelpers' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { appendSavedViewToMySavedViewsCache } from '@/utils/savedViewsCache' + +type SaveViewDialogProps = { + isOpen: boolean, + onClose: () => void, + /** Entity this view is saved from */ + baseEntityType: SavedViewEntityType, + filterDefinition: string, + sortDefinition: string, + parameters: string, + presentation?: 'default' | 'fromSystemList', + /** Optional: navigate or toast after save */ + onCreated?: (id: string) => void, +} + +export function SaveViewDialog({ + isOpen, + onClose, + baseEntityType, + filterDefinition, + sortDefinition, + parameters, + presentation = 'default', + onCreated, +}: SaveViewDialogProps) { + const translation = useTasksTranslation() + const [name, setName] = useState('') + + const handleClose = useCallback(() => { + onClose() + setName('') + }, [onClose]) + + const [createSavedView, { loading }] = useMutation< + CreateSavedViewMutation, + CreateSavedViewMutationVariables + >(getParsedDocument(CreateSavedViewDocument), { + refetchQueries: [{ query: getParsedDocument(MySavedViewsDocument) }], + awaitRefetchQueries: true, + update(cache, { data }) { + const view = data?.createSavedView + if (view) { + appendSavedViewToMySavedViewsCache(cache, view) + } + }, + onCompleted(data) { + onCreated?.(data?.createSavedView?.id) + handleClose() + }, + }) + + return ( + +
+
+ + setName(e.target.value)} + /> +
+
+ + +
+
+
+ ) +} diff --git a/web/components/views/SavedViewEntityTypeChip.tsx b/web/components/views/SavedViewEntityTypeChip.tsx new file mode 100644 index 00000000..8b6354f5 --- /dev/null +++ b/web/components/views/SavedViewEntityTypeChip.tsx @@ -0,0 +1,31 @@ +import type { ChipProps } from '@helpwave/hightide' +import { Chip } from '@helpwave/hightide' +import { SavedViewEntityType } from '@/api/gql/generated' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import clsx from 'clsx' + +export type SavedViewEntityTypeChipProps = Omit & { + entityType: SavedViewEntityType, +} + +export function SavedViewEntityTypeChip({ + entityType, + className, + size = 'sm', + ...props +}: SavedViewEntityTypeChipProps) { + const translation = useTasksTranslation() + const isPatient = entityType === SavedViewEntityType.Patient + + return ( + + {isPatient ? translation('viewsEntityPatient') : translation('viewsEntityTask')} + + ) +} diff --git a/web/components/views/TaskViewPatientsPanel.tsx b/web/components/views/TaskViewPatientsPanel.tsx new file mode 100644 index 00000000..70538501 --- /dev/null +++ b/web/components/views/TaskViewPatientsPanel.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useMemo } from 'react' +import { useTasks } from '@/data' +import { PatientState, type GetTasksQuery } from '@/api/gql/generated' +import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } from '@/utils/tableStateToApi' +import { + deserializeColumnFiltersFromView, + deserializeSortingFromView, + parseViewParameters +} from '@/utils/viewDefinition' +import type { ViewParameters } from '@/utils/viewDefinition' +import { LoadingContainer } from '@helpwave/hightide' +import { PatientList } from '@/components/tables/PatientList' +import type { PatientViewModel } from '@/components/tables/PatientList' + +const ADMITTED_OR_WAITING: PatientState[] = [PatientState.Admitted, PatientState.Wait] + +type TaskPatient = NonNullable + +function buildEmbeddedPatientsFromTasks(tasks: GetTasksQuery['tasks']): PatientViewModel[] { + const agg = new Map() + for (const t of tasks) { + if (!t.patient) continue + const id = t.patient.id + let row = agg.get(id) + if (!row) { + row = { patient: t.patient, open: 0, closed: 0 } + agg.set(id, row) + } + if (t.done) row.closed += 1 + else row.open += 1 + } + return [...agg.values()].map(({ patient, open, closed }) => { + const countForAggregate = ADMITTED_OR_WAITING.includes(patient.state) + return { + id: patient.id, + name: patient.name, + firstname: patient.firstname, + lastname: patient.lastname, + birthdate: new Date(patient.birthdate), + sex: patient.sex, + state: patient.state, + position: patient.position, + openTasksCount: countForAggregate ? open : 0, + closedTasksCount: countForAggregate ? closed : 0, + tasks: [], + properties: patient.properties ?? [], + } + }) +} + +type TaskViewPatientsPanelProps = { + filterDefinitionJson: string, + sortDefinitionJson: string, + parameters: ViewParameters, + relatedFilterDefinitionJson: string, + relatedSortDefinitionJson: string, + relatedParametersJson: string, + savedViewId?: string, + isOwner: boolean, +} + +export function TaskViewPatientsPanel({ + filterDefinitionJson, + sortDefinitionJson, + parameters, + relatedFilterDefinitionJson, + relatedSortDefinitionJson, + relatedParametersJson, + savedViewId, + isOwner, +}: TaskViewPatientsPanelProps) { + const filters = deserializeColumnFiltersFromView(filterDefinitionJson) + const sorting = deserializeSortingFromView(sortDefinitionJson) + const apiFilters = useMemo(() => columnFiltersToQueryFilterClauses(filters), [filters]) + const apiSorting = useMemo(() => sortingStateToQuerySortClauses(sorting), [sorting]) + + const { data, loading } = useTasks( + { + rootLocationIds: parameters.rootLocationIds, + assigneeId: parameters.assigneeId, + filters: apiFilters.length > 0 ? apiFilters : undefined, + sorts: apiSorting.length > 0 ? apiSorting : undefined, + }, + { + skip: !parameters.rootLocationIds?.length && !parameters.assigneeId, + } + ) + + const embeddedPatients = useMemo( + () => buildEmbeddedPatientsFromTasks(data?.tasks ?? []), + [data?.tasks] + ) + + const defaultRelatedFilters = useMemo( + () => deserializeColumnFiltersFromView(relatedFilterDefinitionJson), + [relatedFilterDefinitionJson] + ) + const defaultRelatedSorting = useMemo( + () => deserializeSortingFromView(relatedSortDefinitionJson), + [relatedSortDefinitionJson] + ) + const relatedParams = useMemo( + () => parseViewParameters(relatedParametersJson), + [relatedParametersJson] + ) + + if (loading && embeddedPatients.length === 0) { + return ( +
+ +
+ ) + } + + return ( + + ) +} diff --git a/web/context/SystemSuggestionTasksContext.tsx b/web/context/SystemSuggestionTasksContext.tsx index 8d056311..4d9f0977 100644 --- a/web/context/SystemSuggestionTasksContext.tsx +++ b/web/context/SystemSuggestionTasksContext.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState, - type ReactNode, + type ReactNode } from 'react' import type { MachineGeneratedTask } from '@/types/systemSuggestion' import type { SuggestedTaskItem } from '@/types/systemSuggestion' @@ -13,16 +13,16 @@ import type { SuggestedTaskItem } from '@/types/systemSuggestion' type ToastState = { message: string } | null type SystemSuggestionTasksContextValue = { - getCreatedTasksForPatient: (patientId: string) => MachineGeneratedTask[] + 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 + ) => void, + setCreatedTaskDone: (patientId: string, taskId: string, done: boolean) => void, + toast: ToastState, + showToast: (message: string) => void, + clearToast: () => void, } const SystemSuggestionTasksContext = createContext(null) diff --git a/web/data/cache/policies.ts b/web/data/cache/policies.ts index b0025b9c..2b0415d0 100644 --- a/web/data/cache/policies.ts +++ b/web/data/cache/policies.ts @@ -1,10 +1,38 @@ -import type { InMemoryCacheConfig } from '@apollo/client/cache' +import type { InMemoryCacheConfig, Reference } from '@apollo/client/cache' const propertyValueKeyFields = (object: Readonly>): readonly ['id'] | false => { const id = object?.['id'] return id != null && id !== '' ? ['id'] : false } +type ReadFieldFromReference = (fieldName: string, from: Reference) => unknown + +const getReferenceIdentity = (readField: ReadFieldFromReference, reference: Reference): string => { + const id = readField('id', reference) + return typeof id === 'string' && id !== '' ? id : JSON.stringify(reference) +} + +const mergeReferencesByIdentity = ( + existing: readonly Reference[] = [], + incoming: readonly Reference[] = [], + { readField }: { readField: ReadFieldFromReference } +): readonly Reference[] => { + const incomingIdentities = new Set() + for (const reference of incoming) { + incomingIdentities.add(getReferenceIdentity(readField, reference)) + } + + const mergedReferences = [...incoming] + for (const reference of existing) { + const identity = getReferenceIdentity(readField, reference) + if (!incomingIdentities.has(identity)) { + mergedReferences.push(reference) + } + } + + return mergedReferences +} + export function buildCacheConfig(): InMemoryCacheConfig { return { typePolicies: { @@ -12,11 +40,11 @@ export function buildCacheConfig(): InMemoryCacheConfig { fields: { task: { keyArgs: ['id'] }, tasks: { - keyArgs: ['rootLocationIds', 'assigneeId', 'assigneeTeamId', 'filtering', 'sorting', 'search', 'pagination'], + keyArgs: ['rootLocationIds', 'assigneeId', 'assigneeTeamId', 'filters', 'sorts', 'search', 'pagination'], }, patient: { keyArgs: ['id'] }, patients: { - keyArgs: ['locationId', 'rootLocationIds', 'states', 'filtering', 'sorting', 'search', 'pagination'], + keyArgs: ['locationId', 'rootLocationIds', 'states', 'filters', 'sorts', 'search', 'pagination'], merge: (_existing, incoming) => incoming, }, locationNode: { keyArgs: ['id'] }, @@ -29,7 +57,18 @@ export function buildCacheConfig(): InMemoryCacheConfig { }, }, Task: { keyFields: ['id'] }, - Patient: { keyFields: ['id'] }, + Patient: { + keyFields: ['id'], + fields: { + properties: { merge: mergeReferencesByIdentity }, + }, + }, + PatientType: { + keyFields: ['id'], + fields: { + properties: { merge: mergeReferencesByIdentity }, + }, + }, User: { keyFields: ['id'] }, UserType: { keyFields: ['id'], @@ -45,6 +84,9 @@ export function buildCacheConfig(): InMemoryCacheConfig { PropertyValue: { keyFields: propertyValueKeyFields }, PropertyValueType: { keyFields: propertyValueKeyFields }, PropertyDefinitionType: { keyFields: ['id'] }, + TaskGraphType: { keyFields: false }, + TaskGraphNodeType: { keyFields: false }, + TaskGraphEdgeType: { keyFields: false }, }, } } diff --git a/web/data/hooks/index.ts b/web/data/hooks/index.ts index c0db2b5f..39094399 100644 --- a/web/data/hooks/index.ts +++ b/web/data/hooks/index.ts @@ -16,6 +16,7 @@ export { usePropertiesForSubject } from './usePropertiesForSubject' export { useMyTasks } from './useMyTasks' export { useTasksPaginated } from './useTasksPaginated' export { usePatientsPaginated } from './usePatientsPaginated' +export { useQueryableFields } from './useQueryableFields' export { useCreateTask } from './useCreateTask' export { useUpdateTask } from './useUpdateTask' export { useDeleteTask } from './useDeleteTask' @@ -36,3 +37,10 @@ export { useCreatePropertyDefinition } from './useCreatePropertyDefinition' export { useUpdatePropertyDefinition } from './useUpdatePropertyDefinition' export { useDeletePropertyDefinition } from './useDeletePropertyDefinition' export { useUpdateProfilePicture } from './useUpdateProfilePicture' +export { useMySavedViews, useSavedView } from './useSavedViews' +export { useTaskPresets } from './useTaskPresets' +export { useTaskPreset } from './useTaskPreset' +export { useCreateTaskPreset } from './useCreateTaskPreset' +export { useUpdateTaskPreset } from './useUpdateTaskPreset' +export { useDeleteTaskPreset } from './useDeleteTaskPreset' +export { useApplyTaskGraph } from './useApplyTaskGraph' diff --git a/web/data/hooks/queryHelpers.ts b/web/data/hooks/queryHelpers.ts index dee33a5c..346409a9 100644 --- a/web/data/hooks/queryHelpers.ts +++ b/web/data/hooks/queryHelpers.ts @@ -2,7 +2,6 @@ import { useMemo } from 'react' import { useQuery } from '@apollo/client/react' import type { DocumentNode } from 'graphql' import { parse } from 'graphql' -import { useApolloClientOptional } from '@/providers/ApolloProviderWithData' const parsedCache = new Map() @@ -30,9 +29,8 @@ export function useQueryWhenReady { - const client = useApolloClientOptional() const doc = useMemo(() => getParsedDocument(document), [document]) - const skip = options?.skip ?? !client + const skip = options?.skip ?? false const result = useQuery(doc, { variables, skip, diff --git a/web/data/hooks/useApplyTaskGraph.ts b/web/data/hooks/useApplyTaskGraph.ts new file mode 100644 index 00000000..36d9f865 --- /dev/null +++ b/web/data/hooks/useApplyTaskGraph.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@apollo/client/react' +import { + ApplyTaskGraphDocument, + type ApplyTaskGraphMutation, + type ApplyTaskGraphMutationVariables +} from '@/api/gql/generated' +import { getParsedDocument } from './queryHelpers' + +export function useApplyTaskGraph() { + return useMutation< + ApplyTaskGraphMutation, + ApplyTaskGraphMutationVariables + >(getParsedDocument(ApplyTaskGraphDocument)) +} diff --git a/web/data/hooks/useAssignTask.ts b/web/data/hooks/useAssignTask.ts index 0651f02a..23a3b5c3 100644 --- a/web/data/hooks/useAssignTask.ts +++ b/web/data/hooks/useAssignTask.ts @@ -1,8 +1,8 @@ import { useCallback, useState } from 'react' import { - AssignTaskDocument, - type AssignTaskMutation, - type AssignTaskMutationVariables + AddTaskAssigneeDocument, + type AddTaskAssigneeMutation, + type AddTaskAssigneeMutationVariables } from '@/api/gql/generated' import { assignTaskOptimisticPlan, @@ -13,8 +13,8 @@ import { useMutateOptimistic } from '@/hooks/useMutateOptimistic' import { useConflictOnConflict } from '@/providers/ConflictProvider' type MutateOptions = { - variables: AssignTaskMutationVariables, - onCompleted?: (data: AssignTaskMutation['assignTask']) => void, + variables: AddTaskAssigneeMutationVariables, + onCompleted?: (data: AddTaskAssigneeMutation['addTaskAssignee']) => void, onError?: (error: Error) => void, } @@ -25,23 +25,23 @@ export function useAssignTask() { const [error, setError] = useState(null) const mutate = useCallback( - async (options: MutateOptions): Promise => { + async (options: MutateOptions): Promise => { setError(null) setLoading(true) try { - const data = await mutateOptimisticFn({ - document: getParsedDocument(AssignTaskDocument), + const data = await mutateOptimisticFn({ + document: getParsedDocument(AddTaskAssigneeDocument), variables: options.variables, optimisticPlan: assignTaskOptimisticPlan, optimisticPlanKey: assignTaskOptimisticPlanKey, - onSuccess: (d) => options.onCompleted?.(d.assignTask), + onSuccess: (d) => options.onCompleted?.(d.addTaskAssignee), onError: (err) => { setError(err) options.onError?.(err) }, onConflict: onConflict ?? undefined, }) - return data?.assignTask + return data?.addTaskAssignee } catch (e) { const err = e instanceof Error ? e : new Error(String(e)) setError(err) diff --git a/web/data/hooks/useCreateTaskPreset.ts b/web/data/hooks/useCreateTaskPreset.ts new file mode 100644 index 00000000..8334bb48 --- /dev/null +++ b/web/data/hooks/useCreateTaskPreset.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@apollo/client/react' +import { + CreateTaskPresetDocument, + type CreateTaskPresetMutation, + type CreateTaskPresetMutationVariables +} from '@/api/gql/generated' +import { getParsedDocument } from './queryHelpers' + +export function useCreateTaskPreset() { + return useMutation< + CreateTaskPresetMutation, + CreateTaskPresetMutationVariables + >(getParsedDocument(CreateTaskPresetDocument)) +} diff --git a/web/data/hooks/useDeleteTaskPreset.ts b/web/data/hooks/useDeleteTaskPreset.ts new file mode 100644 index 00000000..1e6ac065 --- /dev/null +++ b/web/data/hooks/useDeleteTaskPreset.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@apollo/client/react' +import { + DeleteTaskPresetDocument, + type DeleteTaskPresetMutation, + type DeleteTaskPresetMutationVariables +} from '@/api/gql/generated' +import { getParsedDocument } from './queryHelpers' + +export function useDeleteTaskPreset() { + return useMutation< + DeleteTaskPresetMutation, + DeleteTaskPresetMutationVariables + >(getParsedDocument(DeleteTaskPresetDocument)) +} diff --git a/web/data/hooks/usePaginatedEntityQuery.ts b/web/data/hooks/usePaginatedEntityQuery.ts index 98bf6695..363ed77b 100644 --- a/web/data/hooks/usePaginatedEntityQuery.ts +++ b/web/data/hooks/usePaginatedEntityQuery.ts @@ -1,12 +1,14 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useRef } from 'react' import { useQueryWhenReady } from './queryHelpers' -import type { FilterInput, SortInput } from '@/api/gql/generated' +import type { QueryFilterClauseInput, QuerySearchInput, QuerySortClauseInput } from '@/api/gql/generated' export type UsePaginatedEntityQueryOptions = { pagination: { pageIndex: number, pageSize: number }, - sorting?: SortInput[], - filtering?: FilterInput[], + sorts?: QuerySortClauseInput[], + filters?: QueryFilterClauseInput[], + search?: QuerySearchInput, getPageDataKey?: (data: TQueryData | undefined) => string, + skip?: boolean, } export type UsePaginatedEntityQueryResult = { @@ -19,8 +21,9 @@ export type UsePaginatedEntityQueryResult = { type VariablesWithPagination = { pagination: { pageIndex: number, pageSize: number }, - sorting?: SortInput[], - filtering?: FilterInput[], + sorts?: QuerySortClauseInput[], + filters?: QueryFilterClauseInput[], + search?: QuerySearchInput, } export function usePaginatedEntityQuery< @@ -34,23 +37,28 @@ export function usePaginatedEntityQuery< extractItems: (data: TQueryData | undefined) => TItem[], extractTotal: (data: TQueryData | undefined) => number | undefined ): UsePaginatedEntityQueryResult { - const { pagination, sorting, filtering } = options + const { pagination, sorts, filters, search, skip: skipQuery } = options const variablesWithPagination = useMemo(() => ({ ...(variables ?? {}), pagination: { pageIndex: pagination.pageIndex, pageSize: pagination.pageSize }, - ...(sorting != null && sorting.length > 0 ? { sorting } : {}), - ...(filtering != null && filtering.length > 0 ? { filtering } : {}), - }), [variables, pagination.pageIndex, pagination.pageSize, sorting, filtering]) + ...(sorts != null && sorts.length > 0 ? { sorts } : {}), + ...(filters != null && filters.length > 0 ? { filters } : {}), + ...(search != null && search.searchText ? { search } : {}), + }), [variables, pagination.pageIndex, pagination.pageSize, sorts, filters, search]) const variablesTyped = variablesWithPagination as TVariables & VariablesWithPagination const result = useQueryWhenReady( document, variablesTyped, - { fetchPolicy: 'cache-and-network' } + { fetchPolicy: 'cache-and-network', skip: skipQuery === true } ) - const totalCount = extractTotal(result.data) + const extractItemsRef = useRef(extractItems) + extractItemsRef.current = extractItems + const extractTotalRef = useRef(extractTotal) + extractTotalRef.current = extractTotal + const totalCount = extractTotalRef.current(result.data) const data = useMemo( - () => extractItems(result.data), - [result.data, extractItems] + () => extractItemsRef.current(result.data), + [result.data] ) const refetch = useCallback(() => { result.refetch() diff --git a/web/data/hooks/usePatientsPaginated.ts b/web/data/hooks/usePatientsPaginated.ts index 210258d9..2db97c87 100644 --- a/web/data/hooks/usePatientsPaginated.ts +++ b/web/data/hooks/usePatientsPaginated.ts @@ -3,13 +3,15 @@ import { type GetPatientsQuery, type GetPatientsQueryVariables } from '@/api/gql/generated' -import type { FilterInput, SortInput } from '@/api/gql/generated' +import type { QueryFilterClauseInput, QuerySortClauseInput, QuerySearchInput } from '@/api/gql/generated' import { usePaginatedEntityQuery } from './usePaginatedEntityQuery' export type UsePatientsPaginatedOptions = { pagination: { pageIndex: number, pageSize: number }, - sorting?: SortInput[], - filtering?: FilterInput[], + sorts?: QuerySortClauseInput[], + filters?: QueryFilterClauseInput[], + search?: QuerySearchInput, + skip?: boolean, } export type UsePatientsPaginatedResult = { @@ -45,9 +47,11 @@ export function usePatientsPaginated( variables, { pagination: options.pagination, - sorting: options.sorting, - filtering: options.filtering, + sorts: options.sorts, + filters: options.filters, + search: options.search, getPageDataKey, + skip: options.skip, }, (data) => data?.patients ?? [], (data) => data?.patientsTotal diff --git a/web/data/hooks/useQueryableFields.ts b/web/data/hooks/useQueryableFields.ts new file mode 100644 index 00000000..78484080 --- /dev/null +++ b/web/data/hooks/useQueryableFields.ts @@ -0,0 +1,14 @@ +import { useQueryWhenReady } from './queryHelpers' +import { + QueryableFieldsDocument, + type QueryableFieldsQuery, + type QueryableFieldsQueryVariables +} from '@/api/gql/generated' + +export function useQueryableFields(entity: string) { + return useQueryWhenReady( + QueryableFieldsDocument, + { entity }, + { fetchPolicy: 'cache-first' } + ) +} diff --git a/web/data/hooks/useSavedViews.ts b/web/data/hooks/useSavedViews.ts new file mode 100644 index 00000000..192617e3 --- /dev/null +++ b/web/data/hooks/useSavedViews.ts @@ -0,0 +1,45 @@ +import { useApolloClient, useQuery } from '@apollo/client/react' +import { useEffect, useMemo } from 'react' +import { + MySavedViewsDocument, + SavedViewDocument, + type MySavedViewsQuery, + type MySavedViewsQueryVariables, + type SavedViewQuery, + type SavedViewQueryVariables +} from '@/api/gql/generated' +import { schedulePersistCache } from '../cache/persist' +import { getParsedDocument, useQueryWhenReady } from './queryHelpers' + +type MySavedViewsHookOptions = { + skip?: boolean, + fetchPolicy?: 'cache-first' | 'cache-and-network' | 'network-only', +} + +export function useMySavedViews(options?: MySavedViewsHookOptions) { + const client = useApolloClient() + const doc = useMemo(() => getParsedDocument(MySavedViewsDocument), []) + const result = useQuery(doc, { + variables: {}, + skip: options?.skip, + fetchPolicy: options?.fetchPolicy ?? 'cache-first', + }) + useEffect(() => { + if (!result.data?.mySavedViews || typeof window === 'undefined') return + schedulePersistCache(client.cache) + }, [result.data, client]) + return { + data: result.data, + loading: result.loading, + error: result.error as Error | undefined, + refetch: result.refetch, + } +} + +export function useSavedView(id: string | undefined, options?: { skip?: boolean }) { + return useQueryWhenReady( + SavedViewDocument, + { id: id ?? '' }, + { skip: options?.skip ?? !id } + ) +} diff --git a/web/data/hooks/useTaskPreset.ts b/web/data/hooks/useTaskPreset.ts new file mode 100644 index 00000000..4109510b --- /dev/null +++ b/web/data/hooks/useTaskPreset.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@apollo/client/react' +import { + TaskPresetDocument, + type TaskPresetQuery, + type TaskPresetQueryVariables +} from '@/api/gql/generated' +import { getParsedDocument } from './queryHelpers' + +export function useTaskPreset(id: string | null | undefined) { + return useQuery( + getParsedDocument(TaskPresetDocument), + { + variables: { id: id ?? '' }, + skip: !id, + } + ) +} diff --git a/web/data/hooks/useTaskPresets.ts b/web/data/hooks/useTaskPresets.ts new file mode 100644 index 00000000..2ac62ad5 --- /dev/null +++ b/web/data/hooks/useTaskPresets.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@apollo/client/react' +import { + TaskPresetsDocument, + type TaskPresetsQuery, + type TaskPresetsQueryVariables +} from '@/api/gql/generated' +import { getParsedDocument } from './queryHelpers' + +export function useTaskPresets() { + return useQuery( + getParsedDocument(TaskPresetsDocument) + ) +} diff --git a/web/data/hooks/useTasksPaginated.ts b/web/data/hooks/useTasksPaginated.ts index 2b5c853d..5fa1db98 100644 --- a/web/data/hooks/useTasksPaginated.ts +++ b/web/data/hooks/useTasksPaginated.ts @@ -3,13 +3,14 @@ import { type GetTasksQuery, type GetTasksQueryVariables } from '@/api/gql/generated' -import type { FilterInput, SortInput } from '@/api/gql/generated' +import type { QueryFilterClauseInput, QuerySortClauseInput, QuerySearchInput } from '@/api/gql/generated' import { usePaginatedEntityQuery } from './usePaginatedEntityQuery' export type UseTasksPaginatedOptions = { pagination: { pageIndex: number, pageSize: number }, - sorting?: SortInput[], - filtering?: FilterInput[], + sorts?: QuerySortClauseInput[], + filters?: QueryFilterClauseInput[], + search?: QuerySearchInput, } export type UseTasksPaginatedResult = { @@ -33,8 +34,9 @@ export function useTasksPaginated( variables, { pagination: options.pagination, - sorting: options.sorting, - filtering: options.filtering, + sorts: options.sorts, + filters: options.filters, + search: options.search, }, (data) => data?.tasks ?? [], (data) => data?.tasksTotal diff --git a/web/data/hooks/useUnassignTask.ts b/web/data/hooks/useUnassignTask.ts index 2135680a..17a074b8 100644 --- a/web/data/hooks/useUnassignTask.ts +++ b/web/data/hooks/useUnassignTask.ts @@ -1,15 +1,15 @@ import { useMutation } from '@apollo/client/react' import { - UnassignTaskDocument, - type UnassignTaskMutation, - type UnassignTaskMutationVariables + RemoveTaskAssigneeDocument, + type RemoveTaskAssigneeMutation, + type RemoveTaskAssigneeMutationVariables } from '@/api/gql/generated' import { getParsedDocument } from './queryHelpers' export function useUnassignTask() { const [mutate, result] = useMutation< - UnassignTaskMutation, - UnassignTaskMutationVariables - >(getParsedDocument(UnassignTaskDocument)) + RemoveTaskAssigneeMutation, + RemoveTaskAssigneeMutationVariables + >(getParsedDocument(RemoveTaskAssigneeDocument)) return [mutate, result] as const } diff --git a/web/data/hooks/useUpdateTaskPreset.ts b/web/data/hooks/useUpdateTaskPreset.ts new file mode 100644 index 00000000..aba4cfd9 --- /dev/null +++ b/web/data/hooks/useUpdateTaskPreset.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@apollo/client/react' +import { + UpdateTaskPresetDocument, + type UpdateTaskPresetMutation, + type UpdateTaskPresetMutationVariables +} from '@/api/gql/generated' +import { getParsedDocument } from './queryHelpers' + +export function useUpdateTaskPreset() { + return useMutation< + UpdateTaskPresetMutation, + UpdateTaskPresetMutationVariables + >(getParsedDocument(UpdateTaskPresetDocument)) +} diff --git a/web/data/index.ts b/web/data/index.ts index 6ce1ae9b..2f046790 100644 --- a/web/data/index.ts +++ b/web/data/index.ts @@ -50,6 +50,7 @@ export { useMyTasks, useTasksPaginated, usePatientsPaginated, + useQueryableFields, useCreateTask, useUpdateTask, useDeleteTask, @@ -70,6 +71,14 @@ export { useUpdatePropertyDefinition, useDeletePropertyDefinition, useUpdateProfilePicture, + useMySavedViews, + useSavedView, + useTaskPresets, + useTaskPreset, + useCreateTaskPreset, + useUpdateTaskPreset, + useDeleteTaskPreset, + useApplyTaskGraph, } from './hooks' export type { ClientMutationId, diff --git a/web/data/subscriptions/useApolloGlobalSubscriptions.ts b/web/data/subscriptions/useApolloGlobalSubscriptions.ts index dbe22ba5..bcc56e17 100644 --- a/web/data/subscriptions/useApolloGlobalSubscriptions.ts +++ b/web/data/subscriptions/useApolloGlobalSubscriptions.ts @@ -1,6 +1,8 @@ import { useEffect, useRef } from 'react' import { parse } from 'graphql' import type { ApolloClient } from '@apollo/client/core' +import { GetPatientsDocument, GetTasksDocument } from '@/api/gql/generated' +import { getParsedDocument } from '@/data/hooks/queryHelpers' import { mergeTaskUpdatedIntoCache, mergePatientUpdatedIntoCache, @@ -101,7 +103,9 @@ export function useApolloGlobalSubscriptions( await mergeTaskUpdatedIntoCache(client, taskId, payloadObj, optionsRef.current).catch( () => {} ) - client.refetchQueries({ include: 'active' }) + await client.refetchQueries({ + include: [getParsedDocument(GetPatientsDocument)], + }) } finally { removeRefreshingTask(taskId) } @@ -132,7 +136,9 @@ export function useApolloGlobalSubscriptions( await mergePatientUpdatedIntoCache(client, patientId, payloadObj, optionsRef.current).catch( () => {} ) - client.refetchQueries({ include: 'active' }) + await client.refetchQueries({ + include: [getParsedDocument(GetTasksDocument)], + }) } finally { removeRefreshingPatient(patientId) } @@ -163,7 +169,9 @@ export function useApolloGlobalSubscriptions( await mergePatientUpdatedIntoCache(client, patientId, payloadObj, optionsRef.current).catch( () => {} ) - client.refetchQueries({ include: 'active' }) + await client.refetchQueries({ + include: [getParsedDocument(GetTasksDocument)], + }) } finally { removeRefreshingPatient(patientId) } diff --git a/web/globals.css b/web/globals.css index f12e4b41..1d8e81f0 100644 --- a/web/globals.css +++ b/web/globals.css @@ -1,11 +1,21 @@ @import 'tailwindcss'; -@import "@helpwave/hightide/style/uncompiled/globals.css"; +@import "./node_modules/@helpwave/hightide/dist/style/uncompiled/globals.css"; @import "./style/index.css"; @source "./node_modules/@helpwave/hightide"; @layer components { body { - @apply w-screen max-w-screen h-screen max-h-screen overflow-hidden; + @apply w-screen max-w-screen min-h-dvh h-dvh max-h-dvh overflow-hidden; + padding-top: max(0px, env(safe-area-inset-top)); + padding-bottom: max(0px, env(safe-area-inset-bottom)); + padding-left: max(0px, env(safe-area-inset-left)); + padding-right: max(0px, env(safe-area-inset-right)); + } +} + +@layer utilities { + .touch-pan-x { + -webkit-overflow-scrolling: touch; } } diff --git a/web/hooks/useAccumulatedPagination.ts b/web/hooks/useAccumulatedPagination.ts new file mode 100644 index 00000000..d94678ce --- /dev/null +++ b/web/hooks/useAccumulatedPagination.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export function useAccumulatedPagination(options: { + resetKey: string, + pageData: T[] | undefined, + pageIndex: number, + setPageIndex: React.Dispatch>, + totalCount: number | undefined, + loading: boolean, +}): { + accumulated: T[], + loadMore: () => void, + hasMore: boolean, +} { + const { resetKey, pageData, pageIndex, setPageIndex, totalCount, loading } = options + const [accumulated, setAccumulated] = useState([]) + const prevResetKeyRef = useRef(resetKey) + + useEffect(() => { + if (prevResetKeyRef.current !== resetKey) { + prevResetKeyRef.current = resetKey + setPageIndex(0) + setAccumulated([]) + } + }, [resetKey, setPageIndex]) + + useEffect(() => { + if (pageData === undefined || loading) return + if (pageIndex === 0) { + setAccumulated(pageData) + return + } + setAccumulated(prev => { + const ids = new Set(prev.map(x => x.id)) + const next = [...prev] + for (const item of pageData) { + if (!ids.has(item.id)) next.push(item) + } + return next + }) + }, [pageData, pageIndex, loading]) + + const loadMore = useCallback(() => { + setPageIndex(i => i + 1) + }, [setPageIndex]) + + const hasMore = totalCount != null && accumulated.length < totalCount + + return { accumulated, loadMore, hasMore } +} diff --git a/web/hooks/useAuth.tsx b/web/hooks/useAuth.tsx index 059b6ea2..63eb49cf 100644 --- a/web/hooks/useAuth.tsx +++ b/web/hooks/useAuth.tsx @@ -143,7 +143,7 @@ export const AuthProvider = ({ }, []) const authLoadingContent = ( -
+
) diff --git a/web/hooks/useDeferredColumnOrderChange.ts b/web/hooks/useDeferredColumnOrderChange.ts new file mode 100644 index 00000000..78a64268 --- /dev/null +++ b/web/hooks/useDeferredColumnOrderChange.ts @@ -0,0 +1,46 @@ +import type { Dispatch, SetStateAction } from 'react' +import { useCallback, useEffect, useRef, startTransition } from 'react' +import type { ColumnOrderState } from '@tanstack/table-core' + +export function useDeferredColumnOrderChange( + setColumnOrder: Dispatch> +): Dispatch> { + const mountedRef = useRef(false) + const pendingRef = useRef[]>([]) + + useEffect(() => { + mountedRef.current = true + const pending = pendingRef.current + pendingRef.current = [] + for (const u of pending) { + startTransition(() => { + setColumnOrder(u) + }) + } + return () => { + mountedRef.current = false + pendingRef.current = [] + } + }, [setColumnOrder]) + + return useCallback( + (updater: SetStateAction) => { + if (typeof window === 'undefined') { + return + } + if (!mountedRef.current) { + pendingRef.current.push(updater) + return + } + window.setTimeout(() => { + if (!mountedRef.current) { + return + } + startTransition(() => { + setColumnOrder(updater) + }) + }, 0) + }, + [setColumnOrder] + ) +} diff --git a/web/hooks/usePropertyColumnVisibility.ts b/web/hooks/usePropertyColumnVisibility.ts index 09d80f5f..27a3555d 100644 --- a/web/hooks/usePropertyColumnVisibility.ts +++ b/web/hooks/usePropertyColumnVisibility.ts @@ -1,7 +1,8 @@ -import { useEffect } from 'react' +import { useCallback, useMemo } from 'react' import type { Dispatch, SetStateAction } from 'react' import type { VisibilityState } from '@tanstack/react-table' import type { PropertyEntity } from '@/api/gql/generated' +import { normalizedVisibilityForViewCompare } from '@/utils/viewDefinition' type PropertyDefinitionsData = { propertyDefinitions?: Array<{ @@ -11,30 +12,49 @@ type PropertyDefinitionsData = { }>, } | null | undefined -export function usePropertyColumnVisibility( +export function getPropertyColumnIds( + propertyDefinitionsData: PropertyDefinitionsData, + entity: PropertyEntity +): string[] { + if (!propertyDefinitionsData?.propertyDefinitions) return [] + const entityValue = entity as string + return propertyDefinitionsData.propertyDefinitions + .filter(def => def.isActive && def.allowedEntities.includes(entityValue)) + .map(prop => `property_${prop.id}`) +} + +export function useColumnVisibilityWithPropertyDefaults( propertyDefinitionsData: PropertyDefinitionsData, entity: PropertyEntity, - columnVisibility: VisibilityState, setColumnVisibility: Dispatch> -): void { - useEffect(() => { - if (!propertyDefinitionsData?.propertyDefinitions) return - - const entityValue = entity as string - const properties = propertyDefinitionsData.propertyDefinitions.filter( - def => def.isActive && def.allowedEntities.includes(entityValue) - ) - const propertyColumnIds = properties.map(prop => `property_${prop.id}`) - const hasPropertyColumnsInVisibility = propertyColumnIds.some( - id => id in columnVisibility - ) +): Dispatch> { + const propertyColumnIds = useMemo( + () => getPropertyColumnIds(propertyDefinitionsData, entity), + [propertyDefinitionsData, entity] + ) - if (!hasPropertyColumnsInVisibility && propertyColumnIds.length > 0) { - const initialVisibility: VisibilityState = { ...columnVisibility } - propertyColumnIds.forEach(id => { - initialVisibility[id] = false + return useCallback( + (updater: SetStateAction) => { + setColumnVisibility(prev => { + const next = typeof updater === 'function' + ? (updater as (p: VisibilityState) => VisibilityState)(prev) + : updater + if (propertyColumnIds.length === 0) { + return normalizedVisibilityForViewCompare(next) === normalizedVisibilityForViewCompare(prev) + ? prev + : next + } + const merged: VisibilityState = { ...next } + for (const id of propertyColumnIds) { + if (!(id in merged)) { + merged[id] = false + } + } + return normalizedVisibilityForViewCompare(merged) === normalizedVisibilityForViewCompare(prev) + ? prev + : merged }) - setColumnVisibility(initialVisibility) - } - }, [propertyDefinitionsData, entity, columnVisibility, setColumnVisibility]) + }, + [propertyColumnIds, setColumnVisibility] + ) } diff --git a/web/hooks/useStableSerializedList.ts b/web/hooks/useStableSerializedList.ts new file mode 100644 index 00000000..aafb9c89 --- /dev/null +++ b/web/hooks/useStableSerializedList.ts @@ -0,0 +1,19 @@ +import { useRef } from 'react' + +export function useStableSerializedList( + list: readonly T[] | undefined | null, + serializeItem: (item: T) => unknown +): T[] | undefined { + const ref = useRef<{ key: string, list: T[] | undefined }>({ key: '', list: undefined }) + if (!list?.length) { + ref.current = { key: '', list: undefined } + return undefined + } + const key = JSON.stringify(list.map(serializeItem)) + if (ref.current.key === key) { + return ref.current.list + } + const next = [...list] as T[] + ref.current = { key, list: next } + return next +} diff --git a/web/hooks/useTableState.ts b/web/hooks/useTableState.ts index 9acb60d4..5046d44e 100644 --- a/web/hooks/useTableState.ts +++ b/web/hooks/useTableState.ts @@ -1,12 +1,11 @@ import type { ColumnFiltersState, + ColumnOrderState, PaginationState, SortingState, VisibilityState } from '@tanstack/react-table' -import type { Dispatch, SetStateAction } from 'react' -import type { DateFilterParameter, DatetimeFilterParameter, TableFilterValue } from '@helpwave/hightide' -import { useStorage } from '@/hooks/useStorage' +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react' const defaultPagination: PaginationState = { pageSize: 10, @@ -16,12 +15,14 @@ const defaultPagination: PaginationState = { const defaultSorting: SortingState = [] const defaultFilters: ColumnFiltersState = [] const defaultColumnVisibility: VisibilityState = {} +const defaultColumnOrder: ColumnOrderState = [] export type UseTableStateOptions = { defaultSorting?: SortingState, defaultPagination?: PaginationState, defaultFilters?: ColumnFiltersState, defaultColumnVisibility?: VisibilityState, + defaultColumnOrder?: ColumnOrderState, } export type UseTableStateResult = { @@ -33,98 +34,56 @@ export type UseTableStateResult = { setFilters: Dispatch>, columnVisibility: VisibilityState, setColumnVisibility: Dispatch>, + columnOrder: ColumnOrderState, + setColumnOrder: Dispatch>, } -export function useStorageSyncedTableState( - storageKeyPrefix: string, - options: UseTableStateOptions = {} -): UseTableStateResult { +export function useTableState(options: UseTableStateOptions = {}): UseTableStateResult { const { defaultSorting: initialSorting = defaultSorting, defaultPagination: initialPagination = defaultPagination, defaultFilters: initialFilters = defaultFilters, defaultColumnVisibility: initialColumnVisibility = defaultColumnVisibility, + defaultColumnOrder: initialColumnOrder = defaultColumnOrder, } = options - const { value: pagination, setValue: setPagination } = useStorage({ - key: `${storageKeyPrefix}-column-pagination`, - defaultValue: initialPagination, - }) - const { value: sorting, setValue: setSorting } = useStorage({ - key: `${storageKeyPrefix}-column-sorting`, - defaultValue: initialSorting, - }) - const { value: filters, setValue: setFilters } = useStorage({ - key: `${storageKeyPrefix}-column-filters`, - defaultValue: initialFilters, - serialize: (value) => { - const mappedColumnFilter = value.map((filter) => { - const tableFilterValue = filter.value as TableFilterValue - let parameter: Record = tableFilterValue.parameter - if(tableFilterValue.operator.startsWith('dateTime')) { - const dateTimeParameter: DatetimeFilterParameter = parameter as DatetimeFilterParameter - parameter = { - ...parameter, - compareDatetime: dateTimeParameter.compareDatetime ? dateTimeParameter.compareDatetime.toISOString() : undefined, - min: dateTimeParameter.min ? dateTimeParameter.min.toISOString() : undefined, - max: dateTimeParameter.max ? dateTimeParameter.max.toISOString() : undefined, - } - } else if(tableFilterValue.operator.startsWith('date')) { - const dateParameter: DateFilterParameter = parameter as DateFilterParameter - parameter = { - ...parameter, - compareDate: dateParameter.compareDate ? dateParameter.compareDate.toISOString() : undefined, - min: dateParameter.min ? dateParameter.min.toISOString() : undefined, - max: dateParameter.max ? dateParameter.max.toISOString() : undefined, - } - } - return { - ...filter, - id: filter.id, - value: { - ...tableFilterValue, - parameter, - }, - } - }) - return JSON.stringify(mappedColumnFilter) - }, - deserialize: (value) => { - const mappedColumnFilter = JSON.parse(value) as Record[] - return mappedColumnFilter.map((filter) => { - const filterValue = filter['value'] as Record - const operator: string = filterValue['operator'] as string - let parameter: Record = filterValue['parameter'] as Record - if(operator.startsWith('dateTime')) { - parameter = { - ...parameter, - compareDatetime: parameter['compareDatetime'] ? new Date(parameter['compareDatetime'] as string) : undefined, - min: parameter['min'] ? new Date(parameter['min'] as string) : undefined, - max: parameter['max'] ? new Date(parameter['max'] as string) : undefined, - } - } - else if(operator.startsWith('date')) { - parameter = { - ...parameter, - compareDate: parameter['compareDate'] ? new Date(parameter['compareDate'] as string) : undefined, - min: parameter['min'] ? new Date(parameter['min'] as string) : undefined, - max: parameter['max'] ? new Date(parameter['max'] as string) : undefined, - } - } - return { - ...filter, - value: { - ...filterValue, - parameter, - }, - } - }) as unknown as ColumnFiltersState - }, - }) - const { value: columnVisibility, setValue: setColumnVisibility } = useStorage({ - key: `${storageKeyPrefix}-column-visibility`, - defaultValue: initialColumnVisibility, - }) + const [pagination, setPagination] = useState(initialPagination) + const [sorting, setSorting] = useState(initialSorting) + const [filters, setFilters] = useState(initialFilters) + const [columnVisibility, setColumnVisibility] = useState(initialColumnVisibility) + const [columnOrder, setColumnOrder] = useState(initialColumnOrder) + + return { + pagination, + setPagination, + sorting, + setSorting, + filters, + setFilters, + columnVisibility, + setColumnVisibility, + columnOrder, + setColumnOrder, + } +} + +export function useRecentOverviewTableState( + options: Pick = {} +): UseTableStateResult { + const { + defaultPagination: initialPagination = defaultPagination, + defaultColumnVisibility: initialColumnVisibility = defaultColumnVisibility, + defaultColumnOrder: initialColumnOrder = defaultColumnOrder, + } = options + + const [pagination, setPagination] = useState(initialPagination) + const [columnVisibility, setColumnVisibility] = useState(initialColumnVisibility) + const [columnOrder, setColumnOrder] = useState(initialColumnOrder) + + const sorting = useMemo(() => [] as SortingState, []) + const filters = useMemo(() => [] as ColumnFiltersState, []) + const setSorting = useCallback((_u: SetStateAction) => undefined, []) + const setFilters = useCallback((_u: SetStateAction) => undefined, []) return { pagination, @@ -135,5 +94,7 @@ export function useStorageSyncedTableState( setFilters, columnVisibility, setColumnVisibility, + columnOrder, + setColumnOrder, } } diff --git a/web/hooks/useTasksContext.tsx b/web/hooks/useTasksContext.tsx index 098e449a..20fc9b20 100644 --- a/web/hooks/useTasksContext.tsx +++ b/web/hooks/useTasksContext.tsx @@ -78,6 +78,7 @@ type SidebarContextType = { isShowingTeams: boolean, isShowingWards: boolean, isShowingClinics: boolean, + isShowingSavedViews: boolean, } export type TasksContextState = { @@ -132,6 +133,7 @@ export const TasksContextProvider = ({ children }: PropsWithChildren) => { isShowingTeams: false, isShowingWards: false, isShowingClinics: false, + isShowingSavedViews: false, }, selectedRootLocationIds: storedSelectedRootLocationIds.length > 0 ? storedSelectedRootLocationIds : undefined, isRootLocationReinitializing: false, diff --git a/web/i18n/translations.ts b/web/i18n/translations.ts index f2ec590f..ba0855c1 100644 --- a/web/i18n/translations.ts +++ b/web/i18n/translations.ts @@ -13,6 +13,9 @@ export type TasksTranslationEntries = { 'account': string, 'active': string, 'add': string, + 'addFastAccess': string, + 'addFastAccessDescription': string, + 'additionalAssigneesCount': (values: { count: number }) => string, 'addPatient': string, 'addProperty': string, 'addTask': string, @@ -22,6 +25,7 @@ export type TasksTranslationEntries = { 'archivedPropertyDescription': string, 'archiveProperty': string, 'assignedTo': string, + 'assigneeTeam': string, 'authenticationFailed': string, 'birthdate': string, 'cancel': string, @@ -33,6 +37,7 @@ export type TasksTranslationEntries = { 'closedTasks': string, 'collapseAll': string, 'confirm': string, + 'confirmDeleteView': string, 'confirmSelection': string, 'confirmShiftHandover': string, 'confirmShiftHandoverDescription': string, @@ -41,8 +46,12 @@ export type TasksTranslationEntries = { 'connectionConnected': string, 'connectionConnecting': string, 'connectionDisconnected': string, + 'copyShareLink': string, + 'copyShareLinkEnableSharingFirst': string, + 'copyViewToMyViews': string, 'create': string, 'createTask': string, + 'creationDate': string, 'currentTime': string, 'dashboard': string, 'dashboardWelcomeAfternoon': (values: { name: string }) => string, @@ -58,6 +67,7 @@ export type TasksTranslationEntries = { 'descriptionPlaceholder': string, 'deselectAll': string, 'developmentAndPreviewInstance': string, + 'discardViewChanges': string, 'dischargePatient': string, 'dischargePatientConfirmation': string, 'dismiss': string, @@ -65,6 +75,7 @@ export type TasksTranslationEntries = { 'diverse': string, 'done': string, 'dueDate': string, + 'duplicate': string, 'edit': string, 'editPatient': string, 'editTask': string, @@ -76,10 +87,12 @@ export type TasksTranslationEntries = { 'feedback': string, 'feedbackDescription': string, 'female': string, + 'filter': string, 'filterAll': string, 'filterUndone': string, 'firstName': string, 'freeBeds': string, + 'hide': string, 'homePage': string, 'imprint': string, 'inactive': string, @@ -88,7 +101,13 @@ export type TasksTranslationEntries = { 'installAppDescription': string, 'language': string, 'lastName': string, + 'listViewCard': string, + 'listViewTable': string, 'loading': string, + 'loadMore': string, + 'loadTaskPreset': string, + 'loadTaskPresetConfirm': (values: { count: number }) => string, + 'loadTaskPresetTitle': string, 'location': string, 'locationBed': string, 'locationClinic': string, @@ -103,26 +122,32 @@ export type TasksTranslationEntries = { 'markPatientDead': string, 'markPatientDeadConfirmation': string, 'menu': string, + 'more': string, 'myFavorites': string, 'myOpenTasks': string, 'myTasks': string, 'name': string, + 'nFilter': (values: { count: number }) => string, 'no': string, 'noClosedTasks': string, 'noLocationsFound': string, + 'none': string, 'noNotifications': string, 'noOpenTasks': string, 'noPatient': string, + 'noPatientsInTaskView': string, 'noResultsFound': string, 'notAssigned': string, 'notifications': string, 'nPatient': (values: { count: number }) => string, + 'nSorting': (values: { count: number }) => string, 'nTask': (values: { count: number }) => string, 'nYears': (values: { years: number }) => string, 'occupancy': string, 'ok': string, 'openSurvey': string, 'openTasks': string, + 'openView': string, 'option': string, 'organizations': string, 'overview': string, @@ -149,9 +174,11 @@ export type TasksTranslationEntries = { 'priorityNone': string, 'privacy': string, 'properties': string, + 'propertiesSettingsDescription': string, 'property': string, 'pThemes': (values: { count: number }) => string, 'rAdd': (values: { name: string }) => string, + 'readOnlyView': string, 'recentPatients': string, 'recentTasks': string, 'rEdit': (values: { name: string }) => string, @@ -160,6 +187,12 @@ export type TasksTranslationEntries = { 'removePropertyConfirmation': string, 'retakeSurvey': string, 'rShow': (values: { name: string }) => string, + 'savedViews': string, + 'saveView': string, + 'saveViewAsNew': string, + 'saveViewDescription': string, + 'saveViewDescriptionFromSystemList': string, + 'saveViewOverwriteCurrent': string, 'search': string, 'searchLocations': string, 'searchUserOrTeam': string, @@ -181,6 +214,7 @@ export type TasksTranslationEntries = { 'shiftHandover': string, 'showAllTasks': string, 'showTeamTasks': string, + 'sorting': string, 'sPropertySubjectType': (values: { subject: string }) => string, 'sPropertyType': (values: { type: string }) => string, 'stagingModalDisclaimerMarkdown': string, @@ -195,7 +229,34 @@ export type TasksTranslationEntries = { 'surveyTitle': string, 'system': string, 'task': string, + 'taskFromPresetTooltip': string, + 'taskPresetAddRow': string, + 'taskPresetApplyToRow': string, + 'taskPresetCreate': string, + 'taskPresetDelete': string, + 'taskPresetDeleteConfirm': string, + 'taskPresetEdit': string, + 'taskPresetEditDetails': string, + 'taskPresetEmptyList': string, + 'taskPresetKey': string, + 'taskPresetKeyHint': string, + 'taskPresetName': string, + 'taskPresetNoEstimate': string, + 'taskPresetNoTasks': string, + 'taskPresetRemoveRow': string, + 'taskPresets': string, + 'taskPresetSave': string, + 'taskPresetScope': string, + 'taskPresetScopeGlobal': string, + 'taskPresetScopePersonal': string, + 'taskPresetsDescription': string, + 'taskPresetSelectPlaceholder': string, + 'taskPresetSourceNotFound': string, + 'taskPresetSourceOpenSettings': string, + 'taskPresetSourceTasksInGraph': string, + 'taskPresetSourceTitle': string, 'tasks': string, + 'tasksCreatedFromPreset': string, 'tasksUpdatedRecently': string, 'taskTitlePlaceholder': string, 'teams': string, @@ -205,8 +266,19 @@ export type TasksTranslationEntries = { 'type': string, 'updated': string, 'url': string, + 'user': string, 'userInformation': string, 'users': string, + 'viewAnyoneWithLinkCanView': string, + 'viewDerivedPatientsHint': string, + 'views': string, + 'viewsEntityPatient': string, + 'viewsEntityTask': string, + 'viewSettings': string, + 'viewSettingsDescription': string, + 'viewVisibility': string, + 'viewVisibilityLinkShared': string, + 'viewVisibilityPrivate': string, 'waitingForPatient': string, 'waitPatient': string, 'wards': string, @@ -218,6 +290,11 @@ export const tasksTranslation: Translation { + return `+${count}` + }, 'addPatient': `Patient hinzufügen`, 'addProperty': `Eigenschaften hinzufügen`, 'addTask': `Aufgabe hinzufügen`, @@ -227,6 +304,7 @@ export const tasksTranslation: Translation { @@ -282,6 +365,7 @@ export const tasksTranslation: Translation { + let _out: string = '' + _out += TranslationGen.resolvePlural(count, { + '=1': `${count} Aufgabe`, + 'other': `${count} Aufgaben`, + }) + _out += ` für diesen Patienten anlegen?` + return _out + }, + 'loadTaskPresetTitle': `Aufgaben-Vorlage laden`, 'location': `Ort`, 'locationBed': `Bett`, 'locationClinic': `Klinik`, @@ -336,16 +437,25 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} Filter`, + 'other': `${count} Filter`, + }) + }, 'no': `Nein`, 'noClosedTasks': `Keine erledigten Aufgaben`, 'noLocationsFound': `Keine Standorte gefunden`, + 'none': `Keine`, 'noNotifications': `Keine aktuellen Updates`, 'noOpenTasks': `Keine offenen Aufgaben`, 'noPatient': `Kein Patient`, + 'noPatientsInTaskView': `Keine Patienten für diesen Aufgaben-Schnellzugriff.`, 'noResultsFound': `Keine Ergebnisse gefunden`, 'notAssigned': `Nicht zugewiesen`, 'notifications': `Benachrichtigungen`, @@ -355,6 +465,12 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} Sortierung`, + 'other': `${count} Sortierungen`, + }) + }, 'nTask': ({ count }): string => { return TranslationGen.resolvePlural(count, { '=1': `${count} Aufgabe`, @@ -372,6 +488,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolvePlural(count, { @@ -424,6 +542,7 @@ export const tasksTranslation: Translation { return `${name} hinzufügen` }, + 'readOnlyView': `Nur lesen`, 'recentPatients': `Deine kürzlichen Patienten`, 'recentTasks': `Deine kürzlichen Aufgaben`, 'rEdit': ({ name }): string => { @@ -436,6 +555,12 @@ export const tasksTranslation: Translation { return `${name} anzeigen` }, + 'savedViews': `Schnellzugriff`, + 'saveView': `Schnellzugriff speichern`, + 'saveViewAsNew': `Als neuen Schnellzugriff speichern`, + 'saveViewDescription': `Benennen Sie diesen Schnellzugriff. Filter, Sortierung, Suche und Standort werden gespeichert.`, + 'saveViewDescriptionFromSystemList': `Legt einen neuen Schnellzugriff aus diesem Layout an. Diese Seite selbst wird nicht überschrieben und bleibt für alle gleich — den Schnellzugriff öffnen Sie bei Bedarf in der Seitenleiste.`, + 'saveViewOverwriteCurrent': `Aktuellen Schnellzugriff überschreiben`, 'search': `Suchen`, 'searchLocations': `Standorte suchen...`, 'searchUserOrTeam': `Nach Benutzer (oder Team) suchen...`, @@ -457,6 +582,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(subject, { 'patient': `Patient`, @@ -491,7 +617,34 @@ export const tasksTranslation: Translation { + return `+${count}` + }, 'addPatient': `Add Patient`, 'addProperty': `Add Property`, 'addTask': `Add Task`, @@ -528,6 +697,7 @@ export const tasksTranslation: Translation { @@ -583,6 +758,7 @@ export const tasksTranslation: Translation { + let _out: string = '' + _out += `Create ` + _out += TranslationGen.resolvePlural(count, { + '=1': `${count} task`, + 'other': `${count} tasks`, + }) + _out += ` for this patient?` + return _out + }, + 'loadTaskPresetTitle': `Load task preset`, 'location': `Location`, 'locationBed': `Bed`, 'locationClinic': `Clinic`, @@ -637,16 +831,25 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} filter`, + 'other': `${count} filters`, + }) + }, 'no': `No`, 'noClosedTasks': `No closed tasks`, 'noLocationsFound': `No locations found`, + 'none': `None`, 'noNotifications': `No recent updates`, 'noOpenTasks': `No open tasks`, 'noPatient': `No Patient`, + 'noPatientsInTaskView': `No patients found for this task quick access.`, 'noResultsFound': `No results found`, 'notAssigned': `Not assigned`, 'notifications': `Notifications`, @@ -656,6 +859,12 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} sort`, + 'other': `${count} sorts`, + }) + }, 'nTask': ({ count }): string => { return TranslationGen.resolvePlural(count, { '=1': `${count} Task`, @@ -673,6 +882,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolvePlural(count, { @@ -725,10 +936,11 @@ export const tasksTranslation: Translation { return `Add ${name}` }, + 'readOnlyView': `Read-only`, 'recentPatients': `Your Recent Patients`, 'recentTasks': `Your Recent Tasks`, 'rEdit': ({ name }): string => { - return `Update ${name}!` + return `Update ${name}` }, 'refreshing': `Refreshing…`, 'removeProperty': `Remove Property`, @@ -737,6 +949,12 @@ export const tasksTranslation: Translation { return `Show ${name}` }, + 'savedViews': `Quick access`, + 'saveView': `Save quick access`, + 'saveViewAsNew': `Save as new quick access`, + 'saveViewDescription': `Name this quick access. Filters, sorting, search, and location are stored.`, + 'saveViewDescriptionFromSystemList': `Creates a new quick access entry from this layout. This page is fixed by the app and is not saved in place — open it from the sidebar when you need it.`, + 'saveViewOverwriteCurrent': `Overwrite current quick access`, 'search': `Search`, 'searchLocations': `Search locations...`, 'searchUserOrTeam': `Search for user (or team)...`, @@ -758,6 +976,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(subject, { 'patient': `Patient`, @@ -792,7 +1011,34 @@ export const tasksTranslation: Translation { + return `+${count}` + }, 'addPatient': `Añadir paciente`, 'addProperty': `Añadir propiedad`, 'addTask': `Añadir tarea`, @@ -829,6 +1091,7 @@ export const tasksTranslation: Translation { @@ -884,6 +1152,7 @@ export const tasksTranslation: Translation { + let _out: string = '' + _out += `Create ` + _out += TranslationGen.resolvePlural(count, { + '=1': `${count} task`, + 'other': `${count} tasks`, + }) + _out += ` for this patient?` + return _out + }, + 'loadTaskPresetTitle': `Load task preset`, 'location': `Ubicación`, 'locationBed': `Cama`, 'locationClinic': `Clínica`, @@ -938,16 +1225,25 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} filtro`, + 'other': `${count} filtros`, + }) + }, 'no': `No`, 'noClosedTasks': `No hay tareas cerradas`, 'noLocationsFound': `No se encontraron ubicaciones`, + 'none': `Ninguno`, 'noNotifications': `Sin actualizaciones recientes`, 'noOpenTasks': `No hay tareas abiertas`, 'noPatient': `Sin paciente`, + 'noPatientsInTaskView': `No se encontraron pacientes para este acceso rápido de tareas.`, 'noResultsFound': `No se encontraron resultados`, 'notAssigned': `No asignado`, 'notifications': `Notificaciones`, @@ -957,6 +1253,12 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} ordenación`, + 'other': `${count} ordenaciones`, + }) + }, 'nTask': ({ count }): string => { return TranslationGen.resolvePlural(count, { '=1': `${count} Tarea`, @@ -974,6 +1276,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolvePlural(count, { @@ -1025,10 +1329,11 @@ export const tasksTranslation: Translation { return `Añadir ${name}` }, + 'readOnlyView': `Solo lectura`, 'recentPatients': `Tus pacientes recientes`, 'recentTasks': `Tus tareas recientes`, 'rEdit': ({ name }): string => { - return `Actualizar ${name}!` + return `Actualizar ${name}` }, 'refreshing': `Actualizando…`, 'removeProperty': `Eliminar propiedad`, @@ -1037,6 +1342,12 @@ export const tasksTranslation: Translation { return `Mostrar ${name}` }, + 'savedViews': `Acceso rápido`, + 'saveView': `Guardar acceso rápido`, + 'saveViewAsNew': `Guardar como acceso rápido nuevo`, + 'saveViewDescription': `Pon nombre a este acceso rápido. Se guardan filtros, ordenación, búsqueda y ámbito de ubicación.`, + 'saveViewDescriptionFromSystemList': `Crea un acceso rápido nuevo a partir de este diseño. Esta página está fijada por la aplicación y no se guarda en su sitio: ábralo desde la barra lateral cuando lo necesite.`, + 'saveViewOverwriteCurrent': `Sobrescribir acceso rápido actual`, 'search': `Buscar`, 'searchLocations': `Buscar ubicaciones...`, 'searchUserOrTeam': `Buscar usuario (o equipo)...`, @@ -1058,6 +1369,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(subject, { 'patient': `Paciente`, @@ -1092,7 +1404,34 @@ export const tasksTranslation: Translation { + return `+${count}` + }, 'addPatient': `Ajouter un patient`, 'addProperty': `Ajouter une propriété`, 'addTask': `Ajouter une tâche`, @@ -1129,6 +1484,7 @@ export const tasksTranslation: Translation { @@ -1184,6 +1545,7 @@ export const tasksTranslation: Translation { + let _out: string = '' + _out += `Create ` + _out += TranslationGen.resolvePlural(count, { + '=1': `${count} task`, + 'other': `${count} tasks`, + }) + _out += ` for this patient?` + return _out + }, + 'loadTaskPresetTitle': `Load task preset`, 'location': `Emplacement`, 'locationBed': `Lit`, 'locationClinic': `Clinique`, @@ -1238,16 +1618,25 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} filtre`, + 'other': `${count} filtres`, + }) + }, 'no': `Non`, 'noClosedTasks': `Aucune tâche terminée`, 'noLocationsFound': `Aucun emplacement trouvé`, + 'none': `Aucun`, 'noNotifications': `Aucune mise à jour récente`, 'noOpenTasks': `Aucune tâche ouverte`, 'noPatient': `Aucun patient`, + 'noPatientsInTaskView': `Aucun patient trouvé pour cet accès rapide de tâches.`, 'noResultsFound': `Aucun résultat trouvé`, 'notAssigned': `Non assigné`, 'notifications': `Notifications`, @@ -1257,6 +1646,12 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} tri`, + 'other': `${count} tris`, + }) + }, 'nTask': ({ count }): string => { return TranslationGen.resolvePlural(count, { '=1': `${count} Tâche`, @@ -1274,6 +1669,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolvePlural(count, { @@ -1325,10 +1722,11 @@ export const tasksTranslation: Translation { return `Ajouter ${name}` }, + 'readOnlyView': `Lecture seule`, 'recentPatients': `Vos patients récents`, 'recentTasks': `Vos tâches récentes`, 'rEdit': ({ name }): string => { - return `Mettre à jour ${name} !` + return `Mettre à jour ${name}` }, 'refreshing': `Actualisation…`, 'removeProperty': `Supprimer la propriété`, @@ -1337,6 +1735,12 @@ export const tasksTranslation: Translation { return `Afficher ${name}` }, + 'savedViews': `Accès rapide`, + 'saveView': `Enregistrer l'accès rapide`, + 'saveViewAsNew': `Enregistrer comme nouvel accès rapide`, + 'saveViewDescription': `Nommez cet accès rapide. Filtres, tri, recherche et périmètre de lieu sont enregistrés.`, + 'saveViewDescriptionFromSystemList': `Crée un nouvel accès rapide à partir de cette disposition. Cette page est fixée par l'application et n'est pas enregistrée en place — ouvrez-le depuis la barre latérale si besoin.`, + 'saveViewOverwriteCurrent': `Remplacer l'accès rapide actuel`, 'search': `Rechercher`, 'searchLocations': `Rechercher des emplacements...`, 'searchUserOrTeam': `Rechercher un utilisateur (ou une équipe)...`, @@ -1358,6 +1762,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(subject, { 'patient': `Patient`, @@ -1392,7 +1797,34 @@ export const tasksTranslation: Translation { + return `+${count}` + }, 'addPatient': `Patiënt toevoegen`, 'addProperty': `Eigenschap toevoegen`, 'addTask': `Taak toevoegen`, @@ -1429,6 +1877,7 @@ export const tasksTranslation: Translation { @@ -1484,6 +1938,7 @@ export const tasksTranslation: Translation { + let _out: string = '' + _out += `Create ` + _out += TranslationGen.resolvePlural(count, { + '=1': `${count} task`, + 'other': `${count} tasks`, + }) + _out += ` for this patient?` + return _out + }, + 'loadTaskPresetTitle': `Load task preset`, 'location': `Locatie`, 'locationBed': `Bed`, 'locationClinic': `Kliniek`, @@ -1538,16 +2011,25 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} filter`, + 'other': `${count} filters`, + }) + }, 'no': `Nee`, 'noClosedTasks': `Geen afgeronde taken`, 'noLocationsFound': `Geen locaties gevonden`, + 'none': `Geen`, 'noNotifications': `Geen recente updates`, 'noOpenTasks': `Geen open taken`, 'noPatient': `Geen patiënt`, + 'noPatientsInTaskView': `Geen patiënten gevonden voor deze taak-snelle toegang.`, 'noResultsFound': `Geen resultaten gevonden`, 'notAssigned': `Niet toegewezen`, 'notifications': `Meldingen`, @@ -1557,6 +2039,12 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} sortering`, + 'other': `${count} sorteringen`, + }) + }, 'nTask': ({ count }): string => { return TranslationGen.resolvePlural(count, { '=1': `${count} Taak`, @@ -1574,6 +2062,7 @@ export const tasksTranslation: Translation { let _out: string = '' @@ -1628,10 +2118,11 @@ export const tasksTranslation: Translation { return `${name} toevoegen` }, + 'readOnlyView': `Alleen-lezen`, 'recentPatients': `Uw recente patiënten`, 'recentTasks': `Uw recente taken`, 'rEdit': ({ name }): string => { - return `${name} bijwerken!` + return `${name} bijwerken` }, 'refreshing': `Bijwerken…`, 'removeProperty': `Eigenschap verwijderen`, @@ -1640,6 +2131,12 @@ export const tasksTranslation: Translation { return `${name} tonen` }, + 'savedViews': `Snelle toegang`, + 'saveView': `Snelle toegang opslaan`, + 'saveViewAsNew': `Opslaan als nieuwe snelle toegang`, + 'saveViewDescription': `Geef deze snelle toegang een naam. Filters, sortering, zoeken en locatie worden opgeslagen.`, + 'saveViewDescriptionFromSystemList': `Maakt een nieuwe snelle toegang op basis van deze indeling. Deze pagina is vast in de app en wordt niet ter plekke opgeslagen — open de snelle toegang vanuit de zijbalk wanneer nodig.`, + 'saveViewOverwriteCurrent': `Huidige snelle toegang overschrijven`, 'search': `Zoeken`, 'searchLocations': `Locaties zoeken...`, 'searchUserOrTeam': `Zoek gebruiker (of team)...`, @@ -1661,6 +2158,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(subject, { 'patient': `Patiënt`, @@ -1695,7 +2193,34 @@ export const tasksTranslation: Translation { + return `+${count}` + }, 'addPatient': `Adicionar paciente`, 'addProperty': `Adicionar propriedade`, 'addTask': `Adicionar tarefa`, @@ -1732,6 +2273,7 @@ export const tasksTranslation: Translation { @@ -1787,6 +2334,7 @@ export const tasksTranslation: Translation { + let _out: string = '' + _out += `Create ` + _out += TranslationGen.resolvePlural(count, { + '=1': `${count} task`, + 'other': `${count} tasks`, + }) + _out += ` for this patient?` + return _out + }, + 'loadTaskPresetTitle': `Load task preset`, 'location': `Localização`, 'locationBed': `Leito`, 'locationClinic': `Clínica`, @@ -1841,16 +2407,25 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} filtro`, + 'other': `${count} filtros`, + }) + }, 'no': `Não`, 'noClosedTasks': `Nenhuma tarefa concluída`, 'noLocationsFound': `Nenhuma localização encontrada`, + 'none': `Nenhum`, 'noNotifications': `Nenhuma atualização recente`, 'noOpenTasks': `Nenhuma tarefa aberta`, 'noPatient': `Sem paciente`, + 'noPatientsInTaskView': `Nenhum paciente encontrado para este acesso rápido de tarefas.`, 'noResultsFound': `Nenhum resultado encontrado`, 'notAssigned': `Não atribuído`, 'notifications': `Notificações`, @@ -1860,6 +2435,12 @@ export const tasksTranslation: Translation { + return TranslationGen.resolvePlural(count, { + '=1': `${count} ordenação`, + 'other': `${count} ordenações`, + }) + }, 'nTask': ({ count }): string => { return TranslationGen.resolvePlural(count, { '=1': `${count} Tarefa`, @@ -1877,6 +2458,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolvePlural(count, { @@ -1928,10 +2511,11 @@ export const tasksTranslation: Translation { return `Adicionar ${name}` }, + 'readOnlyView': `Somente leitura`, 'recentPatients': `Seus pacientes recentes`, 'recentTasks': `Suas tarefas recentes`, 'rEdit': ({ name }): string => { - return `Atualizar ${name}!` + return `Atualizar ${name}` }, 'refreshing': `Atualizando…`, 'removeProperty': `Remover propriedade`, @@ -1940,6 +2524,12 @@ export const tasksTranslation: Translation { return `Mostrar ${name}` }, + 'savedViews': `Acesso rápido`, + 'saveView': `Salvar acesso rápido`, + 'saveViewAsNew': `Salvar como novo acesso rápido`, + 'saveViewDescription': `Dê um nome para este acesso rápido. Filtros, ordenação, busca e localização são salvos.`, + 'saveViewDescriptionFromSystemList': `Cria uma nova entrada de acesso rápido a partir deste layout. Esta página é fixa no app e não é salva no lugar — abra o acesso rápido pela barra lateral quando precisar.`, + 'saveViewOverwriteCurrent': `Sobrescrever acesso rápido atual`, 'search': `Pesquisar`, 'searchLocations': `Pesquisar localizações...`, 'searchUserOrTeam': `Pesquisar usuário (ou equipe)...`, @@ -1961,6 +2551,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(subject, { 'patient': `Paciente`, @@ -1995,7 +2586,34 @@ export const tasksTranslation: Translation=6.9.0" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", @@ -235,28 +213,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -267,20 +223,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", @@ -313,19 +255,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", @@ -336,38 +265,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -399,478 +296,43 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-proposal-class-properties": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", - "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-object-rest-spread": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", - "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.20.5", - "@babel/helper-compilation-targets": "^7.20.7", - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.20.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", - "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", - "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-flow": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/types": "^7.29.0" }, - "engines": { - "node": ">=6.9.0" + "bin": { + "parser": "bin/babel-parser.js" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1127,9 +589,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1201,9 +663,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1462,17 +924,16 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/plugin-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.1.0.tgz", - "integrity": "sha512-JJypehWTcty9kxKiqH7TQOetkGdOYjY78RHlI+23qB59cV2wxjFFVf8l7kmuXS4cpGVUNfIjFhVr7A1W7JMtdA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.2.1.tgz", + "integrity": "sha512-shRr26TfVZ6KFBjzRYUj02gLNh6yaECz9gTGgI6riANw5sSH9PONwTsBRYkEgU+6IXiL7VQeCumahvxSGFbRlQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.0.0", + "@graphql-tools/utils": "^11.0.0", "change-case-all": "1.0.15", "common-tags": "1.8.2", "import-from": "4.0.0", - "lodash": "~4.17.0", "tslib": "~2.6.0" }, "engines": { @@ -1482,6 +943,25 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/@graphql-codegen/plugin-helpers/node_modules/@graphql-tools/utils": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@graphql-codegen/plugin-helpers/node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -1620,14 +1100,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typescript-react-query": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-react-query/-/typescript-react-query-6.1.1.tgz", - "integrity": "sha512-knSlUFmq7g7G2DIa5EGjOnwWtNfpU4k+sXWJkxdwJ7lU9nrw6pnDizJcjHCqKelRmk2xwfspVNzu0KoXP7LLsg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-react-query/-/typescript-react-query-7.0.0.tgz", + "integrity": "sha512-mAgjoMbe0J5s8BhQBlx5txibWNFW2LUVQcV7fc6FY+eYHzRqvqTwBxJWwgMzUAjAZFW32Bzkdyn6F9T8N6TKNg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^3.0.0", - "@graphql-codegen/visitor-plugin-common": "2.13.8", + "@graphql-codegen/plugin-helpers": "^6.1.1", + "@graphql-codegen/visitor-plugin-common": "^6.2.4", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "^2.8.1" @@ -1639,302 +1119,55 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/@ardatan/relay-compiler": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-12.0.0.tgz", - "integrity": "sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.14.0", - "@babel/generator": "^7.14.0", - "@babel/parser": "^7.14.0", - "@babel/runtime": "^7.0.0", - "@babel/traverse": "^7.14.0", - "@babel/types": "^7.0.0", - "babel-preset-fbjs": "^3.4.0", - "chalk": "^4.0.0", - "fb-watchman": "^2.0.0", - "fbjs": "^3.0.0", - "glob": "^7.1.1", - "immutable": "~3.7.6", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "relay-runtime": "12.0.0", - "signedsource": "^1.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "relay-compiler": "bin/relay-compiler" - }, - "peerDependencies": { - "graphql": "*" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/@graphql-codegen/plugin-helpers": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-3.1.2.tgz", - "integrity": "sha512-emOQiHyIliVOIjKVKdsI5MXj312zmRDwmHpyUTZMjfpvxq/UVAHUJIVdVf+lnjjrI+LXBTgMlTWTgHQfmICxjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^9.0.0", - "change-case-all": "1.0.15", - "common-tags": "1.8.2", - "import-from": "4.0.0", - "lodash": "~4.17.0", - "tslib": "~2.4.0" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/@graphql-codegen/plugin-helpers/node_modules/tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", - "dev": true, - "license": "0BSD" - }, "node_modules/@graphql-codegen/typescript-react-query/node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "2.13.8", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-2.13.8.tgz", - "integrity": "sha512-IQWu99YV4wt8hGxIbBQPtqRuaWZhkQRG2IZKbMoSvh0vGeWb3dB0n0hSgKaOOxDY+tljtOf9MTcUYvJslQucMQ==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.2.4.tgz", + "integrity": "sha512-iwiVCc7Mv8/XAa3K35AdFQ9chJSDv/gYEnBeQFF/Sq/W8EyJoHypOGOTTLk7OSrWO4xea65ggv0e7fGt7rPJjQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^3.1.2", - "@graphql-tools/optimize": "^1.3.0", - "@graphql-tools/relay-operation-optimizer": "^6.5.0", - "@graphql-tools/utils": "^9.0.0", + "@graphql-codegen/plugin-helpers": "^6.1.1", + "@graphql-tools/optimize": "^2.0.0", + "@graphql-tools/relay-operation-optimizer": "^7.1.1", + "@graphql-tools/utils": "^11.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", - "dependency-graph": "^0.11.0", + "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", - "tslib": "~2.4.0" + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "node_modules/@graphql-codegen/typescript-react-query/node_modules/@graphql-codegen/visitor-plugin-common/node_modules/tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", "dev": true, "license": "0BSD" }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/@graphql-tools/optimize": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-1.4.0.tgz", - "integrity": "sha512-dJs/2XvZp+wgHH8T5J2TqptT9/6uVzIYvA6uFACha+ufvdMBedkfR4b4GbT8jAKLRARiqRTxy3dctnwkTM2tdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/@graphql-tools/relay-operation-optimizer": { - "version": "6.5.18", - "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-6.5.18.tgz", - "integrity": "sha512-mc5VPyTeV+LwiM+DNvoDQfPqwQYhPV/cl5jOBjTgSniyaq8/86aODfMkrE2OduhQ5E00hqrkuL2Fdrgk0w1QJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ardatan/relay-compiler": "12.0.0", - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@graphql-codegen/typescript-react-query/node_modules/@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", "dev": true, "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/dependency-graph": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", - "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, "engines": { - "node": ">=8" - } - }, - "node_modules/@graphql-codegen/typescript-react-query/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" + "node": ">=16.0.0" }, - "engines": { - "node": ">=6" + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "node_modules/@graphql-codegen/typescript/node_modules/@graphql-codegen/visitor-plugin-common": { @@ -2712,13 +1945,13 @@ } }, "node_modules/@graphql-tools/relay-operation-optimizer": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.27.tgz", - "integrity": "sha512-rdkL1iDMFaGDiHWd7Bwv7hbhrhnljkJaD0MXeqdwQlZVgVdUDlMot2WuF7CEKVgijpH6eSC6AxXMDeqVgSBS2g==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.1.tgz", + "integrity": "sha512-va+ZieMlz6Fj18xUbwyQkZ34PsnzIdPT6Ccy1BNOQw1iclQwk52HejLMZeE/4fH+4cu80Q2HXi5+FjCKpmnJCg==", "dev": true, "license": "MIT", "dependencies": { - "@ardatan/relay-compiler": "^12.0.3", + "@ardatan/relay-compiler": "^13.0.0", "@graphql-tools/utils": "^11.0.0", "tslib": "^2.4.0" }, @@ -2916,12 +2149,13 @@ } }, "node_modules/@helpwave/hightide": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@helpwave/hightide/-/hightide-0.8.12.tgz", - "integrity": "sha512-jZZ48RGIZa1UnWNrWMr9Tr2nSVBKf5g92ZKBVi0iHL95U5et7VKUa1cTw5DA2Nu2qsEDjGWXMRkL44P2n3eleQ==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@helpwave/hightide/-/hightide-0.9.5.tgz", + "integrity": "sha512-Q510q5FiI27n/Unm1o49sDSDgshpHlVsdhyvnK2f4djG4NE+qmICgvT9hQAPEfZfPPCMQpdPYmTbLKn8lmZhCg==", "license": "MPL-2.0", "dependencies": { "@helpwave/internationalization": "0.4.0", + "@radix-ui/react-slot": "1.2.4", "@tailwindcss/cli": "4.1.18", "@tanstack/react-table": "8.21.3", "clsx": "2.1.1", @@ -2929,6 +2163,9 @@ "react": "19.2.3", "react-dom": "19.2.3", "tailwindcss": "4.1.18" + }, + "bin": { + "barrel": "dist/scripts/barrel.js" } }, "node_modules/@helpwave/hightide/node_modules/lucide-react": { @@ -3868,9 +3105,9 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", + "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -3884,9 +3121,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", + "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", "cpu": [ "arm64" ], @@ -3900,9 +3137,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", + "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", "cpu": [ "x64" ], @@ -3916,9 +3153,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", + "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", "cpu": [ "arm64" ], @@ -3932,9 +3169,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", + "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", "cpu": [ "arm64" ], @@ -3948,9 +3185,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", + "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", "cpu": [ "x64" ], @@ -3964,9 +3201,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", + "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", "cpu": [ "x64" ], @@ -3980,9 +3217,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", + "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", "cpu": [ "arm64" ], @@ -3996,9 +3233,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", + "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", "cpu": [ "x64" ], @@ -4377,8 +3614,41 @@ "bin": { "playwright": "cli.js" }, - "engines": { - "node": ">=18" + "engines": { + "node": ">=18" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@repeaterjs/repeater": { @@ -4658,6 +3928,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -5288,7 +4612,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -5686,9 +5010,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -5984,52 +5308,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/babel-plugin-syntax-trailing-function-commas": { - "version": "7.0.0-beta.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-7.0.0-beta.0.tgz", - "integrity": "sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-preset-fbjs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/babel-preset-fbjs/-/babel-preset-fbjs-3.4.0.tgz", - "integrity": "sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-proposal-class-properties": "^7.0.0", - "@babel/plugin-proposal-object-rest-spread": "^7.0.0", - "@babel/plugin-syntax-class-properties": "^7.0.0", - "@babel/plugin-syntax-flow": "^7.0.0", - "@babel/plugin-syntax-jsx": "^7.0.0", - "@babel/plugin-syntax-object-rest-spread": "^7.0.0", - "@babel/plugin-transform-arrow-functions": "^7.0.0", - "@babel/plugin-transform-block-scoped-functions": "^7.0.0", - "@babel/plugin-transform-block-scoping": "^7.0.0", - "@babel/plugin-transform-classes": "^7.0.0", - "@babel/plugin-transform-computed-properties": "^7.0.0", - "@babel/plugin-transform-destructuring": "^7.0.0", - "@babel/plugin-transform-flow-strip-types": "^7.0.0", - "@babel/plugin-transform-for-of": "^7.0.0", - "@babel/plugin-transform-function-name": "^7.0.0", - "@babel/plugin-transform-literals": "^7.0.0", - "@babel/plugin-transform-member-expression-literals": "^7.0.0", - "@babel/plugin-transform-modules-commonjs": "^7.0.0", - "@babel/plugin-transform-object-super": "^7.0.0", - "@babel/plugin-transform-parameters": "^7.0.0", - "@babel/plugin-transform-property-literals": "^7.0.0", - "@babel/plugin-transform-react-display-name": "^7.0.0", - "@babel/plugin-transform-react-jsx": "^7.0.0", - "@babel/plugin-transform-shorthand-properties": "^7.0.0", - "@babel/plugin-transform-spread": "^7.0.0", - "@babel/plugin-transform-template-literals": "^7.0.0", - "babel-plugin-syntax-trailing-function-commas": "^7.0.0-beta.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6103,16 +5381,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -6184,16 +5452,6 @@ "tslib": "^2.0.3" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001769", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", @@ -6519,16 +5777,6 @@ } } }, - "node_modules/cross-fetch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", - "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, "node_modules/cross-inspect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", @@ -6561,7 +5809,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-uri-to-buffer": { @@ -6666,16 +5914,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7210,9 +6448,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -7277,9 +6515,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -7421,39 +6659,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fbjs": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", - "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-fetch": "^3.1.5", - "fbjs-css-vars": "^1.0.0", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^1.0.35" - } - }, - "node_modules/fbjs-css-vars": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", - "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", - "dev": true, - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -7554,9 +6759,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -7620,18 +6825,10 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7783,28 +6980,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7818,30 +6993,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -8385,14 +7536,11 @@ } }, "node_modules/immutable": { - "version": "3.7.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", - "integrity": "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.8.0" - } + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.1", @@ -8444,25 +7592,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -9533,13 +8662,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9770,9 +8892,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9796,13 +8918,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -9863,14 +8985,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", + "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", "license": "MIT", "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.2.1", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -9882,15 +9004,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.1", + "@next/swc-darwin-x64": "16.2.1", + "@next/swc-linux-arm64-gnu": "16.2.1", + "@next/swc-linux-arm64-musl": "16.2.1", + "@next/swc-linux-x64-gnu": "16.2.1", + "@next/swc-linux-x64-musl": "16.2.1", + "@next/swc-win32-arm64-msvc": "16.2.1", + "@next/swc-win32-x64-msvc": "16.2.1", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -10005,34 +9127,6 @@ "node": ">=10.5.0" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -10053,13 +9147,6 @@ "node": ">=0.10.0" } }, - "node_modules/nullthrows": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", - "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "dev": true, - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10285,16 +9372,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -10385,16 +9462,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -10452,9 +9519,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -10533,6 +9600,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-flexbugs-fixes": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", + "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -10550,16 +9627,6 @@ "node": ">= 0.8.0" } }, - "node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "asap": "~2.0.3" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -10675,18 +9742,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/relay-runtime": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz", - "integrity": "sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.0.0", - "fbjs": "^3.0.0", - "invariant": "^2.2.4" - } - }, "node_modules/remedial": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/remedial/-/remedial-1.0.8.tgz", @@ -10721,13 +9776,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true, - "license": "ISC" - }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -10915,13 +9963,6 @@ "upper-case-first": "^2.0.2" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10971,13 +10012,6 @@ "node": ">= 0.4" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, - "license": "MIT" - }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -11159,13 +10193,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/signedsource": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/signedsource/-/signedsource-1.0.0.tgz", - "integrity": "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -11563,13 +10590,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -11724,33 +10744,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ua-parser-js": { - "version": "1.0.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", - "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -11877,13 +10870,6 @@ "node": ">= 8" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -11894,17 +10880,6 @@ "node": ">=18" } }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -11988,13 +10963,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true, - "license": "ISC" - }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/web/package.json b/web/package.json index 86e73cdb..2dc92562 100644 --- a/web/package.json +++ b/web/package.json @@ -12,12 +12,19 @@ "generate-graphql": "graphql-codegen", "check-translations": "node scripts/check-translation-keys.mjs" }, + "browserslist": [ + "last 3 iOS versions", + "last 3 Safari versions", + "last 2 Chrome versions", + "last 2 Firefox versions", + "Firefox ESR" + ], "dependencies": { "@apollo/client": "4.0.11", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "7.0.2", - "@helpwave/hightide": "0.8.12", + "@helpwave/hightide": "0.9.5", "@helpwave/internationalization": "0.4.0", "@tailwindcss/postcss": "4.1.3", "@tanstack/react-query": "5.90.16", @@ -29,7 +36,7 @@ "formidable": "3.5.4", "graphql-ws": "6.0.6", "lucide-react": "0.468.0", - "next": "16.1.6", + "next": "^16.2.1", "next-runtime-env": "2.0.1", "oidc-client-ts": "3.4.1", "postcss": "8.5.3", @@ -45,7 +52,7 @@ "@graphql-codegen/client-preset": "5.2.1", "@graphql-codegen/typescript": "5.0.6", "@graphql-codegen/typescript-operations": "5.0.6", - "@graphql-codegen/typescript-react-query": "6.1.1", + "@graphql-codegen/typescript-react-query": "7.0.0", "@helpwave/eslint-config": "0.0.11", "@playwright/test": "1.57.0", "@types/node": "20.17.10", @@ -55,6 +62,7 @@ "baseline-browser-mapping": "^2.9.19", "eslint": "9.39.1", "graphql": "16.12.0", - "graphql-request": "7.3.5" + "graphql-request": "7.3.5", + "postcss-flexbugs-fixes": "5.0.2" } -} \ No newline at end of file +} diff --git a/web/pages/auth/callback.tsx b/web/pages/auth/callback.tsx index 4b980f20..9f88da2b 100644 --- a/web/pages/auth/callback.tsx +++ b/web/pages/auth/callback.tsx @@ -56,7 +56,7 @@ export default function AuthCallback() { }, [hasProcessed, hasError, router]) return ( -
+
{hasError && ( diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 2c769d53..b0986fab 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -5,15 +5,14 @@ import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { Avatar } from '@helpwave/hightide' import { CurrentTime } from '@/components/Date/CurrentTime' import { ClockIcon, ListCheckIcon, UsersIcon } from 'lucide-react' -import { useCallback, useMemo, useState, type ReactNode } from 'react' +import { useMemo, type ReactNode } from 'react' import { useTasksContext } from '@/hooks/useTasksContext' import Link from 'next/link' -import { Drawer } from '@helpwave/hightide' -import { PatientDetailView } from '@/components/patients/PatientDetailView' -import { TaskDetailView } from '@/components/tasks/TaskDetailView' -import { useOverviewData, useCompleteTask, useReopenTask } from '@/data' -import { RecentTasksTable } from '@/components/tables/RecentTasksTable' -import { RecentPatientsTable } from '@/components/tables/RecentPatientsTable' +import { useOverviewData } from '@/data' +import { TaskList } from '@/components/tables/TaskList' +import { PatientList } from '@/components/tables/PatientList' +import { overviewRecentTaskToTaskViewModel } from '@/utils/overviewRecentTaskToTaskViewModel' +import { overviewRecentPatientToPatientViewModel } from '@/utils/overviewRecentPatientToPatientViewModel' import clsx from 'clsx' @@ -79,15 +78,16 @@ const Dashboard: NextPage = () => { recentPatientsPagination: { pageSize: 5, pageIndex: 0 }, }), [selectedRootLocationIds]) const { data, refetch } = useOverviewData(overviewVariables) - const [completeTask] = useCompleteTask() - const [reopenTask] = useReopenTask() - const [selectedPatientId, setSelectedPatientId] = useState(null) - const [selectedTaskId, setSelectedTaskId] = useState(null) - - const recentPatients = useMemo(() => data?.recentPatients ?? [], [data]) - const recentTasks = useMemo(() => data?.recentTasks ?? [], [data]) + const taskListTasks = useMemo( + () => (data?.recentTasks ?? []).map(overviewRecentTaskToTaskViewModel), + [data?.recentTasks] + ) + const patientListPatients = useMemo( + () => (data?.recentPatients ?? []).map(overviewRecentPatientToPatientViewModel), + [data?.recentPatients] + ) return ( @@ -124,53 +124,32 @@ const Dashboard: NextPage = () => {
- completeTask({ variables: { id }, onCompleted: () => refetch() }), [completeTask, refetch])} - reopenTask={useCallback((id) => reopenTask({ variables: { id }, onCompleted: () => refetch() }), [reopenTask, refetch])} - onSelectPatient={setSelectedPatientId} - onSelectTask={setSelectedTaskId} - className="w-full min-w-0" - /> - - -
- - setSelectedPatientId(null)} - > - {selectedPatientId && ( - setSelectedPatientId(null)} - onSuccess={() => {}} +
+
+ {translation('recentTasks')} + {translation('tasksUpdatedRecently')} +
+ void refetch()} + showAssignee={true} + totalCount={data?.recentTasksTotal ?? undefined} /> - )} - - - setSelectedTaskId(null)} - > - {selectedTaskId && ( - setSelectedTaskId(null)} - onSuccess={() => {}} +
+ +
+
+ {translation('recentPatients')} + {translation('patientsUpdatedRecently')} +
+ void refetch()} /> - )} - +
+
diff --git a/web/pages/location/[id].tsx b/web/pages/location/[id].tsx index 6b7aad37..a74a29c4 100644 --- a/web/pages/location/[id].tsx +++ b/web/pages/location/[id].tsx @@ -12,6 +12,8 @@ import { type LocationType, PatientState } from '@/api/gql/generated' import { useLocationNode, usePatients, useTasks } from '@/data' import { useMemo, useState } from 'react' import { useRouter } from 'next/router' +import type { ColumnFiltersState } from '@tanstack/react-table' +import type { FilterValue } from '@helpwave/hightide' import { LocationChips } from '@/components/locations/LocationChips' import { LOCATION_PATH_SEPARATOR } from '@/utils/location' @@ -45,6 +47,16 @@ const LocationPage: NextPage = () => { const isTeamLocation = locationData?.locationNode?.kind === 'TEAM' + const viewDefaultLocationFilters = useMemo((): ColumnFiltersState => { + if (!id) return [] + const value: FilterValue = { + dataType: 'singleTag', + operator: 'equals', + parameter: { uuidValue: id }, + } + return [{ id: 'position', value }] + }, [id]) + const { data: patientsData, refetch: refetchPatients, loading: isLoadingPatients } = usePatients( { rootLocationIds: id ? [id] : undefined }, { skip: !id || isTeamLocation } @@ -76,12 +88,15 @@ const LocationPage: NextPage = () => { locations: task.patient.assignedLocations || [] } : undefined, - assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl, isOnline: task.assignee.isOnline ?? null } + assignee: task.assignees[0] + ? { id: task.assignees[0].id, name: task.assignees[0].name, avatarURL: task.assignees[0].avatarUrl, isOnline: task.assignees[0].isOnline ?? null } : undefined, assigneeTeam: task.assigneeTeam ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } : undefined, + additionalAssigneeCount: + !task.assigneeTeam && task.assignees.length > 1 ? task.assignees.length - 1 : 0, + sourceTaskPresetId: task.sourceTaskPresetId ?? null, })) } @@ -111,12 +126,15 @@ const LocationPage: NextPage = () => { name: patient.name, locations: mergedLocations }, - assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl, isOnline: task.assignee.isOnline ?? null } + assignee: task.assignees[0] + ? { id: task.assignees[0].id, name: task.assignees[0].name, avatarURL: task.assignees[0].avatarUrl, isOnline: task.assignees[0].isOnline ?? null } : undefined, assigneeTeam: task.assigneeTeam ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } : undefined, + additionalAssigneeCount: + !task.assigneeTeam && task.assignees.length > 1 ? task.assignees.length - 1 : 0, + sourceTaskPresetId: task.sourceTaskPresetId ?? null, })) }) }, [patientsData, tasksData, isTeamLocation]) @@ -196,7 +214,7 @@ const LocationPage: NextPage = () => { {translation('errorOccurred')}
) : ( - + )} @@ -210,7 +228,6 @@ const LocationPage: NextPage = () => { onRefetch={handleRefetch} showAssignee={true} loading={isTeamLocation ? isLoadingTasks : isLoadingPatients} - showAllTasksMode={isTeamLocation && showAllTasks} headerActions={ isTeamLocation ? ( + + @@ -274,40 +302,38 @@ const SettingsPage: NextPage = () => {
{translation('language')} -
{translation('pThemes', { count: 1 })} - setName(e.target.value)} className="w-full" /> +
+
+ {translation('taskPresetScope')} + +
+
+
+ {translation('addTask')} + {rows.map((row, index) => ( +
+
+
+ { + const next = [...rows] + const cur = next[index] ?? defaultRow() + next[index] = { ...cur, title: e.target.value } + setRows(next) + }} + className="w-full" + /> +
+ + + {row.priority + ? translation('priority', { priority: row.priority }) + : translation('priorityNone')} + + + {row.estimatedTime != null && row.estimatedTime > 0 + ? `${translation('estimatedTime')}: ${row.estimatedTime}` + : translation('taskPresetNoEstimate')} + +
+ {row.description.trim() !== '' && ( +

+ {row.description} +

+ )} +
+
+ openPresetRowDrawer('create', index)} + > + + + setRows(rows.filter((_, i) => i !== index))} + disabled={rows.length <= 1} + > + + +
+
+
+ ))} + +
+
+ + +
+
+ + + setPresetRowDrawer(null)} + alignment="right" + titleElement={translation('createTask')} + description={undefined} + > + {presetRowDrawer != null && (() => { + const row = presetRowDrawer.section === 'create' + ? rows[presetRowDrawer.index] + : editRows[presetRowDrawer.index] + if (!row) { + return null + } + const target = presetRowDrawer + return ( + { + const apply = (r: TaskPresetListRow): TaskPresetListRow => ({ + ...r, + title: data.title, + description: data.description, + priority: data.priority, + estimatedTime: data.estimatedTime, + }) + if (target.section === 'create') { + setRows(prev => { + const next = [...prev] + const cur = next[target.index] + if (cur) next[target.index] = apply(cur) + return next + }) + } else { + setEditRows(prev => { + const next = [...prev] + const cur = next[target.index] + if (cur) next[target.index] = apply(cur) + return next + }) + } + setPresetRowDrawer(null) + }, + }} + onClose={() => setPresetRowDrawer(null)} + /> + ) + })()} + + + + +
+
+ {translation('taskPresetName')} + setEditName(e.target.value)} className="w-full" /> +
+
+ {translation('addTask')} + {editRows.map((row, index) => ( +
+
+
+ { + const next = [...editRows] + const cur = next[index] ?? defaultRow() + next[index] = { ...cur, title: e.target.value } + setEditRows(next) + }} + className="w-full" + /> +
+ + + {row.priority + ? translation('priority', { priority: row.priority }) + : translation('priorityNone')} + + + {row.estimatedTime != null && row.estimatedTime > 0 + ? `${translation('estimatedTime')}: ${row.estimatedTime}` + : translation('taskPresetNoEstimate')} + +
+ {row.description.trim() !== '' && ( +

+ {row.description} +

+ )} +
+
+ openPresetRowDrawer('edit', index)} + > + + + setEditRows(editRows.filter((_, i) => i !== index))} + disabled={editRows.length <= 1} + > + + +
+
+
+ ))} + +
+
+ + +
+
+
+
+ + { + setDeleteOpen(false) + setDeleteId(null) + }} + titleElement={translation('taskPresetDelete')} + description={null} + position="center" + isModal={true} + className="max-w-md" + > +
+

{translation('taskPresetDeleteConfirm')}

+
+ + +
+
+
+ + ) +} + +export default TaskPresetsPage diff --git a/web/pages/settings/views.tsx b/web/pages/settings/views.tsx new file mode 100644 index 00000000..0550b84d --- /dev/null +++ b/web/pages/settings/views.tsx @@ -0,0 +1,304 @@ +'use client' + +import type { NextPage } from 'next' +import { useCallback, useMemo, useState } from 'react' +import { useMutation } from '@apollo/client/react' +import { useRouter } from 'next/router' +import { Page } from '@/components/layout/Page' +import titleWrapper from '@/utils/titleWrapper' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { ContentPanel } from '@/components/layout/ContentPanel' +import { Button, ConfirmDialog, Dialog, FillerCell, IconButton, Input, LoadingContainer, Table } from '@helpwave/hightide' +import { DateDisplay } from '@/components/Date/DateDisplay' +import { SavedViewEntityTypeChip } from '@/components/views/SavedViewEntityTypeChip' +import { useMySavedViews } from '@/data' +import { + DeleteSavedViewDocument, + DuplicateSavedViewDocument, + type DeleteSavedViewMutation, + type DeleteSavedViewMutationVariables, + type DuplicateSavedViewMutation, + type DuplicateSavedViewMutationVariables, + MySavedViewsDocument, + UpdateSavedViewDocument, + type UpdateSavedViewMutation, + type UpdateSavedViewMutationVariables +} from '@/api/gql/generated' +import { getParsedDocument } from '@/data/hooks/queryHelpers' +import { + appendSavedViewToMySavedViewsCache, + removeSavedViewFromMySavedViewsCache, + replaceSavedViewInMySavedViewsCache +} from '@/utils/savedViewsCache' +import type { ColumnDef } from '@tanstack/table-core' +import { EditIcon, ExternalLink, Trash2, Share2, CopyPlus } from 'lucide-react' +import type { MySavedViewsQuery, SavedViewEntityType } from '@/api/gql/generated' + +type SavedViewRowGql = MySavedViewsQuery['mySavedViews'][number] + +type SavedViewRow = { + id: string, + name: string, + baseEntityType: SavedViewEntityType, + updatedAt: string, +} + +const ViewsSettingsPage: NextPage = () => { + const translation = useTasksTranslation() + const router = useRouter() + const { data, loading } = useMySavedViews({ fetchPolicy: 'cache-and-network' }) + const rows: SavedViewRow[] = useMemo(() => { + return ((data?.mySavedViews ?? []) as SavedViewRowGql[]).map((v: SavedViewRowGql) => ({ + id: v.id, + name: v.name, + baseEntityType: v.baseEntityType, + updatedAt: v.updatedAt, + })) + }, [data]) + + const fillerRowCell = useCallback(() => (), []) + + const [renameOpen, setRenameOpen] = useState(false) + const [renameId, setRenameId] = useState(null) + const [renameValue, setRenameValue] = useState('') + + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleteId, setDeleteId] = useState(null) + + const [duplicateOpen, setDuplicateOpen] = useState(false) + const [duplicateId, setDuplicateId] = useState(null) + const [duplicateName, setDuplicateName] = useState('') + + const savedViewsRefetch = { query: getParsedDocument(MySavedViewsDocument) } + const [updateSavedView] = useMutation( + getParsedDocument(UpdateSavedViewDocument), + { + refetchQueries: [savedViewsRefetch], + awaitRefetchQueries: true, + update(cache, { data }) { + const view = data?.updateSavedView + if (view) { + replaceSavedViewInMySavedViewsCache(cache, view) + } + }, + } + ) + const [deleteSavedView] = useMutation( + getParsedDocument(DeleteSavedViewDocument), + { + refetchQueries: [savedViewsRefetch], + awaitRefetchQueries: true, + update(cache, { data }, options) { + if (data?.deleteSavedView && options.variables?.id) { + removeSavedViewFromMySavedViewsCache(cache, options.variables.id) + } + }, + } + ) + const [duplicateSavedView] = useMutation( + getParsedDocument(DuplicateSavedViewDocument), + { + refetchQueries: [savedViewsRefetch], + awaitRefetchQueries: true, + update(cache, { data }) { + const view = data?.duplicateSavedView + if (view) { + appendSavedViewToMySavedViewsCache(cache, view) + } + }, + } + ) + + const copyLink = useCallback((id: string) => { + if (typeof window === 'undefined') return + void navigator.clipboard.writeText(`${window.location.origin}/view/${id}`) + }, []) + + const handleRename = useCallback(async () => { + if (!renameId || renameValue.trim().length < 1) return + await updateSavedView({ variables: { id: renameId, data: { name: renameValue.trim() } } }) + setRenameOpen(false) + setRenameId(null) + }, [renameId, renameValue, updateSavedView]) + + const handleDelete = useCallback(async () => { + if (!deleteId) return + await deleteSavedView({ variables: { id: deleteId } }) + setDeleteOpen(false) + setDeleteId(null) + }, [deleteId, deleteSavedView]) + + const handleDuplicate = useCallback(async () => { + if (!duplicateId || duplicateName.trim().length < 2) return + const { data: d } = await duplicateSavedView({ + variables: { id: duplicateId, name: duplicateName.trim() }, + }) + setDuplicateOpen(false) + setDuplicateId(null) + setDuplicateName('') + const newId = d?.duplicateSavedView?.id + if (newId) router.push(`/view/${newId}`) + }, [duplicateId, duplicateName, duplicateSavedView, router]) + + const columns = useMemo[]>(() => [ + { + id: 'name', + header: translation('name'), + accessorKey: 'name', + minSize: 280, + size: 320, + enableSorting: false, + }, + { + id: 'entity', + header: translation('subjectType'), + cell: ({ row }) => ( + + ), + minSize: 128, + size: 140, + enableSorting: false, + }, + { + id: 'updated', + header: translation('updated'), + accessorKey: 'updatedAt', + cell: ({ row }) => ( + + ), + minSize: 168, + size: 180, + enableSorting: false, + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( +
+ router.push(`/view/${row.original.id}`)} + > + + + copyLink(row.original.id)} + > + + + { + setRenameId(row.original.id) + setRenameValue(row.original.name) + setRenameOpen(true) + }} + > + + + { + setDuplicateId(row.original.id) + setDuplicateName(`${row.original.name} (2)`) + setDuplicateOpen(true) + }} + > + + + { + setDeleteId(row.original.id) + setDeleteOpen(true) + }} + > + + +
+ ), + size: 228, + minSize: 228, + maxSize: 228, + enableSorting: false, + }, + ], [copyLink, router, translation]) + + return ( + + + {loading ? ( + + ) : ( +
+ + + )} + + setRenameOpen(false)} + titleElement={translation('rEdit', { name: translation('name') })} + description={undefined} + > +
+ setRenameValue(e.target.value)} /> +
+ + +
+
+
+ + setDuplicateOpen(false)} + titleElement={translation('copyViewToMyViews')} + description={undefined} + > +
+ setDuplicateName(e.target.value)} /> +
+ + +
+
+
+ + setDeleteOpen(false)} + onConfirm={() => void handleDelete()} + titleElement={translation('delete')} + description={translation('confirmDeleteView')} + confirmType="negative" + /> + + + ) +} + +export default ViewsSettingsPage diff --git a/web/pages/tasks/index.tsx b/web/pages/tasks/index.tsx index d7a1ab23..50919270 100644 --- a/web/pages/tasks/index.tsx +++ b/web/pages/tasks/index.tsx @@ -5,36 +5,107 @@ import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { ContentPanel } from '@/components/layout/ContentPanel' import type { TaskViewModel } from '@/components/tables/TaskList' import { TaskList } from '@/components/tables/TaskList' -import { useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useRouter } from 'next/router' import { useTasksContext } from '@/hooks/useTasksContext' -import { useTasksPaginated } from '@/data' -import { useStorageSyncedTableState } from '@/hooks/useTableState' -import { columnFiltersToFilterInput, paginationStateToPaginationInput, sortingStateToSortInput } from '@/utils/tableStateToApi' +import { usePropertyDefinitions, useTasksPaginated } from '@/data' +import { getPropertyColumnIds } from '@/hooks/usePropertyColumnVisibility' +import { PropertyEntity } from '@/api/gql/generated' +import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } from '@/utils/tableStateToApi' +import { LIST_PAGE_SIZE } from '@/utils/listPaging' +import { useAccumulatedPagination } from '@/hooks/useAccumulatedPagination' +import { Visibility } from '@helpwave/hightide' +import { SaveViewDialog } from '@/components/views/SaveViewDialog' +import { SaveViewActionsMenu } from '@/components/views/SaveViewActionsMenu' +import { SavedViewEntityType } from '@/api/gql/generated' +import { + serializeColumnFiltersForView, + serializeSortingForView, + stringifyViewParameters, + tableViewStateMatchesBaseline +} from '@/utils/viewDefinition' +import type { ColumnFiltersState, ColumnOrderState, SortingState, VisibilityState } from '@tanstack/react-table' const TasksPage: NextPage = () => { const translation = useTasksTranslation() const router = useRouter() const { selectedRootLocationIds, user, myTasksCount } = useTasksContext() - const { - pagination, - setPagination, - sorting, - setSorting, - filters, - setFilters, - columnVisibility, - setColumnVisibility, - } = useStorageSyncedTableState('task-list', { - defaultSorting: useMemo(() => [ - { id: 'done', desc: false }, - { id: 'dueDate', desc: false }, - ], []), - }) + const { data: propertyDefinitionsData } = usePropertyDefinitions() + const propertyColumnIds = useMemo( + () => getPropertyColumnIds(propertyDefinitionsData, PropertyEntity.Task), + [propertyDefinitionsData] + ) + const defaultSorting = useMemo(() => [ + { id: 'done', desc: false }, + { id: 'dueDate', desc: false }, + ], []) + const [fetchPageIndex, setFetchPageIndex] = useState(0) + const [sorting, setSorting] = useState(defaultSorting) + const [filters, setFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [columnOrder, setColumnOrder] = useState([]) + + const baselineFilters = useMemo((): ColumnFiltersState => [], []) - const apiFiltering = useMemo(() => columnFiltersToFilterInput(filters, 'task'), [filters]) - const apiSorting = useMemo(() => sortingStateToSortInput(sorting, 'task'), [sorting]) - const apiPagination = useMemo(() => paginationStateToPaginationInput(pagination), [pagination]) + const [isSaveViewOpen, setIsSaveViewOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + const viewMatchesBaseline = useMemo( + () => tableViewStateMatchesBaseline({ + filters: filters as ColumnFiltersState, + baselineFilters, + sorting, + baselineSorting: defaultSorting, + searchQuery, + baselineSearch: '', + columnVisibility, + baselineColumnVisibility: undefined, + columnOrder, + baselineColumnOrder: undefined, + propertyColumnIds, + }), + [ + filters, + baselineFilters, + sorting, + defaultSorting, + searchQuery, + columnVisibility, + columnOrder, + propertyColumnIds, + ] + ) + const hasUnsavedViewChanges = !viewMatchesBaseline + + const handleDiscardTasksView = useCallback(() => { + setFilters(baselineFilters) + setSorting(defaultSorting) + setSearchQuery('') + setColumnVisibility({}) + setColumnOrder([]) + }, [baselineFilters, defaultSorting]) + + const apiFilters = useMemo(() => columnFiltersToQueryFilterClauses(filters), [filters]) + const apiSorting = useMemo(() => sortingStateToQuerySortClauses(sorting), [sorting]) + const apiPagination = useMemo( + () => ({ pageIndex: fetchPageIndex, pageSize: LIST_PAGE_SIZE }), + [fetchPageIndex] + ) + const searchInput = useMemo( + () => (searchQuery ? { searchText: searchQuery, includeProperties: true } : undefined), + [searchQuery] + ) + + const accumulationResetKey = useMemo( + () => JSON.stringify({ + filters: apiFilters, + sorts: apiSorting, + search: searchInput, + root: selectedRootLocationIds, + assignee: user?.id, + }), + [apiFilters, apiSorting, searchInput, selectedRootLocationIds, user?.id] + ) const { data: tasksData, refetch, totalCount, loading: tasksLoading } = useTasksPaginated( !!selectedRootLocationIds && !!user @@ -42,16 +113,27 @@ const TasksPage: NextPage = () => { : undefined, { pagination: apiPagination, - sorting: apiSorting.length > 0 ? apiSorting : undefined, - filtering: apiFiltering.length > 0 ? apiFiltering : undefined, + sorts: apiSorting.length > 0 ? apiSorting : undefined, + filters: apiFilters.length > 0 ? apiFilters : undefined, + search: searchInput, } ) + + const { accumulated: accumulatedTasksRaw, loadMore, hasMore } = useAccumulatedPagination({ + resetKey: accumulationResetKey, + pageData: tasksData, + pageIndex: fetchPageIndex, + setPageIndex: setFetchPageIndex, + totalCount, + loading: tasksLoading, + }) + const taskId = router.query['taskId'] as string | undefined const tasks: TaskViewModel[] = useMemo(() => { - if (!tasksData || tasksData.length === 0) return [] + if (!accumulatedTasksRaw || accumulatedTasksRaw.length === 0) return [] - return tasksData.map((task) => ({ + return accumulatedTasksRaw.map((task) => ({ id: task.id, name: task.title, description: task.description || undefined, @@ -67,12 +149,18 @@ const TasksPage: NextPage = () => { locations: task.patient.assignedLocations || [] } : undefined, - assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, avatarURL: task.assignee.avatarUrl, isOnline: task.assignee.isOnline ?? null } + assignee: task.assignees[0] + ? { id: task.assignees[0].id, name: task.assignees[0].name, avatarURL: task.assignees[0].avatarUrl, isOnline: task.assignees[0].isOnline ?? null } + : undefined, + assigneeTeam: task.assigneeTeam + ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } : undefined, + additionalAssigneeCount: + !task.assigneeTeam && task.assignees.length > 1 ? task.assignees.length - 1 : 0, + sourceTaskPresetId: task.sourceTaskPresetId ?? null, properties: task.properties ?? [], })) - }, [tasksData]) + }, [accumulatedTasksRaw]) return ( @@ -80,23 +168,52 @@ const TasksPage: NextPage = () => { titleElement={translation('myTasks')} description={myTasksCount !== undefined ? translation('nTask', { count: myTasksCount }) : undefined} > + setIsSaveViewOpen(false)} + baseEntityType={SavedViewEntityType.Task} + filterDefinition={serializeColumnFiltersForView(filters as ColumnFiltersState)} + sortDefinition={serializeSortingForView(sorting)} + parameters={stringifyViewParameters({ + rootLocationIds: selectedRootLocationIds ?? undefined, + assigneeId: user?.id, + columnVisibility, + columnOrder, + })} + presentation="fromSystemList" + onCreated={(id) => router.push(`/view/${id}`)} + /> router.replace('/tasks', undefined, { shallow: true })} totalCount={totalCount} loading={tasksLoading} + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} + loadMore={loadMore} + hasMore={hasMore} + saveViewSlot={( + + undefined} + onOpenSaveAsNew={() => setIsSaveViewOpen(true)} + onDiscard={handleDiscardTasksView} + /> + + )} tableState={{ - pagination, - setPagination, sorting, setSorting, filters, setFilters, columnVisibility, setColumnVisibility, + columnOrder, + setColumnOrder, }} /> diff --git a/web/pages/view/[uid].tsx b/web/pages/view/[uid].tsx new file mode 100644 index 00000000..5a781c07 --- /dev/null +++ b/web/pages/view/[uid].tsx @@ -0,0 +1,583 @@ +'use client' + +import type { NextPage } from 'next' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useMutation } from '@apollo/client/react' +import { Page } from '@/components/layout/Page' +import titleWrapper from '@/utils/titleWrapper' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import { ContentPanel } from '@/components/layout/ContentPanel' +import { Button, Chip, IconButton, LoadingContainer, TabList, TabPanel, TabSwitcher, Visibility } from '@helpwave/hightide' +import { CenteredLoadingLogo } from '@/components/CenteredLoadingLogo' +import { PatientList } from '@/components/tables/PatientList' +import { TaskList, type TaskViewModel } from '@/components/tables/TaskList' +import { PatientViewTasksPanel } from '@/components/views/PatientViewTasksPanel' +import { TaskViewPatientsPanel } from '@/components/views/TaskViewPatientsPanel' +import { usePropertyDefinitions, useSavedView, useTasksPaginated } from '@/data' +import { getPropertyColumnIds } from '@/hooks/usePropertyColumnVisibility' +import { + DuplicateSavedViewDocument, + MySavedViewsDocument, + SavedViewDocument, + UpdateSavedViewDocument, + type DuplicateSavedViewMutation, + type DuplicateSavedViewMutationVariables, + type UpdateSavedViewMutation, + type UpdateSavedViewMutationVariables, + PropertyEntity, + SavedViewEntityType +} from '@/api/gql/generated' +import { getParsedDocument } from '@/data/hooks/queryHelpers' +import { appendSavedViewToMySavedViewsCache, replaceSavedViewInMySavedViewsCache } from '@/utils/savedViewsCache' +import { + deserializeColumnFiltersFromView, + deserializeSortingFromView, + parseViewParameters, + serializeColumnFiltersForView, + serializeSortingForView, + stringifyViewParameters, + tableViewStateMatchesBaseline +} from '@/utils/viewDefinition' +import { SaveViewDialog } from '@/components/views/SaveViewDialog' +import { SaveViewActionsMenu } from '@/components/views/SaveViewActionsMenu' +import { SavedViewEntityTypeChip } from '@/components/views/SavedViewEntityTypeChip' +import type { ColumnFiltersState } from '@tanstack/react-table' +import { useTasksContext } from '@/hooks/useTasksContext' +import { useTableState } from '@/hooks/useTableState' +import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } from '@/utils/tableStateToApi' +import { LIST_PAGE_SIZE } from '@/utils/listPaging' +import { useAccumulatedPagination } from '@/hooks/useAccumulatedPagination' +import { CopyPlus, Eye, Share2 } from 'lucide-react' + +type SavedTaskViewTabProps = { + viewId: string, + filterDefinition: string, + sortDefinition: string, + parameters: ReturnType, + isOwner: boolean, +} + +function SavedTaskViewTab({ + viewId, + filterDefinition, + sortDefinition, + parameters, + isOwner, +}: SavedTaskViewTabProps) { + const router = useRouter() + const { selectedRootLocationIds, user } = useTasksContext() + const defaultFilters = deserializeColumnFiltersFromView(filterDefinition) + const defaultSorting = deserializeSortingFromView(sortDefinition) + + const baselineSort = useMemo(() => [ + { id: 'done', desc: false }, + { id: 'dueDate', desc: false }, + ], []) + + const viewSortBaseline = useMemo( + () => (defaultSorting.length > 0 ? defaultSorting : baselineSort), + [defaultSorting, baselineSort] + ) + + const baselineSearch = parameters.searchQuery ?? '' + const baselineColumnVisibility = useMemo( + () => parameters.columnVisibility ?? {}, + [parameters.columnVisibility] + ) + const baselineColumnOrder = useMemo( + () => parameters.columnOrder ?? [], + [parameters.columnOrder] + ) + + const { data: propertyDefinitionsData } = usePropertyDefinitions() + const propertyColumnIds = useMemo( + () => getPropertyColumnIds(propertyDefinitionsData, PropertyEntity.Task), + [propertyDefinitionsData] + ) + + const persistedViewContentKey = useMemo( + () => + `${filterDefinition}\0${sortDefinition}\0${stringifyViewParameters({ + rootLocationIds: parameters.rootLocationIds, + locationId: parameters.locationId, + searchQuery: parameters.searchQuery, + assigneeId: parameters.assigneeId, + columnVisibility: parameters.columnVisibility, + columnOrder: parameters.columnOrder, + })}`, + [filterDefinition, sortDefinition, parameters] + ) + + const { + sorting, + setSorting, + filters, + setFilters, + columnVisibility, + setColumnVisibility, + columnOrder, + setColumnOrder, + } = useTableState({ + defaultFilters, + defaultSorting: viewSortBaseline, + defaultColumnVisibility: baselineColumnVisibility, + defaultColumnOrder: baselineColumnOrder, + }) + + const [fetchPageIndex, setFetchPageIndex] = useState(0) + const [searchQuery, setSearchQuery] = useState(baselineSearch) + const [isSaveViewOpen, setIsSaveViewOpen] = useState(false) + + useEffect(() => { + const nextFilters = deserializeColumnFiltersFromView(filterDefinition) + const nextSort = deserializeSortingFromView(sortDefinition) + const nextSortBaseline = nextSort.length > 0 + ? nextSort + : [ + { id: 'done', desc: false }, + { id: 'dueDate', desc: false }, + ] + setFilters(nextFilters) + setSorting(nextSortBaseline) + setSearchQuery(parameters.searchQuery ?? '') + setColumnVisibility(parameters.columnVisibility ?? {}) + setColumnOrder(parameters.columnOrder ?? []) + setFetchPageIndex(0) + }, [ + persistedViewContentKey, + filterDefinition, + sortDefinition, + parameters.searchQuery, + parameters.columnVisibility, + parameters.columnOrder, + setFilters, + setSorting, + setSearchQuery, + setColumnVisibility, + setColumnOrder, + ]) + + const viewMatchesBaseline = useMemo( + () => tableViewStateMatchesBaseline({ + filters: filters as ColumnFiltersState, + baselineFilters: defaultFilters, + sorting, + baselineSorting: viewSortBaseline, + searchQuery, + baselineSearch, + columnVisibility, + baselineColumnVisibility, + columnOrder, + baselineColumnOrder, + propertyColumnIds, + }), + [ + filters, + defaultFilters, + sorting, + viewSortBaseline, + searchQuery, + baselineSearch, + columnVisibility, + baselineColumnVisibility, + columnOrder, + baselineColumnOrder, + propertyColumnIds, + ] + ) + const hasUnsavedViewChanges = !viewMatchesBaseline + + const [updateSavedView, { loading: overwriteLoading }] = useMutation< + UpdateSavedViewMutation, + UpdateSavedViewMutationVariables + >(getParsedDocument(UpdateSavedViewDocument), { + awaitRefetchQueries: true, + refetchQueries: [ + { query: getParsedDocument(SavedViewDocument), variables: { id: viewId } }, + { query: getParsedDocument(MySavedViewsDocument) }, + ], + update(cache, { data }) { + const view = data?.updateSavedView + if (view) { + replaceSavedViewInMySavedViewsCache(cache, view) + } + }, + }) + + const handleDiscardTaskView = useCallback(() => { + setFilters(defaultFilters) + setSorting(viewSortBaseline) + setSearchQuery(baselineSearch) + setColumnVisibility(baselineColumnVisibility) + setColumnOrder(baselineColumnOrder) + }, [ + baselineSearch, + baselineColumnOrder, + baselineColumnVisibility, + defaultFilters, + setFilters, + setSorting, + setSearchQuery, + setColumnVisibility, + setColumnOrder, + viewSortBaseline, + ]) + + const rootIds = parameters.rootLocationIds?.length ? parameters.rootLocationIds : selectedRootLocationIds + const assigneeId = parameters.assigneeId ?? user?.id + + const handleOverwriteTaskView = useCallback(async () => { + await updateSavedView({ + variables: { + id: viewId, + data: { + filterDefinition: serializeColumnFiltersForView(filters as ColumnFiltersState), + sortDefinition: serializeSortingForView(sorting), + parameters: stringifyViewParameters({ + rootLocationIds: rootIds ?? undefined, + assigneeId: assigneeId ?? undefined, + searchQuery: searchQuery || undefined, + columnVisibility, + columnOrder, + }), + }, + }, + }) + }, [updateSavedView, viewId, filters, sorting, rootIds, assigneeId, searchQuery, columnVisibility, columnOrder]) + + const apiFilters = useMemo(() => columnFiltersToQueryFilterClauses(filters), [filters]) + const apiSorting = useMemo(() => sortingStateToQuerySortClauses(sorting), [sorting]) + const apiPagination = useMemo( + () => ({ pageIndex: fetchPageIndex, pageSize: LIST_PAGE_SIZE }), + [fetchPageIndex] + ) + const searchInput = useMemo( + () => (searchQuery ? { searchText: searchQuery, includeProperties: true } : undefined), + [searchQuery] + ) + + const accumulationResetKey = useMemo( + () => JSON.stringify({ + filters: apiFilters, + sorts: apiSorting, + search: searchInput, + root: rootIds, + assignee: assigneeId, + }), + [apiFilters, apiSorting, searchInput, rootIds, assigneeId] + ) + + const { data: tasksData, refetch, totalCount, loading: tasksLoading } = useTasksPaginated( + rootIds && assigneeId + ? { rootLocationIds: rootIds, assigneeId } + : undefined, + { + pagination: apiPagination, + sorts: apiSorting.length > 0 ? apiSorting : undefined, + filters: apiFilters.length > 0 ? apiFilters : undefined, + search: searchInput, + } + ) + + const { accumulated: accumulatedTasksRaw, loadMore, hasMore } = useAccumulatedPagination({ + resetKey: accumulationResetKey, + pageData: tasksData, + pageIndex: fetchPageIndex, + setPageIndex: setFetchPageIndex, + totalCount, + loading: tasksLoading, + }) + + const tasks: TaskViewModel[] = useMemo(() => { + if (!accumulatedTasksRaw || accumulatedTasksRaw.length === 0) return [] + return accumulatedTasksRaw.map((task) => ({ + id: task.id, + name: task.title, + description: task.description || undefined, + updateDate: task.updateDate ? new Date(task.updateDate) : new Date(task.creationDate), + dueDate: task.dueDate ? new Date(task.dueDate) : undefined, + priority: task.priority || null, + estimatedTime: task.estimatedTime ?? null, + done: task.done, + patient: task.patient + ? { + id: task.patient.id, + name: task.patient.name, + locations: task.patient.assignedLocations || [] + } + : undefined, + assignee: task.assignees[0] + ? { id: task.assignees[0].id, name: task.assignees[0].name, avatarURL: task.assignees[0].avatarUrl, isOnline: task.assignees[0].isOnline ?? null } + : undefined, + assigneeTeam: task.assigneeTeam + ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } + : undefined, + additionalAssigneeCount: + !task.assigneeTeam && task.assignees.length > 1 ? task.assignees.length - 1 : 0, + sourceTaskPresetId: task.sourceTaskPresetId ?? null, + properties: task.properties ?? [], + })) + }, [accumulatedTasksRaw]) + + const viewParametersForSave = useMemo(() => stringifyViewParameters({ + rootLocationIds: rootIds ?? undefined, + assigneeId: assigneeId ?? undefined, + searchQuery: searchQuery || undefined, + }), [rootIds, assigneeId, searchQuery]) + + return ( + <> + setIsSaveViewOpen(false)} + baseEntityType={SavedViewEntityType.Task} + filterDefinition={serializeColumnFiltersForView(filters as ColumnFiltersState)} + sortDefinition={serializeSortingForView(sorting)} + parameters={viewParametersForSave} + onCreated={(id) => router.push(`/view/${id}`)} + /> + void refetch()} + showAssignee={true} + totalCount={totalCount} + loading={tasksLoading} + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} + saveViewSlot={isOwner ? ( + + setIsSaveViewOpen(true)} + onDiscard={handleDiscardTaskView} + /> + + ) : undefined} + loadMore={loadMore} + hasMore={hasMore} + tableState={{ + sorting, + setSorting, + filters, + setFilters, + columnVisibility, + setColumnVisibility, + columnOrder, + setColumnOrder, + }} + /> + + ) +} + +const ViewPage: NextPage = () => { + const translation = useTasksTranslation() + const router = useRouter() + const uid = typeof router.query['uid'] === 'string' ? router.query['uid'] : undefined + const { data, loading, error } = useSavedView(uid) + const view = data?.savedView + const params = useMemo(() => (view ? parseViewParameters(view.parameters) : {}), [view]) + + const [duplicateOpen, setDuplicateOpen] = useState(false) + const [duplicateName, setDuplicateName] = useState('') + const [patientViewRefreshVersion, setPatientViewRefreshVersion] = useState(0) + + const [duplicateSavedView] = useMutation< + DuplicateSavedViewMutation, + DuplicateSavedViewMutationVariables + >(getParsedDocument(DuplicateSavedViewDocument), { + refetchQueries: [{ query: getParsedDocument(MySavedViewsDocument) }], + awaitRefetchQueries: true, + update(cache, { data }) { + const view = data?.duplicateSavedView + if (view) { + appendSavedViewToMySavedViewsCache(cache, view) + } + }, + }) + + const handleDuplicate = useCallback(async () => { + if (!view?.id || duplicateName.trim().length < 2) return + const { data: d } = await duplicateSavedView({ + variables: { id: view.id, name: duplicateName.trim() }, + }) + setDuplicateOpen(false) + setDuplicateName('') + const newId = d?.duplicateSavedView?.id + if (newId) router.push(`/view/${newId}`) + }, [duplicateSavedView, duplicateName, router, view?.id]) + + const copyShareLink = useCallback(() => { + if (typeof window === 'undefined' || !uid) return + void navigator.clipboard.writeText(`${window.location.origin}/view/${uid}`) + }, [uid]) + + if (!router.isReady || !uid) { + return ( + + + + ) + } + + if (loading) { + return ( + + }> + + + + ) + } + + if (error || !view) { + return ( + + +
{translation('errorOccurred')}
+
+
+ ) + } + + const defaultFilters = deserializeColumnFiltersFromView(view.filterDefinition) + const defaultSorting = deserializeSortingFromView(view.sortDefinition) + + return ( + + +
+ {view.name} + + {!view.isOwner && ( + + + + {translation('readOnlyView')} + + + )} +
+
+ + + + {!view.isOwner && ( + setDuplicateOpen(true)} + > + + + )} +
+ + )} + > + {duplicateOpen && ( +
+
+ {translation('copyViewToMyViews')} + +
+ + +
+
+
+ )} + + {view.baseEntityType === SavedViewEntityType.Patient && ( + + + + router.push(`/view/${id}`)} + onPatientUpdated={() => setPatientViewRefreshVersion(v => v + 1)} + /> + + + + + + )} + + {view.baseEntityType === SavedViewEntityType.Task && ( + + + + + + + + + + )} +
+
+ ) +} + +export default ViewPage diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs index 478530e9..47994e4a 100644 --- a/web/postcss.config.mjs +++ b/web/postcss.config.mjs @@ -1,8 +1,11 @@ const config = { plugins: { '@tailwindcss/postcss': {}, - 'autoprefixer': {}, - } + 'postcss-flexbugs-fixes': {}, + 'autoprefixer': { + flexbox: 'no-2009', + }, + }, } export default config diff --git a/web/providers/ApolloProviderWithData.tsx b/web/providers/ApolloProviderWithData.tsx index df01a467..b67cb9fd 100644 --- a/web/providers/ApolloProviderWithData.tsx +++ b/web/providers/ApolloProviderWithData.tsx @@ -7,8 +7,11 @@ import { getUser } from '@/api/auth/authService' import { createApolloClient, rehydrateCache, - replayPendingMutations + replayPendingMutations, + schedulePersistCache } from '@/data' +import { MySavedViewsDocument } from '@/api/gql/generated' +import { getParsedDocument } from '@/data/hooks/queryHelpers' const ApolloClientContext = createContext(null) @@ -26,20 +29,25 @@ export function ApolloProviderWithData({ children }: { children: React.ReactNode const [isReady, setIsReady] = useState(false) useEffect(() => { - const navEntry = typeof performance !== 'undefined' && performance.getEntriesByType?.('navigation')?.[0] - const isPageReload = navEntry && 'type' in navEntry && (navEntry as { type: string }).type === 'reload' - - if (isPageReload) { - client.clearStore().then(() => setIsReady(true)) - return - } - rehydrateCache(client.cache) .then(() => replayPendingMutations(client.cache)) .then(() => setIsReady(true)) .catch(() => setIsReady(true)) }, [client]) + useEffect(() => { + if (!isReady) return + void client + .query({ + query: getParsedDocument(MySavedViewsDocument), + fetchPolicy: 'cache-first', + }) + .then(() => { + schedulePersistCache(client.cache) + }) + .catch(() => {}) + }, [client, isReady]) + return ( diff --git a/web/schema.graphql b/web/schema.graphql new file mode 100644 index 00000000..53d76027 --- /dev/null +++ b/web/schema.graphql @@ -0,0 +1,490 @@ +type AuditLogType { + caseId: String! + activity: String! + userId: String + timestamp: DateTime! + context: String +} + +input CreateLocationNodeInput { + title: String! + kind: LocationType! + parentId: ID = null +} + +input CreatePatientInput { + firstname: String! + lastname: String! + birthdate: Date! + sex: Sex! + assignedLocationId: ID = null + assignedLocationIds: [ID!] = null + clinicId: ID! + positionId: ID = null + teamIds: [ID!] = null + properties: [PropertyValueInput!] = null + state: PatientState = null + description: String = null +} + +input CreatePropertyDefinitionInput { + name: String! + fieldType: FieldType! + allowedEntities: [PropertyEntity!]! + description: String = null + options: [String!] = null + isActive: Boolean! = true +} + +input CreateSavedViewInput { + name: String! + baseEntityType: SavedViewEntityType! + filterDefinition: String! + sortDefinition: String! + parameters: String! + relatedFilterDefinition: String = "{}" + relatedSortDefinition: String = "{}" + relatedParameters: String = "{}" + visibility: SavedViewVisibility! = PRIVATE +} + +input CreateTaskInput { + title: String! + patientId: ID = null + description: String = null + dueDate: DateTime = null + assigneeIds: [ID!] = null + assigneeTeamId: ID = null + previousTaskIds: [ID!] = null + properties: [PropertyValueInput!] = null + priority: TaskPriority = null + estimatedTime: Int = null +} + +"""Date (isoformat)""" +scalar Date + +"""Date with time (isoformat)""" +scalar DateTime + +enum FieldType { + FIELD_TYPE_UNSPECIFIED + FIELD_TYPE_TEXT + FIELD_TYPE_NUMBER + FIELD_TYPE_CHECKBOX + FIELD_TYPE_DATE + FIELD_TYPE_DATE_TIME + FIELD_TYPE_SELECT + FIELD_TYPE_MULTI_SELECT + FIELD_TYPE_USER +} + +type LocationNodeType { + id: ID! + title: String! + kind: LocationType! + parentId: ID + parent: LocationNodeType + children: [LocationNodeType!]! + patients: [PatientType!]! + organizationIds: [String!]! +} + +enum LocationType { + HOSPITAL + PRACTICE + CLINIC + TEAM + WARD + ROOM + BED + OTHER +} + +type Mutation { + createPatient(data: CreatePatientInput!): PatientType! + updatePatient(id: ID!, data: UpdatePatientInput!): PatientType! + deletePatient(id: ID!): Boolean! + admitPatient(id: ID!): PatientType! + dischargePatient(id: ID!): PatientType! + markPatientDead(id: ID!): PatientType! + waitPatient(id: ID!): PatientType! + createTask(data: CreateTaskInput!): TaskType! + updateTask(id: ID!, data: UpdateTaskInput!): TaskType! + addTaskAssignee(id: ID!, userId: ID!): TaskType! + removeTaskAssignee(id: ID!, userId: ID!): TaskType! + assignTaskToTeam(id: ID!, teamId: ID!): TaskType! + unassignTaskFromTeam(id: ID!): TaskType! + completeTask(id: ID!): TaskType! + reopenTask(id: ID!): TaskType! + deleteTask(id: ID!): Boolean! + createPropertyDefinition(data: CreatePropertyDefinitionInput!): PropertyDefinitionType! + updatePropertyDefinition(id: ID!, data: UpdatePropertyDefinitionInput!): PropertyDefinitionType! + deletePropertyDefinition(id: ID!): Boolean! + createLocationNode(data: CreateLocationNodeInput!): LocationNodeType! + updateLocationNode(id: ID!, data: UpdateLocationNodeInput!): LocationNodeType! + deleteLocationNode(id: ID!): Boolean! + updateProfilePicture(data: UpdateProfilePictureInput!): UserType! + createSavedView(data: CreateSavedViewInput!): SavedView! + updateSavedView(id: ID!, data: UpdateSavedViewInput!): SavedView! + deleteSavedView(id: ID!): Boolean! + duplicateSavedView(id: ID!, name: String!): SavedView! +} + +input PaginationInput { + pageIndex: Int! = 0 + pageSize: Int = null +} + +enum PatientState { + WAIT + ADMITTED + DISCHARGED + DEAD +} + +type PatientType { + id: ID! + firstname: String! + lastname: String! + birthdate: Date! + sex: Sex! + state: PatientState! + assignedLocationId: ID + clinicId: ID! + positionId: ID + description: String + name: String! + age: Int! + assignedLocation: LocationNodeType + assignedLocations: [LocationNodeType!]! + clinic: LocationNodeType! + position: LocationNodeType + teams: [LocationNodeType!]! + tasks(done: Boolean = null): [TaskType!]! + properties: [PropertyValueType!]! + checksum: String! +} + +type PropertyDefinitionType { + id: ID! + name: String! + description: String + fieldType: FieldType! + isActive: Boolean! + options: [String!]! + allowedEntities: [PropertyEntity!]! +} + +enum PropertyEntity { + PATIENT + TASK +} + +input PropertyValueInput { + definitionId: ID! + textValue: String = null + numberValue: Float = null + booleanValue: Boolean = null + dateValue: Date = null + dateTimeValue: DateTime = null + selectValue: String = null + multiSelectValues: [String!] = null + userValue: String = null +} + +type PropertyValueType { + id: ID! + definition: PropertyDefinitionType! + textValue: String + numberValue: Float + booleanValue: Boolean + dateValue: Date + dateTimeValue: DateTime + selectValue: String + userValue: String + multiSelectValues: [String!] + user: UserType + team: LocationNodeType +} + +type Query { + patient(id: ID!): PatientType + patients(locationNodeId: ID = null, rootLocationIds: [ID!] = null, states: [PatientState!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [PatientType!]! + patientsTotal(locationNodeId: ID = null, rootLocationIds: [ID!] = null, states: [PatientState!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, search: QuerySearchInput = null): Int! + recentPatients(rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [PatientType!]! + recentPatientsTotal(rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, search: QuerySearchInput = null): Int! + task(id: ID!): TaskType + tasks(patientId: ID = null, assigneeId: ID = null, assigneeTeamId: ID = null, rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [TaskType!]! + tasksTotal(patientId: ID = null, assigneeId: ID = null, assigneeTeamId: ID = null, rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, search: QuerySearchInput = null): Int! + recentTasks(rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [TaskType!]! + recentTasksTotal(rootLocationIds: [ID!] = null, filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, search: QuerySearchInput = null): Int! + locationRoots: [LocationNodeType!]! + locationNode(id: ID!): LocationNodeType + locationNodes(kind: LocationType = null, search: String = null, parentId: ID = null, recursive: Boolean! = false, orderByName: Boolean! = false, limit: Int = null, offset: Int = null): [LocationNodeType!]! + propertyDefinitions: [PropertyDefinitionType!]! + user(id: ID!): UserType + users(filters: [QueryFilterClauseInput!] = null, sorts: [QuerySortClauseInput!] = null, pagination: PaginationInput = null, search: QuerySearchInput = null): [UserType!]! + me: UserType + auditLogs(caseId: ID!, limit: Int = null, offset: Int = null): [AuditLogType!]! + queryableFields(entity: String!): [QueryableField!]! + savedView(id: ID!): SavedView + mySavedViews: [SavedView!]! +} + +input QueryFilterClauseInput { + fieldKey: String! + operator: QueryOperator! + value: QueryFilterValueInput = null +} + +input QueryFilterValueInput { + stringValue: String = null + stringValues: [String!] = null + floatValue: Float = null + floatMin: Float = null + floatMax: Float = null + boolValue: Boolean = null + dateValue: DateTime = null + dateMin: Date = null + dateMax: Date = null + uuidValue: String = null + uuidValues: [String!] = null +} + +enum QueryOperator { + EQ + NEQ + GT + GTE + LT + LTE + BETWEEN + IN + NOT_IN + CONTAINS + STARTS_WITH + ENDS_WITH + IS_NULL + IS_NOT_NULL + ANY_EQ + ANY_IN + ALL_IN + NONE_IN + IS_EMPTY + IS_NOT_EMPTY +} + +input QuerySearchInput { + searchText: String = null + includeProperties: Boolean! = false +} + +input QuerySortClauseInput { + fieldKey: String! + direction: SortDirection! +} + +type QueryableChoiceMeta { + optionKeys: [String!]! + optionLabels: [String!]! +} + +type QueryableField { + key: String! + label: String! + kind: QueryableFieldKind! + valueType: QueryableValueType! + allowedOperators: [QueryOperator!]! + sortable: Boolean! + sortDirections: [SortDirection!]! + searchable: Boolean! + filterable: Boolean! + relation: QueryableRelationMeta + choice: QueryableChoiceMeta + propertyDefinitionId: String +} + +enum QueryableFieldKind { + SCALAR + PROPERTY + REFERENCE + REFERENCE_LIST + CHOICE + CHOICE_LIST +} + +type QueryableRelationMeta { + targetEntity: String! + idFieldKey: String! + labelFieldKey: String! + allowedFilterModes: [ReferenceFilterMode!]! +} + +enum QueryableValueType { + STRING + NUMBER + BOOLEAN + DATE + DATETIME + UUID + STRING_LIST + UUID_LIST +} + +enum ReferenceFilterMode { + ID + LABEL +} + +type SavedView { + id: ID! + name: String! + baseEntityType: SavedViewEntityType! + filterDefinition: String! + sortDefinition: String! + parameters: String! + relatedFilterDefinition: String! + relatedSortDefinition: String! + relatedParameters: String! + ownerUserId: ID! + visibility: SavedViewVisibility! + createdAt: String! + updatedAt: String! + isOwner: Boolean! +} + +enum SavedViewEntityType { + TASK + PATIENT +} + +enum SavedViewVisibility { + PRIVATE + LINK_SHARED +} + +enum Sex { + MALE + FEMALE + UNKNOWN +} + +enum SortDirection { + ASC + DESC +} + +type Subscription { + patientCreated(rootLocationIds: [ID!] = null): ID! + patientUpdated(patientId: ID = null, rootLocationIds: [ID!] = null): ID! + patientStateChanged(patientId: ID = null, rootLocationIds: [ID!] = null): ID! + patientDeleted(rootLocationIds: [ID!] = null): ID! + taskCreated(rootLocationIds: [ID!] = null): ID! + taskUpdated(taskId: ID = null, rootLocationIds: [ID!] = null): ID! + taskDeleted(rootLocationIds: [ID!] = null): ID! + locationNodeCreated: ID! + locationNodeUpdated(locationId: ID = null): ID! + locationNodeDeleted: ID! +} + +enum TaskPriority { + P1 + P2 + P3 + P4 +} + +type TaskType { + id: ID! + title: String! + description: String + done: Boolean! + dueDate: DateTime + creationDate: DateTime! + updateDate: DateTime + assigneeTeamId: ID + patientId: ID + priority: String + estimatedTime: Int + assignees: [UserType!]! + assigneeTeam: LocationNodeType + patient: PatientType + properties: [PropertyValueType!]! + checksum: String! +} + +input UpdateLocationNodeInput { + title: String = null + kind: LocationType = null + parentId: ID = null +} + +input UpdatePatientInput { + firstname: String = null + lastname: String = null + birthdate: Date = null + sex: Sex = null + assignedLocationId: ID = null + assignedLocationIds: [ID!] = null + clinicId: ID = null + positionId: ID + teamIds: [ID!] + properties: [PropertyValueInput!] = null + checksum: String = null + description: String = null +} + +input UpdateProfilePictureInput { + avatarUrl: String! +} + +input UpdatePropertyDefinitionInput { + name: String = null + description: String = null + options: [String!] = null + isActive: Boolean = null + allowedEntities: [PropertyEntity!] = null +} + +input UpdateSavedViewInput { + name: String = null + filterDefinition: String = null + sortDefinition: String = null + parameters: String = null + relatedFilterDefinition: String = null + relatedSortDefinition: String = null + relatedParameters: String = null + visibility: SavedViewVisibility = null +} + +input UpdateTaskInput { + title: String = null + patientId: ID + description: String = null + done: Boolean = null + dueDate: DateTime + assigneeIds: [ID!] + assigneeTeamId: ID + previousTaskIds: [ID!] = null + properties: [PropertyValueInput!] = null + checksum: String = null + priority: TaskPriority + estimatedTime: Int +} + +type UserType { + id: ID! + username: String! + email: String + firstname: String + lastname: String + title: String + avatarUrl: String + lastOnline: DateTime + name: String! + isOnline: Boolean! + organizations: String + tasks(rootLocationIds: [ID!] = null): [TaskType!]! + rootLocations: [LocationNodeType!]! +} diff --git a/web/style/colors.css b/web/style/colors.css index 7c42222f..0de5b553 100644 --- a/web/style/colors.css +++ b/web/style/colors.css @@ -11,9 +11,9 @@ --color-gender-on-male: var(--color-white); --color-gender-male-hover: var(--color-blue-600); - --color-gender-neutral: var(--color-blue-500); + --color-gender-neutral: var(--color-gray-600); --color-gender-on-neutral: var(--color-white); - --color-gender-neutral-hover: var(--color-blue-600); + --color-gender-neutral-hover: var(--color-gray-700); --color-location-hospital: var(--color-red-100); --color-location-on-hospital: var(--color-red-700); diff --git a/web/style/table-printing.css b/web/style/table-printing.css index 7a4bbee2..b156ad5b 100644 --- a/web/style/table-printing.css +++ b/web/style/table-printing.css @@ -20,9 +20,9 @@ height: auto !important; margin: 0 !important; padding: 0 !important; - overflow: hidden !important; + overflow: auto !important; overflow-x: hidden !important; - overflow-y: hidden !important; + overflow-y: auto !important; max-height: none !important; max-width: 100% !important; background: white !important; @@ -39,7 +39,8 @@ } .print-content { - position: fixed; + position: absolute; + z-index: 10000 !important; width: 100% !important; max-width: 100% !important; left: 0 !important; diff --git a/web/types/systemSuggestion.ts b/web/types/systemSuggestion.ts index f9816650..a855e2d2 100644 --- a/web/types/systemSuggestion.ts +++ b/web/types/systemSuggestion.ts @@ -1,39 +1,39 @@ export type GuidelineAdherenceStatus = 'adherent' | 'non_adherent' | 'unknown' export type SuggestedTaskItem = { - id: string - title: string - description?: string + id: string, + title: string, + description?: string, } export type SystemSuggestionExplanation = { - details: string - references: Array<{ title: string; url: string }> + 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 + 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 + 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, } diff --git a/web/utils/columnOrder.ts b/web/utils/columnOrder.ts new file mode 100644 index 00000000..1f520cd4 --- /dev/null +++ b/web/utils/columnOrder.ts @@ -0,0 +1,29 @@ +import type { ColumnDef, ColumnOrderState } from '@tanstack/table-core' + +export function columnIdsFromColumnDefs(columns: ColumnDef[]): string[] { + return columns + .map((col) => { + if (typeof col.id === 'string' && col.id.length > 0) { + return col.id + } + if ('accessorKey' in col && typeof col.accessorKey === 'string') { + return col.accessorKey + } + return undefined + }) + .filter((id): id is string => id != null) +} + +export function sanitizeColumnOrderForKnownColumns( + order: ColumnOrderState, + knownColumnIdsOrdered: readonly string[] +): ColumnOrderState { + if (knownColumnIdsOrdered.length === 0) { + return [] + } + const knownSet = new Set(knownColumnIdsOrdered) + const kept = order.filter((id) => knownSet.has(id)) + const seen = new Set(kept) + const appended = knownColumnIdsOrdered.filter((id) => !seen.has(id)) + return [...kept, ...appended] +} diff --git a/web/utils/hightideDateFormat.ts b/web/utils/hightideDateFormat.ts new file mode 100644 index 00000000..e2f9d6e3 --- /dev/null +++ b/web/utils/hightideDateFormat.ts @@ -0,0 +1,57 @@ +export type DateTimeFormat = 'date' | 'time' | 'dateTime' + +const timesInSeconds = { + second: 1, + minute: 60, + hour: 3600, + day: 86400, + week: 604800, + monthImprecise: 2629800, + yearImprecise: 31557600, +} as const + +export function formatAbsoluteHightide(date: Date, locale: string, format: DateTimeFormat): string { + let options: Intl.DateTimeFormatOptions + + switch (format) { + case 'date': + options = { + year: '2-digit', + month: '2-digit', + day: '2-digit', + } + break + case 'time': + options = { + hour: '2-digit', + minute: '2-digit', + } + break + case 'dateTime': + options = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + } + break + } + + return new Intl.DateTimeFormat(locale, options).format(date) +} + +export function formatRelativeHightide(date: Date, locale: string): string { + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) + const now = new Date() + const diffInSeconds = (date.getTime() - now.getTime()) / 1000 + + if (Math.abs(diffInSeconds) < timesInSeconds.minute) return rtf.format(Math.round(diffInSeconds), 'second') + if (Math.abs(diffInSeconds) < timesInSeconds.hour) return rtf.format(Math.round(diffInSeconds / timesInSeconds.minute), 'minute') + if (Math.abs(diffInSeconds) < timesInSeconds.day) return rtf.format(Math.round(diffInSeconds / timesInSeconds.hour), 'hour') + if (Math.abs(diffInSeconds) < timesInSeconds.week) return rtf.format(Math.round(diffInSeconds / timesInSeconds.day), 'day') + if (Math.abs(diffInSeconds) < timesInSeconds.monthImprecise) return rtf.format(Math.round(diffInSeconds / timesInSeconds.week), 'week') + if (Math.abs(diffInSeconds) < timesInSeconds.yearImprecise) return rtf.format(Math.round(diffInSeconds / timesInSeconds.monthImprecise), 'month') + + return rtf.format(Math.round(diffInSeconds / timesInSeconds.yearImprecise), 'year') +} diff --git a/web/utils/listPaging.ts b/web/utils/listPaging.ts new file mode 100644 index 00000000..215faf12 --- /dev/null +++ b/web/utils/listPaging.ts @@ -0,0 +1 @@ +export const LIST_PAGE_SIZE = 25 diff --git a/web/utils/overviewRecentPatientToPatientViewModel.ts b/web/utils/overviewRecentPatientToPatientViewModel.ts new file mode 100644 index 00000000..374aadf1 --- /dev/null +++ b/web/utils/overviewRecentPatientToPatientViewModel.ts @@ -0,0 +1,26 @@ +import type { PatientViewModel } from '@/components/tables/PatientList' +import type { GetOverviewDataQuery, TaskType } from '@/api/gql/generated' +import { PatientState } from '@/api/gql/generated' + +type OverviewRecentPatient = GetOverviewDataQuery['recentPatients'][0] + +const ADMITTED_OR_WAITING: PatientState[] = [PatientState.Admitted, PatientState.Wait] + +export function overviewRecentPatientToPatientViewModel(p: OverviewRecentPatient): PatientViewModel { + const tasks = (p.tasks ?? []) as TaskType[] + const countForAggregate = ADMITTED_OR_WAITING.includes(p.state) + return { + id: p.id, + name: p.name, + firstname: p.firstname, + lastname: p.lastname, + birthdate: new Date(p.birthdate), + sex: p.sex, + state: p.state, + position: p.position as PatientViewModel['position'], + openTasksCount: countForAggregate ? tasks.filter(t => !t.done).length : 0, + closedTasksCount: countForAggregate ? tasks.filter(t => t.done).length : 0, + tasks, + properties: p.properties ?? [], + } +} diff --git a/web/utils/overviewRecentTaskToTaskViewModel.ts b/web/utils/overviewRecentTaskToTaskViewModel.ts new file mode 100644 index 00000000..a7a886e2 --- /dev/null +++ b/web/utils/overviewRecentTaskToTaskViewModel.ts @@ -0,0 +1,51 @@ +import type { TaskViewModel } from '@/components/tables/TaskList' +import type { GetOverviewDataQuery } from '@/api/gql/generated' + +type OverviewRecentTask = GetOverviewDataQuery['recentTasks'][0] + +export function overviewRecentTaskToTaskViewModel(task: OverviewRecentTask): TaskViewModel { + return { + id: task.id, + name: task.title, + description: task.description ?? undefined, + updateDate: task.updateDate ? new Date(task.updateDate) : new Date(task.creationDate), + dueDate: task.dueDate ? new Date(task.dueDate) : undefined, + priority: task.priority ?? null, + estimatedTime: task.estimatedTime ?? null, + done: task.done, + patient: task.patient + ? { + id: task.patient.id, + name: task.patient.name, + locations: task.patient.position + ? [{ + id: task.patient.position.id, + title: task.patient.position.title, + parent: task.patient.position.parent + ? { + id: task.patient.position.parent.id, + title: task.patient.position.parent.title, + parent: null, + } + : undefined, + }] + : [], + } + : undefined, + assignee: task.assignees[0] + ? { + id: task.assignees[0].id, + name: task.assignees[0].name, + avatarURL: task.assignees[0].avatarUrl, + isOnline: task.assignees[0].isOnline ?? null, + } + : undefined, + assigneeTeam: task.assigneeTeam + ? { id: task.assigneeTeam.id, title: task.assigneeTeam.title } + : undefined, + additionalAssigneeCount: + !task.assigneeTeam && task.assignees.length > 1 ? task.assignees.length - 1 : 0, + sourceTaskPresetId: task.sourceTaskPresetId ?? null, + properties: task.properties ?? [], + } +} diff --git a/web/utils/propertyColumn.tsx b/web/utils/propertyColumn.tsx index 5310125c..857ead78 100644 --- a/web/utils/propertyColumn.tsx +++ b/web/utils/propertyColumn.tsx @@ -1,5 +1,5 @@ import type { ColumnDef } from '@tanstack/table-core' -import { ColumnType, FieldType, type LocationType, type PropertyDefinitionType, type PropertyValueType, type PropertyEntity } from '@/api/gql/generated' +import { FieldType, type LocationType, type PropertyDefinitionType, type PropertyValueType, type PropertyEntity } from '@/api/gql/generated' import { getPropertyFilterFn } from './propertyFilterMapping' import { PropertyCell } from '@/components/properties/PropertyCell' @@ -49,7 +49,8 @@ function getFilterData(prop: PropertyDefinitionType) { } export function createPropertyColumn( - prop: PropertyDefinitionType + prop: PropertyDefinitionType, + hasFilter?: boolean ): ColumnDef { const columnId = `property_${prop.id}` const filterFn = getPropertyFilterFn(prop.fieldType) @@ -71,7 +72,8 @@ export function createPropertyColumn( return () }, meta: { - columnType: ColumnType.Property, + columnType: 'PROPERTY', + columnLabel: prop.name, propertyDefinitionId: prop.id, fieldType: prop.fieldType, ...(filterData && { filterData }), @@ -79,7 +81,7 @@ export function createPropertyColumn( minSize: 220, size: 220, maxSize: 300, - filterFn, + filterFn: hasFilter ? filterFn : undefined, } as ColumnDef } @@ -89,11 +91,12 @@ type PropertyDefinitionsData = { export function getPropertyColumnsForEntity( propertyDefinitionsData: PropertyDefinitionsData, - entity: PropertyEntity + entity: PropertyEntity, + hasFilter?: boolean ): ColumnDef[] { if (!propertyDefinitionsData?.propertyDefinitions) return [] const properties = propertyDefinitionsData.propertyDefinitions.filter( def => def.isActive && def.allowedEntities.includes(entity) ) - return properties.map(prop => createPropertyColumn(prop)) + return properties.map(prop => createPropertyColumn(prop, hasFilter)) } diff --git a/web/utils/propertyFilterMapping.ts b/web/utils/propertyFilterMapping.ts index a73a32e0..06a3bfe1 100644 --- a/web/utils/propertyFilterMapping.ts +++ b/web/utils/propertyFilterMapping.ts @@ -1,5 +1,5 @@ import { FieldType } from '@/api/gql/generated' -import type { TableFilterCategory } from '@helpwave/hightide' +import type { DataType } from '@helpwave/hightide' /** * Maps a FieldType to the appropriate filter function name for TanStack Table. @@ -9,7 +9,7 @@ import type { TableFilterCategory } from '@helpwave/hightide' * - date vs datetime (for proper date/time filtering) * - tags (multi-select) vs tags_single (single select) */ -export function getPropertyFilterFn(fieldType: FieldType): TableFilterCategory { +export function getPropertyFilterFn(fieldType: FieldType): DataType { switch (fieldType) { case FieldType.FieldTypeCheckbox: return 'boolean' diff --git a/web/utils/queryableFilterList.tsx b/web/utils/queryableFilterList.tsx new file mode 100644 index 00000000..169bf9bb --- /dev/null +++ b/web/utils/queryableFilterList.tsx @@ -0,0 +1,102 @@ +import type { ReactNode } from 'react' +import type { FilterListItem, FilterListPopUpBuilderProps, FilterValue } from '@helpwave/hightide' +import type { DataType } from '@helpwave/hightide' +import type { QueryableField } from '@/api/gql/generated' +import { FieldType, QueryableFieldKind, QueryableValueType } from '@/api/gql/generated' +import { AssigneeFilterActiveLabel } from '@/components/tables/AssigneeFilterActiveLabel' +import { LocationFilterActiveLabel } from '@/components/tables/LocationFilterActiveLabel' +import { LocationSubtreeFilterPopUp } from '@/components/tables/LocationSubtreeFilterPopUp' +import { UserSelectFilterPopUp } from '@/components/tables/UserSelectFilterPopUp' + +function valueKindToDataType(field: QueryableField): DataType { + const vt = field.valueType + const k = field.kind + if (k === QueryableFieldKind.Choice) return 'singleTag' + if (k === QueryableFieldKind.ChoiceList) return 'multiTags' + if (k === QueryableFieldKind.Reference) return 'text' + if (vt === QueryableValueType.Boolean) return 'boolean' + if (vt === QueryableValueType.Number) return 'number' + if (vt === QueryableValueType.Date) return 'date' + if (vt === QueryableValueType.Datetime) return 'dateTime' + return 'text' +} + +function filterFieldDataType(field: QueryableField): DataType { + if (field.key === 'position' || field.key === 'assignee') return 'singleTag' + return valueKindToDataType(field) +} + +export type QueryableSortListItem = Pick +export type QueryableFieldLabelResolver = (field: QueryableField) => string +export type QueryableChoiceTagLabelResolver = ( + field: QueryableField, + optionKey: string, + backendLabel: string, +) => string + +export function queryableFieldsToFilterListItems( + fields: QueryableField[], + propertyFieldTypeByDefId: Map, + resolveLabel?: QueryableFieldLabelResolver, + resolveChoiceTagLabel?: QueryableChoiceTagLabelResolver +): FilterListItem[] { + return fields.filter(field => field.filterable).map((field): FilterListItem => { + const dataType = filterFieldDataType(field) + const label = resolveLabel ? resolveLabel(field) : field.label + const tags = field.choice + ? field.choice.optionLabels.map((backendLabel, idx) => { + const optionKey = field.choice!.optionKeys[idx] ?? backendLabel + const displayLabel = resolveChoiceTagLabel + ? resolveChoiceTagLabel(field, optionKey, backendLabel) + : backendLabel + return { label: displayLabel, tag: optionKey } + }) + : [] + + const ft = field.propertyDefinitionId + ? propertyFieldTypeByDefId.get(field.propertyDefinitionId) + : undefined + + const isUserFilterUi = ft === FieldType.FieldTypeUser || field.key === 'assignee' + + return { + id: field.key, + label, + dataType, + tags, + activeLabelBuilder: field.key === 'position' + ? (v: FilterValue): ReactNode => ( + <> + {label} + + + ) + : isUserFilterUi + ? (v: FilterValue): ReactNode => ( + <> + {label} + + + ) + : undefined, + popUpBuilder: isUserFilterUi + ? (props: FilterListPopUpBuilderProps): ReactNode => () + : field.key === 'position' + ? (props: FilterListPopUpBuilderProps): ReactNode => () + : undefined, + } + }) +} + +export function queryableFieldsToSortingListItems( + fields: QueryableField[], + resolveLabel?: QueryableFieldLabelResolver +): QueryableSortListItem[] { + return fields + .filter(field => field.sortable && field.sortDirections.length > 0) + .map((field): QueryableSortListItem => ({ + id: field.key, + label: resolveLabel ? resolveLabel(field) : field.label, + dataType: valueKindToDataType(field), + })) +} diff --git a/web/utils/savedViewsCache.ts b/web/utils/savedViewsCache.ts new file mode 100644 index 00000000..65cc9be8 --- /dev/null +++ b/web/utils/savedViewsCache.ts @@ -0,0 +1,43 @@ +import type { ApolloCache } from '@apollo/client' +import { MySavedViewsDocument, type MySavedViewsQuery } from '@/api/gql/generated' +import { getParsedDocument } from '@/data/hooks/queryHelpers' + +type SavedViewRow = MySavedViewsQuery['mySavedViews'][number] + +const mySavedViewsQuery = { query: getParsedDocument(MySavedViewsDocument) } + +export function appendSavedViewToMySavedViewsCache(cache: ApolloCache, view: SavedViewRow): void { + cache.updateQuery(mySavedViewsQuery, (data) => { + if (!data) { + return data + } + if (data.mySavedViews.some((v) => v.id === view.id)) { + return data + } + return { ...data, mySavedViews: [...data.mySavedViews, view] } + }) +} + +export function replaceSavedViewInMySavedViewsCache(cache: ApolloCache, view: SavedViewRow): void { + cache.updateQuery(mySavedViewsQuery, (data) => { + if (!data) { + return data + } + const idx = data.mySavedViews.findIndex((v) => v.id === view.id) + if (idx === -1) { + return { ...data, mySavedViews: [...data.mySavedViews, view] } + } + const next = [...data.mySavedViews] + next[idx] = view + return { ...data, mySavedViews: next } + }) +} + +export function removeSavedViewFromMySavedViewsCache(cache: ApolloCache, id: string): void { + cache.updateQuery(mySavedViewsQuery, (data) => { + if (!data) { + return data + } + return { ...data, mySavedViews: data.mySavedViews.filter((v) => v.id !== id) } + }) +} diff --git a/web/utils/tableStateToApi.ts b/web/utils/tableStateToApi.ts index c7342bcf..0248b1fc 100644 --- a/web/utils/tableStateToApi.ts +++ b/web/utils/tableStateToApi.ts @@ -1,158 +1,199 @@ import type { ColumnFiltersState, PaginationState, SortingState } from '@tanstack/react-table' -import type { FilterInput, FilterOperator, FilterParameter, SortInput } from '@/api/gql/generated' -import { ColumnType, SortDirection } from '@/api/gql/generated' -import type { TableFilterValue } from '@helpwave/hightide' +import type { QueryFilterClauseInput, QueryFilterValueInput, QuerySortClauseInput } from '@/api/gql/generated' +import { QueryOperator, SortDirection } from '@/api/gql/generated' +import type { DataType, FilterValue, FilterOperator as HightideFilterOperator } from '@helpwave/hightide' -const TABLE_OPERATOR_TO_API: Record = { - textEquals: 'TEXT_EQUALS' as FilterOperator, - textNotEquals: 'TEXT_NOT_EQUALS' as FilterOperator, - textContains: 'TEXT_CONTAINS' as FilterOperator, - textNotContains: 'TEXT_NOT_CONTAINS' as FilterOperator, - textStartsWith: 'TEXT_STARTS_WITH' as FilterOperator, - textEndsWith: 'TEXT_ENDS_WITH' as FilterOperator, - textNotWhitespace: 'TEXT_NOT_WHITESPACE' as FilterOperator, - numberEquals: 'NUMBER_EQUALS' as FilterOperator, - numberNotEquals: 'NUMBER_NOT_EQUALS' as FilterOperator, - numberGreaterThan: 'NUMBER_GREATER_THAN' as FilterOperator, - numberGreaterThanOrEqual: 'NUMBER_GREATER_THAN_OR_EQUAL' as FilterOperator, - numberLessThan: 'NUMBER_LESS_THAN' as FilterOperator, - numberLessThanOrEqual: 'NUMBER_LESS_THAN_OR_EQUAL' as FilterOperator, - numberBetween: 'NUMBER_BETWEEN' as FilterOperator, - numberNotBetween: 'NUMBER_NOT_BETWEEN' as FilterOperator, - dateEquals: 'DATE_EQUALS' as FilterOperator, - dateNotEquals: 'DATE_NOT_EQUALS' as FilterOperator, - dateGreaterThan: 'DATE_GREATER_THAN' as FilterOperator, - dateGreaterThanOrEqual: 'DATE_GREATER_THAN_OR_EQUAL' as FilterOperator, - dateLessThan: 'DATE_LESS_THAN' as FilterOperator, - dateLessThanOrEqual: 'DATE_LESS_THAN_OR_EQUAL' as FilterOperator, - dateBetween: 'DATE_BETWEEN' as FilterOperator, - dateNotBetween: 'DATE_NOT_BETWEEN' as FilterOperator, - dateTimeEquals: 'DATETIME_EQUALS' as FilterOperator, - dateTimeNotEquals: 'DATETIME_NOT_EQUALS' as FilterOperator, - dateTimeGreaterThan: 'DATETIME_GREATER_THAN' as FilterOperator, - dateTimeGreaterThanOrEqual: 'DATETIME_GREATER_THAN_OR_EQUAL' as FilterOperator, - dateTimeLessThan: 'DATETIME_LESS_THAN' as FilterOperator, - dateTimeLessThanOrEqual: 'DATETIME_LESS_THAN_OR_EQUAL' as FilterOperator, - dateTimeBetween: 'DATETIME_BETWEEN' as FilterOperator, - dateTimeNotBetween: 'DATETIME_NOT_BETWEEN' as FilterOperator, - booleanIsTrue: 'BOOLEAN_IS_TRUE' as FilterOperator, - booleanIsFalse: 'BOOLEAN_IS_FALSE' as FilterOperator, - tagsEquals: 'TAGS_EQUALS' as FilterOperator, - tagsNotEquals: 'TAGS_NOT_EQUALS' as FilterOperator, - tagsContains: 'TAGS_CONTAINS' as FilterOperator, - tagsNotContains: 'TAGS_NOT_CONTAINS' as FilterOperator, - tagsSingleEquals: 'TAGS_SINGLE_EQUALS' as FilterOperator, - tagsSingleNotEquals: 'TAGS_SINGLE_NOT_EQUALS' as FilterOperator, - tagsSingleContains: 'TAGS_SINGLE_CONTAINS' as FilterOperator, - tagsSingleNotContains: 'TAGS_SINGLE_NOT_CONTAINS' as FilterOperator, - isNull: 'IS_NULL' as FilterOperator, - isNotNull: 'IS_NOT_NULL' as FilterOperator, +const TABLE_OPERATOR_TO_QUERY: Record>> = { + text: { + equals: QueryOperator.Eq, + notEquals: QueryOperator.Neq, + contains: QueryOperator.Contains, + notContains: QueryOperator.Neq, + startsWith: QueryOperator.StartsWith, + endsWith: QueryOperator.EndsWith, + isNotUndefined: QueryOperator.IsNotNull, + isUndefined: QueryOperator.IsNull, + }, + number: { + equals: QueryOperator.Eq, + notEquals: QueryOperator.Neq, + greaterThan: QueryOperator.Gt, + greaterThanOrEqual: QueryOperator.Gte, + lessThan: QueryOperator.Lt, + lessThanOrEqual: QueryOperator.Lte, + between: QueryOperator.Between, + notBetween: QueryOperator.Neq, + isNotUndefined: QueryOperator.IsNotNull, + isUndefined: QueryOperator.IsNull, + }, + date: { + equals: QueryOperator.Eq, + notEquals: QueryOperator.Neq, + greaterThan: QueryOperator.Gt, + greaterThanOrEqual: QueryOperator.Gte, + lessThan: QueryOperator.Lt, + lessThanOrEqual: QueryOperator.Lte, + between: QueryOperator.Between, + notBetween: QueryOperator.Neq, + isNotUndefined: QueryOperator.IsNotNull, + isUndefined: QueryOperator.IsNull, + }, + dateTime: { + equals: QueryOperator.Eq, + notEquals: QueryOperator.Neq, + greaterThan: QueryOperator.Gt, + greaterThanOrEqual: QueryOperator.Gte, + lessThan: QueryOperator.Lt, + lessThanOrEqual: QueryOperator.Lte, + between: QueryOperator.Between, + notBetween: QueryOperator.Neq, + isNotUndefined: QueryOperator.IsNotNull, + isUndefined: QueryOperator.IsNull, + }, + boolean: { + isTrue: QueryOperator.Eq, + isFalse: QueryOperator.Eq, + isNotUndefined: QueryOperator.IsNotNull, + isUndefined: QueryOperator.IsNull, + }, + singleTag: { + equals: QueryOperator.Eq, + notEquals: QueryOperator.Neq, + contains: QueryOperator.In, + notContains: QueryOperator.Neq, + isNotUndefined: QueryOperator.IsNotNull, + isUndefined: QueryOperator.IsNull, + }, + multiTags: { + equals: QueryOperator.AllIn, + notEquals: QueryOperator.Neq, + contains: QueryOperator.AnyIn, + notContains: QueryOperator.NoneIn, + isNotUndefined: QueryOperator.IsNotNull, + isUndefined: QueryOperator.IsNull, + }, + unknownType: { + isNotUndefined: QueryOperator.IsNotNull, + isUndefined: QueryOperator.IsNull, + }, } -function tableOperatorToApi(operator: string): FilterOperator | null { - const normalized = operator.replace(/([A-Z])/g, (m) => m.toLowerCase()) - return TABLE_OPERATOR_TO_API[normalized] ?? TABLE_OPERATOR_TO_API[operator] ?? (operator in TABLE_OPERATOR_TO_API ? (operator as FilterOperator) : null) +function tableOperatorToQuery(dataType: DataType, operator: HightideFilterOperator): QueryOperator | null { + return TABLE_OPERATOR_TO_QUERY[dataType][operator] ?? null } -function toFilterParameter(value: TableFilterValue): FilterParameter { - const p = value.parameter as Record - const param: FilterParameter = { - searchText: typeof p['searchText'] === 'string' ? p['searchText'] : undefined, - isCaseSensitive: typeof p['isCaseSensitive'] === 'boolean' ? p['isCaseSensitive'] : false, - compareValue: typeof p['compareValue'] === 'number' ? p['compareValue'] : undefined, - min: typeof p['min'] === 'number' ? p['min'] : undefined, - max: typeof p['max'] === 'number' ? p['max'] : undefined, - } - if (p['compareDate'] instanceof Date) { - param.compareDate = (p['compareDate'] as Date).toISOString().slice(0, 10) - } else if (typeof p['compareDate'] === 'string') { - param.compareDate = p['compareDate'] - } - if (p['min'] instanceof Date) param.minDate = (p['min'] as Date).toISOString().slice(0, 10) - else if (typeof p['min'] === 'string' && (p['min'] as string).length === 10) param.minDate = p['min'] as string - if (p['max'] instanceof Date) param.maxDate = (p['max'] as Date).toISOString().slice(0, 10) - else if (typeof p['max'] === 'string' && (p['max'] as string).length === 10) param.maxDate = p['max'] as string - if (p['compareDatetime'] instanceof Date) { - param.compareDateTime = (p['compareDatetime'] as Date).toISOString() - } else if (typeof p['compareDatetime'] === 'string') { - param.compareDateTime = p['compareDatetime'] - } - if (p['minDateTime'] instanceof Date) param.minDateTime = (p['minDateTime'] as Date).toISOString() - else if (typeof p['minDateTime'] === 'string') param.minDateTime = p['minDateTime'] - if (p['maxDateTime'] instanceof Date) param.maxDateTime = (p['maxDateTime'] as Date).toISOString() - else if (typeof p['maxDateTime'] === 'string') param.maxDateTime = p['maxDateTime'] - if (Array.isArray(p['searchTags'])) { - param.searchTags = (p['searchTags'] as unknown[]).filter((t): t is string => typeof t === 'string') - } - if (Array.isArray(p['searchTagsContains']) && (param.searchTags == null || param.searchTags.length === 0)) { - param.searchTags = (p['searchTagsContains'] as unknown[]).filter((t): t is string => typeof t === 'string') - } - if (param.searchTags == null && p['searchTag'] != null) { - param.searchTags = [String(p['searchTag'])] - } - if (typeof p['propertyDefinitionId'] === 'string') { - param.propertyDefinitionId = p['propertyDefinitionId'] - } - return param +function formatLocalDateOnly(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` } -const TASK_COLUMN_TO_BACKEND: Record = { - dueDate: 'due_date', - updateDate: 'update_date', - creationDate: 'creation_date', - estimatedTime: 'estimated_time', - assigneeTeam: 'assignee_team_id', +function toGraphqlDateInput(value: unknown): string | undefined { + if (value == null) return undefined + if (typeof value === 'string') { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return undefined + return formatLocalDateOnly(parsed) + } + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return undefined + return formatLocalDateOnly(value) + } + return undefined } -function isPropertyColumnId(id: string): boolean { - return id.startsWith('property_') +function localCalendarDateToIso(dateYmd: string): string | undefined { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateYmd) + if (!match?.[1] || !match[2] || !match[3]) return undefined + const y = Number(match[1]) + const m = Number(match[2]) + const d = Number(match[3]) + const dt = new Date(y, m - 1, d) + if (Number.isNaN(dt.getTime())) return undefined + return dt.toISOString() } -function getPropertyDefinitionId(id: string): string | undefined { - if (!isPropertyColumnId(id)) return undefined - return id.replace(/^property_/, '') +function filterDateValueForDataType(value: FilterValue): string | undefined { + const parameter = value.parameter + if (value.dataType === 'dateTime') { + if (parameter.dateValue == null) return undefined + return parameter.dateValue.toISOString() + } + const day = toGraphqlDateInput(parameter.dateValue) + if (!day) return undefined + return localCalendarDateToIso(day) } -function columnIdToBackend(columnId: string, entity: 'task' | 'patient'): string { - if (entity === 'task' && TASK_COLUMN_TO_BACKEND[columnId]) { - return TASK_COLUMN_TO_BACKEND[columnId] +function toQueryFilterValue(value: FilterValue): QueryFilterValueInput { + const parameter = value.parameter + const raw = parameter as Record + const multi = parameter.uuidValues + const hasMulti = Array.isArray(multi) && multi.length > 0 + const hasSingle = parameter.uuidValue != null && String(parameter.uuidValue) !== '' + let searchTagsUnknownType: unknown[] = [] + if (!hasMulti && !hasSingle) { + if (Array.isArray(raw['searchTags']) && raw['searchTags'].length > 0) { + searchTagsUnknownType = raw['searchTags'] as unknown[] + } else if (Array.isArray(raw['searchTagsContains']) && raw['searchTagsContains'].length > 0) { + searchTagsUnknownType = raw['searchTagsContains'] as unknown[] + } else if (raw['searchTag'] != null) { + searchTagsUnknownType = [raw['searchTag']] + } + } + const searchTags: string[] = searchTagsUnknownType.map((t) => String(t)) + const base: QueryFilterValueInput = { + stringValue: parameter.stringValue, + floatValue: parameter.numberValue, + floatMin: parameter.numberMin, + floatMax: parameter.numberMax, + dateValue: filterDateValueForDataType(value), + dateMin: toGraphqlDateInput(parameter.dateMin), + dateMax: toGraphqlDateInput(parameter.dateMax), + stringValues: searchTags.length > 0 ? searchTags : undefined, + uuidValue: hasSingle ? String(parameter.uuidValue) : undefined, + uuidValues: hasMulti ? (multi as string[]) : undefined, } - return columnId + if (value.dataType === 'singleTag' && value.operator === 'equals' && searchTags.length === 1 && !hasSingle) { + base.stringValue = searchTags[0] + base.stringValues = undefined + } + if (value.dataType === 'boolean') { + if (value.operator === 'isTrue') { + base.boolValue = true + } else if (value.operator === 'isFalse') { + base.boolValue = false + } + } + return base } -export function columnFiltersToFilterInput( - filters: ColumnFiltersState, - entity: 'task' | 'patient' = 'patient' -): FilterInput[] { - const result: FilterInput[] = [] +export function columnFiltersToQueryFilterClauses( + filters: ColumnFiltersState +): QueryFilterClauseInput[] { + const result: QueryFilterClauseInput[] = [] for (const filter of filters) { - const value = filter.value as TableFilterValue - if (!value?.operator || !value?.parameter) continue - const apiOperator = tableOperatorToApi(value.operator) + const value = filter.value as FilterValue + if (!value?.operator || !value?.parameter || !value?.dataType) continue + const apiOperator = tableOperatorToQuery(value.dataType, value.operator) if (!apiOperator) continue - const isProperty = isPropertyColumnId(filter.id) - const propertyDefinitionId = getPropertyDefinitionId(filter.id) - const column = columnIdToBackend(filter.id, entity) + const fieldKey = filter.id result.push({ - column, + fieldKey, operator: apiOperator, - parameter: toFilterParameter(value), - columnType: isProperty ? ColumnType.Property : ColumnType.DirectAttribute, - propertyDefinitionId: propertyDefinitionId ?? undefined, + value: toQueryFilterValue(value), }) } return result } -export function sortingStateToSortInput( - sorting: SortingState, - entity: 'task' | 'patient' = 'patient' -): SortInput[] { +export function sortingStateToQuerySortClauses( + sorting: SortingState +): QuerySortClauseInput[] { return sorting.map((s) => ({ - column: columnIdToBackend(s.id, entity), - direction: s.desc ? SortDirection.Desc : SortDirection.Asc, - columnType: isPropertyColumnId(s.id) ? ColumnType.Property : ColumnType.DirectAttribute, - propertyDefinitionId: getPropertyDefinitionId(s.id) ?? undefined, + fieldKey: s.id, + direction: s.desc ? SortDirection.Desc : SortDirection.Asc })) } @@ -162,3 +203,6 @@ export function paginationStateToPaginationInput(pagination: PaginationState): { pageSize: pagination.pageSize ?? 10, } } + +export { columnFiltersToQueryFilterClauses as columnFiltersToFilterInput } +export { sortingStateToQuerySortClauses as sortingStateToSortInput } diff --git a/web/utils/taskGraph.ts b/web/utils/taskGraph.ts new file mode 100644 index 00000000..6c941052 --- /dev/null +++ b/web/utils/taskGraph.ts @@ -0,0 +1,74 @@ +import type { TaskGraphInput, TaskGraphNodeInput, TaskPriority } from '@/api/gql/generated' +import type { SuggestedTaskItem } from '@/types/systemSuggestion' + +export type TaskPresetListRow = { + title: string, + description: string, + priority: TaskPriority | null, + estimatedTime: number | null, +} + +export function listRowsToTaskGraphInput(rows: TaskPresetListRow[]): TaskGraphInput { + const trimmed = rows.map(r => ({ + title: r.title.trim(), + description: r.description.trim(), + priority: r.priority, + estimatedTime: r.estimatedTime, + })).filter(r => r.title.length > 0) + const nodes: TaskGraphNodeInput[] = trimmed.map((r, i) => ({ + nodeId: `n${i + 1}`, + title: r.title, + description: r.description.length > 0 ? r.description : undefined, + priority: r.priority ?? undefined, + estimatedTime: r.estimatedTime ?? undefined, + })) + const edges = [] + for (let i = 0; i < nodes.length - 1; i++) { + const a = nodes[i] + const b = nodes[i + 1] + if (!a || !b) continue + edges.push({ + fromNodeId: a.nodeId, + toNodeId: b.nodeId, + }) + } + return { nodes, edges } +} + +export function graphNodesToListRows(graph: { + nodes: Array<{ + id: string, + title: string, + description?: string | null, + priority?: string | null, + estimatedTime?: number | null, + }>, +}): TaskPresetListRow[] { + return graph.nodes.map(n => ({ + title: n.title, + description: n.description ?? '', + priority: (n.priority as TaskPriority | null) ?? null, + estimatedTime: n.estimatedTime ?? null, + })) +} + +export function suggestionItemsToTaskGraphInput(items: SuggestedTaskItem[]): TaskGraphInput { + const nodes: TaskGraphNodeInput[] = items.map((t, i) => ({ + nodeId: `s-${i}-${t.id}`, + title: t.title, + description: t.description ?? undefined, + priority: undefined, + estimatedTime: undefined, + })) + const edges = [] + for (let i = 0; i < nodes.length - 1; i++) { + const a = nodes[i] + const b = nodes[i + 1] + if (!a || !b) continue + edges.push({ + fromNodeId: a.nodeId, + toNodeId: b.nodeId, + }) + } + return { nodes, edges } +} diff --git a/web/utils/viewDefinition.ts b/web/utils/viewDefinition.ts new file mode 100644 index 00000000..f4b025b6 --- /dev/null +++ b/web/utils/viewDefinition.ts @@ -0,0 +1,218 @@ +import type { + ColumnFilter, + ColumnFiltersState, + ColumnOrderState, + SortingState, + VisibilityState +} from '@tanstack/react-table' +import type { DataType, FilterOperator, FilterParameter, FilterValue } from '@helpwave/hightide' + +export type ViewParameters = { + rootLocationIds?: string[], + locationId?: string, + searchQuery?: string, + assigneeId?: string, + columnVisibility?: VisibilityState, + columnOrder?: ColumnOrderState, +} + +export function hasActiveLocationFilter(filters: ColumnFiltersState): boolean { + return filters.some(f => { + if (f.id !== 'position' && f.id !== 'locationSubtree') return false + const v = f.value as FilterValue | undefined + if (!v?.parameter) return false + const p = v.parameter + if (p.uuidValue != null && String(p.uuidValue) !== '') return true + if (Array.isArray(p.uuidValues) && p.uuidValues.length > 0) return true + return false + }) +} + +export function normalizedVisibilityForViewCompare(v: VisibilityState): string { + const keys = Object.keys(v).sort() + const sorted: Record = {} + for (const k of keys) { + sorted[k] = v[k] as boolean + } + return JSON.stringify(sorted) +} + +export function visibilityMatchesViewBaseline( + current: VisibilityState, + baseline: VisibilityState | undefined +): boolean { + return normalizedVisibilityForViewCompare(current) === normalizedVisibilityForViewCompare(baseline ?? {}) +} + +export function expandVisibilityWithPropertyColumnDefaults( + v: VisibilityState, + propertyColumnIds: readonly string[] +): VisibilityState { + if (propertyColumnIds.length === 0) { + return v + } + const out: VisibilityState = { ...v } + for (const id of propertyColumnIds) { + if (!(id in out)) { + out[id] = false + } + } + return out +} + +export function visibilityMatchesViewBaselineForDirty( + current: VisibilityState, + baseline: VisibilityState | undefined, + propertyColumnIds: readonly string[] +): boolean { + const base = baseline ?? {} + return normalizedVisibilityForViewCompare( + expandVisibilityWithPropertyColumnDefaults(current, propertyColumnIds) + ) === normalizedVisibilityForViewCompare( + expandVisibilityWithPropertyColumnDefaults(base, propertyColumnIds) + ) +} + +export function normalizedColumnOrderForViewCompare(order: ColumnOrderState): string { + return JSON.stringify(order) +} + +export function columnOrderMatchesViewBaseline( + current: ColumnOrderState, + baseline: ColumnOrderState | undefined +): boolean { + return normalizedColumnOrderForViewCompare(current) === normalizedColumnOrderForViewCompare(baseline ?? []) +} + +export function columnOrderMatchesBaselineForDirty( + current: ColumnOrderState, + baseline: ColumnOrderState | undefined +): boolean { + const b = baseline ?? [] + if (b.length === 0) { + return true + } + return normalizedColumnOrderForViewCompare(current) === normalizedColumnOrderForViewCompare(b) +} + +export function parseViewParameters(json: string): ViewParameters { + try { + const v = JSON.parse(json) as unknown + if (!v || typeof v !== 'object') return {} + return v as ViewParameters + } catch { + return {} + } +} + +export function stringifyViewParameters(p: ViewParameters): string { + return JSON.stringify(p) +} + +/** Wire format for `filterDefinition` on saved views (JSON string). */ +export function serializeColumnFiltersForView(filters: ColumnFiltersState): string { + const mappedColumnFilter = filters.map((filter) => { + const tableFilterValue = filter.value as FilterValue + const filterParameter = tableFilterValue.parameter + const parameter: Record = { + ...filterParameter, + dateValue: filterParameter.dateValue ? filterParameter.dateValue.toISOString() : undefined, + dateMin: filterParameter.dateMin ? filterParameter.dateMin.toISOString() : undefined, + dateMax: filterParameter.dateMax ? filterParameter.dateMax.toISOString() : undefined, + } + return { + ...filter, + id: filter.id, + value: { + ...tableFilterValue, + parameter, + }, + } + }) + return JSON.stringify(mappedColumnFilter) +} + +export function deserializeColumnFiltersFromView(json: string): ColumnFiltersState { + try { + const mappedColumnFilter = JSON.parse(json) as Record[] + return mappedColumnFilter.map((filter): ColumnFilter => { + const filterId = filter['id'] + const resolvedId = filterId === 'locationSubtree' ? 'position' : filterId + const value = filter['value'] as Record + const parameter = value['parameter'] as Record + const dateValueRaw = parameter['dateValue'] ?? parameter['compareDate'] + const dateMinRaw = parameter['dateMin'] ?? parameter['minDate'] + const dateMaxRaw = parameter['dateMax'] ?? parameter['maxDate'] + const parseStoredDate = (raw: unknown): Date | undefined => { + if (raw == null || raw === '') return undefined + const d = new Date(String(raw)) + return Number.isNaN(d.getTime()) ? undefined : d + } + const filterParameter: FilterParameter = { + stringValue: (parameter['stringValue'] ?? parameter['searchText']) as string | undefined, + numberValue: (parameter['numberValue'] ?? parameter['compareValue']) as number | undefined, + numberMin: (parameter['numberMin'] ?? parameter['minNumber']) as number | undefined, + numberMax: (parameter['numberMax'] ?? parameter['maxNumber']) as number | undefined, + booleanValue: parameter['booleanValue'] as boolean | undefined, + dateValue: parseStoredDate(dateValueRaw), + dateMin: parseStoredDate(dateMinRaw), + dateMax: parseStoredDate(dateMaxRaw), + uuidValue: parameter['uuidValue'] ?? parameter['singleOptionSearch'], + uuidValues: (parameter['uuidValues'] ?? parameter['multiOptionSearch']) as unknown[] | undefined, + } + const mappedValue: FilterValue = { + operator: value['operator'] as FilterOperator, + dataType: value['dataType'] as DataType, + parameter: filterParameter, + } + return { + ...filter, + id: resolvedId as string, + value: mappedValue, + } as ColumnFilter + }) + } catch { + return [] + } +} + +export function serializeSortingForView(sorting: SortingState): string { + return JSON.stringify(sorting) +} + +export function deserializeSortingFromView(json: string): SortingState { + try { + const v = JSON.parse(json) as unknown + if (!Array.isArray(v)) return [] + return v as SortingState + } catch { + return [] + } +} + +export function tableViewStateMatchesBaseline(params: { + filters: ColumnFiltersState, + baselineFilters: ColumnFiltersState, + sorting: SortingState, + baselineSorting: SortingState, + searchQuery: string, + baselineSearch: string, + columnVisibility: VisibilityState, + baselineColumnVisibility: VisibilityState | undefined, + columnOrder: ColumnOrderState, + baselineColumnOrder: ColumnOrderState | undefined, + propertyColumnIds: readonly string[], +}): boolean { + const filtersMatch = + serializeColumnFiltersForView(params.filters) === serializeColumnFiltersForView(params.baselineFilters) + const sortMatch = + serializeSortingForView(params.sorting) === serializeSortingForView(params.baselineSorting) + const searchMatch = params.searchQuery === params.baselineSearch + const visMatch = visibilityMatchesViewBaselineForDirty( + params.columnVisibility, + params.baselineColumnVisibility, + params.propertyColumnIds + ) + const orderMatch = columnOrderMatchesBaselineForDirty(params.columnOrder, params.baselineColumnOrder) + return filtersMatch && sortMatch && searchMatch && visMatch && orderMatch +} diff --git a/web/utils/virtualDerivedTableState.ts b/web/utils/virtualDerivedTableState.ts new file mode 100644 index 00000000..fc524f76 --- /dev/null +++ b/web/utils/virtualDerivedTableState.ts @@ -0,0 +1,464 @@ +import type { ColumnFilter, ColumnFiltersState } from '@tanstack/react-table' +import type { SortingState } from '@tanstack/table-core' +import type { FilterOperator, FilterValue } from '@helpwave/hightide' +import type { TaskViewModel } from '@/components/tables/TaskList' +import type { PatientViewModel } from '@/components/tables/PatientList' + +function normalizeLower(s: string | undefined | null): string { + return (s ?? '').toLowerCase() +} + +function calendarDateParts(d: Date): { y: number, m: number, day: number } { + return { y: d.getFullYear(), m: d.getMonth(), day: d.getDate() } +} + +function compareCalendarDate(a: Date, b: Date): number { + const ca = calendarDateParts(a) + const cb = calendarDateParts(b) + if (ca.y !== cb.y) return ca.y - cb.y + if (ca.m !== cb.m) return ca.m - cb.m + return ca.day - cb.day +} + +function taskPropertyText(task: TaskViewModel, definitionId: string): string { + const prop = task.properties?.find((p) => p.definition.id === definitionId) + return prop?.textValue ?? '' +} + +function patientPropertyText(patient: PatientViewModel, definitionId: string): string { + const prop = patient.properties?.find((p) => p.definition.id === definitionId) + return prop?.textValue ?? '' +} + +function matchesTextOperator( + haystack: string, + operator: FilterOperator, + needle: string +): boolean { + const h = normalizeLower(haystack) + const n = normalizeLower(needle) + switch (operator) { + case 'contains': + return h.includes(n) + case 'notContains': + return !h.includes(n) + case 'equals': + return h === n + case 'notEquals': + return h !== n + case 'startsWith': + return h.startsWith(n) + case 'endsWith': + return h.endsWith(n) + case 'isUndefined': + return haystack === '' + case 'isNotUndefined': + return haystack !== '' + default: + return true + } +} + +function matchesNumberOperator( + value: number | undefined, + operator: FilterOperator, + p: FilterValue['parameter'] +): boolean { + const v = value + const eq = p.numberValue + const min = p.numberMin + const max = p.numberMax + switch (operator) { + case 'equals': + return v != null && eq != null && v === eq + case 'notEquals': + return v == null || eq == null || v !== eq + case 'greaterThan': + return v != null && eq != null && v > eq + case 'greaterThanOrEqual': + return v != null && eq != null && v >= eq + case 'lessThan': + return v != null && eq != null && v < eq + case 'lessThanOrEqual': + return v != null && eq != null && v <= eq + case 'between': + return v != null && min != null && max != null && v >= min && v <= max + case 'notBetween': + return v == null || min == null || max == null || v < min || v > max + case 'isUndefined': + return v == null + case 'isNotUndefined': + return v != null + default: + return true + } +} + +function matchesDateOperator( + value: Date | undefined, + operator: FilterOperator, + fv: FilterValue +): boolean { + const p = fv.parameter + if (operator === 'isUndefined') return value == null + if (operator === 'isNotUndefined') return value != null + if (value == null) return false + const cmp = p.dateValue + const dmin = p.dateMin + const dmax = p.dateMax + if (fv.dataType === 'dateTime') { + const t = value.getTime() + switch (operator) { + case 'equals': + return cmp != null && Math.abs(t - cmp.getTime()) < 60000 + case 'notEquals': + return cmp == null || Math.abs(t - cmp.getTime()) >= 60000 + case 'greaterThan': + return cmp != null && t > cmp.getTime() + case 'greaterThanOrEqual': + return cmp != null && t >= cmp.getTime() + case 'lessThan': + return cmp != null && t < cmp.getTime() + case 'lessThanOrEqual': + return cmp != null && t <= cmp.getTime() + case 'between': + return dmin != null && dmax != null && t >= dmin.getTime() && t <= dmax.getTime() + case 'notBetween': + return dmin == null || dmax == null || t < dmin.getTime() || t > dmax.getTime() + default: + return true + } + } + switch (operator) { + case 'equals': + return cmp != null && compareCalendarDate(value, cmp) === 0 + case 'notEquals': + return cmp == null || compareCalendarDate(value, cmp) !== 0 + case 'greaterThan': + return cmp != null && compareCalendarDate(value, cmp) > 0 + case 'greaterThanOrEqual': + return cmp != null && compareCalendarDate(value, cmp) >= 0 + case 'lessThan': + return cmp != null && compareCalendarDate(value, cmp) < 0 + case 'lessThanOrEqual': + return cmp != null && compareCalendarDate(value, cmp) <= 0 + case 'between': + return dmin != null && dmax != null + && compareCalendarDate(value, dmin) >= 0 && compareCalendarDate(value, dmax) <= 0 + case 'notBetween': + return dmin == null || dmax == null + || compareCalendarDate(value, dmin) < 0 || compareCalendarDate(value, dmax) > 0 + default: + return true + } +} + +function matchesBooleanOperator(done: boolean, operator: FilterOperator): boolean { + if (operator === 'isTrue') return done === true + if (operator === 'isFalse') return done === false + return true +} + +function matchesSingleTagOperator( + value: string | undefined, + operator: FilterOperator, + fv: FilterValue +): boolean { + const p = fv.parameter + const tags = (p as { uuidValues?: unknown[], stringValue?: string }).uuidValues as string[] | undefined + const single = p.stringValue ?? (tags?.length === 1 ? tags[0] : undefined) + const v = value ?? '' + switch (operator) { + case 'equals': + return v === single + case 'notEquals': + return v !== single + case 'contains': + return tags != null && tags.includes(v) + case 'notContains': + return tags == null || !tags.includes(v) + case 'isUndefined': + return v === '' + case 'isNotUndefined': + return v !== '' + default: + return true + } +} + +function taskMatchesColumnFilter(task: TaskViewModel, filter: ColumnFilter): boolean { + const value = filter.value as FilterValue | undefined + if (!value?.operator || !value.parameter || !value.dataType) return true + const id = filter.id + const op = value.operator + const fv = value + + if (id === 'done') { + return matchesBooleanOperator(task.done, op) + } + if (id === 'title' || id === 'name') { + return matchesTextOperator(task.name, op, fv.parameter.stringValue ?? '') + } + if (id === 'description') { + return matchesTextOperator(task.description ?? '', op, fv.parameter.stringValue ?? '') + } + if (id === 'dueDate') { + return matchesDateOperator(task.dueDate, op, fv) + } + if (id === 'priority') { + return matchesSingleTagOperator(task.priority ?? undefined, op, fv) + } + if (id === 'patient') { + return matchesTextOperator(task.patient?.name ?? '', op, fv.parameter.stringValue ?? '') + } + if (id === 'assignee') { + const label = task.assignee?.name ?? task.assigneeTeam?.title ?? '' + return matchesTextOperator(label, op, fv.parameter.stringValue ?? '') + } + if (id === 'assigneeTeam') { + return matchesTextOperator(task.assigneeTeam?.title ?? '', op, fv.parameter.stringValue ?? '') + } + if (id === 'updated' || id === 'updateDate') { + return matchesDateOperator(task.updateDate, op, fv) + } + if (id === 'creationDate') { + return matchesDateOperator(task.updateDate, op, fv) + } + if (id === 'estimatedTime') { + return matchesNumberOperator(task.estimatedTime ?? undefined, op, fv.parameter) + } + if (id.startsWith('property_')) { + const defId = id.replace(/^property_/, '') + return matchesTextOperator(taskPropertyText(task, defId), op, fv.parameter.stringValue ?? '') + } + return true +} + +function patientMatchesColumnFilter(patient: PatientViewModel, filter: ColumnFilter): boolean { + const value = filter.value as FilterValue | undefined + if (!value?.operator || !value.parameter || !value.dataType) return true + const id = filter.id === 'locationSubtree' ? 'position' : filter.id + const op = value.operator + const fv = value + + if (id === 'name') { + return matchesTextOperator(patient.name, op, fv.parameter.stringValue ?? '') + } + if (id === 'state') { + const p = fv.parameter + const raw = p.uuidValues?.length ? p.uuidValues : p.stringValue ? [p.stringValue] : [] + const tags = raw.map(String) + if (tags.length === 0) return true + return tags.includes(patient.state) + } + if (id === 'sex') { + return matchesSingleTagOperator(patient.sex, op, fv) + } + if (id === 'birthdate') { + return matchesDateOperator(patient.birthdate, op, fv) + } + if (id === 'position' || id === 'locationSubtree') { + const want = fv.parameter.uuidValue != null && String(fv.parameter.uuidValue) !== '' + ? String(fv.parameter.uuidValue) + : null + const multi = fv.parameter.uuidValues as string[] | undefined + if (multi && multi.length > 0) { + const posId = patient.position?.id + return posId != null && multi.includes(posId) + } + if (want && patient.position?.id) { + return patient.position.id === want + } + return matchesTextOperator(patient.position?.title ?? '', op, fv.parameter.stringValue ?? '') + } + if (id === 'tasks') { + const open = patient.openTasksCount + const closed = patient.closedTasksCount + const total = open + closed + return matchesNumberOperator(total, op, fv.parameter) + } + if (id.startsWith('property_')) { + const defId = id.replace(/^property_/, '') + return matchesTextOperator(patientPropertyText(patient, defId), op, fv.parameter.stringValue ?? '') + } + return true +} + +function taskMatchesSearch(task: TaskViewModel, q: string): boolean { + const lower = q.trim().toLowerCase() + if (!lower) return true + if (task.name.toLowerCase().includes(lower)) return true + if ((task.description ?? '').toLowerCase().includes(lower)) return true + if ((task.patient?.name ?? '').toLowerCase().includes(lower)) return true + return false +} + +function patientMatchesSearch(patient: PatientViewModel, q: string): boolean { + const lower = q.trim().toLowerCase() + if (!lower) return true + if (patient.name.toLowerCase().includes(lower)) return true + if (patient.firstname.toLowerCase().includes(lower)) return true + if (patient.lastname.toLowerCase().includes(lower)) return true + return false +} + +function compareTaskBySortId( + a: TaskViewModel, + b: TaskViewModel, + sortId: string, + desc: boolean +): number { + const dir = desc ? -1 : 1 + const cmp = (x: number) => x * dir + + if (sortId === 'done') { + if (a.done === b.done) return 0 + return cmp(a.done ? 1 : -1) + } + if (sortId === 'title' || sortId === 'name') { + return cmp(a.name.localeCompare(b.name)) + } + if (sortId === 'description') { + return cmp((a.description ?? '').localeCompare(b.description ?? '')) + } + if (sortId === 'dueDate') { + const ta = a.dueDate?.getTime() ?? Number.POSITIVE_INFINITY + const tb = b.dueDate?.getTime() ?? Number.POSITIVE_INFINITY + if (ta === tb) return 0 + return cmp(ta < tb ? -1 : 1) + } + if (sortId === 'priority') { + return cmp((a.priority ?? '').localeCompare(b.priority ?? '')) + } + if (sortId === 'patient') { + return cmp((a.patient?.name ?? '').localeCompare(b.patient?.name ?? '')) + } + if (sortId === 'assignee') { + const la = a.assignee?.name ?? a.assigneeTeam?.title ?? '' + const lb = b.assignee?.name ?? b.assigneeTeam?.title ?? '' + return cmp(la.localeCompare(lb)) + } + if (sortId === 'assigneeTeam') { + return cmp((a.assigneeTeam?.title ?? '').localeCompare(b.assigneeTeam?.title ?? '')) + } + if (sortId === 'updated' || sortId === 'updateDate') { + const ta = a.updateDate.getTime() + const tb = b.updateDate.getTime() + if (ta === tb) return 0 + return cmp(ta < tb ? -1 : 1) + } + if (sortId === 'creationDate') { + const ta = a.updateDate.getTime() + const tb = b.updateDate.getTime() + if (ta === tb) return 0 + return cmp(ta < tb ? -1 : 1) + } + if (sortId === 'estimatedTime') { + const ea = a.estimatedTime ?? -1 + const eb = b.estimatedTime ?? -1 + if (ea === eb) return 0 + return cmp(ea < eb ? -1 : 1) + } + if (sortId.startsWith('property_')) { + const defId = sortId.replace(/^property_/, '') + return cmp(taskPropertyText(a, defId).localeCompare(taskPropertyText(b, defId))) + } + return 0 +} + +function sortTasksWithState(tasks: TaskViewModel[], sorting: SortingState): TaskViewModel[] { + const rules = sorting.length > 0 + ? sorting + : [ + { id: 'done', desc: false }, + { id: 'dueDate', desc: false }, + ] + return [...tasks].sort((a, b) => { + for (const s of rules) { + const c = compareTaskBySortId(a, b, s.id, s.desc) + if (c !== 0) return c + } + return a.id.localeCompare(b.id) + }) +} + +function comparePatientBySortId( + a: PatientViewModel, + b: PatientViewModel, + sortId: string, + desc: boolean +): number { + const dir = desc ? -1 : 1 + const cmp = (x: number) => x * dir + + if (sortId === 'name') { + return cmp(a.name.localeCompare(b.name)) + } + if (sortId === 'state') { + return cmp(a.state.localeCompare(b.state)) + } + if (sortId === 'sex') { + return cmp(a.sex.localeCompare(b.sex)) + } + if (sortId === 'birthdate') { + const ta = a.birthdate.getTime() + const tb = b.birthdate.getTime() + if (ta === tb) return 0 + return cmp(ta < tb ? -1 : 1) + } + if (sortId === 'position') { + return cmp((a.position?.title ?? '').localeCompare(b.position?.title ?? '')) + } + if (sortId === 'tasks') { + const ta = a.openTasksCount + a.closedTasksCount + const tb = b.openTasksCount + b.closedTasksCount + if (ta === tb) return 0 + return cmp(ta < tb ? -1 : 1) + } + if (sortId === 'updateDate') { + return cmp(a.name.localeCompare(b.name)) + } + if (sortId.startsWith('property_')) { + const defId = sortId.replace(/^property_/, '') + return cmp(patientPropertyText(a, defId).localeCompare(patientPropertyText(b, defId))) + } + return 0 +} + +function sortPatientsWithState(patients: PatientViewModel[], sorting: SortingState): PatientViewModel[] { + const rules = sorting.length > 0 ? sorting : [{ id: 'name', desc: false }] + return [...patients].sort((a, b) => { + for (const s of rules) { + const c = comparePatientBySortId(a, b, s.id, s.desc) + if (c !== 0) return c + } + return a.id.localeCompare(b.id) + }) +} + +export function applyVirtualDerivedTasks( + tasks: TaskViewModel[], + filters: ColumnFiltersState, + sorting: SortingState, + searchQuery: string +): TaskViewModel[] { + let out = tasks.filter((t) => taskMatchesSearch(t, searchQuery)) + for (const f of filters) { + out = out.filter((t) => taskMatchesColumnFilter(t, f)) + } + return sortTasksWithState(out, sorting) +} + +export function applyVirtualDerivedPatients( + patients: PatientViewModel[], + filters: ColumnFiltersState, + sorting: SortingState, + searchQuery: string +): PatientViewModel[] { + let out = patients.filter((p) => patientMatchesSearch(p, searchQuery)) + for (const f of filters) { + out = out.filter((p) => patientMatchesColumnFilter(p, f)) + } + return sortPatientsWithState(out, sorting) +} +