diff --git a/apps/web/src/routes/_app/datahub/index.tsx b/apps/web/src/routes/_app/datahub/index.tsx index 632d6df5d..4a813e689 100644 --- a/apps/web/src/routes/_app/datahub/index.tsx +++ b/apps/web/src/routes/_app/datahub/index.tsx @@ -1,15 +1,22 @@ import React, { useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; -import { ActionDropdown, Button, DataTable, Dialog, Heading } from '@douglasneuroinformatics/libui/components'; +import { + ActionDropdown, + Button, + DataTable, + Dialog, + DropdownMenu, + Heading +} from '@douglasneuroinformatics/libui/components'; import type { TanstackTable } from '@douglasneuroinformatics/libui/components'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { InstrumentRecordsExport } from '@opendatacapture/schemas/instrument-records'; -import type { Subject } from '@opendatacapture/schemas/subject'; +import type { Sex, Subject } from '@opendatacapture/schemas/subject'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import axios from 'axios'; -import { UserSearchIcon } from 'lucide-react'; +import { ChevronDownIcon, UserSearchIcon } from 'lucide-react'; import { unpack } from 'msgpackr/unpack'; import { unparse } from 'papaparse'; @@ -19,9 +26,133 @@ import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery import { useAppStore } from '@/store'; import { downloadExcel } from '@/utils/excel'; -type MasterDataTableProps = { - data: Subject[]; - onSelect: (subject: Subject) => void; +type DateFilter = { + allowNull: boolean; + max: Date | null; + min: Date | null; +}; + +type SexFilter = (null | Sex)[]; + +const Filters: React.FC<{ table: TanstackTable.Table }> = ({ table }) => { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + + const columns = table.getAllColumns(); + + const dobColumn = columns.find((column) => column.id === 'date-of-birth')!; + const dobFilter = dobColumn.getFilterValue() as DateFilter; + + const sexColumn = columns.find((column) => column.id === 'sex')!; + const sexFilter = sexColumn.getFilterValue() as SexFilter; + + return ( + + + + + + {t('core.identificationData.sex.label')} + + { + sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => { + if (checked) { + return [...prevValue, 'MALE']; + } + return prevValue.filter((item) => item !== 'MALE'); + }); + }} + onSelect={(e) => e.preventDefault()} + > + {t('core.identificationData.sex.male')} + + { + sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => { + if (checked) { + return [...prevValue, 'FEMALE']; + } + return prevValue.filter((item) => item !== 'FEMALE'); + }); + }} + onSelect={(e) => e.preventDefault()} + > + {t('core.identificationData.sex.female')} + + { + sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => { + if (checked) { + return [...prevValue, null]; + } + return prevValue.filter((item) => item !== null); + }); + }} + onSelect={(e) => e.preventDefault()} + > + NULL + + + {t('core.identificationData.dateOfBirth.label')} + +
+ Min: + { + dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => { + return { + ...prevValue, + min: event.target.valueAsDate + }; + }); + }} + /> +
+
+ Max: + { + dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => { + return { + ...prevValue, + max: event.target.valueAsDate + }; + }); + }} + /> +
+ { + dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => { + return { + ...prevValue, + allowNull: checked + }; + }); + }} + onSelect={(e) => e.preventDefault()} + > + NULL + +
+
+
+ ); }; const Toggles: React.FC<{ table: TanstackTable.Table }> = ({ table }) => { @@ -124,21 +255,21 @@ const Toggles: React.FC<{ table: TanstackTable.Table }> = ({ table }) = }; return ( - <> +
@@ -148,20 +279,25 @@ const Toggles: React.FC<{ table: TanstackTable.Table }> = ({ table }) = void lookupSubject(data)} /> + - +
); }; -const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => { +const MasterDataTable: React.FC<{ + data: Subject[]; + onSelect: (subject: Subject) => void; +}> = ({ data, onSelect }) => { const { t } = useTranslation(); const subjectIdDisplaySetting = useAppStore((store) => store.currentGroup?.settings.subjectIdDisplayLength); @@ -175,13 +311,29 @@ const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => { id: 'subjectId' }, { - accessorFn: (subject) => (subject.dateOfBirth ? toBasicISOString(new Date(subject.dateOfBirth)) : 'NULL'), + accessorFn: (subject) => subject.dateOfBirth, + cell: (ctx) => { + const value = ctx.getValue() as Date | null | undefined; + return value ? toBasicISOString(value) : 'NULL'; + }, + filterFn: (row, id, filter: DateFilter) => { + const value = row.getValue(id); + if (!value) { + return filter.allowNull; + } else if (filter.max && value > filter.max) { + return false; + } else if (filter.min && value < filter.min) { + return false; + } + return true; + }, header: t('core.identificationData.dateOfBirth.label'), id: 'date-of-birth' }, { - accessorFn: (subject) => { - switch (subject.sex) { + accessorFn: (subject) => subject.sex ?? null, + cell: (ctx) => { + switch (ctx.getValue() as Sex) { case 'FEMALE': return t('core.identificationData.sex.female'); case 'MALE': @@ -190,12 +342,31 @@ const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => { return 'NULL'; } }, + filterFn: (row, id, filter: SexFilter) => { + return filter.includes(row.getValue(id)); + }, header: t('core.identificationData.sex.label'), id: 'sex' } ]} data={data} data-testid="master-data-table" + initialState={{ + columnFilters: [ + { + id: 'sex', + value: ['MALE', 'FEMALE', null] satisfies SexFilter + }, + { + id: 'date-of-birth', + value: { + allowNull: true, + max: null, + min: null + } satisfies DateFilter + } + ] + }} rowActions={[ { label: t({ en: 'View', fr: 'Voir' }),