[offers][fix] add temp admin page (#544)

pull/546/head
Zhang Ziqing 2 years ago committed by GitHub
parent 93d5c4cace
commit 3995d2d2cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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 (
<th
key={header}
className={clsx(
'bg-slate-100 py-3 px-4',
sortType &&
'hover:cursor-pointer hover:bg-slate-200 active:bg-slate-300',
header !== 'Company' && 'whitespace-nowrap',
(sortDirection === OFFER_TABLE_SORT_ORDER.ASC ||
sortDirection === OFFER_TABLE_SORT_ORDER.DESC) &&
'text-primary-600',
// Make last column sticky.
isLastColumn && 'sticky right-0 drop-shadow md:drop-shadow-none',
)}
scope="col"
onClick={
onSort &&
sortType &&
(() => {
onSort(
sortDirection
? getOppositeSortOrder(sortDirection)
: OFFER_TABLE_SORT_ORDER.ASC,
sortType,
);
})
}>
<div className="my-auto flex items-center justify-start">
{header}
{onSort && sortType && (
<span className="ml-2 grid grid-cols-1 space-y-0 text-[9px] text-gray-300">
<div
className={clsx(
'-mb-2 flex items-end sm:-mb-3',
sortDirection === OFFER_TABLE_SORT_ORDER.ASC &&
'text-primary-500',
sortDirection === OFFER_TABLE_SORT_ORDER.DESC &&
'text-slate-200',
)}>
</div>
<div
className={clsx(
'-mb-3 flex items-end',
sortDirection === OFFER_TABLE_SORT_ORDER.DESC &&
'text-primary-500',
sortDirection === OFFER_TABLE_SORT_ORDER.ASC &&
'text-slate-200',
)}>
</div>
</span>
)}
</div>
</th>
);
}

@ -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 (
<tr key={id} className="divide-x divide-slate-200 border-b bg-white">
<td className="space-y-0.5 py-4 px-4" scope="row">
<div className="font-medium">{company.name}</div>
<div className="text-xs text-slate-500">
{location.cityName} ({location.countryCode})
</div>
</td>
<td className="py-4 px-4">
{getLabelForJobTitleType(title as JobTitleType)}
</td>
<td className="py-4 px-4">{totalYoe}</td>
<td className="py-4 px-4">{convertMoneyToString(income)}</td>
{jobType === JobType.FULLTIME && (
<td className="py-4 px-4">
{`${convertMoneyToString(baseSalary)} / ${convertMoneyToString(
bonus,
)} / ${convertMoneyToString(stocks)}`}
</td>
)}
<td className="py-4 px-4">{formatDate(monthYearReceived)}</td>
<td
className={clsx(
'sticky right-0 bg-white px-4 py-4 drop-shadow lg:drop-shadow-none',
)}>
<Link
className="text-primary-600 dark:text-primary-500 font-medium hover:underline"
href={`/offers/profile/${profileId}?token=${token}`}>
View Editable Profile
</Link>
{numberOfOtherOffers > 0 && (
<div className="text-xs text-slate-500">
This person also received {numberOfOtherOffers} other offer(s).
</div>
)}
</td>
</tr>
);
}

@ -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>(JobType.FULLTIME);
const [pagination, setPagination] = useState<Paging>({
currentPage: 0,
numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
const [offers, setOffers] = useState<Array<AdminDashboardOffer>>([]);
const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [
selectedYoeCategory,
setSelectedYoeCategory,
isYoeCategoryInitialized,
] = useSearchParamSingle<keyof typeof YOE_CATEGORY_PARAM>('yoeCategory');
const [
selectedSortDirection,
setSelectedSortDirection,
isSortDirectionInitialized,
] = useSearchParamSingle<OFFER_TABLE_SORT_ORDER>('sortDirection');
const [selectedSortType, setSelectedSortType, isSortTypeInitialized] =
useSearchParamSingle<OfferTableSortType>('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<HTMLDivElement>(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 (
<div className="flex items-center justify-between p-4 text-xs text-slate-700 sm:grid-cols-4 sm:text-sm md:text-base">
<DropdownMenu
align="start"
label={
OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoeCategory,
).length > 0
? OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoeCategory,
)[0].label
: OfferTableYoeOptions[0].label
}
size="inherit">
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item
key={value}
isSelected={value === selectedYoeCategory}
label={itemLabel}
onClick={() => {
setSelectedYoeCategory(value);
gaEvent({
action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement',
label: 'Filter by YOE category',
});
}}
/>
))}
</DropdownMenu>
<div className="divide-x-slate-200 col-span-3 flex items-center justify-end space-x-4 divide-x">
<div className="justify-left flex items-center space-x-2 font-medium text-slate-700">
<span className="sr-only sm:not-sr-only sm:inline">
Display offers in
</span>
<CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency}
/>
</div>
</div>
</div>
);
}
function renderHeader() {
const columns: Array<OfferTableColumn> =
jobType === JobType.FULLTIME
? FullTimeOfferTableColumns
: InternOfferTableColumns;
return (
<thead className="font-semibold">
<tr className="divide-x divide-slate-200">
{columns.map((header, index) => (
<OffersHeader
key={header.label}
header={header.label}
isLastColumn={index === columns.length - 1}
sortDirection={
header.sortType === selectedSortType
? selectedSortDirection
: undefined
}
sortType={header.sortType}
onSort={onSort}
/>
))}
</tr>
</thead>
);
}
const handlePageChange = (currPage: number) => {
if (0 <= currPage && currPage < pagination.numOfPages) {
setPagination({ ...pagination, currentPage: currPage });
}
};
return (
<div className="relative w-full divide-y divide-slate-200 border border-slate-200 bg-white">
<div ref={topRef}>{renderFilters()}</div>
<OffersTablePagination
endNumber={
pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + offers.length
}
handlePageChange={handlePageChange}
isInitialFetch={isLoading}
isLoading={isResultsLoading}
pagination={pagination}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + 1}
/>
<div className="overflow-x-auto text-slate-600">
<table className="w-full divide-y divide-slate-200 text-left text-xs text-slate-700 sm:text-sm">
{renderHeader()}
{!isLoading && (
<tbody className="divide-y divide-slate-200">
{offers.map((offer) => (
<OffersRow key={offer.id} jobType={jobType} row={offer} />
))}
</tbody>
)}
</table>
{isLoading && (
<div className="flex justify-center py-32">
<Spinner display="block" size="lg" />
</div>
)}
{(!isLoading && !offers) ||
(offers.length === 0 && (
<div className="py-16 text-lg">
<div className="flex justify-center">No data yet 🥺</div>
</div>
))}
</div>
<OffersTablePagination
endNumber={
pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + offers.length
}
handlePageChange={(number) => {
topRef?.current?.scrollIntoView({
block: 'start',
});
handlePageChange(number);
}}
isInitialFetch={isLoading}
isLoading={isResultsLoading}
pagination={pagination}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + 1}
/>
</div>
);
}

@ -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 (
<nav aria-label="Offers Pagination" className="py-3 px-4">
<div className="grid grid-cols-1 items-center gap-2 md:grid-cols-2">
<div>
{!isInitialFetch && (
<div className="flex items-center space-x-2">
<div className="text-sm text-slate-500">
Showing
<span className="font-semibold text-slate-900">
{` ${endNumber > 0 ? startNumber : 0} - ${endNumber} `}
</span>
{`of `}
<span className="font-semibold text-slate-900">
{pagination.totalItems}
</span>{' '}
results
</div>
{isLoading && <Spinner size="xs" />}
</div>
)}
</div>
<div className="flex md:justify-end">
<Pagination
current={pagination.currentPage + 1}
end={pagination.numOfPages}
label="Pagination"
pagePadding={screenWidth > 500 ? 2 : 0}
start={1}
onSelect={(currPage) => {
handlePageChange(currPage - 1);
}}
/>
</div>
</div>
</nav>
);
}

@ -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<string, number> = {
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<OfferTableColumn> = [
{ 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<OfferTableColumn> = [
{ label: 'Company', sortType: 'companyName' },
{ label: 'Title', sortType: 'jobTitle' },
{ label: 'YOE', sortType: 'totalYoe' },
{ label: 'Monthly Salary', sortType: 'totalCompensation' },
{ label: 'Date Offered', sortType: 'monthYearReceived' },
{ label: 'Actions' },
];

@ -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';

@ -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<typeof getServerSideProps>) {
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<JobTitleType | null>('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 && (
<>
<Head>
<title>Admin Home - Tech Offers Repo</title>
</Head>
<main className="flex-1 overflow-y-auto">
<Banner size="sm">
Check if your offer is competitive by submitting it{' '}
<Link className="underline" href="/offers/submit">
here
</Link>
.
</Banner>
<div className="text-primary-600 flex items-center justify-end space-x-1 bg-slate-100 px-4 pt-4 sm:text-lg">
<span>
<MapPinIcon className="flex h-7 w-7" />
</span>
<CountriesTypeahead
isLabelHidden={true}
placeholder="All Countries"
onSelect={(option) => {
if (option) {
setCountryFilter(option.value);
gaEvent({
action: `offers.table_filter_country_${option.value}`,
category: 'engagement',
label: 'Filter by country',
});
} else {
setCountryFilter('');
}
}}
/>
</div>
<div className="bg-slate-100 py-16 px-4">
<div>
<div>
<h1 className="text-primary-600 text-center text-4xl font-bold sm:text-5xl">
Tech Offers Repo (Admin)
</h1>
</div>
<div className="mt-4 text-center text-lg text-slate-600 sm:text-2xl">
Find out how good your offer is. Discover how others got their
offers.
</div>
</div>
<div className="mt-6 flex flex-col items-center justify-center space-y-2 text-sm text-slate-700 sm:mt-10 sm:flex-row sm:space-y-0 sm:space-x-4 sm:text-lg">
<span>Viewing offers for</span>
<div className="flex items-center space-x-4">
<JobTitlesTypeahead
isLabelHidden={true}
placeholder="All Job Titles"
textSize="inherit"
value={
selectedJobTitleId
? {
id: selectedJobTitleId,
label: getLabelForJobTitleType(
selectedJobTitleId as JobTitleType,
),
value: selectedJobTitleId,
}
: null
}
onSelect={(option) => {
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);
}
}}
/>
<span>in</span>
<CompaniesTypeahead
isLabelHidden={true}
placeholder="All Companies"
textSize="inherit"
value={
selectedCompanyName
? {
id: selectedCompanyId,
label: selectedCompanyName,
value: selectedCompanyId,
}
: null
}
onSelect={(option) => {
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('');
}
}}
/>
</div>
</div>
</div>
<Container className="pb-20 pt-10">
<OffersTable
companyFilter={selectedCompanyId}
companyName={selectedCompanyName}
country={country}
countryFilter={countryFilter}
jobTitleFilter={selectedJobTitleId ?? ''}
/>
</Container>
</main>
</>
)
);
}
Loading…
Cancel
Save