From 3995d2d2ccdbe72a32658b1e72c0346ae5ac845d Mon Sep 17 00:00:00 2001
From: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com>
Date: Wed, 9 Nov 2022 02:04:39 +0800
Subject: [PATCH] [offers][fix] add temp admin page (#544)
---
.../offers/admin_temp/OffersHeader.tsx | 83 +++++
.../offers/admin_temp/OffersRow.tsx | 74 ++++
.../offers/admin_temp/OffersTable.tsx | 321 ++++++++++++++++++
.../admin_temp/OffersTablePagination.tsx | 65 ++++
.../src/components/offers/admin_temp/types.ts | 82 +++++
.../components/offers/table/OffersTable.tsx | 6 +-
apps/portal/src/pages/offers/admin_temp.tsx | 186 ++++++++++
7 files changed, 814 insertions(+), 3 deletions(-)
create mode 100644 apps/portal/src/components/offers/admin_temp/OffersHeader.tsx
create mode 100644 apps/portal/src/components/offers/admin_temp/OffersRow.tsx
create mode 100644 apps/portal/src/components/offers/admin_temp/OffersTable.tsx
create mode 100644 apps/portal/src/components/offers/admin_temp/OffersTablePagination.tsx
create mode 100644 apps/portal/src/components/offers/admin_temp/types.ts
create mode 100644 apps/portal/src/pages/offers/admin_temp.tsx
diff --git a/apps/portal/src/components/offers/admin_temp/OffersHeader.tsx b/apps/portal/src/components/offers/admin_temp/OffersHeader.tsx
new file mode 100644
index 00000000..99ce8143
--- /dev/null
+++ b/apps/portal/src/components/offers/admin_temp/OffersHeader.tsx
@@ -0,0 +1,83 @@
+import clsx from 'clsx';
+
+import type { OfferTableSortType } from '~/components/offers/admin_temp/types';
+import {
+ getOppositeSortOrder,
+ OFFER_TABLE_SORT_ORDER,
+} from '~/components/offers/admin_temp/types';
+
+export type OffersTableHeaderProps = Readonly<{
+ header: string;
+ isLastColumn: boolean;
+ onSort?: (
+ sortDirection: OFFER_TABLE_SORT_ORDER,
+ sortType: OfferTableSortType,
+ ) => void;
+ sortDirection?: OFFER_TABLE_SORT_ORDER;
+ sortType?: OfferTableSortType;
+}>;
+
+export default function OffersHeader({
+ header,
+ isLastColumn,
+ onSort,
+ sortDirection,
+ sortType,
+}: OffersTableHeaderProps) {
+ return (
+
{
+ onSort(
+ sortDirection
+ ? getOppositeSortOrder(sortDirection)
+ : OFFER_TABLE_SORT_ORDER.ASC,
+ sortType,
+ );
+ })
+ }>
+
+ {header}
+ {onSort && sortType && (
+
+
+ ▲
+
+
+ ▼
+
+
+ )}
+
+ |
+ );
+}
diff --git a/apps/portal/src/components/offers/admin_temp/OffersRow.tsx b/apps/portal/src/components/offers/admin_temp/OffersRow.tsx
new file mode 100644
index 00000000..bad4a96a
--- /dev/null
+++ b/apps/portal/src/components/offers/admin_temp/OffersRow.tsx
@@ -0,0 +1,74 @@
+import clsx from 'clsx';
+import Link from 'next/link';
+import { JobType } from '@prisma/client';
+
+import type { JobTitleType } from '~/components/shared/JobTitles';
+import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
+
+import { convertMoneyToString } from '~/utils/offers/currency';
+import { formatDate } from '~/utils/offers/time';
+
+import type { AdminDashboardOffer } from '~/types/offers';
+
+export type OfferTableRowProps = Readonly<{
+ jobType: JobType;
+ row: AdminDashboardOffer;
+}>;
+
+export default function OfferTableRow({
+ jobType,
+ row: {
+ baseSalary,
+ bonus,
+ company,
+ id,
+ income,
+ location,
+ monthYearReceived,
+ numberOfOtherOffers,
+ profileId,
+ stocks,
+ title,
+ totalYoe,
+ token,
+ },
+}: OfferTableRowProps) {
+ return (
+
+
+ {company.name}
+
+ {location.cityName} ({location.countryCode})
+
+ |
+
+ {getLabelForJobTitleType(title as JobTitleType)}
+ |
+ {totalYoe} |
+ {convertMoneyToString(income)} |
+ {jobType === JobType.FULLTIME && (
+
+ {`${convertMoneyToString(baseSalary)} / ${convertMoneyToString(
+ bonus,
+ )} / ${convertMoneyToString(stocks)}`}
+ |
+ )}
+ {formatDate(monthYearReceived)} |
+
+
+ View Editable Profile
+
+ {numberOfOtherOffers > 0 && (
+
+ This person also received {numberOfOtherOffers} other offer(s).
+
+ )}
+ |
+
+ );
+}
diff --git a/apps/portal/src/components/offers/admin_temp/OffersTable.tsx b/apps/portal/src/components/offers/admin_temp/OffersTable.tsx
new file mode 100644
index 00000000..39ce9b7c
--- /dev/null
+++ b/apps/portal/src/components/offers/admin_temp/OffersTable.tsx
@@ -0,0 +1,321 @@
+import { useRouter } from 'next/router';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { JobType } from '@prisma/client';
+import { DropdownMenu, Spinner, useToast } from '@tih/ui';
+
+import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
+import OffersRow from '~/components/offers/admin_temp//OffersRow';
+import OffersHeader from '~/components/offers/admin_temp/OffersHeader';
+import OffersTablePagination from '~/components/offers/admin_temp/OffersTablePagination';
+import type {
+ OfferTableColumn,
+ OfferTableSortType,
+} from '~/components/offers/admin_temp/types';
+import {
+ FullTimeOfferTableColumns,
+ InternOfferTableColumns,
+ OFFER_TABLE_SORT_ORDER,
+ OfferTableYoeOptions,
+ YOE_CATEGORY_PARAM,
+} from '~/components/offers/admin_temp/types';
+
+import { getCurrencyForCountry } from '~/utils/offers/currency/CurrencyEnum';
+import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
+import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
+import { trpc } from '~/utils/trpc';
+
+import type {
+ AdminDashboardOffer,
+ GetAdminOffersResponse,
+ Paging,
+} from '~/types/offers';
+
+const NUMBER_OF_OFFERS_PER_PAGE = 20;
+
+export type OffersTableProps = Readonly<{
+ companyFilter: string;
+ companyName?: string;
+ country: string | null;
+ countryFilter: string;
+ jobTitleFilter: string;
+}>;
+
+export default function OffersTable({
+ country,
+ countryFilter,
+ companyName,
+ companyFilter,
+ jobTitleFilter,
+}: OffersTableProps) {
+ const [currency, setCurrency] = useState(
+ getCurrencyForCountry(country).toString(),
+ );
+ const [jobType, setJobType] = useState(JobType.FULLTIME);
+ const [pagination, setPagination] = useState({
+ currentPage: 0,
+ numOfItems: 0,
+ numOfPages: 0,
+ totalItems: 0,
+ });
+
+ const [offers, setOffers] = useState>([]);
+
+ const { event: gaEvent } = useGoogleAnalytics();
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(true);
+
+ const [
+ selectedYoeCategory,
+ setSelectedYoeCategory,
+ isYoeCategoryInitialized,
+ ] = useSearchParamSingle('yoeCategory');
+
+ const [
+ selectedSortDirection,
+ setSelectedSortDirection,
+ isSortDirectionInitialized,
+ ] = useSearchParamSingle('sortDirection');
+
+ const [selectedSortType, setSelectedSortType, isSortTypeInitialized] =
+ useSearchParamSingle('sortType');
+
+ const areFilterParamsInitialized = useMemo(() => {
+ return (
+ isYoeCategoryInitialized &&
+ isSortDirectionInitialized &&
+ isSortTypeInitialized
+ );
+ }, [
+ isYoeCategoryInitialized,
+ isSortDirectionInitialized,
+ isSortTypeInitialized,
+ ]);
+ const { pathname } = router;
+
+ useEffect(() => {
+ if (areFilterParamsInitialized) {
+ router.replace(
+ {
+ pathname,
+ query: {
+ companyId: companyFilter,
+ companyName,
+ jobTitleId: jobTitleFilter,
+ sortDirection: selectedSortDirection,
+ sortType: selectedSortType,
+ yoeCategory: selectedYoeCategory,
+ },
+ },
+ undefined,
+ { shallow: true },
+ );
+ setPagination({
+ currentPage: 0,
+ numOfItems: 0,
+ numOfPages: 0,
+ totalItems: 0,
+ });
+ setIsLoading(true);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ areFilterParamsInitialized,
+ currency,
+ countryFilter,
+ companyFilter,
+ jobTitleFilter,
+ selectedSortDirection,
+ selectedSortType,
+ selectedYoeCategory,
+ pathname,
+ ]);
+
+ useEffect(() => {
+ setSelectedSortDirection(OFFER_TABLE_SORT_ORDER.UNSORTED);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedYoeCategory]);
+ const topRef = useRef(null);
+ const { showToast } = useToast();
+ const { isLoading: isResultsLoading } = trpc.useQuery(
+ [
+ 'offers.admin.list',
+ {
+ companyId: companyFilter,
+ countryId: countryFilter,
+ currency,
+ limit: NUMBER_OF_OFFERS_PER_PAGE,
+ offset: pagination.currentPage,
+ sortBy:
+ selectedSortDirection && selectedSortType
+ ? `${selectedSortDirection}${selectedSortType}`
+ : '-monthYearReceived',
+ title: jobTitleFilter,
+ yoeCategory: selectedYoeCategory
+ ? YOE_CATEGORY_PARAM[selectedYoeCategory as string]
+ : undefined,
+ },
+ ],
+ {
+ onError: () => {
+ showToast({
+ title: 'Error loading the page.',
+ variant: 'failure',
+ });
+ setIsLoading(false);
+ },
+ onSuccess: (response: GetAdminOffersResponse) => {
+ setOffers(response.data);
+ setPagination(response.paging);
+ setJobType(response.jobType);
+ setIsLoading(false);
+ },
+ },
+ );
+
+ const onSort = (
+ sortDirection: OFFER_TABLE_SORT_ORDER,
+ sortType: OfferTableSortType,
+ ) => {
+ gaEvent({
+ action: 'offers_table_sort',
+ category: 'engagement',
+ label: `${sortType} - ${sortDirection}`,
+ });
+ setSelectedSortType(sortType);
+ setSelectedSortDirection(sortDirection);
+ };
+
+ function renderFilters() {
+ return (
+
+
itemValue === selectedYoeCategory,
+ ).length > 0
+ ? OfferTableYoeOptions.filter(
+ ({ value: itemValue }) => itemValue === selectedYoeCategory,
+ )[0].label
+ : OfferTableYoeOptions[0].label
+ }
+ size="inherit">
+ {OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
+ {
+ setSelectedYoeCategory(value);
+ gaEvent({
+ action: `offers.table_filter_yoe_category_${value}`,
+ category: 'engagement',
+ label: 'Filter by YOE category',
+ });
+ }}
+ />
+ ))}
+
+
+
+
+ Display offers in
+
+ setCurrency(value)}
+ selectedCurrency={currency}
+ />
+
+
+
+ );
+ }
+
+ function renderHeader() {
+ const columns: Array =
+ jobType === JobType.FULLTIME
+ ? FullTimeOfferTableColumns
+ : InternOfferTableColumns;
+
+ return (
+
+
+ {columns.map((header, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ const handlePageChange = (currPage: number) => {
+ if (0 <= currPage && currPage < pagination.numOfPages) {
+ setPagination({ ...pagination, currentPage: currPage });
+ }
+ };
+
+ return (
+
+
{renderFilters()}
+
+
+
+ {renderHeader()}
+ {!isLoading && (
+
+ {offers.map((offer) => (
+
+ ))}
+
+ )}
+
+ {isLoading && (
+
+
+
+ )}
+ {(!isLoading && !offers) ||
+ (offers.length === 0 && (
+
+ ))}
+
+
{
+ topRef?.current?.scrollIntoView({
+ block: 'start',
+ });
+ handlePageChange(number);
+ }}
+ isInitialFetch={isLoading}
+ isLoading={isResultsLoading}
+ pagination={pagination}
+ startNumber={pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + 1}
+ />
+
+ );
+}
diff --git a/apps/portal/src/components/offers/admin_temp/OffersTablePagination.tsx b/apps/portal/src/components/offers/admin_temp/OffersTablePagination.tsx
new file mode 100644
index 00000000..fd227cfa
--- /dev/null
+++ b/apps/portal/src/components/offers/admin_temp/OffersTablePagination.tsx
@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react';
+import { Pagination, Spinner } from '@tih/ui';
+
+import type { Paging } from '~/types/offers';
+
+type OffersTablePaginationProps = Readonly<{
+ endNumber: number;
+ handlePageChange: (page: number) => void;
+ isInitialFetch?: boolean;
+ isLoading?: boolean;
+ pagination: Paging;
+ startNumber: number;
+}>;
+
+export default function OffersTablePagination({
+ isInitialFetch,
+ isLoading,
+ endNumber,
+ pagination,
+ startNumber,
+ handlePageChange,
+}: OffersTablePaginationProps) {
+ const [screenWidth, setScreenWidth] = useState(0);
+
+ useEffect(() => {
+ setScreenWidth(window.innerWidth);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/apps/portal/src/components/offers/admin_temp/types.ts b/apps/portal/src/components/offers/admin_temp/types.ts
new file mode 100644
index 00000000..7893b111
--- /dev/null
+++ b/apps/portal/src/components/offers/admin_temp/types.ts
@@ -0,0 +1,82 @@
+// eslint-disable-next-line no-shadow
+export enum YOE_CATEGORY {
+ ENTRY = 'entry',
+ INTERN = 'intern',
+ MID = 'mid',
+ SENIOR = 'senior',
+}
+
+export const YOE_CATEGORY_PARAM: Record = {
+ entry: 1,
+ intern: 0,
+ mid: 2,
+ senior: 3,
+};
+
+export const OfferTableYoeOptions = [
+ { label: 'All Full Time YOE', value: '' },
+ {
+ label: 'Fresh Grad (0-2 YOE)',
+ value: YOE_CATEGORY.ENTRY,
+ },
+ {
+ label: 'Mid (3-5 YOE)',
+ value: YOE_CATEGORY.MID,
+ },
+ {
+ label: 'Senior (6+ YOE)',
+ value: YOE_CATEGORY.SENIOR,
+ },
+ {
+ label: 'Internship',
+ value: YOE_CATEGORY.INTERN,
+ },
+];
+
+export type OfferTableSortType =
+ | 'companyName'
+ | 'jobTitle'
+ | 'monthYearReceived'
+ | 'totalCompensation'
+ | 'totalYoe';
+
+export enum OFFER_TABLE_SORT_ORDER {
+ ASC = '+',
+ DESC = '-',
+ UNSORTED = '',
+}
+
+export function getOppositeSortOrder(
+ order: OFFER_TABLE_SORT_ORDER,
+): OFFER_TABLE_SORT_ORDER {
+ if (order === OFFER_TABLE_SORT_ORDER.UNSORTED) {
+ return OFFER_TABLE_SORT_ORDER.UNSORTED;
+ }
+ return order === OFFER_TABLE_SORT_ORDER.ASC
+ ? OFFER_TABLE_SORT_ORDER.DESC
+ : OFFER_TABLE_SORT_ORDER.ASC;
+}
+
+export type OfferTableColumn = {
+ label: string;
+ sortType?: OfferTableSortType;
+};
+
+export const FullTimeOfferTableColumns: Array = [
+ { label: 'Company', sortType: 'companyName' },
+ { label: 'Title', sortType: 'jobTitle' },
+ { label: 'YOE', sortType: 'totalYoe' },
+ { label: 'Annual TC', sortType: 'totalCompensation' },
+ { label: 'Annual Base / Bonus / Stocks' },
+ { label: 'Date Offered', sortType: 'monthYearReceived' },
+ { label: 'Actions' },
+];
+
+export const InternOfferTableColumns: Array = [
+ { label: 'Company', sortType: 'companyName' },
+ { label: 'Title', sortType: 'jobTitle' },
+ { label: 'YOE', sortType: 'totalYoe' },
+ { label: 'Monthly Salary', sortType: 'totalCompensation' },
+ { label: 'Date Offered', sortType: 'monthYearReceived' },
+ { label: 'Actions' },
+];
diff --git a/apps/portal/src/components/offers/table/OffersTable.tsx b/apps/portal/src/components/offers/table/OffersTable.tsx
index bd53fc92..e1f0440f 100644
--- a/apps/portal/src/components/offers/table/OffersTable.tsx
+++ b/apps/portal/src/components/offers/table/OffersTable.tsx
@@ -11,10 +11,10 @@ import type {
OfferTableColumn,
OfferTableSortType,
} from '~/components/offers/table/types';
-import { OFFER_TABLE_SORT_ORDER } from '~/components/offers/table/types';
-import { InternOfferTableColumns } from '~/components/offers/table/types';
-import { FullTimeOfferTableColumns } from '~/components/offers/table/types';
import {
+ FullTimeOfferTableColumns,
+ InternOfferTableColumns,
+ OFFER_TABLE_SORT_ORDER,
OfferTableYoeOptions,
YOE_CATEGORY_PARAM,
} from '~/components/offers/table/types';
diff --git a/apps/portal/src/pages/offers/admin_temp.tsx b/apps/portal/src/pages/offers/admin_temp.tsx
new file mode 100644
index 00000000..3e0c2d6e
--- /dev/null
+++ b/apps/portal/src/pages/offers/admin_temp.tsx
@@ -0,0 +1,186 @@
+import crypto from 'crypto';
+import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
+import Head from 'next/head';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useSession } from 'next-auth/react';
+import { useState } from 'react';
+import { MapPinIcon } from '@heroicons/react/24/outline';
+import { Banner } from '@tih/ui';
+
+import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
+import OffersTable from '~/components/offers/admin_temp/OffersTable';
+import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
+import Container from '~/components/shared/Container';
+import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
+import type { JobTitleType } from '~/components/shared/JobTitles';
+import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
+import JobTitlesTypeahead from '~/components/shared/JobTitlesTypeahead';
+
+import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
+
+export const getServerSideProps: GetServerSideProps = async ({ req }) => {
+ return {
+ props: {
+ country: req.cookies.country ?? null,
+ },
+ };
+};
+
+export default function OffersHomePage({
+ country,
+}: InferGetServerSidePropsType) {
+ const router = useRouter();
+ const [countryFilter, setCountryFilter] = useState('');
+ const { event: gaEvent } = useGoogleAnalytics();
+ const [selectedCompanyName, setSelectedCompanyName] =
+ useSearchParamSingle('companyName');
+ const [selectedCompanyId, setSelectedCompanyId] =
+ useSearchParamSingle('companyId');
+
+ const [selectedJobTitleId, setSelectedJobTitleId] =
+ useSearchParamSingle('jobTitleId');
+
+ const { data: session, status } = useSession();
+
+ const authoizedPeople = [
+ '8b4550989cb7fe9ea7649b5538178b8d19aba0f3e5944dbff0b8d0e2ffe3911f',
+ '0544d5d2be7815b5347dd2233c4d08a52120e52ac529f21b1a5c2005db3c42ab',
+ '9934698c65bc72876018350a02910acdb27b7974dc757a320057588b67c5422b',
+ '5cd57c9d1cc00d1010c3548ea3941941c04d18f7cf50766cdec30b12630e69ac',
+ ];
+
+ const isAuthorized = authoizedPeople.includes(
+ crypto
+ .createHash('sha256')
+ .update(session?.user?.email ?? '')
+ .digest('hex'),
+ );
+
+ if (!isAuthorized && status !== 'loading') {
+ router.push('/offers');
+ }
+ return (
+ isAuthorized && (
+ <>
+
+ Admin Home - Tech Offers Repo
+
+
+
+ ⭐ Check if your offer is competitive by submitting it{' '}
+
+ here
+
+ . ⭐
+
+
+
+
+
+ {
+ if (option) {
+ setCountryFilter(option.value);
+ gaEvent({
+ action: `offers.table_filter_country_${option.value}`,
+ category: 'engagement',
+ label: 'Filter by country',
+ });
+ } else {
+ setCountryFilter('');
+ }
+ }}
+ />
+
+
+
+
+
+ Tech Offers Repo (Admin)
+
+
+
+ Find out how good your offer is. Discover how others got their
+ offers.
+
+
+
+
Viewing offers for
+
+ {
+ if (option) {
+ setSelectedJobTitleId(option.id as JobTitleType);
+ gaEvent({
+ action: `offers.table_filter_job_title_${option.value}`,
+ category: 'engagement',
+ label: 'Filter by job title',
+ });
+ } else {
+ setSelectedJobTitleId(null);
+ }
+ }}
+ />
+ in
+ {
+ if (option) {
+ setSelectedCompanyId(option.id);
+ setSelectedCompanyName(option.label);
+ gaEvent({
+ action: `offers.table_filter_company_${option.value}`,
+ category: 'engagement',
+ label: 'Filter by company',
+ });
+ } else {
+ setSelectedCompanyId('');
+ setSelectedCompanyName('');
+ }
+ }}
+ />
+
+
+
+
+
+
+
+ >
+ )
+ );
+}