diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index c24fba170..487ae60d6 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -21,6 +21,18 @@ const ManageUserPage: LazyLoadedComponent = lazyLoad( 'ManageUserPage', ) +const ReviewManagement: LazyLoadedComponent = lazyLoad( + () => import('./review-management/ReviewManagement'), +) +const ReviewManagementPage: LazyLoadedComponent = lazyLoad( + () => import('./review-management/ReviewManagementPage'), + 'ReviewManagementPage', +) +const ManageReviewerPage: LazyLoadedComponent = lazyLoad( + () => import('./review-management/ManageReviewerPage'), + 'ManageReviewerPage', +) + export const toolTitle: string = ToolTitle.admin export const rootRoute: string = EnvironmentConfig.SUBDOMAIN === AppSubdomain.admin @@ -28,6 +40,7 @@ export const rootRoute: string : `/${AppSubdomain.admin}` export const manageChallengeRouteId = 'challenge-management' +export const manageReviewRouteId = 'review-management' export const adminRoutes: ReadonlyArray = [ // Admin App Root @@ -56,6 +69,24 @@ export const adminRoutes: ReadonlyArray = [ id: manageChallengeRouteId, route: manageChallengeRouteId, }, + // Reviewer Management Module + { + children: [ + { + element: , + id: 'review-management-page', + route: '', + }, + { + element: , + id: 'manage-reviewer', + route: ':challengeId/manage-reviewer', + }, + ], + element: , + id: manageReviewRouteId, + route: manageReviewRouteId, + }, ], domain: AppSubdomain.admin, element: , diff --git a/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/RejectPendingConfirmDialog.module.scss b/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/RejectPendingConfirmDialog.module.scss new file mode 100644 index 000000000..552549188 --- /dev/null +++ b/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/RejectPendingConfirmDialog.module.scss @@ -0,0 +1,8 @@ +.rejectPendingConfirmDialog { + .actionButtons { + display: flex; + justify-content: flex-end; + gap: 6px; + padding-top: 32px; + } +} diff --git a/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/RejectPendingConfirmDialog.tsx b/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/RejectPendingConfirmDialog.tsx new file mode 100644 index 000000000..9ce3c0ebc --- /dev/null +++ b/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/RejectPendingConfirmDialog.tsx @@ -0,0 +1,40 @@ +import { FC } from 'react' + +import { BaseModal, Button } from '~/libs/ui' + +import { useEventCallback } from '../../hooks' + +import styles from './RejectPendingConfirmDialog.module.scss' + +interface RejectPendingConfirmDialogProps { + open: boolean + reject: () => void + setOpen: (isOpen: boolean) => void +} + +const RejectPendingConfirmDialog: FC = props => { + const handleClose = useEventCallback(() => props.setOpen(false)) + const handleRemove = useEventCallback(() => { + props.setOpen(false) + props.reject() + }) + return ( + +
+

+ Are you sure? You want to reject pending? +

+
+ + +
+
+
+ ) +} + +export default RejectPendingConfirmDialog diff --git a/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/index.ts b/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/index.ts new file mode 100644 index 000000000..572085eb4 --- /dev/null +++ b/src/apps/admin/src/lib/components/RejectPendingConfirmDialog/index.ts @@ -0,0 +1 @@ +export { default as RejectPendingConfirmDialog } from './RejectPendingConfirmDialog' diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.module.scss b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.module.scss new file mode 100644 index 000000000..47dc4e423 --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.module.scss @@ -0,0 +1,70 @@ +@import '@libs/ui/styles/includes'; +@import '@libs/ui/styles/typography'; + +.mobileListView { + > *:nth-child(odd) { + background: $black-5; + } +} + +.mobileListViewItemContainer { + display: grid; + grid-template-columns: 58px 1fr; + padding: $sp-4; + border-radius: $sp-2; + margin-top: 16px; + grid-template-columns: 1fr; + + .rows { + display: flex; + flex-direction: column; + gap: 16px; + + .row1, + .row2, + .row3, + .row4, + .row5, + .row6, + .row7 { + display: grid; + gap: 16px 32px; + align-items: center; + } + + .row1 { + grid-template-columns: 1fr auto; + + button { + white-space: break-spaces; + } + } + + .row2, + .row3, + .row4 { + grid-template-columns: 1fr auto; + } + + .row5 { + justify-content: flex-end; + } + } + + @media (max-width: 567px) { + gap: 8px; + } +} + +.propertyElement { + @extend .medium-subtitle; + word-break: break-word; + width: fit-content; +} + +.propertyElementLabel { + color: $black-60; + font-size: 11px; + line-height: 14px; + text-transform: uppercase; +} diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.tsx b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.tsx new file mode 100644 index 000000000..dee9332af --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.tsx @@ -0,0 +1,99 @@ +import { FC } from 'react' + +import { TableColumn } from '~/libs/ui/lib/components/table' + +import { ReviewSummary } from '../../../models' + +import styles from './MobileListView.module.scss' + +type Property = TableColumn + +export interface MobileListViewProps { + properties: ReadonlyArray> + data: T[] +} + +const PropertyElement = (props: Property & { data: T }): JSX.Element => { + let item: JSX.Element | undefined + switch (props.type) { + case 'element': { + item = props.renderer?.(props.data) + break + } + + case 'action': { + item = props.renderer?.(props.data) + break + } + + case 'text': + case 'number': { + item = ( + <> + {props.propertyName + ? (props.data as { [K: string]: unknown })[ + props.propertyName + ] + : undefined} + + ) + break + } + + default: { + item = undefined + } + } + + return
{item}
+} + +const MobileListView: FC> = props => { + const renderListItem = (d: ReviewSummary): JSX.Element => { + const propertyElements = props.properties.map(p => ( + + )) + + const propertyElementLabels = props.properties.map(p => ( +
{`${p.label}`}
+ )) + + return ( +
+
+
+ {/* Title */ propertyElements[1]} + {/* Status */ propertyElements[4]} +
+
+ {/* Legacy ID */ propertyElements[2]} +
+
+ {propertyElementLabels[5]} + {/* Open Review Opp' */ propertyElements[5]} +
+
+ {propertyElementLabels[6]} + {/* Review Applications */ propertyElements[6]} +
+
+ {/* Action */ propertyElements[7]} +
+
+
+ ) + } + + return ( +
+ {props.data.map(renderListItem)} +
+ ) +} + +export default MobileListView diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/index.ts b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/index.ts new file mode 100644 index 000000000..2eba7abce --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/index.ts @@ -0,0 +1,2 @@ +export * from './MobileListView' +export { default as MobileListView } from './MobileListView' diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.module.scss b/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.module.scss new file mode 100644 index 000000000..937b80419 --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.module.scss @@ -0,0 +1,34 @@ +@import '@libs/ui/styles/includes'; +@import '../../styles'; + +.reviewSummaryList { + padding: 0 15px; + + table tbody tr td:nth-last-child(3) { + text-align: center; + } +} + +.challengeTitle { + min-width: 200px; + padding: 0; + justify-content: flex-start; + border-radius: 0; + color: $body-color; + line-height: 16px; + white-space: break-spaces; + + &:hover { + color: $blue-110; + } +} + +.rowActions { + display: flex; + align-items: center; + gap: 5px; + + > * { + flex: none; + } +} diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.tsx b/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.tsx new file mode 100644 index 000000000..ae2103f54 --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.tsx @@ -0,0 +1,152 @@ +import { FC, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' + +import { useWindowSize, WindowSize } from '~/libs/shared' +import { Button, LinkButton, Table, type TableColumn } from '~/libs/ui' +import { Sort } from '~/apps/gamification-admin/src/game-lib/pagination' + +import { Pagination } from '../common/Pagination' +import { useEventCallback } from '../../hooks' +import { ReviewFilterCriteria, ReviewSummary } from '../../models' +import { Paging } from '../../models/challenge-management/Pagination' + +import { MobileListView } from './MobileListView' +import styles from './ReviewSummaryList.module.scss' + +export interface ReviewListProps { + reviews: ReviewSummary[] + paging: Paging + currentFilters: ReviewFilterCriteria + onPageChange: (page: number) => void + onToggleSort: (sort: Sort) => void +} + +const Actions: FC<{ + review: ReviewSummary + currentFilters: ReviewFilterCriteria +}> = props => { + const navigate = useNavigate() + const goToManageReviewer = useEventCallback(() => { + navigate(`${props.review.legacyChallengeId}/manage-reviewer`, { + state: { previousReviewSummaryListFilter: props.currentFilters }, + }) + }) + + return ( +
+ +
+ ) +} + +const ChallengeTitle: FC<{ + review: ReviewSummary +}> = props => { + const goToChallenge = useEventCallback(() => { + window.location.href = `https://www.topcoder.com/challenges/${props.review.legacyChallengeId}` + }) + + return ( + + {props.review.challengeName} + + ) +} + +const ReviewSummaryList: FC = props => { + const columns = useMemo[]>( + () => [ + { + label: 'Challenge type', + propertyName: '', + type: 'text', + }, + { + label: 'Challenge Title', + propertyName: 'challengeName', + renderer: (review: ReviewSummary) => ( + + ), + type: 'element', + }, + { + label: 'Legacy ID', + propertyName: 'legacyChallengeId', + type: 'text', + }, + { + label: 'Current phase', + propertyName: '', + type: 'text', + }, + { label: 'Status', propertyName: 'challengeStatus', type: 'text' }, + // I think this column is important, and it exits in `admin-app` + // but resp does not have it, so I just comment it here + // { + // label: 'Submission End Date', + // propertyName: 'submissionEndDate', + // renderer: (review: ReviewSummary) => ( + // // eslint-disable-next-line jsx-a11y/anchor-is-valid + //
+ // {review.submissionEndDate} + // {/* {format( + // new Date(review.submissionEndDate), + // 'MMM dd, yyyy HH:mm' + // )} */} + //
+ // ), + // type: 'element', + // }, + { + label: 'Open Review Opp', + renderer: (review: ReviewSummary) => ( +
{review.numberOfReviewerSpots - review.numberOfApprovedApplications}
+ ), + type: 'element', + }, + { + label: 'Review Applications', + propertyName: 'numberOfPendingApplications', + type: 'number', + }, + { + label: '', + renderer: (review: ReviewSummary) => ( + + ), + type: 'action', + }, + ], + [], // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: props.currentFilters + ) + + const { width: screenWidth }: WindowSize = useWindowSize() + return ( +
+ {screenWidth > 1279 && ( + + )} + {screenWidth <= 1279 && ( + + )} + + + ) +} + +export default ReviewSummaryList diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/index.ts b/src/apps/admin/src/lib/components/ReviewSummaryList/index.ts new file mode 100644 index 000000000..67c1b69bf --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/index.ts @@ -0,0 +1 @@ +export { default as ReviewSummaryList } from './ReviewSummaryList' diff --git a/src/apps/admin/src/lib/components/ReviewerList/MobileListView/MobileListView.module.scss b/src/apps/admin/src/lib/components/ReviewerList/MobileListView/MobileListView.module.scss new file mode 100644 index 000000000..df93b0fb4 --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewerList/MobileListView/MobileListView.module.scss @@ -0,0 +1,71 @@ +@import '@libs/ui/styles/includes'; +@import '@libs/ui/styles/typography'; + +.mobileListView { + > *:nth-child(odd) { + background: $black-5; + } +} + +.mobileListViewItemContainer { + display: grid; + grid-template-columns: 58px 1fr; + padding: $sp-4; + border-radius: $sp-2; + margin-top: 16px; + grid-template-columns: 1fr; + + .rows { + display: flex; + flex-direction: column; + gap: 16px; + + .row1, + .row2, + .row3, + .row4, + .row5, + .row6, + .row7 { + display: grid; + gap: 16px 32px; + align-items: center; + } + + .row1, + .row2 { + grid-template-columns: 1fr auto; + + button { + white-space: break-spaces; + } + } + + .row3, + .row4, + .row5 { + grid-template-columns: 1fr auto; + } + + .row6 { + justify-content: flex-end; + } + } + + @media (max-width: 567px) { + gap: 8px; + } +} + +.propertyElement { + @extend .medium-subtitle; + word-break: break-word; + width: fit-content; +} + +.propertyElementLabel { + color: $black-60; + font-size: 11px; + line-height: 14px; + text-transform: uppercase; +} diff --git a/src/apps/admin/src/lib/components/ReviewerList/MobileListView/MobileListView.tsx b/src/apps/admin/src/lib/components/ReviewerList/MobileListView/MobileListView.tsx new file mode 100644 index 000000000..7190f66c7 --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewerList/MobileListView/MobileListView.tsx @@ -0,0 +1,104 @@ +import { FC } from 'react' + +import { TableColumn } from '~/libs/ui/lib/components/table' + +import { Reviewer } from '../../../models' + +import styles from './MobileListView.module.scss' + +type Property = TableColumn + +export interface MobileListViewProps { + properties: ReadonlyArray> + data: T[] +} + +const PropertyElement = (props: Property & { data: T }): JSX.Element => { + let item: JSX.Element | undefined + switch (props.type) { + case 'element': { + item = props.renderer?.(props.data) + break + } + + case 'action': { + item = props.renderer?.(props.data) + break + } + + case 'text': + case 'number': { + item = ( + <> + {props.propertyName + ? (props.data as { [K: string]: unknown })[ + props.propertyName + ] + : undefined} + + ) + break + } + + default: { + item = undefined + } + } + + return
{item}
+} + +const MobileListView: FC> = props => { + const renderListItem = (d: Reviewer): JSX.Element => { + const propertyElements = props.properties.map(p => ( + + )) + + const propertyElementLabels = props.properties.map(p => ( +
{`${p.label}`}
+ )) + + return ( +
+
+
+ {/* Handle */ propertyElements[0]} +
+
+ {/* Email */ propertyElements[1]} +
+
+ {/* Status */ propertyElements[2]} + {/* Received Date */ propertyElements[3]} +
+
+ {propertyElementLabels[4]} + {/* Open Review Opp' */ propertyElements[4]} +
+
+ {propertyElementLabels[5]} + {/* Latest Completed Reviews' */ propertyElements[5]} +
+
+ {/* Actions */ propertyElements[7]} +
+
+
+ ) + } + + return ( + <> +
+ {props.data.map(renderListItem)} +
+ + ) +} + +export default MobileListView diff --git a/src/apps/admin/src/lib/components/ReviewerList/MobileListView/index.ts b/src/apps/admin/src/lib/components/ReviewerList/MobileListView/index.ts new file mode 100644 index 000000000..2eba7abce --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewerList/MobileListView/index.ts @@ -0,0 +1,2 @@ +export * from './MobileListView' +export { default as MobileListView } from './MobileListView' diff --git a/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.module.scss b/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.module.scss new file mode 100644 index 000000000..ec41c0540 --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.module.scss @@ -0,0 +1,49 @@ +@import '../../styles/includes'; +@import '@libs/ui/styles/includes'; + +.reviewerList { + padding: 0 15px; + + table tbody tr td { + &:nth-last-child(3), + &:nth-last-child(4) { + text-align: center; + } + } +} + +.reviewerHandle, +.reviewerEmail { + min-width: 150px; + padding: 0; + justify-content: flex-start; + border-radius: 0; + color: $body-color; + line-height: 16px; + white-space: break-spaces; + + &:hover { + color: $blue-110; + } +} + +.reviewerEmail { + min-width: 200px; +} + + +.approvingLoadingSpinner { + display: inline-block; + width: 32px; + height: 32px; + margin: 0 auto; + background: none; + + :global { + svg { + width: inherit; + height: inherit; + } + } +} + diff --git a/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.tsx b/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.tsx new file mode 100644 index 000000000..5bf4444c1 --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.tsx @@ -0,0 +1,214 @@ +import { FC, useMemo } from 'react' +import { format } from 'date-fns' + +import { CheckIcon } from '@heroicons/react/solid' +import { useWindowSize, WindowSize } from '~/libs/shared' +import { + Button, + LinkButton, + LoadingSpinner, + Table, + type TableColumn, +} from '~/libs/ui' +import { Sort } from '~/apps/gamification-admin/src/game-lib/pagination' + +import { Reviewer } from '../../models' +import { useEventCallback } from '../../hooks' +import { Paging } from '../../models/challenge-management/Pagination' +import { Pagination } from '../common/Pagination' + +import { MobileListView } from './MobileListView' +import styles from './ReviewerList.module.scss' + +export interface ReviewerListProps { + reviewers: Reviewer[] + openReviews: number + paging: Paging + approvingReviewerId: number + onPageChange: (page: number) => void + onApproveApplication: (reviewer: Reviewer) => void + onToggleSort: (sort: Sort) => void +} + +const ApproveButton: FC<{ + reviewer: Reviewer + openReviews: number + approvingReviewerId: number + onApproveApplication: ReviewerListProps['onApproveApplication'] +}> = props => { + const handleApprove = useEventCallback((): void => { + props.onApproveApplication(props.reviewer) + }) + + const isApproving = props.approvingReviewerId === props.reviewer.userId + const isOtherApproving = props.approvingReviewerId > 0 + const hideApproveButton + = props.openReviews < 1 || props.reviewer.applicationStatus === 'Approved' + + return ( + <> + {isApproving ? ( + + ) : ( + !hideApproveButton && ( + + ) + )} + + ) +} + +const ReviewerHandle: FC<{ + reviewer: Reviewer +}> = props => { + const goToHandleUrl = useEventCallback(() => { + window.location.href = `https://profiles.topcoder.com/${props.reviewer.handle}` + }) + + return ( + + {props.reviewer.handle} + + ) +} + +const ReviewerMail: FC<{ + reviewer: Reviewer +}> = props => { + const mailTo = useEventCallback(() => { + window.open(`mailto:${props.reviewer.emailAddress}`, '_blank') + }) + + return ( + + {props.reviewer.emailAddress} + + ) +} + +const Actions: FC<{ + reviewer: Reviewer + openReviews: number + approvingReviewerId: number + onApproveApplication: ReviewerListProps['onApproveApplication'] +}> = props => ( +
+ +
+) + +const ReviewerList: FC = props => { + const columns = useMemo[]>( + () => [ + { + label: 'Reviewer', + propertyName: 'handle', + renderer: (reviewer: Reviewer) => ( + + ), + type: 'element', + }, + { + label: 'Email', + propertyName: 'emailAddress', + renderer: (reviewer: Reviewer) => ( + + ), + type: 'element', + }, + { + label: 'Application Status', + propertyName: 'applicationStatus', + type: 'text', + }, + { + label: 'Received Date', + propertyName: 'applicationDate', + renderer: (reviewer: Reviewer) => ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid +
+ {format( + new Date(reviewer.applicationDate), + 'MMM dd, yyyy HH:mm', + )} +
+ ), + type: 'element', + }, + { + label: 'Open Review Opp', + propertyName: '', + renderer: () => ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid +
+ {props.openReviews} +
+ ), + type: 'element', + }, + { + label: 'Latest Completed Reviews', + propertyName: 'reviewsInPast60Days', + type: 'number', + }, + { label: 'Matching Skills', propertyName: '', type: 'text' }, + { + label: '', + renderer: (reviewer: Reviewer) => ( + + ), + type: 'action', + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.openReviews, props.approvingReviewerId], + ) + + const { width: screenWidth }: WindowSize = useWindowSize() + return ( +
+ {screenWidth > 984 && ( +
+ )} + {screenWidth <= 984 && ( + + )} + + + ) +} + +export default ReviewerList diff --git a/src/apps/admin/src/lib/components/ReviewerList/index.ts b/src/apps/admin/src/lib/components/ReviewerList/index.ts new file mode 100644 index 000000000..95bc6b928 --- /dev/null +++ b/src/apps/admin/src/lib/components/ReviewerList/index.ts @@ -0,0 +1 @@ +export { default as ReviewerList } from './ReviewerList' diff --git a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts index 24b57c7e2..3e1bbb167 100644 --- a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts +++ b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts @@ -1,11 +1,15 @@ import { TabsNavItem } from '~/libs/ui' -import { manageChallengeRouteId } from '~/apps/admin/src/admin-app.routes' +import { manageChallengeRouteId, manageReviewRouteId } from '~/apps/admin/src/admin-app.routes' export const SystemAdminTabsConfig: TabsNavItem[] = [ { id: manageChallengeRouteId, title: 'Challenge Management', }, + { + id: manageReviewRouteId, + title: 'Review Management', + }, ] export function getTabIdFromPathName(pathname: string): string { @@ -13,5 +17,9 @@ export function getTabIdFromPathName(pathname: string): string { return manageChallengeRouteId } + if (pathname.includes(`/${manageReviewRouteId}`)) { + return manageReviewRouteId + } + return manageChallengeRouteId } diff --git a/src/apps/admin/src/lib/components/index.ts b/src/apps/admin/src/lib/components/index.ts index 09f2f94b3..82ad4f5a9 100644 --- a/src/apps/admin/src/lib/components/index.ts +++ b/src/apps/admin/src/lib/components/index.ts @@ -10,3 +10,6 @@ export * from './ChallengeList' export * from './ChallengeUserFilters' export * from './ChallengeUserList' export * from './ChallengeAddUserDialog' +export * from './ReviewSummaryList' +export * from './ReviewerList' +export * from './RejectPendingConfirmDialog' diff --git a/src/apps/admin/src/lib/contexts/ReviewManagementContextProvider.tsx b/src/apps/admin/src/lib/contexts/ReviewManagementContextProvider.tsx new file mode 100644 index 000000000..ec964e017 --- /dev/null +++ b/src/apps/admin/src/lib/contexts/ReviewManagementContextProvider.tsx @@ -0,0 +1,102 @@ +import { + Context, + createContext, + FC, + PropsWithChildren, + useCallback, + useMemo, + useState, +} from 'react' + +import { + ChallengeStatus, + ChallengeTrack, + ChallengeType, + ResourceRole, +} from '../models' +import { + getChallengeTracks, + getChallengeTypes, + getResourceRoles, +} from '../services' + +export type ReviewManagementContextType = { + challengeTypes: ChallengeType[] + challengeTracks: ChallengeTrack[] + challengeStatuses: ChallengeStatus[] + resourceRoles: ResourceRole[] + + loadChallengeTypes: () => void + loadChallengeTracks: () => void + loadResourceRoles: () => void +} + +export const ReviewManagementContext: Context + = createContext({ + challengeStatuses: [], + challengeTracks: [], + challengeTypes: [], + loadChallengeTracks: () => undefined, + loadChallengeTypes: () => undefined, + loadResourceRoles: () => undefined, + resourceRoles: [], + }) + +export const ReviewManagementContextProvider: FC = props => { + const [challengeTypes, setChallengeTypes] = useState([]) + const [challengeTracks, setChallengeTracks] = useState([]) + const [challengeStatuses] = useState([ + ChallengeStatus.New, + ChallengeStatus.Draft, + ChallengeStatus.Active, + ChallengeStatus.Completed, + ]) + const [resourceRoles, setResourceRoles] = useState([]) + + const loadChallengeTypes = useCallback(() => { + getChallengeTypes() + .then(types => { + setChallengeTypes(types) + }) + }, []) + + const loadChallengeTracks = useCallback(() => { + getChallengeTracks() + .then(tracks => { + setChallengeTracks(tracks) + }) + }, []) + + const loadResourceRoles = useCallback(() => { + getResourceRoles() + .then(roles => { + setResourceRoles(roles) + }) + }, []) + + const value = useMemo( + () => ({ + challengeStatuses, + challengeTracks, + challengeTypes, + loadChallengeTracks, + loadChallengeTypes, + loadResourceRoles, + resourceRoles, + }), + [ + challengeStatuses, + challengeTracks, + challengeTypes, + loadChallengeTracks, + loadChallengeTypes, + loadResourceRoles, + resourceRoles, + ], + ) + return ( + + {props.children} + + ) +} diff --git a/src/apps/admin/src/lib/contexts/index.ts b/src/apps/admin/src/lib/contexts/index.ts index c586b9908..6170d45c5 100644 --- a/src/apps/admin/src/lib/contexts/index.ts +++ b/src/apps/admin/src/lib/contexts/index.ts @@ -1,2 +1,3 @@ export * from './SWRConfigProvider' export * from './ChallengeManagementContextProvider' +export * from './ReviewManagementContextProvider' diff --git a/src/apps/admin/src/lib/models/index.ts b/src/apps/admin/src/lib/models/index.ts index 84ada863b..94cd6bd50 100644 --- a/src/apps/admin/src/lib/models/index.ts +++ b/src/apps/admin/src/lib/models/index.ts @@ -1 +1,2 @@ export * from './challenge-management' +export * from './review-management' diff --git a/src/apps/admin/src/lib/models/review-management/ReviewFilterCriteria.ts b/src/apps/admin/src/lib/models/review-management/ReviewFilterCriteria.ts new file mode 100644 index 000000000..eda35e175 --- /dev/null +++ b/src/apps/admin/src/lib/models/review-management/ReviewFilterCriteria.ts @@ -0,0 +1,10 @@ +export type ReviewFilterCriteria = { + /** Pagination current page */ + page: number + /** Pagination page size */ + perPage: number + /** Sort direction */ + order: 'asc' | 'desc' + /** Sort field name */ + sortBy: string +} diff --git a/src/apps/admin/src/lib/models/review-management/ReviewOpportunity.ts b/src/apps/admin/src/lib/models/review-management/ReviewOpportunity.ts new file mode 100644 index 000000000..9afb59aa2 --- /dev/null +++ b/src/apps/admin/src/lib/models/review-management/ReviewOpportunity.ts @@ -0,0 +1,12 @@ +export interface ReviewOpportunity { + /** Id */ + id: number + /** Open positions */ + openPositions: number + /** Start Date */ + startDate: string + /** Number Of submissions */ + submissions: number + /** Review type */ + type: string +} diff --git a/src/apps/admin/src/lib/models/review-management/ReviewSummary.ts b/src/apps/admin/src/lib/models/review-management/ReviewSummary.ts new file mode 100644 index 000000000..b489b9cc2 --- /dev/null +++ b/src/apps/admin/src/lib/models/review-management/ReviewSummary.ts @@ -0,0 +1,18 @@ +export interface ReviewSummary { + /** Challenge Name */ + challengeName: string + /** Challenge Status */ + challengeStatus: string + /** Challenge Id */ + legacyChallengeId: string + /** Number Of Approved Applications */ + numberOfApprovedApplications: number + /** Number Of Pending Applications */ + numberOfPendingApplications: number + /** Number Of Reviewer Spots */ + numberOfReviewerSpots: number + /** Number Of Submissions */ + numberOfSubmissions: number + /** Submission End Date */ + submissionEndDate: string +} diff --git a/src/apps/admin/src/lib/models/review-management/Reviewer.ts b/src/apps/admin/src/lib/models/review-management/Reviewer.ts new file mode 100644 index 000000000..0def63ce0 --- /dev/null +++ b/src/apps/admin/src/lib/models/review-management/Reviewer.ts @@ -0,0 +1,22 @@ +export interface Reviewer { + /** User Id */ + userId: number, + /** User handle */ + handle: string, + /** User email */ + emailAddress: string, + /** Application Status */ + applicationStatus: string, + /** Review Auction Id */ + reviewAuctionId: number, + /** Application Role Id */ + applicationRoleId: number, + /** Application Role */ + applicationRole: string, + /** Application Date */ + applicationDate: string, + /** Number of completed reviews in last 60 days */ + reviewsInPast60Days: number, + /** Current number of open reviews */ + currentNumberOfReviewPositions: number +} diff --git a/src/apps/admin/src/lib/models/review-management/index.ts b/src/apps/admin/src/lib/models/review-management/index.ts new file mode 100644 index 000000000..fe472607a --- /dev/null +++ b/src/apps/admin/src/lib/models/review-management/index.ts @@ -0,0 +1,4 @@ +export * from './ReviewSummary' +export * from './ReviewFilterCriteria' +export * from './Reviewer' +export * from './ReviewOpportunity' diff --git a/src/apps/admin/src/lib/services/index.js b/src/apps/admin/src/lib/services/index.js index 9ace2239e..93a2856af 100644 --- a/src/apps/admin/src/lib/services/index.js +++ b/src/apps/admin/src/lib/services/index.js @@ -1,2 +1,3 @@ export * from './challenge-management.service' export * from './user.service' +export * from './review-management.service' diff --git a/src/apps/admin/src/lib/services/review-management.service.ts b/src/apps/admin/src/lib/services/review-management.service.ts new file mode 100644 index 000000000..7c8715976 --- /dev/null +++ b/src/apps/admin/src/lib/services/review-management.service.ts @@ -0,0 +1,76 @@ +import { EnvironmentConfig } from '~/config' +import { + xhrGetAsync, + xhrPostAsync, +} from '~/libs/core' + +import { + Reviewer, + ReviewFilterCriteria, + ReviewOpportunity, + ReviewSummary, +} from '../models' +import { createReviewQueryString } from '../utils' + +/** + * Searches the review opportunities using v3 api. + */ +export const getReviewOpportunities = async ( + filterCriteria: ReviewFilterCriteria, +): Promise> => { + type v3Response = { result: { content: Review[], metadata: { totalCount: number } } } + const data = await xhrGetAsync>( + // eslint-disable-next-line max-len + `${EnvironmentConfig.API.V3}/reviewOpportunities/reviewApplicationsSummary?${createReviewQueryString(filterCriteria)}`, + ) + return data.result.content +} + +/** + * Searches the reviewer for challenge using v3 api. + */ +export const getChallengeReviewers = async ( + challengeId: string, +): Promise> => { + type v3Response = { result: { content: Reviewer[] } } + const data = await xhrGetAsync>( + `${EnvironmentConfig.API.V3}/reviewOpportunities/${challengeId}/reviewApplications`, + ) + return data.result.content +} + +/** + * Get review opportunities for challenge using v3 api. + */ +export const getChallengeReviewOpportunities = async ( + challengeId: string, +): Promise => { + type v3Response = { result: { content: ReviewOpportunity } } + const data = await xhrGetAsync>( + `${EnvironmentConfig.API.V3}/reviewOpportunities/${challengeId}`, + ) + return data.result.content +} + +/** + * Approve application for challenge using v3 api. + */ +export const approveApplication = async (challengeId: string, data: { + userId: number + reviewAuctionId: number + applicationRoleId: number +}): Promise => xhrPostAsync( + // eslint-disable-next-line max-len + `${EnvironmentConfig.API.V3}/reviewOpportunities/${challengeId}/reviewApplications/assign?userId=${data.userId}&reviewAuctionId=${data.reviewAuctionId}&applicationRoleId=${data.applicationRoleId}`, + {}, +) + +/** + * Reject pending for challenge using v3 api. + */ +export const rejectPending = async ( + challengeId: string, +): Promise => xhrPostAsync( + `${EnvironmentConfig.API.V3}/reviewOpportunities/${challengeId}/reviewApplications/rejectPending`, + {}, +) diff --git a/src/apps/admin/src/lib/utils/api.ts b/src/apps/admin/src/lib/utils/api.ts index 3fd991178..ce83fae87 100644 --- a/src/apps/admin/src/lib/utils/api.ts +++ b/src/apps/admin/src/lib/utils/api.ts @@ -1,6 +1,6 @@ import { toast } from 'react-toastify' -import { ChallengeFilterCriteria } from '../models' +import { ChallengeFilterCriteria, ReviewFilterCriteria } from '../models' /** * Handles api v5 errors. @@ -55,6 +55,22 @@ export const createChallengeQueryString = ( return filter } +export const createReviewQueryString = ( + filterCriteria: ReviewFilterCriteria, +): string => { + let filter = '' + + if (filterCriteria.page) { + filter += `page=${filterCriteria.page}` + } + + if (filterCriteria.perPage) { + filter += `&perPage=${filterCriteria.perPage}` + } + + return filter +} + export const replaceBrowserUrlQuery = (qs: string): void => { const newUrl = `${window.location.pathname}?${qs}` window.history.replaceState({}, '', newUrl) diff --git a/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.module.scss b/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.module.scss new file mode 100644 index 000000000..8be60a062 --- /dev/null +++ b/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.module.scss @@ -0,0 +1,21 @@ +.headerActions { + display: flex; + gap: 30px; + margin-left: auto; + margin-top: 16px; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + margin-top: -30px; + + .spinner { + background: none; + } +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} diff --git a/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.tsx b/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.tsx new file mode 100644 index 000000000..93fa1acc7 --- /dev/null +++ b/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.tsx @@ -0,0 +1,608 @@ +import { + Dispatch, + FC, + SetStateAction, + useEffect, + useReducer, + useRef, + useState, +} from 'react' +import { useParams } from 'react-router-dom' +import { sortBy } from 'lodash' + +import { XIcon } from '@heroicons/react/solid' +import { + Button, + LinkButton, + LoadingSpinner, + PageTitle, +} from '~/libs/ui' +import { Sort } from '~/apps/gamification-admin/src/game-lib/pagination' + +import { + Display, + PageContent, + PageHeader, + RejectPendingConfirmDialog, + ReviewerList, +} from '../../lib/components' +import { + Reviewer, + ReviewFilterCriteria, +} from '../../lib/models' +import { + approveApplication, + getChallengeReviewers, + getChallengeReviewOpportunities, + rejectPending, +} from '../../lib/services' +import { rootRoute } from '../../admin-app.routes' +import { handleError } from '../../lib/utils' +import { useEventCallback } from '../../lib/hooks' + +import styles from './ManageReviewerPage.module.scss' + +const BackToChallengeListButton: FC = () => ( + + Back + +) + +/** + * Manage Reviewers page. + */ +export const ManageReviewerPage: FC = () => { + const pageTitle = 'Manage Reviewers For ' + const { challengeId = '' }: { challengeId?: string } = useParams<{ + challengeId: string + }>() + const [filterCriteria, setFilterCriteria]: [ + ReviewFilterCriteria, + Dispatch> + ] = useState({ + order: 'desc', + page: 1, + perPage: 10, + sortBy: '', + }) + + const [reviewers, setReviewers]: [ + Array, + Dispatch>> + ] = useState>([]) + + const { + search: doSearch, + newSearch: doNewSearch, + sortData: doSortData, + searching, + searched, + totalReviewers: totalUsers, + openReviews, + }: ReturnType = useSearch({ challengeId, filterCriteria }) + + const { + reject: doReject, + rejecting, + }: ReturnType = useReject({ challengeId }) + const [openRejectPendingConfirmDialog, setOpenRejectPendingConfirmDialog] + = useState(false) + + const { approve: doApprove, userId }: ReturnType + = useApprove({ challengeId }) + + const search = useEventCallback((): void => { + doSearch() + .then(data => { + setReviewers(data) + window.scrollTo({ left: 0, top: 0 }) + }) + }) + + const sortData = useEventCallback(() => { + doSortData() + .then(data => { + setReviewers(data) + window.scrollTo({ left: 0, top: 0 }) + }) + }) + + const reject = useEventCallback((): void => { + doReject() + .then(res => { + if (res) { + doNewSearch() + } + }) + }) + + const approve = useEventCallback((reviewer: Reviewer): void => { + doApprove(reviewer) + .then(res => { + if (res) { + doNewSearch() + } + }) + }) + + // Init + useEffect(() => { + search() + }, [challengeId]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: search + + // Page change + const [pageChangeEvent, setPageChangeEvent] = useState(false) + const previousPageChangeEvent = useRef(false) + useEffect(() => { + if (pageChangeEvent) { + search() + setPageChangeEvent(false) + previousPageChangeEvent.current = true + } + }, [pageChangeEvent]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: search + + // Sort change + const [sortChangeEvent, setSortChangeEvent] = useState(false) + const previousSortChangeEvent = useRef(false) + useEffect(() => { + if (sortChangeEvent) { + sortData() + setSortChangeEvent(false) + previousSortChangeEvent.current = true + } + }, [sortChangeEvent]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: sortChangeEvent + + const handleRejectPendingConfirmDialog = useEventCallback(() => { + setOpenRejectPendingConfirmDialog(true) + }) + + const handlePageChange = useEventCallback((page: number) => { + setFilterCriteria({ ...filterCriteria, page }) + setPageChangeEvent(true) + }) + + const handleSortChange = useEventCallback((sort: Sort) => { + setFilterCriteria({ + ...filterCriteria, + order: sort.direction, + page: 1, + sortBy: sort.fieldName, + }) + setSortChangeEvent(true) + }) + + return ( + <> + {`${pageTitle} ${challengeId}`} + +

{`${pageTitle} ${challengeId}`}

+
+ + +
+
+ + {searching && ( +
+ +
+ )} + {searched && reviewers.length === 0 && ( +

No reviewers.

+ )} + + + +
+ {openRejectPendingConfirmDialog && ( + + )} + + ) +} + +/// ///////////////// +// Search reducer +/// ///////////////// + +const SearchActionType = { + SEARCH_DONE: 'SEARCH_DONE' as const, + SEARCH_FAILED: 'SEARCH_FAILED' as const, + SEARCH_INIT: 'SEARCH_INIT' as const, +} + +type SearchState = { + isLoading: boolean + searched: boolean + totalReviewers: number + openReviews: number + allReviewers: Reviewer[] +} + +type SearchReducerAction = + | { + type: + | typeof SearchActionType.SEARCH_INIT + | typeof SearchActionType.SEARCH_FAILED + } + | { + type: typeof SearchActionType.SEARCH_DONE + payload: { + totalReviewers: number + openReviews: number + allReviewers: Reviewer[] + } + } + +const searchReducer = ( + previousState: SearchState, + action: SearchReducerAction, +): SearchState => { + switch (action.type) { + case SearchActionType.SEARCH_INIT: { + return { + ...previousState, + allReviewers: [], + isLoading: true, + openReviews: 0, + searched: false, + totalReviewers: 0, + } + } + + case SearchActionType.SEARCH_DONE: { + return { + ...previousState, + allReviewers: action.payload.allReviewers, + isLoading: false, + openReviews: action.payload.openReviews, + searched: true, + totalReviewers: action.payload.totalReviewers, + } + } + + case SearchActionType.SEARCH_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + default: { + return previousState + } + } +} + +function getPageData( + reviewers: Reviewer[], + filterCriteria: ReviewFilterCriteria, +): Reviewer[] { + const total = reviewers.length + const startIndex = (filterCriteria.page - 1) * filterCriteria.perPage + const endIndex = Math.min(startIndex + filterCriteria.perPage, total) + return reviewers.slice(startIndex, endIndex) +} + +function useSearch({ + challengeId, + filterCriteria, +}: { + challengeId: string + filterCriteria: ReviewFilterCriteria +}): { + search: () => Promise + newSearch: () => Promise + sortData: () => Promise + searched: boolean + searching: boolean + totalReviewers: number + openReviews: number +} { + const [state, dispatch] = useReducer(searchReducer, { + allReviewers: [], + isLoading: false, + openReviews: 0, + searched: false, + totalReviewers: 0, + }) + + const sortData = useEventCallback(async (data?: Reviewer[]) => { + const toSortData = data || state.allReviewers + let sortedList = [] + + if (filterCriteria.sortBy === 'applicationDate') { + sortedList = sortBy( + toSortData, + item => new Date(item.applicationDate), + ) + } else { + sortedList = sortBy(toSortData, filterCriteria.sortBy) + } + + if (filterCriteria.order === 'desc') { + sortedList = sortedList.reverse() + } + + if (data) { + return sortedList + } + + dispatch({ + payload: { + allReviewers: sortedList, + openReviews: state.openReviews, + totalReviewers: sortedList.length, + }, + type: SearchActionType.SEARCH_DONE, + }) + + return getPageData(sortedList, filterCriteria) + }) + + const search = useEventCallback(async (newSearch?: boolean): Promise => { + // If api search has done, just get page data from last api response + if (state.searched && !newSearch) { + return getPageData(state.allReviewers, filterCriteria) + } + + dispatch({ type: SearchActionType.SEARCH_INIT }) + try { + const data = await getChallengeReviewers(challengeId) + const reviewOpportunity = await getChallengeReviewOpportunities( + challengeId, + ) + + dispatch({ + payload: { + allReviewers: data, + openReviews: reviewOpportunity?.openPositions || 0, + totalReviewers: data.length, + }, + type: SearchActionType.SEARCH_DONE, + }) + return getPageData(data, filterCriteria) + } catch (error) { + dispatch({ type: SearchActionType.SEARCH_FAILED }) + handleError(error) + return [] + } + + }) + + const newSearch = useEventCallback(async (): Promise => search(true)) + + return { + newSearch, + openReviews: state.openReviews, + search, + searched: state.searched, + searching: state.isLoading, + sortData, + totalReviewers: state.totalReviewers, + } +} + +/// ///////////////// +// Reject reducer +/// ///////////////// + +const RejectActionType = { + REJECT_DONE: 'REJECT_DONE' as const, + REJECT_FAILED: 'REJECT_FAILED' as const, + REJECT_INIT: 'REJECT_INIT' as const, +} + +type RemoveActionType = { + type: + | typeof RejectActionType.REJECT_INIT + | typeof RejectActionType.REJECT_FAILED + | typeof RejectActionType.REJECT_DONE +} + +type RejectState = { + isRejecting: boolean + rejected: boolean +} + +const rejectReducer = ( + previousState: RejectState, + action: RemoveActionType, +): RejectState => { + switch (action.type) { + case RejectActionType.REJECT_INIT: { + return { + ...previousState, + isRejecting: true, + rejected: false, + } + } + + case RejectActionType.REJECT_DONE: { + return { + ...previousState, + isRejecting: false, + rejected: true, + } + } + + case RejectActionType.REJECT_FAILED: { + return { + ...previousState, + isRejecting: false, + } + } + + default: { + return previousState + } + } +} + +function useReject({ challengeId }: { challengeId: string }): { + reject: () => Promise + rejected: boolean + rejecting: boolean +} { + const [state, dispatch] = useReducer(rejectReducer, { + isRejecting: false, + rejected: false, + }) + + const reject = useEventCallback(async (): Promise => { + dispatch({ type: RejectActionType.REJECT_INIT }) + + try { + await rejectPending(challengeId) + dispatch({ type: RejectActionType.REJECT_DONE }) + return true + } catch (error) { + dispatch({ type: RejectActionType.REJECT_FAILED }) + handleError(error) + return false + } + }) + + return { + reject, + rejected: state.rejected, + rejecting: state.isRejecting, + } +} + +/// ///////////////// +// Approve reducer +/// ///////////////// + +const ApproveActionType = { + APPROVE_DONE: 'APPROVE_DONE' as const, + APPROVE_FAILED: 'APPROVE_FAILED' as const, + APPROVE_INIT: 'APPROVE_INIT' as const, +} + +type ApproveActionType = + | { + type: // | typeof ApproveActionType.APPROVE_INIT + | typeof ApproveActionType.APPROVE_FAILED + | typeof ApproveActionType.APPROVE_DONE + } + | { + type: typeof ApproveActionType.APPROVE_INIT + payload: { + userId: number + } + } + +type ApproveState = { + isApproving: boolean + userId: number +} + +const approveReducer = ( + previousState: ApproveState, + action: ApproveActionType, +): ApproveState => { + switch (action.type) { + case ApproveActionType.APPROVE_INIT: { + return { + ...previousState, + isApproving: true, + userId: action.payload.userId, + } + } + + case ApproveActionType.APPROVE_DONE: { + return { + ...previousState, + isApproving: false, + userId: 0, + } + } + + case ApproveActionType.APPROVE_FAILED: { + return { + ...previousState, + isApproving: false, + userId: 0, + } + } + + default: { + return previousState + } + } +} + +function useApprove({ challengeId }: { challengeId: string }): { + approve: (reviewer: Reviewer) => Promise + approving: boolean + userId: number +} { + const [state, dispatch] = useReducer(approveReducer, { + isApproving: false, + userId: 0, + }) + + const approve = useEventCallback( + async (reviewer: Reviewer): Promise => { + dispatch({ + payload: { userId: reviewer.userId }, + type: ApproveActionType.APPROVE_INIT, + }) + + try { + await approveApplication(challengeId, { + applicationRoleId: reviewer.applicationRoleId, + reviewAuctionId: reviewer.reviewAuctionId, + userId: reviewer.userId, + }) + dispatch({ type: ApproveActionType.APPROVE_DONE }) + return true + } catch (error) { + dispatch({ type: ApproveActionType.APPROVE_FAILED }) + handleError(error) + return false + } + }, + ) + + return { + approve, + approving: state.isApproving, + userId: state.userId, + } +} + +export default ManageReviewerPage diff --git a/src/apps/admin/src/review-management/ManageReviewerPage/index.ts b/src/apps/admin/src/review-management/ManageReviewerPage/index.ts new file mode 100644 index 000000000..79370e2b9 --- /dev/null +++ b/src/apps/admin/src/review-management/ManageReviewerPage/index.ts @@ -0,0 +1 @@ +export { default as ManageReviewerPage } from './ManageReviewerPage' diff --git a/src/apps/admin/src/review-management/ReviewManagement.tsx b/src/apps/admin/src/review-management/ReviewManagement.tsx new file mode 100644 index 000000000..47b8dd910 --- /dev/null +++ b/src/apps/admin/src/review-management/ReviewManagement.tsx @@ -0,0 +1,46 @@ +import { FC, PropsWithChildren, useContext, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { Layout } from '../lib/components' +import { ReviewManagementContextProvider } from '../lib/contexts' +import { adminRoutes, manageReviewRouteId } from '../admin-app.routes' + +/** + * The router outlet with layout. + */ +export const ReviewManagement: FC & { + Layout: FC +} = () => { + const childRoutes = useChildRoutes() + + return ( + + + {childRoutes} + + ) +} + +function useChildRoutes(): Array | undefined { + const { getRouteElement }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo( + () => adminRoutes[0].children + ?.find(r => r.id === manageReviewRouteId) + ?.children?.map(getRouteElement), + [], // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: getRouteElement + ) + return childRoutes +} + +/** + * The outlet layout. + */ +ReviewManagement.Layout = function ReviewManagementLayout( + props: PropsWithChildren, +) { + return {props.children} +} + +export default ReviewManagement diff --git a/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.module.scss b/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.module.scss new file mode 100644 index 000000000..69b682b20 --- /dev/null +++ b/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.module.scss @@ -0,0 +1,14 @@ +.loadingSpinnerContainer { + position: relative; + height: 100px; + margin-top: -30px; + + .spinner { + background: none; + } +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} diff --git a/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.tsx b/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.tsx new file mode 100644 index 000000000..87061d7d6 --- /dev/null +++ b/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.tsx @@ -0,0 +1,321 @@ +import { + Dispatch, + FC, + SetStateAction, + useCallback, + useEffect, + useReducer, + useRef, + useState, +} from 'react' +import { sortBy } from 'lodash' + +import { LoadingSpinner, PageTitle } from '~/libs/ui' +import { Sort } from '~/apps/gamification-admin/src/game-lib/pagination' + +import { + Display, + PageContent, + PageHeader, + ReviewSummaryList, +} from '../../lib/components' +import { ReviewFilterCriteria, ReviewSummary } from '../../lib/models' +import { getReviewOpportunities } from '../../lib/services' +import { handleError } from '../../lib/utils' +import { useEventCallback } from '../../lib/hooks' + +import styles from './ReviewManagementPage.module.scss' + +/** + * Challenge Management page. + */ +export const ReviewManagementPage: FC = () => { + const pageTitle = 'Review Management' + + const [filterCriteria, setFilterCriteria]: [ + ReviewFilterCriteria, + Dispatch> + ] = useState({ + order: 'desc', + page: 1, + perPage: 10, + sortBy: '', + }) + + const [reviews, setReviews]: [ + Array, + Dispatch>> + ] = useState>([]) + + const { + search: doSearch, + sortData: doSortData, + searching, + searched, + totalReviews, + }: ReturnType = useSearch({ filterCriteria }) + + const search = useEventCallback(() => { + doSearch() + .then(data => { + setReviews(data) + window.scrollTo({ left: 0, top: 0 }) + }) + }) + + const sortData = useEventCallback(() => { + doSortData() + .then(data => { + setReviews(data) + window.scrollTo({ left: 0, top: 0 }) + }) + }) + + // Init + const initFilters = useCallback(() => { + search() + }, []) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: filterCriteria + + useEffect(() => { + initFilters() + }, [initFilters]) + + // Page change + const [pageChangeEvent, setPageChangeEvent] = useState(false) + const previousPageChangeEvent = useRef(false) + useEffect(() => { + if (pageChangeEvent) { + search() + setPageChangeEvent(false) + previousPageChangeEvent.current = true + } + }, [pageChangeEvent]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: pageChangeEvent + + // Sort change + const [sortChangeEvent, setSortChangeEvent] = useState(false) + const previousSortChangeEvent = useRef(false) + useEffect(() => { + if (sortChangeEvent) { + sortData() + setSortChangeEvent(false) + previousSortChangeEvent.current = true + } + }, [sortChangeEvent]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: sortChangeEvent + + const handlePageChange = useEventCallback((page: number) => { + setFilterCriteria({ ...filterCriteria, page }) + setPageChangeEvent(true) + }) + + const handleSortChange = useEventCallback((sort: Sort) => { + setFilterCriteria({ + ...filterCriteria, + order: sort.direction, + page: 1, + sortBy: sort.fieldName, + }) + setSortChangeEvent(true) + }) + + return ( + <> + {pageTitle} + +

{pageTitle}

+
+ + {searching && ( +
+ +
+ )} + {searched && reviews.length === 0 && ( +

No record found.

+ )} + + + +
+ + ) +} + +/// ///////////////// +// Search reducer +/// //////////////// + +type SearchState = { + isLoading: boolean + searched: boolean + totalReviews: number + allReviews: ReviewSummary[] +} + +const SearchActionType = { + SEARCH_DONE: 'SEARCH_DONE' as const, + SEARCH_FAILED: 'SEARCH_FAILED' as const, + SEARCH_INIT: 'SEARCH_INIT' as const, +} + +type SearchReducerAction = + | { + type: + | typeof SearchActionType.SEARCH_INIT + | typeof SearchActionType.SEARCH_FAILED + } + | { + type: typeof SearchActionType.SEARCH_DONE + payload: { + totalReviews: number + allReviews: ReviewSummary[] + } + } + +const reducer = ( + previousState: SearchState, + action: SearchReducerAction, +): SearchState => { + switch (action.type) { + case SearchActionType.SEARCH_INIT: { + return { + ...previousState, + allReviews: [], + isLoading: true, + searched: false, + totalReviews: 0, + } + } + + case SearchActionType.SEARCH_DONE: { + return { + ...previousState, + allReviews: action.payload.allReviews, + isLoading: false, + searched: true, + totalReviews: action.payload.totalReviews, + } + } + + case SearchActionType.SEARCH_FAILED: { + return { + ...previousState, + allReviews: [], + isLoading: false, + totalReviews: 0, + } + } + + default: { + return previousState + } + } +} + +function getPageData( + reviews: ReviewSummary[], + filterCriteria: ReviewFilterCriteria, +): ReviewSummary[] { + const total = reviews.length + const startIndex = (filterCriteria.page - 1) * filterCriteria.perPage + const endIndex = Math.min(startIndex + filterCriteria.perPage, total) + return reviews.slice(startIndex, endIndex) +} + +function useSearch({ + filterCriteria, +}: { + filterCriteria: ReviewFilterCriteria +}): { + search: () => Promise + sortData: () => Promise + searched: boolean + searching: boolean + totalReviews: number +} { + const [state, dispatch] = useReducer(reducer, { + allReviews: [], + isLoading: false, + searched: false, + totalReviews: 0, + }) + + const sortData = useEventCallback(async (data?: ReviewSummary[]) => { + const toSortData = data || state.allReviews + let sortedList = [] + if (filterCriteria.sortBy === 'submissionEndDate') { + sortedList = sortBy( + toSortData, + item => new Date(item.submissionEndDate), + ) + } else { + sortedList = sortBy(toSortData, filterCriteria.sortBy) + } + + if (filterCriteria.order === 'desc') { + sortedList = sortedList.reverse() + } + + if (data) { + return sortedList + } + + dispatch({ + payload: { + allReviews: sortedList, + totalReviews: sortedList.length, + }, + type: SearchActionType.SEARCH_DONE, + }) + + return getPageData(sortedList, filterCriteria) + }) + + const search = useEventCallback(async () => { + // If api search has done, just get page data from last api response + if (state.searched) { + return getPageData(state.allReviews, filterCriteria) + } + + dispatch({ type: SearchActionType.SEARCH_INIT }) + try { + const data: ReviewSummary[] = await getReviewOpportunities( + filterCriteria, + ) + const total = data.length + + // const sortedList = await sortData(data) + + dispatch({ + payload: { allReviews: data, totalReviews: total }, + type: SearchActionType.SEARCH_DONE, + }) + return getPageData(data, filterCriteria) + } catch (error) { + dispatch({ type: SearchActionType.SEARCH_FAILED }) + handleError(error) + return [] + } + + }) + + return { + search, + searched: state.searched, + searching: state.isLoading, + sortData, + totalReviews: state.totalReviews, + } +} + +export default ReviewManagementPage diff --git a/src/apps/admin/src/review-management/ReviewManagementPage/index.ts b/src/apps/admin/src/review-management/ReviewManagementPage/index.ts new file mode 100644 index 000000000..cf45ac174 --- /dev/null +++ b/src/apps/admin/src/review-management/ReviewManagementPage/index.ts @@ -0,0 +1 @@ +export { default as ReviewManagementPage } from './ReviewManagementPage' diff --git a/src/libs/ui/lib/components/table/Table.tsx b/src/libs/ui/lib/components/table/Table.tsx index 47e315c8b..e2c0f2de4 100644 --- a/src/libs/ui/lib/components/table/Table.tsx +++ b/src/libs/ui/lib/components/table/Table.tsx @@ -22,6 +22,7 @@ interface TableProps { readonly data: ReadonlyArray readonly moreToLoad?: boolean readonly disableSorting?: boolean + readonly initSort?: Sort readonly onLoadMoreClick?: () => void readonly onRowClick?: (data: T) => void readonly onToggleSort?: (sort: Sort) => void @@ -35,7 +36,7 @@ const Table: (props: TableProps) = = (props: TableProps) => { const [sort, setSort]: [Sort | undefined, Dispatch>] - = useState(tableGetDefaultSort(props.columns)) + = useState(tableGetDefaultSort(props.columns, props.initSort)) const [defaultSortDirectionMap, setDefaultSortDirectionMap]: [ DefaultSortDirectionMap | undefined, Dispatch> diff --git a/src/libs/ui/lib/components/table/table-functions/table.functions.ts b/src/libs/ui/lib/components/table/table-functions/table.functions.ts index 67657c8be..4aacef0d8 100644 --- a/src/libs/ui/lib/components/table/table-functions/table.functions.ts +++ b/src/libs/ui/lib/components/table/table-functions/table.functions.ts @@ -1,7 +1,11 @@ import { Sort } from '../../../../../../apps/gamification-admin/src/game-lib/pagination' import { TableColumn } from '../table-column.model' -export function getDefaultSort(columns: ReadonlyArray>): Sort { +export function getDefaultSort(columns: ReadonlyArray>, initSort?: Sort): Sort { + + if (initSort) { + return initSort + } const defaultSortColumn: TableColumn | undefined = columns.find(col => col.isDefaultSort) || columns.find(col => !!col.propertyName)