mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-08 01:57:32 +08:00
[offers][feat] add default filters and more income columns (#495)
* [offers][feat] add yoe query param and display all by default * [offers][feat] add base bonus stocks to table and default homepage * [offers][style] style loading spinner
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
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';
|
||||
@ -9,25 +10,47 @@ import { formatDate } from '~/utils/offers/time';
|
||||
|
||||
import type { DashboardOffer } from '~/types/offers';
|
||||
|
||||
export type OfferTableRowProps = Readonly<{ row: DashboardOffer }>;
|
||||
export type OfferTableRowProps = Readonly<{
|
||||
jobType: JobType;
|
||||
row: DashboardOffer;
|
||||
}>;
|
||||
|
||||
export default function OfferTableRow({
|
||||
row: { company, id, income, monthYearReceived, profileId, title, totalYoe },
|
||||
jobType,
|
||||
row: {
|
||||
baseSalary,
|
||||
bonus,
|
||||
company,
|
||||
id,
|
||||
income,
|
||||
monthYearReceived,
|
||||
profileId,
|
||||
stocks,
|
||||
title,
|
||||
totalYoe,
|
||||
},
|
||||
}: OfferTableRowProps) {
|
||||
return (
|
||||
<tr key={id} className="divide-x divide-slate-200 border-b bg-white">
|
||||
<th className="whitespace-nowrap py-4 px-6 font-medium" scope="row">
|
||||
<th className="whitespace-nowrap py-4 px-4 font-medium" scope="row">
|
||||
{company.name}
|
||||
</th>
|
||||
<td className="py-4 px-6">
|
||||
<td className="py-4 px-4">
|
||||
{getLabelForJobTitleType(title as JobTitleType)}
|
||||
</td>
|
||||
<td className="py-4 px-6">{totalYoe}</td>
|
||||
<td className="py-4 px-6">{convertMoneyToString(income)}</td>
|
||||
<td className="py-4 px-6">{formatDate(monthYearReceived)}</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">
|
||||
{`${baseSalary && convertMoneyToString(baseSalary)} / ${
|
||||
bonus && convertMoneyToString(bonus)
|
||||
} / ${stocks && convertMoneyToString(stocks)}`}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-4 px-4">{formatDate(monthYearReceived)}</td>
|
||||
<td
|
||||
className={clsx(
|
||||
'sticky right-0 bg-white px-6 py-4 drop-shadow lg:drop-shadow-none',
|
||||
'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"
|
||||
|
@ -1,5 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { JobType } from '@prisma/client';
|
||||
import { DropdownMenu, Spinner } from '@tih/ui';
|
||||
|
||||
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
|
||||
@ -9,6 +11,7 @@ import {
|
||||
OfferTableSortBy,
|
||||
OfferTableYoeOptions,
|
||||
YOE_CATEGORY,
|
||||
YOE_CATEGORY_PARAM,
|
||||
} from '~/components/offers/table/types';
|
||||
|
||||
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
|
||||
@ -21,17 +24,18 @@ import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
|
||||
|
||||
const NUMBER_OF_OFFERS_IN_PAGE = 10;
|
||||
export type OffersTableProps = Readonly<{
|
||||
cityFilter: string;
|
||||
companyFilter: string;
|
||||
countryFilter: string;
|
||||
jobTitleFilter: string;
|
||||
}>;
|
||||
export default function OffersTable({
|
||||
cityFilter,
|
||||
countryFilter,
|
||||
companyFilter,
|
||||
jobTitleFilter,
|
||||
}: OffersTableProps) {
|
||||
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
|
||||
const [selectedYoe, setSelectedYoe] = useState(YOE_CATEGORY.ENTRY);
|
||||
const [selectedYoe, setSelectedYoe] = useState('');
|
||||
const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME);
|
||||
const [pagination, setPagination] = useState<Paging>({
|
||||
currentPage: 0,
|
||||
numOfItems: 0,
|
||||
@ -43,6 +47,10 @@ export default function OffersTable({
|
||||
OfferTableFilterOptions[0].value,
|
||||
);
|
||||
const { event: gaEvent } = useGoogleAnalytics();
|
||||
const router = useRouter();
|
||||
const { yoeCategory = '' } = router.query;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination({
|
||||
currentPage: 0,
|
||||
@ -50,20 +58,26 @@ export default function OffersTable({
|
||||
numOfPages: 0,
|
||||
totalItems: 0,
|
||||
});
|
||||
}, [selectedYoe, currency]);
|
||||
const offersQuery = trpc.useQuery(
|
||||
setIsLoading(true);
|
||||
}, [selectedYoe, currency, countryFilter, companyFilter, jobTitleFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedYoe(yoeCategory as YOE_CATEGORY);
|
||||
event?.preventDefault();
|
||||
}, [yoeCategory]);
|
||||
|
||||
trpc.useQuery(
|
||||
[
|
||||
'offers.list',
|
||||
{
|
||||
companyId: companyFilter,
|
||||
// Location: 'Singapore, Singapore', // TODO: Geolocation
|
||||
countryId: cityFilter,
|
||||
countryId: countryFilter,
|
||||
currency,
|
||||
limit: NUMBER_OF_OFFERS_IN_PAGE,
|
||||
offset: pagination.currentPage,
|
||||
sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
|
||||
title: jobTitleFilter,
|
||||
yoeCategory: selectedYoe,
|
||||
yoeCategory: YOE_CATEGORY_PARAM[yoeCategory as string] ?? undefined,
|
||||
},
|
||||
],
|
||||
{
|
||||
@ -73,6 +87,8 @@ export default function OffersTable({
|
||||
onSuccess: (response: GetOffersResponse) => {
|
||||
setOffers(response.data);
|
||||
setPagination(response.paging);
|
||||
setJobType(response.jobType);
|
||||
setIsLoading(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -80,14 +96,43 @@ export default function OffersTable({
|
||||
function renderFilters() {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 text-sm sm:grid-cols-4 md:text-base">
|
||||
<DropdownMenu align="start" label="Filters" size="inherit">
|
||||
<DropdownMenu
|
||||
align="start"
|
||||
label={
|
||||
OfferTableYoeOptions.filter(
|
||||
({ value: itemValue }) => itemValue === selectedYoe,
|
||||
)[0].label
|
||||
}
|
||||
size="inherit">
|
||||
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
isSelected={value === selectedYoe}
|
||||
label={itemLabel}
|
||||
onClick={() => {
|
||||
setSelectedYoe(value);
|
||||
if (value === '') {
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: undefined,
|
||||
},
|
||||
undefined,
|
||||
// Do not refresh the page
|
||||
{ shallow: true },
|
||||
);
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
['yoeCategory']: value,
|
||||
});
|
||||
router.replace(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}
|
||||
gaEvent({
|
||||
action: `offers.table_filter_yoe_category_${value}`,
|
||||
category: 'engagement',
|
||||
@ -98,7 +143,7 @@ export default function OffersTable({
|
||||
))}
|
||||
</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">
|
||||
<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>
|
||||
@ -134,7 +179,7 @@ export default function OffersTable({
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const columns = [
|
||||
let columns = [
|
||||
'Company',
|
||||
'Title',
|
||||
'YOE',
|
||||
@ -142,6 +187,18 @@ export default function OffersTable({
|
||||
'Date Offered',
|
||||
'Actions',
|
||||
];
|
||||
if (jobType === JobType.FULLTIME) {
|
||||
columns = [
|
||||
'Company',
|
||||
'Title',
|
||||
'YOE',
|
||||
'Annual TC',
|
||||
'Annual Base / Bonus / Stocks',
|
||||
'Date Offered',
|
||||
'Actions',
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<thead className="text-slate-700">
|
||||
<tr className="divide-x divide-slate-200">
|
||||
@ -149,7 +206,7 @@ export default function OffersTable({
|
||||
<th
|
||||
key={header}
|
||||
className={clsx(
|
||||
'bg-slate-100 py-3 px-6',
|
||||
'bg-slate-100 py-3 px-4',
|
||||
// Make last column sticky.
|
||||
index === columns.length - 1 &&
|
||||
'sticky right-0 drop-shadow md:drop-shadow-none',
|
||||
@ -172,20 +229,29 @@ export default function OffersTable({
|
||||
return (
|
||||
<div className="relative w-full border border-slate-200">
|
||||
{renderFilters()}
|
||||
{offersQuery.isLoading ? (
|
||||
<div className="col-span-10 pt-4">
|
||||
{isLoading ? (
|
||||
<div className="col-span-10 py-32">
|
||||
<Spinner display="block" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left text-slate-600">
|
||||
<div className="overflow-x-auto text-slate-600">
|
||||
<table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left">
|
||||
{renderHeader()}
|
||||
<tbody>
|
||||
{offers.map((offer) => (
|
||||
<OffersRow key={offer.id} row={offer} />
|
||||
<OffersRow key={offer.id} jobType={jobType} row={offer} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!offers ||
|
||||
(offers.length === 0 && (
|
||||
<div className="py-16 text-lg">
|
||||
<div className="flex justify-center">No data yet🥺</div>
|
||||
<div className="flex justify-center">
|
||||
Please try another set of filters.
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<OffersTablePagination
|
||||
|
@ -1,12 +1,20 @@
|
||||
// eslint-disable-next-line no-shadow
|
||||
export enum YOE_CATEGORY {
|
||||
INTERN = 0,
|
||||
ENTRY = 1,
|
||||
MID = 2,
|
||||
SENIOR = 3,
|
||||
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,
|
||||
|
@ -5,18 +5,16 @@ import { Banner } from '@tih/ui';
|
||||
|
||||
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
|
||||
import OffersTable from '~/components/offers/table/OffersTable';
|
||||
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
|
||||
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 JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
|
||||
|
||||
export default function OffersHomePage() {
|
||||
const [jobTitleFilter, setJobTitleFilter] = useState<JobTitleType | ''>(
|
||||
'software-engineer',
|
||||
);
|
||||
const [jobTitleFilter, setJobTitleFilter] = useState<JobTitleType | ''>('');
|
||||
const [companyFilter, setCompanyFilter] = useState('');
|
||||
const [cityFilter, setCityFilter] = useState('');
|
||||
const [countryFilter, setCountryFilter] = useState('');
|
||||
const { event: gaEvent } = useGoogleAnalytics();
|
||||
|
||||
return (
|
||||
@ -28,21 +26,23 @@ export default function OffersHomePage() {
|
||||
</Link>
|
||||
. ⭐
|
||||
</Banner>
|
||||
<div className="text-primary-600 flex items-center justify-end space-x-1 bg-slate-100 px-4 pt-4">
|
||||
<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>
|
||||
<CitiesTypeahead
|
||||
<CountriesTypeahead
|
||||
isLabelHidden={true}
|
||||
placeholder="All Cities"
|
||||
placeholder="All Countries"
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
setCityFilter(option.value);
|
||||
setCountryFilter(option.value);
|
||||
gaEvent({
|
||||
action: `offers.table_filter_city_${option.value}`,
|
||||
action: `offers.table_filter_country_${option.value}`,
|
||||
category: 'engagement',
|
||||
label: 'Filter by city',
|
||||
label: 'Filter by country',
|
||||
});
|
||||
} else {
|
||||
setCountryFilter('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -64,7 +64,7 @@ export default function OffersHomePage() {
|
||||
<div className="flex items-center space-x-4">
|
||||
<JobTitlesTypeahead
|
||||
isLabelHidden={true}
|
||||
placeholder="Software Engineer"
|
||||
placeholder="All Job Titles"
|
||||
textSize="inherit"
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
@ -102,8 +102,8 @@ export default function OffersHomePage() {
|
||||
</div>
|
||||
<Container className="pb-20 pt-10">
|
||||
<OffersTable
|
||||
cityFilter={cityFilter}
|
||||
companyFilter={companyFilter}
|
||||
countryFilter={countryFilter}
|
||||
jobTitleFilter={jobTitleFilter}
|
||||
/>
|
||||
</Container>
|
||||
|
@ -195,7 +195,7 @@ export default function OfferProfile() {
|
||||
)}
|
||||
{getProfileQuery.isLoading && (
|
||||
<div className="flex h-screen w-screen">
|
||||
<div className="m-auto mx-auto w-screen justify-center">
|
||||
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
|
||||
<Spinner display="block" size="lg" />
|
||||
<div className="text-center">Loading...</div>
|
||||
</div>
|
||||
|
@ -71,7 +71,7 @@ export default function OffersSubmissionResult() {
|
||||
<>
|
||||
{getAnalysis.isLoading && (
|
||||
<div className="flex h-screen w-screen">
|
||||
<div className="m-auto mx-auto w-screen justify-center">
|
||||
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
|
||||
<Spinner display="block" size="lg" />
|
||||
<div className="text-center">Loading...</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user