Fix types and integrate offers analysis

pull/398/head
Ai Ling 3 years ago
parent 80075326b9
commit 64d7053237

@ -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>
);
}

@ -63,19 +63,19 @@ export const internshipCycleOptions = [
export const yearOptions = [ export const yearOptions = [
{ {
label: '2021', label: '2021',
value: '2021', value: 2021,
}, },
{ {
label: '2022', label: '2022',
value: '2022', value: 2022,
}, },
{ {
label: '2023', label: '2023',
value: '2023', value: 2023,
}, },
{ {
label: '2024', label: '2024',
value: '2024', value: 2024,
}, },
]; ];
@ -106,3 +106,5 @@ export enum FieldError {
Number = 'Please fill in a number in this field.', Number = 'Please fill in a number in this field.',
Required = 'Please fill 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 MonthYearPicker from '~/components/shared/MonthYearPicker';
import { getCurrentMonth, getCurrentYear } from '../../../../utils/offers/time'; import { getCurrentMonth, getCurrentYear } from '../../../utils/offers/time';
type MonthYearPickerProps = ComponentProps<typeof MonthYearPicker>; 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>
);
}

@ -9,16 +9,21 @@ import {
locationOptions, locationOptions,
titleOptions, titleOptions,
} from '~/components/offers/constants'; } from '~/components/offers/constants';
import FormRadioList from '~/components/offers/forms/components/FormRadioList'; import type { BackgroundPostData } from '~/components/offers/types';
import FormSelect from '~/components/offers/forms/components/FormSelect';
import FormTextInput from '~/components/offers/forms/components/FormTextInput';
import { JobType } from '~/components/offers/types'; import { JobType } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; 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() { function YoeSection() {
const { register } = useFormContext(); const { register, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const backgroundFields = formState.errors.background;
return ( return (
<> <>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400"> <h6 className="mb-2 text-left text-xl font-medium text-gray-400">
@ -28,11 +33,13 @@ function YoeSection() {
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5"> <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"> <div className="mb-2 grid grid-cols-3 space-x-3">
<FormTextInput <FormTextInput
errorMessage={backgroundFields?.totalYoe?.message}
label="Total YOE" label="Total YOE"
placeholder="0" placeholder="0"
required={true} required={true}
type="number" type="number"
{...register(`background.totalYoe`, { {...register(`background.totalYoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
})} })}
@ -41,9 +48,11 @@ function YoeSection() {
<Collapsible label="Add specific YOEs by domain"> <Collapsible label="Add specific YOEs by domain">
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput <FormTextInput
errorMessage={backgroundFields?.specificYoes?.[0]?.yoe?.message}
label="Specific YOE 1" label="Specific YOE 1"
type="number" type="number"
{...register(`background.specificYoes.0.yoe`, { {...register(`background.specificYoes.0.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -55,9 +64,11 @@ function YoeSection() {
</div> </div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput <FormTextInput
errorMessage={backgroundFields?.specificYoes?.[1]?.yoe?.message}
label="Specific YOE 2" label="Specific YOE 2"
type="number" type="number"
{...register(`background.specificYoes.1.yoe`, { {...register(`background.specificYoes.1.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -74,7 +85,10 @@ function YoeSection() {
} }
function FullTimeJobFields() { function FullTimeJobFields() {
const { register, setValue } = useFormContext(); const { register, setValue, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
return ( return (
<> <>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
@ -107,12 +121,14 @@ function FullTimeJobFields() {
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={experiencesField?.totalCompensation?.value?.message}
label="Total Compensation (Annual)" label="Total Compensation (Annual)"
placeholder="0.00" placeholder="0.00"
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`background.experiences.0.totalCompensation.value`, { {...register(`background.experiences.0.totalCompensation.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -138,9 +154,11 @@ function FullTimeJobFields() {
{...register(`background.experiences.0.location`)} {...register(`background.experiences.0.location`)}
/> />
<FormTextInput <FormTextInput
errorMessage={experiencesField?.durationInMonths?.message}
label="Duration (months)" label="Duration (months)"
type="number" type="number"
{...register(`background.experiences.0.durationInMonths`, { {...register(`background.experiences.0.durationInMonths`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -151,7 +169,11 @@ function FullTimeJobFields() {
} }
function InternshipJobFields() { function InternshipJobFields() {
const { register, setValue } = useFormContext(); const { register, setValue, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
return ( return (
<> <>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
@ -182,12 +204,16 @@ function InternshipJobFields() {
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={experiencesField?.monthlySalary?.value?.message}
label="Salary (Monthly)" label="Salary (Monthly)"
placeholder="0.00" placeholder="0.00"
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`background.experiences.0.monthlySalary.value`)} {...register(`background.experiences.0.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/> />
</div> </div>
<Collapsible label="Add more details"> <Collapsible label="Add more details">
@ -238,7 +264,7 @@ function CurrentJobSection() {
<RadioList.Item <RadioList.Item
key="Internship" key="Internship"
label="Internship" label="Internship"
value={JobType.Internship} value={JobType.Intern}
/> />
</FormRadioList> </FormRadioList>
</div> </div>

@ -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>
)}
</>
);
}

@ -18,10 +18,6 @@ import {
defaultInternshipOfferValues, defaultInternshipOfferValues,
} from '~/pages/offers/submit'; } 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 { import {
emptyOption, emptyOption,
FieldError, FieldError,
@ -30,10 +26,11 @@ import {
titleOptions, titleOptions,
yearOptions, yearOptions,
} from '../constants'; } from '../constants';
import type { import FormMonthYearPicker from '../forms/FormMonthYearPicker';
FullTimeOfferDetailsFormData, import FormSelect from '../forms/FormSelect';
InternshipOfferDetailsFormData, import FormTextArea from '../forms/FormTextArea';
} from '../types'; import FormTextInput from '../forms/FormTextInput';
import type { OfferFormData } from '../types';
import { JobTypeLabel } from '../types'; import { JobTypeLabel } from '../types';
import { JobType } from '../types'; import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
@ -48,18 +45,21 @@ function FullTimeOfferDetailsForm({
remove, remove,
}: FullTimeOfferDetailsFormProps) { }: FullTimeOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{ const { register, formState, setValue } = useFormContext<{
offers: Array<FullTimeOfferDetailsFormData>; offers: Array<OfferFormData>;
}>(); }>();
const offerFields = formState.errors.offers?.[index]; const offerFields = formState.errors.offers?.[index];
const watchCurrency = useWatch({ const watchCurrency = useWatch({
name: `offers.${index}.job.totalCompensation.currency`, name: `offers.${index}.offersFullTime.totalCompensation.currency`,
}); });
useEffect(() => { useEffect(() => {
setValue(`offers.${index}.job.base.currency`, watchCurrency); setValue(
setValue(`offers.${index}.job.bonus.currency`, watchCurrency); `offers.${index}.offersFullTime.baseSalary.currency`,
setValue(`offers.${index}.job.stocks.currency`, watchCurrency); watchCurrency,
);
setValue(`offers.${index}.offersFullTime.bonus.currency`, watchCurrency);
setValue(`offers.${index}.offersFullTime.stocks.currency`, watchCurrency);
}, [watchCurrency, index, setValue]); }, [watchCurrency, index, setValue]);
return ( return (
@ -67,21 +67,21 @@ function FullTimeOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.job?.title?.message} errorMessage={offerFields?.offersFullTime?.title?.message}
label="Title" label="Title"
options={titleOptions} options={titleOptions}
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.job.title`, { {...register(`offers.${index}.offersFullTime.title`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
<FormTextInput <FormTextInput
errorMessage={offerFields?.job?.specialization?.message} errorMessage={offerFields?.offersFullTime?.specialization?.message}
label="Focus / Specialization" label="Focus / Specialization"
placeholder="e.g. Front End" placeholder="e.g. Front End"
required={true} required={true}
{...register(`offers.${index}.job.specialization`, { {...register(`offers.${index}.offersFullTime.specialization`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
@ -95,11 +95,11 @@ function FullTimeOfferDetailsForm({
/> />
</div> </div>
<FormTextInput <FormTextInput
errorMessage={offerFields?.job?.level?.message} errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level" label="Level"
placeholder="e.g. L4, Junior" placeholder="e.g. L4, Junior"
required={true} required={true}
{...register(`offers.${index}.job.level`, { {...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
@ -133,24 +133,32 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.totalCompensation.currency`, { {...register(
required: FieldError.Required, `offers.${index}.offersFullTime.totalCompensation.currency`,
})} {
required: FieldError.Required,
},
)}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.totalCompensation?.value?.message} errorMessage={
offerFields?.offersFullTime?.totalCompensation?.value?.message
}
label="Total Compensation (Annual)" label="Total Compensation (Annual)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.totalCompensation.value`, { {...register(
min: { message: FieldError.NonNegativeNumber, value: 0 }, `offers.${index}.offersFullTime.totalCompensation.value`,
required: FieldError.Required, {
valueAsNumber: true, min: { message: FieldError.NonNegativeNumber, value: 0 },
})} required: FieldError.Required,
valueAsNumber: true,
},
)}
/> />
</div> </div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
@ -161,20 +169,23 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.base.currency`, { {...register(
required: FieldError.Required, `offers.${index}.offersFullTime.baseSalary.currency`,
})} {
required: FieldError.Required,
},
)}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.base?.value?.message} errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message}
label="Base Salary (Annual)" label="Base Salary (Annual)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.base.value`, { {...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
@ -187,20 +198,20 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.bonus.currency`, { {...register(`offers.${index}.offersFullTime.bonus.currency`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.bonus?.value?.message} errorMessage={offerFields?.offersFullTime?.bonus?.value?.message}
label="Bonus (Annual)" label="Bonus (Annual)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.bonus.value`, { {...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
@ -215,20 +226,20 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.stocks.currency`, { {...register(`offers.${index}.offersFullTime.stocks.currency`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.stocks?.value?.message} errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
label="Stocks (Annual)" label="Stocks (Annual)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.stocks.value`, { {...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
@ -273,7 +284,7 @@ function InternshipOfferDetailsForm({
remove, remove,
}: InternshipOfferDetailsFormProps) { }: InternshipOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{ const { register, formState, setValue } = useFormContext<{
offers: Array<InternshipOfferDetailsFormData>; offers: Array<OfferFormData>;
}>(); }>();
const offerFields = formState.errors.offers?.[index]; const offerFields = formState.errors.offers?.[index];
@ -283,22 +294,22 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.job?.title?.message} errorMessage={offerFields?.offersIntern?.title?.message}
label="Title" label="Title"
options={titleOptions} options={titleOptions}
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.job.title`, { {...register(`offers.${index}.offersIntern.title`, {
minLength: 1, minLength: 1,
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
<FormTextInput <FormTextInput
errorMessage={offerFields?.job?.specialization?.message} errorMessage={offerFields?.offersIntern?.specialization?.message}
label="Focus / Specialization" label="Focus / Specialization"
placeholder="e.g. Front End" placeholder="e.g. Front End"
required={true} required={true}
{...register(`offers.${index}.job.specialization`, { {...register(`offers.${index}.offersIntern.specialization`, {
minLength: 1, minLength: 1,
required: FieldError.Required, required: FieldError.Required,
})} })}
@ -327,24 +338,25 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.job?.internshipCycle?.message} errorMessage={offerFields?.offersIntern?.internshipCycle?.message}
label="Internship Cycle" label="Internship Cycle"
options={internshipCycleOptions} options={internshipCycleOptions}
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.job.internshipCycle`, { {...register(`offers.${index}.offersIntern.internshipCycle`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.job?.startYear?.message} errorMessage={offerFields?.offersIntern?.startYear?.message}
label="Internship Year" label="Internship Year"
options={yearOptions} options={yearOptions}
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.job.startYear`, { {...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true,
})} })}
/> />
</div> </div>
@ -366,20 +378,25 @@ function InternshipOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.monthlySalary.currency`, { {...register(
required: FieldError.Required, `offers.${index}.offersIntern.monthlySalary.currency`,
})} {
required: FieldError.Required,
},
)}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.monthlySalary?.value?.message} errorMessage={
offerFields?.offersIntern?.monthlySalary?.value?.message
}
label="Salary (Monthly)" label="Salary (Monthly)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.monthlySalary.value`, { {...register(`offers.${index}.offersIntern.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
@ -468,7 +485,7 @@ export default function OfferDetailsForm() {
const toggleJobType = () => { const toggleJobType = () => {
remove(); remove();
if (jobType === JobType.FullTime) { if (jobType === JobType.FullTime) {
setJobType(JobType.Internship); setJobType(JobType.Intern);
append(defaultInternshipOfferValues); append(defaultInternshipOfferValues);
} else { } else {
setJobType(JobType.FullTime); setJobType(JobType.FullTime);
@ -477,9 +494,7 @@ export default function OfferDetailsForm() {
}; };
const switchJobTypeLabel = () => const switchJobTypeLabel = () =>
jobType === JobType.FullTime jobType === JobType.FullTime ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
? JobTypeLabel.INTERNSHIP
: JobTypeLabel.FULLTIME;
return ( return (
<div className="mb-5"> <div className="mb-5">
@ -504,11 +519,11 @@ export default function OfferDetailsForm() {
<div className="mx-5 w-1/3"> <div className="mx-5 w-1/3">
<Button <Button
display="block" display="block"
label={JobTypeLabel.INTERNSHIP} label={JobTypeLabel.INTERN}
size="md" size="md"
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'} variant={jobType === JobType.Intern ? 'secondary' : 'tertiary'}
onClick={() => { onClick={() => {
if (jobType === JobType.Internship) { if (jobType === JobType.Intern) {
return; return;
} }
setDialogOpen(true); setDialogOpen(true);

@ -52,7 +52,8 @@ export default function OfferProfileSave() {
<p className="mb-5 text-gray-900"> <p className="mb-5 text-gray-900">
If you do not want to keep the edit link, you can opt to save this 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> </p>
<div className="mb-20"> <div className="mb-20">
<Button <Button

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

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

@ -51,7 +51,106 @@ const searchOfferPercentile = (
}; };
export const offersAnalysisRouter = createRouter() 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({ input: z.object({
profileId: z.string(), 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); return profileAnalysisDtoMapper(analysis);
}, },
}); });

Loading…
Cancel
Save