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 && ( +
+
No data yet 🥺
+
+ ))} +
+ { + 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(''); + } + }} + /> +
+
+
+ + + +
+ + ) + ); +}