11import React , { useState } from 'react' ;
22
33import { toBasicISOString } from '@douglasneuroinformatics/libjs' ;
4- import { ActionDropdown , Button , DataTable , Dialog , Heading } from '@douglasneuroinformatics/libui/components' ;
4+ import {
5+ ActionDropdown ,
6+ Button ,
7+ DataTable ,
8+ Dialog ,
9+ DropdownMenu ,
10+ Heading
11+ } from '@douglasneuroinformatics/libui/components' ;
512import type { TanstackTable } from '@douglasneuroinformatics/libui/components' ;
613import { useDownload , useNotificationsStore , useTranslation } from '@douglasneuroinformatics/libui/hooks' ;
714import type { InstrumentRecordsExport } from '@opendatacapture/schemas/instrument-records' ;
8- import type { Subject } from '@opendatacapture/schemas/subject' ;
15+ import type { Sex , Subject } from '@opendatacapture/schemas/subject' ;
916import { removeSubjectIdScope } from '@opendatacapture/subject-utils' ;
1017import { createFileRoute , useNavigate } from '@tanstack/react-router' ;
1118import axios from 'axios' ;
12- import { UserSearchIcon } from 'lucide-react' ;
19+ import { ChevronDownIcon , UserSearchIcon } from 'lucide-react' ;
1320import { unpack } from 'msgpackr/unpack' ;
1421import { unparse } from 'papaparse' ;
1522
@@ -19,9 +26,133 @@ import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery
1926import { useAppStore } from '@/store' ;
2027import { downloadExcel } from '@/utils/excel' ;
2128
22- type MasterDataTableProps = {
23- data : Subject [ ] ;
24- onSelect : ( subject : Subject ) => void ;
29+ type DateFilter = {
30+ allowNull : boolean ;
31+ max : Date | null ;
32+ min : Date | null ;
33+ } ;
34+
35+ type SexFilter = ( null | Sex ) [ ] ;
36+
37+ const Filters : React . FC < { table : TanstackTable . Table < Subject > } > = ( { table } ) => {
38+ const { t } = useTranslation ( ) ;
39+
40+ const [ isOpen , setIsOpen ] = useState ( false ) ;
41+
42+ const columns = table . getAllColumns ( ) ;
43+
44+ const dobColumn = columns . find ( ( column ) => column . id === 'date-of-birth' ) ! ;
45+ const dobFilter = dobColumn . getFilterValue ( ) as DateFilter ;
46+
47+ const sexColumn = columns . find ( ( column ) => column . id === 'sex' ) ! ;
48+ const sexFilter = sexColumn . getFilterValue ( ) as SexFilter ;
49+
50+ return (
51+ < DropdownMenu open = { isOpen } onOpenChange = { setIsOpen } >
52+ < DropdownMenu . Trigger asChild >
53+ < Button className = "flex items-center justify-between gap-2" variant = "outline" >
54+ { t ( { en : 'Filters' , fr : 'Filtres' } ) }
55+ < ChevronDownIcon className = "opacity-50" />
56+ </ Button >
57+ </ DropdownMenu . Trigger >
58+ < DropdownMenu . Content align = "end" className = "w-56" >
59+ < DropdownMenu . Label > { t ( 'core.identificationData.sex.label' ) } </ DropdownMenu . Label >
60+ < DropdownMenu . Group >
61+ < DropdownMenu . CheckboxItem
62+ checked = { sexFilter . includes ( 'MALE' ) }
63+ onCheckedChange = { ( checked ) => {
64+ sexColumn . setFilterValue ( ( prevValue : SexFilter ) : SexFilter => {
65+ if ( checked ) {
66+ return [ ...prevValue , 'MALE' ] ;
67+ }
68+ return prevValue . filter ( ( item ) => item !== 'MALE' ) ;
69+ } ) ;
70+ } }
71+ onSelect = { ( e ) => e . preventDefault ( ) }
72+ >
73+ { t ( 'core.identificationData.sex.male' ) }
74+ </ DropdownMenu . CheckboxItem >
75+ < DropdownMenu . CheckboxItem
76+ checked = { sexFilter . includes ( 'FEMALE' ) }
77+ onCheckedChange = { ( checked ) => {
78+ sexColumn . setFilterValue ( ( prevValue : SexFilter ) : SexFilter => {
79+ if ( checked ) {
80+ return [ ...prevValue , 'FEMALE' ] ;
81+ }
82+ return prevValue . filter ( ( item ) => item !== 'FEMALE' ) ;
83+ } ) ;
84+ } }
85+ onSelect = { ( e ) => e . preventDefault ( ) }
86+ >
87+ { t ( 'core.identificationData.sex.female' ) }
88+ </ DropdownMenu . CheckboxItem >
89+ < DropdownMenu . CheckboxItem
90+ checked = { sexFilter . includes ( null ) }
91+ onCheckedChange = { ( checked ) => {
92+ sexColumn . setFilterValue ( ( prevValue : SexFilter ) : SexFilter => {
93+ if ( checked ) {
94+ return [ ...prevValue , null ] ;
95+ }
96+ return prevValue . filter ( ( item ) => item !== null ) ;
97+ } ) ;
98+ } }
99+ onSelect = { ( e ) => e . preventDefault ( ) }
100+ >
101+ NULL
102+ </ DropdownMenu . CheckboxItem >
103+ </ DropdownMenu . Group >
104+ < DropdownMenu . Label > { t ( 'core.identificationData.dateOfBirth.label' ) } </ DropdownMenu . Label >
105+ < DropdownMenu . Group >
106+ < div className = "rounded-xs relative flex items-center justify-between gap-1 px-2 pb-1 pt-1.5 text-sm transition-colors" >
107+ < span className = "pb-1" > Min:</ span >
108+ < input
109+ className = "text-muted-foreground pointer-events-auto rounded-sm border-b pb-0.5"
110+ type = "date"
111+ value = { dobFilter . min ? toBasicISOString ( dobFilter . min ) : '' }
112+ onChange = { ( event ) => {
113+ dobColumn . setFilterValue ( ( prevValue : DateFilter ) : DateFilter => {
114+ return {
115+ ...prevValue ,
116+ min : event . target . valueAsDate
117+ } ;
118+ } ) ;
119+ } }
120+ />
121+ </ div >
122+ < div className = "rounded-xs relative flex items-center justify-between gap-1 px-2 pb-1 pt-1.5 text-sm transition-colors" >
123+ < span className = "pb-1" > Max:</ span >
124+ < input
125+ className = "text-muted-foreground pointer-events-auto rounded-sm border-b pb-0.5"
126+ type = "date"
127+ value = { dobFilter . max ? toBasicISOString ( dobFilter . max ) : '' }
128+ onChange = { ( event ) => {
129+ dobColumn . setFilterValue ( ( prevValue : DateFilter ) : DateFilter => {
130+ return {
131+ ...prevValue ,
132+ max : event . target . valueAsDate
133+ } ;
134+ } ) ;
135+ } }
136+ />
137+ </ div >
138+ < DropdownMenu . CheckboxItem
139+ checked = { dobFilter . allowNull }
140+ onCheckedChange = { ( checked ) => {
141+ dobColumn . setFilterValue ( ( prevValue : DateFilter ) : DateFilter => {
142+ return {
143+ ...prevValue ,
144+ allowNull : checked
145+ } ;
146+ } ) ;
147+ } }
148+ onSelect = { ( e ) => e . preventDefault ( ) }
149+ >
150+ NULL
151+ </ DropdownMenu . CheckboxItem >
152+ </ DropdownMenu . Group >
153+ </ DropdownMenu . Content >
154+ </ DropdownMenu >
155+ ) ;
25156} ;
26157
27158const Toggles : React . FC < { table : TanstackTable . Table < Subject > } > = ( { table } ) => {
@@ -124,21 +255,21 @@ const Toggles: React.FC<{ table: TanstackTable.Table<Subject> }> = ({ table }) =
124255 } ;
125256
126257 return (
127- < >
258+ < div className = "flex gap-3" >
128259 < Dialog open = { isLookupOpen } onOpenChange = { setIsLookupOpen } >
129260 < Dialog . Trigger asChild >
130261 < Button
131- className = "gap-1 "
262+ className = "gap-2 "
132263 data-spotlight-type = "subject-lookup-search-button"
133264 data-testid = "subject-lookup-search-button"
134265 id = "subject-lookup-search-button"
135266 variant = "outline"
136267 >
137- < UserSearchIcon /> { ' ' }
138268 { t ( {
139269 en : 'Subject Lookup' ,
140270 fr : 'Trouver un client'
141271 } ) }
272+ < UserSearchIcon style = { { strokeWidth : '2px' } } />
142273 </ Button >
143274 </ Dialog . Trigger >
144275 < Dialog . Content data-spotlight-type = "subject-lookup-modal" data-testid = "datahub-subject-lookup-dialog" >
@@ -148,20 +279,25 @@ const Toggles: React.FC<{ table: TanstackTable.Table<Subject> }> = ({ table }) =
148279 < IdentificationForm onSubmit = { ( data ) => void lookupSubject ( data ) } />
149280 </ Dialog . Content >
150281 </ Dialog >
282+ < Filters table = { table } />
151283 < ActionDropdown
152284 widthFull
153- className = "min-w-48"
285+ align = "end"
286+ className = "font-medium"
154287 data-spotlight-type = "export-data-dropdown"
155288 data-testid = "datahub-export-dropdown"
156289 options = { [ 'CSV' , 'JSON' , 'Excel' ] }
157290 title = { t ( 'datahub.index.table.export' ) }
158291 onSelection = { handleExportSelection }
159292 />
160- </ >
293+ </ div >
161294 ) ;
162295} ;
163296
164- const MasterDataTable = ( { data, onSelect } : MasterDataTableProps ) => {
297+ const MasterDataTable : React . FC < {
298+ data : Subject [ ] ;
299+ onSelect : ( subject : Subject ) => void ;
300+ } > = ( { data, onSelect } ) => {
165301 const { t } = useTranslation ( ) ;
166302 const subjectIdDisplaySetting = useAppStore ( ( store ) => store . currentGroup ?. settings . subjectIdDisplayLength ) ;
167303
@@ -175,13 +311,29 @@ const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
175311 id : 'subjectId'
176312 } ,
177313 {
178- accessorFn : ( subject ) => ( subject . dateOfBirth ? toBasicISOString ( new Date ( subject . dateOfBirth ) ) : 'NULL' ) ,
314+ accessorFn : ( subject ) => subject . dateOfBirth ,
315+ cell : ( ctx ) => {
316+ const value = ctx . getValue ( ) as Date | null | undefined ;
317+ return value ? toBasicISOString ( value ) : 'NULL' ;
318+ } ,
319+ filterFn : ( row , id , filter : DateFilter ) => {
320+ const value = row . getValue ( id ) ;
321+ if ( ! value ) {
322+ return filter . allowNull ;
323+ } else if ( filter . max && value > filter . max ) {
324+ return false ;
325+ } else if ( filter . min && value < filter . min ) {
326+ return false ;
327+ }
328+ return true ;
329+ } ,
179330 header : t ( 'core.identificationData.dateOfBirth.label' ) ,
180331 id : 'date-of-birth'
181332 } ,
182333 {
183- accessorFn : ( subject ) => {
184- switch ( subject . sex ) {
334+ accessorFn : ( subject ) => subject . sex ?? null ,
335+ cell : ( ctx ) => {
336+ switch ( ctx . getValue ( ) as Sex ) {
185337 case 'FEMALE' :
186338 return t ( 'core.identificationData.sex.female' ) ;
187339 case 'MALE' :
@@ -190,12 +342,31 @@ const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
190342 return 'NULL' ;
191343 }
192344 } ,
345+ filterFn : ( row , id , filter : SexFilter ) => {
346+ return filter . includes ( row . getValue ( id ) ) ;
347+ } ,
193348 header : t ( 'core.identificationData.sex.label' ) ,
194349 id : 'sex'
195350 }
196351 ] }
197352 data = { data }
198353 data-testid = "master-data-table"
354+ initialState = { {
355+ columnFilters : [
356+ {
357+ id : 'sex' ,
358+ value : [ 'MALE' , 'FEMALE' , null ] satisfies SexFilter
359+ } ,
360+ {
361+ id : 'date-of-birth' ,
362+ value : {
363+ allowNull : true ,
364+ max : null ,
365+ min : null
366+ } satisfies DateFilter
367+ }
368+ ]
369+ } }
199370 rowActions = { [
200371 {
201372 label : t ( { en : 'View' , fr : 'Voir' } ) ,
0 commit comments