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 && (
+ }
+ sx={{ justifyContent: 'flex-start', minHeight: 40 }}
+ variant="outlined"
+ >
+ More Filters ({activeCustomFieldFilterCount}/{customFieldCount})
+
+ )}
+
+
+ );
+}
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({