diff --git a/graphql.schema.json b/graphql.schema.json index 96b4716c4..f3ab9f88e 100644 --- a/graphql.schema.json +++ b/graphql.schema.json @@ -17990,6 +17990,50 @@ ], "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "EnrollmentRelationshipInput", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "enrollmentId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "relationshipToHoh", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "RelationshipToHoH", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "ENUM", "name": "EnrollmentSortOption", @@ -28017,6 +28061,70 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "JoinHouseholdPayload", + "description": "Autogenerated return type of JoinHousehold.", + "isOneOf": null, + "fields": [ + { + "name": "donorHousehold", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Household", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ValidationError", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "receivingHousehold", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Household", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "SCALAR", "name": "JsonObject", @@ -30465,6 +30573,59 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "joinHousehold", + "description": null, + "args": [ + { + "name": "joiningEnrollmentInputs", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EnrollmentRelationshipInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "receivingHouseholdId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "JoinHouseholdPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "mergeClients", "description": null, @@ -36101,6 +36262,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "canSplitHouseholds", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "canViewDob", "description": null, diff --git a/src/api/operations/access.fragments.graphql b/src/api/operations/access.fragments.graphql index 2cbccc841..5820e7447 100644 --- a/src/api/operations/access.fragments.graphql +++ b/src/api/operations/access.fragments.graphql @@ -85,6 +85,7 @@ fragment ProjectAccessFields on ProjectAccess { canManageIncomingReferrals canManageOutgoingReferrals canManageExternalFormSubmissions + canSplitHouseholds } fragment OrganizationAccessFields on OrganizationAccess { diff --git a/src/api/operations/enrollment.fragments.graphql b/src/api/operations/enrollment.fragments.graphql index 60006c0c8..4f9d694b6 100644 --- a/src/api/operations/enrollment.fragments.graphql +++ b/src/api/operations/enrollment.fragments.graphql @@ -122,6 +122,7 @@ fragment AllEnrollmentDetails on Enrollment { hasUnits access { id + canSplitHouseholds canManageIncomingReferrals # determine whether to link to source referral } staffAssignmentsEnabled @@ -269,3 +270,10 @@ fragment EnrollmentRangeFields on Enrollment { exitDate inProgress } + +fragment EnrollmentWithHouseholdFields on Enrollment { + ...EnrollmentFields + household { + ...HouseholdFields + } +} diff --git a/src/api/operations/enrollment.queries.graphql b/src/api/operations/enrollment.queries.graphql index d13817050..149c6537f 100644 --- a/src/api/operations/enrollment.queries.graphql +++ b/src/api/operations/enrollment.queries.graphql @@ -15,14 +15,7 @@ query GetEnrollmentDetails($id: ID!) { query GetEnrollmentWithHousehold($id: ID!) { enrollment(id: $id) { - ...EnrollmentFields - household { - id - shortId - householdClients { - ...HouseholdClientFields - } - } + ...EnrollmentWithHouseholdFields } } diff --git a/src/api/operations/household.operations.graphql b/src/api/operations/household.operations.graphql new file mode 100644 index 000000000..391166b01 --- /dev/null +++ b/src/api/operations/household.operations.graphql @@ -0,0 +1,16 @@ +mutation JoinHousehold( + $receivingHouseholdId: ID! + $joiningEnrollmentInputs: [EnrollmentRelationshipInput!]! +) { + joinHousehold( + receivingHouseholdId: $receivingHouseholdId + joiningEnrollmentInputs: $joiningEnrollmentInputs + ) { + receivingHousehold { + ...HouseholdFields + } + donorHousehold { + ...HouseholdFields + } + } +} diff --git a/src/components/elements/CommonDialog.tsx b/src/components/elements/CommonDialog.tsx index 8480202eb..165cce72c 100644 --- a/src/components/elements/CommonDialog.tsx +++ b/src/components/elements/CommonDialog.tsx @@ -4,11 +4,11 @@ import { useCallback } from 'react'; import SentryErrorBoundary from '@/modules/errors/components/SentryErrorBoundary'; -interface Props extends DialogProps { +export interface CommonDialogProps extends DialogProps { enableBackdropClick?: boolean; } -const CommonDialog: React.FC<Props> = ({ +const CommonDialog: React.FC<CommonDialogProps> = ({ children, onClose, enableBackdropClick = false, diff --git a/src/components/elements/DateWithRelativeTooltip.tsx b/src/components/elements/DateWithRelativeTooltip.tsx index c63bf7130..fdfa7a43d 100644 --- a/src/components/elements/DateWithRelativeTooltip.tsx +++ b/src/components/elements/DateWithRelativeTooltip.tsx @@ -54,7 +54,10 @@ const DateWithRelativeTooltip = ({ > {formattedDate} {/* Include the tooltip text as visually hidden for accessibility */} - <Box sx={visuallyHidden}>, {formattedDateRelative}</Box> + {/* Add position: fixed to address visual bug when visuallyHidden is used inside dialog */} + <Box sx={{ ...visuallyHidden, position: 'fixed' }}> + , {formattedDateRelative} + </Box> </Typography> </Tooltip> ); diff --git a/src/components/elements/StepDialog.stories.tsx b/src/components/elements/StepDialog.stories.tsx new file mode 100644 index 000000000..f2e71edab --- /dev/null +++ b/src/components/elements/StepDialog.stories.tsx @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react'; +import StepDialog from './StepDialog'; + +export default { + component: StepDialog, +} as Meta<typeof StepDialog>; + +type Story = StoryObj<typeof StepDialog>; + +export const Default: Story = { + args: { + open: true, + title: 'Stepper Dialog Demo', + stepDefinitions: [ + { + key: 'one', + title: 'One', + content: 'hello first tab', + }, + { + key: 'two', + title: 'Two', + content: 'this is the second tab', + }, + ], + }, +}; diff --git a/src/components/elements/StepDialog.tsx b/src/components/elements/StepDialog.tsx new file mode 100644 index 000000000..a756464a3 --- /dev/null +++ b/src/components/elements/StepDialog.tsx @@ -0,0 +1,191 @@ +import { LoadingButton } from '@mui/lab'; +import { + Box, + Button, + ButtonProps, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material'; +import { ArrowLeftIcon, ArrowRightIcon } from '@mui/x-date-pickers'; +import React, { ReactNode, useMemo, useState } from 'react'; +import ButtonTooltipContainer from '@/components/elements/ButtonTooltipContainer'; +import CommonDialog, { + CommonDialogProps, +} from '@/components/elements/CommonDialog'; +import Loading from '@/components/elements/Loading'; + +export type StepDefinition = { + key: string; + title?: string; + content: ReactNode; + + // if onProceed is not provided, the default action is to just go to the next step + onProceed?: () => Promise<any> | VoidFunction; // after promise resolves, go to the next step if there is one + proceedButtonText?: string; + proceedButtonType?: 'submit' | 'button'; + proceedLoading?: boolean; + ButtonProps?: ButtonProps; + + // `disableProceed` can be used to disable the proceed action + disableProceed?: boolean; + disabledReason?: string; +}; + +interface Props extends Omit<CommonDialogProps, 'onSubmit' | 'onClose'> { + title: string; + stepDefinitions: StepDefinition[]; + onClose: VoidFunction; + loading?: boolean; +} + +/** + * StepDialog is a Dialog that guides the user through several steps (StepDefinitions). + * Each step may render a title, content, and buttons to cancel, go back, or proceed. + * + * If a step has an onSubmit action specified, the proceed button triggers that action, + * waits for the promise to resolve, and then renders the next step. + * Note that this means a step with an `onSubmit` action does not necessarily need + * to be the last step in the workflow. The last step can, for example, be a + * successful response/wayfinding step. + * + * If no onSubmit is specified, the proceed button just renders the next step + * (it is purely navigational and does not submit anything). + * + * StepDialog only manages the state of which step is currently selected. + * If Steps have inputs that update some shared pieces of state, that state + * should live in StepDialog's parent (or wherever the StepDefinitions are defined). + */ +const StepDialog = ({ + title, + stepDefinitions, + onClose, + loading, + ...rest +}: Props) => { + const [currentStepKey, setCurrentStepKey] = useState(stepDefinitions[0].key); + + const currentStepIndex = useMemo( + () => stepDefinitions.findIndex((t) => t.key === currentStepKey), + [currentStepKey, stepDefinitions] + ); + + const [prevStep, thisStep, nextStep] = useMemo(() => { + return [ + stepDefinitions[currentStepIndex - 1], + stepDefinitions[currentStepIndex], + stepDefinitions[currentStepIndex + 1], + ]; + }, [stepDefinitions, currentStepIndex]); + + const { + title: stepTitle, + content, + onProceed, + proceedButtonText, + proceedButtonType = 'button', + proceedLoading, + disableProceed, + disabledReason, + ButtonProps, + } = thisStep; + + const nextButton = useMemo(() => { + if (!onProceed && !nextStep) return undefined; + + const handleClick = async () => { + if (onProceed) await onProceed(); + if (nextStep) setCurrentStepKey(nextStep.key); + }; + + const defaultButtonText = nextStep ? nextStep.title || 'Next' : 'Finish'; + const buttonText = proceedButtonText || defaultButtonText; + + return ( + <LoadingButton + onClick={handleClick} + type={proceedButtonType} + loading={proceedLoading} + sx={{ minWidth: '120px' }} + disabled={disableProceed} + endIcon={nextStep ? <ArrowRightIcon /> : undefined} + {...ButtonProps} + > + {buttonText} + </LoadingButton> + ); + }, [ + ButtonProps, + disableProceed, + nextStep, + onProceed, + proceedButtonText, + proceedButtonType, + proceedLoading, + ]); + + return ( + <CommonDialog onClose={onClose} {...rest}> + <DialogTitle>{title}</DialogTitle> + <DialogContent> + {loading ? ( + <Loading /> + ) : ( + <Stack sx={{ mt: 2 }} gap={2}> + {stepTitle && ( + <Box> + <Typography variant='overline'> + Step {currentStepIndex + 1} + </Typography> + <Typography variant='h3'>{stepTitle}</Typography> + </Box> + )} + {content} + </Stack> + )} + </DialogContent> + {!!nextButton && ( + <DialogActions> + <Stack + direction='row' + justifyContent={'space-between'} + sx={{ width: '100%' }} + > + <Box flexGrow={1}> + {prevStep && ( + <Button + startIcon={<ArrowLeftIcon />} + color='grayscale' + onClick={() => setCurrentStepKey(prevStep.key)} + > + Back + </Button> + )} + </Box> + + <Stack gap={3} direction='row'> + <Button onClick={onClose} color='grayscale'> + Cancel + </Button> + + {disableProceed && disabledReason ? ( + <ButtonTooltipContainer + title={disabledReason} + placement='top-start' + > + {nextButton} + </ButtonTooltipContainer> + ) : ( + nextButton + )} + </Stack> + </Stack> + </DialogActions> + )} + </CommonDialog> + ); +}; + +export default StepDialog; diff --git a/src/components/elements/table/GenericTable.tsx b/src/components/elements/table/GenericTable.tsx index 084f2985d..9590d105c 100644 --- a/src/components/elements/table/GenericTable.tsx +++ b/src/components/elements/table/GenericTable.tsx @@ -19,16 +19,8 @@ import { } from '@mui/material'; import { SystemStyleObject } from '@mui/system'; import { visuallyHidden } from '@mui/utils'; -import { compact, get, includes, isNil, without } from 'lodash-es'; -import { - ComponentType, - ReactNode, - SyntheticEvent, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; +import { compact, get, includes, isNil } from 'lodash-es'; +import { ComponentType, ReactNode, SyntheticEvent, useMemo } from 'react'; import { To } from 'react-router-dom'; import Loading from '../Loading'; @@ -44,6 +36,7 @@ import { } from './types'; import { CommonMenuItem } from '@/components/elements/CommonMenuButton'; import RouterLink from '@/components/elements/RouterLink'; +import { useTableSelection } from '@/components/elements/table/hooks/useTableSelection'; import TableRowActions from '@/components/elements/table/TableRowActions'; import { LocationState } from '@/routes/routeUtil'; @@ -76,8 +69,9 @@ export interface Props<T> { renderVerticalHeaderCell?: RenderFunction<T>; rowSx?: (row: T) => SxProps<Theme>; selectable?: 'row' | 'checkbox'; // selectable by clicking row or by clicking checkbox + selected?: readonly string[]; // selection can optionally be controlled by the parent isRowSelectable?: (row: T) => boolean; - onChangeSelectedRowIds?: (ids: readonly string[]) => void; + onChangeSelectedRowIds?: (ids: readonly string[]) => void; // Used BOTH by parents that control selection, AND those with uncontrolled selection to know what rows are currently selected EnhancedTableToolbarProps?: Omit< EnhancedTableToolbarProps<T>, 'selectedIds' | 'rows' @@ -283,6 +277,7 @@ const GenericTable = <T extends { id: string }>({ rowSx, selectable, isRowSelectable, + selected: selectedProp, onChangeSelectedRowIds, EnhancedTableToolbarProps, filterToolbar, @@ -298,41 +293,14 @@ const GenericTable = <T extends { id: string }>({ ); const hasHeaders = columns.find((c) => !!c.header); - // initially undefined so we can early return and avoid state flicker - const [selected, setSelected] = useState<string[]>(); - - const selectableRowIds = useMemo(() => { - if (!selectable) return []; - if (!isRowSelectable) return rows.map((r) => r.id); - return rows.filter(isRowSelectable).map((r) => r.id); - }, [rows, selectable, isRowSelectable]); - - const handleSelectAllClick = useCallback( - (event: React.ChangeEvent<HTMLInputElement>) => { - if (event.target.checked) { - setSelected(selectableRowIds); - } else { - setSelected([]); - } - }, - [selectableRowIds] - ); - - const handleSelectRow = useCallback( - (row: T) => - setSelected((old) => { - if (!old) return undefined; - return old.includes(row.id) ? without(old, row.id) : [...old, row.id]; - }), - [] - ); - - // Clear selection when data changes - useEffect(() => setSelected([]), [rows]); - - useEffect(() => { - if (selected) onChangeSelectedRowIds?.(selected); - }, [selected, onChangeSelectedRowIds]); + const { selected, selectableRowIds, handleSelectAllClick, handleSelectRow } = + useTableSelection({ + selectable: !!selectable, + isRowSelectable, + rows, + selectedControlled: selectedProp, + onChangeSelected: onChangeSelectedRowIds, + }); // avoid state flicker due to state reset if (!selected) return <Loading />; @@ -389,7 +357,8 @@ const GenericTable = <T extends { id: string }>({ } checked={ selectableRowIds.length > 0 && - selected.length === selectableRowIds.length + // >= instead of === accommodates rows that are selected but disabled + selected.length >= selectableRowIds.length } disabled={selectableRowIds.length === 0} onChange={handleSelectAllClick} @@ -410,6 +379,7 @@ const GenericTable = <T extends { id: string }>({ textAlign: def.textAlign, width: def.width, }} + {...def.headerCellProps} > {renderHeaderCellContents(def)} </HeaderCell> @@ -479,6 +449,7 @@ const GenericTable = <T extends { id: string }>({ sx={{ ...verticalCellSx(1), width: '350px' }} key={getColumnKey(def)} role='rowheader' + {...def.headerCellProps} > {renderHeaderCellContents(def)} </HeaderCell> @@ -590,10 +561,15 @@ const GenericTable = <T extends { id: string }>({ const isLinked = rowLink && !dontLink; + const cellProps = + typeof tableCellProps === 'function' + ? tableCellProps(row) + : tableCellProps; + return ( <TableCell key={getColumnKey(def) || index} - {...tableCellProps} + {...cellProps} sx={{ ...getStickyCellStyles({ sticky, @@ -605,7 +581,7 @@ const GenericTable = <T extends { id: string }>({ ...(isLinked ? { p: 0 } : undefined), textAlign, whiteSpace: 'initial', - ...tableCellProps?.sx, + ...cellProps?.sx, }} role={sticky === 'left' ? 'rowheader' : undefined} // Reuse `dontLink` to prevent click propagation if the row has a click handler, but the cell has a more specific click target diff --git a/src/components/elements/table/hooks/useTableSelection.tsx b/src/components/elements/table/hooks/useTableSelection.tsx new file mode 100644 index 000000000..624092066 --- /dev/null +++ b/src/components/elements/table/hooks/useTableSelection.tsx @@ -0,0 +1,72 @@ +import { without } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export function useTableSelection<T extends { id: string }>({ + selectable = false, + isRowSelectable, + rows, + selectedControlled, + onChangeSelected, +}: { + selectable?: boolean; + isRowSelectable?: (row: T) => boolean; + rows: T[]; + selectedControlled?: readonly string[]; + onChangeSelected?: (ids: readonly string[]) => void; +}) { + // Initially set selected to undefined, so we can early return and avoid state flicker + const [selectedState, setSelectedState] = useState<string[]>(); + + // Table row selection can be either controlled or uncontrolled: + // - controlled: `selectedCtrl` and `onChangeSelectedCtrl` props passed from parent + // - uncontrolled: managed internally in `selectedState` + const isSelectControlled = selectedControlled !== undefined; + + const selected = useMemo( + () => (isSelectControlled ? selectedControlled : selectedState || []), + [isSelectControlled, selectedState, selectedControlled] + ); + + const selectableRowIds = useMemo(() => { + if (!selectable) return []; + if (!isRowSelectable) return rows.map((r) => r.id); + return rows.filter(isRowSelectable).map((r) => r.id); + }, [rows, selectable, isRowSelectable]); + + const handleSelectAllClick = useCallback( + (event: React.ChangeEvent<HTMLInputElement>) => { + if (event.target.checked) { + // select all + if (!isSelectControlled) setSelectedState(selectableRowIds); + onChangeSelected?.(selectableRowIds); + } else { + // deselect all + if (!isSelectControlled) setSelectedState([]); + onChangeSelected?.([]); + } + }, + [isSelectControlled, onChangeSelected, selectableRowIds] + ); + + const handleSelectRow = useCallback( + (row: T) => { + const newValue = selected.includes(row.id) + ? without(selected, row.id) + : [...selected, row.id]; + + if (!isSelectControlled) setSelectedState(newValue); + onChangeSelected?.(newValue); + }, + [isSelectControlled, onChangeSelected, selected] + ); + + // Clear selection when data changes + useEffect(() => setSelectedState([]), [rows]); + + return { + selected, + selectableRowIds, + handleSelectAllClick, + handleSelectRow, + }; +} diff --git a/src/components/elements/table/types.tsx b/src/components/elements/table/types.tsx index dbe0578c5..9418064d5 100644 --- a/src/components/elements/table/types.tsx +++ b/src/components/elements/table/types.tsx @@ -13,7 +13,8 @@ type BaseColumnDef<T> = { // whether to NOT link this cell even when the whole row is linked using rowLinkTo. Use if there are clickable elements in the cell. dontLink?: boolean; textAlign?: 'center' | 'end' | 'justify' | 'left' | 'right' | 'start'; - tableCellProps?: TableCellProps; + tableCellProps?: TableCellProps | ((row: T) => TableCellProps); + headerCellProps?: TableCellProps; optional?: boolean; defaultHidden?: boolean; sticky?: 'left' | 'right'; diff --git a/src/modules/clientAlerts/hooks/useClientAlerts.tsx b/src/modules/clientAlerts/hooks/useClientAlerts.tsx index cd03d9ec3..93492de9d 100644 --- a/src/modules/clientAlerts/hooks/useClientAlerts.tsx +++ b/src/modules/clientAlerts/hooks/useClientAlerts.tsx @@ -4,6 +4,7 @@ import { useHasRootPermissions } from '@/modules/permissions/useHasPermissionsHo import { ClientAccess, ClientWithAlertFieldsFragment, + GetHouseholdClientAlertsQuery, useGetHouseholdClientAlertsQuery, } from '@/types/gqlTypes'; @@ -15,6 +16,7 @@ enum AlertPriority { interface ClientAlertParams { householdId?: string; + household?: GetHouseholdClientAlertsQuery['household']; client?: Omit<ClientWithAlertFieldsFragment, 'access'> & { access?: Partial<ClientAccess>; }; @@ -26,14 +28,19 @@ export default function useClientAlerts(params: ClientAlertParams) { const [canViewClientAlerts] = useHasRootPermissions(['canViewClientAlerts']); const { - data: { household } = {}, + data: { household: queriedHousehold } = {}, loading, error, } = useGetHouseholdClientAlertsQuery({ variables: { id: params.householdId || '' }, - skip: !params.householdId || !canViewClientAlerts, + skip: !params.householdId || !!params.household || !canViewClientAlerts, }); + const household = useMemo( + () => queriedHousehold || params.household, + [params.household, queriedHousehold] + ); + const canViewAlertsForAnyHouseholdMembers = useMemo(() => { if (!canViewClientAlerts) return false; if (!household) return true; diff --git a/src/modules/errors/components/ErrorAlert.tsx b/src/modules/errors/components/ErrorAlert.tsx index af38a8326..c352896de 100644 --- a/src/modules/errors/components/ErrorAlert.tsx +++ b/src/modules/errors/components/ErrorAlert.tsx @@ -2,6 +2,7 @@ import { Alert, AlertProps, AlertTitle } from '@mui/material'; import { find, reject } from 'lodash-es'; import { + ErrorFilterFn, ErrorRenderFn, FIXABLE_ERROR_HEADING, UNKNOWN_VALIDATION_ERROR_HEADING, @@ -16,14 +17,18 @@ const ErrorAlert = ({ fixable = false, AlertProps = {}, renderError, + errorFilter, }: { errors: ValidationError[]; fixable?: boolean; AlertProps?: AlertProps; renderError?: ErrorRenderFn; + errorFilter?: ErrorFilterFn; // Allows the caller to filter out error messages (e.g. to selectively render outside the ErrorAlert, as in Join Households) }) => { - const filtered = reject(errors, ['severity', 'warning']); - if (filtered.length === 0) return null; + const filteredErrors = errorFilter ? errors.filter(errorFilter) : errors; + + const errorsWithoutWarnings = reject(filteredErrors, ['severity', 'warning']); + if (errorsWithoutWarnings.length === 0) return null; let title = fixable ? FIXABLE_ERROR_HEADING @@ -41,7 +46,7 @@ const ErrorAlert = ({ {...AlertProps} > <AlertTitle>{title}</AlertTitle> - <ValidationErrorList errors={errors} renderError={renderError} /> + <ValidationErrorList errors={filteredErrors} renderError={renderError} /> </Alert> ); }; diff --git a/src/modules/errors/util.ts b/src/modules/errors/util.ts index 3bbb000eb..dd8fc05e3 100644 --- a/src/modules/errors/util.ts +++ b/src/modules/errors/util.ts @@ -41,6 +41,9 @@ export type ErrorRenderFn = ( args?: { attributeOnly?: boolean } ) => React.ReactNode; +export type ErrorFilterFn = (error: ValidationError) => boolean; +export type OnChangeErrorsFn = (errors: ErrorState) => void; + export const hasAnyValue = (state: ErrorState): boolean => !!state.apolloError || state.errors.length > 0 || state.warnings.length > 0; diff --git a/src/modules/form/components/DynamicForm.tsx b/src/modules/form/components/DynamicForm.tsx index ea459643c..4566b71a2 100644 --- a/src/modules/form/components/DynamicForm.tsx +++ b/src/modules/form/components/DynamicForm.tsx @@ -22,7 +22,7 @@ import ApolloErrorAlert from '@/modules/errors/components/ApolloErrorAlert'; import ErrorAlert from '@/modules/errors/components/ErrorAlert'; import { ValidationDialogProps } from '@/modules/errors/components/ValidationDialog'; import { useValidationDialog } from '@/modules/errors/hooks/useValidationDialog'; -import { ErrorState, hasErrors } from '@/modules/errors/util'; +import { ErrorFilterFn, ErrorState, hasErrors } from '@/modules/errors/util'; import { formAutoCompleteOff } from '@/modules/form/util/formUtil'; import { FormDefinitionJson } from '@/types/gqlTypes'; @@ -79,6 +79,7 @@ export interface DynamicFormProps localConstants?: LocalConstants; errorRef?: RefObject<HTMLDivElement>; variant?: 'standard' | 'without_top_level_cards'; + errorFilter?: ErrorFilterFn; } export interface DynamicFormRef { SaveIfDirty: VoidFunction; @@ -110,6 +111,7 @@ const DynamicForm = forwardRef( errorRef, onDirty, variant = 'standard', + errorFilter, }: DynamicFormProps, ref: Ref<DynamicFormRef> ) => { @@ -242,7 +244,11 @@ const DynamicForm = forwardRef( <Grid item> <Stack gap={2}> <ApolloErrorAlert error={errorState.apolloError} /> - <ErrorAlert errors={errorState.errors} fixable /> + <ErrorAlert + errors={errorState.errors} + fixable + errorFilter={errorFilter} + /> </Stack> </Grid> )} diff --git a/src/modules/form/hooks/useFormDialog.tsx b/src/modules/form/hooks/useFormDialog.tsx index 16ef3e02c..38141050d 100644 --- a/src/modules/form/hooks/useFormDialog.tsx +++ b/src/modules/form/hooks/useFormDialog.tsx @@ -5,7 +5,14 @@ import { DialogTitle, Grid, } from '@mui/material'; -import { ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import DynamicForm, { DynamicFormProps, @@ -23,7 +30,11 @@ import useFormDefinition from './useFormDefinition'; import CommonDialog from '@/components/elements/CommonDialog'; import Loading from '@/components/elements/Loading'; -import { emptyErrorState } from '@/modules/errors/util'; +import { + emptyErrorState, + ErrorFilterFn, + OnChangeErrorsFn, +} from '@/modules/errors/util'; import { FormDefinitionFieldsFragment, @@ -48,6 +59,8 @@ interface Args<T> extends Omit<DynamicFormHandlerArgs<T>, 'formDefinition'> { onClose?: VoidFunction; localDefinition?: FormDefinitionFieldsFragment; projectId?: string; // Project context for fetching form definition + onChangeErrors?: OnChangeErrorsFn; + errorFilter?: ErrorFilterFn; } export function useFormDialog<T extends SubmitFormAllowedTypes>({ onCompleted, @@ -59,6 +72,8 @@ export function useFormDialog<T extends SubmitFormAllowedTypes>({ localDefinition, pickListArgs, projectId, + onChangeErrors, + errorFilter, }: Args<T>) { const errorRef = useRef<HTMLDivElement>(null); const [dialogOpen, setDialogOpen] = useState<boolean>(false); @@ -116,6 +131,10 @@ export function useFormDialog<T extends SubmitFormAllowedTypes>({ const { initialValues, errors, onSubmit, submitLoading, setErrors } = useDynamicFormHandlersForRecord(hookArgs); + useEffect(() => { + onChangeErrors?.(errors); + }, [errors, onChangeErrors]); + const closeDialog = useCallback(() => { setDialogOpen(false); setErrors(emptyErrorState); @@ -185,6 +204,7 @@ export function useFormDialog<T extends SubmitFormAllowedTypes>({ }} variant={variant} hideSubmit + errorFilter={errorFilter} {...props} errorRef={errorRef} /> @@ -210,6 +230,7 @@ export function useFormDialog<T extends SubmitFormAllowedTypes>({ closeDialog, definitionLoading, dialogOpen, + errorFilter, errors, formDefinition, formRole, diff --git a/src/modules/hmis/hmisUtil.ts b/src/modules/hmis/hmisUtil.ts index 07b4f9c9c..e4ef98106 100644 --- a/src/modules/hmis/hmisUtil.ts +++ b/src/modules/hmis/hmisUtil.ts @@ -660,3 +660,32 @@ export const raceEthnicityDisplayString = (race?: Race[]) => { return race.map((r) => HmisEnums.Race[r]).join(', '); }; + +export function stringifyArray(arr: string[]) { + if (arr.length === 1) return arr[0]; + const firsts = arr.slice(0, arr.length - 1); + const last = arr[arr.length - 1]; + const finalJoiner = arr.length === 2 ? ' and ' : ', and '; // oxford comma + return firsts.join(', ') + finalJoiner + last; +} + +export const stringifyHousehold = ( + householdClients: HouseholdClientFieldsFragment[] +) => { + return stringifyArray( + householdClients.map((hc) => clientBriefName(hc.client)) + ); +}; + +export const findHohOrRep = ( + householdClients: HouseholdClientFieldsFragment[] +) => { + // It's invalid, but possible in case of bad data, to have no HoH. If so, return the first household member + const sorted = sortHouseholdMembers(householdClients); + return ( + sorted.find( + ({ relationshipToHoH }) => + relationshipToHoH === RelationshipToHoH.SelfHeadOfHousehold + ) || sorted[0] + ); +}; diff --git a/src/modules/household/components/CreateHouseholdPage.tsx b/src/modules/household/components/CreateHouseholdPage.tsx index 25ca8aa97..da16e4ba1 100644 --- a/src/modules/household/components/CreateHouseholdPage.tsx +++ b/src/modules/household/components/CreateHouseholdPage.tsx @@ -27,7 +27,7 @@ const CreateHouseholdPage = () => { <> <PageTitle title={`Enroll Household in ${project.projectName}`} /> <ManageHousehold - projectId={project.id} + project={project} renderBackButton={(householdId) => ( <BackButton onClick={() => { diff --git a/src/modules/household/components/EditHouseholdMemberTable.tsx b/src/modules/household/components/EditHouseholdMemberTable.tsx index 71d16627b..af589c05d 100644 --- a/src/modules/household/components/EditHouseholdMemberTable.tsx +++ b/src/modules/household/components/EditHouseholdMemberTable.tsx @@ -238,6 +238,9 @@ const EditHouseholdMemberTable = ({ })} loading={loading} loadingVariant='linear' + tableProps={{ + 'aria-label': 'Manage Household', + }} /> </SsnDobShowContextProvider> {renderValidationDialog({ diff --git a/src/modules/household/components/EditHouseholdPage.tsx b/src/modules/household/components/EditHouseholdPage.tsx index 5ddc867f4..b5c41ef7e 100644 --- a/src/modules/household/components/EditHouseholdPage.tsx +++ b/src/modules/household/components/EditHouseholdPage.tsx @@ -23,7 +23,7 @@ const EditHousehold = () => { <ManageHousehold householdId={enrollment.householdId} - projectId={enrollment.project.id} + project={enrollment.project} BackButton={ <BackButtonLink to={generateSafePath(EnrollmentDashboardRoutes.HOUSEHOLD, { diff --git a/src/modules/household/components/ManageHousehold.tsx b/src/modules/household/components/ManageHousehold.tsx index 4cd54c0d6..5cfa45ed6 100644 --- a/src/modules/household/components/ManageHousehold.tsx +++ b/src/modules/household/components/ManageHousehold.tsx @@ -31,21 +31,30 @@ import { ClientSortOption, EnrollmentFieldsFragment, ExternalIdentifierType, + ProjectAccessFieldsFragment, + ProjectAllFieldsFragment, SearchClientsDocument, SearchClientsQuery, SearchClientsQueryVariables, } from '@/types/gqlTypes'; +export type ManageHouseholdProject = Pick< + ProjectAllFieldsFragment, + 'id' | 'projectName' +> & { + access: Pick<ProjectAccessFieldsFragment, 'canSplitHouseholds'>; +}; + interface Props { householdId?: string; - projectId: string; + project: ManageHouseholdProject; BackButton?: ReactNode; renderBackButton?: (householdId?: string) => ReactNode; } const ManageHousehold = ({ householdId: initialHouseholdId, - projectId, + project, BackButton, renderBackButton, }: Props) => { @@ -64,7 +73,7 @@ const ManageHousehold = ({ householdId, } = useAddToHouseholdColumns({ householdId: initialHouseholdId, - projectId, + project, }); // Fetch members to show in "previously associated" table @@ -150,7 +159,7 @@ const ManageHousehold = ({ currentDashboardEnrollmentId={currentDashboardEnrollmentId} refetchHousehold={refetchHousehold} loading={loading} - projectId={projectId} + projectId={project.id} /> </TitleCard> )} @@ -185,7 +194,7 @@ const ManageHousehold = ({ {hasSearched && ( <RootPermissionsFilter permissions='canEditClients'> <AddNewClientButton - projectId={projectId} + projectId={project.id} householdId={householdId} onCompleted={handleNewClientAdded} /> @@ -214,6 +223,9 @@ const ManageHousehold = ({ rowSecondaryActionConfigs={(row) => [ getViewClientMenuItem(row), ]} + tableProps={{ + 'aria-label': 'Search results', + }} /> </Paper> </SsnDobShowContextProvider> diff --git a/src/modules/household/components/elements/AddToHouseholdButton.tsx b/src/modules/household/components/elements/AddToHouseholdButton.tsx index 3419f6b56..14540bb91 100644 --- a/src/modules/household/components/elements/AddToHouseholdButton.tsx +++ b/src/modules/household/components/elements/AddToHouseholdButton.tsx @@ -1,38 +1,47 @@ -import { Button } from '@mui/material'; -import { useEffect, useMemo, useState } from 'react'; +import { Button, Stack } from '@mui/material'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import ButtonTooltipContainer from '@/components/elements/ButtonTooltipContainer'; import usePrevious from '@/hooks/usePrevious'; import ClientAlertStack from '@/modules/clientAlerts/components/ClientAlertStack'; import useClientAlerts from '@/modules/clientAlerts/hooks/useClientAlerts'; +import { ErrorState } from '@/modules/errors/util'; import { useFormDialog } from '@/modules/form/hooks/useFormDialog'; import { clientBriefName } from '@/modules/hmis/hmisUtil'; +import ConflictingEnrollmentAlert from '@/modules/household/components/householdActions/ConflictingEnrollmentAlert'; +import JoinHouseholdDialog from '@/modules/household/components/householdActions/JoinHouseholdDialog'; +import { ManageHouseholdProject } from '@/modules/household/components/ManageHousehold'; import { useProjectCocsCountFromCache } from '@/modules/projects/hooks/useProjectCocsCountFromCache'; import { ClientSearchResultFieldsFragment, + HouseholdFieldsFragment, RecordFormRole, SubmittedEnrollmentResultFieldsFragment, + ValidationError, } from '@/types/gqlTypes'; interface Props { client: ClientSearchResultFieldsFragment; isMember: boolean; householdId?: string; // if omitted, a new household will be created - projectId: string; + project: ManageHouseholdProject; onSuccess: (householdId: string) => void; + household?: HouseholdFieldsFragment; + disabled?: boolean; } const AddToHouseholdButton = ({ client, isMember, - householdId, onSuccess, - projectId, + project, + household, + disabled, }: Props) => { const prevIsMember = usePrevious(isMember); const [added, setAdded] = useState(isMember); - const cocCount = useProjectCocsCountFromCache(projectId); + const cocCount = useProjectCocsCountFromCache(project.id); useEffect(() => { // If client was previously added but has since been removed @@ -41,11 +50,15 @@ const AddToHouseholdButton = ({ } }, [prevIsMember, isMember, setAdded]); - let text = householdId ? 'Add to Household' : 'Enroll Client'; + let text = household ? 'Add to Household' : 'Enroll Client'; const color: 'secondary' | 'error' = 'secondary'; if (added) text = 'Added'; const clientId = client.id; + const [conflictingEnrollmentId, setConflictingEnrollmentId] = useState< + string | undefined + >(); + const memoedArgs = useMemo( () => ({ formRole: RecordFormRole.Enrollment, @@ -53,25 +66,69 @@ const AddToHouseholdButton = ({ setAdded(true); onSuccess(data.householdId); }, - inputVariables: { projectId, clientId }, - pickListArgs: { projectId, householdId }, - localConstants: { householdId, projectCocCount: cocCount }, + inputVariables: { projectId: project.id, clientId }, + pickListArgs: { projectId: project.id, householdId: household?.id }, + localConstants: { householdId: household?.id, projectCocCount: cocCount }, + errorFilter: (error: ValidationError) => { + // If there's an error about a conflicting enrollment, and we're adding to an existing household, + // then we will show the ConflictingEnrollmentAlert (so filter it out from the ErrorAlert display) + return !( + household?.id && error.data?.hasOwnProperty('conflictingEnrollmentId') + ); + }, + onChangeErrors: (errors: ErrorState) => { + const error = errors.errors.find((e) => + e.data?.hasOwnProperty('conflictingEnrollmentId') + ); + if (error) { + setConflictingEnrollmentId(error.data.conflictingEnrollmentId); + } + }, }), - [projectId, clientId, householdId, cocCount, onSuccess] + [project.id, clientId, cocCount, onSuccess, household?.id] ); - const { openFormDialog, renderFormDialog } = + const { openFormDialog, renderFormDialog, closeDialog } = useFormDialog<SubmittedEnrollmentResultFieldsFragment>(memoedArgs); + const [joinHouseholdDialogOpen, setJoinHouseholdDialogOpen] = useState(false); + const { clientAlerts } = useClientAlerts({ client: client }); + const clientAlertsComponent = useMemo( + () => + clientAlerts.length > 0 ? ( + <ClientAlertStack clientAlerts={clientAlerts} /> + ) : undefined, + [clientAlerts] + ); + + const onCloseJoinHousehold = useCallback(() => { + setJoinHouseholdDialogOpen(false); + setConflictingEnrollmentId(undefined); + }, []); + + const onJoinHouseholdSuccess = useCallback( + (joinedHousehold: HouseholdFieldsFragment) => { + // This only updates the initiating client's button + if ( + joinedHousehold.householdClients.find( + (hc) => hc.client.id === client.id + ) + ) { + setAdded(true); + } + }, + [client.id] + ); + return ( <> <ButtonTooltipContainer title={added ? 'Client is already a member of this household' : null} > <Button - disabled={added} + disabled={added || disabled} color={color} fullWidth size='small' @@ -84,10 +141,38 @@ const AddToHouseholdButton = ({ {renderFormDialog({ title: <>Enroll {clientBriefName(client)}</>, submitButtonText: `Enroll`, - preFormComponent: clientAlerts.length > 0 && ( - <ClientAlertStack clientAlerts={clientAlerts} /> - ), + // TODO(#7234) This wrapper is necessary around the preFormComponent Stack, + // otherwise the form dialog renders some awkward extra spacing. + // It could be fixed more systematically with some updates to renderFormDialog's internals. + preFormComponent: + !!clientAlertsComponent || (household && conflictingEnrollmentId) ? ( + <Stack gap={2}> + {clientAlertsComponent} + {household && conflictingEnrollmentId && ( + <ConflictingEnrollmentAlert + project={project} + joiningClient={client} + receivingHousehold={household} + conflictingEnrollmentId={conflictingEnrollmentId} + onClickJoinEnrollment={() => { + closeDialog(); + setJoinHouseholdDialogOpen(true); + }} + /> + )} + </Stack> + ) : undefined, })} + {household && conflictingEnrollmentId && ( + <JoinHouseholdDialog + open={joinHouseholdDialogOpen} + initiatorEnrollmentId={conflictingEnrollmentId} + onClose={onCloseJoinHousehold} + onSuccess={onJoinHouseholdSuccess} + receivingHousehold={household} + project={project} + /> + )} </> ); }; diff --git a/src/modules/household/components/householdActions/AddRelationshipsStep.tsx b/src/modules/household/components/householdActions/AddRelationshipsStep.tsx new file mode 100644 index 000000000..a01b0406d --- /dev/null +++ b/src/modules/household/components/householdActions/AddRelationshipsStep.tsx @@ -0,0 +1,125 @@ +import { Chip, Paper, Stack } from '@mui/material'; +import React, { ReactNode, useId } from 'react'; +import GenericTable from '@/components/elements/table/GenericTable'; +import ClientName from '@/modules/client/components/ClientName'; +import RequiredLabel from '@/modules/form/components/RequiredLabel'; +import HmisEnum from '@/modules/hmis/components/HmisEnum'; +import RelationshipToHohSelect from '@/modules/household/components/elements/RelationshipToHohSelect'; +import { WITH_ENROLLMENT_COLUMNS } from '@/modules/projects/components/tables/ProjectClientEnrollmentsTable'; +import { + asClient, + CLIENT_COLUMNS, +} from '@/modules/search/components/ClientSearch'; +import { HmisEnums } from '@/types/gqlEnums'; +import { + HouseholdClientFieldsFragment, + RelationshipToHoH, +} from '@/types/gqlTypes'; + +interface Props { + existingClients: HouseholdClientFieldsFragment[]; + newClients: HouseholdClientFieldsFragment[]; + relationships: Record<string, RelationshipToHoH | null>; + updateRelationship: ( + enrollmentId: string, + relationship: RelationshipToHoH | null + ) => void; + showNewIndicator?: boolean; + children?: ReactNode; +} + +const AddRelationshipsStep = ({ + existingClients, + newClients, + relationships, + updateRelationship, + showNewIndicator, + children, +}: Props) => { + const relationshipHeaderId = useId(); + + return ( + <Stack gap={2}> + {children} + <Paper> + <GenericTable<HouseholdClientFieldsFragment> + rows={[...existingClients, ...newClients]} + columns={[ + { + ...CLIENT_COLUMNS.name, + render: (client) => ( + <Stack direction='row' gap={1}> + <ClientName client={asClient(client)} /> + {showNewIndicator && newClients.includes(client) && ( + <Chip label='New' size='small' variant='outlined' /> + )} + </Stack> + ), + sticky: 'left', + tableCellProps: (client) => { + return { + // enables using aria-labelledby on inputs in this row + id: `client-${client.id}`, + }; + }, + }, + CLIENT_COLUMNS.age, + { + header: ( + <RequiredLabel + text='Relationship' + TypographyProps={{ + fontWeight: 'bold', + }} + required={true} + /> + ), + headerCellProps: { + id: relationshipHeaderId, + }, + key: 'relationship', + render: (hc: HouseholdClientFieldsFragment) => { + if (newClients.includes(hc)) { + return ( + <RelationshipToHohSelect + value={relationships[hc.enrollment.id] || null} + onChange={(_event, selected) => { + updateRelationship( + hc.enrollment.id, + selected?.value || null + ); + }} + textInputProps={{ + warnIfEmptyTreatment: !relationships[hc.enrollment.id], + inputProps: { + 'aria-labelledby': `client-${hc.id} ${relationshipHeaderId}`, + }, + }} + /> + ); + } else { + return ( + <HmisEnum + key={hc.id} + value={hc.relationshipToHoH} + enumMap={HmisEnums.RelationshipToHoH} + whiteSpace='nowrap' + /> + ); + } + }, + tableCellProps: { sx: { py: 0 } }, + }, + WITH_ENROLLMENT_COLUMNS.entryDate, + WITH_ENROLLMENT_COLUMNS.enrollmentStatus, + ]} + tableProps={{ + 'aria-label': 'Add Relationships', + }} + /> + </Paper> + </Stack> + ); +}; + +export default AddRelationshipsStep; diff --git a/src/modules/household/components/householdActions/ConflictingEnrollmentAlert.tsx b/src/modules/household/components/householdActions/ConflictingEnrollmentAlert.tsx new file mode 100644 index 000000000..a226c1ba1 --- /dev/null +++ b/src/modules/household/components/householdActions/ConflictingEnrollmentAlert.tsx @@ -0,0 +1,106 @@ +import { + Alert, + AlertTitle, + Box, + Button, + List, + ListItem, + Stack, +} from '@mui/material'; +import { useMemo } from 'react'; +import { generatePath } from 'react-router-dom'; +import ButtonLink from '@/components/elements/ButtonLink'; +import ButtonTooltipContainer from '@/components/elements/ButtonTooltipContainer'; +import { clientBriefName } from '@/modules/hmis/hmisUtil'; +import { ManageHouseholdProject } from '@/modules/household/components/ManageHousehold'; +import { EnrollmentDashboardRoutes } from '@/routes/routes'; +import { + ClientSearchResultFieldsFragment, + HouseholdFieldsFragment, + RelationshipToHoH, +} from '@/types/gqlTypes'; + +interface Props { + project: ManageHouseholdProject; + joiningClient: ClientSearchResultFieldsFragment; + conflictingEnrollmentId: string; + receivingHousehold: HouseholdFieldsFragment; + onClickJoinEnrollment: VoidFunction; +} + +const ConflictingEnrollmentAlert = ({ + project, + joiningClient, + receivingHousehold, + conflictingEnrollmentId, + onClickJoinEnrollment, +}: Props) => { + const canSplitHouseholds = project.access.canSplitHouseholds; + + const joiningClientName = clientBriefName(joiningClient); + + const hohName = useMemo(() => { + const hoh = receivingHousehold.householdClients.find( + (hc) => hc.relationshipToHoH === RelationshipToHoH.SelfHeadOfHousehold + ); + if (!hoh) return ''; + return clientBriefName(hoh.client); + }, [receivingHousehold.householdClients]); + + return ( + <Alert severity='warning'> + <AlertTitle>Conflicting Enrollment</AlertTitle> + <Stack direction='column' gap={1}> + <Box> + {joiningClientName} has a conflicting enrollment in this project that + overlaps with the selected entry date.{' '} + {canSplitHouseholds && ( + <> + You have two options: + <List sx={{ listStyle: 'decimal', pl: 4 }} component='ol'> + <ListItem sx={{ display: 'list-item' }}> + The conflicting enrollment can be joined with {hohName}’s + household. + </ListItem> + <ListItem sx={{ display: 'list-item' }}> + To retain the conflicting enrollment, update the entry and/or + exit dates to not overlap with this new entry date before + attempting again to enroll. + </ListItem> + </List> + </> + )} + </Box> + <Stack direction='row' gap={2}> + {canSplitHouseholds ? ( + <Button color='warning' onClick={onClickJoinEnrollment}> + Join Enrollments + </Button> + ) : ( + <ButtonTooltipContainer + title={ + "You don't have permission to join enrollments. Request permission from your HMIS administrator." + } + > + <Button disabled color='warning'> + Join Enrollments + </Button> + </ButtonTooltipContainer> + )} + <ButtonLink + to={generatePath(EnrollmentDashboardRoutes.ENROLLMENT_OVERVIEW, { + clientId: joiningClient.id, + enrollmentId: conflictingEnrollmentId, + })} + variant='contained' + color='grayscale' + > + View Conflicting Enrollment + </ButtonLink> + </Stack> + </Stack> + </Alert> + ); +}; + +export default ConflictingEnrollmentAlert; diff --git a/src/modules/household/components/householdActions/JoinHouseholdDialog.tsx b/src/modules/household/components/householdActions/JoinHouseholdDialog.tsx new file mode 100644 index 000000000..4f7e36fe9 --- /dev/null +++ b/src/modules/household/components/householdActions/JoinHouseholdDialog.tsx @@ -0,0 +1,245 @@ +import { MergeTypeRounded } from '@mui/icons-material'; +import { Typography } from '@mui/material'; +import React, { useCallback, useMemo, useState } from 'react'; +import Loading from '@/components/elements/Loading'; +import StepDialog, { StepDefinition } from '@/components/elements/StepDialog'; +import { + clientBriefName, + findHohOrRep, + sortHouseholdMembers, + stringifyHousehold, +} from '@/modules/hmis/hmisUtil'; +import AddRelationshipsStep from '@/modules/household/components/householdActions/AddRelationshipsStep'; +import JoinHouseholdReview from '@/modules/household/components/householdActions/JoinHouseholdReview'; +import JoinHouseholdSelectClients from '@/modules/household/components/householdActions/JoinHouseholdSelectClients'; +import SuccessWayfindingStep from '@/modules/household/components/householdActions/SuccessWayfindingStep'; +import { usePerformJoinHousehold } from '@/modules/household/hooks/usePerformJoinHousehold'; +import { + HouseholdClientFieldsFragment, + HouseholdFieldsFragment, + ProjectAllFieldsFragment, + RelationshipToHoH, + useGetEnrollmentWithHouseholdQuery, +} from '@/types/gqlTypes'; + +interface Props { + open: boolean; + initiatorEnrollmentId: string; + receivingHousehold: HouseholdFieldsFragment; + project: Pick<ProjectAllFieldsFragment, 'id' | 'projectName'>; + onClose: VoidFunction; + onSuccess?: (joinedHousehold: HouseholdFieldsFragment) => void; +} + +const JoinHouseholdDialog = ({ + open, + onClose, + onSuccess, + initiatorEnrollmentId, + receivingHousehold, + project, +}: Props) => { + // `joiningClients` and `relationships` are shared across steps, so they are hoisted up and managed here. + // These pieces of state are updated live as soon as a user makes a selection within a step. + // Except for the `onSubmit` (joinHousehold mutation), the buttons to navigate between steps + // are purely navigational and don't submit anything. + const [joiningClients, setJoiningClients] = useState< + HouseholdClientFieldsFragment[] + >([]); + const [relationships, setRelationships] = useState< + Record<string, RelationshipToHoH | null> + >({}); + + // Fetch the initiator's enrollment by ID since we need the full EnrollmentWithHousehold + const { + data: { enrollment: initiatorEnrollment } = {}, + loading: fetchLoading, + error: fetchError, + } = useGetEnrollmentWithHouseholdQuery({ + variables: { id: initiatorEnrollmentId }, + onCompleted: (data) => { + const household = data?.enrollment?.household; + + const initiator = household?.householdClients.find( + (hc) => hc.enrollment.id === initiatorEnrollmentId + ); + + if (!household || !initiator) { + throw new Error(`Enrollment ${initiatorEnrollmentId} not found`); + } + + // Set the joining clients list to the initiator client, plus, if they are the HoH, all their household members. + if ( + initiator.relationshipToHoH === RelationshipToHoH.SelfHeadOfHousehold + ) { + setJoiningClients(sortHouseholdMembers(household.householdClients)); + } else { + setJoiningClients([initiator]); + } + }, + }); + + const donorHousehold = useMemo( + () => initiatorEnrollment?.household, + [initiatorEnrollment] + ); + + const receivingHoh = useMemo( + () => findHohOrRep(receivingHousehold.householdClients), + [receivingHousehold.householdClients] + ); + + const receivingHohName = useMemo(() => { + return clientBriefName(receivingHoh.client); + }, [receivingHoh]); + + const missingRelationshipsCount = useMemo( + () => + joiningClients.filter((jc) => !relationships[jc.enrollment.id]).length, + [joiningClients, relationships] + ); + + const missingRelationshipsProps = useMemo(() => { + return { + disableProceed: missingRelationshipsCount > 0, + disabledReason: + missingRelationshipsCount > 0 + ? `Required fields missing (${missingRelationshipsCount})` + : undefined, + }; + }, [missingRelationshipsCount]); + + const { + performJoinHousehold, + loading: joinLoading, + error: joinError, + remainingHousehold, + } = usePerformJoinHousehold({ onSuccess }); + + const onSubmit = useCallback( + () => + performJoinHousehold({ + receivingHouseholdId: receivingHousehold.id, + joiningClients, + relationships, + }), + [joiningClients, performJoinHousehold, receivingHousehold.id, relationships] + ); + + const stepDefinitions: StepDefinition[] = useMemo( + () => [ + { + key: 'select', + title: 'Select Clients', + content: ( + <> + {donorHousehold && ( + <JoinHouseholdSelectClients + donorHousehold={donorHousehold} + selectedClients={joiningClients} + setSelectedClients={setJoiningClients} + receivingHohName={receivingHohName} + /> + )} + </> + ), + disableProceed: joiningClients.length === 0, + disabledReason: 'Select a client', + }, + { + key: 'relationships', + title: 'Add Relationships', + content: ( + <AddRelationshipsStep + existingClients={sortHouseholdMembers( + receivingHousehold.householdClients + )} + newClients={joiningClients} + showNewIndicator={true} + relationships={relationships} + updateRelationship={(enrollmentId, relationship) => { + setRelationships((prev) => { + return { + ...prev, + [enrollmentId]: relationship, + }; + }); + }} + > + <Typography variant='body1'> + Update joining clients’ relationships{' '} + {receivingHohName && <>to {receivingHohName}</>} + </Typography> + </AddRelationshipsStep> + ), + ...missingRelationshipsProps, + }, + { + key: 'review', + title: 'Review Join', + content: !donorHousehold ? ( + <Loading /> + ) : ( + <JoinHouseholdReview + joiningClients={joiningClients} + receivingHousehold={receivingHousehold} + donorHousehold={donorHousehold} + relationships={relationships} + /> + ), + ...missingRelationshipsProps, + onProceed: onSubmit, + proceedLoading: joinLoading, + proceedButtonText: 'Join Enrollments', + ButtonProps: { + endIcon: <MergeTypeRounded />, + }, + disabled: joinLoading || missingRelationshipsCount > 0, + }, + { + key: 'success', + content: ( + <SuccessWayfindingStep + title={'Successful Join'} + description={`${stringifyHousehold(joiningClients)} ${joiningClients.length > 1 ? 'have' : 'has'} been successfully joined to ${receivingHohName}’s Enrollment at ${project.projectName}`} + primaryClientName={receivingHohName} + secondary={findHohOrRep(remainingHousehold?.householdClients || [])} + project={project} + onClose={onClose} + /> + ), + }, + ], + [ + donorHousehold, + joinLoading, + joiningClients, + missingRelationshipsCount, + missingRelationshipsProps, + onClose, + onSubmit, + project, + receivingHohName, + receivingHousehold, + relationships, + remainingHousehold?.householdClients, + ] + ); + + if (fetchError) throw fetchError; + if (joinError) throw joinError; + + return ( + <StepDialog + title={'Join Enrollments'} + loading={fetchLoading} + open={open} + fullWidth + maxWidth='md' + onClose={onClose} + stepDefinitions={stepDefinitions} + /> + ); +}; + +export default JoinHouseholdDialog; diff --git a/src/modules/household/components/householdActions/JoinHouseholdReview.tsx b/src/modules/household/components/householdActions/JoinHouseholdReview.tsx new file mode 100644 index 000000000..b7588492d --- /dev/null +++ b/src/modules/household/components/householdActions/JoinHouseholdReview.tsx @@ -0,0 +1,87 @@ +import { Typography } from '@mui/material'; +import { uniqBy } from 'lodash-es'; +import { useMemo } from 'react'; +import { + sortHouseholdMembers, + stringifyHousehold, +} from '@/modules/hmis/hmisUtil'; +import ReviewHouseholdsStep from '@/modules/household/components/householdActions/ReviewHouseholdsStep'; +import { + HouseholdClientFieldsFragment, + HouseholdFieldsFragment, + RelationshipToHoH, +} from '@/types/gqlTypes'; + +interface Props { + joiningClients: HouseholdClientFieldsFragment[]; + donorHousehold: HouseholdFieldsFragment; + receivingHousehold: HouseholdFieldsFragment; + relationships: Record<string, RelationshipToHoH | null>; +} + +const JoinHouseholdReview = ({ + joiningClients, + donorHousehold, + receivingHousehold, + relationships, +}: Props) => { + const joinedHouseholdClients = useMemo(() => { + return uniqBy( + [ + // use uniqBy to avoid console warning after the household has been updated in the cache + ...sortHouseholdMembers(receivingHousehold.householdClients), + ...joiningClients.map((jc) => { + return { + ...jc, + relationshipToHoH: + relationships[jc.enrollment.id] || + RelationshipToHoH.DataNotCollected, + }; + }), + ], + 'id' + ); + }, [joiningClients, receivingHousehold.householdClients, relationships]); + + const remainingHouseholdClients = useMemo(() => { + return sortHouseholdMembers( + donorHousehold.householdClients.filter( + (hc) => !joiningClients.includes(hc) + ) + ); + }, [donorHousehold.householdClients, joiningClients]); + + const joining = useMemo( + () => stringifyHousehold(joiningClients), + [joiningClients] + ); + + return ( + <ReviewHouseholdsStep + reviewableHouseholds={[ + { + title: 'Joining Household', + description: `The household that ${joining} will join`, + members: joinedHouseholdClients, + }, + ...(remainingHouseholdClients + ? [ + { + title: 'Remaining Household', + description: `The household that ${joining} will leave`, + members: remainingHouseholdClients, + }, + ] + : []), + ]} + > + <Typography variant='body1'> + Check that the joined{' '} + {remainingHouseholdClients.length > 0 && 'and remaining '}household + members and details are correct + </Typography> + </ReviewHouseholdsStep> + ); +}; + +export default JoinHouseholdReview; diff --git a/src/modules/household/components/householdActions/JoinHouseholdSelectClients.tsx b/src/modules/household/components/householdActions/JoinHouseholdSelectClients.tsx new file mode 100644 index 000000000..73fffde49 --- /dev/null +++ b/src/modules/household/components/householdActions/JoinHouseholdSelectClients.tsx @@ -0,0 +1,117 @@ +import { InfoOutlined } from '@mui/icons-material'; +import { Alert, AlertTitle, Typography } from '@mui/material'; + +import { Dispatch, SetStateAction, useCallback, useMemo } from 'react'; +import { generatePath } from 'react-router-dom'; +import ButtonLink from '@/components/elements/ButtonLink'; +import ClientAlertStack from '@/modules/clientAlerts/components/ClientAlertStack'; +import useClientAlerts from '@/modules/clientAlerts/hooks/useClientAlerts'; +import { clientBriefName, sortHouseholdMembers } from '@/modules/hmis/hmisUtil'; +import SelectClientsStep from '@/modules/household/components/householdActions/SelectClientsStep'; +import { EnrollmentDashboardRoutes } from '@/routes/routes'; +import { + HouseholdClientFieldsFragment, + HouseholdFieldsFragment, + RelationshipToHoH, +} from '@/types/gqlTypes'; + +interface Props { + donorHousehold: HouseholdFieldsFragment; + selectedClients: HouseholdClientFieldsFragment[]; + setSelectedClients: Dispatch<SetStateAction<HouseholdClientFieldsFragment[]>>; + receivingHohName?: string; +} + +const JoinHouseholdSelectClients = ({ + donorHousehold, + selectedClients, + setSelectedClients: setSelectedClientsProp, + receivingHohName, +}: Props) => { + const donorHoh = useMemo( + () => + donorHousehold.householdClients.find( + (hc) => hc.relationshipToHoH === RelationshipToHoH.SelfHeadOfHousehold + ), + [donorHousehold.householdClients] + ); + + const isHohSelected = useMemo(() => { + return donorHoh && selectedClients.includes(donorHoh); + }, [donorHoh, selectedClients]); + + // This is a controlled component, so the selected clients state is stored a level above, + // but here hijack the selection logic in order to select *all* members when HoH is selected. + // (Can't leave behind a household without a HoH) + const setSelectedClients = useCallback( + (clients: HouseholdClientFieldsFragment[]) => { + // If the HoH from the donor household is selected, all other hh members must be selected. + if (donorHoh && clients.includes(donorHoh)) { + setSelectedClientsProp( + sortHouseholdMembers(donorHousehold.householdClients) + ); + } else { + setSelectedClientsProp(clients); + } + }, + [donorHoh, setSelectedClientsProp, donorHousehold.householdClients] + ); + + const isRowSelectable = useCallback( + (row: HouseholdClientFieldsFragment) => { + if (donorHoh && isHohSelected) { + // Visually disable de-selecting other clients if the HoH is selected + // (This is also functionally disabled by the logic in setSelectedClientIds above) + return row.id === donorHoh.id; + } + + return true; + }, + [donorHoh, isHohSelected] + ); + + const { clientAlerts } = useClientAlerts({ + household: donorHousehold, + showClientName: true, + }); + + return ( + <SelectClientsStep + donorHousehold={donorHousehold} + selectedClients={selectedClients} + setSelectedClients={setSelectedClients} + isRowSelectable={isRowSelectable} + > + <Typography variant='body1'> + Select which clients you would like to join{' '} + {receivingHohName ? `${receivingHohName}’s` : 'this'} household. + </Typography> + {<ClientAlertStack clientAlerts={clientAlerts} />} + {donorHoh && isHohSelected && donorHousehold.householdSize > 1 && ( + <Alert + color='info' + icon={<InfoOutlined />} + action={ + <ButtonLink + variant='contained' + color='grayscale' + to={generatePath(EnrollmentDashboardRoutes.EDIT_HOUSEHOLD, { + clientId: donorHoh.client.id, + enrollmentId: donorHoh.enrollment.id, + })} + > + Edit Household + </ButtonLink> + } + > + <AlertTitle>Head of Household Selected</AlertTitle> + If the current Head of Household is selected, all members must also + join. Edit {clientBriefName(donorHoh.client)}’s household and select a + different Head of Household if this is not your intention. + </Alert> + )} + </SelectClientsStep> + ); +}; + +export default JoinHouseholdSelectClients; diff --git a/src/modules/household/components/householdActions/ReviewHouseholdsStep.tsx b/src/modules/household/components/householdActions/ReviewHouseholdsStep.tsx new file mode 100644 index 000000000..1515cae78 --- /dev/null +++ b/src/modules/household/components/householdActions/ReviewHouseholdsStep.tsx @@ -0,0 +1,43 @@ +import { Paper, Stack, Typography } from '@mui/material'; +import { Fragment, ReactNode } from 'react'; +import GenericTable from '@/components/elements/table/GenericTable'; +import { MANAGE_HOUSEHOLD_COLUMNS } from '@/modules/household/components/householdActions/SelectClientsStep'; +import { HouseholdClientFieldsFragment } from '@/types/gqlTypes'; + +type ReviewableHousehold = { + title: string; + description: string; + members: HouseholdClientFieldsFragment[]; +}; + +interface Props { + reviewableHouseholds: ReviewableHousehold[]; + children?: ReactNode; +} + +const ReviewHouseholdsStep = ({ reviewableHouseholds, children }: Props) => { + return ( + <Stack gap={2}> + {children} + {reviewableHouseholds.map((household) => { + if (household.members.length === 0) return; + + return ( + <Fragment key={household.title}> + <Typography variant='h4'>{household.title}</Typography> + <Typography variant='body2'>{household.description}</Typography> + <Paper> + <GenericTable<HouseholdClientFieldsFragment> + rows={household.members} + columns={MANAGE_HOUSEHOLD_COLUMNS} + tableProps={{ 'aria-label': household.title }} + /> + </Paper> + </Fragment> + ); + })} + </Stack> + ); +}; + +export default ReviewHouseholdsStep; diff --git a/src/modules/household/components/householdActions/SelectClientsStep.tsx b/src/modules/household/components/householdActions/SelectClientsStep.tsx new file mode 100644 index 000000000..3f602c211 --- /dev/null +++ b/src/modules/household/components/householdActions/SelectClientsStep.tsx @@ -0,0 +1,83 @@ +import { Paper, Stack } from '@mui/material'; + +import { ReactNode, useCallback, useMemo } from 'react'; +import GenericTable from '@/components/elements/table/GenericTable'; +import { ColumnDef } from '@/components/elements/table/types'; +import { sortHouseholdMembers } from '@/modules/hmis/hmisUtil'; +import { WITH_ENROLLMENT_COLUMNS } from '@/modules/projects/components/tables/ProjectClientEnrollmentsTable'; +import { ENROLLMENT_RELATIONSHIP_COL } from '@/modules/projects/components/tables/ProjectHouseholdsTable'; +import { CLIENT_COLUMNS } from '@/modules/search/components/ClientSearch'; +import { + HouseholdClientFieldsFragment, + HouseholdFieldsFragment, +} from '@/types/gqlTypes'; + +export const MANAGE_HOUSEHOLD_COLUMNS: ColumnDef<HouseholdClientFieldsFragment>[] = + [ + { ...CLIENT_COLUMNS.name, sticky: 'left' }, + CLIENT_COLUMNS.age, + ENROLLMENT_RELATIONSHIP_COL, + WITH_ENROLLMENT_COLUMNS.entryDate, + WITH_ENROLLMENT_COLUMNS.enrollmentStatus, + ]; + +interface Props { + donorHousehold: HouseholdFieldsFragment; + selectedClients: HouseholdClientFieldsFragment[]; + setSelectedClients: (clients: HouseholdClientFieldsFragment[]) => void; + children?: ReactNode; + isRowSelectable?: (client: HouseholdClientFieldsFragment) => boolean; +} + +const SelectClientsStep = ({ + donorHousehold, + selectedClients, + setSelectedClients, + isRowSelectable, + children, +}: Props) => { + const donorHouseholdMembers = useMemo( + () => sortHouseholdMembers(donorHousehold.householdClients), + [donorHousehold.householdClients] + ); + + // Selection is controlled, so the selection state is stored in the parent. + // Here, translate the list of selected HouseholdClients from the parent to a list of row IDs for GenericTable. + const selectedClientIds = useMemo( + () => selectedClients.map((hc) => hc.id), + [selectedClients] + ); + + // .. and here, translate back again + const setSelectedClientIds = useCallback( + (clientIds: readonly string[]) => { + setSelectedClients( + sortHouseholdMembers( + donorHousehold.householdClients.filter((hc) => + clientIds.includes(hc.id) + ) + ) + ); + }, + [donorHousehold.householdClients, setSelectedClients] + ); + + return ( + <Stack gap={2}> + {children} + <Paper> + <GenericTable<HouseholdClientFieldsFragment> + rows={donorHouseholdMembers} + columns={MANAGE_HOUSEHOLD_COLUMNS} + selectable={'checkbox'} + selected={selectedClientIds} + onChangeSelectedRowIds={setSelectedClientIds} + isRowSelectable={isRowSelectable} + tableProps={{ 'aria-label': 'Select Clients' }} + /> + </Paper> + </Stack> + ); +}; + +export default SelectClientsStep; diff --git a/src/modules/household/components/householdActions/SuccessWayfindingStep.tsx b/src/modules/household/components/householdActions/SuccessWayfindingStep.tsx new file mode 100644 index 000000000..a4f712a41 --- /dev/null +++ b/src/modules/household/components/householdActions/SuccessWayfindingStep.tsx @@ -0,0 +1,68 @@ +import { Alert, AlertTitle, Button, Stack } from '@mui/material'; +import React from 'react'; +import { generatePath } from 'react-router-dom'; +import ButtonLink from '@/components/elements/ButtonLink'; +import { clientBriefName } from '@/modules/hmis/hmisUtil'; +import { + EnrollmentDashboardRoutes, + ProjectDashboardRoutes, +} from '@/routes/routes'; +import { + HouseholdClientFieldsFragment, + ProjectAllFieldsFragment, +} from '@/types/gqlTypes'; + +interface Props { + title: string; + description: string; + primaryClientName: string; + secondary?: HouseholdClientFieldsFragment; + project: Pick<ProjectAllFieldsFragment, 'id' | 'projectName'>; + onClose: VoidFunction; +} + +const SuccessWayfindingStep = ({ + title, + description, + primaryClientName, + secondary, + project, + onClose, +}: Props) => { + const secondaryName = secondary + ? clientBriefName(secondary.client) + : undefined; + + return ( + <Stack gap={2}> + <Alert color='success'> + <AlertTitle>{title}</AlertTitle> + {description} + </Alert> + <Button onClick={onClose} variant='contained'> + Return to {primaryClientName}’s Enrollment + </Button> + {secondary && ( + <ButtonLink + to={generatePath(EnrollmentDashboardRoutes.ENROLLMENT_OVERVIEW, { + clientId: secondary.client.id, + enrollmentId: secondary.enrollment.id, + })} + variant='outlined' + > + View {secondaryName}’s Enrollment + </ButtonLink> + )} + <ButtonLink + to={generatePath(ProjectDashboardRoutes.PROJECT_ENROLLMENTS, { + projectId: project.id, + })} + variant='outlined' + > + View Enrollments at {project.projectName} + </ButtonLink> + </Stack> + ); +}; + +export default SuccessWayfindingStep; diff --git a/src/modules/household/hooks/useAddToHouseholdColumns.tsx b/src/modules/household/hooks/useAddToHouseholdColumns.tsx index 5a413d9be..9472eb3e9 100644 --- a/src/modules/household/hooks/useAddToHouseholdColumns.tsx +++ b/src/modules/household/hooks/useAddToHouseholdColumns.tsx @@ -4,6 +4,7 @@ import AddToHouseholdButton from '../components/elements/AddToHouseholdButton'; import { isRecentHouseholdMember, RecentHouseholdMember } from '../types'; import { ColumnDef } from '@/components/elements/table/types'; +import { ManageHouseholdProject } from '@/modules/household/components/ManageHousehold'; import { ClientSearchResultFieldsFragment, useGetHouseholdLazyQuery, @@ -11,12 +12,12 @@ import { interface Args { householdId?: string; - projectId: string; + project: ManageHouseholdProject; } export default function useAddToHouseholdColumns({ householdId: initialHouseholdId, - projectId, + project, }: Args) { const [householdId, setHouseholdId] = useState(initialHouseholdId); const [getHousehold, { data, loading, error }] = useGetHouseholdLazyQuery({ @@ -76,16 +77,25 @@ export default function useAddToHouseholdColumns({ return ( <AddToHouseholdButton client={client} - householdId={householdId} - projectId={projectId} + project={project} isMember={currentMembersMap.has(client.id)} onSuccess={onSuccess} + household={data?.household || undefined} + // Disable button until `household` is fetched + disabled={loading && !!householdId && !data?.household} /> ); }, }, ]; - }, [currentMembersMap, householdId, projectId, onSuccess]); + }, [ + project, + currentMembersMap, + onSuccess, + data?.household, + loading, + householdId, + ]); if (error) throw error; diff --git a/src/modules/household/hooks/usePerformJoinHousehold.tsx b/src/modules/household/hooks/usePerformJoinHousehold.tsx new file mode 100644 index 000000000..6310d60d1 --- /dev/null +++ b/src/modules/household/hooks/usePerformJoinHousehold.tsx @@ -0,0 +1,80 @@ +import { useCallback, useState } from 'react'; +import { clientBriefName } from '@/modules/hmis/hmisUtil'; +import { + HouseholdClientFieldsFragment, + HouseholdFieldsFragment, + RelationshipToHoH, + useJoinHouseholdMutation, +} from '@/types/gqlTypes'; + +export function usePerformJoinHousehold({ + onSuccess, +}: { + onSuccess?: (joinedHousehold: HouseholdFieldsFragment) => void; +}) { + const [joinedHousehold, setJoinedHousehold] = useState< + HouseholdFieldsFragment | undefined + >(undefined); + + const [remainingHousehold, setRemainingHousehold] = useState< + HouseholdFieldsFragment | undefined + >(undefined); + + const [joinHousehold, { loading, error }] = useJoinHouseholdMutation({ + onCompleted: (data) => { + if (data.joinHousehold) { + setJoinedHousehold(data.joinHousehold.receivingHousehold); + setRemainingHousehold(data.joinHousehold.donorHousehold || undefined); + onSuccess?.(data.joinHousehold.receivingHousehold); + } + }, + }); + + const performJoinHousehold = useCallback( + ({ + receivingHouseholdId, + joiningClients, + relationships, + }: { + receivingHouseholdId: string; + joiningClients: HouseholdClientFieldsFragment[]; + relationships: Record<string, RelationshipToHoH | null>; + }) => { + const joiningEnrollmentInputs = joiningClients.map((hc) => { + const relationship = relationships[hc.enrollment.id]; + + if (!relationship) { + // don't really need to aggregate failures here; expect the caller to guard against this by disabling the submit button + throw new Error( + `Select relationship for ${clientBriefName(hc.client)}` + ); + } + + return { + enrollmentId: hc.enrollment.id, + relationshipToHoh: relationship, + }; + }); + + if (joiningEnrollmentInputs.length === 0) { + throw new Error('Select at least one client to join'); + } + + return joinHousehold({ + variables: { + receivingHouseholdId, + joiningEnrollmentInputs, + }, + }); + }, + [joinHousehold] + ); + + return { + performJoinHousehold, + loading, + error, + joinedHousehold, + remainingHousehold, + }; +} diff --git a/src/modules/projects/components/tables/ProjectClientEnrollmentsTable.tsx b/src/modules/projects/components/tables/ProjectClientEnrollmentsTable.tsx index b661da530..18768339d 100644 --- a/src/modules/projects/components/tables/ProjectClientEnrollmentsTable.tsx +++ b/src/modules/projects/components/tables/ProjectClientEnrollmentsTable.tsx @@ -1,5 +1,5 @@ import { Box, Chip, Tooltip } from '@mui/material'; -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import DateWithRelativeTooltip from '@/components/elements/DateWithRelativeTooltip'; import { getViewClientMenuItem, diff --git a/src/modules/projects/components/tables/ProjectHouseholdsTable.tsx b/src/modules/projects/components/tables/ProjectHouseholdsTable.tsx index e6847c073..5b4ae6a91 100644 --- a/src/modules/projects/components/tables/ProjectHouseholdsTable.tsx +++ b/src/modules/projects/components/tables/ProjectHouseholdsTable.tsx @@ -171,11 +171,12 @@ const ProjectHouseholdsTable: React.FC<Props> = ({ projectId, searchTerm }) => { const columns: ColumnDef<HouseholdFields>[] = useMemo(() => { return [ ...BASE_COLUMNS, - ...(staffAssignmentsEnabled ? [{ ...ASSIGNED_STAFF_COL }] : []), // typescript appeasement + ...(staffAssignmentsEnabled ? [{ ...ASSIGNED_STAFF_COL }] : []), ACTION_COL, ].map(({ render, ...rest }) => ({ ...rest, render: () => null, + tableCellProps: undefined, // typescript appeasement })); }, [staffAssignmentsEnabled]); diff --git a/src/modules/search/components/ClientSearch.tsx b/src/modules/search/components/ClientSearch.tsx index b4270a487..d0fb05889 100644 --- a/src/modules/search/components/ClientSearch.tsx +++ b/src/modules/search/components/ClientSearch.tsx @@ -48,7 +48,7 @@ import { SearchClientsQueryVariables, } from '@/types/gqlTypes'; -function asClient( +export function asClient( record: | ClientSearchResultFieldsFragment | HouseholdClientFieldsFragment diff --git a/src/types/gqlObjects.ts b/src/types/gqlObjects.ts index b9950c79e..d52d55d3b 100644 --- a/src/types/gqlObjects.ts +++ b/src/types/gqlObjects.ts @@ -4892,6 +4892,14 @@ export const HmisObjectSchemas: GqlSchema[] = [ ofType: { kind: 'SCALAR', name: 'Boolean', ofType: null }, }, }, + { + name: 'canSplitHouseholds', + type: { + kind: 'NON_NULL', + name: null, + ofType: { kind: 'SCALAR', name: 'Boolean', ofType: null }, + }, + }, { name: 'canViewDob', type: { @@ -7051,6 +7059,27 @@ export const HmisInputObjectSchemas: GqlInputObjectSchema[] = [ }, ], }, + { + name: 'EnrollmentRelationshipInput', + args: [ + { + name: 'enrollmentId', + type: { + kind: 'NON_NULL', + name: null, + ofType: { kind: 'SCALAR', name: 'ID', ofType: null }, + }, + }, + { + name: 'relationshipToHoh', + type: { + kind: 'NON_NULL', + name: null, + ofType: { kind: 'ENUM', name: 'RelationshipToHoH', ofType: null }, + }, + }, + ], + }, { name: 'EnrollmentsForClientFilterOptions', args: [ diff --git a/src/types/gqlTypes.ts b/src/types/gqlTypes.ts index 74ff58fa9..cf7003a41 100644 --- a/src/types/gqlTypes.ts +++ b/src/types/gqlTypes.ts @@ -2573,6 +2573,11 @@ export enum EnrollmentFilterOptionStatus { Incomplete = 'INCOMPLETE', } +export type EnrollmentRelationshipInput = { + enrollmentId: Scalars['ID']['input']; + relationshipToHoh: RelationshipToHoH; +}; + /** HUD Enrollment Sorting Options */ export enum EnrollmentSortOption { /** Age: Oldest to Youngest */ @@ -3934,6 +3939,14 @@ export enum ItemType { TimeOfDay = 'TIME_OF_DAY', } +/** Autogenerated return type of JoinHousehold. */ +export type JoinHouseholdPayload = { + __typename?: 'JoinHouseholdPayload'; + donorHousehold?: Maybe<Household>; + errors: Array<ValidationError>; + receivingHousehold: Household; +}; + export type KeyValue = { __typename?: 'KeyValue'; key: Scalars['String']['output']; @@ -4194,6 +4207,7 @@ export type Mutation = { deleteService?: Maybe<DeleteServicePayload>; deleteServiceType?: Maybe<DeleteServiceTypePayload>; deleteUnits?: Maybe<DeleteUnitsPayload>; + joinHousehold?: Maybe<JoinHouseholdPayload>; mergeClients?: Maybe<MergeClientsPayload>; publishFormDefinition?: Maybe<PublishFormDefinitionPayload>; refreshExternalSubmissions?: Maybe<RefreshExternalSubmissionsPayload>; @@ -4396,6 +4410,11 @@ export type MutationDeleteUnitsArgs = { input: DeleteUnitsInput; }; +export type MutationJoinHouseholdArgs = { + joiningEnrollmentInputs: Array<EnrollmentRelationshipInput>; + receivingHouseholdId: Scalars['ID']['input']; +}; + export type MutationMergeClientsArgs = { input: MergeClientsInput; }; @@ -5640,6 +5659,7 @@ export type ProjectAccess = { canManageIncomingReferrals: Scalars['Boolean']['output']; canManageOutgoingReferrals: Scalars['Boolean']['output']; canManageUnits: Scalars['Boolean']['output']; + canSplitHouseholds: Scalars['Boolean']['output']; canViewDob: Scalars['Boolean']['output']; canViewEnrollmentDetails: Scalars['Boolean']['output']; canViewFullSsn: Scalars['Boolean']['output']; @@ -8095,6 +8115,7 @@ export type ProjectAccessFieldsFragment = { canManageIncomingReferrals: boolean; canManageOutgoingReferrals: boolean; canManageExternalFormSubmissions: boolean; + canSplitHouseholds: boolean; }; export type OrganizationAccessFieldsFragment = { @@ -21059,6 +21080,7 @@ export type AllEnrollmentDetailsFragment = { access: { __typename?: 'ProjectAccess'; id: string; + canSplitHouseholds: boolean; canManageIncomingReferrals: boolean; }; projectCocs: { __typename?: 'ProjectCocsPaginated'; nodesCount: number }; @@ -21428,6 +21450,131 @@ export type EnrollmentRangeFieldsFragment = { inProgress: boolean; }; +export type EnrollmentWithHouseholdFieldsFragment = { + __typename?: 'Enrollment'; + id: string; + lockVersion: number; + entryDate: string; + exitDate?: string | null; + exitDestination?: Destination | null; + autoExited: boolean; + inProgress: boolean; + relationshipToHoH: RelationshipToHoH; + enrollmentCoc?: string | null; + householdId: string; + householdShortId: string; + householdSize: number; + household: { + __typename?: 'Household'; + id: string; + householdSize: number; + shortId: string; + householdClients: Array<{ + __typename?: 'HouseholdClient'; + id: string; + relationshipToHoH: RelationshipToHoH; + client: { + __typename?: 'Client'; + id: string; + lockVersion: number; + firstName?: string | null; + middleName?: string | null; + lastName?: string | null; + nameSuffix?: string | null; + dob?: string | null; + age?: number | null; + gender: Array<Gender>; + pronouns: Array<string>; + ssn?: string | null; + race: Array<Race>; + veteranStatus: NoYesReasonsForMissingData; + access: { + __typename?: 'ClientAccess'; + id: string; + canEditClient: boolean; + canDeleteClient: boolean; + canViewDob: boolean; + canViewFullSsn: boolean; + canViewPartialSsn: boolean; + canViewClientName: boolean; + canViewClientPhoto: boolean; + canViewClientAlerts: boolean; + canManageClientAlerts: boolean; + canViewEnrollmentDetails: boolean; + canAuditClients: boolean; + canManageScanCards: boolean; + canMergeClients: boolean; + canViewAnyFiles: boolean; + canManageAnyClientFiles: boolean; + canManageOwnClientFiles: boolean; + canUploadClientFiles: boolean; + }; + externalIds: Array<{ + __typename?: 'ExternalIdentifier'; + id: string; + identifier?: string | null; + url?: string | null; + label: string; + type: ExternalIdentifierType; + }>; + alerts: Array<{ + __typename?: 'ClientAlert'; + id: string; + note: string; + expirationDate?: string | null; + createdAt: string; + priority: ClientAlertPriorityLevel; + createdBy?: { + __typename: 'ApplicationUser'; + id: string; + name: string; + firstName?: string | null; + lastName?: string | null; + email: string; + } | null; + }>; + }; + enrollment: { + __typename?: 'Enrollment'; + id: string; + lockVersion: number; + relationshipToHoH: RelationshipToHoH; + autoExited: boolean; + entryDate: string; + exitDate?: string | null; + inProgress: boolean; + currentUnit?: { __typename?: 'Unit'; id: string; name: string } | null; + }; + }>; + }; + project: { + __typename?: 'Project'; + id: string; + projectName: string; + projectType?: ProjectType | null; + }; + client: { + __typename?: 'Client'; + dob?: string | null; + veteranStatus: NoYesReasonsForMissingData; + id: string; + lockVersion: number; + firstName?: string | null; + middleName?: string | null; + lastName?: string | null; + nameSuffix?: string | null; + }; + access: { + __typename?: 'EnrollmentAccess'; + id: string; + canEditEnrollments: boolean; + canDeleteEnrollments: boolean; + canAuditEnrollments: boolean; + canViewEnrollmentLocationMap: boolean; + }; + currentUnit?: { __typename?: 'Unit'; id: string; name: string } | null; +}; + export type GetEnrollmentQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -22369,6 +22516,7 @@ export type GetEnrollmentDetailsQuery = { access: { __typename?: 'ProjectAccess'; id: string; + canSplitHouseholds: boolean; canManageIncomingReferrals: boolean; }; projectCocs: { __typename?: 'ProjectCocsPaginated'; nodesCount: number }; @@ -22430,6 +22578,7 @@ export type GetEnrollmentWithHouseholdQuery = { household: { __typename?: 'Household'; id: string; + householdSize: number; shortId: string; householdClients: Array<{ __typename?: 'HouseholdClient'; @@ -31001,6 +31150,7 @@ export type SubmitFormMutation = { canManageIncomingReferrals: boolean; canManageOutgoingReferrals: boolean; canManageExternalFormSubmissions: boolean; + canSplitHouseholds: boolean; }; user?: { __typename: 'ApplicationUser'; @@ -33229,6 +33379,194 @@ export type ProjectEnrollmentsHouseholdClientFieldsFragment = { }; }; +export type JoinHouseholdMutationVariables = Exact<{ + receivingHouseholdId: Scalars['ID']['input']; + joiningEnrollmentInputs: + | Array<EnrollmentRelationshipInput> + | EnrollmentRelationshipInput; +}>; + +export type JoinHouseholdMutation = { + __typename?: 'Mutation'; + joinHousehold?: { + __typename?: 'JoinHouseholdPayload'; + receivingHousehold: { + __typename?: 'Household'; + id: string; + householdSize: number; + shortId: string; + householdClients: Array<{ + __typename?: 'HouseholdClient'; + id: string; + relationshipToHoH: RelationshipToHoH; + client: { + __typename?: 'Client'; + id: string; + lockVersion: number; + firstName?: string | null; + middleName?: string | null; + lastName?: string | null; + nameSuffix?: string | null; + dob?: string | null; + age?: number | null; + gender: Array<Gender>; + pronouns: Array<string>; + ssn?: string | null; + race: Array<Race>; + veteranStatus: NoYesReasonsForMissingData; + access: { + __typename?: 'ClientAccess'; + id: string; + canEditClient: boolean; + canDeleteClient: boolean; + canViewDob: boolean; + canViewFullSsn: boolean; + canViewPartialSsn: boolean; + canViewClientName: boolean; + canViewClientPhoto: boolean; + canViewClientAlerts: boolean; + canManageClientAlerts: boolean; + canViewEnrollmentDetails: boolean; + canAuditClients: boolean; + canManageScanCards: boolean; + canMergeClients: boolean; + canViewAnyFiles: boolean; + canManageAnyClientFiles: boolean; + canManageOwnClientFiles: boolean; + canUploadClientFiles: boolean; + }; + externalIds: Array<{ + __typename?: 'ExternalIdentifier'; + id: string; + identifier?: string | null; + url?: string | null; + label: string; + type: ExternalIdentifierType; + }>; + alerts: Array<{ + __typename?: 'ClientAlert'; + id: string; + note: string; + expirationDate?: string | null; + createdAt: string; + priority: ClientAlertPriorityLevel; + createdBy?: { + __typename: 'ApplicationUser'; + id: string; + name: string; + firstName?: string | null; + lastName?: string | null; + email: string; + } | null; + }>; + }; + enrollment: { + __typename?: 'Enrollment'; + id: string; + lockVersion: number; + relationshipToHoH: RelationshipToHoH; + autoExited: boolean; + entryDate: string; + exitDate?: string | null; + inProgress: boolean; + currentUnit?: { + __typename?: 'Unit'; + id: string; + name: string; + } | null; + }; + }>; + }; + donorHousehold?: { + __typename?: 'Household'; + id: string; + householdSize: number; + shortId: string; + householdClients: Array<{ + __typename?: 'HouseholdClient'; + id: string; + relationshipToHoH: RelationshipToHoH; + client: { + __typename?: 'Client'; + id: string; + lockVersion: number; + firstName?: string | null; + middleName?: string | null; + lastName?: string | null; + nameSuffix?: string | null; + dob?: string | null; + age?: number | null; + gender: Array<Gender>; + pronouns: Array<string>; + ssn?: string | null; + race: Array<Race>; + veteranStatus: NoYesReasonsForMissingData; + access: { + __typename?: 'ClientAccess'; + id: string; + canEditClient: boolean; + canDeleteClient: boolean; + canViewDob: boolean; + canViewFullSsn: boolean; + canViewPartialSsn: boolean; + canViewClientName: boolean; + canViewClientPhoto: boolean; + canViewClientAlerts: boolean; + canManageClientAlerts: boolean; + canViewEnrollmentDetails: boolean; + canAuditClients: boolean; + canManageScanCards: boolean; + canMergeClients: boolean; + canViewAnyFiles: boolean; + canManageAnyClientFiles: boolean; + canManageOwnClientFiles: boolean; + canUploadClientFiles: boolean; + }; + externalIds: Array<{ + __typename?: 'ExternalIdentifier'; + id: string; + identifier?: string | null; + url?: string | null; + label: string; + type: ExternalIdentifierType; + }>; + alerts: Array<{ + __typename?: 'ClientAlert'; + id: string; + note: string; + expirationDate?: string | null; + createdAt: string; + priority: ClientAlertPriorityLevel; + createdBy?: { + __typename: 'ApplicationUser'; + id: string; + name: string; + firstName?: string | null; + lastName?: string | null; + email: string; + } | null; + }>; + }; + enrollment: { + __typename?: 'Enrollment'; + id: string; + lockVersion: number; + relationshipToHoH: RelationshipToHoH; + autoExited: boolean; + entryDate: string; + exitDate?: string | null; + inProgress: boolean; + currentUnit?: { + __typename?: 'Unit'; + id: string; + name: string; + } | null; + }; + }>; + } | null; + } | null; +}; + export type GetHouseholdQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -34297,6 +34635,7 @@ export type ProjectAllFieldsFragment = { canManageIncomingReferrals: boolean; canManageOutgoingReferrals: boolean; canManageExternalFormSubmissions: boolean; + canSplitHouseholds: boolean; }; user?: { __typename: 'ApplicationUser'; @@ -35202,6 +35541,7 @@ export type GetProjectQuery = { canManageIncomingReferrals: boolean; canManageOutgoingReferrals: boolean; canManageExternalFormSubmissions: boolean; + canSplitHouseholds: boolean; }; user?: { __typename: 'ApplicationUser'; @@ -35381,6 +35721,7 @@ export type GetProjectPermissionsQuery = { canManageIncomingReferrals: boolean; canManageOutgoingReferrals: boolean; canManageExternalFormSubmissions: boolean; + canSplitHouseholds: boolean; }; } | null; }; @@ -41000,6 +41341,7 @@ export const AllEnrollmentDetailsFragmentDoc = gql` hasUnits access { id + canSplitHouseholds canManageIncomingReferrals } staffAssignmentsEnabled @@ -41032,6 +41374,66 @@ export const SubmittedEnrollmentResultFieldsFragmentDoc = gql` ${EnrollmentOccurrencePointFieldsFragmentDoc} ${CustomDataElementFieldsFragmentDoc} `; +export const HouseholdClientFieldsFragmentDoc = gql` + fragment HouseholdClientFields on HouseholdClient { + id + relationshipToHoH + client { + id + ...ClientName + ...ClientIdentificationFields + ...AssessedClientFields + access { + ...ClientAccessFields + } + externalIds { + ...ClientIdentifierFields + } + alerts { + ...ClientAlertFields + } + } + enrollment { + id + lockVersion + relationshipToHoH + ...EnrollmentRangeFields + autoExited + currentUnit { + id + name + } + } + } + ${ClientNameFragmentDoc} + ${ClientIdentificationFieldsFragmentDoc} + ${AssessedClientFieldsFragmentDoc} + ${ClientAccessFieldsFragmentDoc} + ${ClientIdentifierFieldsFragmentDoc} + ${ClientAlertFieldsFragmentDoc} + ${EnrollmentRangeFieldsFragmentDoc} +`; +export const HouseholdFieldsFragmentDoc = gql` + fragment HouseholdFields on Household { + id + householdSize + shortId + householdClients { + ...HouseholdClientFields + } + } + ${HouseholdClientFieldsFragmentDoc} +`; +export const EnrollmentWithHouseholdFieldsFragmentDoc = gql` + fragment EnrollmentWithHouseholdFields on Enrollment { + ...EnrollmentFields + household { + ...HouseholdFields + } + } + ${EnrollmentFieldsFragmentDoc} + ${HouseholdFieldsFragmentDoc} +`; export const ExternalFormSubmissionSummaryFragmentDoc = gql` fragment ExternalFormSubmissionSummary on ExternalFormSubmission { id @@ -41118,56 +41520,6 @@ export const GeolocationFieldsWithMetadataFragmentDoc = gql` } ${UserFieldsFragmentDoc} `; -export const HouseholdClientFieldsFragmentDoc = gql` - fragment HouseholdClientFields on HouseholdClient { - id - relationshipToHoH - client { - id - ...ClientName - ...ClientIdentificationFields - ...AssessedClientFields - access { - ...ClientAccessFields - } - externalIds { - ...ClientIdentifierFields - } - alerts { - ...ClientAlertFields - } - } - enrollment { - id - lockVersion - relationshipToHoH - ...EnrollmentRangeFields - autoExited - currentUnit { - id - name - } - } - } - ${ClientNameFragmentDoc} - ${ClientIdentificationFieldsFragmentDoc} - ${AssessedClientFieldsFragmentDoc} - ${ClientAccessFieldsFragmentDoc} - ${ClientIdentifierFieldsFragmentDoc} - ${ClientAlertFieldsFragmentDoc} - ${EnrollmentRangeFieldsFragmentDoc} -`; -export const HouseholdFieldsFragmentDoc = gql` - fragment HouseholdFields on Household { - id - householdSize - shortId - householdClients { - ...HouseholdClientFields - } - } - ${HouseholdClientFieldsFragmentDoc} -`; export const ProjectEnrollmentsHouseholdClientFieldsFragmentDoc = gql` fragment ProjectEnrollmentsHouseholdClientFields on HouseholdClient { id @@ -41333,6 +41685,7 @@ export const ProjectAccessFieldsFragmentDoc = gql` canManageIncomingReferrals canManageOutgoingReferrals canManageExternalFormSubmissions + canSplitHouseholds } `; export const ProjectAllFieldsFragmentDoc = gql` @@ -46520,18 +46873,10 @@ export type GetEnrollmentDetailsQueryResult = Apollo.QueryResult< export const GetEnrollmentWithHouseholdDocument = gql` query GetEnrollmentWithHousehold($id: ID!) { enrollment(id: $id) { - ...EnrollmentFields - household { - id - shortId - householdClients { - ...HouseholdClientFields - } - } + ...EnrollmentWithHouseholdFields } } - ${EnrollmentFieldsFragmentDoc} - ${HouseholdClientFieldsFragmentDoc} + ${EnrollmentWithHouseholdFieldsFragmentDoc} `; /** @@ -49210,6 +49555,69 @@ export type GetEnrollmentGeolocationsQueryResult = Apollo.QueryResult< GetEnrollmentGeolocationsQuery, GetEnrollmentGeolocationsQueryVariables >; +export const JoinHouseholdDocument = gql` + mutation JoinHousehold( + $receivingHouseholdId: ID! + $joiningEnrollmentInputs: [EnrollmentRelationshipInput!]! + ) { + joinHousehold( + receivingHouseholdId: $receivingHouseholdId + joiningEnrollmentInputs: $joiningEnrollmentInputs + ) { + receivingHousehold { + ...HouseholdFields + } + donorHousehold { + ...HouseholdFields + } + } + } + ${HouseholdFieldsFragmentDoc} +`; +export type JoinHouseholdMutationFn = Apollo.MutationFunction< + JoinHouseholdMutation, + JoinHouseholdMutationVariables +>; + +/** + * __useJoinHouseholdMutation__ + * + * To run a mutation, you first call `useJoinHouseholdMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useJoinHouseholdMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [joinHouseholdMutation, { data, loading, error }] = useJoinHouseholdMutation({ + * variables: { + * receivingHouseholdId: // value for 'receivingHouseholdId' + * joiningEnrollmentInputs: // value for 'joiningEnrollmentInputs' + * }, + * }); + */ +export function useJoinHouseholdMutation( + baseOptions?: Apollo.MutationHookOptions< + JoinHouseholdMutation, + JoinHouseholdMutationVariables + > +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + JoinHouseholdMutation, + JoinHouseholdMutationVariables + >(JoinHouseholdDocument, options); +} +export type JoinHouseholdMutationHookResult = ReturnType< + typeof useJoinHouseholdMutation +>; +export type JoinHouseholdMutationResult = + Apollo.MutationResult<JoinHouseholdMutation>; +export type JoinHouseholdMutationOptions = Apollo.BaseMutationOptions< + JoinHouseholdMutation, + JoinHouseholdMutationVariables +>; export const GetHouseholdDocument = gql` query GetHousehold($id: ID!) { household(id: $id) {