diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml
new file mode 100644
index 000000000..02f198a18
--- /dev/null
+++ b/.github/workflows/code_reviewer.yml
@@ -0,0 +1,22 @@
+name: AI PR Reviewer
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+permissions:
+ pull-requests: write
+jobs:
+ tc-ai-pr-review:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Repo
+ uses: actions/checkout@v3
+
+ - name: TC AI PR Reviewer
+ uses: topcoder-platform/tc-ai-pr-reviewer@master
+ with:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret)
+ LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }}
+ exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas
diff --git a/package.json b/package.json
index b8bea93e0..93a0323e3 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"dependencies": {
"@datadog/browser-logs": "^4.21.2",
"@heroicons/react": "^1.0.6",
+ "@hookform/resolvers": "^4.1.2",
"@popperjs/core": "^2.11.8",
"@sprig-technologies/sprig-browser": "^2.20.1",
"@storybook/addon-actions": "7.6.10",
@@ -71,6 +72,7 @@
"react-elastic-carousel": "^0.11.5",
"react-gtm-module": "^2.0.11",
"react-helmet": "^6.1.0",
+ "react-hook-form": "^7.54.2",
"react-markdown": "8.0.6",
"react-otp-input": "^3.1.1",
"react-popper": "^2.3.0",
@@ -101,7 +103,8 @@
"tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.27",
"typescript": "^4.8.4",
"universal-navigation": "https://github.com/topcoder-platform/universal-navigation#9fc50d938be7182",
- "uuid": "^9.0.0"
+ "uuid": "^9.0.0",
+ "yup": "^1.6.1"
},
"devDependencies": {
"@babel/core": "^7.19.3",
diff --git a/src/apps/self-service/index.ts b/src/apps/admin/index.ts
similarity index 100%
rename from src/apps/self-service/index.ts
rename to src/apps/admin/index.ts
diff --git a/src/apps/admin/src/AdminApp.tsx b/src/apps/admin/src/AdminApp.tsx
new file mode 100644
index 000000000..564392814
--- /dev/null
+++ b/src/apps/admin/src/AdminApp.tsx
@@ -0,0 +1,39 @@
+import { FC, useContext, useEffect, useMemo } from 'react'
+import { Outlet, Routes } from 'react-router-dom'
+
+import { routerContext, RouterContextData } from '~/libs/core'
+
+import { AdminAppContextProvider, Layout, SWRConfigProvider } from './lib'
+import { toolTitle } from './admin-app.routes'
+import './lib/styles/index.scss'
+
+/**
+ * The admin app.
+ */
+const AdminApp: FC = () => {
+ const { getChildRoutes }: RouterContextData = useContext(routerContext)
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- missing dependency: getChildRoutes
+ const childRoutes = useMemo(() => getChildRoutes(toolTitle), [])
+
+ useEffect(() => {
+ document.body.classList.add('admin-app')
+ return () => {
+ document.body.classList.remove('admin-app')
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+ {childRoutes}
+
+
+
+
+ )
+}
+
+export default AdminApp
diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx
new file mode 100644
index 000000000..b5251bf65
--- /dev/null
+++ b/src/apps/admin/src/admin-app.routes.tsx
@@ -0,0 +1,276 @@
+import { AppSubdomain, ToolTitle } from '~/config'
+import {
+ lazyLoad,
+ LazyLoadedComponent,
+ PlatformRoute,
+ Rewrite,
+ UserRole,
+} from '~/libs/core'
+
+import {
+ billingAccountRouteId,
+ manageChallengeRouteId,
+ manageReviewRouteId,
+ permissionManagementRouteId,
+ rootRoute,
+ userManagementRouteId,
+} from './config/routes.config'
+
+const AdminApp: LazyLoadedComponent = lazyLoad(() => import('./AdminApp'))
+
+const ChallengeManagement: LazyLoadedComponent = lazyLoad(
+ () => import('./challenge-management/ChallengeManagement'),
+)
+const ChallengeManagementPage: LazyLoadedComponent = lazyLoad(
+ () => import('./challenge-management/ChallengeManagementPage'),
+ 'ChallengeManagementPage',
+)
+const ManageUserPage: LazyLoadedComponent = lazyLoad(
+ () => import('./challenge-management/ManageUserPage'),
+ 'ManageUserPage',
+)
+const ManageResourcePage: LazyLoadedComponent = lazyLoad(
+ () => import('./challenge-management/ManageResourcePage'),
+ 'ManageResourcePage',
+)
+const AddResourcePage: LazyLoadedComponent = lazyLoad(
+ () => import('./challenge-management/AddResourcePage'),
+ 'AddResourcePage',
+)
+const UserManagementPage: LazyLoadedComponent = lazyLoad(
+ () => import('./user-management/UserManagementPage'),
+ 'UserManagementPage',
+)
+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',
+)
+const BillingAccount: LazyLoadedComponent = lazyLoad(
+ () => import('./billing-account/BillingAccount'),
+)
+const BillingAccountsPage: LazyLoadedComponent = lazyLoad(
+ () => import('./billing-account/BillingAccountsPage'),
+ 'BillingAccountsPage',
+)
+const BillingAccountNewPage: LazyLoadedComponent = lazyLoad(
+ () => import('./billing-account/BillingAccountNewPage'),
+ 'BillingAccountNewPage',
+)
+const BillingAccountDetailsPage: LazyLoadedComponent = lazyLoad(
+ () => import('./billing-account/BillingAccountDetailsPage'),
+ 'BillingAccountDetailsPage',
+)
+const BillingAccountResourcesPage: LazyLoadedComponent = lazyLoad(
+ () => import('./billing-account/BillingAccountResourcesPage'),
+ 'BillingAccountResourcesPage',
+)
+const BillingAccountResourceNewPage: LazyLoadedComponent = lazyLoad(
+ () => import('./billing-account/BillingAccountResourceNewPage'),
+ 'BillingAccountResourceNewPage',
+)
+const ClientsPage: LazyLoadedComponent = lazyLoad(
+ () => import('./billing-account/ClientsPage'),
+ 'ClientsPage',
+)
+const ClientEditPage: LazyLoadedComponent = lazyLoad(
+ () => import('./billing-account/ClientEditPage'),
+ 'ClientEditPage',
+)
+const PermissionManagement: LazyLoadedComponent = lazyLoad(
+ () => import('./permission-management/PermissionManagement'),
+)
+const PermissionRolesPage: LazyLoadedComponent = lazyLoad(
+ () => import('./permission-management/PermissionRolesPage'),
+ 'PermissionRolesPage',
+)
+const PermissionRoleMembersPage: LazyLoadedComponent = lazyLoad(
+ () => import('./permission-management/PermissionRoleMembersPage'),
+ 'PermissionRoleMembersPage',
+)
+const PermissionAddRoleMembersPage: LazyLoadedComponent = lazyLoad(
+ () => import('./permission-management/PermissionAddRoleMembersPage'),
+ 'PermissionAddRoleMembersPage',
+)
+const PermissionGroupsPage: LazyLoadedComponent = lazyLoad(
+ () => import('./permission-management/PermissionGroupsPage'),
+ 'PermissionGroupsPage',
+)
+const PermissionGroupMembersPage: LazyLoadedComponent = lazyLoad(
+ () => import('./permission-management/PermissionGroupMembersPage'),
+ 'PermissionGroupMembersPage',
+)
+const PermissionAddGroupMembersPage: LazyLoadedComponent = lazyLoad(
+ () => import('./permission-management/PermissionAddGroupMembersPage'),
+ 'PermissionAddGroupMembersPage',
+)
+
+export const toolTitle: string = ToolTitle.admin
+
+export const adminRoutes: ReadonlyArray = [
+ // Admin App Root
+ {
+ authRequired: true,
+ children: [
+ {
+ element: ,
+ route: '',
+ },
+ // Challenge Management Module
+ {
+ children: [
+ {
+ element: ,
+ id: 'challenge-management-page',
+ route: '',
+ },
+ {
+ element: ,
+ id: 'manage-user',
+ route: ':challengeId/manage-user',
+ },
+ {
+ element: ,
+ id: 'manage-resource',
+ route: ':challengeId/manage-resource',
+ },
+ {
+ element: ,
+ id: 'add-resource',
+ route: ':challengeId/manage-resource/add',
+ },
+ ],
+ element: ,
+ id: manageChallengeRouteId,
+ route: manageChallengeRouteId,
+ },
+ // User Management Module
+ {
+ element: ,
+ id: userManagementRouteId,
+ route: userManagementRouteId,
+ },
+ // Reviewer Management Module
+ {
+ children: [
+ {
+ element: ,
+ id: 'review-management-page',
+ route: '',
+ },
+ {
+ element: ,
+ id: 'manage-reviewer',
+ route: ':challengeId/manage-reviewer',
+ },
+ ],
+ element: ,
+ id: manageReviewRouteId,
+ route: manageReviewRouteId,
+ },
+ // Billing Account Module
+ {
+ children: [
+ {
+ element: ,
+ id: 'billing-accounts-page',
+ route: 'billing-accounts',
+ },
+ {
+ element: ,
+ id: 'billing-account-new-page',
+ route: 'billing-accounts/new',
+ },
+ {
+ element: ,
+ id: 'billing-account-details-page',
+ route: 'billing-accounts/:accountId/details',
+ },
+ {
+ element: ,
+ id: 'billing-account-resources-page',
+ route: 'billing-accounts/:accountId/resources',
+ },
+ {
+ element: ,
+ id: 'billing-account-resources-page',
+ route: 'billing-accounts/:accountId/edit',
+ },
+ {
+ element: ,
+ id: 'billing-account-resource-new-page',
+ route: 'billing-accounts/:accountId/resources/new',
+ },
+ {
+ element: ,
+ id: 'billing-account-clients-page',
+ route: 'clients',
+ },
+ {
+ element: ,
+ id: 'billing-account-client-edit-page',
+ route: 'clients/:clientId/edit',
+ },
+ {
+ element: ,
+ id: 'billing-account-client-edit-page',
+ route: 'clients/new',
+ },
+ ],
+ element: ,
+ id: billingAccountRouteId,
+ route: billingAccountRouteId,
+ },
+ // Permission Management Module
+ {
+ children: [
+ {
+ element: ,
+ id: 'permission-roles-page',
+ route: 'roles',
+ },
+ {
+ element: ,
+ id: 'permission-role-members-page',
+ route: 'roles/:roleId/role-members',
+ },
+ {
+ element: ,
+ id: 'permission-add-role-members-page',
+ route: 'roles/:roleId/role-members/add',
+ },
+ {
+ element: ,
+ id: 'permission-groups-page',
+ route: 'groups',
+ },
+ {
+ element: ,
+ id: 'permission-group-members-page',
+ route: 'groups/:groupId/group-members',
+ },
+ {
+ element: ,
+ id: 'permission-add-group-members-page',
+ route: 'groups/:groupId/group-members/add',
+ },
+ ],
+ element: ,
+ id: permissionManagementRouteId,
+ route: permissionManagementRouteId,
+ },
+ ],
+ domain: AppSubdomain.admin,
+ element: ,
+ id: toolTitle,
+ rolesRequired: [UserRole.administrator],
+ route: rootRoute,
+ title: toolTitle,
+ },
+]
diff --git a/src/apps/admin/src/billing-account/BillingAccount.tsx b/src/apps/admin/src/billing-account/BillingAccount.tsx
new file mode 100644
index 000000000..e40bb5b3e
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccount.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 { adminRoutes } from '../admin-app.routes'
+import { billingAccountRouteId } from '../config/routes.config'
+
+/**
+ * The router outlet with layout.
+ */
+export const BillingAccount: 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 === billingAccountRouteId)
+ ?.children?.map(getRouteElement),
+ [], // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: getRouteElement
+ )
+ return childRoutes
+}
+
+/**
+ * The outlet layout.
+ */
+BillingAccount.Layout = function BillingAccountLayout(
+ props: PropsWithChildren,
+) {
+ return {props.children}
+}
+
+export default BillingAccount
diff --git a/src/apps/admin/src/billing-account/BillingAccountDetailsPage/BillingAccountDetailsPage.module.scss b/src/apps/admin/src/billing-account/BillingAccountDetailsPage/BillingAccountDetailsPage.module.scss
new file mode 100644
index 000000000..09eca44f9
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountDetailsPage/BillingAccountDetailsPage.module.scss
@@ -0,0 +1,30 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.loadingSpinnerContainer {
+ position: relative;
+ height: 100px;
+
+ .spinner {
+ background: none;
+ }
+}
+
+.blockBottom {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 30px;
+ margin-left: auto;
+ margin-top: $sp-2;
+
+ @include ltemd {
+ flex-direction: column;
+ align-items: flex-end;
+ }
+}
diff --git a/src/apps/admin/src/billing-account/BillingAccountDetailsPage/BillingAccountDetailsPage.tsx b/src/apps/admin/src/billing-account/BillingAccountDetailsPage/BillingAccountDetailsPage.tsx
new file mode 100644
index 000000000..673b2385b
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountDetailsPage/BillingAccountDetailsPage.tsx
@@ -0,0 +1,201 @@
+/**
+ * Billing account details page.
+ */
+import { FC, useMemo } from 'react'
+import { useParams } from 'react-router-dom'
+import classNames from 'classnames'
+
+import { LinkButton, LoadingSpinner, PageTitle } from '~/libs/ui'
+
+import { DetailsTableColumn } from '../../lib/models/DetailsTableColumn.model'
+import { useManageBillingAccountDetail, useManageBillingAccountDetailProps } from '../../lib/hooks'
+import { PageContent, PageHeader } from '../../lib'
+import { BillingAccount } from '../../lib/models'
+import { TableRowDetails } from '../../lib/components/common/TableRowDetails'
+
+import styles from './BillingAccountDetailsPage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+const pageTitle = 'Details - Billing Account'
+
+export const BillingAccountDetailsPage: FC = (props: Props) => {
+ const { accountId = '' }: { accountId?: string } = useParams<{
+ accountId: string
+ }>()
+ const { isLoading, billingAccount }: useManageBillingAccountDetailProps
+ = useManageBillingAccountDetail(accountId)
+
+ const columns = useMemo[][]>(
+ () => [
+ [
+ {
+ detailType: 'label',
+ label: 'Name label',
+ propertyName: 'name',
+ renderer: () => Name
,
+ type: 'element',
+ },
+ {
+ label: 'Name',
+ propertyName: 'name',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'Customer Number label',
+ propertyName: 'companyId',
+ renderer: () => Customer Number
,
+ type: 'element',
+ },
+ {
+ label: 'Customer Number',
+ propertyName: 'companyId',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'Start Date label',
+ propertyName: 'startDateString',
+ renderer: () => Start Date
,
+ type: 'element',
+ },
+ {
+ label: 'Start Date',
+ propertyName: 'startDateString',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'End Date label',
+ propertyName: 'endDateString',
+ renderer: () => End Date
,
+ type: 'element',
+ },
+ {
+ label: 'End Date',
+ propertyName: 'endDateString',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'Status label',
+ propertyName: 'status',
+ renderer: () => Status
,
+ type: 'element',
+ },
+ {
+ label: 'Status',
+ propertyName: 'status',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'Amount label',
+ propertyName: 'budgetAmount',
+ renderer: () => Amount
,
+ type: 'element',
+ },
+ {
+ label: 'Amount',
+ propertyName: 'budgetAmount',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'PO Number label',
+ propertyName: 'poNumber',
+ renderer: () => PO Number
,
+ type: 'element',
+ },
+ {
+ label: 'PO Number',
+ propertyName: 'poNumber',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'Subscription Number label',
+ propertyName: 'subscriptionNumber',
+ renderer: () => Subscription Number
,
+ type: 'element',
+ },
+ {
+ label: 'Subscription Number',
+ propertyName: 'subscriptionNumber',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'Description label',
+ propertyName: 'description',
+ renderer: () => Description
,
+ type: 'element',
+ },
+ {
+ label: 'Description',
+ propertyName: 'description',
+ type: 'text',
+ },
+ ],
+ [
+ {
+ detailType: 'label',
+ label: 'Payment Terms label',
+ propertyName: 'paymentTerms',
+ renderer: () => Payment Terms
,
+ type: 'element',
+ },
+ {
+ label: 'Payment Terms',
+ propertyName: 'paymentTerms',
+ type: 'text',
+ },
+ ],
+ ],
+ [],
+ )
+ return (
+
+
{pageTitle}
+
+ {pageTitle}
+
+
+
+ {isLoading || !billingAccount ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ Cancel
+
+
+
+ )
+}
+
+export default BillingAccountDetailsPage
diff --git a/src/apps/admin/src/billing-account/BillingAccountDetailsPage/index.ts b/src/apps/admin/src/billing-account/BillingAccountDetailsPage/index.ts
new file mode 100644
index 000000000..77d07ea48
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountDetailsPage/index.ts
@@ -0,0 +1 @@
+export { default as BillingAccountDetailsPage } from './BillingAccountDetailsPage'
diff --git a/src/apps/admin/src/billing-account/BillingAccountNewPage/BillingAccountNewPage.module.scss b/src/apps/admin/src/billing-account/BillingAccountNewPage/BillingAccountNewPage.module.scss
new file mode 100644
index 000000000..60845b6b8
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountNewPage/BillingAccountNewPage.module.scss
@@ -0,0 +1,63 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.blockForm {
+ padding: $sp-8;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ position: relative;
+
+ @include ltelg {
+ padding: $sp-4;
+ }
+}
+
+.blockFields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 15px 30px;
+
+ @include ltemd {
+ grid-template-columns: 1fr;
+ }
+}
+
+.blockBottom {
+ display: flex;
+ gap: 15px;
+ justify-content: flex-end;
+}
+
+.loadingSpinnerContainer {
+ position: relative;
+ height: 100px;
+
+ .spinner {
+ background: none;
+ }
+}
+
+.blockActionLoading {
+ position: absolute;
+ width: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 64px;
+ left: $sp-8;
+ bottom: $sp-8;
+
+ .spinner {
+ background: none;
+ }
+
+ @include ltelg {
+ left: $sp-4;
+ bottom: $sp-4;
+ }
+}
diff --git a/src/apps/admin/src/billing-account/BillingAccountNewPage/BillingAccountNewPage.tsx b/src/apps/admin/src/billing-account/BillingAccountNewPage/BillingAccountNewPage.tsx
new file mode 100644
index 000000000..88e288e71
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountNewPage/BillingAccountNewPage.tsx
@@ -0,0 +1,438 @@
+/* eslint-disable complexity */
+/**
+ * Billing account add page.
+ */
+import { FC, useCallback, useEffect, useMemo } from 'react'
+import {
+ Controller,
+ ControllerRenderProps,
+ useForm,
+ UseFormReturn,
+} from 'react-hook-form'
+import { NavigateFunction, useNavigate, useParams } from 'react-router-dom'
+import _ from 'lodash'
+import classNames from 'classnames'
+import moment from 'moment'
+
+import {
+ Button,
+ InputDatePicker,
+ InputSelect,
+ InputText,
+ LinkButton,
+ LoadingSpinner,
+ PageTitle,
+} from '~/libs/ui'
+import { yupResolver } from '@hookform/resolvers/yup'
+
+import { FieldClientSelect } from '../../lib/components/FieldClientSelect'
+import { useManageAddBillingAccount, useManageAddBillingAccountProps } from '../../lib/hooks'
+import { BILLING_ACCOUNT_STATUS_EDIT_OPTIONS } from '../../config/index.config'
+import { PageContent, PageHeader } from '../../lib'
+import { FormEditBillingAccount, SelectOption } from '../../lib/models'
+import { formEditBillingAccountSchema } from '../../lib/utils'
+
+import styles from './BillingAccountNewPage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+export const BillingAccountNewPage: FC = (props: Props) => {
+ const navigate: NavigateFunction = useNavigate()
+ const maxDate = useMemo(() => moment()
+ .add(20, 'y')
+ .toDate(), [])
+ const { accountId = '' }: { accountId?: string } = useParams<{
+ accountId: string
+ }>()
+ const {
+ isLoading,
+ isAdding,
+ isUpdating,
+ doAddBillingAccount,
+ doUpdateBillingAccount,
+ billingAccount,
+ }: useManageAddBillingAccountProps = useManageAddBillingAccount(accountId)
+ const pageTitle = useMemo(
+ () => (accountId ? 'Edit Billing Account' : 'New Billing Account'),
+ [accountId],
+ )
+ const {
+ register,
+ handleSubmit,
+ control,
+ watch,
+ reset,
+ formState: { errors, isDirty },
+ }: UseFormReturn = useForm({
+ defaultValues: {
+ budgetAmount: '' as any,
+ client: undefined,
+ companyId: '' as any,
+ description: '',
+ endDate: undefined,
+ name: '',
+ paymentTerms: '' as any,
+ poNumber: '',
+ salesTax: 0,
+ startDate: undefined,
+ status: 'Active',
+ subscriptionNumber: '' as any,
+ },
+ mode: 'all',
+ resolver: yupResolver(formEditBillingAccountSchema),
+ })
+
+ const endDate = watch('endDate')
+ const startDate = watch('startDate')
+ const maxStartDate = useMemo(
+ () => (endDate ?? maxDate),
+ [maxDate, endDate],
+ )
+ const minEndDate = useMemo(
+ () => (startDate ?? new Date()),
+ [startDate],
+ )
+ const onSubmit = useCallback(
+ (data: FormEditBillingAccount) => {
+ if (accountId) {
+ doUpdateBillingAccount(data, () => {
+ navigate('./../..')
+ })
+ } else {
+ doAddBillingAccount(data, () => {
+ navigate('./..')
+ })
+ }
+ },
+ [
+ doAddBillingAccount,
+ accountId,
+ doUpdateBillingAccount,
+ navigate,
+ ],
+ )
+
+ useEffect(() => {
+ if (billingAccount) {
+ reset({
+ budgetAmount: billingAccount.budgetAmount,
+ client: billingAccount.client
+ ? {
+ id: billingAccount.client.id,
+ name: billingAccount.client.name,
+ }
+ : undefined,
+ companyId: billingAccount.companyId,
+ description: billingAccount.description,
+ endDate: billingAccount.endDate,
+ name: billingAccount.name,
+ paymentTerms: billingAccount.paymentTerms
+ ? parseInt(billingAccount.paymentTerms, 10) ?? 0
+ : undefined,
+ poNumber: billingAccount.poNumber,
+ salesTax: billingAccount.salesTax,
+ startDate: billingAccount.startDate,
+ status: billingAccount.status,
+ subscriptionNumber: billingAccount.subscriptionNumber
+ ? parseInt(billingAccount.subscriptionNumber, 10)
+ : undefined,
+ })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [billingAccount])
+
+ return (
+
+
{pageTitle}
+
+ {pageTitle}
+
+ {isLoading ? (
+
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+export default BillingAccountNewPage
diff --git a/src/apps/admin/src/billing-account/BillingAccountNewPage/index.ts b/src/apps/admin/src/billing-account/BillingAccountNewPage/index.ts
new file mode 100644
index 000000000..12abd9bed
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountNewPage/index.ts
@@ -0,0 +1 @@
+export { default as BillingAccountNewPage } from './BillingAccountNewPage'
diff --git a/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/BillingAccountResourceNewPage.module.scss b/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/BillingAccountResourceNewPage.module.scss
new file mode 100644
index 000000000..83981a18b
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/BillingAccountResourceNewPage.module.scss
@@ -0,0 +1,61 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.blockForm {
+ padding: $sp-8;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ position: relative;
+
+ @include ltelg {
+ padding: $sp-4;
+ }
+}
+
+.blockFields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 15px 30px;
+
+ @include ltemd {
+ grid-template-columns: 1fr;
+ }
+}
+
+.fieldUserId {
+ grid-column: span 2 / span 2;
+
+ @include ltemd {
+ grid-column: span 1 / span 1;
+ }
+}
+
+.blockBtns {
+ display: flex;
+ gap: 15px;
+ justify-content: flex-end;
+}
+
+.blockActionLoading {
+ position: absolute;
+ width: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ bottom: 0;
+ height: 64px;
+ left: $sp-8;
+
+ .spinner {
+ background: none;
+ }
+
+ @include ltelg {
+ left: $sp-4;
+ }
+}
diff --git a/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/BillingAccountResourceNewPage.tsx b/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/BillingAccountResourceNewPage.tsx
new file mode 100644
index 000000000..d5a266143
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/BillingAccountResourceNewPage.tsx
@@ -0,0 +1,186 @@
+/**
+ * Billing account resource new page.
+ */
+import { FC, useCallback, useEffect, useRef } from 'react'
+import {
+ Controller,
+ ControllerRenderProps,
+ useForm,
+ UseFormReturn,
+} from 'react-hook-form'
+import { NavigateFunction, useNavigate, useParams } from 'react-router-dom'
+import _ from 'lodash'
+import classNames from 'classnames'
+
+import {
+ Button,
+ InputSelect,
+ InputText,
+ LinkButton,
+ LoadingSpinner,
+ PageTitle,
+} from '~/libs/ui'
+import { yupResolver } from '@hookform/resolvers/yup'
+
+import { useManageAddBillingAccountResource, useManageAddBillingAccountResourceProps } from '../../lib/hooks'
+import { BILLING_ACCOUNT_RESOURCE_STATUS_EDIT_OPTIONS } from '../../config/index.config'
+import { FormNewBillingAccountResource } from '../../lib/models'
+import { PageContent, PageHeader } from '../../lib'
+import { formNewBillingAccountResourceSchema } from '../../lib/utils'
+
+import styles from './BillingAccountResourceNewPage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+const pageTitle = 'New Billing Account Resource'
+
+export const BillingAccountResourceNewPage: FC = (props: Props) => {
+ const navigate: NavigateFunction = useNavigate()
+ const { accountId = '' }: { accountId?: string } = useParams<{
+ accountId: string
+ }>()
+ const {
+ isLoading,
+ isAdding,
+ userInfo,
+ doSearchUserInfo,
+ doAddBillingAccountResource,
+ }: useManageAddBillingAccountResourceProps = useManageAddBillingAccountResource(accountId)
+ const shouldValidateUserId = useRef(false)
+ const {
+ control,
+ handleSubmit,
+ register,
+ formState: { errors, isDirty },
+ setValue,
+ }: UseFormReturn = useForm({
+ defaultValues: {
+ name: '',
+ status: 'active',
+ userId: '',
+ },
+ mode: 'all',
+ resolver: yupResolver(formNewBillingAccountResourceSchema),
+ })
+ const onSubmit = useCallback(
+ (data: FormNewBillingAccountResource) => {
+ doAddBillingAccountResource(data, () => {
+ navigate('./..')
+ })
+ },
+ [doAddBillingAccountResource, navigate],
+ )
+
+ useEffect(() => {
+ setValue('userId', userInfo?.id ?? '', {
+ shouldValidate: shouldValidateUserId.current,
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userInfo])
+
+ return (
+
+
{pageTitle}
+
+ {pageTitle}
+
+
+
+
+
+ )
+}
+
+export default BillingAccountResourceNewPage
diff --git a/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/index.ts b/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/index.ts
new file mode 100644
index 000000000..6b5c6a854
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountResourceNewPage/index.ts
@@ -0,0 +1 @@
+export { default as BillingAccountResourceNewPage } from './BillingAccountResourceNewPage'
diff --git a/src/apps/admin/src/billing-account/BillingAccountResourcesPage/BillingAccountResourcesPage.module.scss b/src/apps/admin/src/billing-account/BillingAccountResourcesPage/BillingAccountResourcesPage.module.scss
new file mode 100644
index 000000000..0b4cd2918
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountResourcesPage/BillingAccountResourcesPage.module.scss
@@ -0,0 +1,45 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.headerActions {
+ display: flex;
+ gap: 30px;
+ margin-left: auto;
+ margin-top: $sp-2;
+}
+
+.blockTableContainer {
+ position: relative;
+}
+
+.loadingSpinnerContainer {
+ position: relative;
+ height: 100px;
+
+ .spinner {
+ background: none;
+ }
+}
+
+.blockActionLoading {
+ position: absolute;
+ width: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ bottom: 0;
+ height: 64px;
+ left: $sp-8;
+
+ .spinner {
+ background: none;
+ }
+
+ @include ltelg {
+ left: $sp-4;
+ }
+}
diff --git a/src/apps/admin/src/billing-account/BillingAccountResourcesPage/BillingAccountResourcesPage.tsx b/src/apps/admin/src/billing-account/BillingAccountResourcesPage/BillingAccountResourcesPage.tsx
new file mode 100644
index 000000000..5bcee8faf
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountResourcesPage/BillingAccountResourcesPage.tsx
@@ -0,0 +1,90 @@
+/**
+ * Billing account resources page.
+ */
+import { FC } from 'react'
+import { useParams } from 'react-router-dom'
+import classNames from 'classnames'
+
+import { PlusIcon } from '@heroicons/react/solid'
+import { LinkButton, LoadingSpinner, PageTitle } from '~/libs/ui'
+
+import { BillingAccountResourcesTable } from '../../lib/components/BillingAccountResourcesTable'
+import { useManageBillingAccountResources, useManageBillingAccountResourcesProps } from '../../lib/hooks'
+import { MSG_NO_RECORD_FOUND } from '../../config/index.config'
+import { PageContent, PageHeader } from '../../lib'
+
+import styles from './BillingAccountResourcesPage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+const pageTitle = 'Billing Account Resources'
+
+export const BillingAccountResourcesPage: FC = (props: Props) => {
+ const { accountId = '' }: { accountId?: string } = useParams<{
+ accountId: string
+ }>()
+ const {
+ billingAccountResources,
+ isLoading,
+ isRemoving,
+ isRemovingBool,
+ doRemoveBillingAccountResource,
+ }: useManageBillingAccountResourcesProps = useManageBillingAccountResources(accountId)
+
+ return (
+
+
{pageTitle}
+
+ {pageTitle}
+
+
+
+ Back
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {billingAccountResources.length === 0 ? (
+
+ {MSG_NO_RECORD_FOUND}
+
+ ) : (
+
+
+
+ {isRemovingBool && (
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+
+ )
+}
+
+export default BillingAccountResourcesPage
diff --git a/src/apps/admin/src/billing-account/BillingAccountResourcesPage/index.ts b/src/apps/admin/src/billing-account/BillingAccountResourcesPage/index.ts
new file mode 100644
index 000000000..1895d027b
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountResourcesPage/index.ts
@@ -0,0 +1 @@
+export { default as BillingAccountResourcesPage } from './BillingAccountResourcesPage'
diff --git a/src/apps/admin/src/billing-account/BillingAccountsPage/BillingAccountsPage.module.scss b/src/apps/admin/src/billing-account/BillingAccountsPage/BillingAccountsPage.module.scss
new file mode 100644
index 000000000..4dd23dc4f
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountsPage/BillingAccountsPage.module.scss
@@ -0,0 +1,27 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.headerActions {
+ display: flex;
+ gap: 30px;
+ margin-left: auto;
+ margin-top: $sp-2;
+}
+
+.loadingSpinnerContainer {
+ position: relative;
+ height: 100px;
+
+ .spinner {
+ background: none;
+ }
+}
+
+.noRecordFound {
+ padding: 16px 16px 32px;
+ text-align: center;
+}
diff --git a/src/apps/admin/src/billing-account/BillingAccountsPage/BillingAccountsPage.tsx b/src/apps/admin/src/billing-account/BillingAccountsPage/BillingAccountsPage.tsx
new file mode 100644
index 000000000..13d299563
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountsPage/BillingAccountsPage.tsx
@@ -0,0 +1,88 @@
+/**
+ * Billing accounts page.
+ */
+import { FC } from 'react'
+import classNames from 'classnames'
+
+import { PlusIcon } from '@heroicons/react/solid'
+import { LinkButton, LoadingSpinner, PageDivider, PageTitle } from '~/libs/ui'
+
+import { BillingAccountsFilter } from '../../lib/components/BillingAccountsFilter'
+import { BillingAccountsTable } from '../../lib/components/BillingAccountsTable'
+import { useManageBillingAccounts, useManageBillingAccountsProps } from '../../lib/hooks'
+import { MSG_NO_RECORD_FOUND } from '../../config/index.config'
+import { PageContent, PageHeader } from '../../lib'
+
+import styles from './BillingAccountsPage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+const pageTitle = 'Billing Accounts'
+
+export const BillingAccountsPage: FC = (props: Props) => {
+ const {
+ isLoading,
+ datas,
+ totalPages,
+ page,
+ setPage,
+ sort,
+ setSort,
+ setFilterCriteria,
+ }: useManageBillingAccountsProps = useManageBillingAccounts({
+ endDateString: 'endDate',
+ startDateString: 'startDate',
+ })
+ return (
+
+
{pageTitle}
+
+ {pageTitle}
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {datas.length === 0 ? (
+
+ {MSG_NO_RECORD_FOUND}
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+ )
+}
+
+export default BillingAccountsPage
diff --git a/src/apps/admin/src/billing-account/BillingAccountsPage/index.ts b/src/apps/admin/src/billing-account/BillingAccountsPage/index.ts
new file mode 100644
index 000000000..0a90e4bf2
--- /dev/null
+++ b/src/apps/admin/src/billing-account/BillingAccountsPage/index.ts
@@ -0,0 +1 @@
+export { default as BillingAccountsPage } from './BillingAccountsPage'
diff --git a/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.module.scss b/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.module.scss
new file mode 100644
index 000000000..bf017446f
--- /dev/null
+++ b/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.module.scss
@@ -0,0 +1,62 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.blockForm {
+ padding: $sp-8;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ position: relative;
+
+ @include ltelg {
+ padding: $sp-4;
+ }
+}
+
+.blockFields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 15px 30px;
+
+ @include ltemd {
+ grid-template-columns: 1fr;
+ }
+}
+
+.blockBottom {
+ display: flex;
+ gap: 15px;
+ justify-content: flex-end;
+}
+
+.loadingSpinnerContainer {
+ position: relative;
+ height: 100px;
+
+ .spinner {
+ background: none;
+ }
+}
+
+.blockActionLoading {
+ position: absolute;
+ width: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ bottom: 0;
+ height: 64px;
+ left: $sp-8;
+
+ .spinner {
+ background: none;
+ }
+
+ @include ltelg {
+ left: $sp-4;
+ }
+}
diff --git a/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.tsx b/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.tsx
new file mode 100644
index 000000000..25b2e58d3
--- /dev/null
+++ b/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.tsx
@@ -0,0 +1,302 @@
+/**
+ * Billing account client edit page.
+ */
+import { FC, useCallback, useEffect, useMemo } from 'react'
+import {
+ Controller,
+ ControllerRenderProps,
+ useForm,
+ UseFormReturn,
+} from 'react-hook-form'
+import { NavigateFunction, useNavigate, useParams } from 'react-router-dom'
+import _ from 'lodash'
+import classNames from 'classnames'
+import moment from 'moment'
+
+import {
+ Button,
+ InputDatePicker,
+ InputSelect,
+ InputText,
+ LinkButton,
+ LoadingSpinner,
+ PageTitle,
+} from '~/libs/ui'
+import { yupResolver } from '@hookform/resolvers/yup'
+
+import { useManageAddClient, useManageAddClientProps } from '../../lib/hooks'
+import { BILLING_ACCOUNT_STATUS_EDIT_OPTIONS } from '../../config/index.config'
+import { FormEditClient } from '../../lib/models'
+import { PageContent, PageHeader } from '../../lib'
+import { formEditClientSchema } from '../../lib/utils'
+
+import styles from './ClientEditPage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+export const ClientEditPage: FC = (props: Props) => {
+ const navigate: NavigateFunction = useNavigate()
+ const maxDate = useMemo(() => moment()
+ .add(20, 'y')
+ .toDate(), [])
+ const { clientId = '' }: { clientId?: string } = useParams<{
+ clientId: string
+ }>()
+ const pageTitle = useMemo(
+ () => (clientId ? 'Edit Client' : 'New Client'),
+ [clientId],
+ )
+ const {
+ isLoading,
+ isAdding,
+ isUpdating,
+ doAddClientInfo,
+ doUpdateClientInfo,
+ clientInfo,
+ }: useManageAddClientProps = useManageAddClient(clientId)
+ const {
+ register,
+ handleSubmit,
+ control,
+ formState: { errors, isDirty },
+ watch,
+ reset,
+ }: UseFormReturn = useForm({
+ defaultValues: {
+ codeName: '',
+ endDate: undefined,
+ name: '',
+ startDate: undefined,
+ status: 'Active',
+ },
+ mode: 'all',
+ resolver: yupResolver(formEditClientSchema),
+ })
+
+ useEffect(() => {
+ if (clientInfo) {
+ reset({
+ codeName: clientInfo.codeName,
+ endDate: clientInfo.endDate,
+ name: clientInfo.name,
+ startDate: clientInfo.startDate,
+ status: clientInfo.status,
+ })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [clientInfo])
+
+ const endDate = watch('endDate')
+ const startDate = watch('startDate')
+ const maxStartDate = useMemo(
+ () => (endDate ?? maxDate),
+ [maxDate, endDate],
+ )
+ const minEndDate = useMemo(
+ () => (startDate ?? new Date()),
+ [startDate],
+ )
+ const onSubmit = useCallback((data: FormEditClient) => {
+ if (clientId) {
+ doUpdateClientInfo(data, () => {
+ navigate('./../..')
+ })
+ } else {
+ doAddClientInfo(data, () => {
+ navigate('./..')
+ })
+ }
+ }, [
+ clientId,
+ doAddClientInfo,
+ doUpdateClientInfo,
+ navigate,
+ ])
+
+ return (
+
+
{pageTitle}
+
+ {pageTitle}
+
+ {isLoading ? (
+
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+export default ClientEditPage
diff --git a/src/apps/admin/src/billing-account/ClientEditPage/index.ts b/src/apps/admin/src/billing-account/ClientEditPage/index.ts
new file mode 100644
index 000000000..04bfa4090
--- /dev/null
+++ b/src/apps/admin/src/billing-account/ClientEditPage/index.ts
@@ -0,0 +1 @@
+export { default as ClientEditPage } from './ClientEditPage'
diff --git a/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.module.scss b/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.module.scss
new file mode 100644
index 000000000..4dd23dc4f
--- /dev/null
+++ b/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.module.scss
@@ -0,0 +1,27 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.headerActions {
+ display: flex;
+ gap: 30px;
+ margin-left: auto;
+ margin-top: $sp-2;
+}
+
+.loadingSpinnerContainer {
+ position: relative;
+ height: 100px;
+
+ .spinner {
+ background: none;
+ }
+}
+
+.noRecordFound {
+ padding: 16px 16px 32px;
+ text-align: center;
+}
diff --git a/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.tsx b/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.tsx
new file mode 100644
index 000000000..ee2e97010
--- /dev/null
+++ b/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.tsx
@@ -0,0 +1,89 @@
+/**
+ * Billing account clients page.
+ */
+import { FC } from 'react'
+import classNames from 'classnames'
+
+import { LinkButton, LoadingSpinner, PageDivider, PageTitle } from '~/libs/ui'
+import { PlusIcon } from '@heroicons/react/solid'
+
+import { MSG_NO_RECORD_FOUND } from '../../config/index.config'
+import { useManageClients, useManageClientsProps } from '../../lib/hooks'
+import { PageContent, PageHeader } from '../../lib'
+import { ClientsFilter } from '../../lib/components/ClientsFilter'
+import { ClientsTable } from '../../lib/components/ClientsTable'
+
+import styles from './ClientsPage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+const pageTitle = 'Clients'
+
+export const ClientsPage: FC = (props: Props) => {
+ const {
+ isLoading,
+ datas,
+ totalPages,
+ page,
+ setPage,
+ sort,
+ setSort,
+ setFilterCriteria,
+ }: useManageClientsProps = useManageClients({
+ endDateString: 'endDate',
+ startDateString: 'startDate',
+ })
+
+ return (
+
+
{pageTitle}
+
+ {pageTitle}
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {datas.length === 0 ? (
+
+ {MSG_NO_RECORD_FOUND}
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+ )
+}
+
+export default ClientsPage
diff --git a/src/apps/admin/src/billing-account/ClientsPage/index.ts b/src/apps/admin/src/billing-account/ClientsPage/index.ts
new file mode 100644
index 000000000..d407ee121
--- /dev/null
+++ b/src/apps/admin/src/billing-account/ClientsPage/index.ts
@@ -0,0 +1 @@
+export { default as ClientsPage } from './ClientsPage'
diff --git a/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.module.scss b/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.module.scss
new file mode 100644
index 000000000..776aec7da
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.module.scss
@@ -0,0 +1,6 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+}
diff --git a/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.tsx b/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.tsx
new file mode 100644
index 000000000..f030f04ca
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.tsx
@@ -0,0 +1,216 @@
+/**
+ * Add Resource Page.
+ */
+import { FC, useCallback, useContext } from 'react'
+import {
+ Controller,
+ ControllerRenderProps,
+ useForm,
+ UseFormReturn,
+} from 'react-hook-form'
+import { NavigateFunction, useNavigate, useParams } from 'react-router-dom'
+import _ from 'lodash'
+import classNames from 'classnames'
+
+import { Button, LinkButton } from '~/libs/ui'
+import { yupResolver } from '@hookform/resolvers/yup'
+
+import {
+ useManageAddChallengeResource,
+ useManageAddChallengeResourceProps,
+ useOnComponentDidMount,
+ useSearchUserInfo,
+ useSearchUserInfoProps,
+} from '../../lib/hooks'
+import {
+ ChallengeManagementContext,
+ ChallengeManagementContextType,
+ FieldHandleSelect,
+ FieldSingleSelect,
+ FormAddWrapper,
+ InputTextAdmin,
+ PageWrapper,
+} from '../../lib'
+import { FormAddResource, SelectOption } from '../../lib/models'
+import { formAddResourceSchema } from '../../lib/utils'
+
+import styles from './AddResourcePage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+export const AddResourcePage: FC = (props: Props) => {
+ const { challengeId = '' }: { challengeId?: string } = useParams<{
+ challengeId: string
+ }>()
+
+ const { isLoading, doSearchUserInfo, setUserInfo }: useSearchUserInfoProps
+ = useSearchUserInfo()
+
+ const { resourceRoles, loadResourceRoles, resourceRolesLoading }: ChallengeManagementContextType
+ = useContext(ChallengeManagementContext)
+
+ const {
+ doAddChallengeResource,
+ isAdding,
+ }: useManageAddChallengeResourceProps = useManageAddChallengeResource(challengeId)
+
+ const navigate: NavigateFunction = useNavigate()
+ const {
+ control,
+ handleSubmit,
+ register,
+ formState: { errors, isDirty },
+ setValue,
+ }: UseFormReturn = useForm({
+ defaultValues: {
+ handle: undefined,
+ resourceRole: undefined,
+ userId: '',
+ },
+ mode: 'all',
+ resolver: yupResolver(formAddResourceSchema),
+ })
+ const onSubmit = useCallback((data: FormAddResource) => {
+ doAddChallengeResource(data, () => {
+ navigate('./..')
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ useOnComponentDidMount(() => {
+ loadResourceRoles()
+ })
+
+ return (
+
+
+
+
+ Cancel
+
+ >
+ )}
+ >
+ {
+ doSearchUserInfo(
+ e.target.value,
+ userInfo => {
+ setValue(
+ 'handle',
+ {
+ label: userInfo.handle,
+ value: userInfo.userId,
+ },
+ {
+ shouldValidate: true,
+ },
+ )
+ },
+ () => {
+ // eslint-disable-next-line unicorn/no-null
+ setValue('handle', null as any, { // only null value work in this place
+ shouldValidate: true,
+ })
+ },
+ )
+ },
+ })}
+ disabled={isAdding}
+ error={_.get(errors, 'userId.message')}
+ dirty
+ isLoading={isLoading}
+ />
+
+ }) {
+ return (
+
+ )
+ }}
+ />
+
+ }) {
+ return (
+ ({
+ label: resourceRole.name,
+ value: resourceRole.id,
+ }))}
+ label='Resource Role'
+ placeholder='Select'
+ value={controlProps.field.value}
+ onChange={controlProps.field.onChange}
+ onBlur={controlProps.field.onBlur}
+ error={_.get(errors, 'resourceRole.message')}
+ dirty
+ disabled={isAdding}
+ isLoading={resourceRolesLoading}
+ />
+ )
+ }}
+ />
+
+
+ )
+}
+
+export default AddResourcePage
diff --git a/src/apps/admin/src/challenge-management/AddResourcePage/index.ts b/src/apps/admin/src/challenge-management/AddResourcePage/index.ts
new file mode 100644
index 000000000..7e4a35404
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/AddResourcePage/index.ts
@@ -0,0 +1 @@
+export { default as AddResourcePage } from './AddResourcePage'
diff --git a/src/apps/admin/src/challenge-management/ChallengeManagement.tsx b/src/apps/admin/src/challenge-management/ChallengeManagement.tsx
new file mode 100644
index 000000000..f510038ab
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ChallengeManagement.tsx
@@ -0,0 +1,47 @@
+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 { ChallengeManagementContextProvider } from '../lib/contexts'
+import { adminRoutes } from '../admin-app.routes'
+import { manageChallengeRouteId } from '../config/routes.config'
+
+/**
+ * The router outlet with layout.
+ */
+export const ChallengeManagement: 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 === manageChallengeRouteId)
+ ?.children?.map(getRouteElement),
+ [], // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: getRouteElement
+ )
+ return childRoutes
+}
+
+/**
+ * The outlet layout.
+ */
+ChallengeManagement.Layout = function ChallengeManagementLayout(
+ props: PropsWithChildren,
+) {
+ return {props.children}
+}
+
+export default ChallengeManagement
diff --git a/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.module.scss b/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.module.scss
new file mode 100644
index 000000000..69b682b20
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.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/challenge-management/ChallengeManagementPage/ChallengeManagementPage.tsx b/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.tsx
new file mode 100644
index 000000000..53a812aad
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.tsx
@@ -0,0 +1,333 @@
+import {
+ Dispatch,
+ FC,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+} from 'react'
+import { useSearchParams } from 'react-router-dom'
+
+import { LoadingSpinner, PageDivider, PageTitle } from '~/libs/ui'
+import { PaginatedResponse } from '~/libs/core'
+
+import {
+ ChallengeFilters,
+ ChallengeList,
+ Display,
+ PageContent,
+ PageHeader,
+} from '../../lib/components'
+import {
+ Challenge,
+ ChallengeFilterCriteria,
+ ChallengeStatus,
+} from '../../lib/models'
+import { searchChallenges } from '../../lib/services'
+import {
+ createChallengeQueryString,
+ handleError,
+ replaceBrowserUrlQuery,
+} from '../../lib/utils'
+import { useEventCallback } from '../../lib/hooks'
+
+import styles from './ChallengeManagementPage.module.scss'
+
+/**
+ * Challenge Management page.
+ */
+export const ChallengeManagementPage: FC = () => {
+ const pageTitle = 'v5 Challenge Management'
+ const [filterCriteria, setFilterCriteria]: [
+ ChallengeFilterCriteria,
+ Dispatch>,
+ ] = useState({
+ challengeId: '',
+ legacyId: 0,
+ name: '',
+ page: 1,
+ perPage: 25,
+ status: ChallengeStatus.Active,
+ track: null!, // eslint-disable-line @typescript-eslint/no-non-null-assertion, unicorn/no-null
+ type: null!, // eslint-disable-line @typescript-eslint/no-non-null-assertion, unicorn/no-null
+ })
+ const [challenges, setChallenges]: [
+ Array,
+ Dispatch>>,
+ ] = useState>([])
+ const {
+ search: doSearch,
+ searching,
+ searched,
+ totalChallenges,
+ }: ReturnType = useSearch({ filterCriteria })
+
+ const updateBrowserUrl = (): void => {
+ const s = createChallengeQueryString(filterCriteria)
+ replaceBrowserUrlQuery(s)
+ }
+
+ const search = useEventCallback(() => {
+ doSearch()
+ .then(data => {
+ setChallenges(data)
+ window.scrollTo({ left: 0, top: 0 })
+ })
+ updateBrowserUrl()
+ })
+
+ // Init
+ const [filtersInited, setFiltersInited] = useState(false)
+ const qs = useInitialQueryState()
+ const initFilters = useCallback(() => {
+ if (qs) {
+ const newFilters: ChallengeFilterCriteria = {
+ ...filterCriteria,
+ challengeId: qs.id || filterCriteria.challengeId,
+ legacyId: +(qs.legacyId || filterCriteria.legacyId),
+ name: qs.name || filterCriteria.name,
+ page: +(qs.page || filterCriteria.page),
+ perPage: +(qs.perPage || filterCriteria.perPage),
+ status: (qs.status || filterCriteria.status) as ChallengeStatus,
+ track: qs.tracks?.[0] || filterCriteria.track,
+ type: qs.types?.[0] || filterCriteria.type,
+ }
+ setFilterCriteria(newFilters)
+ setFiltersInited(true)
+ }
+ }, [qs]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: filterCriteria
+ useEffect(() => {
+ initFilters()
+ }, [initFilters])
+ useEffect(() => {
+ if (filtersInited) {
+ search()
+ }
+ }, [filtersInited]) // 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
+
+ // Reset
+ const [resetEvent, setResetEvent] = useState(false)
+ useEffect(() => {
+ if (resetEvent) {
+ search()
+ setResetEvent(false)
+ }
+ }, [resetEvent]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: search
+
+ const handleReset = useEventCallback(() => {
+ previousPageChangeEvent.current = false
+ setResetEvent(true)
+ })
+ const handlePageChange = useEventCallback((page: number) => {
+ setFilterCriteria({ ...filterCriteria, page })
+ setPageChangeEvent(true)
+ })
+
+ return (
+ <>
+ {pageTitle}
+
+ {pageTitle}
+
+
+
+
+ {searching && (
+
+
+
+ )}
+ {searched && challenges.length === 0 && (
+ No record found.
+ )}
+
+
+
+
+ >
+ )
+}
+
+/// /////////////////
+// Search reducer
+/// ////////////////
+
+type SearchState = {
+ isLoading: boolean
+ searched: boolean
+ totalChallenges: number
+}
+
+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: {
+ totalChallenges: number
+ }
+ }
+
+const reducer = (
+ previousState: SearchState,
+ action: SearchReducerAction,
+): SearchState => {
+ switch (action.type) {
+ case SearchActionType.SEARCH_INIT: {
+ return {
+ ...previousState,
+ isLoading: true,
+ searched: false,
+ totalChallenges: 0,
+ }
+ }
+
+ case SearchActionType.SEARCH_DONE: {
+ return {
+ ...previousState,
+ isLoading: false,
+ searched: true,
+ totalChallenges: action.payload.totalChallenges,
+ }
+ }
+
+ case SearchActionType.SEARCH_FAILED: {
+ return {
+ ...previousState,
+ isLoading: false,
+ totalChallenges: 0,
+ }
+ }
+
+ default: {
+ return previousState
+ }
+ }
+}
+
+function useSearch({
+ filterCriteria,
+}: {
+ filterCriteria: ChallengeFilterCriteria
+}): {
+ search: () => Promise
+ searched: boolean
+ searching: boolean
+ totalChallenges: number
+} {
+ const [state, dispatch] = useReducer(reducer, {
+ isLoading: false,
+ searched: false,
+ totalChallenges: 0,
+ })
+
+ const search = useEventCallback(async () => {
+ dispatch({ type: SearchActionType.SEARCH_INIT })
+ try {
+ const { data, total }: PaginatedResponse
+ = await searchChallenges(filterCriteria)
+ dispatch({
+ payload: { totalChallenges: total },
+ type: SearchActionType.SEARCH_DONE,
+ })
+ return data
+ } catch (error) {
+ dispatch({ type: SearchActionType.SEARCH_FAILED })
+ handleError(error)
+ return []
+ }
+ })
+
+ return {
+ search,
+ searched: state.searched,
+ searching: state.isLoading,
+ totalChallenges: state.totalChallenges,
+ }
+}
+
+/// /////////////////
+// Query filter state
+/// /////////////////
+
+function useInitialQueryState():
+ | {
+ id: string | undefined
+ legacyId: string | undefined
+ name: string | undefined
+ page: string | undefined
+ perPage: string | undefined
+ status: string | undefined
+ tracks: string[] | undefined
+ types: string[] | undefined
+ }
+ | undefined {
+ const [searchParams] = useSearchParams()
+ const page = searchParams.get('page') || undefined
+ const perPage = searchParams.get('perPage') || undefined
+ const name = searchParams.get('name') || undefined
+ const id = searchParams.get('id') || undefined
+ const legacyId = searchParams.get('legacyId') || undefined
+ const types = searchParams.getAll('types[]') || undefined
+ const tracks = searchParams.getAll('tracks[]') || undefined
+ const status = searchParams.get('status') || undefined
+
+ const [inited, setInited] = useState(false)
+ useEffect(() => {
+ setInited(true)
+ }, [])
+
+ const qs = useMemo(
+ () => (inited
+ ? { id, legacyId, name, page, perPage, status, tracks, types }
+ : undefined),
+ [inited], // eslint-disable-line react-hooks/exhaustive-deps, max-len -- missing dependencies: id, legacyId, name, page, perPage, status, tracks, types
+ )
+ return qs
+}
+
+export default ChallengeManagementPage
diff --git a/src/apps/admin/src/challenge-management/ChallengeManagementPage/index.ts b/src/apps/admin/src/challenge-management/ChallengeManagementPage/index.ts
new file mode 100644
index 000000000..c6b8b8e8c
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ChallengeManagementPage/index.ts
@@ -0,0 +1 @@
+export { default as ChallengeManagementPage } from './ChallengeManagementPage'
diff --git a/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.module.scss b/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.module.scss
new file mode 100644
index 000000000..06ebfb150
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.module.scss
@@ -0,0 +1,8 @@
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.blockTableContainer {
+ position: relative;
+}
diff --git a/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.tsx b/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.tsx
new file mode 100644
index 000000000..bc485739d
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.tsx
@@ -0,0 +1,99 @@
+/**
+ * Manage Resource Page.
+ */
+import { FC } from 'react'
+import { useParams } from 'react-router-dom'
+import classNames from 'classnames'
+
+import { LinkButton } from '~/libs/ui'
+import { PlusIcon } from '@heroicons/react/solid'
+
+import {
+ useManageChallengeResources,
+ useManageChallengeResourcesProps,
+} from '../../lib/hooks'
+import {
+ ActionLoading,
+ PageWrapper,
+ ResourceTable,
+ TableLoading,
+ TableNoRecord,
+} from '../../lib'
+
+import styles from './ManageResourcePage.module.scss'
+
+interface Props {
+ className?: string
+}
+
+export const ManageResourcePage: FC = (props: Props) => {
+ const { challengeId = '' }: { challengeId?: string } = useParams<{
+ challengeId: string
+ }>()
+
+ const {
+ isLoading,
+ resources,
+ totalPages,
+ page,
+ setPage,
+ sort,
+ setSort,
+ isRemovingBool,
+ doRemoveResource,
+ }: useManageChallengeResourcesProps = useManageChallengeResources(
+ challengeId,
+ {
+ createdString: 'created',
+ },
+ )
+
+ return (
+
+
+
+ Back
+
+ >
+ )}
+ >
+ {isLoading ? (
+
+ ) : (
+ <>
+ {resources.length === 0 ? (
+
+ ) : (
+
+
+
+ {isRemovingBool &&
}
+
+ )}
+ >
+ )}
+
+ )
+}
+
+export default ManageResourcePage
diff --git a/src/apps/admin/src/challenge-management/ManageResourcePage/index.ts b/src/apps/admin/src/challenge-management/ManageResourcePage/index.ts
new file mode 100644
index 000000000..38d7bc603
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ManageResourcePage/index.ts
@@ -0,0 +1 @@
+export { default as ManageResourcePage } from './ManageResourcePage'
diff --git a/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.module.scss b/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.module.scss
new file mode 100644
index 000000000..97485335e
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.module.scss
@@ -0,0 +1,23 @@
+@import '@libs/ui/styles/includes';
+
+.headerActions {
+ display: flex;
+ gap: 30px;
+ margin-left: auto;
+ margin-top: $sp-2;
+}
+
+.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/challenge-management/ManageUserPage/ManageUserPage.tsx b/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.tsx
new file mode 100644
index 000000000..dffe348c3
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.tsx
@@ -0,0 +1,466 @@
+import {
+ Dispatch,
+ FC,
+ SetStateAction,
+ useEffect,
+ useReducer,
+ useRef,
+ useState,
+} from 'react'
+import { useLocation, useParams } from 'react-router-dom'
+import { toast } from 'react-toastify'
+
+import { PlusIcon } from '@heroicons/react/solid'
+import { PaginatedResponse } from '~/libs/core'
+import {
+ Button,
+ LinkButton,
+ LoadingSpinner,
+ PageDivider,
+ PageTitle,
+} from '~/libs/ui'
+
+import {
+ ChallengeAddUserDialog,
+ ChallengeUserFilters,
+ ChallengeUserList,
+ Display,
+ PageContent,
+ PageHeader,
+} from '../../lib/components'
+import { TABLE_PAGINATION_ITEM_PER_PAGE } from '../../config/index.config'
+import {
+ ChallengeFilterCriteria,
+ ChallengeResource,
+ ChallengeResourceFilterCriteria,
+} from '../../lib/models'
+import {
+ addChallengeResource,
+ deleteChallengeResource,
+ getChallengeByLegacyId,
+ getChallengeResources,
+} from '../../lib/services'
+import { createChallengeQueryString, handleError } from '../../lib/utils'
+import { useEventCallback } from '../../lib/hooks'
+import { rootRoute } from '../../config/routes.config'
+
+import styles from './ManageUserPage.module.scss'
+
+const BackToChallengeListButton: FC = () => {
+ const location = useLocation()
+ const routeState: { previousChallengeListFilter: ChallengeFilterCriteria }
+ = location.state || {}
+ const qs = routeState.previousChallengeListFilter
+ ? `?${createChallengeQueryString(routeState.previousChallengeListFilter)}`
+ : ''
+ return (
+
+ Back
+
+ )
+}
+
+/**
+ * Manage Users page.
+ */
+export const ManageUserPage: FC = () => {
+ const pageTitle = 'Manage Users'
+ const { challengeId = '' }: { challengeId?: string } = useParams<{
+ challengeId: string
+ }>()
+ const [filterCriteria, setFilterCriteria]: [
+ ChallengeResourceFilterCriteria,
+ Dispatch>,
+ ] = useState({
+ page: 1,
+ perPage: TABLE_PAGINATION_ITEM_PER_PAGE,
+ roleId: '',
+ })
+ const [users, setUsers]: [
+ Array,
+ Dispatch>>,
+ ] = useState>([])
+ const {
+ filter: doFilter,
+ filtering,
+ filtered,
+ totalUsers,
+ }: ReturnType = useFilter({ challengeId, filterCriteria })
+ const { remove: doRemove, removing }: ReturnType
+ = useRemove({ challengeId })
+ const [openAddUserDialog, setOpenAddUserDialog] = useState(false)
+
+ const filter = useEventCallback((): void => {
+ doFilter()
+ .then(data => {
+ setUsers(data)
+ window.scrollTo({ left: 0, top: 0 })
+ })
+ })
+
+ const remove = useEventCallback(
+ (usersToRemove: Array): void => {
+ // eslint-disable-next-line max-len
+ const removeUser = (user: ChallengeResource): void => setUsers(oldUsers => oldUsers.filter(i => i.id !== user.id))
+
+ if (usersToRemove.length === 1) {
+ doRemove(usersToRemove[0])
+ .then(() => removeUser(usersToRemove[0]))
+ } else {
+ Promise.all(
+ usersToRemove.map(usr => doRemove(usr)
+ .then(() => removeUser(usr))),
+ )
+ }
+ },
+ )
+
+ const add = useEventCallback(
+ async ({ handles, roleId }: { handles: string[]; roleId: string }) => {
+ let successCount = 0
+ await Promise.all(
+ handles.map(handle => addChallengeResource({
+ challengeId,
+ memberHandle: handle,
+ roleId,
+ })
+ .then(() => {
+ successCount += 1
+ })
+ .catch(handleError)),
+ )
+ if (successCount) {
+ const msg
+ = successCount > 1
+ ? `${successCount} users have been added.`
+ : 'User has been added.'
+ toast.success(msg)
+ if (
+ !filterCriteria.roleId
+ || filterCriteria.roleId === roleId
+ ) {
+ filter()
+ }
+ }
+ },
+ )
+
+ // Init
+ const [filtersInited, setFiltersInited] = useState(false)
+ useEffect(() => {
+ if (filtersInited) {
+ filter()
+ }
+ }, [filtersInited]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: filter
+
+ // Page change
+ const [pageChangeEvent, setPageChangeEvent] = useState(false)
+ const previousPageChangeEvent = useRef(false)
+ useEffect(() => {
+ if (pageChangeEvent) {
+ filter()
+ setPageChangeEvent(false)
+ previousPageChangeEvent.current = true
+ }
+ }, [pageChangeEvent]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: filter
+
+ // Reset
+ const [resetEvent, setResetEvent] = useState(false)
+ useEffect(() => {
+ if (resetEvent) {
+ filter()
+ setResetEvent(false)
+ }
+ }, [resetEvent]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: filter
+
+ const handleOpenAddUserDialog = useEventCallback(() => setOpenAddUserDialog(true))
+ const handleReset = useEventCallback(() => {
+ previousPageChangeEvent.current = false
+ setResetEvent(true)
+ })
+ const handlePageChange = useEventCallback((page: number) => {
+ setFilterCriteria({ ...filterCriteria, page })
+ setPageChangeEvent(true)
+ })
+ const handleFilterIntialize = useEventCallback(() => {
+ setFiltersInited(true)
+ })
+
+ return (
+ <>
+ {pageTitle}
+
+ {pageTitle}
+
+
+
+
+
+
+
+
+ {filtering && (
+
+
+
+ )}
+ {filtered && users.length === 0 && (
+ No users.
+ )}
+
+
+
+
+ {openAddUserDialog && (
+
+ )}
+ >
+ )
+}
+
+/// /////////////////
+// Filter reducer
+/// /////////////////
+
+const FilterActionType = {
+ FILTER_DONE: 'FILTER_DONE' as const,
+ FILTER_FAILED: 'FILTER_FAILED' as const,
+ FILTER_INIT: 'FILTER_INIT' as const,
+}
+
+type FilterState = {
+ isLoading: boolean
+ filtered: boolean
+ totalUsers: number
+}
+
+type FilterReducerAction =
+ | {
+ type:
+ | typeof FilterActionType.FILTER_INIT
+ | typeof FilterActionType.FILTER_FAILED
+ }
+ | {
+ type: typeof FilterActionType.FILTER_DONE
+ payload: {
+ totalUsers: number
+ }
+ }
+
+const filterReducer = (
+ previousState: FilterState,
+ action: FilterReducerAction,
+): FilterState => {
+ switch (action.type) {
+ case FilterActionType.FILTER_INIT: {
+ return {
+ ...previousState,
+ filtered: false,
+ isLoading: true,
+ totalUsers: 0,
+ }
+ }
+
+ case FilterActionType.FILTER_DONE: {
+ return {
+ ...previousState,
+ filtered: true,
+ isLoading: false,
+ totalUsers: action.payload.totalUsers,
+ }
+ }
+
+ case FilterActionType.FILTER_FAILED: {
+ return {
+ ...previousState,
+ isLoading: false,
+ }
+ }
+
+ default: {
+ return previousState
+ }
+ }
+}
+
+function useFilter({
+ filterCriteria,
+ challengeId,
+}: {
+ filterCriteria: ChallengeResourceFilterCriteria
+ challengeId: string
+}): {
+ filter: () => Promise
+ filtered: boolean
+ filtering: boolean
+ totalUsers: number
+} {
+ const [state, dispatch] = useReducer(filterReducer, {
+ filtered: false,
+ isLoading: false,
+ totalUsers: 0,
+ })
+
+ const filter = useEventCallback(async (): Promise => {
+ const checkIfLegacyId = async (id: string): Promise => {
+ if (/^[0-9]+$/.test(`${id}`)) {
+ return (await getChallengeByLegacyId(+id)).id
+ }
+
+ return id
+ }
+
+ dispatch({ type: FilterActionType.FILTER_INIT })
+ try {
+ const id = await checkIfLegacyId(challengeId)
+ const { data, total }: PaginatedResponse
+ = await getChallengeResources(id, filterCriteria)
+ dispatch({
+ payload: { totalUsers: total },
+ type: FilterActionType.FILTER_DONE,
+ })
+ return data
+ } catch (error) {
+ dispatch({ type: FilterActionType.FILTER_FAILED })
+ handleError(error)
+ return []
+ }
+ })
+
+ return {
+ filter,
+ filtered: state.filtered,
+ filtering: state.isLoading,
+ totalUsers: state.totalUsers,
+ }
+}
+
+/// /////////////////
+// Remove reducer
+/// /////////////////
+
+const RemoveActionType = {
+ REMOVE_DONE: 'REMOVE_DONE' as const,
+ REMOVE_FAILED: 'REMOVE_FAILED' as const,
+ REMOVE_INIT: 'REMOVE_INIT' as const,
+}
+
+type RemoveActionType = {
+ type:
+ | typeof RemoveActionType.REMOVE_INIT
+ | typeof RemoveActionType.REMOVE_FAILED
+ | typeof RemoveActionType.REMOVE_DONE
+}
+
+type RemoveState = {
+ isRemoving: number
+ removed: boolean
+}
+
+const removeReducer = (
+ previousState: RemoveState,
+ action: RemoveActionType,
+): RemoveState => {
+ switch (action.type) {
+ case RemoveActionType.REMOVE_INIT: {
+ return {
+ ...previousState,
+ isRemoving: previousState.isRemoving + 1,
+ removed: false,
+ }
+ }
+
+ case RemoveActionType.REMOVE_DONE: {
+ return {
+ ...previousState,
+ isRemoving: previousState.isRemoving - 1,
+ removed: true,
+ }
+ }
+
+ case RemoveActionType.REMOVE_FAILED: {
+ return {
+ ...previousState,
+ isRemoving: previousState.isRemoving - 1,
+ }
+ }
+
+ default: {
+ return previousState
+ }
+ }
+}
+
+function useRemove({ challengeId }: { challengeId: string }): {
+ remove: (user: ChallengeResource) => Promise
+ removed: boolean
+ removing: boolean
+} {
+ const [state, dispatch] = useReducer(removeReducer, {
+ isRemoving: 0,
+ removed: false,
+ })
+
+ const remove = useEventCallback(
+ async (user: ChallengeResource): Promise => {
+ dispatch({ type: RemoveActionType.REMOVE_INIT })
+
+ try {
+ await deleteChallengeResource({
+ challengeId,
+ memberHandle: user.memberHandle,
+ roleId: user.roleId,
+ })
+ dispatch({ type: RemoveActionType.REMOVE_DONE })
+ return true
+ } catch (error) {
+ dispatch({ type: RemoveActionType.REMOVE_FAILED })
+ handleError(error)
+ return false
+ }
+ },
+ )
+
+ return {
+ remove,
+ removed: state.removed,
+ removing: state.isRemoving !== 0,
+ }
+}
+
+export default ManageUserPage
diff --git a/src/apps/admin/src/challenge-management/ManageUserPage/index.ts b/src/apps/admin/src/challenge-management/ManageUserPage/index.ts
new file mode 100644
index 000000000..f1aa8824f
--- /dev/null
+++ b/src/apps/admin/src/challenge-management/ManageUserPage/index.ts
@@ -0,0 +1 @@
+export { default as ManageUserPage } from './ManageUserPage'
diff --git a/src/apps/admin/src/config/index.config.ts b/src/apps/admin/src/config/index.config.ts
new file mode 100644
index 000000000..efa294770
--- /dev/null
+++ b/src/apps/admin/src/config/index.config.ts
@@ -0,0 +1,45 @@
+// eslint-disable-next-line max-len
+import { InputSelectOption } from '~/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact'
+
+/**
+ * Common config for ui.
+ */
+const EMPTY_OPTION: InputSelectOption = { label: 'all', value: '' }
+export const USER_STATUS_SELECT_OPTIONS: InputSelectOption[] = [
+ EMPTY_OPTION,
+ { label: 'active', value: 'active' },
+ { label: 'inactive', value: 'inactive' },
+]
+export const BILLING_ACCOUNT_STATUS_FILTER_OPTIONS: InputSelectOption[] = [
+ { label: 'Select status', value: '' },
+ { label: 'Active', value: '1' },
+ { label: 'Inactive', value: '0' },
+]
+export const BILLING_ACCOUNT_STATUS_EDIT_OPTIONS: InputSelectOption[] = [
+ { label: 'Active', value: 'Active' },
+ { label: 'Inactive', value: 'Inactive' },
+]
+export const BILLING_ACCOUNT_RESOURCE_STATUS_EDIT_OPTIONS: InputSelectOption[]
+ = [
+ { label: 'Active', value: 'active' },
+ { label: 'Inactive', value: 'inactive' },
+ ]
+export const USER_STATUS_DETAIL_SELECT_OPTIONS: InputSelectOption[] = [
+ { label: 'Verified', value: 'A' },
+ { label: 'Inactive - Duplicate account', value: '5' },
+ { label: 'Inactive - Member wanted account removed', value: '4' },
+ { label: 'Inactive - Deactivated for cheating', value: '6' },
+]
+export const DICT_USER_STATUS = {
+ 4: 'Deactivated(User request)',
+ 5: 'Deactivated(Duplicate account)',
+ 6: 'Deactivated(Cheating account)',
+ A: 'Verified',
+ U: 'Unverified',
+}
+export const TABLE_DATE_FORMAT = 'MMM DD, YYYY HH:mm'
+export const TABLE_PAGINATION_ITEM_PER_PAGE = 25
+export const TABLE_USER_TEMRS_PAGINATION_ITEM_PER_PAGE = 10
+export const LABEL_EMAIL_STATUS_VERIFIED = 'Verified'
+export const LABEL_EMAIL_STATUS_UNVERIFIED = 'Unverified'
+export const MSG_NO_RECORD_FOUND = 'No record found.'
diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts
new file mode 100644
index 000000000..080a79e7d
--- /dev/null
+++ b/src/apps/admin/src/config/routes.config.ts
@@ -0,0 +1,15 @@
+/**
+ * Common config for routes in admin app.
+ */
+import { AppSubdomain, EnvironmentConfig } from '~/config'
+
+export const rootRoute: string
+ = EnvironmentConfig.SUBDOMAIN === AppSubdomain.admin
+ ? ''
+ : `/${AppSubdomain.admin}`
+
+export const manageChallengeRouteId = 'challenge-management'
+export const manageReviewRouteId = 'review-management'
+export const userManagementRouteId = 'user-management'
+export const billingAccountRouteId = 'billing-account'
+export const permissionManagementRouteId = 'permission-management'
diff --git a/src/apps/admin/src/declarations.d.ts b/src/apps/admin/src/declarations.d.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/apps/admin/src/index.ts b/src/apps/admin/src/index.ts
new file mode 100644
index 000000000..2c65914b1
--- /dev/null
+++ b/src/apps/admin/src/index.ts
@@ -0,0 +1,2 @@
+export { adminRoutes } from './admin-app.routes'
+export { rootRoute as adminRootRoute } from './config/routes.config'
diff --git a/src/apps/admin/src/lib/assets/i/cloud-upload-icon.svg b/src/apps/admin/src/lib/assets/i/cloud-upload-icon.svg
new file mode 100644
index 000000000..26a0e903f
--- /dev/null
+++ b/src/apps/admin/src/lib/assets/i/cloud-upload-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/apps/admin/src/lib/assets/i/rectangle-list-regular-icon.svg b/src/apps/admin/src/lib/assets/i/rectangle-list-regular-icon.svg
new file mode 100644
index 000000000..ad5ecc059
--- /dev/null
+++ b/src/apps/admin/src/lib/assets/i/rectangle-list-regular-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/apps/admin/src/lib/assets/i/registrant-user-icon.svg b/src/apps/admin/src/lib/assets/i/registrant-user-icon.svg
new file mode 100644
index 000000000..c35a87000
--- /dev/null
+++ b/src/apps/admin/src/lib/assets/i/registrant-user-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/apps/admin/src/lib/assets/i/submission-icon.svg b/src/apps/admin/src/lib/assets/i/submission-icon.svg
new file mode 100644
index 000000000..e90a3e237
--- /dev/null
+++ b/src/apps/admin/src/lib/assets/i/submission-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/apps/admin/src/lib/assets/i/user-group-icon.svg b/src/apps/admin/src/lib/assets/i/user-group-icon.svg
new file mode 100644
index 000000000..c44f9cc1f
--- /dev/null
+++ b/src/apps/admin/src/lib/assets/i/user-group-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.module.scss b/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.module.scss
new file mode 100644
index 000000000..cf03543d0
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.module.scss
@@ -0,0 +1,23 @@
+@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;
+ }
+
+ th:first-child {
+ padding-left: 16px !important;
+
+ @include ltemd {
+ padding-left: 5px !important;
+ }
+ }
+}
+
+.btnDelete {
+ padding-right: 0;
+}
diff --git a/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.tsx b/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.tsx
new file mode 100644
index 000000000..57250f8f6
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.tsx
@@ -0,0 +1,133 @@
+/**
+ * Billing account resources table.
+ */
+import { FC, useMemo } from 'react'
+import classNames from 'classnames'
+
+import { useWindowSize, WindowSize } from '~/libs/shared'
+import { Button, Table, TableColumn } from '~/libs/ui'
+
+import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks'
+import { Pagination } from '../common/Pagination'
+import {
+ BillingAccountResource,
+ IsRemovingType,
+ MobileTableColumn,
+} from '../../models'
+import { TableMobile } from '../common/TableMobile'
+
+import styles from './BillingAccountResourcesTable.module.scss'
+
+interface Props {
+ className?: string
+ isRemoving: IsRemovingType
+ datas: BillingAccountResource[]
+ doRemoveItem: (item: BillingAccountResource) => void
+}
+
+export const BillingAccountResourcesTable: FC = (props: Props) => {
+ const {
+ page,
+ setPage,
+ totalPages,
+ results,
+ setSort,
+ sort,
+ }: useTableFilterLocalProps = useTableFilterLocal(
+ props.datas ?? [],
+ )
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ label: 'Name',
+ propertyName: 'name',
+ type: 'text',
+ },
+ {
+ label: 'Status',
+ propertyName: 'status',
+ type: 'text',
+ },
+ {
+ className: styles.blockColumnAction,
+ label: '',
+ renderer: (data: BillingAccountResource) => (
+
+ ),
+ type: 'action',
+ },
+ ],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [props.isRemoving, props.doRemoveItem],
+ )
+
+ const columnsMobile = useMemo<
+ MobileTableColumn[][]
+ >(
+ () => [
+ [
+ {
+ ...columns[0],
+ colSpan: 2,
+ },
+ ],
+ [
+ {
+ label: 'Status label',
+ mobileType: 'label',
+ propertyName: 'status',
+ renderer: () => Status:
,
+ type: 'element',
+ },
+ {
+ ...columns[1],
+ mobileType: 'last-value',
+ },
+ ],
+ [
+ {
+ ...columns[2],
+ colSpan: 2,
+ mobileType: 'last-value',
+ },
+ ],
+ ],
+ [columns],
+ )
+
+ const { width: screenWidth }: WindowSize = useWindowSize()
+ const isTablet = useMemo(() => screenWidth <= 465, [screenWidth])
+
+ return (
+
+ {isTablet ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+export default BillingAccountResourcesTable
diff --git a/src/apps/admin/src/lib/components/BillingAccountResourcesTable/index.ts b/src/apps/admin/src/lib/components/BillingAccountResourcesTable/index.ts
new file mode 100644
index 000000000..8a2e89f94
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountResourcesTable/index.ts
@@ -0,0 +1 @@
+export { default as BillingAccountResourcesTable } from './BillingAccountResourcesTable'
diff --git a/src/apps/admin/src/lib/components/BillingAccountsFilter/BillingAccountsFilter.module.scss b/src/apps/admin/src/lib/components/BillingAccountsFilter/BillingAccountsFilter.module.scss
new file mode 100644
index 000000000..f6a160a31
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountsFilter/BillingAccountsFilter.module.scss
@@ -0,0 +1,54 @@
+@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: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 15px 30px;
+
+ @include ltelg {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ @include ltemd {
+ grid-template-columns: 1fr;
+ }
+}
+
+.field {
+ grid-column: span 2 / span 2;
+
+ @include ltelg {
+ grid-column: span 1 / span 1;
+ }
+}
+
+.fieldDate {
+ grid-column: span 3 / span 3;
+
+ @include ltelg {
+ grid-column: span 1 / span 1;
+ }
+}
+
+.blockBottom {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-start;
+ gap: 30px;
+ flex-wrap: wrap;
+
+ @include ltemd {
+ flex-direction: column;
+ align-items: flex-end;
+ }
+}
diff --git a/src/apps/admin/src/lib/components/BillingAccountsFilter/BillingAccountsFilter.tsx b/src/apps/admin/src/lib/components/BillingAccountsFilter/BillingAccountsFilter.tsx
new file mode 100644
index 000000000..898102337
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountsFilter/BillingAccountsFilter.tsx
@@ -0,0 +1,170 @@
+/**
+ * Billing accounts filter.
+ */
+import { FC, useCallback, useMemo } from 'react'
+import {
+ Controller,
+ ControllerRenderProps,
+ useForm,
+ UseFormReturn,
+} from 'react-hook-form'
+import _ from 'lodash'
+import classNames from 'classnames'
+import moment from 'moment'
+
+import { Button, InputDatePicker, InputSelect, InputText } from '~/libs/ui'
+import { yupResolver } from '@hookform/resolvers/yup'
+
+import { BILLING_ACCOUNT_STATUS_FILTER_OPTIONS } from '../../../config/index.config'
+import { FormBillingAccountsFilter } from '../../models'
+import { formBillingAccountsFilterSchema } from '../../utils'
+
+import styles from './BillingAccountsFilter.module.scss'
+
+interface Props {
+ className?: string
+ isLoading?: boolean
+ onSubmitForm?: (data: FormBillingAccountsFilter) => void
+}
+
+export const BillingAccountsFilter: FC = (props: Props) => {
+ const maxDate = useMemo(() => moment()
+ .add(20, 'y')
+ .toDate(), [])
+ const {
+ register,
+ handleSubmit,
+ control,
+ formState: { isValid },
+ }: UseFormReturn = useForm({
+ defaultValues: {
+ status: '1',
+ },
+ mode: 'all',
+ resolver: yupResolver(formBillingAccountsFilterSchema),
+ })
+ const onSubmit = useCallback(
+ (data: FormBillingAccountsFilter) => {
+ props.onSubmitForm?.(data)
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [props.onSubmitForm],
+ )
+
+ return (
+
+ )
+}
+
+export default BillingAccountsFilter
diff --git a/src/apps/admin/src/lib/components/BillingAccountsFilter/index.ts b/src/apps/admin/src/lib/components/BillingAccountsFilter/index.ts
new file mode 100644
index 000000000..ad26231f0
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountsFilter/index.ts
@@ -0,0 +1 @@
+export { default as BillingAccountsFilter } from './BillingAccountsFilter'
diff --git a/src/apps/admin/src/lib/components/BillingAccountsTable/BillingAccountsTable.module.scss b/src/apps/admin/src/lib/components/BillingAccountsTable/BillingAccountsTable.module.scss
new file mode 100644
index 000000000..b5bc883e4
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountsTable/BillingAccountsTable.module.scss
@@ -0,0 +1,40 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+ padding: 0 $sp-8;
+
+ @include ltelg {
+ padding: 0 $sp-4;
+ }
+
+ th:first-child {
+ padding-left: 16px !important;
+
+ @include ltemd {
+ padding-left: 5px !important;
+ }
+ }
+
+ a {
+ color: $blue-110;
+
+ &:hover {
+ color: $blue-110;
+ }
+ }
+}
+
+.btnEditAccount {
+ padding-left: 0;
+}
+
+.btnViewResources {
+ padding-right: 0;
+}
+
+.tableCell {
+ white-space: break-spaces !important;
+ text-align: left !important;
+}
diff --git a/src/apps/admin/src/lib/components/BillingAccountsTable/BillingAccountsTable.tsx b/src/apps/admin/src/lib/components/BillingAccountsTable/BillingAccountsTable.tsx
new file mode 100644
index 000000000..0dca4f59e
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountsTable/BillingAccountsTable.tsx
@@ -0,0 +1,181 @@
+/**
+ * Billing accounts table.
+ */
+import { Dispatch, FC, SetStateAction, useMemo } from 'react'
+import { Link } from 'react-router-dom'
+import classNames from 'classnames'
+
+import { Sort } from '~/apps/gamification-admin/src/game-lib'
+import { LinkButton, Table, TableColumn } from '~/libs/ui'
+import { useWindowSize, WindowSize } from '~/libs/shared'
+
+import { BillingAccount, MobileTableColumn } from '../../models'
+import { Pagination } from '../common/Pagination'
+import { TableMobile } from '../common/TableMobile'
+
+import styles from './BillingAccountsTable.module.scss'
+
+interface Props {
+ className?: string
+ datas: BillingAccount[]
+ totalPages: number
+ page: number
+ setPage: Dispatch>
+ sort: Sort | undefined,
+ setSort: Dispatch>
+}
+
+export const BillingAccountsTable: FC = (props: Props) => {
+ const columns = useMemo[]>(
+ () => [
+ {
+ label: 'Account ID',
+ renderer: (data: BillingAccount) => (
+
+ {data.id}
+
+ ),
+ type: 'element',
+ },
+ {
+ className: styles.tableCell,
+ label: 'Name',
+ propertyName: 'name',
+ type: 'text',
+ },
+ {
+ label: 'Status',
+ propertyName: 'status',
+ type: 'text',
+ },
+ {
+ label: 'Start Date',
+ propertyName: 'startDateString',
+ type: 'text',
+ },
+ {
+ label: 'End Date',
+ propertyName: 'endDateString',
+ type: 'text',
+ },
+ {
+ label: '',
+ renderer: (data: BillingAccount) => (
+
+
+ Edit Account
+
+ |
+
+ View Resources
+
+
+ ),
+ type: 'action',
+ },
+ ],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ )
+
+ const columnsMobile = useMemo[][]>(
+ () => [
+ [
+ {
+ colSpan: 2,
+ label: 'Account ID',
+ propertyName: columns[0].propertyName,
+ renderer: (data: BillingAccount) => (
+
+ {data.id}
+ {' '}
+ |
+ {' '}
+ {data.name}
+
+ ),
+ type: 'element',
+ },
+ ],
+ [
+ {
+ label: 'Status label',
+ mobileType: 'label',
+ propertyName: columns[2].propertyName,
+ renderer: () => Status:
,
+ type: 'element',
+ },
+ {
+ ...columns[2],
+ mobileType: 'last-value',
+ },
+ ],
+ [
+ {
+ label: 'Start Date label',
+ mobileType: 'label',
+ propertyName: columns[3].propertyName,
+ renderer: () => Start Date:
,
+ type: 'element',
+ },
+ {
+ ...columns[3],
+ mobileType: 'last-value',
+ },
+ ],
+ [
+ {
+ label: 'End Date label',
+ mobileType: 'label',
+ propertyName: columns[4].propertyName,
+ renderer: () => End Date:
,
+ type: 'element',
+ },
+ {
+ ...columns[4],
+ mobileType: 'last-value',
+ },
+ ],
+ [
+ {
+ ...columns[5],
+ colSpan: 2,
+ mobileType: 'last-value',
+ },
+ ],
+ ],
+ [columns],
+ )
+
+ const { width: screenWidth }: WindowSize = useWindowSize()
+ const isTablet = useMemo(() => screenWidth <= 1000, [screenWidth])
+
+ return (
+
+ {isTablet ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+export default BillingAccountsTable
diff --git a/src/apps/admin/src/lib/components/BillingAccountsTable/index.ts b/src/apps/admin/src/lib/components/BillingAccountsTable/index.ts
new file mode 100644
index 000000000..a834112d1
--- /dev/null
+++ b/src/apps/admin/src/lib/components/BillingAccountsTable/index.ts
@@ -0,0 +1 @@
+export { default as BillingAccountsTable } from './BillingAccountsTable'
diff --git a/src/apps/admin/src/lib/components/ChallengeAddUserDialog/ChallengeAddUserDialog.module.scss b/src/apps/admin/src/lib/components/ChallengeAddUserDialog/ChallengeAddUserDialog.module.scss
new file mode 100644
index 000000000..102354f33
--- /dev/null
+++ b/src/apps/admin/src/lib/components/ChallengeAddUserDialog/ChallengeAddUserDialog.module.scss
@@ -0,0 +1,44 @@
+@import '@libs/ui/styles/includes';
+
+.addUserForm {
+ .actionButtons {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 6px;
+ margin-top: 20px;
+ }
+}
+
+.selectUserHandles {
+ margin-bottom: 16px;
+
+ .selectUserHandlesTitle {
+ font-weight: 700;
+ font-size: 14px;
+ padding-bottom: 2px;
+ }
+}
+
+
+
+.selectUserHandlesCustomMultiValue {
+ font-size: 13px;
+ padding: 0 8px;
+ margin-right: 6px;
+ color: $black-60;
+ background-color: $black-10;
+ border-radius: 4px;
+
+ &.invalid {
+ color: $red-100;
+ }
+
+ .label {
+ font-weight: 700;
+ }
+}
+
+.selectUserHandlesDropdownContainer {
+ z-index: 9999 !important;
+}
diff --git a/src/apps/admin/src/lib/components/ChallengeAddUserDialog/ChallengeAddUserDialog.tsx b/src/apps/admin/src/lib/components/ChallengeAddUserDialog/ChallengeAddUserDialog.tsx
new file mode 100644
index 000000000..b82b7339c
--- /dev/null
+++ b/src/apps/admin/src/lib/components/ChallengeAddUserDialog/ChallengeAddUserDialog.tsx
@@ -0,0 +1,321 @@
+import {
+ ChangeEvent,
+ createContext,
+ Dispatch,
+ FC,
+ KeyboardEventHandler,
+ SetStateAction,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { MultiValue, MultiValueProps } from 'react-select'
+import _ from 'lodash'
+import CreatableSelect from 'react-select/creatable'
+import cn from 'classnames'
+
+import {
+ BaseModal,
+ Button,
+ IconOutline,
+ InputSelect,
+ InputSelectOption,
+} from '~/libs/ui'
+
+import {
+ getMembersByHandle,
+ getMemberSuggestionsByHandle,
+} from '../../services'
+import { ResourceRole } from '../../models'
+import {
+ ChallengeManagementContext,
+ ChallengeManagementContextType,
+} from '../../contexts'
+import { useEventCallback } from '../../hooks'
+
+import styles from './ChallengeAddUserDialog.module.scss'
+
+interface Option {
+ readonly label: string
+ readonly value: string
+}
+
+export interface ChallengeAddUserDialogProps {
+ open: boolean
+ setOpen: (isOpen: boolean) => void
+ onAdd: (payload: { handles: string[]; roleId: string }) => void
+}
+
+type SelectUserHandlesContextType = { invalidHandles: Set }
+const SelectUserHandlesContext = createContext({
+ invalidHandles: new Set(),
+})
+
+const CustomMultiValue = (
+ props: MultiValueProps