From b1f3f56e2cafbda1871ef1a328f6de3277a69ee8 Mon Sep 17 00:00:00 2001 From: stasel <2033301+stasel@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:11:28 +0200 Subject: [PATCH] Added Volunteer management UI --- CLIENT_STRUCTURE.md | 6 +- README.md | 1 + client/src/components/DropdownSelect.tsx | 4 + client/src/data/types/ProfileType.ts | 8 +- .../src/features/interactions/Interaction.ts | 10 +- .../profile-picture/data/mutations.ts | 6 +- .../search/components/SearchResultsList.tsx | 2 +- .../features/search/models/search-result.ts | 1 + client/src/features/volunteers/Volunteer.ts | 55 ++++++ .../volunteers/VolunteerProfilePage.tsx | 34 ++++ .../features/volunteers/VolunteerStatus.ts | 12 ++ .../volunteers/VolunteersOverviewPage.tsx | 44 +++++ .../features/volunteers/VolunteersPage.tsx | 20 --- client/src/features/volunteers/api/api.ts | 36 ++++ client/src/features/volunteers/api/mapper.ts | 49 ++++++ client/src/features/volunteers/api/types.ts | 34 ++++ .../components/AddVolunteerDialog.tsx | 156 ++++++++++++++++++ .../components/VolunteerStatusChip.tsx | 17 ++ .../volunteers/components/VolunteersTable.tsx | 51 ++++++ client/src/features/volunteers/data/keys.ts | 6 + .../src/features/volunteers/data/mutations.ts | 25 +++ .../volunteers/data/volunteer-queries.ts | 27 +++ .../volunteers/profile/VolunteerHeader.tsx | 58 +++++++ .../volunteers/profile/VolunteerInfo.tsx | 111 +++++++++++++ .../volunteers/profile/VolunteerProfile.tsx | 99 +++++++++++ client/src/layout/Sidebar/constants.ts | 11 +- client/src/routes/routes.tsx | 13 +- 27 files changed, 862 insertions(+), 34 deletions(-) create mode 100644 client/src/features/volunteers/Volunteer.ts create mode 100644 client/src/features/volunteers/VolunteerProfilePage.tsx create mode 100644 client/src/features/volunteers/VolunteerStatus.ts create mode 100644 client/src/features/volunteers/VolunteersOverviewPage.tsx delete mode 100644 client/src/features/volunteers/VolunteersPage.tsx create mode 100644 client/src/features/volunteers/api/api.ts create mode 100644 client/src/features/volunteers/api/mapper.ts create mode 100644 client/src/features/volunteers/api/types.ts create mode 100644 client/src/features/volunteers/components/AddVolunteerDialog.tsx create mode 100644 client/src/features/volunteers/components/VolunteerStatusChip.tsx create mode 100644 client/src/features/volunteers/components/VolunteersTable.tsx create mode 100644 client/src/features/volunteers/data/keys.ts create mode 100644 client/src/features/volunteers/data/mutations.ts create mode 100644 client/src/features/volunteers/data/volunteer-queries.ts create mode 100644 client/src/features/volunteers/profile/VolunteerHeader.tsx create mode 100644 client/src/features/volunteers/profile/VolunteerInfo.tsx create mode 100644 client/src/features/volunteers/profile/VolunteerProfile.tsx diff --git a/CLIENT_STRUCTURE.md b/CLIENT_STRUCTURE.md index de69a096..bed70359 100644 --- a/CLIENT_STRUCTURE.md +++ b/CLIENT_STRUCTURE.md @@ -100,11 +100,11 @@ features/ ├── interactions/ # Interactions tab of the trainee and organisation profiles ├── login/ # Login feature ├── organisations/ # Organisations list, the dialog to add one, and the organisation profile -├── profile-picture/ # Picture of the trainee and organisation profiles +├── profile-picture/ # Picture of the trainee, organisation and volunteer profiles ├── search/ # Search feature ├── trainee-profile/ # Trainee profile feature (see detailed structure below) ├── trainees/ # Trainees list, grouped by cohort (see below) -└── volunteers/ # Volunteers page, a placeholder for now +└── volunteers/ # Volunteers list, the dialog to add one, and the volunteer profile ``` ### `/src/layout` @@ -179,7 +179,7 @@ trainee-profile/ └── create/ # Dialog to add a trainee ``` -**Profile tabs:** the profile (`TraineeProfile`, `OrganisationProfile`) owns the page padding, so the tabs have none. A tab is a stack of `ProfileSection`s from `components/profile/`; lay out fields in `FieldRow`s with `ProfileTextField`, `ProfileSelect` and the fields a single profile adds (like `ProfileDateField` for trainees and `ProfileUserPicker` for organisations), so every field gets the same width, spacing, and read-only and edit behavior. Until the profile is edited, a field is its label and value as text, not a read-only input; while editing it is a small outlined input with the label above it, in the same place. Use `` when the fields should share the width of the row instead. +**Profile tabs:** the profile (`TraineeProfile`, `OrganisationProfile`, `VolunteerProfile`) owns the page padding, so the tabs have none. A tab is a stack of `ProfileSection`s from `components/profile/`; lay out fields in `FieldRow`s with `ProfileTextField`, `ProfileSelect` and the fields a single profile adds (like `ProfileDateField` for trainees and `ProfileUserPicker` for organisations), so every field gets the same width, spacing, and read-only and edit behavior. Until the profile is edited, a field is its label and value as text, not a read-only input; while editing it is a small outlined input with the label above it, in the same place. Use `` when the fields should share the width of the row instead. ### Example: Trainees Feature diff --git a/README.md b/README.md index ee925497..3de1bc7f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Dojo is HackYourFuture's in-house management tool designed to track HackYourFutu - Generate real-time reports - Trainees overview, grouped by cohort - Partner organisations overview, with a profile to view and update each one +- Volunteers overview, with a profile to view and update each one - Secure authentication using Google OAuth ## Technology Stack diff --git a/client/src/components/DropdownSelect.tsx b/client/src/components/DropdownSelect.tsx index 05991d5c..8cd74768 100644 --- a/client/src/components/DropdownSelect.tsx +++ b/client/src/components/DropdownSelect.tsx @@ -10,6 +10,8 @@ type DropdownSelectProps = { options: SelectOption[]; disabled?: boolean; error?: string; + // Adds an empty option, which clears the field. + nullable?: boolean; onChange: (event: SelectChangeEvent) => void; width?: string | number; }; @@ -23,6 +25,7 @@ export const DropdownSelect = ({ options, disabled = false, error, + nullable = false, onChange, width = '25ch', }: DropdownSelectProps) => { @@ -41,6 +44,7 @@ export const DropdownSelect = ({ error={!!error} onChange={onChange} > + {nullable && -} {options.map((option) => ( {option.label} diff --git a/client/src/data/types/ProfileType.ts b/client/src/data/types/ProfileType.ts index 6b57c404..02af2e01 100644 --- a/client/src/data/types/ProfileType.ts +++ b/client/src/data/types/ProfileType.ts @@ -1,5 +1,9 @@ // The kinds of profile, like the server's ProfileType. -export type ProfileType = 'trainee' | 'organisation'; +export type ProfileType = 'trainee' | 'organisation' | 'volunteer'; // The path of each kind of profile in the API. -export const PROFILE_PATHS: Record = { trainee: 'trainees', organisation: 'organisations' }; +export const PROFILE_PATHS: Record = { + trainee: 'trainees', + organisation: 'organisations', + volunteer: 'volunteers', +}; diff --git a/client/src/features/interactions/Interaction.ts b/client/src/features/interactions/Interaction.ts index 04f7f8ca..67172026 100644 --- a/client/src/features/interactions/Interaction.ts +++ b/client/src/features/interactions/Interaction.ts @@ -30,7 +30,7 @@ export enum InteractionType { Other = 'other', } -// The types each profile offers when adding or editing an interaction. The server accepts every type on both. +// The types each profile offers when adding or editing an interaction. The server accepts every type on every profile. export const INTERACTION_TYPES: Record = { trainee: [ InteractionType.Call, @@ -53,4 +53,12 @@ export const INTERACTION_TYPES: Record = { InteractionType.InPerson, InteractionType.Other, ], + volunteer: [ + InteractionType.Call, + InteractionType.Chat, + InteractionType.Email, + InteractionType.Feedback, + InteractionType.InPerson, + InteractionType.Other, + ], }; diff --git a/client/src/features/profile-picture/data/mutations.ts b/client/src/features/profile-picture/data/mutations.ts index fb59f327..75c7bf4d 100644 --- a/client/src/features/profile-picture/data/mutations.ts +++ b/client/src/features/profile-picture/data/mutations.ts @@ -8,11 +8,13 @@ import { cropPicture } from '../utils/pictureHelper'; import { organisationKeys } from '../../organisations/data/keys'; import { searchKeys } from '../../search/data/keys'; import { traineeKeys } from '../../trainee-profile/data/keys'; +import { volunteerKeys } from '../../volunteers/data/keys'; // Where each kind of profile is cached. const PROFILE_KEYS: Record QueryKey> = { trainee: traineeKeys.details, organisation: organisationKeys.details, + volunteer: volunteerKeys.details, }; const updatePictureQueries = ( @@ -31,7 +33,7 @@ const updatePictureQueries = ( queryClient.resetQueries({ queryKey: searchKeys.all() }); }; -/** Hook to crop a new picture for a trainee or an organisation and upload it. */ +/** Hook to crop a new picture for a profile and upload it. */ export const useUploadPicture = (profileType: ProfileType, profileId: string) => { const queryClient = useQueryClient(); @@ -43,7 +45,7 @@ export const useUploadPicture = (profileType: ProfileType, profileId: string) => }); }; -/** Hook to delete the picture of a trainee or an organisation. */ +/** Hook to delete the picture of a profile. */ export const useDeletePicture = (profileType: ProfileType, profileId: string) => { const queryClient = useQueryClient(); diff --git a/client/src/features/search/components/SearchResultsList.tsx b/client/src/features/search/components/SearchResultsList.tsx index 50167877..ae2df2f2 100644 --- a/client/src/features/search/components/SearchResultsList.tsx +++ b/client/src/features/search/components/SearchResultsList.tsx @@ -13,7 +13,7 @@ interface SearchResultsListProps { query: string; } -/** The search results for the query, each a link that opens its trainee or organisation. */ +/** The search results for the query, each a link that opens its profile. */ const SearchResultsList = ({ query }: SearchResultsListProps) => { const { isLoading, data, error } = useGetSearchResults(query); diff --git a/client/src/features/search/models/search-result.ts b/client/src/features/search/models/search-result.ts index 8fc4551b..36e40c07 100644 --- a/client/src/features/search/models/search-result.ts +++ b/client/src/features/search/models/search-result.ts @@ -1,6 +1,7 @@ // The kinds of records the search finds. export enum SearchResultType { Trainee = 'trainee', + Volunteer = 'volunteer', Organisation = 'organisation', ContactPerson = 'contact-person', } diff --git a/client/src/features/volunteers/Volunteer.ts b/client/src/features/volunteers/Volunteer.ts new file mode 100644 index 00000000..5f923376 --- /dev/null +++ b/client/src/features/volunteers/Volunteer.ts @@ -0,0 +1,55 @@ +import { Gender } from '../../data/types/Person'; +import { VolunteerStatus } from './VolunteerStatus'; + +export interface VolunteerSummary { + readonly id: string; + displayName: string; + profilePath: string; + thumbnailUrl: string | null; + status: VolunteerStatus; + companyName: string | null; + jobRole: string | null; + email: string; + githubHandle: string | null; + slackId: string | null; + linkedinUrl: string | null; +} + +export interface Volunteer extends VolunteerSummary { + pictureUrl: string | null; + firstName: string; + lastName: string; + gender: Gender | null; + pronouns: string | null; + phone: string | null; + notes: string | null; +} + +// The fields of the profile that can be edited. +export const EDITABLE_VOLUNTEER_FIELDS = [ + 'firstName', + 'lastName', + 'status', + 'gender', + 'pronouns', + 'companyName', + 'jobRole', + 'email', + 'phone', + 'slackId', + 'githubHandle', + 'linkedinUrl', + 'notes', +] as const; + +export type EditableVolunteerField = (typeof EDITABLE_VOLUNTEER_FIELDS)[number]; + +export type VolunteerChanges = Partial>; + +// The fields of the add dialog, where the gender is null until one is picked. +export interface NewVolunteer { + firstName: string; + lastName: string; + gender: Gender | null; + email: string; +} diff --git a/client/src/features/volunteers/VolunteerProfilePage.tsx b/client/src/features/volunteers/VolunteerProfilePage.tsx new file mode 100644 index 00000000..e0239c84 --- /dev/null +++ b/client/src/features/volunteers/VolunteerProfilePage.tsx @@ -0,0 +1,34 @@ +import { ErrorBox, Loader } from '../../components'; + +import { Box } from '@mui/material'; +import VolunteerProfile from './profile/VolunteerProfile'; +import { useGetVolunteer } from './data/volunteer-queries'; +import { useParams } from 'react-router'; + +/** The profile page of a volunteer. */ +const VolunteerProfilePage = () => { + // The name and the id of the volunteer, joined by an underscore. A bare id works too. + const { volunteerInfo } = useParams(); + const volunteerId = volunteerInfo?.split('_').pop() ?? ''; + const { isLoading, data, isError, error, isFetching } = useGetVolunteer(volunteerId); + + // Show spinner only for the first load + if ((isLoading || isFetching) && data === undefined) { + return ; + } + + if (isError && error instanceof Error) { + return ( + + + + ); + } + + if (data) { + // Keyed by volunteer, so going straight from one volunteer to another does not keep the previous one's state. + return ; + } +}; + +export default VolunteerProfilePage; diff --git a/client/src/features/volunteers/VolunteerStatus.ts b/client/src/features/volunteers/VolunteerStatus.ts new file mode 100644 index 00000000..036c5f6a --- /dev/null +++ b/client/src/features/volunteers/VolunteerStatus.ts @@ -0,0 +1,12 @@ +// Where the volunteer stands with HackYourFuture. +export enum VolunteerStatus { + Active = 'active', + Paused = 'paused', + Stopped = 'stopped', +} + +export const volunteerStatusLabels: Record = { + [VolunteerStatus.Active]: 'Active', + [VolunteerStatus.Paused]: 'Paused', + [VolunteerStatus.Stopped]: 'Stopped', +}; diff --git a/client/src/features/volunteers/VolunteersOverviewPage.tsx b/client/src/features/volunteers/VolunteersOverviewPage.tsx new file mode 100644 index 00000000..ebb72845 --- /dev/null +++ b/client/src/features/volunteers/VolunteersOverviewPage.tsx @@ -0,0 +1,44 @@ +import { Box, Button, Container, Typography } from '@mui/material'; + +import AddIcon from '@mui/icons-material/Add'; +import { AddVolunteerDialog } from './components/AddVolunteerDialog'; +import { ListLoader } from '../../components/ListLoader'; +import { VolunteersTable } from './components/VolunteersTable'; +import { useGetVolunteers } from './data/volunteer-queries'; +import { usePageTitle } from '../../hooks/usePageTitle'; +import { useState } from 'react'; + +/** The volunteers page, with the volunteers ordered by first name. */ +const VolunteersOverviewPage = () => { + usePageTitle('Volunteers'); + + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + + const volunteersQuery = useGetVolunteers(); + const { data: volunteers } = volunteersQuery; + + return ( + + + Volunteers + + + + {volunteers && + (volunteers.length > 0 ? ( + + ) : ( + No volunteers yet. + ))} + + + + + setIsAddDialogOpen(false)} /> + + ); +}; + +export default VolunteersOverviewPage; diff --git a/client/src/features/volunteers/VolunteersPage.tsx b/client/src/features/volunteers/VolunteersPage.tsx deleted file mode 100644 index 8129d530..00000000 --- a/client/src/features/volunteers/VolunteersPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ComingSoon } from '../../components'; -import VolunteerOutlinedIcon from '@mui/icons-material/VolunteerActivismOutlined'; -import { usePageTitle } from '../../hooks/usePageTitle'; - -/** - * Component for displaying the volunteers page. A placeholder until the feature is built. - */ -const VolunteersPage = () => { - usePageTitle('Volunteers'); - - return ( - - ); -}; - -export default VolunteersPage; diff --git a/client/src/features/volunteers/api/api.ts b/client/src/features/volunteers/api/api.ts new file mode 100644 index 00000000..6a48d7b9 --- /dev/null +++ b/client/src/features/volunteers/api/api.ts @@ -0,0 +1,36 @@ +import { NewVolunteer, VolunteerChanges } from '../Volunteer'; +import { PAGE_SIZE, PagedModel, mapPageToDomain } from '../../../data/pagination'; +import { VolunteerResponse, VolunteerSummaryResponse } from './types'; +import { + mapDomainToCreateVolunteerRequest, + mapDomainToUpdateVolunteerRequest, + mapVolunteerSummaryToDomain, + mapVolunteerToDomain, +} from './mapper'; + +import axios from 'axios'; + +// Sorted by first name, from A to Z. +export const getVolunteerSummaries = async (page: number) => { + const { data } = await axios.get>('/api/volunteers', { + params: { page, size: PAGE_SIZE }, + }); + return mapPageToDomain(data, mapVolunteerSummaryToDomain); +}; + +export const getVolunteer = async (volunteerId: string) => { + const { data } = await axios.get(`/api/volunteers/${volunteerId}`); + return mapVolunteerToDomain(data); +}; + +export const updateVolunteer = async (volunteerId: string, changes: VolunteerChanges) => { + const volunteerRequest = mapDomainToUpdateVolunteerRequest(changes); + const { data } = await axios.patch(`/api/volunteers/${volunteerId}`, volunteerRequest); + return mapVolunteerToDomain(data); +}; + +export const createVolunteer = async (newVolunteer: NewVolunteer) => { + const volunteerRequest = mapDomainToCreateVolunteerRequest(newVolunteer); + const { data } = await axios.post('/api/volunteers', volunteerRequest); + return mapVolunteerToDomain(data); +}; diff --git a/client/src/features/volunteers/api/mapper.ts b/client/src/features/volunteers/api/mapper.ts new file mode 100644 index 00000000..e064dd9f --- /dev/null +++ b/client/src/features/volunteers/api/mapper.ts @@ -0,0 +1,49 @@ +import { CreateVolunteerRequest, UpdateVolunteerRequest, VolunteerResponse, VolunteerSummaryResponse } from './types'; +import { NewVolunteer, Volunteer, VolunteerChanges, VolunteerSummary } from '../Volunteer'; + +import { VolunteerStatus } from '../VolunteerStatus'; +import { blankFieldsToNull } from '../../../data/text'; + +export const mapVolunteerSummaryToDomain = (volunteer: VolunteerSummaryResponse): VolunteerSummary => { + return { + id: volunteer.id, + displayName: volunteer.displayName, + profilePath: volunteer.profilePath, + thumbnailUrl: volunteer.thumbnailUrl, + status: volunteer.status, + companyName: volunteer.companyName, + jobRole: volunteer.jobRole, + email: volunteer.email, + githubHandle: volunteer.githubHandle, + slackId: volunteer.slackId, + linkedinUrl: volunteer.linkedinUrl, + }; +}; + +export const mapVolunteerToDomain = (volunteer: VolunteerResponse): Volunteer => { + return { + ...mapVolunteerSummaryToDomain(volunteer), + pictureUrl: volunteer.pictureUrl, + firstName: volunteer.firstName, + lastName: volunteer.lastName, + gender: volunteer.gender, + pronouns: volunteer.pronouns, + phone: volunteer.phone, + notes: volunteer.notes, + }; +}; + +export const mapDomainToUpdateVolunteerRequest = (changes: VolunteerChanges): UpdateVolunteerRequest => { + return blankFieldsToNull(changes); +}; + +export const mapDomainToCreateVolunteerRequest = (newVolunteer: NewVolunteer): CreateVolunteerRequest => { + return { + firstName: newVolunteer.firstName, + lastName: newVolunteer.lastName, + gender: newVolunteer.gender, + email: newVolunteer.email, + // A new volunteer starts as active. + status: VolunteerStatus.Active, + }; +}; diff --git a/client/src/features/volunteers/api/types.ts b/client/src/features/volunteers/api/types.ts new file mode 100644 index 00000000..4e22abdd --- /dev/null +++ b/client/src/features/volunteers/api/types.ts @@ -0,0 +1,34 @@ +import { EditableVolunteerField } from '../Volunteer'; +import { Gender } from '../../../data/types/Person'; +import { VolunteerStatus } from '../VolunteerStatus'; + +export interface VolunteerSummaryResponse { + id: string; + displayName: string; + profilePath: string; + thumbnailUrl: string | null; + status: VolunteerStatus; + companyName: string | null; + jobRole: string | null; + email: string; + githubHandle: string | null; + slackId: string | null; + linkedinUrl: string | null; +} + +export interface VolunteerResponse extends VolunteerSummaryResponse { + pictureUrl: string | null; + firstName: string; + lastName: string; + gender: Gender | null; + pronouns: string | null; + phone: string | null; + notes: string | null; +} + +// A merge patch: every field that is sent is stored, and null clears it. +export type UpdateVolunteerRequest = { + [Field in EditableVolunteerField]?: VolunteerResponse[Field] | null; +}; + +export type CreateVolunteerRequest = Pick; diff --git a/client/src/features/volunteers/components/AddVolunteerDialog.tsx b/client/src/features/volunteers/components/AddVolunteerDialog.tsx new file mode 100644 index 00000000..90cbbd59 --- /dev/null +++ b/client/src/features/volunteers/components/AddVolunteerDialog.tsx @@ -0,0 +1,156 @@ +import { ChangeEvent, useState } from 'react'; +import { Gender, genderOptions } from '../../../data/types/Person'; +import { SelectChangeEvent, Stack, TextField } from '@mui/material'; +import { emailValidationError, nameValidationError } from '../../../data/text'; + +import { DropdownSelect } from '../../../components/DropdownSelect'; +import { FormDialog } from '../../../components/FormDialog'; +import { NewVolunteer } from '../Volunteer'; +import { useCreateVolunteer } from '../data/mutations'; +import { useNavigate } from 'react-router'; + +interface AddVolunteerDialogProps { + isOpen: boolean; + handleClose: () => void; +} + +type FormErrors = Partial>; + +const INITIAL_STATE: NewVolunteer = { + firstName: '', + lastName: '', + gender: null, + email: '', +}; + +// The errors of the fields that fail, or null when the form can be sent. +const getFormErrors = (formState: NewVolunteer): FormErrors | null => { + const errors: FormErrors = {}; + const firstNameError = nameValidationError(formState.firstName); + if (firstNameError) { + errors.firstName = firstNameError; + } + const lastNameError = nameValidationError(formState.lastName); + if (lastNameError) { + errors.lastName = lastNameError; + } + const emailError = emailValidationError(formState.email); + if (emailError) { + errors.email = emailError; + } + return Object.keys(errors).length > 0 ? errors : null; +}; + +/** The dialog to add a volunteer, which opens the profile of the new volunteer. */ +export const AddVolunteerDialog = ({ isOpen, handleClose }: AddVolunteerDialogProps) => { + const navigate = useNavigate(); + const { mutate: createVolunteer, isPending, error: submitError, reset } = useCreateVolunteer(); + + const [formState, setFormState] = useState(INITIAL_STATE); + const [errors, setErrors] = useState({}); + + // Also forgets the error of the last attempt, so it is not shown the next time the dialog opens. + const onClose = () => { + setFormState(INITIAL_STATE); + setErrors({}); + reset(); + handleClose(); + }; + + const handleTextChange = (event: ChangeEvent) => { + const { name, value } = event.target; + setErrors((prevErrors) => ({ ...prevErrors, [name]: undefined })); + setFormState((prevState) => ({ ...prevState, [name]: value })); + }; + + const handleGenderChange = (event: SelectChangeEvent) => { + // The empty option clears the gender. + const gender = event.target.value ? (event.target.value as Gender) : null; + setFormState((prevState) => ({ ...prevState, gender })); + }; + + const handleSubmit = () => { + const formErrors = getFormErrors(formState); + if (formErrors) { + setErrors(formErrors); + return; + } + + createVolunteer(formState, { + onSuccess: (volunteer) => { + onClose(); + navigate(volunteer.profilePath); + }, + }); + }; + + return ( + + {/* Aligned to the top, so one name's error message does not stretch the other. */} + + + + + {/* Aligned to the top, so the email's error message does not stretch the gender. */} + + + + + + ); +}; diff --git a/client/src/features/volunteers/components/VolunteerStatusChip.tsx b/client/src/features/volunteers/components/VolunteerStatusChip.tsx new file mode 100644 index 00000000..f13ace7d --- /dev/null +++ b/client/src/features/volunteers/components/VolunteerStatusChip.tsx @@ -0,0 +1,17 @@ +import { Chip, ChipProps } from '@mui/material'; +import { VolunteerStatus, volunteerStatusLabels } from '../VolunteerStatus'; + +const STATUS_COLORS: Record = { + [VolunteerStatus.Active]: 'success', + [VolunteerStatus.Paused]: 'warning', + [VolunteerStatus.Stopped]: 'default', +}; + +interface VolunteerStatusChipProps { + status: VolunteerStatus; +} + +/** A chip that shows where the volunteer stands with HackYourFuture. */ +export const VolunteerStatusChip = ({ status }: VolunteerStatusChipProps) => ( + +); diff --git a/client/src/features/volunteers/components/VolunteersTable.tsx b/client/src/features/volunteers/components/VolunteersTable.tsx new file mode 100644 index 00000000..653e27ea --- /dev/null +++ b/client/src/features/volunteers/components/VolunteersTable.tsx @@ -0,0 +1,51 @@ +import { LinkTableRow, RowLinksCell } from '../../../components/LinkTableRow'; +import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; + +import { ContactLinkButtons } from '../../../components/ContactLinkButtons'; +import { PersonAvatar } from '../../../components/PersonAvatar'; +import { VolunteerStatusChip } from './VolunteerStatusChip'; +import { VolunteerSummary } from '../Volunteer'; + +interface VolunteersTableProps { + volunteers: VolunteerSummary[]; +} + +/** The volunteers in a table, where a row opens the profile of its volunteer. */ +export const VolunteersTable = ({ volunteers }: VolunteersTableProps) => { + return ( + + + + + + Name + Status + Company + Job role + + + + + {volunteers.map((volunteer) => ( + + + + + + {volunteer.displayName} + + + + + {volunteer.companyName} + {volunteer.jobRole} + + + + + ))} + +
+
+ ); +}; diff --git a/client/src/features/volunteers/data/keys.ts b/client/src/features/volunteers/data/keys.ts new file mode 100644 index 00000000..e72d1f13 --- /dev/null +++ b/client/src/features/volunteers/data/keys.ts @@ -0,0 +1,6 @@ +const VOLUNTEERS_QUERY_KEY = 'volunteers'; + +export const volunteerKeys = { + list: () => [VOLUNTEERS_QUERY_KEY, 'list'] as const, + details: (volunteerId: string) => [VOLUNTEERS_QUERY_KEY, 'details', volunteerId] as const, +}; diff --git a/client/src/features/volunteers/data/mutations.ts b/client/src/features/volunteers/data/mutations.ts new file mode 100644 index 00000000..d10e5d21 --- /dev/null +++ b/client/src/features/volunteers/data/mutations.ts @@ -0,0 +1,25 @@ +import { NewVolunteer, VolunteerChanges } from '../Volunteer'; +import { createVolunteer, updateVolunteer } from '../api/api'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { volunteerKeys } from './keys'; + +/** Hook to save the edited fields of a volunteer profile. */ +export const useUpdateVolunteer = (volunteerId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (changes: VolunteerChanges) => updateVolunteer(volunteerId, changes), + // The response is the saved volunteer, so the profile shows it without loading it again. + onSuccess: (volunteer) => queryClient.setQueryData(volunteerKeys.details(volunteerId), volunteer), + }); +}; + +/** Hook to create a new volunteer. */ +export const useCreateVolunteer = () => { + return useMutation({ + mutationFn: (newVolunteer: NewVolunteer) => createVolunteer(newVolunteer), + // A failed request may still have created it, and a retry would then fail on the taken email. + retry: false, + }); +}; diff --git a/client/src/features/volunteers/data/volunteer-queries.ts b/client/src/features/volunteers/data/volunteer-queries.ts new file mode 100644 index 00000000..85383e3d --- /dev/null +++ b/client/src/features/volunteers/data/volunteer-queries.ts @@ -0,0 +1,27 @@ +import { getLoadedItems, getNextPageParam } from '../../../data/pagination'; +import { getVolunteer, getVolunteerSummaries } from '../api/api'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; + +import { PROFILE_QUERY_OPTIONS } from '../../../data/tanstack/tanstackClient'; +import { volunteerKeys } from './keys'; + +/** Hook to get the volunteers, page by page. */ +export const useGetVolunteers = () => { + return useInfiniteQuery({ + queryKey: volunteerKeys.list(), + queryFn: ({ pageParam }) => getVolunteerSummaries(pageParam), + initialPageParam: 0, + getNextPageParam, + select: getLoadedItems, + }); +}; + +/** Hook to get the profile of a volunteer. */ +export const useGetVolunteer = (volunteerId: string) => { + return useQuery({ + queryKey: volunteerKeys.details(volunteerId), + queryFn: () => getVolunteer(volunteerId), + enabled: !!volunteerId, + ...PROFILE_QUERY_OPTIONS, + }); +}; diff --git a/client/src/features/volunteers/profile/VolunteerHeader.tsx b/client/src/features/volunteers/profile/VolunteerHeader.tsx new file mode 100644 index 00000000..c115de13 --- /dev/null +++ b/client/src/features/volunteers/profile/VolunteerHeader.tsx @@ -0,0 +1,58 @@ +import { Avatar, Box, Stack, Typography } from '@mui/material'; + +import { EditableProfilePicture } from '../../profile-picture/components/EditableProfilePicture'; +import { SocialLinks } from '../../../components/profile/SocialLinks'; +import { Volunteer } from '../Volunteer'; +import { VolunteerStatusChip } from '../components/VolunteerStatusChip'; + +// About as tall as the three rows of text next to the picture. +const PICTURE_SIZE = 96; + +interface VolunteerHeaderProps { + volunteer: Volunteer; +} + +/** The volunteer's picture, name, status, work and social links above the profile tabs, as they are saved. */ +const VolunteerHeader = ({ volunteer }: VolunteerHeaderProps) => { + const { pronouns, slackId, githubHandle, linkedinUrl } = volunteer; + // Like "Senior Developer at Acme B.V.", or just the one that is filled in. + const work = [volunteer.jobRole, volunteer.companyName].filter(Boolean).join(' at '); + + return ( + + + + + + {/* Spaced with a gap instead of margins, which would override the social links offset. */} + + + + {volunteer.displayName} + + {pronouns && {pronouns}} + + + + {work && ( + + {work} + + )} + + + + + ); +}; + +export default VolunteerHeader; diff --git a/client/src/features/volunteers/profile/VolunteerInfo.tsx b/client/src/features/volunteers/profile/VolunteerInfo.tsx new file mode 100644 index 00000000..9184ab3d --- /dev/null +++ b/client/src/features/volunteers/profile/VolunteerInfo.tsx @@ -0,0 +1,111 @@ +import { Dispatch, SetStateAction } from 'react'; +import { FieldRow, PROFILE_DOUBLE_FIELD_WIDTH, ProfileSection } from '../../../components/profile/ProfileSection'; +import { VolunteerStatus, volunteerStatusLabels } from '../VolunteerStatus'; +import { genderOptions, pronounOptions } from '../../../data/types/Person'; + +import { ProfileNotes } from '../../../components/profile/ProfileNotes'; +import { ProfileSelect } from '../../../components/profile/ProfileSelect'; +import { ProfileTextField } from '../../../components/profile/ProfileTextField'; +import { Stack } from '@mui/material'; +import { Volunteer } from '../Volunteer'; +import { createFieldChangeHandlers } from '../../../components/profile/fieldChangeHandlers'; + +// The rows are four fields wide, and a wide field takes two of them, so the fields line up in columns. +const doubleFieldStyle = { width: PROFILE_DOUBLE_FIELD_WIDTH }; + +// The options of the status dropdown. +const statusOptions = Object.values(VolunteerStatus).map((status) => ({ + label: volunteerStatusLabels[status], + value: status, +})); + +interface VolunteerInfoProps { + volunteer: Volunteer; + setVolunteer: Dispatch>; + isEditing: boolean; +} + +/** The overview tab: the volunteer's details, work and notes, edited with the profile. */ +const VolunteerInfo = ({ volunteer, setVolunteer, isEditing }: VolunteerInfoProps) => { + const { handleValueChange, handleTextChange } = createFieldChangeHandlers(setVolunteer); + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default VolunteerInfo; diff --git a/client/src/features/volunteers/profile/VolunteerProfile.tsx b/client/src/features/volunteers/profile/VolunteerProfile.tsx new file mode 100644 index 00000000..4022fecb --- /dev/null +++ b/client/src/features/volunteers/profile/VolunteerProfile.tsx @@ -0,0 +1,99 @@ +import { Box, Stack, Tab } from '@mui/material'; +import { EDITABLE_VOLUNTEER_FIELDS, Volunteer, VolunteerChanges } from '../Volunteer'; + +import { ContactFields } from '../../../components/profile/ContactFields'; +import { ProfileSection } from '../../../components/profile/ProfileSection'; +import { ProfileTabBar } from '../../../components/profile/ProfileTabBar'; +import VolunteerHeader from './VolunteerHeader'; +import VolunteerInfo from './VolunteerInfo'; +import { createFieldChangeHandlers } from '../../../components/profile/fieldChangeHandlers'; +import { usePageTitle } from '../../../hooks/usePageTitle'; +import { useProfileSave } from '../../../hooks/useProfileSave'; +import { useState } from 'react'; +import { useUpdateVolunteer } from '../data/mutations'; + +// The fields that were changed while editing, with their new values. +const getChangedFields = (original: Volunteer, edited: Volunteer): VolunteerChanges => { + const changedFields = EDITABLE_VOLUNTEER_FIELDS.filter((field) => original[field] !== edited[field]); + return Object.fromEntries(changedFields.map((field) => [field, edited[field]])); +}; + +interface VolunteerProfileProps { + volunteer: Volunteer; +} + +/** The volunteer profile: the header, the tabs and the content of the active tab. */ +const VolunteerProfile = ({ volunteer }: VolunteerProfileProps) => { + const [activeTab, setActiveTab] = useState('overview'); + const [isEditMode, setIsEditMode] = useState(false); + // A copy of the volunteer with the changes made while editing. + const [editedVolunteer, setEditedVolunteer] = useState(volunteer); + const { isPending: isSaveLoading, mutate: updateVolunteer } = useUpdateVolunteer(volunteer.id); + + const { saveChanges, saveResultSnackbar } = useProfileSave('Volunteer', updateVolunteer); + usePageTitle(volunteer.displayName); + + // Starts editing, or saves the changes while editing. + const onClickEditButton = () => { + if (!isEditMode) { + // A colleague's change may have been loaded since the page opened, so start from the latest data. + setEditedVolunteer(volunteer); + setIsEditMode(true); + return; + } + + saveChanges(getChangedFields(volunteer, editedVolunteer), () => setIsEditMode(false)); + }; + + // Stops editing and drops the changes. + const onCancelEdit = () => { + setIsEditMode(false); + }; + + // For the contact tab, which has only text fields. + const { handleTextChange } = createFieldChangeHandlers(setEditedVolunteer); + + return ( + // The tabs have no padding of their own, so everything lines up with the header. + + + + + + + + + + {saveResultSnackbar} + + {/* The interactions tab stays empty until volunteer interactions are built. */} + + {activeTab === 'overview' && ( + + )} + {activeTab === 'contact' && ( + + + + )} + + + ); +}; + +export default VolunteerProfile; diff --git a/client/src/layout/Sidebar/constants.ts b/client/src/layout/Sidebar/constants.ts index 27d33960..995c0dfd 100644 --- a/client/src/layout/Sidebar/constants.ts +++ b/client/src/layout/Sidebar/constants.ts @@ -11,8 +11,7 @@ import SchoolOutlinedIcon from '@mui/icons-material/SchoolOutlined'; import { SvgIcon } from '@mui/material'; import UsersIcon from '@mui/icons-material/ManageAccounts'; import UsersOutlinedIcon from '@mui/icons-material/ManageAccountsOutlined'; -import VolunteerIcon from '@mui/icons-material/VolunteerActivism'; -import VolunteerOutlinedIcon from '@mui/icons-material/VolunteerActivismOutlined'; +import Diversity3Icon from '@mui/icons-material/Diversity3'; export const SIDEBAR_WIDTH = 240; @@ -44,7 +43,13 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [ activeIcon: SchoolIcon, relatedPaths: ['/trainee'], }, - { name: 'Volunteers', path: '/volunteers', icon: VolunteerOutlinedIcon, activeIcon: VolunteerIcon }, + { + name: 'Volunteers', + path: '/volunteers', + icon: Diversity3Icon, + activeIcon: Diversity3Icon, + relatedPaths: ['/volunteer'], + }, { name: 'Organisations', path: '/organisations', diff --git a/client/src/routes/routes.tsx b/client/src/routes/routes.tsx index 836d53ba..b340f9aa 100644 --- a/client/src/routes/routes.tsx +++ b/client/src/routes/routes.tsx @@ -11,7 +11,8 @@ import SearchPage from '../features/search/SearchPage'; import TraineePage from '../features/trainee-profile/TraineePage'; import TraineesPage from '../features/trainees/TraineesPage'; import UsersPage from '../features/admin/users/UsersPage'; -import VolunteersPage from '../features/volunteers/VolunteersPage'; +import VolunteerProfilePage from '../features/volunteers/VolunteerProfilePage'; +import VolunteersOverviewPage from '../features/volunteers/VolunteersOverviewPage'; export const router = createBrowserRouter([ { @@ -45,7 +46,7 @@ export const router = createBrowserRouter([ path: '/volunteers', element: ( - + ), }, @@ -97,6 +98,14 @@ export const router = createBrowserRouter([ ), }, + { + path: '/volunteer/:volunteerInfo', + element: ( + + + + ), + }, { path: '/login', element: ,