From 290b2e527d0c00d5bcdfb54f6bac541bcd10947f Mon Sep 17 00:00:00 2001 From: Shreeyash Shrestha Date: Fri, 12 Jun 2026 21:45:55 +0545 Subject: [PATCH 1/3] feat(team-member): add teams and team members page --- app/Root/config/routes.ts | 25 +- app/components/EditDeleteActions/index.tsx | 10 +- app/components/NonFieldError/index.tsx | 63 ++++ .../NonFieldError/styles.module.css | 19 ++ app/components/Page/index.tsx | 29 +- app/components/Page/styles.module.css | 25 +- app/components/RegionSelectInput/index.tsx | 27 +- app/hooks/useRegionMap.ts | 16 +- app/utils/common.ts | 32 ++ app/views/RootLayout/index.tsx | 4 +- app/views/RootLayout/styles.module.css | 5 +- app/views/Teams/TeamForm/index.tsx | 6 +- .../TeamMembers/TeamMemberForm/index.tsx | 307 ++++++++++++++++++ app/views/Teams/TeamMembers/index.tsx | 184 +++++++++++ app/views/Teams/{TeamsList => }/index.tsx | 63 +++- app/views/Teams/query.ts | 85 ++++- app/views/Users/UserForm/index.tsx | 2 + .../{UserFilter => UserListFilters}/index.tsx | 6 +- app/views/Users/UsersList/index.tsx | 10 +- backend | 2 +- pnpm-lock.yaml | 8 +- 21 files changed, 852 insertions(+), 76 deletions(-) create mode 100644 app/components/NonFieldError/index.tsx create mode 100644 app/components/NonFieldError/styles.module.css create mode 100644 app/views/Teams/TeamMembers/TeamMemberForm/index.tsx create mode 100644 app/views/Teams/TeamMembers/index.tsx rename app/views/Teams/{TeamsList => }/index.tsx (72%) rename app/views/Users/UsersList/{UserFilter => UserListFilters}/index.tsx (92%) diff --git a/app/Root/config/routes.ts b/app/Root/config/routes.ts index bffe82b..7e42768 100644 --- a/app/Root/config/routes.ts +++ b/app/Root/config/routes.ts @@ -25,24 +25,40 @@ const login: RouteConfig = { const teams: RouteConfig = { index: true, path: '/teams', - load: () => import('#views/Teams/TeamsList'), + load: () => import('#views/Teams/index'), visibility: 'is-authenticated', }; const createTeam: RouteConfig = { - index: true, path: '/teams/new', load: () => import('#views/Teams/TeamForm'), visibility: 'is-authenticated', }; const editTeam: RouteConfig = { - index: true, path: '/teams/:id/edit', load: () => import('#views/Teams/TeamForm'), visibility: 'is-authenticated', }; +const teamMembers: RouteConfig = { + path: '/teams/:id/team-members/', + load: () => import('#views/Teams/TeamMembers'), + visibility: 'is-authenticated', +}; + +const createTeamMember: RouteConfig = { + path: '/teams/:id/team-members/new', + load: () => import('#views/Teams/TeamMembers/TeamMemberForm'), + visibility: 'is-authenticated', +}; + +const editTeamMember: RouteConfig = { + path: '/teams/:id/team-members/:member/edit', + load: () => import('#views/Teams/TeamMembers/TeamMemberForm'), + visibility: 'is-authenticated', +}; + const users: RouteConfig = { index: true, path: '/users', @@ -106,6 +122,8 @@ const routes = { teams, createTeam, editTeam, + teamMembers, + createTeamMember, users, createUser, editUser, @@ -115,6 +133,7 @@ const routes = { documents, onlineInteractive, galleries, + editTeamMember, } satisfies Record; export type RouteKeys = keyof typeof routes; diff --git a/app/components/EditDeleteActions/index.tsx b/app/components/EditDeleteActions/index.tsx index 1be88ea..d78e5ba 100644 --- a/app/components/EditDeleteActions/index.tsx +++ b/app/components/EditDeleteActions/index.tsx @@ -4,12 +4,14 @@ import { EditTwoLineIcon, } from '@ifrc-go/icons'; import { TableActions } from '@ifrc-go/ui'; +import { isDefined } from '@togglecorp/fujs'; import DropdownMenuItem from '#components/DropdownMenuItem'; import useRouting, { type RoutesMap } from '#hooks/useRouting'; export interface Props { id: string; + member?: string; onDelete: (id: string) => void; itemTitle: string; to: keyof RoutesMap; @@ -21,13 +23,19 @@ function EditDeleteActions(props: Props) { onDelete, itemTitle, to, + member, } = props; const navigate = useRouting(); const handleEditClick = useCallback(() => { + // NOTE: This navigation is for Team member + // as id and memberId is needed to access + if (isDefined(member)) { + navigate(to, { id, member }); + } navigate(to, { id }); - }, [navigate, to, id]); + }, [navigate, to, id, member]); return ( { + className?: string; + error?: Error; + withFallbackError?: boolean; +} + +function NonFieldError(props: Props) { + const { + className, + error, + withFallbackError, + } = props; + + const errorObject = useMemo(() => getErrorObject(error), [error]); + + if (isNotDefined(errorObject)) { + return null; + } + + const hasError = analyzeErrors(errorObject); + if (!hasError) { + return null; + } + + const stringError = errorObject?.[nonFieldError] || ( + withFallbackError ? 'Please correct all the errors before submission!' : undefined); + + if (isFalsyString(stringError)) { + return null; + } + + return ( +
+ +
+ {stringError} +
+
+ ); +} + +export default NonFieldError; diff --git a/app/components/NonFieldError/styles.module.css b/app/components/NonFieldError/styles.module.css new file mode 100644 index 0000000..db25d7b --- /dev/null +++ b/app/components/NonFieldError/styles.module.css @@ -0,0 +1,19 @@ +.non-field-error { + display: inline-flex; + align-items: start; + animation: flash var(--go-ui-duration-animation-fast) ease-in-out; + animation-delay: var(--go-ui-duration-animation-slow); + gap: var(--go-ui-spacing-sm); + color: var(--go-ui-color-red); + + .icon { + flex-shrink: 0; + font-size: var(--go-ui-height-icon-multiplier); + } +} + +@keyframes flash { + 0% { opacity: 1; } + 50% { opacity: 0.7; } + 100% { opacity: 1; } +} diff --git a/app/components/Page/index.tsx b/app/components/Page/index.tsx index 00060d4..d3b049c 100644 --- a/app/components/Page/index.tsx +++ b/app/components/Page/index.tsx @@ -1,4 +1,4 @@ -import { ListView } from '@ifrc-go/ui'; +import type { RefObject } from 'react'; import { _cs } from '@togglecorp/fujs'; import styles from './styles.module.css'; @@ -6,6 +6,7 @@ import styles from './styles.module.css'; interface Props { className?: string; children?: React.ReactNode; + elementRef?: RefObject leftPaneContent?: React.ReactNode; leftPaneContainerClassName?: string; } @@ -13,34 +14,22 @@ function Page(props: Props) { const { className, children, + elementRef, leftPaneContent, leftPaneContainerClassName, } = props; return ( - +
{leftPaneContent && ( - +
{leftPaneContent} - +
)} - +
{children} - - +
+
); } diff --git a/app/components/Page/styles.module.css b/app/components/Page/styles.module.css index f5d83e9..c73b79a 100644 --- a/app/components/Page/styles.module.css +++ b/app/components/Page/styles.module.css @@ -1,3 +1,24 @@ -.page , .page > div { - height: 100%; +.page { + display: flex; + flex-direction: row; + flex-grow: 1; + background: var(--go-ui-color-gray-10); + height: 100%; + overflow: hidden; + + >* { + width: 100% } + + .left-pane { + display: flex; + flex-direction: column; + background-color: var(--go-ui-color-white); + gap: var(--go-ui-spacing-md); + width: 20% + } + + .right-pane-content { + overflow: auto; + } +} diff --git a/app/components/RegionSelectInput/index.tsx b/app/components/RegionSelectInput/index.tsx index a004309..5fec4c2 100644 --- a/app/components/RegionSelectInput/index.tsx +++ b/app/components/RegionSelectInput/index.tsx @@ -8,6 +8,9 @@ import { import { idSelector, nameSelector, + type REGION_LEVEL, + type WOREDA_LEVEL, + type ZONE_LEVEL, } from '#utils/common'; export interface Props { @@ -18,21 +21,25 @@ export interface Props { placeholder?: string; error?: string; disabled?: boolean; + level: REGION_LEVEL | ZONE_LEVEL | WOREDA_LEVEL; } -function RegionSelectInput({ - className, - name, - value, - onChange, - placeholder, - error, - disabled, -}: Props) { +function RegionSelectInput(props: Props) { + const { + className, + name, + value, + onChange, + placeholder, + error, + disabled, + level = AdminAreaLevel.Region, + } = props; + const [{ data: adminAreasData }] = useAdminAreasQuery({ variables: { filters: { - level: AdminAreaLevel.Region, + level, }, }, }); diff --git a/app/hooks/useRegionMap.ts b/app/hooks/useRegionMap.ts index 4188ced..24d8d23 100644 --- a/app/hooks/useRegionMap.ts +++ b/app/hooks/useRegionMap.ts @@ -1,16 +1,20 @@ import { useMemo } from 'react'; import { listToMap } from '@togglecorp/fujs'; -import { - AdminAreaLevel, - useAdminAreasQuery, -} from '#generated/types/graphql'; +import { useAdminAreasQuery } from '#generated/types/graphql'; +import type { + REGION_LEVEL, + WOREDA_LEVEL, + ZONE_LEVEL, +} from '#utils/common'; -export default function useRegionMap() { +export default function useRegionMap( + level: REGION_LEVEL | ZONE_LEVEL | WOREDA_LEVEL, +) { const [{ data }] = useAdminAreasQuery({ variables: { filters: { - level: AdminAreaLevel.Region, + level, }, }, }); diff --git a/app/utils/common.ts b/app/utils/common.ts index e2f2c6c..2b9ff35 100644 --- a/app/utils/common.ts +++ b/app/utils/common.ts @@ -1,3 +1,7 @@ +import { nonFieldError } from '@togglecorp/toggle-form'; + +import type { AdminAreaLevel } from '#generated/types/graphql'; + export function labelSelector(item: { label: T }) { return item.label; } @@ -31,3 +35,31 @@ export const statusFilterOptions = [ ]; export const errorMessage = 'Something went wrong. Please try again. '; + +interface ServerError { + field: string; + messages: string | null; + objectErrors?: ServerError[] | null; + arrayErrors?: unknown[] | null; +} + +export function transformToFormError( + serverErrors: ServerError[], +): Record { + return serverErrors.reduce( + (acc, { field, messages, objectErrors }) => { + if (field === 'nonFieldErrors') { + return { ...acc, [nonFieldError]: messages ?? '' }; + } + if (objectErrors?.length) { + return { ...acc, [field]: transformToFormError(objectErrors) }; + } + return { ...acc, [field]: messages ?? '' }; + }, + {} as Record, + ); +} + +export type REGION_LEVEL = AdminAreaLevel.Region; +export type ZONE_LEVEL = AdminAreaLevel.Zone; +export type WOREDA_LEVEL = AdminAreaLevel.Woreda; diff --git a/app/views/RootLayout/index.tsx b/app/views/RootLayout/index.tsx index 907cdb5..270095b 100644 --- a/app/views/RootLayout/index.tsx +++ b/app/views/RootLayout/index.tsx @@ -59,9 +59,7 @@ function RootLayout() { return (
-
- -
+
); } diff --git a/app/views/RootLayout/styles.module.css b/app/views/RootLayout/styles.module.css index 9411b2c..1f38131 100644 --- a/app/views/RootLayout/styles.module.css +++ b/app/views/RootLayout/styles.module.css @@ -3,9 +3,6 @@ position: relative; flex-direction: column; height: 100vh; + overflow: hidden; - .page-content { - flex-grow: 1; - overflow: hidden; - } } diff --git a/app/views/Teams/TeamForm/index.tsx b/app/views/Teams/TeamForm/index.tsx index 728662f..d8a7fdd 100644 --- a/app/views/Teams/TeamForm/index.tsx +++ b/app/views/Teams/TeamForm/index.tsx @@ -115,6 +115,10 @@ function TeamForm() { const teamData = data?.team; + const handleCancelClick = useCallback(() => { + navigate('teams'); + }, [navigate]); + useEffect(() => { if (!teamDetailFetch && isDefined(teamData)) { setValue(removeNull(teamData)); @@ -140,7 +144,7 @@ function TeamForm() { diff --git a/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx b/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx new file mode 100644 index 0000000..9409f9a --- /dev/null +++ b/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx @@ -0,0 +1,307 @@ +import { + useCallback, + useEffect, +} from 'react'; +import { useParams } from 'react-router'; +import { + BlockLoading, + Button, + Container, + InputSection, + ListView, + TextInput, +} from '@ifrc-go/ui'; +import { isDefined } from '@togglecorp/fujs'; +import { + createSubmitHandler, + emailCondition, + getErrorObject, + type ObjectSchema, + type PartialForm, + removeNull, + requiredStringCondition, + useForm, +} from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; +import RegionSelectInput from '#components/RegionSelectInput'; +import { + AdminAreaLevel, + type TeamMemberCreateInput, + type TeamMemberUpdateInput, + useCreateTeamMemberMutation, + useTeamMemberDetailsQuery, + useUpdateTeamMemberMutation, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import useRouting from '#hooks/useRouting'; +import { + errorMessage, + transformToFormError, +} from '#utils/common'; + +type PartialFormType = PartialForm; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +const TeamSchema: FormSchema = { + fields: (): FormSchemaFields => ({ + name: { + required: true, + requiredValidation: requiredStringCondition, + }, + email: { + required: true, + requiredValidation: emailCondition, + }, + phoneNumber: {}, + team: { + required: true, + }, + position: { + required: true, + defaultValue: '', + }, + region: {}, + woreda: {}, + training: {}, + fieldOfStudy: {}, + }), +}; + +function TeamMemberForm() { + const { id, member } = useParams(); + const navigate = useRouting(); + const alert = useAlert(); + + const { + setFieldValue, + error: formError, + value, + validate, + setError, + setValue, + } = useForm(TeamSchema, { value: { team: id } }); + + const [{ data, fetching: teamMemberDetailFetch }] = useTeamMemberDetailsQuery({ + variables: { id: (member ?? '') }, pause: !member, + }); + + const [{ fetching: createPending }, createTeamMemberMutate] = useCreateTeamMemberMutation(); + const [{ fetching: updatePending }, updateTeamMemberMutate] = useUpdateTeamMemberMutation(); + + const handleMutation = useCallback(async (mutationData: PartialFormType) => { + const redirectPath = 'teamMembers'; + const alertMessage = `Team Member ${isDefined(member) ? 'updated' : 'created'} successfully`; + + if (isDefined(member)) { + const res = await updateTeamMemberMutate({ + id: member, + data: mutationData as TeamMemberUpdateInput, + }); + const result = res.data?.updateTeamMember; + if (result?.ok) { + alert.show(alertMessage, { variant: 'success' }); + if (isDefined(id)) { + navigate(redirectPath, { id }); + } + } else if (result?.errors) { + setError(transformToFormError(result.errors)); + alert.show(errorMessage, { variant: 'danger' }); + } + } else { + const res = await createTeamMemberMutate({ + data: mutationData as TeamMemberCreateInput, + }); + const result = res.data?.createTeamMember; + if (result?.ok) { + alert.show(alertMessage, { variant: 'success' }); + if (isDefined(id)) { + navigate(redirectPath, { id }); + } + } else if (result?.errors) { + setError(transformToFormError(result.errors)); + alert.show(errorMessage, { variant: 'danger' }); + } + } + }, [alert, id, updateTeamMemberMutate, member, navigate, setError, createTeamMemberMutate]); + + const handleFormSubmit = useCallback( + () => createSubmitHandler( + validate, + setError, + handleMutation, + )(), + [validate, setError, handleMutation], + ); + + const error = getErrorObject(formError); + + const teamMemberData = data?.teamMember; + + const handleCancelClick = useCallback(() => { + if (isDefined(id)) { + navigate('teamMembers', { id }); + } + }, [id, navigate]); + + useEffect(() => { + if (!teamMemberDetailFetch && isDefined(teamMemberData)) { + const { ...otherValues } = removeNull(teamMemberData); + setValue({ + team: id, + ...otherValues, + }); + } + }, [teamMemberDetailFetch, id, teamMemberData, setValue]); + + const pending = createPending || updatePending || teamMemberDetailFetch; + + if (pending) { + return ( + + ); + } + + return ( + + + + + )} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default TeamMemberForm; diff --git a/app/views/Teams/TeamMembers/index.tsx b/app/views/Teams/TeamMembers/index.tsx new file mode 100644 index 0000000..09a9a77 --- /dev/null +++ b/app/views/Teams/TeamMembers/index.tsx @@ -0,0 +1,184 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { useParams } from 'react-router'; +import { AddFillIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + Pager, + Table, +} from '@ifrc-go/ui'; +import { + createElementColumn, + createStringColumn, +} from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; + +import EditDeleteActions, { type Props as EditDeleteActionsProps } from '#components/EditDeleteActions'; +import { + AdminAreaLevel, + type TeamMembersQuery, + useDeleteTeamMemberMutation, + useTeamDetailQuery, + useTeamMembersQuery, +} from '#generated/types/graphql'; +import useAlert from '#hooks/useAlert'; +import usePagination from '#hooks/usePagination'; +import useRegionMap from '#hooks/useRegionMap'; +import useRouting from '#hooks/useRouting'; +import { idSelector } from '#utils/common'; + +type TeamMembersListItem = NonNullable['results'][number] & { no: string }>; + +function TeamMembers() { + const { + page, + setPage, + pageSize, + variables, + } = usePagination(); + + const alert = useAlert(); + const navigate = useRouting(); + + const { id } = useParams(); + + const [{ data: teamData, fetching: teamDetailFetch }] = useTeamDetailQuery({ + variables: { id: (id ?? '') }, pause: !id, + }); + + const regionMap = useRegionMap(AdminAreaLevel.Region); + const woredaMap = useRegionMap(AdminAreaLevel.Woreda); + + const [, deleteTeamMember] = useDeleteTeamMemberMutation(); + const [{ fetching, data }, reExecuteQuery] = useTeamMembersQuery({ + variables: { + filters: { teamId: id }, + pagination: variables.pagination, + }, + pause: !id, + }); + + const tableData = useMemo(() => ( + data?.teamMembers.results.map((user, index) => { + const no = (page - 1) * pageSize + index + 1; + return { + ...user, + no, + }; + }) as unknown as TeamMembersListItem[]), [page, data, pageSize]); + + const onDeleteClick = useCallback( + (itemId: string) => { + deleteTeamMember({ id: itemId }).then((resp) => { + if (resp.data?.deleteTeamMember) { + reExecuteQuery(); + alert.show('Team Member deleted successfully', { variant: 'success' }); + } + }); + }, + [deleteTeamMember, reExecuteQuery, alert], + ); + + const columns = useMemo(() => [ + createStringColumn( + 'no', + 'No.', + (team) => team.no, + ), + createStringColumn( + 'name', + 'Full Name', + (team) => team.name, + ), + createStringColumn( + 'sex', + 'Sex', + (team) => team.sexDisplay, + ), + createStringColumn( + 'region', + 'Region/Zone', + (team) => (isDefined(team.region) ? regionMap[team.region] : '-'), + ), + createStringColumn( + 'woreda', + 'Woreda', + (team) => (isDefined(team.woreda) ? woredaMap[team.woreda] : '-'), + ), + createStringColumn( + 'phoneNumber', + 'Number', + (team) => team.phoneNumber, + ), + createStringColumn( + 'training', + 'Training', + (team) => team.training, + ), + createStringColumn( + 'fieldOfStudy', + 'Field of Study', + (team) => team.fieldOfStudy, + ), + createElementColumn( + 'actions', + '', + EditDeleteActions, + (_, datum) => ({ + id: id ?? '', + onDelete: onDeleteClick, + itemTitle: datum.name, + member: datum.id, + to: 'editTeamMember', + + }), + { columnWidth: 150 }, + ), + ], [onDeleteClick, id, regionMap, woredaMap]); + + const handleCreateClick = useCallback(() => { + if (isDefined(id)) { + navigate('createTeamMember', { id }); + } + }, [navigate, id]); + + return ( + + )} + headerActions={( + + )} + > + + + ); +} + +export default TeamMembers; diff --git a/app/views/Teams/TeamsList/index.tsx b/app/views/Teams/index.tsx similarity index 72% rename from app/views/Teams/TeamsList/index.tsx rename to app/views/Teams/index.tsx index 740fb16..9a0cdf9 100644 --- a/app/views/Teams/TeamsList/index.tsx +++ b/app/views/Teams/index.tsx @@ -8,6 +8,7 @@ import { Container, Pager, Table, + TextInput, } from '@ifrc-go/ui'; import { createDateColumn, @@ -16,40 +17,60 @@ import { } from '@ifrc-go/ui/utils'; import EditDeleteActions, { type Props as EditDeleteActionsProps } from '#components/EditDeleteActions'; +import Link, { type Props as LinkProps } from '#components/Link'; import { + type TeamFilter, type TeamsQuery, useDeleteTeamMutation, useTeamsQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; -import usePagination from '#hooks/usePagination'; +import useFilterState from '#hooks/useFilterState'; import useRouting from '#hooks/useRouting'; import { idSelector } from '#utils/common'; type TeamsListItem = NonNullable['results'][number] & { no: string }>; +const defaultFilter: TeamFilter = { search: undefined }; + function Teams() { const { + filter, + rawFilter, + filtered, + setFilterField, page, setPage, - pageSize, - variables, - } = usePagination(); + limit, + offset, + } = useFilterState({ + filter: defaultFilter, + }); const alert = useAlert(); const navigate = useRouting(); - const [{ fetching, data }, reExecuteQuery] = useTeamsQuery({ variables }); + const queryVariables = useMemo(() => ({ + pagination: { + limit, + offset, + }, + filters: { + search: filter.search, + }, + }), [limit, offset, filter]); + + const [{ fetching, data }, reExecuteQuery] = useTeamsQuery({ variables: queryVariables }); const [, deleteTeam] = useDeleteTeamMutation(); const tableData = useMemo(() => ( data?.teams.results.map((user, index) => { - const no = (page - 1) * pageSize + index + 1; + const no = (page - 1) * limit + index + 1; return { ...user, no, }; - }) as unknown as TeamsListItem[]), [page, data, pageSize]); + }) as unknown as TeamsListItem[]), [page, data, limit]); const onDeleteClick = useCallback( (id: string) => { @@ -74,11 +95,17 @@ function Teams() { 'Created At', (team) => team.createdAt, ), - createStringColumn( - 'name', - 'Team Name', - (team) => team.name, - ), + createElementColumn( + 'name', + 'Team Name', + Link, + (_, team) => ({ + children: team.name, + to: 'teamMembers', + attrs: { id: team.id }, + }), + ), createStringColumn( 'description', 'Description', @@ -114,11 +141,19 @@ function Teams() { withPadding heading="Teams" headerDescription="Manage a dedicated team committed to delivering impactful solutions" + filters={( + + )} footerActions={( )} @@ -137,7 +172,7 @@ function Teams() { keySelector={idSelector} columns={columns} data={tableData} - filtered={false} + filtered={filtered} pending={fetching} /> diff --git a/app/views/Teams/query.ts b/app/views/Teams/query.ts index 11a73fc..77dddd5 100644 --- a/app/views/Teams/query.ts +++ b/app/views/Teams/query.ts @@ -2,8 +2,8 @@ import { gql } from 'urql'; const TEAMS = gql` - query Teams($pagination: OffsetPaginationInput) { - teams(pagination: $pagination) { + query Teams($pagination: OffsetPaginationInput $filters: TeamFilter) { + teams(pagination: $pagination, filters: $filters) { results { id createdAt @@ -67,3 +67,84 @@ const DELETE_TEAM = gql` } } `; + +const TEAM_MEMBERS = gql` + query TeamMembers($pagination: OffsetPaginationInput, $filters: TeamMemberFilter) { + teamMembers(pagination: $pagination, filters: $filters) { + results { + id + name + email + sex + sexDisplay + region + woreda + position + phoneNumber + training + fieldOfStudy + } + totalCount + } + } +`; + +const TEAM_MEMBER = gql` + query TeamMemberDetails($id: ID!) { + teamMember(id: $id) { + id + name + email + sex + sexDisplay + region + woreda + position + phoneNumber + training + fieldOfStudy + } + } +`; + +const DELETE_TEAM_MEMBER = gql` + mutation DeleteTeamMember($id: ID!) { + deleteTeamMember(id: $id) { + ... on TeamMemberTypeMutationResponseType { + errors + ok + result { + id + name + } + } + ... on OperationInfo { + messages { + message + } + } + } + } +`; + +const CREATE_TEAM_MEMBER_MUTATION = gql` + mutation CreateTeamMember($data: TeamMemberCreateInput!) { + createTeamMember(data: $data) { + ... on TeamMemberTypeMutationResponseType { + errors + ok + } + } +} +`; + +const UPDATE_TEAM_MEMBER_MUTATION = gql` + mutation UpdateTeamMember($id: ID!, $data: TeamMemberUpdateInput!) { + updateTeamMember(id: $id, data: $data) { + ... on TeamMemberTypeMutationResponseType { + errors + ok + } + } +} +`; diff --git a/app/views/Users/UserForm/index.tsx b/app/views/Users/UserForm/index.tsx index 54dc448..08beef2 100644 --- a/app/views/Users/UserForm/index.tsx +++ b/app/views/Users/UserForm/index.tsx @@ -30,6 +30,7 @@ import { import RegionSelectInput from '#components/RegionSelectInput'; import { + AdminAreaLevel, useCreateUserMutation, useEnumsQuery, type UserCreateInput, @@ -267,6 +268,7 @@ function UserForm() { > ( 'regionId', 'Region', - (item) => (item.regionId ? regionMap[item.regionId] : undefined) ?? '-', + (item) => (isDefined(item.regionId) ? regionMap[item.regionId] : '-'), ), createElementColumn( 'lastLogin', @@ -227,7 +229,7 @@ function UsersList() { withPadding heading="Users" filters={( - diff --git a/backend b/backend index 77b27ec..dc06956 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 77b27ec75aa8e9076741319f9744588a107398fc +Subproject commit dc069569f4eda7d3f3c64d8212da176d170cf66b diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 422934b..3c2dba0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 19.2.6(react@19.2.6) react-router: specifier: ^7.15.1 - version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) urql: specifier: ^5.0.1 version: 5.0.2(@urql/core@6.0.1(graphql@16.14.0))(react@19.2.6) @@ -4350,8 +4350,8 @@ packages: '@types/react': optional: true - react-router@7.15.1: - resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + react-router@7.17.0: + resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -9854,7 +9854,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-router@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + react-router@7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: cookie: 1.1.1 react: 19.2.6 From e3b84cbe6938930edae4dee6a790d8d77f238306 Mon Sep 17 00:00:00 2001 From: Shreeyash Shrestha Date: Wed, 17 Jun 2026 15:43:05 +0545 Subject: [PATCH 2/3] feat(member-filter): add TeamMembers Table filter and sex field in TeamForm --- app/components/EditDeleteActions/index.tsx | 3 +- .../TeamMembers/TeamMemberForm/index.tsx | 24 +++++++ .../TeamMembers/TeamMembersFilters/index.tsx | 42 ++++++++++++ app/views/Teams/TeamMembers/index.tsx | 64 ++++++++++++++----- app/views/Teams/index.tsx | 54 +++++++++++++--- app/views/Users/UserForm/index.tsx | 6 +- app/views/Users/query.ts | 4 ++ backend | 2 +- 8 files changed, 168 insertions(+), 31 deletions(-) create mode 100644 app/views/Teams/TeamMembers/TeamMembersFilters/index.tsx diff --git a/app/components/EditDeleteActions/index.tsx b/app/components/EditDeleteActions/index.tsx index d78e5ba..55e14e8 100644 --- a/app/components/EditDeleteActions/index.tsx +++ b/app/components/EditDeleteActions/index.tsx @@ -33,8 +33,9 @@ function EditDeleteActions(props: Props) { // as id and memberId is needed to access if (isDefined(member)) { navigate(to, { id, member }); + } else { + navigate(to, { id }); } - navigate(to, { id }); }, [navigate, to, id, member]); return ( diff --git a/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx b/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx index 9409f9a..bd1b4f6 100644 --- a/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx +++ b/app/views/Teams/TeamMembers/TeamMemberForm/index.tsx @@ -9,6 +9,7 @@ import { Container, InputSection, ListView, + SelectInput, TextInput, } from '@ifrc-go/ui'; import { isDefined } from '@togglecorp/fujs'; @@ -30,6 +31,7 @@ import { type TeamMemberCreateInput, type TeamMemberUpdateInput, useCreateTeamMemberMutation, + useEnumsQuery, useTeamMemberDetailsQuery, useUpdateTeamMemberMutation, } from '#generated/types/graphql'; @@ -37,6 +39,8 @@ import useAlert from '#hooks/useAlert'; import useRouting from '#hooks/useRouting'; import { errorMessage, + keySelector, + labelSelector, transformToFormError, } from '#utils/common'; @@ -58,6 +62,7 @@ const TeamSchema: FormSchema = { team: { required: true, }, + sex: {}, position: { required: true, defaultValue: '', @@ -90,6 +95,10 @@ function TeamMemberForm() { const [{ fetching: createPending }, createTeamMemberMutate] = useCreateTeamMemberMutation(); const [{ fetching: updatePending }, updateTeamMemberMutate] = useUpdateTeamMemberMutation(); + const [{ data: enumsData }] = useEnumsQuery(); + + const sexOptions = enumsData?.enums?.TeamMemberSex; + const handleMutation = useCallback(async (mutationData: PartialFormType) => { const redirectPath = 'teamMembers'; const alertMessage = `Team Member ${isDefined(member) ? 'updated' : 'created'} successfully`; @@ -236,6 +245,21 @@ function TeamMemberForm() { disabled={pending} /> + + + ) => void; +} + +function TeamMembersFilters(props: Props) { + const { value, onChange } = props; + + return ( + <> + {/* + NOTE: we might have to create MultiSelectInput for region + and woredas input + + + */} + + + ); +} + +export default TeamMembersFilters; diff --git a/app/views/Teams/TeamMembers/index.tsx b/app/views/Teams/TeamMembers/index.tsx index 09a9a77..201c57d 100644 --- a/app/views/Teams/TeamMembers/index.tsx +++ b/app/views/Teams/TeamMembers/index.tsx @@ -19,26 +19,42 @@ import { isDefined } from '@togglecorp/fujs'; import EditDeleteActions, { type Props as EditDeleteActionsProps } from '#components/EditDeleteActions'; import { AdminAreaLevel, + type TeamMemberFilter, type TeamMembersQuery, useDeleteTeamMemberMutation, useTeamDetailQuery, useTeamMembersQuery, } from '#generated/types/graphql'; import useAlert from '#hooks/useAlert'; -import usePagination from '#hooks/usePagination'; +import useFilterState from '#hooks/useFilterState'; import useRegionMap from '#hooks/useRegionMap'; import useRouting from '#hooks/useRouting'; -import { idSelector } from '#utils/common'; +import { + errorMessage, + idSelector, +} from '#utils/common'; + +import TeamMembersFilters from './TeamMembersFilters'; type TeamMembersListItem = NonNullable['results'][number] & { no: string }>; +const defaultFilter: TeamMemberFilter = { + search: undefined, +}; + function TeamMembers() { const { + filter, + rawFilter, + filtered, + setFilterField, page, setPage, - pageSize, - variables, - } = usePagination(); + limit, + offset, + } = useFilterState({ + filter: defaultFilter, + }); const alert = useAlert(); const navigate = useRouting(); @@ -55,28 +71,39 @@ function TeamMembers() { const [, deleteTeamMember] = useDeleteTeamMemberMutation(); const [{ fetching, data }, reExecuteQuery] = useTeamMembersQuery({ variables: { - filters: { teamId: id }, - pagination: variables.pagination, + filters: { + teamId: id, + search: filter.search, + }, + pagination: { + limit, + offset, + }, }, pause: !id, }); const tableData = useMemo(() => ( data?.teamMembers.results.map((user, index) => { - const no = (page - 1) * pageSize + index + 1; + const no = (page - 1) * limit + index + 1; return { ...user, no, }; - }) as unknown as TeamMembersListItem[]), [page, data, pageSize]); + }) as unknown as TeamMembersListItem[]), [page, data, limit]); const onDeleteClick = useCallback( - (itemId: string) => { - deleteTeamMember({ id: itemId }).then((resp) => { - if (resp.data?.deleteTeamMember) { + (memberId: string) => { + deleteTeamMember({ id: memberId }).then((resp) => { + const result = resp.data?.deleteTeamMember; + if (result && 'ok' in result && result.ok) { reExecuteQuery(); alert.show('Team Member deleted successfully', { variant: 'success' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); } + }).catch((error) => { + alert.show(error ?? errorMessage, { variant: 'danger' }); }); }, [deleteTeamMember, reExecuteQuery, alert], @@ -130,11 +157,10 @@ function TeamMembers() { EditDeleteActions, (_, datum) => ({ id: id ?? '', - onDelete: onDeleteClick, + onDelete: () => onDeleteClick(datum.id), itemTitle: datum.name, member: datum.id, to: 'editTeamMember', - }), { columnWidth: 150 }, ), @@ -151,11 +177,17 @@ function TeamMembers() { withPadding heading={teamData?.team.name} headerDescription="These teams are specialized volunteer groups trained to respond to disasters at the local, regional, or zonal level within the Red Cross structure." + filters={( + + )} footerActions={( )} @@ -173,8 +205,8 @@ function TeamMembers() {
diff --git a/app/views/Teams/index.tsx b/app/views/Teams/index.tsx index 9a0cdf9..74ef1b7 100644 --- a/app/views/Teams/index.tsx +++ b/app/views/Teams/index.tsx @@ -6,6 +6,7 @@ import { AddFillIcon } from '@ifrc-go/icons'; import { Button, Container, + DateInput, Pager, Table, TextInput, @@ -27,11 +28,23 @@ import { import useAlert from '#hooks/useAlert'; import useFilterState from '#hooks/useFilterState'; import useRouting from '#hooks/useRouting'; -import { idSelector } from '#utils/common'; +import { + errorMessage, + idSelector, +} from '#utils/common'; type TeamsListItem = NonNullable['results'][number] & { no: string }>; -const defaultFilter: TeamFilter = { search: undefined }; +interface TeamsFilterType extends Omit { + createdAtGte: string | undefined; + createdAtLte: string | undefined; +} + +const defaultFilter: TeamsFilterType = { + search: undefined, + createdAtGte: undefined, + createdAtLte: undefined, +}; function Teams() { const { @@ -57,6 +70,10 @@ function Teams() { }, filters: { search: filter.search, + createdAt: (filter.createdAtGte || filter.createdAtLte) ? { + gte: filter.createdAtGte, + lte: filter.createdAtLte, + } : undefined, }, }), [limit, offset, filter]); @@ -75,10 +92,15 @@ function Teams() { const onDeleteClick = useCallback( (id: string) => { deleteTeam({ id }).then((resp) => { - if (resp.data?.deleteTeam) { + const result = resp.data?.deleteTeam; + if (result && 'ok' in result && result.ok) { reExecuteQuery(); alert.show('Team deleted successfully', { variant: 'success' }); + } else { + alert.show(errorMessage, { variant: 'danger' }); } + }).catch((error) => { + alert.show(error ?? errorMessage, { variant: 'danger' }); }); }, [deleteTeam, reExecuteQuery, alert], @@ -142,12 +164,26 @@ function Teams() { heading="Teams" headerDescription="Manage a dedicated team committed to delivering impactful solutions" filters={( - + <> + + + + )} footerActions={( item.key as UserRole; - function UserForm() { const { id } = useParams(); const navigate = useRouting(); @@ -256,7 +254,7 @@ function UserForm() { value={value.role} onChange={setFieldValue} options={roleOptions} - keySelector={roleKeySelector} + keySelector={keySelector} labelSelector={labelSelector} error={error?.role} disabled={pending} diff --git a/app/views/Users/query.ts b/app/views/Users/query.ts index fc9699b..3b4adda 100644 --- a/app/views/Users/query.ts +++ b/app/views/Users/query.ts @@ -109,6 +109,10 @@ const ENUMS = gql` key label } + TeamMemberSex { + key + label + } } } `; diff --git a/backend b/backend index dc06956..5235e51 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit dc069569f4eda7d3f3c64d8212da176d170cf66b +Subproject commit 5235e5173a223dfbd2e0219176ea8a65d265b479 From bc3cfc54a3c9eb4646dc1ef3660a7bc1bc1244fb Mon Sep 17 00:00:00 2001 From: Shreeyash Shrestha Date: Thu, 18 Jun 2026 15:07:47 +0545 Subject: [PATCH 3/3] feat(team-members): add RegionSearchMultiSelectInput and complete TeamMemberslist filter --- .../RegionSearchMultiSelectInput/index.tsx | 107 ++++++++++++++++++ .../TeamMembers/TeamMembersFilters/index.tsx | 45 +++++--- app/views/Teams/TeamMembers/index.tsx | 4 + backend | 2 +- 4 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 app/components/RegionSearchMultiSelectInput/index.tsx diff --git a/app/components/RegionSearchMultiSelectInput/index.tsx b/app/components/RegionSearchMultiSelectInput/index.tsx new file mode 100644 index 0000000..c2ebeac --- /dev/null +++ b/app/components/RegionSearchMultiSelectInput/index.tsx @@ -0,0 +1,107 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + SearchMultiSelectInput, + type SearchMultiSelectInputProps, +} from '@ifrc-go/ui'; +import { unique } from '@togglecorp/fujs'; + +import { + AdminAreaLevel, + type AdminAreasQuery, + useAdminAreasQuery, +} from '#generated/types/graphql'; +import { + idSelector, + nameSelector, + type REGION_LEVEL, + type WOREDA_LEVEL, + type ZONE_LEVEL, +} from '#utils/common'; + +export type AdminAreaItem = NonNullable['results']>[number]; + +type Def = { containerClassName?: string; }; +type RegionSearchMultiSelectInputProps = SearchMultiSelectInputProps< + string, + NAME, + AdminAreaItem, + Def, + 'onSearchValueChange' | 'searchOptions' | 'optionsPending' + | 'keySelector' | 'labelSelector' | 'totalOptionsCount' | 'onShowDropdownChange' + | 'selectedOnTop' +> & { + level: REGION_LEVEL | ZONE_LEVEL | WOREDA_LEVEL; +}; + +function RegionSearchMultiSelectInput( + props: RegionSearchMultiSelectInputProps, +) { + const { + className, + name, + value, + onChange, + onOptionsChange, + level = AdminAreaLevel.Region, + disabled, + readOnly, + ...otherProps + } = props; + + const [opened, setOpened] = useState(false); + + const [{ data: adminAreasData, fetching }] = useAdminAreasQuery({ + pause: !opened, + variables: { + filters: { + level, + }, + }, + }); + + const regionOptions = useMemo( + () => adminAreasData?.adminAreas?.results ?? [], + [adminAreasData?.adminAreas?.results], + ); + + const handleSelectAllClick = useCallback(() => { + const allIds = regionOptions.map(idSelector); + if (allIds.length > 0) { + onChange(allIds, name); + if (onOptionsChange) { + onOptionsChange((existingOptions) => { + const safeOptions = existingOptions ?? []; + return unique([...safeOptions, ...regionOptions], idSelector); + }); + } + } + }, [regionOptions, onChange, name, onOptionsChange]); + + return ( + + ); +} + +export default RegionSearchMultiSelectInput; diff --git a/app/views/Teams/TeamMembers/TeamMembersFilters/index.tsx b/app/views/Teams/TeamMembers/TeamMembersFilters/index.tsx index c36e779..6ba76ae 100644 --- a/app/views/Teams/TeamMembers/TeamMembersFilters/index.tsx +++ b/app/views/Teams/TeamMembers/TeamMembersFilters/index.tsx @@ -1,7 +1,12 @@ +import { useState } from 'react'; import { TextInput } from '@ifrc-go/ui'; import type { EntriesAsList } from '@togglecorp/toggle-form'; -import { type TeamMemberFilter } from '#generated/types/graphql'; +import RegionSearchMultiSelectInput, { type AdminAreaItem } from '#components/RegionSearchMultiSelectInput'; +import { + AdminAreaLevel, + type TeamMemberFilter, +} from '#generated/types/graphql'; interface Props { value: TeamMemberFilter @@ -11,24 +16,30 @@ interface Props { function TeamMembersFilters(props: Props) { const { value, onChange } = props; + const [teamMemberOptions, setTeamMemberOptions] = useState< + AdminAreaItem[] | undefined | null + >([]); + return ( <> - {/* - NOTE: we might have to create MultiSelectInput for region - and woredas input - - - */} + +