diff --git a/src/apps/accounts/src/settings/tabs/account/AccountTab.tsx b/src/apps/accounts/src/settings/tabs/account/AccountTab.tsx index 39999ac33..c39f5ac26 100644 --- a/src/apps/accounts/src/settings/tabs/account/AccountTab.tsx +++ b/src/apps/accounts/src/settings/tabs/account/AccountTab.tsx @@ -2,9 +2,7 @@ import { FC } from 'react' import { UserProfile, UserTraits } from '~/libs/core' -import { AccountRole } from './account-role' import { UserAndPassword } from './user-and-pass' -import { MemberAddress } from './address' import styles from './AccountTab.module.scss' interface AccountTabProps { @@ -15,12 +13,7 @@ interface AccountTabProps { const AccountTab: FC = (props: AccountTabProps) => (

ACCOUNT INFORMATION

- - - - -
) diff --git a/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.module.scss b/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.module.scss deleted file mode 100644 index 0aacacdd7..000000000 --- a/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.module.scss +++ /dev/null @@ -1,61 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.container { - margin: $sp-8 0; - - .content { - display: grid; - grid-template-columns: repeat(2, 1fr); - margin-bottom: 0; - - @include ltelg { - grid-template-columns: 1fr; - } - - > p { - max-width: 380px; - } - - form { - display: flex; - justify-self: flex-end; - - @include ltelg { - flex-direction: column; - justify-self: auto; - margin-top: $sp-4; - } - - .formControlWrap { - border: 1px solid $black-20; - border-radius: 8px; - padding: $sp-6 $sp-4; - min-width: 286px; - display: flex; - justify-content: space-between; - margin-right: $sp-8; - - @include ltelg { - margin-right: 0; - margin-bottom: $sp-4; - } - - &:last-child { - margin-right: 0; - } - - label { - font-weight: $font-weight-medium; - } - - input { - cursor: pointer; - } - } - } - } -} - -:global(.react-responsive-modal-closeButton) { - display: none; -} \ No newline at end of file diff --git a/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.tsx b/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.tsx deleted file mode 100644 index 0eac482d3..000000000 --- a/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Dispatch, FC, SetStateAction, useState } from 'react' - -import { BaseModal, Button, Collapsible } from '~/libs/ui' -import { authUrlLogout, updatePrimaryMemberRoleAsync, UserProfile } from '~/libs/core' - -import styles from './AccountRole.module.scss' - -interface AccountRoleProps { - profile: UserProfile -} -// TODO: move to libs/core after discussion -// we need to have uniq list of TC roles -enum AccountRoles { - CUSTOMER = 'Topcoder Customer', - TALENT = 'Topcoder Talent' -} - -const AccountRole: FC = (props: AccountRoleProps) => { - const [memberRole, setMemberRole]: [string, Dispatch>] - = useState( - props.profile.roles.includes(AccountRoles.CUSTOMER) ? AccountRoles.CUSTOMER : AccountRoles.TALENT, - ) - - const [isUpdating, setIsUpdating]: [boolean, Dispatch>] = useState(false) - - const [isRoleChangeConfirmed, setIsRoleChangeConfirmed]: [boolean, Dispatch>] - = useState(false) - - function handleRoleChange(): void { - const newRole: string = memberRole === AccountRoles.CUSTOMER ? AccountRoles.TALENT : AccountRoles.CUSTOMER - - if (!isUpdating) { - setIsUpdating(true) - updatePrimaryMemberRoleAsync(newRole) - .then(() => { - setMemberRole(newRole) - setIsRoleChangeConfirmed(true) - }) - .finally(() => { - setIsUpdating(false) - }) - } - } - - function handleSignOut(): void { - window.location.href = authUrlLogout - } - - return ( - Account Role} - containerClass={styles.container} - contentClass={styles.content} - > -

- Access to Topcoder tools and applications are based on your account role. - If you change this setting, you will be required to sign out of your account and login. -

- -
-
- - -
-
- - -
-
- - { - isRoleChangeConfirmed && ( - { }} - buttons={} - > -

- You have successfully changed your account role. - Please sign out of your account and login to complete this update. -

-
- ) - } - -
- ) -} - -export default AccountRole diff --git a/src/apps/accounts/src/settings/tabs/account/account-role/index.ts b/src/apps/accounts/src/settings/tabs/account/account-role/index.ts deleted file mode 100644 index eafa05f37..000000000 --- a/src/apps/accounts/src/settings/tabs/account/account-role/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as AccountRole } from './AccountRole' diff --git a/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.module.scss b/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.module.scss deleted file mode 100644 index 32ea795a5..000000000 --- a/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.module.scss +++ /dev/null @@ -1,27 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.container { - margin: $sp-8 0; - - .content { - display: grid; - grid-template-columns: repeat(2, 1fr); - margin-bottom: 0; - - @include ltelg { - grid-template-columns: 1fr; - } - - >p { - max-width: 380px; - } - - .form { - .formCTAs { - margin-top: $sp-4; - padding-top: $sp-4; - border-top: 2px solid $black-10; - } - } - } -} \ No newline at end of file diff --git a/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.tsx b/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.tsx deleted file mode 100644 index f99f43989..000000000 --- a/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { Dispatch, FC, SetStateAction, useMemo, useState } from 'react' -import { toast } from 'react-toastify' -import { bind, trim } from 'lodash' -import classNames from 'classnames' - -import { - Button, - Collapsible, InputSelect, InputText, -} from '~/libs/ui' -import { - CountryLookup, - updateMemberProfileAsync, - useCountryLookup, - UserProfile, -} from '~/libs/core' - -import styles from './MemberAddress.module.scss' - -interface MemberAddressProps { - profile: UserProfile -} - -const MemberAddress: FC = (props: MemberAddressProps) => { - const countryLookup: CountryLookup[] | undefined - = useCountryLookup() - - const contries = useMemo(() => (countryLookup || []).map((cl: CountryLookup) => ({ - label: cl.country, - value: cl.countryCode, - })) - .sort((a, b) => a.label.localeCompare(b.label)), [countryLookup]) - - const [formValues, setFormValues]: [any, Dispatch] = useState({ - country: props.profile.homeCountryCode || props.profile.competitionCountryCode, - ...props.profile.addresses ? props.profile.addresses[0] : {}, - }) - - const [formErrors, setFormErrors]: [ - { [key: string]: string }, - Dispatch> - ] - = useState<{ [key: string]: string }>({}) - - const [isSaving, setIsSaving]: [boolean, Dispatch>] - = useState(false) - - const [isFormChanged, setIsFormChanged]: [boolean, Dispatch>] - = useState(false) - - function handleFormValueChange(key: string, event: React.ChangeEvent): void { - const oldFormValues = { ...formValues } - - setFormValues({ - ...oldFormValues, - [key]: event.target.value, - }) - setIsFormChanged(true) - } - - function handleFormAction(): void { - if (!trim(formValues.city)) { - setFormErrors({ city: 'Please select a city' }) - return - } - - if (!formValues.country) { - setFormErrors({ country: 'Please select a country' }) - return - } - - setIsSaving(true) - - updateMemberProfileAsync( - props.profile.handle, - { - addresses: [{ - city: trim(formValues.city), - stateCode: trim(formValues.stateCode), - streetAddr1: trim(formValues.streetAddr1), - streetAddr2: trim(formValues.streetAddr2), - zip: trim(formValues.zip), - }], - competitionCountryCode: formValues.country, - homeCountryCode: formValues.country, - }, - ) - .then(() => { - toast.success('Your account has been updated.', { position: toast.POSITION.BOTTOM_RIGHT }) - setFormErrors({}) - }) - .catch(() => { - toast.error('Something went wrong. Please try again.', { position: toast.POSITION.BOTTOM_RIGHT }) - }) - .finally(() => { - setIsFormChanged(false) - setIsSaving(false) - }) - } - - return ( - Address} - containerClass={styles.container} - contentClass={styles.content} - > -

- By keeping this information up to date we may surprise you with a cool T-shirt. - Sharing your contact details will never result in robocalls about health insurance plans or junk mail. -

- -
-
- - - - - - - -
-
-
-
-
- ) -} - -export default MemberAddress diff --git a/src/apps/accounts/src/settings/tabs/account/address/index.ts b/src/apps/accounts/src/settings/tabs/account/address/index.ts deleted file mode 100644 index 33f8c6ff5..000000000 --- a/src/apps/accounts/src/settings/tabs/account/address/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as MemberAddress } from './MemberAddress' diff --git a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx index 79bcb4295..ee63a4a90 100644 --- a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx +++ b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx @@ -44,6 +44,7 @@ export const TableMobile: ( {...itemItemColumns} data={itemData} index={indexData} + allRows={props.data} key={getKey([ indexData, indexColumns, diff --git a/src/apps/copilots/src/services/copilot-opportunities.ts b/src/apps/copilots/src/services/copilot-opportunities.ts index 07292b9c7..3446a9ef6 100644 --- a/src/apps/copilots/src/services/copilot-opportunities.ts +++ b/src/apps/copilots/src/services/copilot-opportunities.ts @@ -8,7 +8,7 @@ import { buildUrl } from '~/libs/shared/lib/utils/url' import { CopilotOpportunity } from '../models/CopilotOpportunity' import { CopilotApplication } from '../models/CopilotApplication' -export const copilotBaseUrl = `${EnvironmentConfig.API.V5}/projects` +export const copilotBaseUrl = `${EnvironmentConfig.API.V6}/projects` const PAGE_SIZE = 20 diff --git a/src/apps/copilots/src/services/copilot-requests.ts b/src/apps/copilots/src/services/copilot-requests.ts index 4987089b4..270452f23 100644 --- a/src/apps/copilots/src/services/copilot-requests.ts +++ b/src/apps/copilots/src/services/copilot-requests.ts @@ -9,7 +9,7 @@ import { getPaginatedAsync, PaginatedResponse } from '~/libs/core/lib/xhr/xhr-fu import { CopilotRequest } from '../models/CopilotRequest' -const baseUrl = `${EnvironmentConfig.API.V5}/projects` +const baseUrl = `${EnvironmentConfig.API.V6}/projects` const PAGE_SIZE = 20 /** diff --git a/src/apps/copilots/src/services/projects.ts b/src/apps/copilots/src/services/projects.ts index 2c31d88b7..eed8d8f54 100644 --- a/src/apps/copilots/src/services/projects.ts +++ b/src/apps/copilots/src/services/projects.ts @@ -7,7 +7,7 @@ import { EnvironmentConfig } from '~/config' import { Project } from '../models/Project' -const baseUrl = `${EnvironmentConfig.API.V5}/projects` +const baseUrl = `${EnvironmentConfig.API.V6}/projects` export type ProjectsResponse = SWRResponse diff --git a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts new file mode 100644 index 000000000..4c1041ac4 --- /dev/null +++ b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts @@ -0,0 +1,117 @@ +import { EnvironmentConfig } from '~/config' +import { UserSkill, xhrGetAsync } from '~/libs/core' + +export type CompletedProfile = { + countryCode?: string + countryName?: string + city?: string + firstName?: string + handle: string + lastName?: string + photoURL?: string + skillCount?: number + userId?: number | string +} + +export type CompletedProfilesResponse = { + data: CompletedProfile[] + page: number + perPage: number + total: number + totalPages: number +} + +export const DEFAULT_PAGE_SIZE = 50 + +function normalizeToList(raw: any): any[] { + if (Array.isArray(raw)) { + return raw + } + + if (Array.isArray(raw?.data)) { + return raw.data + } + + if (Array.isArray(raw?.result?.content)) { + return raw.result.content + } + + if (Array.isArray(raw?.result)) { + return raw.result + } + + return [] +} + +function normalizeCompletedProfilesResponse( + raw: any, + fallbackPage: number, + fallbackPerPage: number, +): CompletedProfilesResponse { + if (raw && Array.isArray(raw.data)) { + const total: number = Number(raw.total ?? raw.data.length) + const perPage: number = Number(raw.perPage ?? fallbackPerPage) + const page: number = Number(raw.page ?? fallbackPage) + const safePerPage = Number.isFinite(perPage) ? Math.max(perPage, 1) : fallbackPerPage + const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : raw.data.length + + return { + data: raw.data, + page: Number.isFinite(page) ? Math.max(page, 1) : fallbackPage, + perPage: safePerPage, + total: safeTotal, + totalPages: Number.isFinite(raw.totalPages) + ? Math.max(Number(raw.totalPages), 1) + : Math.max(Math.ceil(safeTotal / safePerPage), 1), + } + } + + const rows = normalizeToList(raw) + const total = Number(raw?.total ?? rows.length) + const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : rows.length + + return { + data: rows, + page: fallbackPage, + perPage: fallbackPerPage, + total: safeTotal, + totalPages: Math.max(Math.ceil(safeTotal / fallbackPerPage), 1), + } +} + +export async function fetchCompletedProfiles( + countryCode: string | undefined, + page: number, + perPage: number, +): Promise { + const queryParams = new URLSearchParams({ + page: String(page), + perPage: String(perPage), + }) + + if (countryCode) { + queryParams.set('countryCode', countryCode) + } + + const response = await xhrGetAsync( + `${EnvironmentConfig.REPORTS_API}/topcoder/completed-profiles?${queryParams.toString()}`, + ) + + return normalizeCompletedProfilesResponse(response, page, perPage) +} + +export async function fetchMemberSkillsData(userId: string | number | undefined): Promise { + if (!userId) { + return [] + } + + const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` + const url = `${baseUrl}/user-skills/${userId}?disablePagination=true` + + try { + return await xhrGetAsync(url) + } catch { + // If skills API fails, return empty array to not block the page + return [] + } +} diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss index 0a68a0b65..f0a0e396d 100644 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss +++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss @@ -86,7 +86,7 @@ table { width: 100%; border-collapse: collapse; - min-width: 420px; + min-width: 1120px; } th, @@ -106,9 +106,89 @@ td { color: $black-100; + vertical-align: middle; } tr:last-child td { border-bottom: 0; } } + +.memberCell { + display: flex; + align-items: center; + gap: $sp-2; +} + +.avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + border: 1px solid $black-20; +} + +.paginationRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-3; + + @include ltemd { + flex-direction: column; + align-items: flex-start; + } +} + +.paginationInfo { + color: $black-60; + font-size: 14px; + line-height: 20px; +} + +.paginationButtons { + display: flex; + align-items: center; + gap: $sp-2; +} + +.skillsList { + display: flex; + flex-wrap: wrap; + gap: $sp-2; +} + +.skillTag { + display: inline-block; + background: $black-5; + border: 1px solid $black-20; + border-radius: $sp-1; + padding: $sp-1 $sp-2; + font-size: 12px; + line-height: 16px; + color: $black-80; + white-space: nowrap; +} + +.moreIndicator { + display: inline-block; + background: $black-5; + border: 1px solid $black-20; + border-radius: $sp-1; + padding: $sp-1 $sp-2; + font-size: 12px; + line-height: 16px; + color: $black-80; + font-weight: 700; + min-width: 24px; + text-align: center; + cursor: help; +} + +.link { + display: flex; + gap: $sp-1; + text-decoration: underline; + color: $link-blue; + cursor: pointer; +} diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx index e565731a6..34ee7785b 100644 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx +++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx @@ -1,65 +1,72 @@ /* eslint-disable react/jsx-no-bind */ -import { ChangeEvent, FC, useMemo, useState } from 'react' +/* eslint-disable no-await-in-loop */ +/* eslint-disable complexity */ +import { ChangeEvent, FC, useEffect, useMemo, useState } from 'react' import useSWR, { SWRResponse } from 'swr' import { EnvironmentConfig } from '~/config' -import { CountryLookup, useCountryLookup, xhrGetAsync } from '~/libs/core' -import { InputSelect, InputSelectOption, LoadingSpinner } from '~/libs/ui' +import { CountryLookup, useCountryLookup, UserSkill, UserSkillDisplayModes } from '~/libs/core' +import { Button, InputSelect, InputSelectOption, LoadingSpinner } from '~/libs/ui' import { PageWrapper } from '../../../lib' +import { + CompletedProfilesResponse, + DEFAULT_PAGE_SIZE, + fetchCompletedProfiles, + fetchMemberSkillsData, +} from '../../../lib/services/profileCompletion.service' import styles from './ProfileCompletionPage.module.scss' -type CompletedProfile = { - countryCode?: string - countryName?: string - handle: string - userId?: number | string -} - -function normalizeToList(raw: any): any[] { - if (Array.isArray(raw)) { - return raw - } - - if (Array.isArray(raw?.data)) { - return raw.data - } - - if (Array.isArray(raw?.result?.content)) { - return raw.result.content - } - - if (Array.isArray(raw?.result)) { - return raw.result - } - - return [] -} - -async function fetchCompletedProfiles(): Promise { - const response = await xhrGetAsync( - `${EnvironmentConfig.REPORTS_API}/topcoder/completed-profiles`, - ) - - return normalizeToList(response) -} - export const ProfileCompletionPage: FC = () => { const [selectedCountry, setSelectedCountry] = useState('all') + const [currentPage, setCurrentPage] = useState(1) + const [memberSkills, setMemberSkills] = useState>(new Map()) const countryLookup: CountryLookup[] | undefined = useCountryLookup() - const { data, error, isValidating }: SWRResponse = useSWR( - 'customer-portal-completed-profiles', - fetchCompletedProfiles, + const countryCodeFilter = selectedCountry === 'all' ? undefined : selectedCountry + + const { data, error, isValidating }: SWRResponse = useSWR( + `customer-portal-completed-profiles:${countryCodeFilter || 'all'}:${currentPage}:${DEFAULT_PAGE_SIZE}`, + () => fetchCompletedProfiles(countryCodeFilter, currentPage, DEFAULT_PAGE_SIZE), { revalidateOnFocus: false, }, ) + // Fetch member skills for all profiles on the current page + useEffect(() => { + if (!data?.data || data.data.length === 0) return + + const fetchAllMemberSkills = async (): Promise => { + const skillsMap = new Map() + + for (const profile of data.data) { + if (profile.userId && !memberSkills.has(profile.userId)) { + const skills = await fetchMemberSkillsData(profile.userId) + skillsMap.set(profile.userId, skills) + } + } + + if (skillsMap.size > 0) { + setMemberSkills(prevSkills => { + const newMap = new Map(prevSkills) + skillsMap.forEach((skills, userId) => { + newMap.set(userId, skills) + }) + return newMap + }) + } + } + + fetchAllMemberSkills() + }, [data?.data]) + const countryMap = useMemo(() => { - const map = new Map(); - (countryLookup || []).forEach(country => { + const map = new Map() + const countries = countryLookup || [] + + countries.forEach((country: CountryLookup) => { if (country.countryCode) { map.set(country.countryCode, country.country) } @@ -69,17 +76,25 @@ export const ProfileCompletionPage: FC = () => { }, [countryLookup]) const countryOptions = useMemo(() => { - const dynamicCodes = new Set(); - (data || []).forEach(profile => { - if (profile.countryCode) { - dynamicCodes.add(profile.countryCode) - } - }) + const staticOptions = (countryLookup || []) + .filter(country => !!country.countryCode) + .map(country => ({ + label: country.country, + value: country.countryCode, + })) + .sort((a, b) => String(a.label) + .localeCompare(String(b.label))) - const dynamicOptions = Array.from(dynamicCodes) - .map(code => ({ - label: countryMap.get(code) || code, - value: code, + const seen = new Set(staticOptions.map(option => option.value)) + const dynamicOptions = (data?.data || []) + .filter(profile => !!profile.countryCode && !seen.has(String(profile.countryCode))) + .map(profile => ({ + label: ( + countryMap.get(String(profile.countryCode)) + || profile.countryName + || String(profile.countryCode) + ), + value: String(profile.countryCode), })) .sort((a, b) => String(a.label) .localeCompare(String(b.label))) @@ -89,27 +104,49 @@ export const ProfileCompletionPage: FC = () => { label: 'All Countries', value: 'all', }, + ...staticOptions, ...dynamicOptions, ] - }, [countryMap, data]) + }, [countryLookup, countryMap, data?.data]) - const profiles = useMemo(() => { - const source = data || [] - if (selectedCountry === 'all') { - return source - } - - return source.filter(profile => profile.countryCode === selectedCountry) - }, [data, selectedCountry]) + const profiles = data?.data || [] + const totalProfiles = data?.total || 0 + const totalPages = data?.totalPages || 1 const displayedRows = useMemo(() => profiles - .map(profile => ({ - ...profile, - countryLabel: profile.countryCode - ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode - : profile.countryName || '-', - })) - .sort((a, b) => a.handle.localeCompare(b.handle)), [profiles, countryMap]) + .map(profile => { + const userSkills = profile.userId ? (memberSkills.get(profile.userId) || []) : [] + + // Prioritize principal skills, then add additional skills + const allSkillsByPriority = [ + ...userSkills.filter(skill => skill.displayMode?.name === UserSkillDisplayModes.principal), + ...userSkills.filter(skill => skill.displayMode?.name !== UserSkillDisplayModes.principal), + ] + + const displayedSkills = allSkillsByPriority.slice(0, 5) + const additionalSkillsCount = Math.max(0, allSkillsByPriority.length - 5) + + return { + ...profile, + additionalSkillsCount, + countryLabel: profile.countryCode + ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode + : profile.countryName || '-', + displayedSkills, + fullName: [profile.firstName, profile.lastName].filter(Boolean) + .join(' ') + .trim(), + locationLabel: [profile.city, profile.countryCode + ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode + : profile.countryName] + .filter(Boolean) + .join(', '), + } + }) + .sort((a, b) => a.handle.localeCompare(b.handle)), [profiles, countryMap, memberSkills]) + + const isPreviousDisabled = currentPage <= 1 || isValidating + const isNextDisabled = isValidating || currentPage >= totalPages return ( { value={selectedCountry} onChange={(event: ChangeEvent) => { setSelectedCountry(event.target.value || 'all') + setCurrentPage(1) }} placeholder='Select country' />
Fully Completed Profiles - {profiles.length} + {totalProfiles}
@@ -155,32 +193,109 @@ export const ProfileCompletionPage: FC = () => { )} {!error && displayedRows.length > 0 && ( -
- - - - - - - - - {displayedRows.map(profile => ( - - - + <> +
+
HandleCountry
- - {profile.handle} - - {profile.countryLabel}
+ + + + + + + - ))} - -
MemberHandleLocationSkills{' '}
-
+ + + {displayedRows.map(profile => ( + + +
+ {profile.photoURL && ( + {profile.handle} + )} + {profile.fullName || '-'} +
+ + + + {profile.handle} + + + {profile.locationLabel || profile.countryLabel} + + {profile.displayedSkills && profile.displayedSkills.length > 0 ? ( +
+ {profile.displayedSkills.map(skill => ( + + {skill.name} + + ))} + {profile.additionalSkillsCount > 0 && ( + + + + {profile.additionalSkillsCount} + {' '} + skills + + )} +
+ ) : ( + '-' + )} + + + + Go to profile + + + + ))} + + + +
+ + Page + {' '} + {currentPage} + {' '} + of + {' '} + {totalPages} + +
+ + +
+
+ )}
) diff --git a/src/apps/engagements/src/components/status-badge/StatusBadge.tsx b/src/apps/engagements/src/components/status-badge/StatusBadge.tsx index 4a815d672..653f6f5e8 100644 --- a/src/apps/engagements/src/components/status-badge/StatusBadge.tsx +++ b/src/apps/engagements/src/components/status-badge/StatusBadge.tsx @@ -15,6 +15,7 @@ const STATUS_LABELS: Record = { [EngagementStatus.OPEN]: 'Open', [EngagementStatus.PENDING_ASSIGNMENT]: 'Pending Assignment', [EngagementStatus.ACTIVE]: 'Active', + [EngagementStatus.ON_HOLD]: 'On Hold', [EngagementStatus.CANCELLED]: 'Cancelled', [EngagementStatus.CLOSED]: 'Closed', } diff --git a/src/apps/engagements/src/lib/models/Engagement.model.ts b/src/apps/engagements/src/lib/models/Engagement.model.ts index 29c93fa44..580aefcd0 100644 --- a/src/apps/engagements/src/lib/models/Engagement.model.ts +++ b/src/apps/engagements/src/lib/models/Engagement.model.ts @@ -2,6 +2,7 @@ export enum EngagementStatus { OPEN = 'open', PENDING_ASSIGNMENT = 'pending_assignment', ACTIVE = 'active', + ON_HOLD = 'on_hold', CANCELLED = 'cancelled', CLOSED = 'closed', } diff --git a/src/apps/engagements/src/pages/application-form/ApplicationFormPage.module.scss b/src/apps/engagements/src/pages/application-form/ApplicationFormPage.module.scss index dc6d1d58c..a893cf794 100644 --- a/src/apps/engagements/src/pages/application-form/ApplicationFormPage.module.scss +++ b/src/apps/engagements/src/pages/application-form/ApplicationFormPage.module.scss @@ -30,6 +30,7 @@ .readOnlyGrid { @include gridColumns(2, 1, 1, 16px); + align-items: start; } .readOnlyField { diff --git a/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx b/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx index 736eb09bd..89e5459bf 100644 --- a/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx +++ b/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx @@ -85,7 +85,7 @@ const ApplicationFormPage: FC = () => { availability: '', coverLetter: '', email: '', - mobileNumber: undefined, + mobileNumber: '', name: '', portfolioUrls: [], resumeUrl: undefined, @@ -223,7 +223,7 @@ const ApplicationFormPage: FC = () => { shouldTouch: false, shouldValidate: false, }) - setValue('mobileNumber', userData.mobileNumber, { + setValue('mobileNumber', userData.mobileNumber ?? '', { shouldDirty: false, shouldTouch: false, shouldValidate: false, @@ -422,8 +422,7 @@ const ApplicationFormPage: FC = () => { const handleMobileNumberChange = useCallback( (field: ControllerRenderProps) => ( (event: ChangeEvent): void => { - const nextValue = event.target.value - field.onChange(nextValue || undefined) + field.onChange(event.target.value) } ), [], diff --git a/src/apps/engagements/src/pages/application-form/application-form.schema.ts b/src/apps/engagements/src/pages/application-form/application-form.schema.ts index bfd1811d5..e9c109371 100644 --- a/src/apps/engagements/src/pages/application-form/application-form.schema.ts +++ b/src/apps/engagements/src/pages/application-form/application-form.schema.ts @@ -32,7 +32,7 @@ export const applicationFormSchema: yup.ObjectSchema = yup. .optional(), mobileNumber: yup .string() - .required(requiredMessage) + .required('Mobile Number must be defined') .test( 'not-whitespace', requiredMessage, @@ -47,7 +47,7 @@ export const applicationFormSchema: yup.ObjectSchema = yup. 'Mobile number must contain only digits, spaces, hyphens, plus signs, and parentheses', ) .max(20, 'Mobile number must be 20 characters or less') - .defined(), + .required('Mobile Number must be defined'), name: yup .string() .optional(), diff --git a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx index 318893cee..87daa4ce2 100644 --- a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx +++ b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx @@ -39,6 +39,7 @@ const APPLICATION_STATUS_LABELS: Record = { } const PRIVATE_ENGAGEMENT_ROLE_KEYWORDS = ['project manager', 'task manager', 'talent manager', 'admin'] +const PRIVATE_ENGAGEMENT_ACCESS_DENIED_MESSAGE = 'You are not authorized to access this private engagement.' const formatEnumLabel = (value?: string): string | undefined => { if (!value) { @@ -209,6 +210,28 @@ const getApplicationStatusLabel = (application?: Application): string | undefine return APPLICATION_STATUS_LABELS[application.status] } +const getApiErrorMessage = (error: any): string | undefined => { + const message = error?.response?.data?.message ?? error?.data?.message ?? error?.message + + if (Array.isArray(message)) { + const firstMessage = message.find(value => typeof value === 'string' && value.trim()) + return firstMessage?.trim() + } + + return typeof message === 'string' ? message.trim() : undefined +} + +const isPrivateEngagementAccessDeniedError = (error: any): boolean => { + const status = error?.response?.status + const message = getApiErrorMessage(error) + + if (status !== 401 && status !== 403) { + return false + } + + return message === PRIVATE_ENGAGEMENT_ACCESS_DENIED_MESSAGE +} + type TermsViewData = { termsTitle: string termsBody?: string @@ -502,6 +525,7 @@ const EngagementDetailPage: FC = () => { const [engagement, setEngagement] = useState(undefined) const [loading, setLoading] = useState(true) const [error, setError] = useState(undefined) + const [privateAccessDenied, setPrivateAccessDenied] = useState(false) const [application, setApplication] = useState(undefined) const [hasApplied, setHasApplied] = useState(false) const [checkingApplication, setCheckingApplication] = useState(false) @@ -545,6 +569,7 @@ const EngagementDetailPage: FC = () => { setLoading(true) setError(undefined) + setPrivateAccessDenied(false) try { const response = await getEngagementByNanoId(nanoId) @@ -559,6 +584,11 @@ const EngagementDetailPage: FC = () => { return } + if (isPrivateEngagementAccessDeniedError(err)) { + setPrivateAccessDenied(true) + return + } + setError('Unable to load engagement details. Please try again.') } finally { setLoading(false) @@ -1009,17 +1039,7 @@ const EngagementDetailPage: FC = () => {

Private engagement

-

- {isLoggedIn - ? 'Only talent managers, project managers, administrators, ' - + 'and assigned members can view this engagement.' - : 'Sign in to confirm your access to this private engagement.'} -

- {!isLoggedIn && ( - - Sign in - - )} +

Only task managers, project managers, administrators, and assigned members can view this engagement.

) @@ -1157,6 +1177,10 @@ const EngagementDetailPage: FC = () => { return renderLoadingState() } + if (privateAccessDenied) { + return renderRestrictedEngagementState() + } + if (error) { return renderErrorState() } diff --git a/src/apps/onboarding/src/pages/open-to-work/index.tsx b/src/apps/onboarding/src/pages/open-to-work/index.tsx index e528028dd..224721b30 100644 --- a/src/apps/onboarding/src/pages/open-to-work/index.tsx +++ b/src/apps/onboarding/src/pages/open-to-work/index.tsx @@ -116,7 +116,7 @@ export const PageOpenToWorkContent: FC = props => { try { const [, updatedTraits] = await Promise.all([ // profile flag - props.updateMemberOpenForWork(formValue.availableForGigs), + props.updateMemberOpenForWork(!!formValue.availableForGigs), // personalization trait updateOrCreateMemberTraitsAsync(props.profileHandle, [{ diff --git a/src/apps/profiles/src/member-profile/profile-header/OpenForGigs/OpenForGigs.tsx b/src/apps/profiles/src/member-profile/profile-header/OpenForGigs/OpenForGigs.tsx index be8e00dc5..7e7db2ecc 100644 --- a/src/apps/profiles/src/member-profile/profile-header/OpenForGigs/OpenForGigs.tsx +++ b/src/apps/profiles/src/member-profile/profile-header/OpenForGigs/OpenForGigs.tsx @@ -12,6 +12,7 @@ import styles from './OpenForGigs.module.scss' interface OpenForGigsProps { canEdit: boolean + isOpenToWork: boolean | null authProfile: UserProfile | undefined profile: UserProfile refreshProfile: (handle: string) => void @@ -27,7 +28,7 @@ const OpenForGigs: FC = (props: OpenForGigsProps) => { const [isEditMode, setIsEditMode]: [boolean, Dispatch>] = useState(false) - const openForWork = props.profile.availableForGigs + const openForWork = props.isOpenToWork useEffect(() => { if (props.authProfile && editMode === profileEditModes.openForWork) { @@ -53,9 +54,16 @@ const OpenForGigs: FC = (props: OpenForGigsProps) => { return props.canEdit || openForWork || props.isPrivilegedViewer ? (
-

- {openForWork ? 'open to work' : 'not open to work'} -

+ {openForWork === null ? ( +

+ Unknown +

+ ) : ( +

+ {openForWork ? 'open to work' : 'not open to work'} +

+ )} + { props.canEdit && ( = (props: OpenForGigsProps) => { onClose={handleModifyOpenForWorkClose} onSave={handleModifyOpenForWorkSave} profile={props.profile} + openForWork={openForWork} memberPersonalizationTraits={props.memberPersonalizationTraits} mutatePersonalizationTraits={props.mutatePersonalizationTraits} /> diff --git a/src/apps/profiles/src/member-profile/profile-header/OpenForGigsModifyModal/OpenForGigsModifyModal.tsx b/src/apps/profiles/src/member-profile/profile-header/OpenForGigsModifyModal/OpenForGigsModifyModal.tsx index 3770ca2e8..5d56b5030 100644 --- a/src/apps/profiles/src/member-profile/profile-header/OpenForGigsModifyModal/OpenForGigsModifyModal.tsx +++ b/src/apps/profiles/src/member-profile/profile-header/OpenForGigsModifyModal/OpenForGigsModifyModal.tsx @@ -22,6 +22,7 @@ interface OpenForGigsModifyModalProps { onClose: () => void onSave: () => void profile: UserProfile + openForWork: boolean | null memberPersonalizationTraits?: UserTrait[] mutatePersonalizationTraits: () => void } @@ -34,7 +35,7 @@ const OpenForGigsModifyModal: FC = (props: OpenForG const [formValue, setFormValue] = useState({ availability: undefined, - availableForGigs: !!props.profile.availableForGigs, + availableForGigs: props.openForWork, preferredRoles: [], }) @@ -56,12 +57,12 @@ const OpenForGigsModifyModal: FC = (props: OpenForG setFormValue(prev => ({ ...prev, availability: openToWorkItem?.availability, - availableForGigs: !!props.profile.availableForGigs, + availableForGigs: props.openForWork, preferredRoles: openToWorkItem?.preferredRoles ?? [], })) }, [ memberPersonalizationTraits, - props.profile.availableForGigs, + props.openForWork, ]) function handleFormChange(nextValue: OpenToWorkData): void { @@ -152,8 +153,8 @@ const OpenForGigsModifyModal: FC = (props: OpenForG >

- By selecting “Open to Work” our talent management team will know - that you are available for engagement opportunities. + By selecting “Open to Work” our customers will know that + you are available for engagement opportunities.

diff --git a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx index 2c1bc1cb0..62b721b65 100644 --- a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx +++ b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx @@ -1,4 +1,5 @@ /* eslint-disable complexity */ +/* eslint-disable unicorn/no-null */ import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react' import { Location, useLocation, useSearchParams } from 'react-router-dom' import { KeyedMutator } from 'swr' @@ -70,7 +71,7 @@ const ProfileHeader: FC = (props: ProfileHeaderProps) => { [state?.queriedSkills], ) - const activeTooltipText = canEdit ? `You have been active in the past 3 months. + const activeTooltipText = canEdit ? `You have been active in the past 3 months. (this information is visible to you only)` : `${props.profile.firstName} has been active in the past 3 months.` useEffect(() => { @@ -146,13 +147,15 @@ const ProfileHeader: FC = (props: ProfileHeaderProps) => { (item: UserTrait) => !!item?.openToWork, ) + const isOpenToWork = hasOpenToWork ? props.profile.availableForGigs : null + function renderOpenForWork(): JSX.Element { const showMyStatusLabel = canEdit const showAdminLabel = isPrivilegedViewer const content = (
- {showMyStatusLabel && My status:} + {showMyStatusLabel && Engagement status:} {showAdminLabel && ( @@ -169,6 +172,7 @@ const ProfileHeader: FC = (props: ProfileHeaderProps) => { isPrivilegedViewer={isPrivilegedViewer} memberPersonalizationTraits={memberPersonalizationTraits} mutatePersonalizationTraits={mutateTraits} + isOpenToWork={isOpenToWork} />
) diff --git a/src/apps/review/src/lib/components/TableAppeals/TableAppeals.tsx b/src/apps/review/src/lib/components/TableAppeals/TableAppeals.tsx index a4415fa77..d854abf1a 100644 --- a/src/apps/review/src/lib/components/TableAppeals/TableAppeals.tsx +++ b/src/apps/review/src/lib/components/TableAppeals/TableAppeals.tsx @@ -42,10 +42,12 @@ import { import type { DownloadButtonConfig, ScoreVisibilityConfig, + SubmissionReviewerRow, SubmissionRow, } from '../common/types' import type { AggregatedSubmissionReviews } from '../../utils/aggregateSubmissionReviews' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { buildSubmissionReviewerRows } from '../common/reviewResult' import styles from './TableAppeals.module.scss' @@ -209,15 +211,9 @@ export const TableAppeals: FC = (props: TableAppealsProps) => restrictToLatest, ]) - const maxReviewCount = useMemo( - () => aggregatedResults.reduce( - (maxCount, result) => { - const reviewCount = result.reviews?.length ?? 0 - return reviewCount > maxCount ? reviewCount : maxCount - }, - 0, - ), - [aggregatedResults], + const reviewerRows = useMemo( + () => buildSubmissionReviewerRows(aggregatedRows), + [aggregatedRows], ) const { canViewAllSubmissions }: UseRolePermissionsResult = useRolePermissions() @@ -302,95 +298,91 @@ export const TableAppeals: FC = (props: TableAppealsProps) => ], ) - const columns = useMemo[]>(() => { - const submissionIdColumn: TableColumn = { - className: styles.submissionColumn, + const columns = useMemo[]>(() => { + const submissionIdColumn: TableColumn = { + className: classNames(styles.submissionColumn, 'no-row-border'), columnId: 'submission-id', label: 'Submission ID', propertyName: 'id', - renderer: (submission: SubmissionRow) => renderSubmissionIdCell( - submission, - downloadButtonConfig, + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderSubmissionIdCell(row, downloadButtonConfig) + : ), type: 'element', } - const baseColumns: TableColumn[] = [submissionIdColumn] + const baseColumns: TableColumn[] = [submissionIdColumn] if (!hideHandleColumn) { baseColumns.push({ + className: 'no-row-border', columnId: 'handle-aggregated', label: 'Submitter', propertyName: 'handle', - renderer: renderSubmitterHandleCell, + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderSubmitterHandleCell(row) + : + ), type: 'element', }) } - baseColumns.push({ - columnId: 'review-date', - label: 'Review Date', - renderer: renderReviewDateCell, - type: 'element', - }) - if (shouldShowAggregatedReviewScore) { baseColumns.push({ + className: 'no-row-border', columnId: 'review-score', label: 'Review Score', - renderer: (submission: SubmissionRow) => renderReviewScoreCell( - submission, - scoreVisibilityConfig, + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderReviewScoreCell(row, scoreVisibilityConfig) + : ), type: 'element', }) } - for (let index = 0; index < maxReviewCount; index += 1) { - const reviewerLabel = maxReviewCount === 1 - ? 'Reviewer' - : `Reviewer ${index + 1}` - const scoreLabel = maxReviewCount === 1 - ? 'Score' - : `Score ${index + 1}` - const appealsLabel = maxReviewCount === 1 - ? 'Appeals' - : `Appeals ${index + 1}` - - baseColumns.push({ - columnId: `reviewer-${index}`, - label: reviewerLabel, - renderer: (submission: SubmissionRow) => renderReviewerCell( - submission, - index, + baseColumns.push( + { + columnId: 'reviewer', + label: 'Reviewer', + renderer: (row: SubmissionReviewerRow) => renderReviewerCell( + row, + row.reviewerIndex, ), type: 'element', - }) + }, + { + columnId: 'review-date', + label: 'Review Date', + renderer: (row: SubmissionReviewerRow) => renderReviewDateCell(row), + type: 'element', + }, + { + columnId: 'score', + label: 'Score', + renderer: (row: SubmissionReviewerRow) => renderScoreCell( + row, + row.reviewerIndex, + scoreVisibilityConfig, + ), + type: 'element', + }, + ) + if (allowsAppeals) { baseColumns.push({ - columnId: `score-${index}`, - label: scoreLabel, - renderer: (submission: SubmissionRow) => renderScoreCell( - submission, - index, + className: styles.tableCellNoWrap, + columnId: 'appeals', + label: 'Appeals', + renderer: (row: SubmissionReviewerRow) => renderAppealsCell( + row, + row.reviewerIndex, scoreVisibilityConfig, ), type: 'element', }) - - if (allowsAppeals) { - baseColumns.push({ - className: styles.tableCellNoWrap, - columnId: `appeals-${index}`, - label: appealsLabel, - renderer: (submission: SubmissionRow) => renderAppealsCell( - submission, - index, - scoreVisibilityConfig, - ), - type: 'element', - }) - } } if (props.aiReviewers) { @@ -398,16 +390,25 @@ export const TableAppeals: FC = (props: TableAppealsProps) => columnId: 'ai-reviews-table', isExpand: true, label: '', - renderer: (submission: SubmissionRow, allRows: SubmissionRow[]) => ( - props.aiReviewers && ( + renderer: (row: SubmissionReviewerRow, allRows: SubmissionReviewerRow[]) => { + if (!row.isLastReviewerRow || !props.aiReviewers) { + return + } + + const firstIndexForSubmission = allRows.findIndex(candidate => ( + candidate.id === row.id && candidate.isFirstReviewerRow + )) + const defaultOpen = firstIndexForSubmission === 0 + + return ( ) - ), + }, type: 'element', }) } @@ -417,12 +418,11 @@ export const TableAppeals: FC = (props: TableAppealsProps) => allowsAppeals, downloadButtonConfig, hideHandleColumn, - maxReviewCount, scoreVisibilityConfig, shouldShowAggregatedReviewScore, ]) - const columnsMobile = useMemo[][]>( + const columnsMobile = useMemo[][]>( () => columns.map(column => ([ column.label && { columnId: `${column.columnId}-label`, @@ -444,7 +444,7 @@ export const TableAppeals: FC = (props: TableAppealsProps) => label: '', mobileType: 'last-value', }, - ]).filter(Boolean) as MobileTableColumn[]), + ]).filter(Boolean) as MobileTableColumn[]), [columns], ) @@ -459,11 +459,11 @@ export const TableAppeals: FC = (props: TableAppealsProps) => )} > {isTablet ? ( - + ) : ( = (props: Table submitterCanViewAllRows, ]) - const maxReviewCount = useMemo( - () => visibleRows.reduce( - (maxCount, row) => { - const reviewCount = row.aggregated?.reviews?.length ?? 0 - return reviewCount > maxCount ? reviewCount : maxCount - }, - 0, - ), + const reviewerRows = useMemo( + () => buildSubmissionReviewerRows(visibleRows), [visibleRows], ) @@ -326,129 +322,124 @@ export const TableAppealsResponse: FC = (props: Table [canRespondToAppeals], ) - const columns = useMemo[]>(() => { - const submissionIdColumn: TableColumn = { - className: styles.submissionColumn, + const columns = useMemo[]>(() => { + const submissionIdColumn: TableColumn = { + className: classNames(styles.submissionColumn, 'no-row-border'), columnId: 'submission-id', label: 'Submission ID', propertyName: 'id', - renderer: (submission: SubmissionRow) => renderSubmissionIdCell( - submission, - downloadButtonConfig, + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderSubmissionIdCell(row, downloadButtonConfig) + : ), type: 'element', } - const baseColumns: TableColumn[] = [submissionIdColumn] + const baseColumns: TableColumn[] = [submissionIdColumn] if (!hideHandleColumn) { baseColumns.push({ + className: 'no-row-border', columnId: 'handle-aggregated', label: 'Submitter', propertyName: 'handle', - renderer: renderSubmitterHandleCell, + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderSubmitterHandleCell(row) + : + ), type: 'element', }) } + baseColumns.push({ + className: 'no-row-border', + columnId: 'review-score', + label: 'Review Score', + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderReviewScoreCell(row, scoreVisibilityConfig) + : + ), + type: 'element', + }) + baseColumns.push( + { + columnId: 'reviewer', + label: 'Reviewer', + renderer: (row: SubmissionReviewerRow) => renderReviewerCell( + row, + row.reviewerIndex, + ), + type: 'element', + }, { columnId: 'review-date', label: 'Review Date', - renderer: renderReviewDateCell, + renderer: (row: SubmissionReviewerRow) => renderReviewDateCell(row), type: 'element', }, { - columnId: 'review-score', - label: 'Review Score', - renderer: (submission: SubmissionRow) => renderReviewScoreCell( - submission, + columnId: 'score', + label: 'Score', + renderer: (row: SubmissionReviewerRow) => renderScoreCell( + row, + row.reviewerIndex, scoreVisibilityConfig, ), type: 'element', }, ) - for (let index = 0; index < maxReviewCount; index += 1) { - const reviewerLabel = maxReviewCount === 1 ? 'Reviewer' : `Reviewer ${index + 1}` - const scoreLabel = maxReviewCount === 1 ? 'Score' : `Score ${index + 1}` - const appealsLabel = maxReviewCount === 1 ? 'Appeals' : `Appeals ${index + 1}` - const remainingLabel = maxReviewCount === 1 ? 'Remaining' : `Remaining ${index + 1}` - - baseColumns.push({ - columnId: `reviewer-${index}`, - label: reviewerLabel, - renderer: (submission: SubmissionRow) => renderReviewerCell( - submission, - index, - ), - type: 'element', - }) - - baseColumns.push({ - columnId: `score-${index}`, - label: scoreLabel, - renderer: (submission: SubmissionRow) => renderScoreCell( - submission, - index, - scoreVisibilityConfig, - ), - type: 'element', - }) - - if (allowsAppeals) { - baseColumns.push({ + if (allowsAppeals) { + baseColumns.push( + { className: styles.tableCellNoWrap, - columnId: `appeals-${index}`, - label: appealsLabel, - renderer: (submission: SubmissionRow) => renderAppealsCell( - submission, - index, + columnId: 'appeals', + label: 'Appeals', + renderer: (row: SubmissionReviewerRow) => renderAppealsCell( + row, + row.reviewerIndex, scoreVisibilityConfig, ), type: 'element', - }) - - baseColumns.push({ + }, + { className: styles.tableCellNoWrap, - columnId: `remaining-${index}`, - label: remainingLabel, - renderer: (submission: SubmissionRow) => renderRemainingCell( - submission, - index, + columnId: 'remaining', + label: 'Remaining', + renderer: (row: SubmissionReviewerRow) => renderRemainingCell( + row, + row.reviewerIndex, ), type: 'element', - }) - } + }, + ) } if (isAppealsResponsePhaseOpen && canRespondToAppeals) { baseColumns.push({ columnId: 'actions', label: 'Actions', - renderer: (submission: SubmissionRow) => { - const reviews = submission.aggregated?.reviews ?? [] + renderer: (row: SubmissionReviewerRow) => { + const reviewDetail = row.aggregated?.reviews?.[row.reviewerIndex] + const reviewId = reviewDetail?.reviewInfo?.id ?? reviewDetail?.reviewId - const actionableReviews = reviews - .map(review => { - const reviewId = review.reviewInfo?.id ?? review.reviewId - if (!reviewId) { - return undefined - } - - const totalAppeals = review.totalAppeals ?? 0 - const finishedAppeals = review.finishedAppeals ?? 0 - const remaining = Math.max(totalAppeals - finishedAppeals, 0) - - if (remaining <= 0) { - return undefined - } + if (!reviewDetail || !reviewId) { + return ( + + -- + + ) + } - return reviewId - }) - .filter((reviewId): reviewId is string => Boolean(reviewId)) + const totalAppeals = reviewDetail.totalAppeals ?? 0 + const finishedAppeals = reviewDetail.finishedAppeals ?? 0 + const remaining = Math.max(totalAppeals - finishedAppeals, 0) - if (!actionableReviews.length) { + if (remaining <= 0) { return ( -- @@ -458,22 +449,19 @@ export const TableAppealsResponse: FC = (props: Table return ( - {actionableReviews.map((reviewId, index, array) => ( - + - - Respond to Appeals - - - ))} + Respond to Appeals + + ) }, @@ -486,16 +474,25 @@ export const TableAppealsResponse: FC = (props: Table columnId: 'ai-reviews-table', isExpand: true, label: '', - renderer: (submission: SubmissionRow, allRows: SubmissionRow[]) => ( - props.aiReviewers && ( + renderer: (row: SubmissionReviewerRow, allRows: SubmissionReviewerRow[]) => { + if (!row.isLastReviewerRow || !props.aiReviewers) { + return + } + + const firstIndexForSubmission = allRows.findIndex(candidate => ( + candidate.id === row.id && candidate.isFirstReviewerRow + )) + const defaultOpen = firstIndexForSubmission === 0 + + return ( ) - ), + }, type: 'element', }) } @@ -507,17 +504,16 @@ export const TableAppealsResponse: FC = (props: Table hideHandleColumn, canRespondToAppeals, isAppealsResponsePhaseOpen, - maxReviewCount, scoreVisibilityConfig, ]) - const columnsMobile = useMemo[][]>( + const columnsMobile = useMemo[][]>( () => columns.map(column => { const label = typeof column.label === 'function' ? column.label() : column.label ?? '' - const labelColumn: MobileTableColumn = { + const labelColumn: MobileTableColumn = { columnId: `${column.columnId}-label`, label: '', mobileType: 'label', @@ -529,7 +525,7 @@ export const TableAppealsResponse: FC = (props: Table type: 'element', } - const valueColumn: MobileTableColumn = { + const valueColumn: MobileTableColumn = { ...column, colSpan: label ? 1 : 2, columnId: `${column.columnId}-value`, @@ -538,7 +534,7 @@ export const TableAppealsResponse: FC = (props: Table ...(column.columnId === 'actions' ? { colSpan: 2 } : {}), } - return [!!label && labelColumn, valueColumn].filter(Boolean) as MobileTableColumn[] + return [!!label && labelColumn, valueColumn].filter(Boolean) as MobileTableColumn[] }), [columns], ) @@ -561,13 +557,13 @@ export const TableAppealsResponse: FC = (props: Table )} > {isTablet ? ( - + ) : (
= (props: TableReviewProps) => { return rows.filter(row => row.id && latestSubmissionIds.has(row.id)) }, [aggregatedSubmissionRows, latestSubmissionIds, restrictToLatest]) - const maxReviewCount = useMemo( - () => aggregatedSubmissionRows.reduce( - (max, aggregated) => Math.max(max, aggregated.reviews?.length ?? 0), - 0, - ), - [aggregatedSubmissionRows], + const reviewerRows = useMemo( + () => buildSubmissionReviewerRows(aggregatedRows), + [aggregatedRows], ) - interface ReviewerColumnMetadata { - label: string - renderLabel?: () => JSX.Element - } - const reviewerColumnMetadata = useMemo(() => ( - Array.from({ length: maxReviewCount }, (_unused, index) => ({ - label: `Reviewer ${index + 1}`, - })) - ), [maxReviewCount]) const [isReopening, setIsReopening] = useState(false) const [pendingReopen, setPendingReopen] = useState(undefined) @@ -436,7 +425,9 @@ export const TableReview: FC = (props: TableReviewProps) => { ], ) - const renderActionsCell = useCallback<(submission: SubmissionRow) => JSX.Element>((submission: SubmissionRow) => { + const renderActionsCell = useCallback<(submission: SubmissionReviewerRow) => JSX.Element>(( + submission: SubmissionReviewerRow, + ) => { const reviews = submission.aggregated?.reviews ?? [] const myReviewDetail = reviews.find(review => { const resourceId = review.reviewInfo?.resourceId ?? review.resourceId @@ -616,78 +607,89 @@ export const TableReview: FC = (props: TableReviewProps) => { shouldShowHistoryActions, ]) - const columns = useMemo[]>(() => { - const submissionIdColumn: TableColumn = { - className: styles.submissionColumn, + const columns = useMemo[]>(() => { + const submissionIdColumn: TableColumn = { + className: classNames(styles.submissionColumn, 'no-row-border'), columnId: 'submission-id', label: 'Submission ID', propertyName: 'id', - renderer: (submission: SubmissionRow) => renderSubmissionIdCell( - submission, - downloadButtonConfig, + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderSubmissionIdCell(row, downloadButtonConfig) + : ), type: 'element', } - const baseColumns: TableColumn[] = [submissionIdColumn] + const baseColumns: TableColumn[] = [submissionIdColumn] if (!hideHandleColumn) { baseColumns.push({ + className: 'no-row-border', columnId: 'handle-aggregated', label: 'Submitter', propertyName: 'handle', - renderer: renderSubmitterHandleCell, + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderSubmitterHandleCell(row) + : + ), type: 'element', }) } - for (let index = 0; index < maxReviewCount; index += 1) { - const metadata = reviewerColumnMetadata[index] ?? { - label: `Reviewer ${index + 1}`, - } - baseColumns.push( - { - columnId: `reviewer-${index}`, - label: metadata.renderLabel ?? metadata.label, - renderer: (submission: SubmissionRow) => renderReviewerCell(submission, index), - type: 'element', - }, - { - columnId: `score-${index}`, - label: `Score ${index + 1}`, - renderer: (submission: SubmissionRow) => renderScoreCell( - submission, - index, - scoreVisibilityConfig, - challengeInfo, - pendingReopen, - canManageCompletedReviews, - isReopening, - openReopenDialog, - ), - type: 'element', - }, - ) - } + baseColumns.push({ + className: 'no-row-border', + columnId: 'review-score', + label: 'Review Score', + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow + ? renderReviewScoreCell(row, scoreVisibilityConfig) + : + ), + type: 'element', + }) baseColumns.push( + { + columnId: 'reviewer', + label: 'Reviewer', + renderer: (row: SubmissionReviewerRow) => renderReviewerCell( + row, + row.reviewerIndex, + ), + type: 'element', + }, { columnId: 'review-date', label: 'Review Date', - renderer: renderReviewDateCell, + renderer: (row: SubmissionReviewerRow) => renderReviewDateCell(row), type: 'element', }, { - columnId: 'review-score', - label: 'Review Score', - renderer: (submission: SubmissionRow) => renderReviewScoreCell(submission, scoreVisibilityConfig), + columnId: 'score', + label: 'Score', + renderer: (row: SubmissionReviewerRow) => renderScoreCell( + row, + row.reviewerIndex, + scoreVisibilityConfig, + challengeInfo, + pendingReopen, + canManageCompletedReviews, + isReopening, + openReopenDialog, + ), type: 'element', }, { columnId: 'review-result', label: 'Review Result', - renderer: (submission: SubmissionRow) => { - const result = resolveSubmissionReviewResult(submission, { + renderer: (row: SubmissionReviewerRow) => { + if (!row.isFirstReviewerRow) { + return + } + + const result = resolveSubmissionReviewResult(row, { minimumPassingScoreByScorecardId, }) if (result === 'PASS') { @@ -717,7 +719,13 @@ export const TableReview: FC = (props: TableReviewProps) => { className: styles.textBlue, columnId: 'actions', label: 'Actions', - renderer: renderActionsCell, + renderer: (row: SubmissionReviewerRow) => ( + row.isFirstReviewerRow ? renderActionsCell(row) : ( + + -- + + ) + ), type: 'element', }) } @@ -727,16 +735,26 @@ export const TableReview: FC = (props: TableReviewProps) => { columnId: 'ai-reviews-table', isExpand: true, label: '', - renderer: (submission: SubmissionRow, allRows: SubmissionRow[]) => ( - props.aiReviewers && ( + renderer: (row: SubmissionReviewerRow, allRows?: SubmissionReviewerRow[]) => { + if (!row.isLastReviewerRow || !props.aiReviewers) { + return + } + + const rows = allRows ?? [] + const firstIndexForSubmission = rows.findIndex(candidate => ( + candidate.id === row.id && candidate.isFirstReviewerRow + )) + const defaultOpen = firstIndexForSubmission === 0 + + return ( ) - ), + }, type: 'element', }) } @@ -745,7 +763,6 @@ export const TableReview: FC = (props: TableReviewProps) => { }, [ downloadButtonConfig, hideHandleColumn, - maxReviewCount, minimumPassingScoreByScorecardId, renderActionsCell, scoreVisibilityConfig, @@ -755,43 +772,13 @@ export const TableReview: FC = (props: TableReviewProps) => { openReopenDialog, challengeInfo, pendingReopen, - reviewerColumnMetadata, ]) - const columnsMobile = useMemo[][]>( + const columnsMobile = useMemo[][]>( () => columns.map(column => { - const resolveLabelString = (): string => { - if (typeof column.label === 'string') { - return column.label - } - - if (typeof column.label === 'function') { - const labelResult = column.label() - if (typeof labelResult === 'string') { - return labelResult - } - - const columnId = column.columnId ?? '' - if (columnId.startsWith('reviewer-')) { - const reviewerIndexRaw = columnId.split('-')[1] - const reviewerIndex = reviewerIndexRaw - ? Number.parseInt(reviewerIndexRaw, 10) - : NaN - if (!Number.isNaN(reviewerIndex)) { - return reviewerColumnMetadata[reviewerIndex]?.label - ?? `Reviewer ${reviewerIndex + 1}` - } - - return 'Reviewer' - } - - return '' - } - - return column.label ?? '' - } - - const resolvedLabel = resolveLabelString() + const resolvedLabel = typeof column.label === 'function' + ? column.label() ?? '' + : (column.label ?? '') const labelForAction = typeof column.label === 'string' ? column.label : resolvedLabel @@ -829,9 +816,9 @@ export const TableReview: FC = (props: TableReviewProps) => { colSpan: labelText ? 1 : 2, mobileType: 'last-value', }, - ].filter(Boolean) as MobileTableColumn[] + ].filter(Boolean) as MobileTableColumn[] }), - [columns, reviewerColumnMetadata], + [columns], ) return ( @@ -843,14 +830,14 @@ export const TableReview: FC = (props: TableReviewProps) => { )} > {isTablet ? ( - + ) : (
{ + const reviews = submission.aggregated?.reviews ?? [] + const reviewCount = reviews.length || 1 + + for (let reviewerIndex = 0; reviewerIndex < reviewCount; reviewerIndex += 1) { + rows.push({ + ...submission, + isFirstReviewerRow: reviewerIndex === 0, + isLastReviewerRow: reviewerIndex === reviewCount - 1, + reviewerIndex, + }) + } + }) + + return rows +} diff --git a/src/apps/review/src/lib/components/common/types.ts b/src/apps/review/src/lib/components/common/types.ts index 667f987a3..1fd272157 100644 --- a/src/apps/review/src/lib/components/common/types.ts +++ b/src/apps/review/src/lib/components/common/types.ts @@ -17,6 +17,19 @@ export interface SubmissionRow extends SubmissionInfo { aggregated?: AggregatedSubmissionReviews } +/** + * Flattened row shape representing a single reviewer entry for a submission. + * Each logical submission can expand to multiple SubmissionReviewerRow entries + * (one per reviewer), while preserving the original submission fields. + */ +export interface SubmissionReviewerRow extends SubmissionRow { + /** Zero-based index of the reviewer within aggregated.reviews for this submission. */ + reviewerIndex: number + /** True when this is the first reviewer row for the submission. */ + isFirstReviewerRow: boolean + /** True when this is the last reviewer row for the submission. */ + isLastReviewerRow: boolean +} /** * Shared configuration available to column renderers that need challenge-level context. */ diff --git a/src/apps/review/src/lib/styles/index.scss b/src/apps/review/src/lib/styles/index.scss index b284ffaea..4d5786723 100644 --- a/src/apps/review/src/lib/styles/index.scss +++ b/src/apps/review/src/lib/styles/index.scss @@ -53,6 +53,7 @@ $icons: review appeal submission warning error event timer upload reopen 1st 2nd } table { width: 100%; + border-collapse: collapse; thead { tr { th { @@ -73,7 +74,6 @@ $icons: review appeal submission warning error event timer upload reopen 1st 2nd } } td { - border-bottom: var(--TableBorderColor) solid 1px; color: var(--FontColor); font-family: "Nunito Sans", sans-serif; font-size: 14px; @@ -93,6 +93,20 @@ $icons: review appeal submission warning error event timer upload reopen 1st 2nd text-transform: none; } } + &:not(.no-row-border) { + border-bottom: var(--TableBorderColor) solid 1px; + } + &.no-row-border { + border-bottom: none; + border-top: none; + } + } + tbody tr:has(td[colspan] .TableCell_blockExpandValue .TableCell_blockCell span:only-child:empty) td { + border-bottom: none; + border-top: none; + padding-top: 0; + padding-bottom: 0; + line-height: 0; } } } diff --git a/src/libs/core/lib/profile/modify-user-profile.model.ts b/src/libs/core/lib/profile/modify-user-profile.model.ts index bd3aef1e5..508fffa65 100644 --- a/src/libs/core/lib/profile/modify-user-profile.model.ts +++ b/src/libs/core/lib/profile/modify-user-profile.model.ts @@ -8,7 +8,7 @@ export interface UpdateProfileRequest { streetAddr2?: string zip?: string }> - availableForGigs?: boolean, + availableForGigs?: boolean | null, competitionCountryCode?: string homeCountryCode?: string email?: string diff --git a/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.module.scss b/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.module.scss index 1fc86080e..f49ecfd84 100644 --- a/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.module.scss +++ b/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.module.scss @@ -22,3 +22,16 @@ } } } + +.radioGroup { + display: flex; + margin-bottom: $sp-6; + > * { + flex: 1; + } + + @include ltemd { + flex-direction: column; + gap: $sp-4; + } +} diff --git a/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.tsx b/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.tsx index 3c07480a0..cf109f6fa 100644 --- a/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.tsx +++ b/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.tsx @@ -1,13 +1,13 @@ import { ChangeEvent, FC, useCallback } from 'react' -import { InputMultiselect, InputMultiselectOption, InputSelect, InputText } from '~/libs/ui' +import { InputMultiselect, InputMultiselectOption, InputRadio, InputSelect } from '~/libs/ui' import styles from './ModifyOpenToWorkModal.module.scss' export type AvailabilityType = 'FULL_TIME' | 'PART_TIME' export interface OpenToWorkData { - availableForGigs: boolean + availableForGigs: boolean | null availability?: AvailabilityType preferredRoles?: string[] } @@ -57,10 +57,12 @@ export const validateOpenToWork = (value: OpenToWorkData): { [key: string]: stri } const OpenToWorkForm: FC = (props: OpenToWorkFormProps) => { - function toggleOpenForWork(): void { + function handleOpenForWorkChange(e: ChangeEvent): void { + const openForWork = e.target.value === 'true' + props.onChange({ ...props.value, - availableForGigs: !props.value.availableForGigs, + availableForGigs: openForWork, }) } @@ -96,20 +98,32 @@ const OpenToWorkForm: FC = (props: OpenToWorkFormProps) => return (
- +
+ + +
{props.value.availableForGigs && ( <> = (props: OpenToWorkFormProps) =>