Skip to content

Commit 0f7847f

Browse files
Merge pull request #1015 from topcoder-platform/PM-589
PM-589 Copilot opportunity listing page
2 parents b1312ae + 62b1fce commit 0f7847f

File tree

7 files changed

+237
-14
lines changed

7 files changed

+237
-14
lines changed

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { lazyLoad, LazyLoadedComponent, PlatformRoute, Rewrite, UserRole } from '~/libs/core'
1+
import { lazyLoad, LazyLoadedComponent, PlatformRoute, UserRole } from '~/libs/core'
22
import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config'
33

44
const CopilotsApp: LazyLoadedComponent = lazyLoad(() => import('./CopilotsApp'))
5+
const CopilotOpportunityList: LazyLoadedComponent = lazyLoad(() => import('./pages/copilot-opportunity-list/index'))
56
const CopilotsRequests: LazyLoadedComponent = lazyLoad(() => import('./pages/copilot-requests/index'))
67
const CopilotsRequestForm: LazyLoadedComponent = lazyLoad(() => import('./pages/copilot-request-form/index'))
78

@@ -13,20 +14,25 @@ export const toolTitle: string = ToolTitle.copilots
1314
export const absoluteRootRoute: string = `${window.location.origin}${rootRoute}`
1415

1516
export const childRoutes = [
17+
{
18+
element: <CopilotOpportunityList />,
19+
id: 'CopilotOpportunityList',
20+
route: '/',
21+
},
1622
{
1723
element: <CopilotsRequests />,
1824
id: 'CopilotRequests',
19-
route: 'requests',
25+
route: '/requests',
2026
},
2127
{
2228
element: <CopilotsRequestForm />,
2329
id: 'CopilotRequestForm',
24-
route: 'requests/new',
30+
route: '/requests/new',
2531
},
2632
{
2733
element: <CopilotsRequests />,
2834
id: 'CopilotRequestDetails',
29-
route: 'requests/:requestId',
35+
route: '/requests/:requestId',
3036
},
3137
] as const
3238

@@ -35,17 +41,13 @@ type RouteMap = {
3541
};
3642

3743
export const copilotRoutesMap = childRoutes.reduce((allRoutes, route) => (
38-
Object.assign(allRoutes, { [route.id]: `${rootRoute}/${route.route}` })
44+
Object.assign(allRoutes, { [route.id]: `${rootRoute}${route.route}` })
3945
), {} as RouteMap)
4046

4147
export const copilotsRoutes: ReadonlyArray<PlatformRoute> = [
4248
{
4349
authRequired: true,
4450
children: [
45-
{
46-
element: <Rewrite to={childRoutes[0].route} />,
47-
route: '',
48-
},
4951
...childRoutes,
5052

5153
],
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { UserSkill } from '~/libs/core'
2+
3+
import { ProjectType } from '../constants'
4+
5+
export interface CopilotOpportunity {
6+
id: number,
7+
copilotRequestId: number,
8+
status: string,
9+
type: ProjectType,
10+
projectId: string,
11+
projectName: string,
12+
projectType: ProjectType,
13+
complexity: 'high' | 'medium' | 'low',
14+
copilotUsername: string,
15+
numHoursPerWeek: number,
16+
numWeeks: number,
17+
overview: string,
18+
paymentType: string,
19+
otherPaymentType: string,
20+
requiresCommunication: 'yes' | 'no',
21+
skills: UserSkill[],
22+
startDate: Date,
23+
tzRestrictions: 'yes' | 'no',
24+
createdAt: Date,
25+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { FC, useMemo } from 'react'
2+
import classNames from 'classnames'
3+
4+
import {
5+
ContentLayout,
6+
LoadingSpinner,
7+
PageTitle,
8+
Table,
9+
TableColumn,
10+
} from '~/libs/ui'
11+
12+
import { CopilotOpportunity } from '../../models/CopilotOpportunity'
13+
import { CopilotOpportunitiesResponse, useCopilotOpportunities } from '../../services/copilot-opportunities'
14+
15+
import styles from './styles.module.scss'
16+
17+
const tableColumns: TableColumn<CopilotOpportunity>[] = [
18+
{
19+
label: 'Title',
20+
propertyName: 'projectName',
21+
type: 'text',
22+
},
23+
{
24+
label: 'Status',
25+
propertyName: 'status',
26+
renderer: (copilotOpportunity: CopilotOpportunity) => (
27+
<div className={classNames(styles.status, copilotOpportunity.status === 'active' && styles.activeStatus)}>
28+
{copilotOpportunity.status}
29+
</div>
30+
),
31+
type: 'element',
32+
},
33+
{
34+
label: 'Skills Required',
35+
propertyName: 'skills',
36+
renderer: (copilotOpportunity: CopilotOpportunity) => (
37+
<div className={styles.skillsContainer}>
38+
{copilotOpportunity.skills.map((skill: any) => (
39+
<div key={skill.id} className={styles.skillPill}>
40+
{skill.name}
41+
</div>
42+
))}
43+
</div>
44+
),
45+
type: 'element',
46+
},
47+
{
48+
label: 'Type',
49+
propertyName: 'type',
50+
type: 'text',
51+
},
52+
{
53+
label: 'Starting Date',
54+
propertyName: 'startDate',
55+
type: 'date',
56+
},
57+
{
58+
label: 'Complexity',
59+
propertyName: 'complexity',
60+
type: 'text',
61+
},
62+
{
63+
label: 'Hours per week needed',
64+
propertyName: 'numHoursPerWeek',
65+
type: 'number',
66+
},
67+
{
68+
label: 'Payment',
69+
propertyName: 'paymentType',
70+
type: 'text',
71+
},
72+
]
73+
74+
const CopilotOpportunityList: FC<{}> = () => {
75+
76+
const {
77+
data: opportunities, isValidating, size, setSize,
78+
}: CopilotOpportunitiesResponse = useCopilotOpportunities()
79+
80+
const tableData = useMemo(() => opportunities, [opportunities])
81+
82+
function loadMore(): void {
83+
setSize(size + 1)
84+
}
85+
86+
const opportunitiesLoading = isValidating
87+
88+
return (
89+
<ContentLayout
90+
title='Copilot Opportunities'
91+
>
92+
<PageTitle>Copilot Opportunities</PageTitle>
93+
<Table
94+
columns={tableColumns}
95+
data={tableData}
96+
moreToLoad={isValidating || opportunities.length > 0}
97+
onLoadMoreClick={loadMore}
98+
/>
99+
{opportunitiesLoading && (
100+
<LoadingSpinner overlay />
101+
) }
102+
103+
</ContentLayout>
104+
)
105+
}
106+
107+
export default CopilotOpportunityList
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
@import '@libs/ui/styles/includes';
2+
3+
.skillsContainer {
4+
display: flex;
5+
flex-wrap: wrap;
6+
gap: 8px;
7+
}
8+
9+
.skillPill {
10+
background-color: #aaaaaa;
11+
color: #333;
12+
padding: 4px 8px;
13+
border-radius: 10px;
14+
white-space: nowrap;
15+
font-size: 14px;
16+
}
17+
18+
.status {
19+
color: orange;
20+
font-weight: bold;
21+
padding: 4px 8px;
22+
border-radius: 4px;
23+
display: inline-block;
24+
text-transform: capitalize;
25+
}
26+
27+
.activeStatus {
28+
color: green;
29+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite'
2+
3+
import { EnvironmentConfig } from '~/config'
4+
import { xhrGetAsync } from '~/libs/core'
5+
6+
import { CopilotOpportunity } from '../models/CopilotOpportunity'
7+
8+
const baseUrl = `${EnvironmentConfig.API.V5}/projects`
9+
10+
const PAGE_SIZE = 20
11+
12+
/**
13+
* Creates a CopilotOpportunity object by merging the provided data and its nested data,
14+
* setting specific properties, and formatting the createdAt date.
15+
*
16+
* @param data - The input data object containing the properties to be merged and transformed.
17+
* @returns A new CopilotOpportunity object with the transformed properties.
18+
*/
19+
function copilotOpportunityFactory(data: any): CopilotOpportunity {
20+
return {
21+
...data,
22+
...data.data,
23+
projectName: data.project.name,
24+
}
25+
}
26+
27+
export interface CopilotOpportunitiesResponse {
28+
isValidating: boolean;
29+
data: CopilotOpportunity[];
30+
size: number;
31+
setSize: (size: number) => void;
32+
}
33+
34+
/**
35+
* Custom hook to fetch all copilot requests.
36+
*
37+
* @returns {CopilotOpportunitiesResponse} - The response containing copilot opportunities.
38+
*/
39+
export const useCopilotOpportunities = (): CopilotOpportunitiesResponse => {
40+
const getKey = (pageIndex: number, previousPageData: CopilotOpportunity[]): string | undefined => {
41+
if (previousPageData && previousPageData.length < PAGE_SIZE) return undefined
42+
return `${baseUrl}/copilots/opportunities?page=${pageIndex + 1}&pageSize=${PAGE_SIZE}&sort=createdAt desc`
43+
}
44+
45+
const fetcher = (url: string): Promise<CopilotOpportunity[]> => xhrGetAsync<CopilotOpportunity[]>(url)
46+
.then((data: any) => data.map(copilotOpportunityFactory))
47+
48+
const {
49+
isValidating,
50+
data = [],
51+
size,
52+
setSize,
53+
}: SWRInfiniteResponse<CopilotOpportunity[]> = useSWRInfinite(getKey, fetcher, {
54+
revalidateOnFocus: false,
55+
})
56+
57+
// Flatten data array
58+
const opportunities = data ? data.flat() : []
59+
60+
return { data: opportunities, isValidating, setSize: (s: number) => { setSize(s) }, size }
61+
}

src/libs/ui/lib/components/table/Table.module.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,5 @@
9696
display: flex;
9797
justify-content: center;
9898
}
99+
100+

src/libs/ui/lib/components/table/table-cell/TableCell.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,16 @@ const TableCell: <T extends { [propertyName: string]: any }>(
3131
) => {
3232
const ContainerTag = props.as ?? 'td'
3333
let data: string | JSX.Element | undefined
34+
const rawDate = props.data[props.propertyName as string]
3435
switch (props.type) {
3536
case 'date':
36-
data = textFormatDateLocaleShortString(
37-
props.data[props.propertyName as string] as Date,
38-
)
37+
data = textFormatDateLocaleShortString(new Date(rawDate))
3938
break
40-
4139
case 'action':
4240
case 'element':
4341
case 'numberElement':
4442
data = props.renderer?.(props.data)
4543
break
46-
4744
case 'money':
4845
data = textFormatMoneyLocaleString(
4946
props.data[props.propertyName as string],

0 commit comments

Comments
 (0)