diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index d8687b904..0d5163fb3 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -10,6 +10,7 @@ import { import { billingAccountRouteId, + defaultReviewersRouteId, gamificationAdminRouteId, manageChallengeRouteId, manageReviewRouteId, @@ -146,6 +147,18 @@ const TermsEditPage: LazyLoadedComponent = lazyLoad( () => import('./platform/terms/TermsEditPage'), 'TermsEditPage', ) +const DefaultReviewersListPage: LazyLoadedComponent = lazyLoad( + () => import('./platform/default-reviewers/DefaultReviewersListPage'), + 'DefaultReviewersListPage', +) +const DefaultReviewersAddPage: LazyLoadedComponent = lazyLoad( + () => import('./platform/default-reviewers/DefaultReviewersAddPage'), + 'DefaultReviewersAddPage', +) +const DefaultReviewersEditPage: LazyLoadedComponent = lazyLoad( + () => import('./platform/default-reviewers/DefaultReviewersEditPage'), + 'DefaultReviewersEditPage', +) const TermsUsersPage: LazyLoadedComponent = lazyLoad( () => import('./platform/terms/TermsUsersPage'), 'TermsUsersPage', @@ -366,6 +379,18 @@ export const adminRoutes: ReadonlyArray = [ element: , route: `${termsRouteId}/:id/edit`, }, + { + element: , + route: defaultReviewersRouteId, + }, + { + element: , + route: `${defaultReviewersRouteId}/add`, + }, + { + element: , + route: `${defaultReviewersRouteId}/:id/edit`, + }, ], element: , id: platformRouteId, diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts index 031002120..a2ebf2790 100644 --- a/src/apps/admin/src/config/routes.config.ts +++ b/src/apps/admin/src/config/routes.config.ts @@ -15,5 +15,6 @@ export const billingAccountRouteId = 'billing-account' export const permissionManagementRouteId = 'permission-management' export const gamificationAdminRouteId = 'gamification-admin' export const termsRouteId = 'terms' +export const defaultReviewersRouteId = 'default-reviewers' export const platformRouteId = 'platform' export const paymentsRouteId = 'payments' diff --git a/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.module.scss b/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.module.scss new file mode 100644 index 000000000..ce3606524 --- /dev/null +++ b/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.module.scss @@ -0,0 +1,16 @@ +.container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.inputField { + margin-bottom: 24px; +} + +.btnDelete { + display: flex; + align-items: center; + gap: 12px; + margin-right: auto; +} diff --git a/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.tsx b/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.tsx new file mode 100644 index 000000000..e5598396a --- /dev/null +++ b/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.tsx @@ -0,0 +1,526 @@ +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import type { FC } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import type { NavigateFunction } from 'react-router-dom' +import { Controller, useForm } from 'react-hook-form' +import type { + ControllerRenderProps, + UseFormReturn, +} from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { yupResolver } from '@hookform/resolvers/yup' +import { + Button, + ConfirmModal, + InputCheckbox, + InputSelectReact, + InputText, + LinkButton, +} from '~/libs/ui' + +import { FormAddWrapper } from '../common/FormAddWrapper' +import { FormAddDefaultReviewer } from '../../models' +import { formAddDefaultReviewerSchema } from '../../utils' +import { + useManageAddDefaultReviewer, + useManageAddDefaultReviewerProps, +} from '../../hooks' + +import styles from './DefaultReviewersAddForm.module.scss' + +interface Props { + className?: string +} + +const opportunityTypeOptions = [ + { label: 'None', value: '' }, + { label: 'Regular Review', value: 'REGULAR_REVIEW' }, + { label: 'Component Dev Review', value: 'COMPONENT_DEV_REVIEW' }, + { label: 'Spec Review', value: 'SPEC_REVIEW' }, + { label: 'Iterative Review', value: 'ITERATIVE_REVIEW' }, + { label: 'Scenarios Review', value: 'SCENARIOS_REVIEW' }, +] + +export const DefaultReviewersAddForm: FC = (props: Props) => { + const [removeConfirmationOpen, setRemoveConfirmationOpen] = useState(false) + const navigate: NavigateFunction = useNavigate() + const { id }: { id?: string } = useParams<{ id?: string }>() + const isEdit = !!id + + const { + challengeTracks, + challengeTypes, + defaultReviewerInfo, + doAddDefaultReviewer, + doRemoveDefaultReviewer, + doUpdateDefaultReviewer, + isFetchingChallengeTracks, + isFetchingChallengeTypes, + isFetchingPhases, + isFetchingScorecards, + isFetchingTimelineTemplates, + isLoading, + isRemoving, + phases, + scorecards, + timelineTemplates, + }: useManageAddDefaultReviewerProps = useManageAddDefaultReviewer(id) + + const challengeTypesOptions = useMemo( + () => challengeTypes.map(item => ({ + label: item.name, + value: item.id, + })), + [challengeTypes], + ) + + const challengeTracksOptions = useMemo( + () => challengeTracks.map(item => ({ + label: item.name, + value: item.id, + })), + [challengeTracks], + ) + + const timelineTemplateOptions = useMemo( + () => [ + { label: 'None', value: '' }, + ...timelineTemplates.map(item => ({ + label: item.name, + value: item.id, + })), + ], + [timelineTemplates], + ) + + const scorecardOptions = useMemo( + () => scorecards.map(item => ({ + label: item.name, + value: item.id, + })), + [scorecards], + ) + + const phaseNameOptions = useMemo( + () => phases.map(item => ({ + label: item.name, + value: item.name, + })), + [phases], + ) + + const opportunityOptions = useMemo(() => opportunityTypeOptions, []) + + const { + control, + handleSubmit, + register, + reset, + watch, + formState: { errors, isDirty }, + }: UseFormReturn = useForm({ + defaultValues: { + baseCoefficient: 0, + fixedAmount: 0, + incrementalCoefficient: 0, + isAIReviewer: false, + isMemberReview: false, + memberReviewerCount: 0, + opportunityType: '', + phaseId: '', + phaseName: '', + scorecardId: '', + shouldOpenOpportunity: true, + timelineTemplateId: '', + trackId: '', + typeId: '', + }, + mode: 'all', + resolver: yupResolver(formAddDefaultReviewerSchema), + }) + + const onSubmit = useCallback( + (data: FormAddDefaultReviewer) => { + const requestBody = _.pickBy(data, _.identity) + if (isEdit) { + doUpdateDefaultReviewer(requestBody, () => { + navigate('./../..') + }) + } else { + doAddDefaultReviewer(requestBody, () => { + navigate('./..') + }) + } + }, + [doAddDefaultReviewer, doUpdateDefaultReviewer, isEdit, navigate], + ) + + const isMemberReview = watch('isMemberReview') + + useEffect(() => { + if (defaultReviewerInfo) { + reset({ + baseCoefficient: defaultReviewerInfo.baseCoefficient ?? 0, + fixedAmount: defaultReviewerInfo.fixedAmount ?? 0, + incrementalCoefficient: defaultReviewerInfo.incrementalCoefficient ?? 0, + isAIReviewer: defaultReviewerInfo.isAIReviewer, + isMemberReview: defaultReviewerInfo.isMemberReview, + memberReviewerCount: defaultReviewerInfo.memberReviewerCount ?? 0, + opportunityType: defaultReviewerInfo.opportunityType ?? '', + phaseId: defaultReviewerInfo.phaseId ?? '', + phaseName: defaultReviewerInfo.phaseName, + scorecardId: defaultReviewerInfo.scorecardId, + shouldOpenOpportunity: defaultReviewerInfo.shouldOpenOpportunity, + timelineTemplateId: defaultReviewerInfo.timelineTemplateId ?? '', + trackId: defaultReviewerInfo.trackId, + typeId: defaultReviewerInfo.typeId, + }) + } + }, [defaultReviewerInfo, reset]) + + return ( + <> + + {isEdit && ( +
+ +
+ )} + + + Cancel + + + )} + > + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> +
+ + }) { + return ( + + ) + }} + /> +
+ {isMemberReview && ( + + )} + + + + + }) { + return ( + + ) + }} + /> +
+ + }) { + return ( + + ) + }} + /> +
+
+ + }) { + return ( + + ) + }} + /> +
+
+ { + setRemoveConfirmationOpen(false) + navigate('./../..') + }) + }} + > +
Are you sure you want to delete this default reviewer?
+
+ + ) +} + +export default DefaultReviewersAddForm diff --git a/src/apps/admin/src/lib/components/DefaultReviewersAddForm/index.ts b/src/apps/admin/src/lib/components/DefaultReviewersAddForm/index.ts new file mode 100644 index 000000000..a035bc0b7 --- /dev/null +++ b/src/apps/admin/src/lib/components/DefaultReviewersAddForm/index.ts @@ -0,0 +1 @@ +export { DefaultReviewersAddForm } from './DefaultReviewersAddForm' diff --git a/src/apps/admin/src/lib/components/DefaultReviewersFilters/DefaultReviewersFilters.module.scss b/src/apps/admin/src/lib/components/DefaultReviewersFilters/DefaultReviewersFilters.module.scss new file mode 100644 index 000000000..a5f98fef8 --- /dev/null +++ b/src/apps/admin/src/lib/components/DefaultReviewersFilters/DefaultReviewersFilters.module.scss @@ -0,0 +1,47 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.fields { + display: flex; + gap: 15px; + align-items: flex-start; + flex-wrap: wrap; + + @include ltemd { + flex-direction: column; + gap: 0; + align-items: flex-end; + } +} + +.field { + flex: 1; + max-width: 500px; + + @include ltelg { + width: 100%; + } + + @include ltemd { + max-width: none; + } +} + +.blockBottom { + display: flex; + gap: 10px; + margin-top: 3px; +} + +.searchButton { + min-width: 120px; +} diff --git a/src/apps/admin/src/lib/components/DefaultReviewersFilters/DefaultReviewersFilters.tsx b/src/apps/admin/src/lib/components/DefaultReviewersFilters/DefaultReviewersFilters.tsx new file mode 100644 index 000000000..84f1ad7ae --- /dev/null +++ b/src/apps/admin/src/lib/components/DefaultReviewersFilters/DefaultReviewersFilters.tsx @@ -0,0 +1,93 @@ +import { useCallback } from 'react' +import type { FC } from 'react' +import { useForm } from 'react-hook-form' +import type { UseFormReturn } from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { yupResolver } from '@hookform/resolvers/yup' +import { Button, InputText } from '~/libs/ui' + +import { formSearchDefaultReviewersSchema } from '../../utils' +import { FormSearchDefaultReviewers } from '../../models' + +import styles from './DefaultReviewersFilters.module.scss' + +interface Props { + className?: string + isLoading: boolean + onSubmitForm?: (data: FormSearchDefaultReviewers) => void +} + +const defaultValues: FormSearchDefaultReviewers = { + phaseName: '', +} + +export const DefaultReviewersFilters: FC = (props: Props) => { + const { + register, + handleSubmit, + reset, + formState: { isValid, isDirty }, + }: UseFormReturn = useForm({ + defaultValues, + mode: 'all', + resolver: yupResolver(formSearchDefaultReviewersSchema), + }) + + const onSubmit = useCallback( + (data: FormSearchDefaultReviewers) => { + props.onSubmitForm?.(data) + }, + [props.onSubmitForm], + ) + + const handleReset = useCallback(() => { + reset(defaultValues) + setTimeout(() => { + onSubmit(defaultValues) + }) + }, [reset, onSubmit]) + + return ( +
+
+ +
+ + +
+
+
+ ) +} + +export default DefaultReviewersFilters diff --git a/src/apps/admin/src/lib/components/DefaultReviewersTable/DefaultReviewersTable.module.scss b/src/apps/admin/src/lib/components/DefaultReviewersTable/DefaultReviewersTable.module.scss new file mode 100644 index 000000000..17c28ba05 --- /dev/null +++ b/src/apps/admin/src/lib/components/DefaultReviewersTable/DefaultReviewersTable.module.scss @@ -0,0 +1,29 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding-top: 0; +} + +.tableCell { + white-space: break-spaces !important; + text-align: left !important; +} + +.rowActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; +} + +.desktopTable { + td { + vertical-align: middle; + } +} + +.deletingSpinner { + margin-left: 0.25rem; +} diff --git a/src/apps/admin/src/lib/components/DefaultReviewersTable/DefaultReviewersTable.tsx b/src/apps/admin/src/lib/components/DefaultReviewersTable/DefaultReviewersTable.tsx new file mode 100644 index 000000000..de695c845 --- /dev/null +++ b/src/apps/admin/src/lib/components/DefaultReviewersTable/DefaultReviewersTable.tsx @@ -0,0 +1,265 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useMemo, + useState, +} from 'react' +import type { FC } from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' +import classNames from 'classnames' + +import { + Button, + colWidthType, + ConfirmModal, + LinkButton, + LoadingSpinner, + Table, + TableColumn, +} from '~/libs/ui' +import { useWindowSize } from '~/libs/shared' +import type { WindowSize } from '~/libs/shared' + +import type { + DefaultChallengeReviewerWithNames, + MobileTableColumn, +} from '../../models' +import { deleteDefaultReviewer } from '../../services/default-reviewers.service' +import { handleError } from '../../utils' +import { TableMobile } from '../common/TableMobile' +import { Pagination } from '../common/Pagination' +import { TableWrapper } from '../common/TableWrapper' + +import styles from './DefaultReviewersTable.module.scss' + +interface Props { + className?: string + datas: DefaultChallengeReviewerWithNames[] + totalPages: number + page: number + setPage: Dispatch> + colWidth: colWidthType | undefined + setColWidth: Dispatch> | undefined + reloadData: () => void +} + +export const DefaultReviewersTable: FC = (props: Props) => { + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 1050, [screenWidth]) + const [deletingId, setDeletingId] = useState(undefined) + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [ + recordToDelete, + setRecordToDelete, + ] = useState(undefined) + + const handleOpenDeleteConfirm = useCallback( + (record: DefaultChallengeReviewerWithNames) => { + setRecordToDelete(record) + setDeleteConfirmOpen(true) + }, + [], + ) + + const handleCloseDeleteConfirm = useCallback(() => { + setDeleteConfirmOpen(false) + setRecordToDelete(undefined) + }, []) + + const handleConfirmDelete = useCallback(async () => { + if (!recordToDelete) { + return + } + + setDeletingId(recordToDelete.id) + + try { + await deleteDefaultReviewer(recordToDelete.id) + toast.success('Default Reviewer deleted successfully') + if (props.page > 1 && props.datas.length === 1) { + props.setPage(props.page - 1) + } else { + props.reloadData() + } + + setDeleteConfirmOpen(false) + setRecordToDelete(undefined) + } catch (error) { + handleError(error) + } finally { + setDeletingId(undefined) + } + }, [ + recordToDelete, + props.datas, + props.page, + props.reloadData, + props.setPage, + ]) + + const columns = useMemo[]>( + () => [ + { + columnId: 'challengeType', + label: 'Challenge Type', + renderer: (data: DefaultChallengeReviewerWithNames) => ( +
{data.typeName ?? data.typeId}
+ ), + type: 'element', + }, + { + columnId: 'track', + label: 'Track', + renderer: (data: DefaultChallengeReviewerWithNames) => ( +
{data.trackName ?? data.trackId}
+ ), + type: 'element', + }, + { + className: styles.tableCell, + columnId: 'timelineTemplate', + label: 'Timeline Template', + renderer: (data: DefaultChallengeReviewerWithNames) => ( +
{data.timelineTemplateName ?? 'N/A'}
+ ), + type: 'element', + }, + { + className: styles.tableCell, + columnId: 'scorecard', + label: 'Scorecard', + renderer: (data: DefaultChallengeReviewerWithNames) => ( +
{data.scorecardName ?? data.scorecardId}
+ ), + type: 'element', + }, + { + columnId: 'phaseName', + label: 'Phase Name', + propertyName: 'phaseName', + type: 'text', + }, + { + columnId: 'Action', + label: '', + renderer: (data: DefaultChallengeReviewerWithNames) => { + function handleDeleteClick(): void { + handleOpenDeleteConfirm(data) + } + + const isDeleting = deletingId === data.id + + return ( +
+ + {isDeleting ? ( + + ) : ( +
+ ) + }, + type: 'element', + }, + ], + [deletingId, handleOpenDeleteConfirm], + ) + + const columnsMobile = useMemo< + MobileTableColumn[][] + >( + () => columns.map(column => { + if (column.label === '') { + return [ + { + ...column, + colSpan: 2, + mobileType: 'last-value', + }, + ] + } + + return [ + { + ...column, + className: '', + label: `${column.label as string} label`, + mobileType: 'label', + renderer: () => ( +
+ {column.label as string} + : +
+ ), + type: 'element', + }, + { + ...column, + mobileType: 'last-value', + }, + ] + }), + [columns], + ) + + return ( + <> + + {isTablet ? ( + + ) : ( + + )} + + + +
+ Are you sure you want to delete the default reviewer for + {' '} + {recordToDelete?.typeName} + {' '} + / + {' '} + {recordToDelete?.trackName} + ? +
+
+ + ) +} + +export default DefaultReviewersTable 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 756fdca57..8c07f6f6c 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 @@ -3,6 +3,7 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' import { billingAccountRouteId, + defaultReviewersRouteId, gamificationAdminRouteId, manageChallengeRouteId, manageReviewRouteId, @@ -10,7 +11,8 @@ import { permissionManagementRouteId, platformRouteId, termsRouteId, - userManagementRouteId } from '~/apps/admin/src/config/routes.config' + userManagementRouteId, +} from '~/apps/admin/src/config/routes.config' import { platformSkillRouteId } from '~/apps/admin/src/platform/routes.config' export const SystemAdminTabsConfig: TabsNavItem[] = [ @@ -57,19 +59,20 @@ export const SystemAdminTabsConfig: TabsNavItem[] = [ { children: [ { - id: `${platformRouteId}/${platformSkillRouteId}`, title: 'Skills', }, { id: `${platformRouteId}/${gamificationAdminRouteId}`, title: 'Badges', - }, { id: `${platformRouteId}/${termsRouteId}`, title: 'Terms', - + }, + { + id: `${platformRouteId}/${defaultReviewersRouteId}`, + title: 'Default Reviewers', }, ], id: platformRouteId, diff --git a/src/apps/admin/src/lib/components/index.ts b/src/apps/admin/src/lib/components/index.ts index f48b1a8dc..bebd5bcaf 100644 --- a/src/apps/admin/src/lib/components/index.ts +++ b/src/apps/admin/src/lib/components/index.ts @@ -23,6 +23,9 @@ export * from './RejectPendingConfirmDialog' export * from './FieldHandleSelect' export * from './FieldSingleSelect' export * from './SubmissionTable' +export * from './DefaultReviewersTable/DefaultReviewersTable' +export * from './DefaultReviewersFilters/DefaultReviewersFilters' +export * from './DefaultReviewersAddForm' export * from './TermsTable' export * from './TermsFilters' export * from './TermsAddForm' diff --git a/src/apps/admin/src/lib/hooks/index.ts b/src/apps/admin/src/lib/hooks/index.ts index 4e50e9cd5..20849e29b 100644 --- a/src/apps/admin/src/lib/hooks/index.ts +++ b/src/apps/admin/src/lib/hooks/index.ts @@ -27,6 +27,8 @@ export * from './useManageBusEvent' export * from './useManageChallengeSubmissions' export * from './useManageMarathonMatch' export * from './useManageUserSSOLogin' +export * from './useManageDefaultReviewers' +export * from './useManageAddDefaultReviewer' export * from './useManageTerms' export * from './useManageAddTerm' export * from './useManageTermsUsers' diff --git a/src/apps/admin/src/lib/hooks/useManageAddDefaultReviewer.ts b/src/apps/admin/src/lib/hooks/useManageAddDefaultReviewer.ts new file mode 100644 index 000000000..7c6b8022a --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageAddDefaultReviewer.ts @@ -0,0 +1,242 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react' +import { toast } from 'react-toastify' + +import { + ChallengeTrack, + ChallengeType, + DefaultChallengeReviewer, + FormAddDefaultReviewer, + Phase, + Scorecard, + TimelineTemplate, +} from '../models' +import { handleError } from '../utils' +import { + createDefaultReviewer, + deleteDefaultReviewer, + getChallengeTracks, + getChallengeTypes, + getDefaultReviewerById, + getPhases, + getScorecards, + getTimelineTemplates, + updateDefaultReviewer, +} from '../services' + +import { useOnComponentDidMount } from './useOnComponentDidMount' + +export interface useManageAddDefaultReviewerProps { + defaultReviewerInfo?: DefaultChallengeReviewer + challengeTypes: ChallengeType[] + isFetchingChallengeTypes: boolean + challengeTracks: ChallengeTrack[] + isFetchingChallengeTracks: boolean + timelineTemplates: TimelineTemplate[] + isFetchingTimelineTemplates: boolean + scorecards: Scorecard[] + isFetchingScorecards: boolean + phases: Phase[] + isFetchingPhases: boolean + doAddDefaultReviewer: ( + data: Partial, + callBack: () => void, + ) => void + doUpdateDefaultReviewer: ( + data: Partial, + callBack: () => void, + ) => void + doRemoveDefaultReviewer: (callBack: () => void) => void + isAdding: boolean + isLoadingDefaultReviewer: boolean + isRemoving: boolean + isLoading: boolean +} + +export function useManageAddDefaultReviewer( + defaultReviewerId?: string, +): useManageAddDefaultReviewerProps { + const [defaultReviewerInfo, setDefaultReviewerInfo] = useState() + + const [challengeTypes, setChallengeTypes] = useState([]) + const [isFetchingChallengeTypes, setIsFetchingChallengeTypes] = useState(false) + + const [challengeTracks, setChallengeTracks] = useState([]) + const [isFetchingChallengeTracks, setIsFetchingChallengeTracks] = useState(false) + + const [timelineTemplates, setTimelineTemplates] = useState([]) + const [isFetchingTimelineTemplates, setIsFetchingTimelineTemplates] = useState(false) + + const [scorecards, setScorecards] = useState([]) + const [isFetchingScorecards, setIsFetchingScorecards] = useState(false) + + const [phases, setPhases] = useState([]) + const [isFetchingPhases, setIsFetchingPhases] = useState(false) + + const [isAdding, setIsAdding] = useState(false) + const [isRemoving, setIsRemoving] = useState(false) + const [isLoadingDefaultReviewer, setIsLoadingDefaultReviewer] = useState(false) + const isLoadingDefaultReviewerRef = useRef(false) + + useOnComponentDidMount(() => { + setIsFetchingChallengeTypes(true) + getChallengeTypes() + .then(result => { + setChallengeTypes(result) + setIsFetchingChallengeTypes(false) + }) + .catch(e => { + setIsFetchingChallengeTypes(false) + handleError(e) + }) + + setIsFetchingChallengeTracks(true) + getChallengeTracks() + .then(result => { + setChallengeTracks(result) + setIsFetchingChallengeTracks(false) + }) + .catch(e => { + setIsFetchingChallengeTracks(false) + handleError(e) + }) + + setIsFetchingTimelineTemplates(true) + getTimelineTemplates() + .then(result => { + setTimelineTemplates(result) + setIsFetchingTimelineTemplates(false) + }) + .catch(e => { + setIsFetchingTimelineTemplates(false) + handleError(e) + }) + + setIsFetchingScorecards(true) + getScorecards() + .then(result => { + setScorecards(result) + setIsFetchingScorecards(false) + }) + .catch(e => { + setIsFetchingScorecards(false) + handleError(e) + }) + + setIsFetchingPhases(true) + getPhases() + .then(result => { + setPhases(result) + setIsFetchingPhases(false) + }) + .catch(e => { + setIsFetchingPhases(false) + handleError(e) + }) + }) + + const doFetchDefaultReviewer = useCallback(() => { + if (!isLoadingDefaultReviewerRef.current && defaultReviewerId) { + isLoadingDefaultReviewerRef.current = true + setIsLoadingDefaultReviewer(isLoadingDefaultReviewerRef.current) + getDefaultReviewerById(defaultReviewerId) + .then(result => { + setDefaultReviewerInfo(result) + isLoadingDefaultReviewerRef.current = false + setIsLoadingDefaultReviewer(isLoadingDefaultReviewerRef.current) + }) + .catch(e => { + isLoadingDefaultReviewerRef.current = false + setIsLoadingDefaultReviewer(isLoadingDefaultReviewerRef.current) + handleError(e) + }) + } + }, [defaultReviewerId]) + + const doAddDefaultReviewer = useCallback( + (data: Partial, callBack: () => void) => { + setIsAdding(true) + createDefaultReviewer(data) + .then(() => { + toast.success('Default Reviewer added successfully', { + toastId: 'Add default reviewer', + }) + setIsAdding(false) + callBack() + }) + .catch(e => { + setIsAdding(false) + handleError(e) + }) + }, + [], + ) + + const doUpdateDefaultReviewer = useCallback( + (data: Partial, callBack: () => void) => { + setIsAdding(true) + updateDefaultReviewer(defaultReviewerId ?? '', data) + .then(() => { + toast.success('Default Reviewer updated successfully', { + toastId: 'Update default reviewer', + }) + setIsAdding(false) + callBack() + }) + .catch(e => { + setIsAdding(false) + handleError(e) + }) + }, + [defaultReviewerId], + ) + + const doRemoveDefaultReviewer = useCallback( + (callBack: () => void) => { + setIsRemoving(true) + deleteDefaultReviewer(defaultReviewerId ?? '') + .then(() => { + toast.success('Default Reviewer removed successfully', { + toastId: 'Remove default reviewer', + }) + setIsRemoving(false) + callBack() + }) + .catch(e => { + setIsRemoving(false) + handleError(e) + }) + }, + [defaultReviewerId], + ) + + useEffect(() => { + doFetchDefaultReviewer() + }, [doFetchDefaultReviewer]) + + return { + challengeTracks, + challengeTypes, + defaultReviewerInfo, + doAddDefaultReviewer, + doRemoveDefaultReviewer, + doUpdateDefaultReviewer, + isAdding, + isFetchingChallengeTracks, + isFetchingChallengeTypes, + isFetchingPhases, + isFetchingScorecards, + isFetchingTimelineTemplates, + isLoading: + isLoadingDefaultReviewer || isAdding || isRemoving, + isLoadingDefaultReviewer, + isRemoving, + phases, + scorecards, + timelineTemplates, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManageDefaultReviewers.ts b/src/apps/admin/src/lib/hooks/useManageDefaultReviewers.ts new file mode 100644 index 000000000..2bbbd3d59 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageDefaultReviewers.ts @@ -0,0 +1,286 @@ +import { + Dispatch, + SetStateAction, + useReducer, + useRef, +} from 'react' + +import { TABLE_PAGINATION_ITEM_PER_PAGE } from '../../config/index.config' +import { + DefaultChallengeReviewer, + DefaultChallengeReviewerWithNames, + FormSearchDefaultReviewers, +} from '../models' +import { handleError } from '../utils' +import { + DefaultReviewersResponsePayload, + fetchAllDefaultReviewers, + getChallengeTracks, + getChallengeTypes, + getScorecards, + getTimelineTemplates, +} from '../services' + +import { + useTableFilterBackend, + useTableFilterBackendProps, +} from './useTableFilterBackend' + +type DefaultReviewersState = { + isLoading: boolean + datas: DefaultChallengeReviewerWithNames[] + totalPages: number +} + +const DefaultReviewersActionType = { + FETCH_DEFAULT_REVIEWERS_DONE: 'FETCH_DEFAULT_REVIEWERS_DONE' as const, + FETCH_DEFAULT_REVIEWERS_FAILED: 'FETCH_DEFAULT_REVIEWERS_FAILED' as const, + FETCH_DEFAULT_REVIEWERS_INIT: 'FETCH_DEFAULT_REVIEWERS_INIT' as const, +} + +type DefaultReviewersReducerAction = + | { + type: + | typeof DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_INIT + | typeof DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_FAILED + } + | { + type: typeof DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_DONE + payload: { + data: DefaultChallengeReviewerWithNames[] + totalPages: number + } + } + +const reducer = ( + previousState: DefaultReviewersState, + action: DefaultReviewersReducerAction, +): DefaultReviewersState => { + switch (action.type) { + case DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_INIT: { + return { + ...previousState, + datas: [], + isLoading: true, + } + } + + case DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_DONE: { + return { + ...previousState, + datas: action.payload.data, + isLoading: false, + totalPages: action.payload.totalPages, + } + } + + case DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + default: { + return previousState + } + } +} + +type ReferenceMaps = { + typeMap: Map + trackMap: Map + timelineTemplateMap: Map + scorecardMap: Map +} + +const normalizeDefaultReviewers = ( + payload: DefaultReviewersResponsePayload, +): DefaultChallengeReviewer[] => { + if (Array.isArray(payload)) { + return payload + } + + return payload.result ?? [] +} + +export interface useManageDefaultReviewersProps { + datas: DefaultChallengeReviewerWithNames[] + isLoading: boolean + page: number + setPage: Dispatch> + reloadData: () => void + setFilterCriteria: ( + criteria: FormSearchDefaultReviewers | undefined + ) => void + totalPages: number +} + +export function useManageDefaultReviewers(): useManageDefaultReviewersProps { + const [state, dispatch] = useReducer(reducer, { + datas: [], + isLoading: false, + totalPages: 1, + }) + + const referenceMapsRef = useRef() + + const ensureReferenceMaps = async (): Promise => { + if (referenceMapsRef.current) { + return referenceMapsRef.current + } + + const [ + challengeTypes, + challengeTracks, + timelineTemplates, + scorecards, + ] = await Promise.all([ + getChallengeTypes(), + getChallengeTracks(), + getTimelineTemplates(), + getScorecards(), + ]) + + const typeMap = new Map( + challengeTypes.map(type => [type.id, type.name]), + ) + const trackMap = new Map( + challengeTracks.map(track => [track.id, track.name]), + ) + const timelineTemplateMap = new Map( + timelineTemplates.map(template => [template.id, template.name]), + ) + const scorecardMap = new Map( + scorecards.map(scorecard => [scorecard.id, scorecard.name]), + ) + + referenceMapsRef.current = { + scorecardMap, + timelineTemplateMap, + trackMap, + typeMap, + } + + return referenceMapsRef.current + } + + const { + page, + setPage, + reloadData, + setFilterCriteria, + }: useTableFilterBackendProps + = useTableFilterBackend( + (pagRequest, _sortRequest, filterCriteria, success, fail) => { + dispatch({ + type: DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_INIT, + }) + + const encodeFilterValue = (value: string): string => encodeURIComponent(value) + const filterSegments: string[] = [ + `page=${pagRequest}`, + `perPage=${TABLE_PAGINATION_ITEM_PER_PAGE}`, + ] + + if (filterCriteria?.typeId) { + filterSegments.push(`typeId=${encodeFilterValue(filterCriteria.typeId)}`) + } + + if (filterCriteria?.trackId) { + filterSegments.push(`trackId=${encodeFilterValue(filterCriteria.trackId)}`) + } + + if (filterCriteria?.timelineTemplateId) { + filterSegments.push( + `timelineTemplateId=${encodeFilterValue(filterCriteria.timelineTemplateId)}`, + ) + } + + const phaseNameFilter + = filterCriteria?.phaseName ?? filterCriteria?.searchKey + if (phaseNameFilter) { + filterSegments.push(`phaseName=${encodeFilterValue(phaseNameFilter)}`) + } + + if (filterCriteria?.scorecardId) { + filterSegments.push( + `scorecardId=${encodeFilterValue(filterCriteria.scorecardId)}`, + ) + } + + const filter = filterSegments.join('&') + + const loadData = async (): Promise => { + try { + const result = await fetchAllDefaultReviewers(filter) + + let referenceMaps: ReferenceMaps + try { + referenceMaps = await ensureReferenceMaps() + } catch (referenceError) { + handleError(referenceError) + referenceMaps = { + scorecardMap: new Map(), + timelineTemplateMap: new Map(), + trackMap: new Map(), + typeMap: new Map(), + } + } + + const defaultReviewers = normalizeDefaultReviewers(result.data) + + const enrichedRecords + = defaultReviewers.map(defaultReviewer => ({ + ...defaultReviewer, + scorecardName: referenceMaps.scorecardMap.get( + defaultReviewer.scorecardId, + ), + timelineTemplateName: + defaultReviewer.timelineTemplateId + ? referenceMaps.timelineTemplateMap.get( + defaultReviewer.timelineTemplateId, + ) + : undefined, + trackName: referenceMaps.trackMap.get( + defaultReviewer.trackId, + ), + typeName: referenceMaps.typeMap.get( + defaultReviewer.typeId, + ), + })) + + dispatch({ + payload: { + data: enrichedRecords, + totalPages: result.totalPages, + }, + type: DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_DONE, + }) + success() + window.scrollTo({ left: 0, top: 0 }) + } catch (error) { + dispatch({ + type: DefaultReviewersActionType.FETCH_DEFAULT_REVIEWERS_FAILED, + }) + handleError(error) + fail() + } + } + + loadData() + .catch(() => undefined) + }, + undefined, + ) + + return { + datas: state.datas, + isLoading: state.isLoading, + page, + reloadData, + setFilterCriteria, + setPage, + totalPages: state.totalPages, + } +} diff --git a/src/apps/admin/src/lib/models/DefaultChallengeReviewer.model.ts b/src/apps/admin/src/lib/models/DefaultChallengeReviewer.model.ts new file mode 100644 index 000000000..f9ab8bd5e --- /dev/null +++ b/src/apps/admin/src/lib/models/DefaultChallengeReviewer.model.ts @@ -0,0 +1,45 @@ +export interface DefaultChallengeReviewer { + id: string; + typeId: string; + trackId: string; + timelineTemplateId?: string; + scorecardId: string; + isMemberReview: boolean; + memberReviewerCount?: number; + phaseName: string; + phaseId?: string; + fixedAmount?: number; + baseCoefficient?: number; + incrementalCoefficient?: number; + opportunityType?: string; + isAIReviewer: boolean; + shouldOpenOpportunity: boolean; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +} + +export interface DefaultChallengeReviewerWithNames extends DefaultChallengeReviewer { + typeName?: string; + trackName?: string; + timelineTemplateName?: string; + scorecardName?: string; +} + +export interface FormAddDefaultReviewer { + typeId: string; + trackId: string; + timelineTemplateId?: string; + scorecardId: string; + isMemberReview: boolean; + memberReviewerCount?: number; + phaseName: string; + phaseId?: string; + fixedAmount?: number; + baseCoefficient?: number; + incrementalCoefficient?: number; + opportunityType?: string; + isAIReviewer: boolean; + shouldOpenOpportunity: boolean; +} diff --git a/src/apps/admin/src/lib/models/FormSearchDefaultReviewers.model.ts b/src/apps/admin/src/lib/models/FormSearchDefaultReviewers.model.ts new file mode 100644 index 000000000..8ec12eec3 --- /dev/null +++ b/src/apps/admin/src/lib/models/FormSearchDefaultReviewers.model.ts @@ -0,0 +1,8 @@ +export interface FormSearchDefaultReviewers { + typeId?: string; + trackId?: string; + timelineTemplateId?: string; + phaseName?: string; + scorecardId?: string; + searchKey?: string; +} diff --git a/src/apps/admin/src/lib/models/Phase.model.ts b/src/apps/admin/src/lib/models/Phase.model.ts new file mode 100644 index 000000000..cc82f2400 --- /dev/null +++ b/src/apps/admin/src/lib/models/Phase.model.ts @@ -0,0 +1,11 @@ +export interface Phase { + id: string; + name: string; + description?: string; + isOpen: boolean; + duration: number; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +} diff --git a/src/apps/admin/src/lib/models/Scorecard.model.ts b/src/apps/admin/src/lib/models/Scorecard.model.ts new file mode 100644 index 000000000..7d623aede --- /dev/null +++ b/src/apps/admin/src/lib/models/Scorecard.model.ts @@ -0,0 +1,12 @@ +export interface Scorecard { + id: string; + name: string; + status: string; + type: string; + challengeTrack: string; + challengeType: string; + version: string; + minScore: number; + maxScore: number; + minimumPassingScore: number; +} diff --git a/src/apps/admin/src/lib/models/TimelineTemplate.model.ts b/src/apps/admin/src/lib/models/TimelineTemplate.model.ts new file mode 100644 index 000000000..d545273a8 --- /dev/null +++ b/src/apps/admin/src/lib/models/TimelineTemplate.model.ts @@ -0,0 +1,10 @@ +export interface TimelineTemplate { + id: string; + name: string; + description?: string; + isActive: boolean; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +} diff --git a/src/apps/admin/src/lib/models/index.ts b/src/apps/admin/src/lib/models/index.ts index a14544c9f..8529d2e2f 100644 --- a/src/apps/admin/src/lib/models/index.ts +++ b/src/apps/admin/src/lib/models/index.ts @@ -3,6 +3,7 @@ export * from './FormEditUserEmail.model' export * from './FormEditUserGroup.model' export * from './FormEditUserRole.model' export * from './FormSearchByKey.model' +export * from './FormSearchDefaultReviewers.model' export * from './FormUsersFilters.model' export * from './UserInfo.model' export * from './UserRole.model' @@ -51,4 +52,8 @@ export * from './TableRolesFilter.type' export * from './AdminAppContextType.type' export * from './IsRemoving.type' export * from './CommonRequestBusAPI.type' +export * from './DefaultChallengeReviewer.model' export * from './PaginatedResponseV6.model' +export * from './Phase.model' +export * from './Scorecard.model' +export * from './TimelineTemplate.model' diff --git a/src/apps/admin/src/lib/services/default-reviewers.service.ts b/src/apps/admin/src/lib/services/default-reviewers.service.ts new file mode 100644 index 000000000..b9ac2684e --- /dev/null +++ b/src/apps/admin/src/lib/services/default-reviewers.service.ts @@ -0,0 +1,66 @@ +import { EnvironmentConfig } from '~/config' +import { + PaginatedResponse, + xhrDeleteAsync, + xhrGetAsync, + xhrGetPaginatedAsync, + xhrPostAsync, + xhrPutAsync, +} from '~/libs/core' + +import { + ApiV5ResponseSuccess, + DefaultChallengeReviewer, + FormAddDefaultReviewer, +} from '../models' + +export type DefaultReviewersResponsePayload = + | DefaultChallengeReviewer[] + | { + result: DefaultChallengeReviewer[] + } + +export const fetchAllDefaultReviewers = async ( + filter: string, +): Promise> => xhrGetPaginatedAsync< + DefaultReviewersResponsePayload +>(`${EnvironmentConfig.API.V6}/default-challenge-reviewers?${filter}`) + +export const getDefaultReviewerById = async ( + id: string, +): Promise => { + const result = await xhrGetAsync( + `${EnvironmentConfig.API.V6}/default-challenge-reviewers/${id}`, + ) + return result +} + +export const createDefaultReviewer = async ( + data: Partial, +): Promise => { + const result = await xhrPostAsync< + Partial, + DefaultChallengeReviewer + >(`${EnvironmentConfig.API.V6}/default-challenge-reviewers`, data) + return result +} + +export const updateDefaultReviewer = async ( + id: string, + data: Partial, +): Promise => { + const result = await xhrPutAsync< + Partial, + DefaultChallengeReviewer + >(`${EnvironmentConfig.API.V6}/default-challenge-reviewers/${id}`, data) + return result +} + +export const deleteDefaultReviewer = async ( + id: string, +): Promise => { + const result = await xhrDeleteAsync( + `${EnvironmentConfig.API.V6}/default-challenge-reviewers/${id}`, + ) + return result +} diff --git a/src/apps/admin/src/lib/services/index.ts b/src/apps/admin/src/lib/services/index.ts index 37e57302b..b47f23458 100644 --- a/src/apps/admin/src/lib/services/index.ts +++ b/src/apps/admin/src/lib/services/index.ts @@ -10,3 +10,7 @@ export * from './billing-accounts.service' export * from './submissions.service' export * from './bus-event.service' export * from './identity-provider.service' +export * from './default-reviewers.service' +export * from './timeline-templates.service' +export * from './phases.service' +export * from './scorecards.service' diff --git a/src/apps/admin/src/lib/services/phases.service.ts b/src/apps/admin/src/lib/services/phases.service.ts new file mode 100644 index 000000000..95a97478e --- /dev/null +++ b/src/apps/admin/src/lib/services/phases.service.ts @@ -0,0 +1,11 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { Phase } from '../models' + +export const getPhases = async (): Promise => { + const result = await xhrGetAsync( + `${EnvironmentConfig.API.V6}/challenge-phases`, + ) + return result +} diff --git a/src/apps/admin/src/lib/services/scorecards.service.ts b/src/apps/admin/src/lib/services/scorecards.service.ts new file mode 100644 index 000000000..0a6799ca8 --- /dev/null +++ b/src/apps/admin/src/lib/services/scorecards.service.ts @@ -0,0 +1,112 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetPaginatedAsync } from '~/libs/core' + +import { Scorecard } from '../models' + +type ScorecardPagePayload = { + scoreCards?: Scorecard[] + data?: Scorecard[] + result?: Scorecard[] + metadata?: { + perPage?: number + total?: number + totalPages?: number + } +} + +type GetScorecardsOptions = { + fetchAll?: boolean + page?: number + perPage?: number +} + +const SCORECARD_DEFAULT_PAGE_SIZE = 200 + +const toScorecardList = (payload: Scorecard[] | ScorecardPagePayload): Scorecard[] => { + if (Array.isArray(payload)) { + return payload + } + + if (payload.scoreCards && Array.isArray(payload.scoreCards)) { + return payload.scoreCards + } + + if (payload.data && Array.isArray(payload.data)) { + return payload.data + } + + if (payload.result && Array.isArray(payload.result)) { + return payload.result + } + + return [] +} + +const getTotalPagesFromPayload = (payload: Scorecard[] | ScorecardPagePayload): number => { + if (Array.isArray(payload) || !payload.metadata) { + return 0 + } + + const totalPages = Number(payload.metadata.totalPages) + if (Number.isFinite(totalPages) && totalPages > 0) { + return totalPages + } + + const perPage = Number(payload.metadata.perPage) + const total = Number(payload.metadata.total) + + if ( + Number.isFinite(total) + && Number.isFinite(perPage) + && perPage > 0 + && total >= 0 + ) { + return Math.ceil(total / perPage) + } + + return 0 +} + +export const getScorecards = async ( + options: GetScorecardsOptions = {}, +): Promise => { + const fetchAll = options.fetchAll ?? true + const perPage = options.perPage && options.perPage > 0 + ? options.perPage + : SCORECARD_DEFAULT_PAGE_SIZE + const accumulated: Scorecard[] = [] + const initialPage = options.page && options.page > 0 ? options.page : 1 + + const fetchPage = async (pageNumber: number): Promise => { + const params = new URLSearchParams() + params.set('page', String(pageNumber)) + params.set('perPage', String(perPage)) + + const response = await xhrGetPaginatedAsync( + `${EnvironmentConfig.API.V6}/scorecards?${params.toString()}`, + ) + + const batch = toScorecardList(response.data) + + if (!fetchAll) { + return batch + } + + accumulated.push(...batch) + + const totalPagesFromHeader = Number(response.totalPages) + const totalPagesFromPayload = getTotalPagesFromPayload(response.data) + const totalPages = totalPagesFromHeader || totalPagesFromPayload + + const hasMoreByHeader = totalPages > 0 && pageNumber < totalPages + const hasMoreByCount = totalPages <= 0 && batch.length === perPage + + if (!hasMoreByHeader && !hasMoreByCount) { + return accumulated + } + + return fetchPage(pageNumber + 1) + } + + return fetchPage(initialPage) +} diff --git a/src/apps/admin/src/lib/services/timeline-templates.service.ts b/src/apps/admin/src/lib/services/timeline-templates.service.ts new file mode 100644 index 000000000..050ff9a75 --- /dev/null +++ b/src/apps/admin/src/lib/services/timeline-templates.service.ts @@ -0,0 +1,11 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { TimelineTemplate } from '../models' + +export const getTimelineTemplates = async (): Promise => { + const result = await xhrGetAsync( + `${EnvironmentConfig.API.V6}/timeline-templates`, + ) + return result +} diff --git a/src/apps/admin/src/lib/utils/index.ts b/src/apps/admin/src/lib/utils/index.ts index 69e7b78ae..ab6460257 100644 --- a/src/apps/admin/src/lib/utils/index.ts +++ b/src/apps/admin/src/lib/utils/index.ts @@ -1,5 +1,6 @@ export * from './api' export * from './validation' +export * from './validation-schemas' export * from './challenge' export * from './number' export * from './string' diff --git a/src/apps/admin/src/lib/utils/validation-schemas.ts b/src/apps/admin/src/lib/utils/validation-schemas.ts new file mode 100644 index 000000000..d458e795e --- /dev/null +++ b/src/apps/admin/src/lib/utils/validation-schemas.ts @@ -0,0 +1,61 @@ +import * as Yup from 'yup' + +import { FormAddDefaultReviewer, FormSearchDefaultReviewers } from '../models' + +export const formSearchDefaultReviewersSchema: Yup.ObjectSchema + = Yup.object({ + phaseName: Yup.string() + .optional(), + scorecardId: Yup.string() + .optional(), + searchKey: Yup.string() + .optional(), + timelineTemplateId: Yup.string() + .optional(), + trackId: Yup.string() + .optional(), + typeId: Yup.string() + .optional(), + }) + +export const formAddDefaultReviewerSchema: Yup.ObjectSchema + = Yup.object({ + baseCoefficient: Yup.number() + .optional() + .min(0, 'Must be non-negative'), + fixedAmount: Yup.number() + .optional() + .min(0, 'Must be non-negative'), + incrementalCoefficient: Yup.number() + .optional() + .min(0, 'Must be non-negative'), + isAIReviewer: Yup.boolean() + .required(), + isMemberReview: Yup.boolean() + .required(), + memberReviewerCount: Yup.number() + .optional() + .when('isMemberReview', { + is: true, + otherwise: schema => schema.optional(), + then: schema => schema + .required('Member Reviewer Count is required when Is Member Review is checked') + .min(1, 'Must be at least 1'), + }), + opportunityType: Yup.string() + .optional(), + phaseId: Yup.string() + .optional(), + phaseName: Yup.string() + .required('Phase Name is required'), + scorecardId: Yup.string() + .required('Scorecard is required'), + shouldOpenOpportunity: Yup.boolean() + .required(), + timelineTemplateId: Yup.string() + .optional(), + trackId: Yup.string() + .required('Challenge Track is required'), + typeId: Yup.string() + .required('Challenge Type is required'), + }) diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/DefaultReviewersAddPage.module.scss b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/DefaultReviewersAddPage.module.scss new file mode 100644 index 000000000..d4d5d8ae6 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/DefaultReviewersAddPage.module.scss @@ -0,0 +1,6 @@ +.container { + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px; +} diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/DefaultReviewersAddPage.tsx b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/DefaultReviewersAddPage.tsx new file mode 100644 index 000000000..3be14bd17 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/DefaultReviewersAddPage.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react' +import classNames from 'classnames' + +import { DefaultReviewersAddForm, PageWrapper } from '../../../lib' +import { useAutoScrollTopWhenInit } from '../../../lib/hooks' + +import styles from './DefaultReviewersAddPage.module.scss' + +export interface Props { + className?: string +} + +export const DefaultReviewersAddPage: FC = props => { + useAutoScrollTopWhenInit() + + return ( + + + + ) +} + +export default DefaultReviewersAddPage diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/index.ts b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/index.ts new file mode 100644 index 000000000..b64a3cf78 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersAddPage/index.ts @@ -0,0 +1 @@ +export { DefaultReviewersAddPage } from './DefaultReviewersAddPage' diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/DefaultReviewersEditPage.module.scss b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/DefaultReviewersEditPage.module.scss new file mode 100644 index 000000000..d4d5d8ae6 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/DefaultReviewersEditPage.module.scss @@ -0,0 +1,6 @@ +.container { + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px; +} diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/DefaultReviewersEditPage.tsx b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/DefaultReviewersEditPage.tsx new file mode 100644 index 000000000..56913a591 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/DefaultReviewersEditPage.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react' +import classNames from 'classnames' + +import { DefaultReviewersAddForm, PageWrapper } from '../../../lib' +import { useAutoScrollTopWhenInit } from '../../../lib/hooks' + +import styles from './DefaultReviewersEditPage.module.scss' + +export interface Props { + className?: string +} + +export const DefaultReviewersEditPage: FC = props => { + useAutoScrollTopWhenInit() + + return ( + + + + ) +} + +export default DefaultReviewersEditPage diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/index.ts b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/index.ts new file mode 100644 index 000000000..4171fb1ed --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersEditPage/index.ts @@ -0,0 +1 @@ +export { DefaultReviewersEditPage } from './DefaultReviewersEditPage' diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage.tsx b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage.tsx new file mode 100644 index 000000000..6f84f55e7 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage.tsx @@ -0,0 +1,4 @@ +import { DefaultReviewersListPage } from './DefaultReviewersListPage/DefaultReviewersListPage' + +export { DefaultReviewersListPage } +export default DefaultReviewersListPage diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/DefaultReviewersListPage.module.scss b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/DefaultReviewersListPage.module.scss new file mode 100644 index 000000000..bc0b35631 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/DefaultReviewersListPage.module.scss @@ -0,0 +1,8 @@ +.container { + display: flex; + flex-direction: column; +} + +.blockTableContainer { + margin-top: 16px; +} diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/DefaultReviewersListPage.tsx b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/DefaultReviewersListPage.tsx new file mode 100644 index 000000000..b011560d4 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/DefaultReviewersListPage.tsx @@ -0,0 +1,84 @@ +import { FC, useState } from 'react' +import classNames from 'classnames' + +import { colWidthType, LinkButton, PageDivider } from '~/libs/ui' +import { PlusIcon } from '@heroicons/react/solid' + +import { + DefaultReviewersFilters, + DefaultReviewersTable, + PageWrapper, + TableLoading, + TableNoRecord, +} from '../../../lib' +import { + useAutoScrollTopWhenInit, + useManageDefaultReviewers, + useManageDefaultReviewersProps, +} from '../../../lib/hooks' + +import styles from './DefaultReviewersListPage.module.scss' + +interface Props { + className?: string +} + +export const DefaultReviewersListPage: FC = (props: Props) => { + useAutoScrollTopWhenInit() + const [colWidth, setColWidth] = useState({}) + const { + isLoading, + datas, + totalPages, + page, + setPage, + setFilterCriteria, + reloadData, + }: useManageDefaultReviewersProps = useManageDefaultReviewers() + + return ( + + )} + > + + + {isLoading ? ( + + ) : ( + <> + {datas.length === 0 ? ( + + ) : ( +
+ +
+ )} + + )} +
+ ) +} + +export default DefaultReviewersListPage diff --git a/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/index.ts b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/index.ts new file mode 100644 index 000000000..9428eef11 --- /dev/null +++ b/src/apps/admin/src/platform/default-reviewers/DefaultReviewersListPage/index.ts @@ -0,0 +1 @@ +export { DefaultReviewersListPage } from './DefaultReviewersListPage' diff --git a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx index ea889754e..0f13be512 100644 --- a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx +++ b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx @@ -871,7 +871,7 @@ export const TableIterativeReview: FC = (props: Props) => { const reviewDateColumn: TableColumn = useMemo( () => ({ columnId: 'review-date', - label: 'Review Date', + label: isApprovalColumn ? 'Approval Date' : 'Review Date', renderer: (data: SubmissionInfo) => { const review = data.review @@ -898,7 +898,7 @@ export const TableIterativeReview: FC = (props: Props) => { }, type: 'element', }), - [], + [isApprovalColumn], ) const approvalResultColumn: TableColumn | undefined = useMemo(() => { @@ -914,6 +914,15 @@ export const TableIterativeReview: FC = (props: Props) => { const status = (review?.status ?? '').toUpperCase() const hasReview = hasActiveReview(review) const isCompleted = ['COMPLETED', 'SUBMITTED'].includes(status) + + if (!hasReview) { + return -- + } + + if (!isCompleted) { + return -- + } + const outcome = resolveSubmissionReviewResult( data as SubmissionRow, { @@ -937,18 +946,6 @@ export const TableIterativeReview: FC = (props: Props) => { ) } - if (!hasReview) { - return -- - } - - if (!isCompleted) { - return ( - - Pending - - ) - } - return -- }, type: 'element', @@ -1278,12 +1275,19 @@ export const TableIterativeReview: FC = (props: Props) => { return undefined } + const hasMyIterativeReviewAssignments = (datas || []).some(entry => { + const resourceId = entry.review?.resourceId + return resourceId ? myResourceIds.has(resourceId) : false + }) + const hasCompletedIterativeReviews = (datas || []).some(d => ( ['COMPLETED', 'SUBMITTED'].includes((d.review?.status || '').toString() .toUpperCase()) )) - const allowColumn = isReviewPhase(challengeInfo) || (isFirst2Finish && hasCompletedIterativeReviews) + const allowColumn = isReviewPhase(challengeInfo) + || hasMyIterativeReviewAssignments + || (isFirst2Finish && hasCompletedIterativeReviews) if (!allowColumn) { return undefined } @@ -1310,6 +1314,7 @@ export const TableIterativeReview: FC = (props: Props) => { renderApprovalAction, renderIterativeAction, renderPostMortemAction, + myResourceIds, ]) const columns = useMemo[]>(() => { diff --git a/src/apps/review/src/pages/active-review-assignements/ScorecardDetailsPage/ScorecardDetailsPage.tsx b/src/apps/review/src/pages/active-review-assignements/ScorecardDetailsPage/ScorecardDetailsPage.tsx index b3366f964..0938a2855 100644 --- a/src/apps/review/src/pages/active-review-assignements/ScorecardDetailsPage/ScorecardDetailsPage.tsx +++ b/src/apps/review/src/pages/active-review-assignements/ScorecardDetailsPage/ScorecardDetailsPage.tsx @@ -159,7 +159,11 @@ const PHASE_ROLE_MATCHERS: Partial> = { 'checkpoint review': normalizedRoleName => normalizedRoleName === 'checkpointreviewer', 'checkpoint screening': normalizedRoleName => normalizedRoleName === 'checkpointscreener', 'post-mortem': normalizedRoleName => normalizedRoleName.includes('postmortem'), - review: normalizedRoleName => normalizedRoleName === 'reviewer', + review: normalizedRoleName => ( + normalizedRoleName.includes('reviewer') + && !normalizedRoleName.includes('checkpoint') + && !normalizedRoleName.includes('postmortem') + ), screening: normalizedRoleName => ( ( normalizedRoleName.includes('screener') @@ -228,7 +232,11 @@ const canRoleEditPhase = ( currentPhaseReviewType: ReviewPhaseType | undefined, normalizedRoleName: string, ): boolean => { - if (!reviewPhaseType || currentPhaseReviewType !== reviewPhaseType) { + if (!reviewPhaseType) { + return false + } + + if (currentPhaseReviewType && currentPhaseReviewType !== reviewPhaseType) { return false }