[offers][feat] Integrate offers analysis into offers submission (#398)

* [offers][fix] Fix minor issues in form

* [offers][fix] Use companies typeahead in form

* [offers][feat] Fix types and integrate offers analysis

* [offers][fix] Fix generate analysis API test
pull/395/head
Ai Ling 2 years ago committed by GitHub
parent 4fa350964f
commit 992d457b8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,27 @@
import type { Analysis } from '~/types/offers';
type OfferPercentileAnalysisProps = Readonly<{
companyName: string;
offerAnalysis: Analysis;
tab: string;
}>;
export default function OfferPercentileAnalysis({
tab,
companyName,
offerAnalysis: { noOfOffers, percentile },
}: OfferPercentileAnalysisProps) {
return tab === 'Overall' ? (
<p>
Your highest offer is from {companyName}, which is {percentile} percentile
out of {noOfOffers} offers received for the same job type, same level, and
same YOE(+/-1) in the last year.
</p>
) : (
<p>
Your offer from {companyName} is {percentile} percentile out of{' '}
{noOfOffers} offers received in {companyName} for the same job type, same
level, and same YOE(+/-1) in the last year.
</p>
);
}

@ -0,0 +1,61 @@
import { UserCircleIcon } from '@heroicons/react/24/outline';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { formatDate } from '~/utils/offers/time';
import { JobType } from '../types';
import type { AnalysisOffer } from '~/types/offers';
type OfferProfileCardProps = Readonly<{
offerProfile: AnalysisOffer;
}>;
export default function OfferProfileCard({
offerProfile: {
company,
income,
profileName,
totalYoe,
level,
monthYearReceived,
jobType,
location,
title,
previousCompanies,
},
}: OfferProfileCardProps) {
return (
<div className="my-5 block rounded-lg border p-4">
<div className="grid grid-flow-col grid-cols-12 gap-x-10">
<div className="col-span-1">
<UserCircleIcon width={50} />
</div>
<div className="col-span-10">
<p className="text-sm font-semibold">{profileName}</p>
<p className="text-xs ">Previous company: {previousCompanies[0]}</p>
<p className="text-xs ">YOE: {totalYoe} year(s)</p>
</div>
</div>
<HorizontalDivider />
<div className="grid grid-flow-col grid-cols-2 gap-x-10">
<div className="col-span-1 row-span-3">
<p className="text-sm font-semibold">{title}</p>
<p className="text-xs ">
Company: {company.name}, {location}
</p>
<p className="text-xs ">Level: {level}</p>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end text-sm">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl">
{jobType === JobType.FullTime
? `$${income} / year`
: `$${income} / month`}
</p>
</div>
</div>
</div>
);
}

@ -22,29 +22,6 @@ export const titleOptions = [
},
];
export const companyOptions = [
{
label: 'Amazon',
value: 'cl93patjt0000txewdi601mub',
},
{
label: 'Microsoft',
value: 'cl93patjt0001txewkglfjsro',
},
{
label: 'Apple',
value: 'cl93patjt0002txewf3ug54m8',
},
{
label: 'Google',
value: 'cl93patjt0003txewyiaky7xx',
},
{
label: 'Meta',
value: 'cl93patjt0004txew88wkcqpu',
},
];
export const locationOptions = [
{
label: 'Singapore, Singapore',
@ -86,26 +63,26 @@ export const internshipCycleOptions = [
export const yearOptions = [
{
label: '2021',
value: '2021',
value: 2021,
},
{
label: '2022',
value: '2022',
value: 2022,
},
{
label: '2023',
value: '2023',
value: 2023,
},
{
label: '2024',
value: '2024',
value: 2024,
},
];
export const educationLevelOptions = Object.entries(
EducationBackgroundType,
).map(([key, value]) => ({
label: key,
).map(([, value]) => ({
label: value,
value,
}));
@ -129,3 +106,5 @@ export enum FieldError {
Number = 'Please fill in a number in this field.',
Required = 'Please fill in this field.',
}
export const OVERALL_TAB = 'Overall';

@ -4,7 +4,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
import { getCurrentMonth, getCurrentYear } from '../../../../utils/offers/time';
import { getCurrentMonth, getCurrentYear } from '../../../utils/offers/time';
type MonthYearPickerProps = ComponentProps<typeof MonthYearPicker>;

@ -1,100 +0,0 @@
import { useState } from 'react';
import { UserCircleIcon } from '@heroicons/react/20/solid';
import { HorizontalDivider, Tabs } from '@tih/ui';
const tabs = [
{
label: 'Overall',
value: 'overall',
},
{
label: 'Shopee',
value: 'company-id',
},
];
function OfferPercentileAnalysis() {
const result = {
company: 'Shopee',
numberOfOffers: 105,
percentile: 56,
};
return (
<p>
Your highest offer is from {result.company}, which is {result.percentile}{' '}
percentile out of {result.numberOfOffers} offers received in Singapore for
the same job type, same level, and same YOE in the last year.
</p>
);
}
function OfferProfileCard() {
return (
<div className="my-5 block rounded-lg border p-4">
<div className="grid grid-flow-col grid-cols-12 gap-x-10">
<div className="col-span-1">
<UserCircleIcon width={50} />
</div>
<div className="col-span-10">
<p className="text-sm font-semibold">profile-name</p>
<p className="text-xs ">Previous company: Meta, Singapore</p>
<p className="text-xs ">YOE: 4 years</p>
</div>
</div>
<HorizontalDivider />
<div className="grid grid-flow-col grid-cols-2 gap-x-10">
<div className="col-span-1 row-span-3">
<p className="text-sm font-semibold">Software engineer</p>
<p className="text-xs ">Company: Google, Singapore</p>
<p className="text-xs ">Level: G4</p>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end text-sm">Sept 2022</p>
<p className="text-end text-xl">$125,000 / year</p>
</div>
</div>
</div>
);
}
function TopOfferProfileList() {
return (
<>
<OfferProfileCard />
<OfferProfileCard />
</>
);
}
function OfferAnalysisContent() {
return (
<>
<OfferPercentileAnalysis />
<TopOfferProfileList />
</>
);
}
export default function OfferAnalysis() {
const [tab, setTab] = useState('Overall');
return (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
<div>
<Tabs
label="Result Navigation"
tabs={tabs}
value={tab}
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent />
</div>
</div>
);
}

@ -2,21 +2,28 @@ import { useFormContext, useWatch } from 'react-hook-form';
import { Collapsible, RadioList } from '@tih/ui';
import {
companyOptions,
educationFieldOptions,
educationLevelOptions,
emptyOption,
FieldError,
locationOptions,
titleOptions,
} from '~/components/offers/constants';
import FormRadioList from '~/components/offers/forms/components/FormRadioList';
import FormSelect from '~/components/offers/forms/components/FormSelect';
import FormTextInput from '~/components/offers/forms/components/FormTextInput';
import type { BackgroundPostData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
import FormRadioList from '../forms/FormRadioList';
import FormSelect from '../forms/FormSelect';
import FormTextInput from '../forms/FormTextInput';
function YoeSection() {
const { register } = useFormContext();
const { register, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const backgroundFields = formState.errors.background;
return (
<>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400">
@ -26,21 +33,26 @@ function YoeSection() {
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-2 grid grid-cols-3 space-x-3">
<FormTextInput
errorMessage={backgroundFields?.totalYoe?.message}
label="Total YOE"
placeholder="0"
required={true}
type="number"
{...register(`background.totalYoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
/>
</div>
<div className="grid grid-cols-1 space-x-3">
<Collapsible label="Add specific YOEs by domain">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
errorMessage={backgroundFields?.specificYoes?.[0]?.yoe?.message}
label="Specific YOE 1"
type="number"
{...register(`background.specificYoes.0.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
@ -52,9 +64,11 @@ function YoeSection() {
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
errorMessage={backgroundFields?.specificYoes?.[1]?.yoe?.message}
label="Specific YOE 2"
type="number"
{...register(`background.specificYoes.1.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
@ -66,13 +80,15 @@ function YoeSection() {
</div>
</Collapsible>
</div>
</div>
</>
);
}
function FullTimeJobFields() {
const { register } = useFormContext();
const { register, setValue, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -80,15 +96,17 @@ function FullTimeJobFields() {
display="block"
label="Title"
options={titleOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.title`)}
/>
<FormSelect
display="block"
label="Company"
options={companyOptions}
{...register(`background.experiences.0.companyId`)}
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.companyId`, value)
}
/>
</div>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
<FormTextInput
endAddOn={
@ -103,12 +121,14 @@ function FullTimeJobFields() {
/>
}
endAddOnType="element"
errorMessage={experiencesField?.totalCompensation?.value?.message}
label="Total Compensation (Annual)"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experiences.0.totalCompensation.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
@ -134,9 +154,11 @@ function FullTimeJobFields() {
{...register(`background.experiences.0.location`)}
/>
<FormTextInput
errorMessage={experiencesField?.durationInMonths?.message}
label="Duration (months)"
type="number"
{...register(`background.experiences.0.durationInMonths`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
@ -147,7 +169,11 @@ function FullTimeJobFields() {
}
function InternshipJobFields() {
const { register } = useFormContext();
const { register, setValue, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -155,15 +181,17 @@ function InternshipJobFields() {
display="block"
label="Title"
options={titleOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.title`)}
/>
<FormSelect
display="block"
label="Company"
options={companyOptions}
{...register(`background.experiences.0.company`)}
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.companyId`, value)
}
/>
</div>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
<FormTextInput
endAddOn={
@ -176,12 +204,16 @@ function InternshipJobFields() {
/>
}
endAddOnType="element"
errorMessage={experiencesField?.monthlySalary?.value?.message}
label="Salary (Monthly)"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experiences.0.monthlySalary.value`)}
{...register(`background.experiences.0.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
</div>
<Collapsible label="Add more details">
@ -195,6 +227,7 @@ function InternshipJobFields() {
display="block"
label="Location"
options={locationOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.location`)}
/>
</div>
@ -231,7 +264,7 @@ function CurrentJobSection() {
<RadioList.Item
key="Internship"
label="Internship"
value={JobType.Internship}
value={JobType.Intern}
/>
</FormRadioList>
</div>
@ -258,12 +291,14 @@ function EducationSection() {
display="block"
label="Education Level"
options={educationLevelOptions}
placeholder={emptyOption}
{...register(`background.educations.0.type`)}
/>
<FormSelect
display="block"
label="Field"
options={educationFieldOptions}
placeholder={emptyOption}
{...register(`background.educations.0.field`)}
/>
</div>
@ -287,9 +322,9 @@ export default function BackgroundForm() {
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Help us better gauge your offers
</h5>
<h6 className="mx-10 mb-8 text-center text-lg font-light text-gray-600">
This section is optional, but your background information helps us
benchmark your offers.
<h6 className="text-md mx-10 mb-8 text-center font-light text-gray-600">
This section is mostly optional, but your background information helps
us benchmark your offers.
</h6>
<div>
<YoeSection />

@ -0,0 +1,138 @@
import Error from 'next/error';
import { useEffect } from 'react';
import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import OfferPercentileAnalysis from '../analysis/OfferPercentileAnalysis';
import OfferProfileCard from '../analysis/OfferProfileCard';
import { OVERALL_TAB } from '../constants';
import type {
Analysis,
AnalysisHighestOffer,
ProfileAnalysis,
} from '~/types/offers';
type OfferAnalysisData = {
offer?: AnalysisHighestOffer;
offerAnalysis?: Analysis;
};
type OfferAnalysisContentProps = Readonly<{
analysis: OfferAnalysisData;
tab: string;
}>;
function OfferAnalysisContent({
analysis: { offer, offerAnalysis },
tab,
}: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) {
return (
<p className="m-10">
You are the first to submit an offer for these companies! Check back
later when there are more submissions.
</p>
);
}
return (
<>
<OfferPercentileAnalysis
companyName={offer.company.name}
offerAnalysis={offerAnalysis}
tab={tab}
/>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard
key={topPercentileOffer.id}
offerProfile={topPercentileOffer}
/>
))}
</>
);
}
type OfferAnalysisProps = Readonly<{
profileId?: string;
}>;
export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB);
const [allAnalysis, setAllAnalysis] = useState<ProfileAnalysis | null>(null);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null);
useEffect(() => {
if (tab === OVERALL_TAB) {
setAnalysis({
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.overallAnalysis,
});
} else {
setAnalysis({
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.companyAnalysis[0],
});
}
}, [tab, allAnalysis]);
if (!profileId) {
return null;
}
const getAnalysisResult = trpc.useQuery(
['offers.analysis.get', { profileId }],
{
onError(error) {
console.error(error.message);
},
onSuccess(data) {
setAllAnalysis(data);
},
},
);
const tabOptions = [
{
label: OVERALL_TAB,
value: OVERALL_TAB,
},
{
label: allAnalysis?.overallHighestOffer.company.name || '',
value: allAnalysis?.overallHighestOffer.company.id || '',
},
];
return (
<>
{getAnalysisResult.isError && (
<Error
statusCode={404}
title="An error occurred while generating profile analysis."
/>
)}
{!getAnalysisResult.isError && analysis && (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
{getAnalysisResult.isLoading ? (
<Spinner className="m-10" display="block" size="lg" />
) : (
<div>
<Tabs
label="Result Navigation"
tabs={tabOptions}
value={tab}
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent analysis={analysis} tab={tab} />
</div>
)}
</div>
)}
</>
);
}

@ -1,5 +1,9 @@
import { useEffect, useState } from 'react';
import type { FieldValues, UseFieldArrayReturn } from 'react-hook-form';
import type {
FieldValues,
UseFieldArrayRemove,
UseFieldArrayReturn,
} from 'react-hook-form';
import { useWatch } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
@ -7,17 +11,14 @@ import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline';
import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import {
defaultFullTimeOfferValues,
defaultInternshipOfferValues,
} from '~/pages/offers/submit';
import FormMonthYearPicker from './components/FormMonthYearPicker';
import FormSelect from './components/FormSelect';
import FormTextArea from './components/FormTextArea';
import FormTextInput from './components/FormTextInput';
import {
companyOptions,
emptyOption,
FieldError,
internshipCycleOptions,
@ -25,36 +26,40 @@ import {
titleOptions,
yearOptions,
} from '../constants';
import type {
FullTimeOfferDetailsFormData,
InternshipOfferDetailsFormData,
} from '../types';
import FormMonthYearPicker from '../forms/FormMonthYearPicker';
import FormSelect from '../forms/FormSelect';
import FormTextArea from '../forms/FormTextArea';
import FormTextInput from '../forms/FormTextInput';
import type { OfferFormData } from '../types';
import { JobTypeLabel } from '../types';
import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{
index: number;
setDialogOpen: (isOpen: boolean) => void;
remove: UseFieldArrayRemove;
}>;
function FullTimeOfferDetailsForm({
index,
setDialogOpen,
remove,
}: FullTimeOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{
offers: Array<FullTimeOfferDetailsFormData>;
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
const watchCurrency = useWatch({
name: `offers.${index}.job.totalCompensation.currency`,
name: `offers.${index}.offersFullTime.totalCompensation.currency`,
});
useEffect(() => {
setValue(`offers.${index}.job.base.currency`, watchCurrency);
setValue(`offers.${index}.job.bonus.currency`, watchCurrency);
setValue(`offers.${index}.job.stocks.currency`, watchCurrency);
setValue(
`offers.${index}.offersFullTime.baseSalary.currency`,
watchCurrency,
);
setValue(`offers.${index}.offersFullTime.bonus.currency`, watchCurrency);
setValue(`offers.${index}.offersFullTime.stocks.currency`, watchCurrency);
}, [watchCurrency, index, setValue]);
return (
@ -62,48 +67,44 @@ function FullTimeOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
errorMessage={offerFields?.offersFullTime?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
{...register(`offers.${index}.offersFullTime.title`, {
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
errorMessage={offerFields?.offersFullTime?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
{...register(`offers.${index}.offersFullTime.specialization`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
<div className="mb-5 flex grid grid-cols-2 space-x-3">
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
}
/>
</div>
<FormTextInput
errorMessage={offerFields?.job?.level?.message}
errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.job.level`, {
{...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
@ -132,24 +133,32 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.totalCompensation.currency`, {
{...register(
`offers.${index}.offersFullTime.totalCompensation.currency`,
{
required: FieldError.Required,
})}
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.totalCompensation?.value?.message}
errorMessage={
offerFields?.offersFullTime?.totalCompensation?.value?.message
}
label="Total Compensation (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.totalCompensation.value`, {
{...register(
`offers.${index}.offersFullTime.totalCompensation.value`,
{
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
},
)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -160,20 +169,23 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.base.currency`, {
{...register(
`offers.${index}.offersFullTime.baseSalary.currency`,
{
required: FieldError.Required,
})}
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.base?.value?.message}
errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message}
label="Base Salary (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.base.value`, {
{...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
@ -186,20 +198,20 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.bonus.currency`, {
{...register(`offers.${index}.offersFullTime.bonus.currency`, {
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.bonus?.value?.message}
errorMessage={offerFields?.offersFullTime?.bonus?.value?.message}
label="Bonus (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.bonus.value`, {
{...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
@ -214,20 +226,20 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.stocks.currency`, {
{...register(`offers.${index}.offersFullTime.stocks.currency`, {
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.stocks?.value?.message}
errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
label="Stocks (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.stocks.value`, {
{...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
@ -254,7 +266,7 @@ function FullTimeOfferDetailsForm({
icon={TrashIcon}
label="Delete"
variant="secondary"
onClick={() => setDialogOpen(true)}
onClick={() => remove(index)}
/>
)}
</div>
@ -264,15 +276,15 @@ function FullTimeOfferDetailsForm({
type InternshipOfferDetailsFormProps = Readonly<{
index: number;
setDialogOpen: (isOpen: boolean) => void;
remove: UseFieldArrayRemove;
}>;
function InternshipOfferDetailsForm({
index,
setDialogOpen,
remove,
}: InternshipOfferDetailsFormProps) {
const { register, formState } = useFormContext<{
offers: Array<InternshipOfferDetailsFormData>;
const { register, formState, setValue } = useFormContext<{
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
@ -282,39 +294,35 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
errorMessage={offerFields?.offersIntern?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
{...register(`offers.${index}.offersIntern.title`, {
minLength: 1,
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
errorMessage={offerFields?.offersIntern?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
{...register(`offers.${index}.offersIntern.specialization`, {
minLength: 1,
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
}
/>
</div>
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
@ -330,24 +338,25 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.internshipCycle?.message}
errorMessage={offerFields?.offersIntern?.internshipCycle?.message}
label="Internship Cycle"
options={internshipCycleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.internshipCycle`, {
{...register(`offers.${index}.offersIntern.internshipCycle`, {
required: FieldError.Required,
})}
/>
<FormSelect
display="block"
errorMessage={offerFields?.job?.startYear?.message}
errorMessage={offerFields?.offersIntern?.startYear?.message}
label="Internship Year"
options={yearOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.startYear`, {
{...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.Required,
valueAsNumber: true,
})}
/>
</div>
@ -369,20 +378,25 @@ function InternshipOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.monthlySalary.currency`, {
{...register(
`offers.${index}.offersIntern.monthlySalary.currency`,
{
required: FieldError.Required,
})}
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.monthlySalary?.value?.message}
errorMessage={
offerFields?.offersIntern?.monthlySalary?.value?.message
}
label="Salary (Monthly)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.monthlySalary.value`, {
{...register(`offers.${index}.offersIntern.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
@ -410,7 +424,7 @@ function InternshipOfferDetailsForm({
label="Delete"
variant="secondary"
onClick={() => {
setDialogOpen(true);
remove(index);
}}
/>
)}
@ -429,7 +443,6 @@ function OfferDetailsFormArray({
jobType,
}: OfferDetailsFormArrayProps) {
const { append, remove, fields } = fieldArrayValues;
const [isDialogOpen, setDialogOpen] = useState(false);
return (
<div>
@ -437,44 +450,10 @@ function OfferDetailsFormArray({
return (
<div key={item.id}>
{jobType === JobType.FullTime ? (
<FullTimeOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
<FullTimeOfferDetailsForm index={index} remove={remove} />
) : (
<InternshipOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
<InternshipOfferDetailsForm index={index} remove={remove} />
)}
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="OK"
variant="primary"
onClick={() => {
remove(index);
setDialogOpen(false);
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setDialogOpen(false)}
/>
}
title="Remove this offer"
onClose={() => setDialogOpen(false)}>
<p>
Are you sure you want to remove this offer? This action cannot
be reversed.
</p>
</Dialog>
</div>
);
})}
@ -501,22 +480,21 @@ export default function OfferDetailsForm() {
const [isDialogOpen, setDialogOpen] = useState(false);
const { control } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' });
const { append, remove } = fieldArrayValues;
const toggleJobType = () => {
fieldArrayValues.remove();
remove();
if (jobType === JobType.FullTime) {
setJobType(JobType.Internship);
fieldArrayValues.append(defaultInternshipOfferValues);
setJobType(JobType.Intern);
append(defaultInternshipOfferValues);
} else {
setJobType(JobType.FullTime);
fieldArrayValues.append(defaultFullTimeOfferValues);
append(defaultFullTimeOfferValues);
}
};
const switchJobTypeLabel = () =>
jobType === JobType.FullTime
? JobTypeLabel.INTERNSHIP
: JobTypeLabel.FULLTIME;
jobType === JobType.FullTime ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
return (
<div className="mb-5">
@ -541,11 +519,11 @@ export default function OfferDetailsForm() {
<div className="mx-5 w-1/3">
<Button
display="block"
label={JobTypeLabel.INTERNSHIP}
label={JobTypeLabel.INTERN}
size="md"
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'}
variant={jobType === JobType.Intern ? 'secondary' : 'tertiary'}
onClick={() => {
if (jobType === JobType.Internship) {
if (jobType === JobType.Intern) {
return;
}
setDialogOpen(true);

@ -52,7 +52,8 @@ export default function OfferProfileSave() {
<p className="mb-5 text-gray-900">
If you do not want to keep the edit link, you can opt to save this
profile under your user accont. It will still only be editable by you.
profile under your user account. It will still only be editable by
you.
</p>
<div className="mb-20">
<Button

@ -6,12 +6,12 @@ import type { MonthYear } from '~/components/shared/MonthYearPicker';
export enum JobType {
FullTime = 'FULLTIME',
Internship = 'INTERNSHIP',
Intern = 'INTERN',
}
export const JobTypeLabel = {
FULLTIME: 'Full-time',
INTERNSHIP: 'Internship',
INTERN: 'Internship',
};
export enum EducationBackgroundType {
@ -20,110 +20,91 @@ export enum EducationBackgroundType {
Masters = 'Masters',
PhD = 'PhD',
Professional = 'Professional',
Seconday = 'Secondary',
Secondary = 'Secondary',
SelfTaught = 'Self-taught',
}
export type Money = {
currency: string;
value: number;
};
type FullTimeJobData = {
base: Money;
bonus: Money;
level: string;
specialization: string;
stocks: Money;
title: string;
totalCompensation: Money;
};
type InternshipJobData = {
internshipCycle: string;
monthlySalary: Money;
specialization: string;
startYear: number;
title: string;
export type OffersProfilePostData = {
background: BackgroundPostData;
offers: Array<OfferPostData>;
};
type OfferDetailsGeneralData = {
comments: string;
companyId: string;
jobType: string;
location: string;
monthYearReceived: MonthYear;
negotiationStrategy: string;
export type OffersProfileFormData = {
background: BackgroundPostData;
offers: Array<OfferFormData>;
};
export type FullTimeOfferDetailsFormData = OfferDetailsGeneralData & {
job: FullTimeJobData;
export type BackgroundPostData = {
educations: Array<EducationPostData>;
experiences: Array<ExperiencePostData>;
specificYoes: Array<SpecificYoePostData>;
totalYoe: number;
};
export type InternshipOfferDetailsFormData = OfferDetailsGeneralData & {
job: InternshipJobData;
type ExperiencePostData = {
companyId?: string | null;
durationInMonths?: number | null;
jobType?: string | null;
level?: string | null;
location?: string | null;
monthlySalary?: Money | null;
specialization?: string | null;
title?: string | null;
totalCompensation?: Money | null;
totalCompensationId?: string | null;
};
export type OfferDetailsFormData =
| FullTimeOfferDetailsFormData
| InternshipOfferDetailsFormData;
export type OfferDetailsPostData = Omit<
OfferDetailsFormData,
'monthYearReceived'
> & {
monthYearReceived: Date;
type EducationPostData = {
endDate?: Date | null;
field?: string | null;
school?: string | null;
startDate?: Date | null;
type?: string | null;
};
type SpecificYoe = {
type SpecificYoePostData = {
domain: string;
yoe: number;
};
type FullTimeExperience = {
level?: string;
totalCompensation?: Money;
};
type InternshipExperience = {
monthlySalary?: Money;
};
type SpecificYoe = SpecificYoePostData;
type GeneralExperience = {
companyId?: string;
durationInMonths?: number;
jobType?: string;
specialization?: string;
title?: string;
export type OfferPostData = {
comments: string;
companyId: string;
jobType: string;
location: string;
monthYearReceived: Date;
negotiationStrategy: string;
offersFullTime?: OfferFullTimePostData | null;
offersIntern?: OfferInternPostData | null;
};
export type Experience =
| (FullTimeExperience & GeneralExperience)
| (GeneralExperience & InternshipExperience);
type Education = {
endDate?: Date;
field?: string;
school?: string;
startDate?: Date;
type?: string;
export type OfferFormData = Omit<OfferPostData, 'monthYearReceived'> & {
monthYearReceived: MonthYear;
};
type BackgroundFormData = {
educations: Array<Education>;
experiences: Array<Experience>;
specificYoes: Array<SpecificYoe>;
totalYoe?: number;
export type OfferFullTimePostData = {
baseSalary: Money;
bonus: Money;
level: string;
specialization: string;
stocks: Money;
title: string;
totalCompensation: Money;
};
export type OfferProfileFormData = {
background: BackgroundFormData;
offers: Array<OfferDetailsFormData>;
export type OfferInternPostData = {
internshipCycle: string;
monthlySalary: Money;
specialization: string;
startYear: number;
title: string;
};
export type OfferProfilePostData = {
background: BackgroundFormData;
offers: Array<OfferDetailsPostData>;
export type Money = {
currency: string;
value: number;
};
type EducationDisplay = {

@ -5,13 +5,13 @@ import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import BackgroundForm from '~/components/offers/forms/BackgroundForm';
import OfferAnalysis from '~/components/offers/forms/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/forms/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/forms/OfferProfileSave';
import BackgroundForm from '~/components/offers/offers-submission/BackgroundForm';
import OfferAnalysis from '~/components/offers/offers-submission/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/offers-submission/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/offers-submission/OfferProfileSave';
import type {
OfferDetailsFormData,
OfferProfileFormData,
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
@ -20,10 +20,11 @@ import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { CreateOfferProfileResponse } from '~/types/offers';
const defaultOfferValues = {
comments: '',
companyId: '',
job: {},
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
@ -40,7 +41,7 @@ export const defaultFullTimeOfferValues = {
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Internship,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
@ -61,10 +62,13 @@ type FormStep = {
export default function OffersSubmissionPage() {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>();
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OfferProfileFormData>({
const formMethods = useForm<OffersProfileFormData>({
defaultValues: defaultOfferProfileValues,
mode: 'all',
});
@ -84,7 +88,9 @@ export default function OffersSubmissionPage() {
label: 'Background',
},
{
component: <OfferAnalysis key={2} />,
component: (
<OfferAnalysis key={2} profileId={createProfileResponse?.id} />
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
@ -115,18 +121,30 @@ export default function OffersSubmissionPage() {
scrollToTop();
};
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const createMutation = trpc.useMutation(['offers.profile.create'], {
onError(error) {
console.error(error.message);
},
onSuccess() {
alert('offer profile submit success!');
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OfferProfileFormData> = async (data) => {
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
@ -142,7 +160,7 @@ export default function OffersSubmissionPage() {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferDetailsFormData) => ({
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,

@ -3,12 +3,15 @@ import React from 'react';
import { trpc } from '~/utils/trpc';
function GenerateAnalysis() {
const analysis = trpc.useQuery([
'offers.analysis.generate',
{ profileId: 'cl98ywtbv0000tx1s4p18eol1' },
]);
const analysisMutation = trpc.useMutation(['offers.analysis.generate']);
return <div>{JSON.stringify(analysis.data)}</div>;
return (
<div>
{JSON.stringify(
analysisMutation.mutate({ profileId: 'cl98ywtbv0000tx1s4p18eol1' }),
)}
</div>
);
}
export default GenerateAnalysis;

@ -51,7 +51,106 @@ const searchOfferPercentile = (
};
export const offersAnalysisRouter = createRouter()
.query('generate', {
.query('get', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const analysis = await ctx.prisma.offersAnalysis.findFirst({
include: {
overallHighestOffer: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
topCompanyOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
topOverallOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
where: {
profileId: input.profileId,
},
});
if (!analysis) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No analysis found on this profile',
});
}
return profileAnalysisDtoMapper(analysis);
},
})
.mutation('generate', {
input: z.object({
profileId: z.string(),
}),
@ -366,105 +465,6 @@ export const offersAnalysisRouter = createRouter()
},
});
return profileAnalysisDtoMapper(analysis);
},
})
.query('get', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const analysis = await ctx.prisma.offersAnalysis.findFirst({
include: {
overallHighestOffer: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
topCompanyOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
topOverallOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
where: {
profileId: input.profileId,
},
});
if (!analysis) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No analysis found on this profile',
});
}
return profileAnalysisDtoMapper(analysis);
},
});

Loading…
Cancel
Save