From 583af7c5b444f1143dff634c4fc040566095ee0f Mon Sep 17 00:00:00 2001 From: Arkadiusz Bachorski <60391032+arkadiuszbachorski@users.noreply.github.com> Date: Tue, 13 Aug 2024 21:18:41 +0200 Subject: [PATCH 1/4] Labs actions --- app/(dashboard)/patients/[id]/LabForm.tsx | 175 ++++++++++++++++++++++ app/(dashboard)/patients/[id]/LabMenu.tsx | 80 ++++++++++ app/(dashboard)/patients/[id]/Labs.tsx | 103 +++++++++---- app/(dashboard)/patients/actions.tsx | 77 +++++++++- app/(dashboard)/patients/clientUtils.ts | 63 ++++++++ app/(dashboard)/patients/utils.ts | 4 +- 6 files changed, 475 insertions(+), 27 deletions(-) create mode 100644 app/(dashboard)/patients/[id]/LabForm.tsx create mode 100644 app/(dashboard)/patients/[id]/LabMenu.tsx create mode 100644 app/(dashboard)/patients/clientUtils.ts diff --git a/app/(dashboard)/patients/[id]/LabForm.tsx b/app/(dashboard)/patients/[id]/LabForm.tsx new file mode 100644 index 00000000..ca547f63 --- /dev/null +++ b/app/(dashboard)/patients/[id]/LabForm.tsx @@ -0,0 +1,175 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +'use client' +import { z } from 'zod' +import { + getObservationTypeUnits, + getUnitOfObservationType, +} from '@/app/(dashboard)/patients/clientUtils' +import { type Observation } from '@/app/(dashboard)/patients/utils' +import { ObservationType } from '@/modules/firebase/utils' +import { Button } from '@/packages/design-system/src/components/Button' +import { DatePicker } from '@/packages/design-system/src/components/DatePicker' +import { Input } from '@/packages/design-system/src/components/Input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/packages/design-system/src/components/Select' +import { Field } from '@/packages/design-system/src/forms/Field' +import { useForm } from '@/packages/design-system/src/forms/useForm' +import { ComponentProps } from 'react' +import { Dialog } from '@radix-ui/react-dialog' +import { + DialogContent, + DialogHeader, + DialogTitle, +} from '@/packages/design-system/src/components/Dialog' + +export const labFormSchema = z.object({ + type: z.nativeEnum(ObservationType), + effectiveDateTime: z.date(), + unit: z.string(), + value: z.number(), +}) + +export type LabFormSchema = z.infer + +interface LabFormProps { + observation?: Observation + onSubmit: (data: LabFormSchema) => Promise +} + +export const LabForm = ({ observation, onSubmit }: LabFormProps) => { + const isEdit = !!observation + const defaultType = observation?.type ?? ObservationType.potassium + const form = useForm({ + formSchema: labFormSchema, + defaultValues: { + type: defaultType, + effectiveDateTime: + observation?.effectiveDateTime ? + new Date(observation.effectiveDateTime) + : new Date(), + unit: observation?.unit ?? getUnitOfObservationType(defaultType).unit, + value: observation?.value, + }, + }) + + const [formType, formUnit] = form.watch(['type', 'unit']) + const units = getObservationTypeUnits(formType) + + const handleSubmit = form.handleSubmit(async (data) => { + await onSubmit(data) + }) + + return ( +
+ ( + + )} + /> + ( + + )} + /> + ( + + field.onChange(event.currentTarget.valueAsNumber) + } + /> + )} + /> + ( + field.onChange(date)} + defaultMonth={field.value} + toYear={new Date().getFullYear()} + /> + )} + /> + + + ) +} + +type LabFormDialogProps = LabFormProps & + Pick, 'open' | 'onOpenChange'> + +export const LabFormDialog = ({ + open, + onOpenChange, + observation, + ...props +}: LabFormDialogProps) => ( + + + + {observation ? 'Edit' : 'Create'} observation + + + + +) diff --git a/app/(dashboard)/patients/[id]/LabMenu.tsx b/app/(dashboard)/patients/[id]/LabMenu.tsx new file mode 100644 index 00000000..4e6a309f --- /dev/null +++ b/app/(dashboard)/patients/[id]/LabMenu.tsx @@ -0,0 +1,80 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +'use client' +import { Pencil, Trash } from 'lucide-react' +import { LabFormDialog } from '@/app/(dashboard)/patients/[id]/LabForm' +import { + deleteObservation, + updateObservation, +} from '@/app/(dashboard)/patients/actions' +import { type Observation } from '@/app/(dashboard)/patients/utils' +import { type ResourceType } from '@/modules/firebase/utils' +import { RowDropdownMenu } from '@/packages/design-system/src/components/DataTable' +import { DropdownMenuItem } from '@/packages/design-system/src/components/DropdownMenu' +import { ConfirmDeleteDialog } from '@/packages/design-system/src/molecules/ConfirmDeleteDialog' +import { useOpenState } from '@/packages/design-system/src/utils/useOpenState' + +interface LabMenuProps { + userId: string + resourceType: ResourceType + observation: Observation +} + +export const LabMenu = ({ + userId, + resourceType, + observation, +}: LabMenuProps) => { + const deleteConfirm = useOpenState() + const editObservation = useOpenState() + + const handleDelete = async () => { + await deleteObservation({ + userId, + resourceType, + observationId: observation.id, + observationType: observation.type, + }) + deleteConfirm.close() + } + + return ( + <> + { + await updateObservation({ + userId, + resourceType, + observationId: observation.id, + ...data, + }) + editObservation.close() + }} + open={editObservation.isOpen} + onOpenChange={editObservation.setIsOpen} + observation={observation} + /> + + + + + Edit + + + + Delete + + + + ) +} diff --git a/app/(dashboard)/patients/[id]/Labs.tsx b/app/(dashboard)/patients/[id]/Labs.tsx index 1648724e..d93dc626 100644 --- a/app/(dashboard)/patients/[id]/Labs.tsx +++ b/app/(dashboard)/patients/[id]/Labs.tsx @@ -7,35 +7,88 @@ // 'use client' import { createColumnHelper } from '@tanstack/table-core' -import { type LabsData } from '@/app/(dashboard)/patients/utils' +import { Plus } from 'lucide-react' +import { useMemo } from 'react' +import { LabFormDialog } from '@/app/(dashboard)/patients/[id]/LabForm' +import { LabMenu } from '@/app/(dashboard)/patients/[id]/LabMenu' +import { createObservation } from '@/app/(dashboard)/patients/actions' +import type { LabsData, Observation } from '@/app/(dashboard)/patients/utils' +import { Button } from '@/packages/design-system/src/components/Button' import { DataTable } from '@/packages/design-system/src/components/DataTable' +import { useOpenState } from '@/packages/design-system/src/utils/useOpenState' interface LabsProps extends LabsData {} -type Observation = LabsData['observations'][number] - const columnHelper = createColumnHelper() -const columns = [ - columnHelper.accessor('effectiveDateTime', { - header: 'Date', - cell: (props) => { - const value = props.getValue() - const date = value ? new Date(value) : undefined - return date?.toLocaleDateString() ?? '' - }, - }), - columnHelper.accessor('type', { - header: 'Type', - }), - columnHelper.accessor('value', { - header: 'Value', - cell: (props) => { - const observation = props.row.original - return `${observation.value} ${observation.unit}` - }, - }), -] -export const Labs = ({ observations }: LabsProps) => { - return +export const Labs = ({ observations, userId, resourceType }: LabsProps) => { + const createDialog = useOpenState() + + const columns = useMemo( + () => [ + columnHelper.accessor('effectiveDateTime', { + header: 'Date', + cell: (props) => { + const value = props.getValue() + const date = value ? new Date(value) : undefined + return date?.toLocaleDateString() ?? '' + }, + }), + columnHelper.accessor('type', { + header: 'Type', + }), + columnHelper.accessor('value', { + header: 'Value', + cell: (props) => { + const observation = props.row.original + return `${observation.value} ${observation.unit}` + }, + }), + columnHelper.display({ + id: 'actions', + cell: (props) => ( + + ), + }), + ], + [resourceType, userId], + ) + + return ( + <> + { + await createObservation({ + userId, + resourceType, + ...data, + }) + createDialog.close() + }} + open={createDialog.isOpen} + onOpenChange={createDialog.setIsOpen} + /> + + + + } + /> + + ) } diff --git a/app/(dashboard)/patients/actions.tsx b/app/(dashboard)/patients/actions.tsx index 4977173f..c18d3d3f 100644 --- a/app/(dashboard)/patients/actions.tsx +++ b/app/(dashboard)/patients/actions.tsx @@ -6,9 +6,16 @@ // SPDX-License-Identifier: MIT // 'use server' -import { deleteDoc } from '@firebase/firestore' +import { addDoc, deleteDoc, setDoc } from '@firebase/firestore' import { revalidatePath } from 'next/cache' +import { type LabFormSchema } from '@/app/(dashboard)/patients/[id]/LabForm' +import { getUnitOfObservationType } from '@/app/(dashboard)/patients/clientUtils' import { getAuthenticatedOnlyApp } from '@/modules/firebase/guards' +import { FHIRObservationStatus } from '@/modules/firebase/models/medication' +import { + type ObservationType, + type ResourceType, +} from '@/modules/firebase/utils' import { routes } from '@/modules/routes' export const deletePatient = async (payload: { userId: string }) => { @@ -24,3 +31,71 @@ export const deleteInvitation = async (payload: { invitationId: string }) => { revalidatePath(routes.patients.index) return 'success' } + +export const deleteObservation = async (payload: { + observationId: string + observationType: ObservationType + userId: string + resourceType: ResourceType +}) => { + const { docRefs } = await getAuthenticatedOnlyApp() + await deleteDoc(docRefs.userObservation(payload)) + revalidatePath(routes.patients.patient(payload.userId)) + return 'success' +} + +const getObservationData = (payload: LabFormSchema) => { + const unit = getUnitOfObservationType(payload.type, payload.unit) + return { + resourceType: 'Observation', + status: FHIRObservationStatus.final, + code: { coding: unit.coding }, + valueQuantity: { + value: payload.value, + unit: unit.unit, + system: 'http://unitsofmeasure.org', + code: unit.code, + }, + effectiveDateTime: payload.effectiveDateTime.toString(), + } +} + +export const createObservation = async ( + payload: { + userId: string + resourceType: ResourceType + } & LabFormSchema, +) => { + const { refs } = await getAuthenticatedOnlyApp() + await addDoc( + refs.userObservation({ + observationType: payload.type, + userId: payload.userId, + resourceType: payload.resourceType, + }), + getObservationData(payload), + ) + revalidatePath(routes.patients.patient(payload.userId)) + return 'success' +} + +export const updateObservation = async ( + payload: { + userId: string + resourceType: ResourceType + observationId: string + } & LabFormSchema, +) => { + const { docRefs } = await getAuthenticatedOnlyApp() + await setDoc( + docRefs.userObservation({ + observationType: payload.type, + userId: payload.userId, + resourceType: payload.resourceType, + observationId: payload.observationId, + }), + getObservationData(payload), + ) + revalidatePath(routes.patients.patient(payload.userId)) + return 'success' +} diff --git a/app/(dashboard)/patients/clientUtils.ts b/app/(dashboard)/patients/clientUtils.ts new file mode 100644 index 00000000..ebc9a2ff --- /dev/null +++ b/app/(dashboard)/patients/clientUtils.ts @@ -0,0 +1,63 @@ +import { ObservationType } from '@/modules/firebase/utils' +import { strategy } from '@/packages/design-system/src/utils/misc' + +export const getObservationTypeUnits = (observationType: ObservationType) => + strategy( + { + [ObservationType.eGFR]: [ + { + unit: 'mL/min/1.73m2', + code: 'mL/min/{1.73_m2}', + coding: [ + { + system: 'http://loinc.org', + code: '98979-8', + display: + 'Glomerular filtration rate/1.73 sq M.predicted [Volume Rate/Area] in Serum, Plasma or Blood by Creatinine-based formula (CKD-EPI 2021)', + }, + ], + }, + ], + [ObservationType.potassium]: [ + { + unit: 'mEq/L', + code: 'meq/L', + coding: [ + { + system: 'http://loinc.org', + code: '6298-4', + display: 'Potassium [Moles/volume] in Blood', + }, + ], + }, + ], + [ObservationType.creatinine]: [ + { + unit: 'mg/dL', + code: 'mg/dL', + coding: [ + { + system: 'http://loinc.org', + code: '2160-0', + display: 'Creatinine [Mass/volume] in Serum or Plasma', + }, + ], + }, + ], + }, + observationType, + ) + +export const getUnitOfObservationType = ( + type: ObservationType, + currentUnit?: string, +) => { + const newUnits = getObservationTypeUnits(type) + const existingUnit = + currentUnit ? newUnits.find((unit) => unit.unit === currentUnit) : undefined + const newUnit = existingUnit ?? newUnits.at(0) + if (!newUnit) throw new Error('Observation units cannot be empty') + return newUnit +} + +export type LabUnit = ReturnType[number] diff --git a/app/(dashboard)/patients/utils.ts b/app/(dashboard)/patients/utils.ts index b7cde2df..b46801b0 100644 --- a/app/(dashboard)/patients/utils.ts +++ b/app/(dashboard)/patients/utils.ts @@ -146,6 +146,7 @@ export const getLabsData = async ({ const observations = rawObservations.flatMap((observations) => observations.data.map((observation) => ({ + id: observation.id, effectiveDateTime: observation.effectiveDateTime, value: observation.valueQuantity?.value, unit: observation.valueQuantity?.unit, @@ -153,8 +154,9 @@ export const getLabsData = async ({ })), ) - return { observations } + return { observations, userId, resourceType } } export type LabsData = Awaited> +export type Observation = LabsData['observations'][number] export type MedicationsData = Awaited> From fa0dc1579245a579cdd2460ff519eba0ef5fee01 Mon Sep 17 00:00:00 2001 From: Arkadiusz Bachorski <60391032+arkadiuszbachorski@users.noreply.github.com> Date: Mon, 19 Aug 2024 08:30:49 +0200 Subject: [PATCH 2/4] Fix compliance --- app/(dashboard)/patients/clientUtils.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/(dashboard)/patients/clientUtils.ts b/app/(dashboard)/patients/clientUtils.ts index ebc9a2ff..f6844810 100644 --- a/app/(dashboard)/patients/clientUtils.ts +++ b/app/(dashboard)/patients/clientUtils.ts @@ -1,3 +1,10 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// import { ObservationType } from '@/modules/firebase/utils' import { strategy } from '@/packages/design-system/src/utils/misc' From e12878f9cc8c0a7a5390ab967b3fa376c4226ce0 Mon Sep 17 00:00:00 2001 From: Arkadiusz Bachorski <60391032+arkadiuszbachorski@users.noreply.github.com> Date: Mon, 19 Aug 2024 08:32:52 +0200 Subject: [PATCH 3/4] Remove prettier from lint fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b5326776..f489305c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "buildServiceWorker": "npx esbuild authServiceWorker.ts --bundle --outfile=public/authServiceWorker.js", "start": "next start", "lint": "eslint .", - "lint:fix": "eslint . --fix & prettier --write .", + "lint:fix": "eslint . --fix", "lint:ci": "npm run prebuild && eslint --output-file eslint_report.json --format json .", "pretest": "npm run prebuild", "test": "jest", From fb3f01eb6b6cf07a48f97b9b88e78c9100c496eb Mon Sep 17 00:00:00 2001 From: Arkadiusz Bachorski <60391032+arkadiuszbachorski@users.noreply.github.com> Date: Mon, 19 Aug 2024 08:32:58 +0200 Subject: [PATCH 4/4] Run lint:fix --- app/(dashboard)/patients/[id]/LabForm.tsx | 14 +++++++------- app/globals.css | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/(dashboard)/patients/[id]/LabForm.tsx b/app/(dashboard)/patients/[id]/LabForm.tsx index ca547f63..c8e79361 100644 --- a/app/(dashboard)/patients/[id]/LabForm.tsx +++ b/app/(dashboard)/patients/[id]/LabForm.tsx @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // 'use client' +import { Dialog } from '@radix-ui/react-dialog' +import { type ComponentProps } from 'react' import { z } from 'zod' import { getObservationTypeUnits, @@ -15,6 +17,11 @@ import { type Observation } from '@/app/(dashboard)/patients/utils' import { ObservationType } from '@/modules/firebase/utils' import { Button } from '@/packages/design-system/src/components/Button' import { DatePicker } from '@/packages/design-system/src/components/DatePicker' +import { + DialogContent, + DialogHeader, + DialogTitle, +} from '@/packages/design-system/src/components/Dialog' import { Input } from '@/packages/design-system/src/components/Input' import { Select, @@ -25,13 +32,6 @@ import { } from '@/packages/design-system/src/components/Select' import { Field } from '@/packages/design-system/src/forms/Field' import { useForm } from '@/packages/design-system/src/forms/useForm' -import { ComponentProps } from 'react' -import { Dialog } from '@radix-ui/react-dialog' -import { - DialogContent, - DialogHeader, - DialogTitle, -} from '@/packages/design-system/src/components/Dialog' export const labFormSchema = z.object({ type: z.nativeEnum(ObservationType), diff --git a/app/globals.css b/app/globals.css index 8abf7c23..84093bca 100644 --- a/app/globals.css +++ b/app/globals.css @@ -42,7 +42,7 @@ SPDX-License-Identifier: MIT @apply focus-ring transition-opacity hover:opacity-60; } - .hide-all-hidden [aria-hidden="true"] { + .hide-all-hidden [aria-hidden='true'] { display: none; } }