diff --git a/.circleci/config.yml b/.circleci/config.yml index d12ec73ae..933f28cfa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -221,7 +221,6 @@ workflows: - dev - LVT-256 - CORE-635 - - pm-578 - deployQa: context: org-global diff --git a/src/apps/copilots/src/models/CopilotApplication.ts b/src/apps/copilots/src/models/CopilotApplication.ts index d9ab68ed5..0c2c18aab 100644 --- a/src/apps/copilots/src/models/CopilotApplication.ts +++ b/src/apps/copilots/src/models/CopilotApplication.ts @@ -3,4 +3,6 @@ export interface CopilotApplication { notes?: string, createdAt: Date, opportunityId: string, + handle?: string, + userId: number, } diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx index ea88a5a2f..21b4c8807 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx @@ -1,13 +1,16 @@ /* eslint-disable react/jsx-no-bind */ +/* eslint-disable complexity */ import { + Dispatch, FC, + SetStateAction, useCallback, useContext, useEffect, useMemo, useState, } from 'react' -import { useNavigate, useParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import { mutate } from 'swr' import moment from 'moment' @@ -17,6 +20,7 @@ import { IconOutline, LoadingSpinner, PageTitle, + TabsNavbar, } from '~/libs/ui' import { profileContext, ProfileContextData, UserRole } from '~/libs/core' @@ -27,9 +31,18 @@ import { useCopilotApplications, useCopilotOpportunity, } from '../../services/copilot-opportunities' +import { FormattedMembers, useMembers } from '../../services/members' import { copilotRoutesMap } from '../../copilots.routes' import { ApplyOpportunityModal } from './apply-opportunity-modal' +import { + CopilotDetailsTabViews, + getCopilotDetailsTabsConfig, + getHashFromTabId, + getTabIdFromHash, +} from './tabs/config/copilot-details-tabs-config' +import { OpportunityDetails } from './tabs/opportunity-details' +import { CopilotApplications } from './tabs/copilot-applications' import styles from './styles.module.scss' const CopilotOpportunityDetails: FC<{}> = () => { @@ -37,12 +50,19 @@ const CopilotOpportunityDetails: FC<{}> = () => { const navigate = useNavigate() const [showNotFound, setShowNotFound] = useState(false) const [showApplyOpportunityModal, setShowApplyOpportunityModal] = useState(false) - const { profile }: ProfileContextData = useContext(profileContext) + const { profile, initialized }: ProfileContextData = useContext(profileContext) const isCopilot: boolean = useMemo( () => !!profile?.roles?.some(role => role === UserRole.copilot), [profile], ) + const isAdminOrPM: boolean = useMemo( + () => !!profile?.roles?.some(role => role === UserRole.administrator || role === UserRole.projectManager), + [profile], + ) const { data: copilotApplications }: { data?: CopilotApplication[] } = useCopilotApplications(opportunityId) + const { data: members }: { data?: FormattedMembers[]} = useMembers( + copilotApplications ? copilotApplications?.map(item => item.userId) : [], + ) if (!opportunityId) { navigate(copilotRoutesMap.CopilotOpportunityList) @@ -50,6 +70,21 @@ const CopilotOpportunityDetails: FC<{}> = () => { const { data: opportunity, isValidating }: CopilotOpportunityResponse = useCopilotOpportunity(opportunityId) + const { hash }: { hash: string } = useLocation() + + const activeTabHash: string = useMemo(() => getTabIdFromHash(hash), [hash]) + + const [activeTab, setActiveTab]: [string, Dispatch>] = useState(activeTabHash) + + useEffect(() => { + setActiveTab(activeTabHash) + }, [activeTabHash]) + + const handleTabChange = useCallback((tabId: string): void => { + setActiveTab(tabId) + window.location.hash = getHashFromTabId(tabId) + }, [getHashFromTabId, setActiveTab]) + useEffect(() => { const timer = setTimeout(() => { if (!opportunity) { @@ -154,29 +189,23 @@ const CopilotOpportunityDetails: FC<{}> = () => { -
-
-

Required skills

-
- {opportunity?.skills.map((skill: any) => ( -
- {skill.name} -
- ))} -
-

Description

-

- {opportunity?.overview} -

-
-
-

Complexity

- {opportunity?.complexity} + { + initialized && ( + + ) + } + {activeTab === CopilotDetailsTabViews.details && } + {activeTab === CopilotDetailsTabViews.applications && isAdminOrPM && ( + + )} -

Requires Communication

- {opportunity?.requiresCommunication} -
-
{ showApplyOpportunityModal && opportunity && ( diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss index a51bd31bc..9b28a7018 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss @@ -13,40 +13,6 @@ color: $teal-100; } -.subHeading { - margin-top: $sp-8; -} - -.content { - margin-top: $sp-6; - display: flex; - flex-direction: row; - gap: 100px; -} - -.content > div:first-child { - flex: 3; -} - -.content > div:last-child { - flex: 1; -} - -.skillsContainer { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: $sp-2; -} - -.skillPill { - background-color: $teal-100; - color: white; - padding: 4px 8px; - border-radius: 10px; - white-space: nowrap; - font-size: 14px; -} .infoRow { display: flex; diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/config/copilot-details-tabs-config.ts b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/config/copilot-details-tabs-config.ts new file mode 100644 index 000000000..a5ece28c9 --- /dev/null +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/config/copilot-details-tabs-config.ts @@ -0,0 +1,55 @@ +import { TabsNavItem } from '~/libs/ui' + +export enum CopilotDetailsTabViews { + details = '0', + applications = '1', +} + +export const getCopilotDetailsTabsConfig = (isAdminOrPM: boolean): TabsNavItem[] => (isAdminOrPM ? [ + { + id: CopilotDetailsTabViews.details, + title: 'Details', + }, + { + id: CopilotDetailsTabViews.applications, + title: 'Applications', + }, +] : [ + { + id: CopilotDetailsTabViews.details, + title: 'Details', + }, +]) + +export const CopilotDetailsTabsConfig: TabsNavItem[] = [ + { + id: CopilotDetailsTabViews.details, + title: 'Details', + }, + { + id: CopilotDetailsTabViews.applications, + title: 'Applications', + }, +] + +export function getHashFromTabId(tabId: string): string { + switch (tabId) { + case CopilotDetailsTabViews.details: + return '#details' + case CopilotDetailsTabViews.applications: + return '#applications' + default: + return '#details' + } +} + +export function getTabIdFromHash(hash: string): string { + switch (hash) { + case '#details': + return CopilotDetailsTabViews.details + case '#applications': + return CopilotDetailsTabViews.applications + default: + return CopilotDetailsTabViews.details + } +} diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/CopilotApplications.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/CopilotApplications.tsx new file mode 100644 index 000000000..283debb48 --- /dev/null +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/CopilotApplications.tsx @@ -0,0 +1,87 @@ +import { FC, useMemo } from 'react' + +import { Table, TableColumn } from '~/libs/ui' +import { USER_PROFILE_URL } from '~/config/environments/default.env' + +import { CopilotApplication } from '../../../../models/CopilotApplication' +import { FormattedMembers } from '../../../../services/members' + +import styles from './styles.module.scss' + +const tableColumns: TableColumn[] = [ + { + label: 'Topcoder Handle', + propertyName: 'handle', + renderer: (copilotApplication: CopilotApplication) => ( + + {copilotApplication.handle} + + ), + type: 'element', + }, + { + label: 'Fulfillment Rating', + propertyName: 'fulfilment', + type: 'text', + }, + { + label: 'Active Projects', + propertyName: 'activeProjects', + type: 'text', + }, + { + label: 'Applied Date', + propertyName: 'createdAt', + type: 'date', + }, + { + label: 'Notes', + propertyName: 'notes', + renderer: (copilotApplication: CopilotApplication) => ( +
+ {copilotApplication.notes} +
+ ), + type: 'element', + }, +] + +const CopilotApplications: FC<{ + copilotApplications?: CopilotApplication[] + members?: FormattedMembers[] +}> = props => { + const getData = (): CopilotApplication[] => (props.copilotApplications ? props.copilotApplications.map(item => { + const member = props.members && props.members.find(each => each.userId === item.userId) + return { + ...item, + activeProjects: member?.activeProjects || 0, + fulfilment: member?.copilotFulfillment || 0, + handle: member?.handle, + } + }) + .sort((a, b) => (b.fulfilment || 0) - (a.fulfilment || 0)) : []) + + const tableData = useMemo(getData, [props.copilotApplications, props.members]) + + return ( +
+ { + tableData.length > 0 && ( + + ) + } + + ) +} + +export default CopilotApplications diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/index.ts b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/index.ts new file mode 100644 index 000000000..1c2945ec9 --- /dev/null +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/index.ts @@ -0,0 +1 @@ +export { default as CopilotApplications } from './CopilotApplications' diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/styles.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx new file mode 100644 index 000000000..847dbc6c9 --- /dev/null +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react' + +import { CopilotOpportunity } from '../../../../models/CopilotOpportunity' + +import styles from './styles.module.scss' + +const OpportunityDetails: FC<{ + opportunity?: CopilotOpportunity +}> = props => ( +
+
+

Required skills

+
+ {props.opportunity?.skills.map((skill: any) => ( +
+ {skill.name} +
+ ))} +
+

Description

+

+ {props.opportunity?.overview} +

+
+
+

Complexity

+ {props.opportunity?.complexity} + +

Requires Communication

+ {props.opportunity?.requiresCommunication} +
+
+) + +export default OpportunityDetails diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/index.ts b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/index.ts new file mode 100644 index 000000000..d4a80895f --- /dev/null +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/index.ts @@ -0,0 +1 @@ +export { default as OpportunityDetails } from './OpportunityDetails' diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss new file mode 100644 index 000000000..8debc451e --- /dev/null +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss @@ -0,0 +1,36 @@ +@import '@libs/ui/styles/includes'; + +.subHeading { + margin-top: $sp-8; +} + +.content { + margin-top: $sp-6; + display: flex; + flex-direction: row; + gap: 100px; +} + +.content > div:first-child { + flex: 3; +} + +.content > div:last-child { + flex: 1; +} + +.skillsContainer { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: $sp-2; +} + +.skillPill { + background-color: $teal-100; + color: white; + padding: 4px 8px; + border-radius: 10px; + white-space: nowrap; + font-size: 14px; +} \ No newline at end of file diff --git a/src/apps/copilots/src/services/members.ts b/src/apps/copilots/src/services/members.ts new file mode 100644 index 000000000..6926bd805 --- /dev/null +++ b/src/apps/copilots/src/services/members.ts @@ -0,0 +1,70 @@ +import useSWR, { SWRResponse } from 'swr' + +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +interface Member { + firstName: string, + lastName: string, + handle: string, + userId: number, + stats: { + COPILOT: { + activeProjects: number, + fulfillment: number, + } + }[] +} + +export interface FormattedMembers extends Member { + copilotFulfillment: number, + activeProjects: number, +} + +export type MembersResponse = SWRResponse + +/** + * Gets a list of members given a list of user ids. + * @param userIds User Ids. + */ +export const getMembersByUserIds = async ( + userIds: string[], +): Promise> => { + let qs = '' + userIds.forEach(userId => { + qs += `&userIds[]=${userId.toLowerCase()}` + }) + + return xhrGetAsync>( + `${EnvironmentConfig.API.V5}/members?${qs}`, + ) +} + +const membersFactory = (members: Member[]): FormattedMembers[] => members.map(member => ({ + ...member, + activeProjects: member.stats.find(item => item.COPILOT.activeProjects)?.COPILOT.activeProjects || 0, + copilotFulfillment: member.stats.find(item => item.COPILOT.fulfillment)?.COPILOT.fulfillment || 0, +})) + +/** + * Custom hook to fetch members by list of user ids + * + * @param {string} userIds - List of user ids + * @returns {MembersResponse} - The response containing the list of members data + */ +export const useMembers = (userIds: number[]): MembersResponse => { + let qs = '' + userIds.forEach(userId => { + qs += `&userIds[]=${userId}` + }) + const url = `${EnvironmentConfig.API.V5}/members?${qs}` + + const fetcher = (urlp: string): Promise => xhrGetAsync(urlp) + .then(data => membersFactory(data)) + .catch(() => []) + + return useSWR(url, fetcher, { + refreshInterval: 0, + revalidateOnFocus: false, + }) +} diff --git a/src/libs/ui/lib/components/table/Table.tsx b/src/libs/ui/lib/components/table/Table.tsx index af302b8b1..7bdc00170 100644 --- a/src/libs/ui/lib/components/table/Table.tsx +++ b/src/libs/ui/lib/components/table/Table.tsx @@ -30,6 +30,7 @@ interface TableProps { readonly onRowClick?: (data: T) => void readonly onToggleSort?: (sort: Sort) => void readonly removeDefaultSort?: boolean + readonly preventDefault?: boolean } interface DefaultSortDirectionMap { @@ -180,6 +181,7 @@ const Table: (props: TableProps) = columns={props.columns} index={index} showExpand={props.showExpand} + preventDefault={props.preventDefault} /> )) diff --git a/src/libs/ui/lib/components/table/table-row/TableRow.tsx b/src/libs/ui/lib/components/table/table-row/TableRow.tsx index 60fd3be33..a038dfd51 100644 --- a/src/libs/ui/lib/components/table/table-row/TableRow.tsx +++ b/src/libs/ui/lib/components/table/table-row/TableRow.tsx @@ -21,6 +21,7 @@ interface Props { readonly columns: ReadonlyArray> index: number readonly showExpand?: boolean + readonly preventDefault?: boolean } export const TableRow: ( @@ -75,7 +76,10 @@ export const TableRow: ( onClick={function onRowClick( event: MouseEvent, ): void { - event.preventDefault() + if (!props.preventDefault) { + event.preventDefault() + } + props.onRowClick?.(props.data) }} >