1- import { useMemo , useState , forwardRef , useImperativeHandle , useEffect , useCallback , useRef } from 'react'
1+ import { useMemo , useState , forwardRef , useImperativeHandle , useEffect , useCallback , useRef , type ReactNode } from 'react'
22import { useMutation } from '@apollo/client/react'
33import type { IdentifierFilterValue , FilterListItem , FilterListPopUpBuilderProps } from '@helpwave/hightide'
4- import { Chip , FillerCell , HelpwaveLogo , LoadingContainer , SearchBar , ProgressIndicator , Tooltip , Drawer , TableProvider , TableDisplay , TableColumnSwitcher , TablePagination , IconButton , useLocale , FilterList , SortingList , Button , ExpansionIcon , Visibility } from '@helpwave/hightide'
5- import { PlusIcon } from 'lucide-react'
4+ import { Chip , FillerCell , HelpwaveLogo , LoadingContainer , SearchBar , ProgressIndicator , Tooltip , Drawer , TableProvider , TableDisplay , TableColumnSwitcher , IconButton , useLocale , FilterList , SortingList , Button , ExpansionIcon , Visibility } from '@helpwave/hightide'
5+ import clsx from 'clsx'
6+ import { LayoutGrid , PlusIcon , Table2 } from 'lucide-react'
67import type { LocationType } from '@/api/gql/generated'
78import { Sex , PatientState , type GetPatientsQuery , type TaskType , PropertyEntity , FieldType } from '@/api/gql/generated'
89import { usePropertyDefinitions , usePatientsPaginated , useQueryableFields , useRefreshingEntityIds } from '@/data'
@@ -16,7 +17,10 @@ import { useTasksContext } from '@/hooks/useTasksContext'
1617import type { ColumnDef , ColumnFiltersState , ColumnOrderState , PaginationState , Row , SortingState , TableState , VisibilityState } from '@tanstack/table-core'
1718import { getPropertyColumnsForEntity } from '@/utils/propertyColumn'
1819import { getPropertyColumnIds , useColumnVisibilityWithPropertyDefaults } from '@/hooks/usePropertyColumnVisibility'
19- import { columnFiltersToQueryFilterClauses , paginationStateToPaginationInput , sortingStateToQuerySortClauses } from '@/utils/tableStateToApi'
20+ import { columnFiltersToQueryFilterClauses , sortingStateToQuerySortClauses } from '@/utils/tableStateToApi'
21+ import { LIST_PAGE_SIZE } from '@/utils/listPaging'
22+ import { useAccumulatedPagination } from '@/hooks/useAccumulatedPagination'
23+ import { PatientCardView } from '@/components/patients/PatientCardView'
2024import { queryableFieldsToFilterListItems , queryableFieldsToSortingListItems } from '@/utils/queryableFilterList'
2125import { getPropertyFilterFn as getPropertyDatatype } from '@/utils/propertyFilterMapping'
2226import { UserSelectFilterPopUp } from './UserSelectFilterPopUp'
@@ -69,6 +73,15 @@ const LOCATION_KIND_HEADERS: Record<LocationKindColumn, string> = {
6973
7074const ADMITTED_OR_WAITING_STATES : PatientState [ ] = [ PatientState . Admitted , PatientState . Wait ]
7175
76+ const PATIENT_CARD_PRIMARY_COLUMN_IDS = new Set ( [
77+ 'name' ,
78+ 'state' ,
79+ 'sex' ,
80+ 'position' ,
81+ 'birthdate' ,
82+ 'tasks' ,
83+ ] )
84+
7285export type PatientListRef = {
7386 openCreate : ( ) => void ,
7487 openPatient : ( patientId : string ) => void ,
@@ -107,7 +120,10 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
107120 const [ isShowFilters , setIsShowFilters ] = useState ( false )
108121 const [ isShowSorting , setIsShowSorting ] = useState ( false )
109122
110- const [ pagination , setPagination ] = useState < PaginationState > ( { pageSize : 10 , pageIndex : 0 } )
123+ const [ fetchPageIndex , setFetchPageIndex ] = useState ( 0 )
124+ const [ listLayout , setListLayout ] = useState < 'table' | 'card' > ( ( ) => (
125+ typeof window !== 'undefined' && window . matchMedia ( '(max-width: 768px)' ) . matches ? 'card' : 'table'
126+ ) )
111127 const [ sorting , setSorting ] = useState < SortingState > ( ( ) => viewDefaultSorting ?? [ ] )
112128 const [ filters , setFilters ] = useState < ColumnFiltersState > ( ( ) => viewDefaultFilters ?? [ ] )
113129 const [ columnVisibility , setColumnVisibilityRaw ] = useState < VisibilityState > ( ( ) => viewDefaultColumnVisibility ?? { } )
@@ -150,7 +166,7 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
150166 setSearchQuery ( baselineSearch )
151167 setColumnVisibility ( baselineColumnVisibility )
152168 setColumnOrder ( baselineColumnOrder )
153- setPagination ( { pageSize : 10 , pageIndex : 0 } )
169+ setFetchPageIndex ( 0 )
154170 } , [
155171 savedViewId ,
156172 persistedSavedViewContentKey ,
@@ -255,14 +271,29 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
255271 return filtered . length > 0 ? ( filtered as PatientState [ ] ) : allPatientStates
256272 } , [ apiFilters , allPatientStates ] )
257273
258- const searchInput = searchQuery
259- ? {
260- searchText : searchQuery ,
261- includeProperties : true ,
262- }
263- : undefined
274+ const searchInput = useMemo (
275+ ( ) => ( searchQuery
276+ ? { searchText : searchQuery , includeProperties : true }
277+ : undefined ) ,
278+ [ searchQuery ]
279+ )
264280 const apiSorting = useMemo ( ( ) => sortingStateToQuerySortClauses ( sorting ) , [ sorting ] )
265- const apiPagination = useMemo ( ( ) => paginationStateToPaginationInput ( pagination ) , [ pagination ] )
281+ const apiPagination = useMemo (
282+ ( ) => ( { pageIndex : fetchPageIndex , pageSize : LIST_PAGE_SIZE } ) ,
283+ [ fetchPageIndex ]
284+ )
285+
286+ const accumulationResetKey = useMemo (
287+ ( ) => JSON . stringify ( {
288+ filters : apiFilters ,
289+ sorts : apiSorting ,
290+ search : searchInput ,
291+ locationId : hasLocationFilter ? undefined : ( locationId || undefined ) ,
292+ root : effectiveRootLocationIds ,
293+ states : patientStates ,
294+ } ) ,
295+ [ apiFilters , apiSorting , searchInput , hasLocationFilter , locationId , effectiveRootLocationIds , patientStates ]
296+ )
266297
267298 const lastTotalCountRef = useRef < number | undefined > ( undefined )
268299 const { data : patientsData , refetch, totalCount, loading : patientsLoading } = usePatientsPaginated (
@@ -283,27 +314,47 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
283314 if ( totalCount != null ) lastTotalCountRef . current = totalCount
284315 const stableTotalCount = totalCount ?? lastTotalCountRef . current
285316
317+ const { accumulated : accumulatedPatientsRaw , loadMore, hasMore } = useAccumulatedPagination ( {
318+ resetKey : accumulationResetKey ,
319+ pageData : patientsData ,
320+ pageIndex : fetchPageIndex ,
321+ setPageIndex : setFetchPageIndex ,
322+ totalCount : stableTotalCount ,
323+ loading : patientsLoading ,
324+ } )
325+
326+ const mapPatientRow = useCallback ( ( p : GetPatientsQuery [ 'patients' ] [ 0 ] ) : PatientViewModel => {
327+ const countForAggregate = ADMITTED_OR_WAITING_STATES . includes ( p . state )
328+ return {
329+ id : p . id ,
330+ name : p . name ,
331+ firstname : p . firstname ,
332+ lastname : p . lastname ,
333+ birthdate : new Date ( p . birthdate ) ,
334+ sex : p . sex ,
335+ state : p . state ,
336+ position : p . position ,
337+ openTasksCount : countForAggregate ? ( p . tasks ?. filter ( t => ! t . done ) . length ?? 0 ) : 0 ,
338+ closedTasksCount : countForAggregate ? ( p . tasks ?. filter ( t => t . done ) . length ?? 0 ) : 0 ,
339+ tasks : [ ] ,
340+ properties : p . properties ?? [ ] ,
341+ }
342+ } , [ ] )
343+
286344 const patients : PatientViewModel [ ] = useMemo ( ( ) => {
287- if ( ! patientsData || patientsData . length === 0 ) return [ ]
288-
289- return patientsData . map ( p => {
290- const countForAggregate = ADMITTED_OR_WAITING_STATES . includes ( p . state )
291- return {
292- id : p . id ,
293- name : p . name ,
294- firstname : p . firstname ,
295- lastname : p . lastname ,
296- birthdate : new Date ( p . birthdate ) ,
297- sex : p . sex ,
298- state : p . state ,
299- position : p . position ,
300- openTasksCount : countForAggregate ? ( p . tasks ?. filter ( t => ! t . done ) . length ?? 0 ) : 0 ,
301- closedTasksCount : countForAggregate ? ( p . tasks ?. filter ( t => t . done ) . length ?? 0 ) : 0 ,
302- tasks : [ ] ,
303- properties : p . properties ?? [ ] ,
304- }
305- } )
306- } , [ patientsData ] )
345+ if ( ! accumulatedPatientsRaw || accumulatedPatientsRaw . length === 0 ) return [ ]
346+ return accumulatedPatientsRaw . map ( mapPatientRow )
347+ } , [ accumulatedPatientsRaw , mapPatientRow ] )
348+
349+ const showBlockingLoadingOverlay = patientsLoading && patients . length === 0
350+
351+ const tablePagination = useMemo (
352+ ( ) : PaginationState => ( {
353+ pageIndex : 0 ,
354+ pageSize : Math . max ( patients . length , 1 ) ,
355+ } ) ,
356+ [ patients . length ]
357+ )
307358
308359 useImperativeHandle ( ref , ( ) => ( {
309360 openCreate : ( ) => {
@@ -528,6 +579,26 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
528579 } ) ) ,
529580 ] , [ translation , patientPropertyColumns , refreshingPatientIds , rowLoadingCell , dateFormat ] )
530581
582+ const renderPatientCardExtras = useCallback ( ( patient : PatientViewModel ) : ReactNode => {
583+ const rows : ReactNode [ ] = [ ]
584+ for ( const col of columns ) {
585+ const id = col . id as string | undefined
586+ if ( ! id || PATIENT_CARD_PRIMARY_COLUMN_IDS . has ( id ) ) continue
587+ if ( columnVisibility [ id ] === false ) continue
588+ if ( ! col . cell ) continue
589+ const headerLabel = typeof col . header === 'string' ? col . header : id
590+ const cell = ( col . cell as ( p : { row : { original : PatientViewModel } } ) => ReactNode ) ( { row : { original : patient } } )
591+ rows . push (
592+ < div key = { id } className = "flex flex-col gap-0.5 sm:flex-row sm:gap-3 sm:items-start text-left" >
593+ < span className = "text-description shrink-0 min-w-[7rem]" > { headerLabel } </ span >
594+ < div className = "min-w-0 break-words" > { cell } </ div >
595+ </ div >
596+ )
597+ }
598+ if ( rows . length === 0 ) return null
599+ return < div className = "mt-3 pt-3 border-t border-border space-y-2 w-full" > { rows } </ div >
600+ } , [ columns , columnVisibility ] )
601+
531602 const propertyFieldTypeByDefId = useMemo (
532603 ( ) => new Map ( propertyDefinitionsData ?. propertyDefinitions . map ( d => [ d . id , d . fieldType ] ) ?? [ ] ) ,
533604 [ propertyDefinitionsData ]
@@ -662,22 +733,22 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
662733
663734 initialState = { {
664735 pagination : {
665- pageSize : 10 ,
736+ pageSize : LIST_PAGE_SIZE ,
666737 }
667738 } }
668739 state = { {
669740 columnVisibility,
670741 columnOrder : sanitizedColumnOrder ,
671- pagination,
742+ pagination : tablePagination ,
672743 } as Partial < TableState > as TableState }
673744 onColumnVisibilityChange = { setColumnVisibility }
674745 onColumnOrderChange = { deferSetColumnOrder }
675- onPaginationChange = { setPagination }
746+ onPaginationChange = { ( ) => { } }
676747 onSortingChange = { setSorting }
677748 onColumnFiltersChange = { setFilters }
678749 enableMultiSort = { true }
679750 enablePinning = { false }
680- pageCount = { stableTotalCount != null ? Math . ceil ( stableTotalCount / pagination . pageSize ) : - 1 }
751+ pageCount = { 1 }
681752
682753 manualPagination = { true }
683754 manualSorting = { true }
@@ -702,22 +773,24 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
702773 buttonProps = { { className : 'min-h-11 min-w-11 shrink-0' } }
703774 style = { { zIndex : 120 } }
704775 />
705- < Button
706- onClick = { ( ) => setIsShowFilters ( ! isShowFilters ) }
707- color = "neutral"
708- className = "font-semibold element"
709- >
710- { translation ( 'filter' ) + ` (${ filters . length } )` }
711- < ExpansionIcon isExpanded = { isShowFilters } className = "size-5" />
712- </ Button >
713- < Button
714- onClick = { ( ) => setIsShowSorting ( ! isShowSorting ) }
715- color = "neutral"
716- className = "font-semibold"
717- >
718- { translation ( 'sorting' ) + ` (${ sorting . length } )` }
719- < ExpansionIcon isExpanded = { isShowSorting } className = "size-5" />
720- </ Button >
776+ < div className = "inline-flex flex-wrap gap-2 items-center shrink-0" >
777+ < Button
778+ onClick = { ( ) => setIsShowFilters ( ! isShowFilters ) }
779+ color = "neutral"
780+ className = "font-semibold element"
781+ >
782+ { translation ( 'filter' ) + ` (${ filters . length } )` }
783+ < ExpansionIcon isExpanded = { isShowFilters } className = "size-5" />
784+ </ Button >
785+ < Button
786+ onClick = { ( ) => setIsShowSorting ( ! isShowSorting ) }
787+ color = "neutral"
788+ className = "font-semibold"
789+ >
790+ { translation ( 'sorting' ) + ` (${ sorting . length } )` }
791+ < ExpansionIcon isExpanded = { isShowSorting } className = "size-5" />
792+ </ Button >
793+ </ div >
721794 < Visibility isVisible = { ! hideSaveView && hasUnsavedViewChanges } >
722795 < SaveViewActionsMenu
723796 canOverwrite = { ! ! savedViewId }
@@ -729,6 +802,22 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
729802 </ Visibility >
730803 </ div >
731804 < div className = "flex flex-wrap gap-2 items-center justify-end shrink-0" >
805+ < IconButton
806+ tooltip = { translation ( 'listViewTable' ) }
807+ className = "min-h-11 min-w-11"
808+ onClick = { ( ) => setListLayout ( 'table' ) }
809+ color = { listLayout === 'table' ? 'primary' : 'neutral' }
810+ >
811+ < Table2 className = "size-5" />
812+ </ IconButton >
813+ < IconButton
814+ tooltip = { translation ( 'listViewCard' ) }
815+ className = "min-h-11 min-w-11"
816+ onClick = { ( ) => setListLayout ( 'card' ) }
817+ color = { listLayout === 'card' ? 'primary' : 'neutral' }
818+ >
819+ < LayoutGrid className = "size-5" />
820+ </ IconButton >
732821 < IconButton
733822 tooltip = { translation ( 'addPatient' ) }
734823 className = "min-h-11 min-w-11"
@@ -758,18 +847,30 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
758847 ) }
759848 </ div >
760849 < div className = "relative print:static" >
761- { patientsLoading && (
850+ { showBlockingLoadingOverlay && (
762851 < div className = "absolute inset-0 z-10 flex items-center justify-center bg-surface/80 rounded-lg min-h-48" >
763852 < HelpwaveLogo animate = "loading" color = "currentColor" height = { 64 } width = { 64 } />
764853 </ div >
765854 ) }
766- < TableDisplay className = "print-content overflow-x-auto touch-pan-x" />
767- { totalCount != null && (
768- < TablePagination
769- allowChangingPageSize = { true }
770- pageSizeOptions = { [ 10 , 25 , 50 ] }
771- className = "mt-2"
772- />
855+ < div className = { clsx ( listLayout === 'table' ? 'block' : 'hidden print:block' ) } >
856+ < TableDisplay className = "print-content overflow-x-auto touch-pan-x" />
857+ </ div >
858+ { listLayout === 'card' && (
859+ < div className = "flex flex-col gap-3 w-full print:hidden" >
860+ { patients . map ( ( patient ) => (
861+ < PatientCardView
862+ key = { patient . id }
863+ patient = { patient }
864+ onClick = { handleEdit }
865+ extraContent = { renderPatientCardExtras ( patient ) }
866+ />
867+ ) ) }
868+ </ div >
869+ ) }
870+ { stableTotalCount != null && hasMore && (
871+ < Button color = "neutral" className = "mt-2 w-full sm:w-auto self-center" onClick = { loadMore } >
872+ { translation ( 'loadMore' ) }
873+ </ Button >
773874 ) }
774875 </ div >
775876 < Drawer
0 commit comments