Skip to content

Commit 8b579cf

Browse files
Merge pull request #1018 from topcoder-platform/PM-589
PM-590 Copilot opportunity details page
2 parents 0f7847f + 634aa25 commit 8b579cf

File tree

5 files changed

+258
-0
lines changed

5 files changed

+258
-0
lines changed

src/apps/copilots/src/copilots.routes.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const CopilotsApp: LazyLoadedComponent = lazyLoad(() => import('./CopilotsApp'))
55
const CopilotOpportunityList: LazyLoadedComponent = lazyLoad(() => import('./pages/copilot-opportunity-list/index'))
66
const CopilotsRequests: LazyLoadedComponent = lazyLoad(() => import('./pages/copilot-requests/index'))
77
const CopilotsRequestForm: LazyLoadedComponent = lazyLoad(() => import('./pages/copilot-request-form/index'))
8+
const CopilotOpportunityDetails: LazyLoadedComponent = lazyLoad(
9+
() => import('./pages/copilot-opportunity-details/index'),
10+
)
811

912
export const rootRoute: string = (
1013
EnvironmentConfig.SUBDOMAIN === AppSubdomain.copilots ? '' : `/${AppSubdomain.copilots}`
@@ -34,6 +37,11 @@ export const childRoutes = [
3437
id: 'CopilotRequestDetails',
3538
route: '/requests/:requestId',
3639
},
40+
{
41+
element: <CopilotOpportunityDetails />,
42+
id: 'CopilotOpportunityDetails',
43+
route: '/opportunity/:opportunityId',
44+
},
3745
] as const
3846

3947
type RouteMap = {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { FC, useEffect, useState } from 'react'
2+
import { useNavigate, useParams } from 'react-router-dom'
3+
4+
import {
5+
ContentLayout,
6+
IconOutline,
7+
LoadingSpinner,
8+
PageTitle,
9+
} from '~/libs/ui'
10+
11+
import { CopilotOpportunityResponse, useCopilotOpportunity } from '../../services/copilot-opportunities'
12+
import { copilotRoutesMap } from '../../copilots.routes'
13+
14+
import styles from './styles.module.scss'
15+
16+
const CopilotOpportunityDetails: FC<{}> = () => {
17+
const { opportunityId }: {opportunityId?: string} = useParams<{ opportunityId?: string }>()
18+
const navigate = useNavigate()
19+
const [showNotFound, setShowNotFound] = useState(false)
20+
21+
if (!opportunityId) {
22+
navigate(copilotRoutesMap.CopilotOpportunityList)
23+
}
24+
25+
const { data: opportunity, isValidating }: CopilotOpportunityResponse = useCopilotOpportunity(opportunityId)
26+
27+
useEffect(() => {
28+
const timer = setTimeout(() => {
29+
if (!opportunity) {
30+
setShowNotFound(true)
31+
}
32+
}, 2000)
33+
34+
return () => clearTimeout(timer) // Cleanup on unmount
35+
}, [opportunity])
36+
37+
if (!opportunity && showNotFound) {
38+
return (
39+
<ContentLayout title='Copilot Opportunity Details'>
40+
<PageTitle>Opportunity Not Found</PageTitle>
41+
<p>The requested opportunity does not exist.</p>
42+
</ContentLayout>
43+
)
44+
}
45+
46+
return (
47+
<ContentLayout title='Copilot Opportunity'>
48+
<PageTitle>Copilot Opportunity</PageTitle>
49+
{isValidating && !showNotFound && (
50+
<LoadingSpinner />
51+
) }
52+
<h1 className={styles.header}>
53+
{opportunity?.projectName}
54+
</h1>
55+
<div className={styles.infoRow}>
56+
<div className={styles.infoColumn}>
57+
<IconOutline.ClipboardCheckIcon className={styles.icon} />
58+
<div className={styles.infoText}>
59+
<span className={styles.infoHeading}>Status</span>
60+
<span className={styles.infoValue}>{opportunity?.status}</span>
61+
</div>
62+
</div>
63+
<div className={styles.infoColumn}>
64+
<IconOutline.CalendarIcon className={styles.icon} />
65+
<div className={styles.infoText}>
66+
<span className={styles.infoHeading}>Duration</span>
67+
<span className={styles.infoValue}>
68+
{opportunity?.numWeeks}
69+
{' '}
70+
weeks
71+
</span>
72+
</div>
73+
</div>
74+
<div className={styles.infoColumn}>
75+
<IconOutline.ClockIcon className={styles.icon} />
76+
<div className={styles.infoText}>
77+
<span className={styles.infoHeading}>Hours</span>
78+
<span className={styles.infoValue}>
79+
{opportunity?.numHoursPerWeek}
80+
{' '}
81+
hours/week
82+
</span>
83+
</div>
84+
</div>
85+
<div className={styles.infoColumn}>
86+
<IconOutline.CogIcon className={styles.icon} />
87+
<div className={styles.infoText}>
88+
<span className={styles.infoHeading}>Type</span>
89+
<span className={styles.infoValue}>{opportunity?.type}</span>
90+
</div>
91+
</div>
92+
<div className={styles.infoColumn}>
93+
<IconOutline.GlobeAltIcon className={styles.icon} />
94+
<div className={styles.infoText}>
95+
<span className={styles.infoHeading}>Working Hours</span>
96+
<span className={styles.infoValue}>{opportunity?.tzRestrictions}</span>
97+
</div>
98+
</div>
99+
</div>
100+
<div className={styles.content}>
101+
<div>
102+
<h2 className={styles.subHeading}> Required skills </h2>
103+
<div className={styles.skillsContainer}>
104+
{opportunity?.skills.map((skill: any) => (
105+
<div key={skill.id} className={styles.skillPill}>
106+
{skill.name}
107+
</div>
108+
))}
109+
</div>
110+
<h2 className={styles.subHeading}> Description </h2>
111+
<p>
112+
{opportunity?.overview}
113+
</p>
114+
</div>
115+
<div>
116+
<h2 className={styles.subHeading}> Complexity </h2>
117+
<span className={styles.textCaps}>{opportunity?.complexity}</span>
118+
119+
<h2 className={styles.subHeading}> Requires Communication </h2>
120+
<span className={styles.textCaps}>{opportunity?.requiresCommunication}</span>
121+
</div>
122+
</div>
123+
</ContentLayout>
124+
)
125+
}
126+
127+
export default CopilotOpportunityDetails
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
@import '@libs/ui/styles/includes';
2+
3+
.header {
4+
display: flex;
5+
align-items: center;
6+
justify-content: center;
7+
text-transform: uppercase;
8+
font-family: $font-barlow-condensed;
9+
font-size: 50px;
10+
font-weight: $font-weight-medium;
11+
margin-top: $sp-2;
12+
padding: $sp-6 0;
13+
color: $teal-100;
14+
}
15+
16+
.subHeading {
17+
margin-top: $sp-8;
18+
}
19+
20+
.content {
21+
margin-top: $sp-6;
22+
display: flex;
23+
flex-direction: row;
24+
gap: 100px;
25+
}
26+
27+
.content > div:first-child {
28+
flex: 3;
29+
}
30+
31+
.content > div:last-child {
32+
flex: 1;
33+
}
34+
35+
.skillsContainer {
36+
display: flex;
37+
flex-wrap: wrap;
38+
gap: 8px;
39+
margin-top: $sp-2;
40+
}
41+
42+
.skillPill {
43+
background-color: $teal-100;
44+
color: white;
45+
padding: 4px 8px;
46+
border-radius: 10px;
47+
white-space: nowrap;
48+
font-size: 14px;
49+
}
50+
51+
.infoRow {
52+
display: flex;
53+
align-items: center;
54+
justify-content: center;
55+
gap: 20px;
56+
margin: 16px 80px;
57+
padding: 12px 0;
58+
}
59+
60+
.infoText {
61+
display: flex;
62+
flex-direction: column;
63+
}
64+
65+
.infoHeading {
66+
font-size: 14px;
67+
color: #666;
68+
}
69+
70+
.infoValue {
71+
font-size: 14px;
72+
font-weight: 600;
73+
text-transform: capitalize;
74+
color: #333;
75+
}
76+
77+
.infoColumn {
78+
display: flex;
79+
align-items: center;
80+
gap: 8px;
81+
flex: 1;
82+
min-width: 120px;
83+
}
84+
85+
.icon {
86+
width: 30px;
87+
height: 30px;
88+
color: $teal-100;
89+
}
90+
91+
.textCaps {
92+
text-transform: capitalize;
93+
}

src/apps/copilots/src/pages/copilot-opportunity-list/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FC, useMemo } from 'react'
2+
import { useNavigate } from 'react-router-dom'
23
import classNames from 'classnames'
34

45
import {
@@ -10,6 +11,7 @@ import {
1011
} from '~/libs/ui'
1112

1213
import { CopilotOpportunity } from '../../models/CopilotOpportunity'
14+
import { copilotRoutesMap } from '../../copilots.routes'
1315
import { CopilotOpportunitiesResponse, useCopilotOpportunities } from '../../services/copilot-opportunities'
1416

1517
import styles from './styles.module.scss'
@@ -72,6 +74,7 @@ const tableColumns: TableColumn<CopilotOpportunity>[] = [
7274
]
7375

7476
const CopilotOpportunityList: FC<{}> = () => {
77+
const navigate = useNavigate()
7578

7679
const {
7780
data: opportunities, isValidating, size, setSize,
@@ -83,6 +86,10 @@ const CopilotOpportunityList: FC<{}> = () => {
8386
setSize(size + 1)
8487
}
8588

89+
function handleRowClick(opportunity: CopilotOpportunity): void {
90+
navigate(copilotRoutesMap.CopilotOpportunityDetails.replace(':opportunityId', `${opportunity.id}`))
91+
}
92+
8693
const opportunitiesLoading = isValidating
8794

8895
return (
@@ -95,6 +102,7 @@ const CopilotOpportunityList: FC<{}> = () => {
95102
data={tableData}
96103
moreToLoad={isValidating || opportunities.length > 0}
97104
onLoadMoreClick={loadMore}
105+
onRowClick={handleRowClick}
98106
/>
99107
{opportunitiesLoading && (
100108
<LoadingSpinner overlay />

src/apps/copilots/src/services/copilot-opportunities.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import useSWR, { SWRResponse } from 'swr'
12
import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite'
23

34
import { EnvironmentConfig } from '~/config'
45
import { xhrGetAsync } from '~/libs/core'
6+
import { buildUrl } from '~/libs/shared/lib/utils/url'
57

68
import { CopilotOpportunity } from '../models/CopilotOpportunity'
79

@@ -59,3 +61,23 @@ export const useCopilotOpportunities = (): CopilotOpportunitiesResponse => {
5961

6062
return { data: opportunities, isValidating, setSize: (s: number) => { setSize(s) }, size }
6163
}
64+
65+
export type CopilotOpportunityResponse = SWRResponse<CopilotOpportunity, CopilotOpportunity>
66+
67+
/**
68+
* Custom hook to fetch copilot opportunity by id.
69+
*
70+
* @param {string} opportunityId - The unique identifier of the copilot request.
71+
* @returns {CopilotOpportunityResponse} - The response containing the copilot request data.
72+
*/
73+
export const useCopilotOpportunity = (opportunityId?: string): CopilotOpportunityResponse => {
74+
const url = opportunityId ? buildUrl(`${baseUrl}/copilots/opportunities/${opportunityId}`) : undefined
75+
76+
const fetcher = (urlp: string): Promise<CopilotOpportunity> => xhrGetAsync<CopilotOpportunity>(urlp)
77+
.then(copilotOpportunityFactory)
78+
79+
return useSWR(url, fetcher, {
80+
refreshInterval: 0,
81+
revalidateOnFocus: false,
82+
})
83+
}

0 commit comments

Comments
 (0)