Skip to content

Commit 4dc16c0

Browse files
authored
Merge pull request #167 from helpwave/patch/card-views
add card view
2 parents b72d9e4 + d43fbc0 commit 4dc16c0

16 files changed

Lines changed: 894 additions & 472 deletions

File tree

‎web/components/patients/PatientCardView.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ReactNode } from 'react'
12
import { Chip, ProgressIndicator, Tooltip } from '@helpwave/hightide'
23
import { DateDisplay } from '@/components/Date/DateDisplay'
34
import { LocationChipsBySetting } from '@/components/patients/LocationChipsBySetting'
@@ -9,9 +10,10 @@ import type { PatientViewModel } from '../tables/PatientList'
910
type PatientCardViewProps = {
1011
patient: PatientViewModel,
1112
onClick: (patient: PatientViewModel) => void,
13+
extraContent?: ReactNode,
1214
}
1315

14-
export const PatientCardView = ({ patient, onClick }: PatientCardViewProps) => {
16+
export const PatientCardView = ({ patient, onClick, extraContent }: PatientCardViewProps) => {
1517
const translation = useTasksTranslation()
1618

1719
const sex = patient.sex
@@ -74,6 +76,7 @@ export const PatientCardView = ({ patient, onClick }: PatientCardViewProps) => {
7476
)}
7577
<PatientStateChip state={patient.state} />
7678
</div>
79+
{extraContent}
7780
</div>
7881
</button>
7982
)

‎web/components/tables/PatientList.tsx‎

Lines changed: 162 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useCallback, useRef } from 'react'
1+
import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useCallback, useRef, type ReactNode } from 'react'
22
import { useMutation } from '@apollo/client/react'
33
import 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'
67
import type { LocationType } from '@/api/gql/generated'
78
import { Sex, PatientState, type GetPatientsQuery, type TaskType, PropertyEntity, FieldType } from '@/api/gql/generated'
89
import { usePropertyDefinitions, usePatientsPaginated, useQueryableFields, useRefreshingEntityIds } from '@/data'
@@ -16,7 +17,10 @@ import { useTasksContext } from '@/hooks/useTasksContext'
1617
import type { ColumnDef, ColumnFiltersState, ColumnOrderState, PaginationState, Row, SortingState, TableState, VisibilityState } from '@tanstack/table-core'
1718
import { getPropertyColumnsForEntity } from '@/utils/propertyColumn'
1819
import { 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'
2024
import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems } from '@/utils/queryableFilterList'
2125
import { getPropertyFilterFn as getPropertyDatatype } from '@/utils/propertyFilterMapping'
2226
import { UserSelectFilterPopUp } from './UserSelectFilterPopUp'
@@ -69,6 +73,15 @@ const LOCATION_KIND_HEADERS: Record<LocationKindColumn, string> = {
6973

7074
const 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+
7285
export 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

Comments
 (0)