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 ? ( + +
+ +
+
+
+ + Cancel + +
+
+
+ ) : ( + +
+
+ + + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + + + + + + + + }) { + return ( + + ) + }} + /> +
+ +
+ + + + Cancel + +
+ + {(isAdding || isUpdating) && ( +
+ +
+ )} +
+
+ )} +
+ ) +} + +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}

+
+ +
+
+ { + shouldValidateUserId.current = true + doSearchUserInfo(e.target.value) + }, + })} + disabled={isAdding} + error={_.get(errors, 'name.message')} + dirty + isLoading={isLoading} + /> + + }) { + return ( + + ) + }} + /> + +
+ +
+ + + Cancel + +
+ + {isAdding && ( +
+ +
+ )} +
+
+
+ ) +} + +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 ? ( + +
+ +
+
+
+ + Cancel + +
+
+
+ ) : ( + +
+
+ + + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> +
+ +
+ + + + Cancel + +
+ + {isAdding && ( +
+ +
+ )} +
+
+ )} +
+ ) +} + +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) => ( +