diff --git a/src/caretogether-pwa/src/AppRoutes.tsx b/src/caretogether-pwa/src/AppRoutes.tsx index 4a2ca783..023c32ac 100644 --- a/src/caretogether-pwa/src/AppRoutes.tsx +++ b/src/caretogether-pwa/src/AppRoutes.tsx @@ -31,7 +31,7 @@ import { RootRoute } from './Access/RootRoute'; import { Dashboard } from './Dashboard/Dashboard'; import { InboxScreen } from './Inbox/InboxScreen'; import { FamilyScreenRoute } from './Families/FamilyScreenRoute'; -import { V1Cases } from './V1Cases/V1Cases'; +import { ClientsScreenRoute } from './V1Cases/ClientsScreenRoute'; import { V1Referrals } from './V1Referrals/V1Referrals'; import { Volunteers } from './Volunteers/Volunteers'; import { Communities } from './Communities/Communities'; @@ -188,7 +188,7 @@ function AuthorizedLocationContextWrapper({ path="families/:familyId" element={} /> - } /> + } /> } /> } /> } /> diff --git a/src/caretogether-pwa/src/Families/v2DataGridStyles.ts b/src/caretogether-pwa/src/Families/v2DataGridStyles.ts index 00469623..ace06570 100644 --- a/src/caretogether-pwa/src/Families/v2DataGridStyles.ts +++ b/src/caretogether-pwa/src/Families/v2DataGridStyles.ts @@ -1,7 +1,7 @@ import type { Theme } from '@mui/material/styles'; type V2DataGridStylesOptions = { - height?: number; + height?: number | string; highlightedRowClassName?: string; highlightedRowColor?: string; }; diff --git a/src/caretogether-pwa/src/FunctionAssignments/AssignmentRoleFilters.tsx b/src/caretogether-pwa/src/FunctionAssignments/AssignmentRoleFilters.tsx index c168af44..2ea87220 100644 --- a/src/caretogether-pwa/src/FunctionAssignments/AssignmentRoleFilters.tsx +++ b/src/caretogether-pwa/src/FunctionAssignments/AssignmentRoleFilters.tsx @@ -1,4 +1,6 @@ +import { useMemo } from 'react'; import { AssignedIndividualVolunteer, Person } from '../GeneratedClient'; +import type { SelectProps } from '@mui/material'; import { CustomFieldsFilterSelect } from '../Generic/CustomFieldsFilter/CustomFieldsFilterSelect'; import { CustomFieldFilterOption, @@ -13,6 +15,8 @@ type AssignmentRoleFiltersProps = { selectedValuesByRole: AssignmentFilterSelectionsByRole; onChange: (assignmentRole: string, selectedValues: (string | null)[]) => void; personLookup: (personId: string) => Person | undefined; + size?: SelectProps['size']; + variant?: SelectProps['variant']; }; function assignmentFilterOptions( @@ -63,7 +67,25 @@ export function AssignmentRoleFilters({ selectedValuesByRole, onChange, personLookup, + size, + variant, }: AssignmentRoleFiltersProps) { + const optionsByAssignmentRole = useMemo( + () => + Object.fromEntries( + assignmentRoles.map((assignmentRole) => [ + assignmentRole, + assignmentFilterOptions( + assignmentRole, + assignments, + selectedValuesByRole[assignmentRole] ?? [], + personLookup + ), + ]) + ), + [assignmentRoles, assignments, personLookup, selectedValuesByRole] + ); + return ( <> {assignmentRoles.map((assignmentRole) => { @@ -73,13 +95,10 @@ export function AssignmentRoleFilters({ onChange(assignmentRole, assignmentFilterValues(selectedValues)) } diff --git a/src/caretogether-pwa/src/Generic/CustomFieldsFilter/CustomFieldsFilter.tsx b/src/caretogether-pwa/src/Generic/CustomFieldsFilter/CustomFieldsFilter.tsx index e683ee37..9928e0e9 100644 --- a/src/caretogether-pwa/src/Generic/CustomFieldsFilter/CustomFieldsFilter.tsx +++ b/src/caretogether-pwa/src/Generic/CustomFieldsFilter/CustomFieldsFilter.tsx @@ -9,7 +9,7 @@ import { type Props = { customFields: CustomField[]; - optionsByField: Record; + getOptionsForField: (field: CustomField) => CustomFieldFilterOption[]; selectedValuesByField: CustomFieldFilterSelectionsByField; onFieldChange: ( fieldName: string, @@ -21,7 +21,7 @@ type Props = { export function CustomFieldsFilter({ customFields, - optionsByField, + getOptionsForField, selectedValuesByField, onFieldChange, direction = 'row', @@ -40,14 +40,13 @@ export function CustomFieldsFilter({ {customFields.map((field) => { if (!field.name) return null; - const options = optionsByField[field.name] ?? []; const selectedValues = selectedValuesByField[field.name] ?? []; return ( getOptionsForField(field)} selectedValues={selectedValues} onChange={(selected) => onFieldChange(field.name, selected)} fullWidth={fullWidthSelects} diff --git a/src/caretogether-pwa/src/Generic/CustomFieldsFilter/CustomFieldsFilterSelect.tsx b/src/caretogether-pwa/src/Generic/CustomFieldsFilter/CustomFieldsFilterSelect.tsx index a732ecde..f7819c5f 100644 --- a/src/caretogether-pwa/src/Generic/CustomFieldsFilter/CustomFieldsFilterSelect.tsx +++ b/src/caretogether-pwa/src/Generic/CustomFieldsFilter/CustomFieldsFilterSelect.tsx @@ -7,15 +7,20 @@ import { Select, SelectChangeEvent, } from '@mui/material'; +import type { SelectProps } from '@mui/material'; import { FilterList as FilterListIcon } from '@mui/icons-material'; +import { useMemo, useState } from 'react'; import { CustomFieldFilterOption, CustomFieldFilterValue } from './types'; type Props = { label: string; - options: CustomFieldFilterOption[]; + options?: CustomFieldFilterOption[]; + getOptions?: () => CustomFieldFilterOption[]; selectedValues: CustomFieldFilterValue[]; onChange: (selected: CustomFieldFilterValue[]) => void; fullWidth?: boolean; + size?: SelectProps['size']; + variant?: SelectProps['variant']; }; function encodeValue(value: CustomFieldFilterValue) { @@ -44,17 +49,31 @@ function decodeValue(value: string): CustomFieldFilterValue { export function CustomFieldsFilterSelect({ label, - options, + getOptions, + options: providedOptions, selectedValues, onChange, fullWidth = false, + size, + variant = 'standard', }: Props) { + const [open, setOpen] = useState(false); const selectedCount = selectedValues.length; - const selectedOptionValues = selectedValues.map(encodeValue); - const displayText = - selectedCount === options.length - ? label - : `${label} (${selectedCount}/${options.length})`; + const shouldLoadOptions = open || selectedCount > 0 || !getOptions; + const options = useMemo( + () => (shouldLoadOptions ? (getOptions?.() ?? providedOptions ?? []) : []), + [getOptions, providedOptions, shouldLoadOptions] + ); + const selectedOptionValues = useMemo( + () => selectedValues.map(encodeValue), + [selectedValues] + ); + const displayText = (() => { + if (selectedCount === 0) return label; + if (options.length === 0) return `${label} (${selectedCount})`; + if (selectedCount === options.length) return label; + return `${label} (${selectedCount}/${options.length})`; + })(); return ( setOpen(true)} + onClose={() => setOpen(false)} onChange={(event: SelectChangeEvent) => { const selected = event.target.value; if (typeof selected !== 'string') { onChange(selected.map(decodeValue)); } }} - input={} - IconComponent={FilterListIcon} + {...(variant === 'standard' + ? { input: , IconComponent: FilterListIcon } + : {})} SelectDisplayProps={{ title: displayText }} renderValue={() => displayText} > diff --git a/src/caretogether-pwa/src/Generic/CustomFieldsFilter/useCustomFieldFilters.ts b/src/caretogether-pwa/src/Generic/CustomFieldsFilter/useCustomFieldFilters.ts index e486cabc..1a513b75 100644 --- a/src/caretogether-pwa/src/Generic/CustomFieldsFilter/useCustomFieldFilters.ts +++ b/src/caretogether-pwa/src/Generic/CustomFieldsFilter/useCustomFieldFilters.ts @@ -14,6 +14,84 @@ type Args = { getValue: (item: TItem, fieldName: string) => unknown; }; +function optionsForField({ + field, + getValue, + isBlank, + items, + selectedValuesByField, +}: Args & { + field: CustomField; + selectedValuesByField: CustomFieldFilterSelectionsByField; +}) { + const selectedSet = new Set( + selectedValuesByField[field.name] ?? [] + ); + + if (field.type === CustomFieldType.Boolean) { + const options: CustomFieldFilterOption[] = [ + { key: '(blank)', value: null, selected: selectedSet.has(null) }, + { key: 'Yes', value: true, selected: selectedSet.has(true) }, + { key: 'No', value: false, selected: selectedSet.has(false) }, + ]; + + return options; + } + + const observedValues: CustomFieldFilterValue[] = + items.flatMap((item) => { + if (isBlank(item, field.name)) return []; + + const raw = getValue(item, field.name); + + if (raw === undefined || raw === null || raw === '') return []; + + if (field.type === CustomFieldType.StringArray) { + if (Array.isArray(raw)) return raw as string[]; + return []; + } + + return [raw.toString()]; + }); + + const values = Array.from( + new Set([ + null, + ...observedValues, + ...(field.validValues ?? []), + ]) + ); + + const mappedOptions: CustomFieldFilterOption[] = values.map((v) => { + const key = v === null ? '(blank)' : v.toString(); + return { + key, + value: v, + selected: selectedSet.has(v), + }; + }); + + const optionsByKey = new Map( + mappedOptions.map((o) => [o.key, o]) + ); + + return field.type === CustomFieldType.StringArray && + field.validValues && + field.validValues.length > 0 + ? [ + ...mappedOptions.filter((o) => o.value === null), + ...sortByPolicyOrder( + mappedOptions.filter((o) => o.value !== null).map((o) => o.key), + field.validValues + ).map((key) => optionsByKey.get(key)!), + ] + : mappedOptions.sort((a, b) => { + if (a.value === null) return -1; + if (b.value === null) return 1; + return a.key.localeCompare(b.key); + }); +} + export function useCustomFieldFilters({ customFields, items, @@ -33,84 +111,23 @@ export function useCustomFieldFilters({ [] ); - const optionsByField = React.useMemo(() => { - return Object.fromEntries( - customFields.map((field) => { - const selectedSet = new Set( - selectedValuesByField[field.name] ?? [] - ); - - if (field.type === CustomFieldType.Boolean) { - const options: CustomFieldFilterOption[] = [ - { key: '(blank)', value: null, selected: selectedSet.has(null) }, - { key: 'Yes', value: true, selected: selectedSet.has(true) }, - { key: 'No', value: false, selected: selectedSet.has(false) }, - ]; - - return [field.name, options] as const; - } - - const observedValues: CustomFieldFilterValue[] = - items.flatMap((item) => { - if (isBlank(item, field.name)) return []; - - const raw = getValue(item, field.name); - - if (raw === undefined || raw === null || raw === '') return []; - - if (field.type === CustomFieldType.StringArray) { - if (Array.isArray(raw)) return raw as string[]; - return []; - } - - return [raw.toString()]; - }); - - const values = Array.from( - new Set([null, ...observedValues, ...(field.validValues ?? [])]) - ); - - const mappedOptions: CustomFieldFilterOption[] = values.map((v) => { - const key = v === null ? '(blank)' : v.toString(); - return { - key, - value: v, - selected: selectedSet.has(v), - }; - }); - - const optionsByKey = new Map( - mappedOptions.map((o) => [o.key, o]) - ); - - const options: CustomFieldFilterOption[] = - field.type === CustomFieldType.StringArray && - field.validValues && - field.validValues.length > 0 - ? [ - ...mappedOptions.filter((o) => o.value === null), - ...sortByPolicyOrder( - mappedOptions - .filter((o) => o.value !== null) - .map((o) => o.key), - field.validValues - ).map((key) => optionsByKey.get(key)!), - ] - : mappedOptions.sort((a, b) => { - if (a.value === null) return -1; - if (b.value === null) return 1; - return a.key.localeCompare(b.key); - }); - - return [field.name, options] as const; - }) - ) as Record; - }, [customFields, getValue, isBlank, items, selectedValuesByField]); + const getOptionsForField = React.useCallback( + (field: CustomField) => + optionsForField({ + customFields, + field, + getValue, + isBlank, + items, + selectedValuesByField, + }), + [customFields, getValue, isBlank, items, selectedValuesByField] + ); return { selectedValuesByField, setSelectedValuesByField, setSelectedValuesForField, - optionsByField, + getOptionsForField, }; } diff --git a/src/caretogether-pwa/src/Model/DirectoryModel.ts b/src/caretogether-pwa/src/Model/DirectoryModel.ts index fad3495e..e5f0fde6 100644 --- a/src/caretogether-pwa/src/Model/DirectoryModel.ts +++ b/src/caretogether-pwa/src/Model/DirectoryModel.ts @@ -1,4 +1,5 @@ import { useRecoilValue } from 'recoil'; +import { useCallback, useMemo } from 'react'; import { AddAdultToFamilyCommand, AddChildToFamilyCommand, @@ -98,11 +99,18 @@ function trackNoteAuthorLookupError(note: Note, reason: string) { export function usePersonLookup() { const visibleFamilies = useRecoilValue(visibleFamiliesQuery); - - return (familyId?: string, personId?: string) => { - const family = visibleFamilies.find( - (family) => family.family!.id === familyId - ); + const familyById = useMemo( + () => + new Map( + visibleFamilies.flatMap((family) => + family.family?.id ? [[family.family.id, family] as const] : [] + ) + ), + [visibleFamilies] + ); + + return useCallback((familyId?: string, personId?: string) => { + const family = familyId ? familyById.get(familyId) : undefined; const adult = family?.family?.adults?.find( (adult) => adult.item1!.id === personId ); @@ -110,26 +118,38 @@ export function usePersonLookup() { adult?.item1 || family?.family?.children?.find((child) => child.id === personId); return person; - }; + }, [familyById]); } export function usePersonAndFamilyLookup() { const visibleFamilies = useRecoilValue(visibleFamiliesQuery); - - return (personId?: string) => { - const family = visibleFamilies.find( - (family) => - family.family!.adults!.some((adult) => adult.item1!.id === personId) || - family.family!.children!.some((child) => child.id === personId) - ); - const adult = family?.family?.adults?.find( - (adult) => adult.item1!.id === personId - ); - const person = - adult?.item1 || - family?.family?.children?.find((child) => child.id === personId); - return { family: family?.family, person: person }; - }; + const personAndFamilyByPersonId = useMemo( + () => + new Map( + visibleFamilies.flatMap((family) => [ + ...(family.family?.adults?.flatMap((adult) => + adult.item1?.id + ? [[adult.item1.id, { family: family.family, person: adult.item1 }] as const] + : [] + ) ?? []), + ...(family.family?.children?.flatMap((child) => + child.id + ? [[child.id, { family: family.family, person: child }] as const] + : [] + ) ?? []), + ]) + ), + [visibleFamilies] + ); + + return useCallback( + (personId?: string) => + (personId ? personAndFamilyByPersonId.get(personId) : undefined) ?? { + family: undefined, + person: undefined, + }, + [personAndFamilyByPersonId] + ); } export function useUserLookup() { @@ -193,13 +213,20 @@ export function useNoteAuthorLookup() { export function useFamilyLookup() { const visibleFamilies = useRecoilValue(visibleFamiliesQuery); + const familyById = useMemo( + () => + new Map( + visibleFamilies.flatMap((family) => + family.family?.id ? [[family.family.id, family] as const] : [] + ) + ), + [visibleFamilies] + ); - return (familyId?: string) => { - const family = visibleFamilies.find( - (family) => family.family!.id === familyId - ); - return family; - }; + return useCallback( + (familyId?: string) => (familyId ? familyById.get(familyId) : undefined), + [familyById] + ); } export function useCommunityLookup() { diff --git a/src/caretogether-pwa/src/V1Cases/Arrangements/ArrangementsDataGridV2.tsx b/src/caretogether-pwa/src/V1Cases/Arrangements/ArrangementsDataGridV2.tsx index d9687177..a3575f93 100644 --- a/src/caretogether-pwa/src/V1Cases/Arrangements/ArrangementsDataGridV2.tsx +++ b/src/caretogether-pwa/src/V1Cases/Arrangements/ArrangementsDataGridV2.tsx @@ -5,7 +5,6 @@ import { Box, Chip, Stack, Typography, useTheme } from '@mui/material'; import { DataGrid, GridColDef, GridToolbar } from '@mui/x-data-grid'; import { Arrangement, - ArrangementPhase, ArrangementPolicy, ChildInvolvement, FunctionRequirement, @@ -17,6 +16,7 @@ import type { ArrangementRowV2, ChildcareArrangementRowV2, } from './arrangementViewModel'; +import { arrangementPhaseColor } from './arrangementPresentationV2'; type ArrangementsDataGridV2Props = { highlightedArrangementId?: string; @@ -28,13 +28,6 @@ function displayValue(value?: string) { return value || '-'; } -function arrangementPhaseColor(phase?: ArrangementPhase) { - if (phase === ArrangementPhase.Ended) return 'success'; - if (phase === ArrangementPhase.Cancelled) return 'default'; - if (phase === ArrangementPhase.Started) return 'info'; - return 'warning'; -} - function usesChildLocation(arrangementPolicy?: ArrangementPolicy) { return ( arrangementPolicy?.childInvolvement === ChildInvolvement.ChildHousing || diff --git a/src/caretogether-pwa/src/V1Cases/Arrangements/arrangementPresentationV2.ts b/src/caretogether-pwa/src/V1Cases/Arrangements/arrangementPresentationV2.ts new file mode 100644 index 00000000..bce25128 --- /dev/null +++ b/src/caretogether-pwa/src/V1Cases/Arrangements/arrangementPresentationV2.ts @@ -0,0 +1,11 @@ +import type { ChipProps } from '@mui/material'; +import { ArrangementPhase } from '../../GeneratedClient'; + +export function arrangementPhaseColor( + phase?: ArrangementPhase +): ChipProps['color'] { + if (phase === ArrangementPhase.Ended) return 'success'; + if (phase === ArrangementPhase.Cancelled) return 'default'; + if (phase === ArrangementPhase.Started) return 'info'; + return 'warning'; +} diff --git a/src/caretogether-pwa/src/V1Cases/ClientArrangementSummaryCellV2.tsx b/src/caretogether-pwa/src/V1Cases/ClientArrangementSummaryCellV2.tsx new file mode 100644 index 00000000..ddc01caa --- /dev/null +++ b/src/caretogether-pwa/src/V1Cases/ClientArrangementSummaryCellV2.tsx @@ -0,0 +1,37 @@ +import { Box, Chip } from '@mui/material'; +import { arrangementPhaseColor } from './Arrangements/arrangementPresentationV2'; +import type { ClientArrangementSummaryItemV2 } from './useClientsBrowserViewModel'; + +type ClientArrangementSummaryCellV2Props = { + arrangementRows: ClientArrangementSummaryItemV2[]; +}; + +const maxVisibleArrangements = 4; + +export function ClientArrangementSummaryCellV2({ + arrangementRows, +}: ClientArrangementSummaryCellV2Props) { + if (arrangementRows.length === 0) { + return null; + } + + const visibleArrangements = arrangementRows.slice(0, maxVisibleArrangements); + const overflowCount = arrangementRows.length - visibleArrangements.length; + + return ( + + {visibleArrangements.map((row) => ( + + ))} + {overflowCount > 0 && ( + + )} + + ); +} diff --git a/src/caretogether-pwa/src/V1Cases/ClientFamilyCellV2.tsx b/src/caretogether-pwa/src/V1Cases/ClientFamilyCellV2.tsx new file mode 100644 index 00000000..dc9002d5 --- /dev/null +++ b/src/caretogether-pwa/src/V1Cases/ClientFamilyCellV2.tsx @@ -0,0 +1,68 @@ +import { Phone as PhoneIcon } from '@mui/icons-material'; +import { Box, Stack, Typography } from '@mui/material'; +import { v2Typography } from '../Families/v2Typography'; + +type ClientFamilyCellV2Props = { + familyName: string; + phoneNumber?: string; + primaryContactName?: string; +}; + +export function ClientFamilyCellV2({ + familyName, + phoneNumber, + primaryContactName, +}: ClientFamilyCellV2Props) { + return ( + + + {familyName} + + {(primaryContactName || phoneNumber) && ( + + {primaryContactName && ( + + {primaryContactName} + + )} + {phoneNumber && ( + + + + {phoneNumber} + + + )} + + )} + + ); +} diff --git a/src/caretogether-pwa/src/V1Cases/ClientsBrowserToolbarV2.tsx b/src/caretogether-pwa/src/V1Cases/ClientsBrowserToolbarV2.tsx new file mode 100644 index 00000000..909fa95b --- /dev/null +++ b/src/caretogether-pwa/src/V1Cases/ClientsBrowserToolbarV2.tsx @@ -0,0 +1,202 @@ +import { + FilterList as FilterListIcon, + Search as SearchIcon, + Sort as SortIcon, +} from '@mui/icons-material'; +import { + Box, + Button, + FormControl, + InputAdornment, + InputLabel, + MenuItem, + Select, + Stack, + TextField, +} from '@mui/material'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import type { + AssignedIndividualVolunteer, + Person, +} from '../GeneratedClient'; +import type { + AssignmentFilterSelectionsByRole, +} from '../FunctionAssignments/assignmentRoleColumns'; +import { AssignmentRoleFilters } from '../FunctionAssignments/AssignmentRoleFilters'; +import { PartneringFamiliesSortMode } from './PartneringFamilies/sortPartneringFamilies'; +import { ArrangementsFilter } from './PartneringFamilies/types'; + +const BLANK_COUNTY_LABEL = '(blank)'; +const BLANK_COUNTY_SELECT_VALUE = '__blank_county__'; + +type ClientsBrowserToolbarV2Props = { + activeCustomFieldFilterCount?: number; + assignmentFilterAssignments?: AssignedIndividualVolunteer[]; + assignmentFilters?: AssignmentFilterSelectionsByRole; + assignmentPersonLookup?: (personId: string) => Person | undefined; + assignmentRoles?: string[]; + customFieldCount?: number; + countyOptions: string[]; + countyValue: (string | null)[]; + onAssignmentFilterChange?: ( + assignmentRole: string, + selectedValues: (string | null)[] + ) => void; + onCountyChange?: (value: (string | null)[]) => void; + onMoreFiltersClick?: () => void; + onSearchChange?: (value: string) => void; + onSortChange?: (value: PartneringFamiliesSortMode) => void; + onStatusChange?: (value: ArrangementsFilter) => void; + searchValue: string; + sortValue: PartneringFamiliesSortMode; + statusValue: ArrangementsFilter; +}; + +export function ClientsBrowserToolbarV2({ + activeCustomFieldFilterCount = 0, + assignmentFilterAssignments = [], + assignmentFilters = {}, + assignmentPersonLookup, + assignmentRoles = [], + customFieldCount = 0, + countyOptions, + countyValue, + onAssignmentFilterChange, + onCountyChange, + onMoreFiltersClick, + onSearchChange, + onSortChange, + onStatusChange, + searchValue, + sortValue, + statusValue, +}: ClientsBrowserToolbarV2Props) { + const selectedCountyValues = countyValue.map( + (county) => county ?? BLANK_COUNTY_SELECT_VALUE + ); + const handleCountyChange = (event: SelectChangeEvent) => { + const value = event.target.value; + const selectedValues = typeof value === 'string' ? value.split(',') : value; + + onCountyChange?.( + selectedValues.map((county) => + county === BLANK_COUNTY_SELECT_VALUE ? null : county + ) + ); + }; + const shouldRenderAssignmentFilters = + assignmentRoles.length > 0 && + !!assignmentPersonLookup && + !!onAssignmentFilterChange; + const shouldRenderMoreFilters = + customFieldCount > 0 && !!onMoreFiltersClick; + + return ( + + + + + ), + }, + }} + label="Search" + onChange={(event) => onSearchChange?.(event.target.value)} + size="small" + sx={{ minWidth: { xs: '100%', sm: 260 } }} + value={searchValue} + /> + + Status + + + + County + + + {shouldRenderAssignmentFilters && ( + + )} + + Sort + + + {shouldRenderMoreFilters && ( + + )} + + + ); +} diff --git a/src/caretogether-pwa/src/V1Cases/ClientsDataGridV2.tsx b/src/caretogether-pwa/src/V1Cases/ClientsDataGridV2.tsx new file mode 100644 index 00000000..97eb9d53 --- /dev/null +++ b/src/caretogether-pwa/src/V1Cases/ClientsDataGridV2.tsx @@ -0,0 +1,232 @@ +import { Box, Stack, Typography, useTheme } from '@mui/material'; +import { + DataGrid, + GridColDef, + GridColumnVisibilityModel, + GridRowParams, +} from '@mui/x-data-grid'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { v2DataGridStyles } from '../Families/v2DataGridStyles'; +import { v2Typography } from '../Families/v2Typography'; +import { ClientBrowserRowV2 } from './useClientsBrowserViewModel'; +import { ClientFamilyCellV2 } from './ClientFamilyCellV2'; +import { ClientArrangementSummaryCellV2 } from './ClientArrangementSummaryCellV2'; +import type { CustomField } from '../GeneratedClient'; + +type ClientsDataGridV2Props = { + assignmentRoles?: string[]; + customFields?: CustomField[]; + loading?: boolean; + onRowClick: (row: ClientBrowserRowV2) => void; + rows: ClientBrowserRowV2[]; +}; + +const CLIENTS_GRID_PAGE_SIZE = 100; +const clientsGridPageSizeOptions = [CLIENTS_GRID_PAGE_SIZE]; +const clientsGridInitialState = { + pagination: { + paginationModel: { pageSize: CLIENTS_GRID_PAGE_SIZE }, + }, +}; +const clientsGridSlots = { + noRowsOverlay: ClientsEmptyState, +}; + +function getClientsRowHeight() { + return 'auto' as const; +} + +function getEstimatedClientsRowHeight() { + return 72; +} + +function displayValue(value: string) { + return value || '-'; +} + +function buildAssignmentColumns( + assignmentRoles: string[] +): GridColDef[] { + return assignmentRoles.map((assignmentRole) => ({ + field: `assignmentRole:${assignmentRole}`, + flex: 1, + headerName: assignmentRole, + minWidth: 160, + renderCell: ({ row }) => ( + + {displayValue(row.assignmentRoleValues[assignmentRole] ?? '')} + + ), + sortable: false, + valueGetter: (_value, row) => row.assignmentRoleValues[assignmentRole] ?? '', + })); +} + +function customFieldColumnField(customFieldName: string) { + return `customField:${customFieldName}`; +} + +function buildCustomFieldColumns( + customFields: CustomField[] +): GridColDef[] { + return customFields.map((customField) => ({ + field: customFieldColumnField(customField.name), + flex: 1, + headerName: customField.name, + minWidth: 160, + renderCell: ({ row }) => ( + + {displayValue(row.customFieldValues[customField.name] ?? '')} + + ), + valueGetter: (_value, row) => row.customFieldValues[customField.name] ?? '', + })); +} + +function buildColumns( + assignmentRoles: string[], + customFields: CustomField[] +): GridColDef[] { + return [ + { + field: 'family', + flex: 1.3, + headerName: 'Family', + minWidth: 220, + renderCell: ({ row }) => ( + + ), + }, + { + field: 'status', + flex: 1, + headerName: 'Status', + minWidth: 150, + renderCell: ({ row }) => ( + + {displayValue(row.status)} + + ), + }, + { + field: 'county', + flex: 1, + headerName: 'County', + minWidth: 140, + renderCell: ({ row }) => ( + + {displayValue(row.county)} + + ), + }, + ...buildAssignmentColumns(assignmentRoles), + ...buildCustomFieldColumns(customFields), + { + field: 'arrangements', + flex: 1.4, + headerName: 'Arrangements', + minWidth: 260, + renderCell: ({ row }) => ( + + ), + }, + ]; +} + +function ClientsEmptyState() { + return ( + + + No client families found. + + + Client families will appear here when they are available. + + + ); +} + +export function ClientsDataGridV2({ + assignmentRoles = [], + customFields = [], + loading = false, + onRowClick, + rows, +}: ClientsDataGridV2Props) { + const theme = useTheme(); + const columns = useMemo(() => buildColumns(assignmentRoles, customFields), [ + assignmentRoles, + customFields, + ]); + const [columnVisibilityModel, setColumnVisibilityModel] = + useState({}); + const handleRowClick = useCallback( + ({ row }: GridRowParams) => onRowClick(row), + [onRowClick] + ); + + useEffect(() => { + setColumnVisibilityModel((current) => { + const customFieldColumnFields = new Set( + customFields.map((customField) => + customFieldColumnField(customField.name) + ) + ); + const next = Object.fromEntries( + Object.entries(current).filter( + ([field]) => + !field.startsWith('customField:') || + customFieldColumnFields.has(field) + ) + ); + + customFieldColumnFields.forEach((field) => { + if (!(field in next)) { + next[field] = false; + } + }); + + return next; + }); + }, [customFields]); + + return ( + + + + ); +} diff --git a/src/caretogether-pwa/src/V1Cases/ClientsScreenRoute.tsx b/src/caretogether-pwa/src/V1Cases/ClientsScreenRoute.tsx new file mode 100644 index 00000000..7052ad33 --- /dev/null +++ b/src/caretogether-pwa/src/V1Cases/ClientsScreenRoute.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; +import { useFeatureFlagEnabled, usePostHog } from 'posthog-js/react'; +import { Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { FAMILY_SCREEN_V2_EARLY_ACCESS_FEATURE_FLAG } from '../featureFlags'; +import { ProgressBackdrop } from '../Shell/ProgressBackdrop'; +import { V1Cases } from './V1Cases'; +import { ClientsScreenV2 } from './ClientsScreenV2'; + +function ClientFamilyRedirect() { + const { familyId } = useParams<{ familyId: string }>(); + + return ; +} + +export function ClientsScreenRoute() { + const posthog = usePostHog(); + const earlyAccessEnabled = useFeatureFlagEnabled( + FAMILY_SCREEN_V2_EARLY_ACCESS_FEATURE_FLAG + ); + const [featureFlagsLoaded, setFeatureFlagsLoaded] = useState( + () => posthog.featureFlags.hasLoadedFlags + ); + + useEffect(() => { + setFeatureFlagsLoaded(posthog.featureFlags.hasLoadedFlags); + + return posthog.onFeatureFlags(() => { + setFeatureFlagsLoaded(true); + }); + }, [posthog]); + + if (!featureFlagsLoaded) { + return ( + +

Loading...

+
+ ); + } + + const showClientsScreenV2 = earlyAccessEnabled === true; + + if (!showClientsScreenV2) { + return ; + } + + return ( + + } /> + } /> + } /> + + ); +} diff --git a/src/caretogether-pwa/src/V1Cases/ClientsScreenV2.tsx b/src/caretogether-pwa/src/V1Cases/ClientsScreenV2.tsx new file mode 100644 index 00000000..0f09bd06 --- /dev/null +++ b/src/caretogether-pwa/src/V1Cases/ClientsScreenV2.tsx @@ -0,0 +1,226 @@ +import { Box, Stack, Typography } from '@mui/material'; +import { useCallback, useEffect, useState } from 'react'; +import { useFeatureFlagEnabled } from 'posthog-js/react'; +import { Permission } from '../GeneratedClient'; +import { useScreenTitle } from '../Shell/ShellScreenTitle'; +import { v2Typography } from '../Families/v2Typography'; +import { useLocalStorage } from '../Hooks/useLocalStorage'; +import { ClientsBrowserToolbarV2 } from './ClientsBrowserToolbarV2'; +import { ClientsDataGridV2 } from './ClientsDataGridV2'; +import { + ClientBrowserRowV2, + useClientsBrowserViewModel, +} from './useClientsBrowserViewModel'; +import { useAppNavigate } from '../Hooks/useAppNavigate'; +import { + normalizePartneringFamiliesSortMode, + PartneringFamiliesSortMode, +} from './PartneringFamilies/sortPartneringFamilies'; +import { + ArrangementsFilter, + normalizeArrangementsFilter, +} from './PartneringFamilies/types'; +import { FUNCTION_ASSIGNMENTS_FEATURE_FLAG } from '../featureFlags'; +import { + useAllPartneringFamiliesPermissions, + useGlobalPermissions, +} from '../Model/SessionModel'; +import { usePersonAndFamilyLookup } from '../Model/DirectoryModel'; +import type { AssignmentFilterSelectionsByRole } from '../FunctionAssignments/assignmentRoleColumns'; +import { useCustomFieldFilters } from '../Generic/CustomFieldsFilter/useCustomFieldFilters'; +import { useSidePanel } from '../Hooks/useSidePanel'; +import { PartneringFamilyCustomFieldFiltersSidePanel } from './PartneringFamilies/PartneringFamilyCustomFieldFiltersSidePanel'; +import { useLoadable } from '../Hooks/useLoadable'; +import { partneringFamiliesData } from '../Model/V1CasesModel'; +import { policyData } from '../Model/ConfigurationModel'; +import { wideTablePageSx } from '../Utilities/wideTablePageSx'; + +const PARTNERING_FAMILIES_SORT_STORAGE_KEY = 'partnering-families-sortMode'; +const ARRANGEMENTS_FILTER_STORAGE_KEY = + 'partnering-families-arrangementsFilter'; +const CLIENT_SEARCH_DEBOUNCE_MS = 200; + +function useDebouncedValue(value: T, delayMs: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeoutId = window.setTimeout(() => setDebouncedValue(value), delayMs); + + return () => window.clearTimeout(timeoutId); + }, [delayMs, value]); + + return debouncedValue; +} + +export function ClientsScreenV2() { + useScreenTitle('Clients'); + const appNavigate = useAppNavigate(); + const personAndFamilyLookup = usePersonAndFamilyLookup(); + const globalPermissions = useGlobalPermissions(); + const permissions = useAllPartneringFamiliesPermissions(); + const functionAssignmentsEnabled = useFeatureFlagEnabled( + FUNCTION_ASSIGNMENTS_FEATURE_FLAG + ); + const { + SidePanel: CustomFieldFiltersSidePanel, + openSidePanel: openCustomFieldFiltersSidePanel, + closeSidePanel: closeCustomFieldFiltersSidePanel, + } = useSidePanel(); + const canViewFunctionAssignments = + functionAssignmentsEnabled === true && + permissions(Permission.ViewV1CaseFunctionAssignments); + const [searchValue, setSearchValue] = useState(''); + const debouncedSearchValue = useDebouncedValue( + searchValue, + CLIENT_SEARCH_DEBOUNCE_MS + ); + const [countyFilter, setCountyFilter] = useState<(string | null)[]>([]); + const [assignmentFilters, setAssignmentFilters] = + useState({}); + const [storedArrangementsFilter, setStoredArrangementsFilter] = + useLocalStorage( + ARRANGEMENTS_FILTER_STORAGE_KEY, + 'All' + ); + const arrangementsFilter = normalizeArrangementsFilter( + storedArrangementsFilter + ); + const [storedSortMode, setStoredSortMode] = + useLocalStorage( + PARTNERING_FAMILIES_SORT_STORAGE_KEY, + 'lastNameAsc' + ); + const sortMode = normalizePartneringFamiliesSortMode(storedSortMode); + const customFieldFilterItems = useLoadable(partneringFamiliesData) ?? []; + const customFieldDefinitions = + useLoadable(policyData)?.referralPolicy?.customFields ?? []; + const isBlankCustomFieldValue = useCallback( + (family: (typeof customFieldFilterItems)[number], fieldName: string) => + family.partneringFamilyInfo?.openV1Case?.missingCustomFields?.includes( + fieldName + ) ?? false, + [] + ); + const getCustomFieldValue = useCallback( + (family: (typeof customFieldFilterItems)[number], fieldName: string) => + family.partneringFamilyInfo?.openV1Case?.completedCustomFields?.find( + (field) => field.customFieldName === fieldName + )?.value, + [] + ); + const { + selectedValuesByField: selectedCustomFieldValuesByField, + setSelectedValuesForField: setSelectedCustomFieldValuesForField, + getOptionsForField: getCustomFieldFilterOptionsForField, + } = useCustomFieldFilters({ + customFields: customFieldDefinitions, + items: customFieldFilterItems, + isBlank: isBlankCustomFieldValue, + getValue: getCustomFieldValue, + }); + const activeCustomFieldFilterCount = Object.values( + selectedCustomFieldValuesByField + ).filter((selectedValues) => selectedValues.length > 0).length; + const assignmentPersonLookup = useCallback( + (personId: string) => personAndFamilyLookup(personId).person, + [personAndFamilyLookup] + ); + const handleAssignmentFilterChange = useCallback( + (assignmentRole: string, selectedValues: (string | null)[]) => + setAssignmentFilters((current) => ({ + ...current, + [assignmentRole]: selectedValues, + })), + [] + ); + const handleRowClick = useCallback( + (row: ClientBrowserRowV2) => appNavigate.family(row.familyId), + [appNavigate] + ); + const { + assignmentFilterAssignments, + assignmentColumnRoles, + assignmentFilterOptions, + customFieldDefinitions: clientFamilyCustomFieldDefinitions, + counties, + isLoading, + rows, + } = useClientsBrowserViewModel({ + arrangementsFilter, + assignmentFilters, + canViewFunctionAssignments, + countyFilter, + filterText: debouncedSearchValue, + selectedCustomFieldValuesByField, + sortMode, + }); + const hasFeaturebaseChat = globalPermissions(Permission.AccessSupportScreen); + + return ( + + + + + Clients + + + Browse client families, open cases, and arrangement summaries. + + + + + + + + + + + + ); +} diff --git a/src/caretogether-pwa/src/V1Cases/PartneringFamilies.tsx b/src/caretogether-pwa/src/V1Cases/PartneringFamilies.tsx index 01d7a4bc..f24b5236 100644 --- a/src/caretogether-pwa/src/V1Cases/PartneringFamilies.tsx +++ b/src/caretogether-pwa/src/V1Cases/PartneringFamilies.tsx @@ -50,7 +50,10 @@ import { useFeatureFlagEnabledWithLocalOverride } from '../Utilities/Instrumenta import { forceCheck } from '../Utilities/reactLazyLoadInterop'; import { PartneringFamilyTableItem } from './PartneringFamilies/PartneringFamilyTableItem'; import { arrangementStatusSummary } from './PartneringFamilies/arrangementStatusSummary'; -import { ArrangementsFilter } from './PartneringFamilies/types'; +import { + ArrangementsFilter, + normalizeArrangementsFilter, +} from './PartneringFamilies/types'; import { containedStickyHeaderTableSx } from '../Utilities/stickyHeaderTableSx'; import { WideTableContainer } from '../Utilities/WideTableContainer'; import { wideTablePageSx } from '../Utilities/wideTablePageSx'; @@ -77,22 +80,6 @@ const PARTNERING_FAMILIES_SORT_STORAGE_KEY = 'partnering-families-sortMode'; const ARRANGEMENTS_FILTER_STORAGE_KEY = 'partnering-families-arrangementsFilter'; -function normalizeArrangementsFilter( - value: ArrangementsFilter | null | undefined -): ArrangementsFilter { - switch (value) { - case 'All': - case 'Intake': - case 'Active': - case 'Setup': - case 'Active + Setup': - return value; - - default: - return 'All'; - } -} - function isSetupOrActiveArrangementPhase(phase: ArrangementPhase | undefined) { return ( phase === ArrangementPhase.Started || @@ -163,7 +150,7 @@ function PartneringFamilies() { const { selectedValuesByField: selectedCustomFieldValuesByField, setSelectedValuesForField: setSelectedCustomFieldValuesForField, - optionsByField: customFieldFilterOptionsByField, + getOptionsForField: getCustomFieldFilterOptionsForField, } = useCustomFieldFilters({ customFields: referralCustomFields, items: partneringFamilies, @@ -548,7 +535,7 @@ function PartneringFamilies() { ; + getOptionsForField: (field: CustomField) => CustomFieldFilterOption[]; selectedValuesByField: CustomFieldFilterSelectionsByField; onFieldChange: ( fieldName: string, @@ -21,7 +21,7 @@ type Props = { export function PartneringFamilyCustomFieldFiltersSidePanel({ customFields, - optionsByField, + getOptionsForField, selectedValuesByField, onFieldChange, onClose, @@ -43,7 +43,7 @@ export function PartneringFamilyCustomFieldFiltersSidePanel({ ; + county: string; + customFieldValues: Record; + family: string; + familyId: string; + id: string; + phoneNumber?: string; + primaryContactName?: string; + status: string; +}; + +export type ClientArrangementSummaryItemV2 = { + arrangementType: string; + id: string; + phase?: ArrangementPhase; + statusLabel: string; +}; + +type ClientBrowserPresentationRowV2 = ClientBrowserRowV2 & { + searchText: string; + sourceFamily: CombinedFamilyInfo; +}; + +type UseClientsBrowserViewModelParameters = { + arrangementsFilter?: ArrangementsFilter; + assignmentFilters?: AssignmentFilterSelectionsByRole; + canViewFunctionAssignments?: boolean; + countyFilter?: (string | null)[]; + filterText?: string; + selectedCustomFieldValuesByField?: CustomFieldFilterSelectionsByField; + sortMode?: PartneringFamiliesSortMode; +}; + +function isSetupOrActiveArrangementPhase(phase: ArrangementPhase | undefined) { + return ( + phase === ArrangementPhase.Started || + phase === ArrangementPhase.SettingUp || + phase === ArrangementPhase.ReadyToStart + ); +} + +function caseStatusText(v1Case: V1Case | undefined) { + if (!v1Case) return 'No case'; + + if (v1Case.openedAtUtc && !v1Case.closedAtUtc) { + return `Open since ${format(v1Case.openedAtUtc, 'MM/dd/yyyy')}`; + } + + if (v1Case.closedAtUtc) { + return [`Closed ${format(v1Case.closedAtUtc, 'MM/dd/yyyy')}`, v1Case.closeReason] + .filter(Boolean) + .join(' - '); + } + + return 'Closed'; +} + +function latestClosedCase(family: CombinedFamilyInfo) { + const closedCases = family.partneringFamilyInfo?.closedV1Cases ?? []; + + return closedCases.length > 0 ? closedCases[closedCases.length - 1] : undefined; +} + +function currentCaseStatusText(family: CombinedFamilyInfo) { + return caseStatusText( + family.partneringFamilyInfo?.openV1Case ?? latestClosedCase(family) + ); +} + +function primaryContact(family: CombinedFamilyInfo) { + return family.family?.adults?.find( + (adult) => adult.item1?.id === family.family?.primaryFamilyContactPersonId + )?.item1; +} + +function clientFamilySearchText(family: CombinedFamilyInfo) { + return [ + ...(family.family?.adults?.map((adult) => + simplify(`${adult.item1?.firstName} ${adult.item1?.lastName}`) + ) ?? []), + ...(family.family?.children?.map((child) => + simplify(`${child?.firstName} ${child?.lastName}`) + ) ?? []), + ].join(' '); +} + +function matchesSearchText(row: ClientBrowserPresentationRowV2, inputText: string) { + return inputText.length === 0 || row.searchText.includes(inputText); +} + +function arrangementPhaseLabel(phase?: ArrangementPhase) { + if (phase === ArrangementPhase.SettingUp) return 'Setting up'; + if (phase === ArrangementPhase.ReadyToStart) return 'Ready to start'; + if (phase === ArrangementPhase.Started) return 'Started'; + if (phase === ArrangementPhase.Ended) return 'Ended'; + if (phase === ArrangementPhase.Cancelled) return 'Cancelled'; + return 'Unknown'; +} + +function arrangementSummaryRows( + arrangements: Arrangement[] +): ClientArrangementSummaryItemV2[] { + return arrangements.map((arrangement) => ({ + arrangementType: arrangement.arrangementType || 'Arrangement', + id: arrangement.id, + phase: arrangement.phase, + statusLabel: arrangementPhaseLabel(arrangement.phase), + })); +} + +function arrangementSummary(arrangementRows: ClientArrangementSummaryItemV2[]) { + if (arrangementRows.length === 0) return ''; + + const activeCount = arrangementRows.filter( + (row) => row.phase === ArrangementPhase.Started + ).length; + const setupCount = arrangementRows.filter((row) => + isSetupOrActiveArrangementPhase(row.phase) + ).length; + + if (activeCount > 0) return `${activeCount} active`; + if (setupCount > 0) return `${setupCount} setup`; + + return `${arrangementRows.length} total`; +} + +function customFieldDisplayValue(value: unknown) { + if (value === true) return 'Yes'; + if (value === false) return 'No'; + if (value === undefined || value === null) return ''; + + return value.toString(); +} + +function customFieldValues( + family: CombinedFamilyInfo, + customFields: CustomField[] +) { + const completedFields = family.family?.completedCustomFields ?? []; + + return Object.fromEntries( + customFields.map((field) => { + const matchingField = completedFields.find( + (completedField) => completedField.customFieldName === field.name + ); + + return [field.name, customFieldDisplayValue(matchingField?.value)]; + }) + ); +} + +function hasIntakeStatus( + family: CombinedFamilyInfo, + openReferralByFamily: ReturnType +) { + const familyId = family.family?.id; + const openCase = family.partneringFamilyInfo?.openV1Case; + + if (!openCase) return !!familyId && openReferralByFamily.has(familyId); + + return (openCase.arrangements ?? []).length === 0; +} + +function matchesArrangementsFilter( + family: CombinedFamilyInfo, + arrangementsFilter: ArrangementsFilter, + openReferralByFamily: ReturnType +) { + const openCase = family.partneringFamilyInfo?.openV1Case; + const arrangements = openCase?.arrangements ?? []; + + if (arrangementsFilter === 'All') return true; + if (arrangementsFilter === 'Intake') { + return hasIntakeStatus(family, openReferralByFamily); + } + if (arrangementsFilter === 'Active') { + return arrangements.some( + (arrangement) => arrangement.phase === ArrangementPhase.Started + ); + } + if (arrangementsFilter === 'Setup') { + return matchingArrangements(family.partneringFamilyInfo!, 'Setup').length > 0; + } + + return arrangements.some((arrangement) => + isSetupOrActiveArrangementPhase(arrangement.phase) + ); +} + +export function useClientsBrowserViewModel({ + arrangementsFilter = 'All', + assignmentFilters = {}, + canViewFunctionAssignments = false, + countyFilter = [], + filterText = '', + selectedCustomFieldValuesByField = {}, + sortMode = 'lastNameAsc', +}: UseClientsBrowserViewModelParameters = {}) { + const partneringFamiliesLoadable = useLoadable(partneringFamiliesData); + const partneringFamilies = useMemo( + () => partneringFamiliesLoadable ?? [], + [partneringFamiliesLoadable] + ); + const visibleReferralsLoadable = useLoadable(visibleReferralsQuery); + const visibleReferrals = useMemo( + () => + visibleReferralsLoadable?.map((referralInfo) => referralInfo.referral) ?? + [], + [visibleReferralsLoadable] + ); + const policy = useLoadable(policyData); + const personAndFamilyLookup = usePersonAndFamilyLookup(); + const isLoading = + partneringFamiliesLoadable === null || + visibleReferralsLoadable === null || + policy === null; + const normalizedFilterText = useMemo(() => simplify(filterText), [filterText]); + + const openReferralByFamily = useMemo( + () => openReferralByFamilyId(visibleReferrals), + [visibleReferrals] + ); + const referralCustomFields = useMemo( + () => policy?.referralPolicy?.customFields ?? [], + [policy?.referralPolicy?.customFields] + ); + const clientFamilyCustomFields = useMemo( + () => policy?.customFamilyFields ?? [], + [policy?.customFamilyFields] + ); + const assignmentFilterAssignments = useMemo( + () => + partneringFamilies.flatMap( + (family) => + family.partneringFamilyInfo?.openV1Case + ?.assignedIndividualVolunteers ?? [] + ), + [partneringFamilies] + ); + const assignmentFilterOptions = useMemo( + () => + assignmentRolesForColumns( + policy?.referralPolicy?.functionAssignmentPolicies?.map( + (assignmentPolicy) => assignmentPolicy.assignmentRole + ) ?? [], + assignmentFilterAssignments + ), + [ + assignmentFilterAssignments, + policy?.referralPolicy?.functionAssignmentPolicies, + ] + ); + const arrangementRowsByFamily = useMemo(() => { + return Object.fromEntries( + partneringFamilies + .map((family) => { + const familyId = family.family?.id; + const openCase = family.partneringFamilyInfo?.openV1Case; + + if (!familyId || !openCase) return undefined; + + return [ + familyId, + arrangementSummaryRows( + matchingArrangements( + family.partneringFamilyInfo!, + arrangementsFilter + ).map((entry) => entry.arrangement) + ), + ] as const; + }) + .filter( + (entry): entry is readonly [string, ClientArrangementSummaryItemV2[]] => + Boolean(entry) + ) + ); + }, [arrangementsFilter, partneringFamilies]); + const presentationRows = useMemo(() => { + return partneringFamilies.flatMap((family) => { + const familyId = family.family?.id; + + if (!familyId) return []; + + const arrangementRows = arrangementRowsByFamily[familyId] ?? []; + const contact = primaryContact(family); + const assignments = + family.partneringFamilyInfo?.openV1Case + ?.assignedIndividualVolunteers ?? []; + const row: ClientBrowserPresentationRowV2 = { + id: familyId, + familyId, + family: familyNameString(family), + status: currentCaseStatusText(family), + county: getFamilyCounty(family) ?? '', + arrangementRows, + arrangements: arrangementSummary(arrangementRows), + customFieldValues: customFieldValues(family, clientFamilyCustomFields), + assignmentRoleValues: Object.fromEntries( + assignmentFilterOptions.map((assignmentRole) => [ + assignmentRole, + assignmentNamesForRole( + assignments, + assignmentRole, + (personId) => personAndFamilyLookup(personId).person + ), + ]) + ), + searchText: clientFamilySearchText(family), + sourceFamily: family, + }; + + return [ + { + ...row, + ...(contact ? { primaryContactName: personNameString(contact) } : {}), + ...(contact?.phoneNumbers?.[0]?.number + ? { phoneNumber: contact.phoneNumbers[0].number } + : {}), + }, + ]; + }); + }, [ + arrangementRowsByFamily, + assignmentFilterOptions, + clientFamilyCustomFields, + partneringFamilies, + personAndFamilyLookup, + ]); + const presentationRowByFamilyId = useMemo( + () => new Map(presentationRows.map((row) => [row.familyId, row])), + [presentationRows] + ); + const searchableRows = useMemo(() => { + const filteredPresentationRows = presentationRows + .filter((row) => + matchesCustomFieldFilters({ + item: row.sourceFamily, + customFields: referralCustomFields, + selectedValuesByField: selectedCustomFieldValuesByField, + isBlank: (item, fieldName) => + item.partneringFamilyInfo?.openV1Case?.missingCustomFields?.includes( + fieldName + ) ?? false, + getValue: (item, fieldName) => + item.partneringFamilyInfo?.openV1Case?.completedCustomFields?.find( + (field) => field.customFieldName === fieldName + )?.value, + }) + ) + .filter((row) => { + if (countyFilter.length === 0) return true; + + const county = getFamilyCounty(row.sourceFamily); + return county === null + ? countyFilter.includes(null) + : countyFilter.includes(county); + }) + .filter((row) => { + if (!canViewFunctionAssignments) return true; + + return matchesAssignmentFilters( + row.sourceFamily.partneringFamilyInfo?.openV1Case + ?.assignedIndividualVolunteers ?? [], + assignmentFilters + ); + }) + .filter((row) => + matchesArrangementsFilter( + row.sourceFamily, + arrangementsFilter, + openReferralByFamily + ) + ); + const sortedFamilies = sortPartneringFamilies( + filteredPresentationRows.map((row) => row.sourceFamily), + sortMode, + openReferralByFamily + ); + + return sortedFamilies.flatMap((family) => { + const familyId = family.family?.id; + const row = familyId ? presentationRowByFamilyId.get(familyId) : undefined; + + return row ? [row] : []; + }); + }, [ + arrangementsFilter, + assignmentFilters, + canViewFunctionAssignments, + countyFilter, + openReferralByFamily, + presentationRowByFamilyId, + presentationRows, + referralCustomFields, + selectedCustomFieldValuesByField, + sortMode, + ]); + const rows = useMemo( + () => + searchableRows.filter((row) => + matchesSearchText(row, normalizedFilterText) + ), + [normalizedFilterText, searchableRows] + ); + const counties = useMemo( + () => + Array.from( + new Set( + partneringFamilies + .map(getFamilyCounty) + .filter((county): county is string => Boolean(county)) + ) + ).sort((a, b) => a.localeCompare(b)), + [partneringFamilies] + ); + const activeFamilies = useMemo( + () => + partneringFamilies.filter( + (family) => + family.partneringFamilyInfo && + matchingArrangements(family.partneringFamilyInfo, 'Active').length > 0 + ).length, + [partneringFamilies] + ); + const intakeFamilies = useMemo( + () => + partneringFamilies.filter((family) => + hasIntakeStatus(family, openReferralByFamily) + ).length, + [openReferralByFamily, partneringFamilies] + ); + const setupFamilies = useMemo( + () => + partneringFamilies.filter( + (family) => + family.partneringFamilyInfo && + matchingArrangements(family.partneringFamilyInfo, 'Setup').length > 0 + ).length, + [partneringFamilies] + ); + + return { + rows, + arrangementRowsByFamily, + counties, + isLoading, + totalFamilies: partneringFamilies.length, + activeFamilies, + intakeFamilies, + setupFamilies, + assignmentColumnRoles: canViewFunctionAssignments + ? assignmentFilterOptions + : [], + assignmentFilterAssignments, + assignmentFilterOptions, + customFieldDefinitions: clientFamilyCustomFields, + }; +} diff --git a/src/caretogether-pwa/src/Volunteers/VolunteerApprovalTab/VolunteerApproval.tsx b/src/caretogether-pwa/src/Volunteers/VolunteerApprovalTab/VolunteerApproval.tsx index 9350d4ec..f6806c54 100644 --- a/src/caretogether-pwa/src/Volunteers/VolunteerApprovalTab/VolunteerApproval.tsx +++ b/src/caretogether-pwa/src/Volunteers/VolunteerApprovalTab/VolunteerApproval.tsx @@ -179,7 +179,7 @@ function VolunteerApproval(props: { onOpen: () => void }) { const { selectedValuesByField: customFieldFilters, setSelectedValuesForField: setCustomFieldFilter, - optionsByField: customFieldFilterOptionsByField, + getOptionsForField: getCustomFieldFilterOptionsForField, } = useCustomFieldFilters({ customFields: (policy.customFamilyFields ?? []).concat( policy.volunteerPolicy?.customFields ?? [] @@ -904,7 +904,7 @@ function VolunteerApproval(props: { onOpen: () => void }) { customFields={(policy.customFamilyFields || []).concat( policy.volunteerPolicy?.customFields || [] )} - optionsByField={customFieldFilterOptionsByField} + getOptionsForField={getCustomFieldFilterOptionsForField} selectedValuesByField={customFieldFilters} onFieldChange={changeCustomFieldFilter} onClose={closeCustomFieldFiltersSidePanel} diff --git a/src/caretogether-pwa/src/Volunteers/VolunteerApprovalTab/VolunteerCustomFieldFiltersSidePanel.tsx b/src/caretogether-pwa/src/Volunteers/VolunteerApprovalTab/VolunteerCustomFieldFiltersSidePanel.tsx index 7c7cde73..e991ec11 100644 --- a/src/caretogether-pwa/src/Volunteers/VolunteerApprovalTab/VolunteerCustomFieldFiltersSidePanel.tsx +++ b/src/caretogether-pwa/src/Volunteers/VolunteerApprovalTab/VolunteerCustomFieldFiltersSidePanel.tsx @@ -10,7 +10,7 @@ import { type Props = { customFields: CustomField[]; - optionsByField: Record; + getOptionsForField: (field: CustomField) => CustomFieldFilterOption[]; selectedValuesByField: CustomFieldFilterSelectionsByField; onFieldChange: ( fieldName: string, @@ -21,7 +21,7 @@ type Props = { export function VolunteerCustomFieldFiltersSidePanel({ customFields, - optionsByField, + getOptionsForField, selectedValuesByField, onFieldChange, onClose, @@ -43,7 +43,7 @@ export function VolunteerCustomFieldFiltersSidePanel({