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 = [
{
label: '2021',
value: '2021',
value: 2021,
},
{
label: '2022',
value: '2022',
value: 2022,
},
{
label: '2023',
value: '2023',
value: 2023,
},
{
label: '2024',
value: '2024',
value: 2024,
},
];
@ -106,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>
);
}

@ -9,16 +9,21 @@ import {
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">
@ -28,11 +33,13 @@ 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,
})}
@ -41,9 +48,11 @@ function YoeSection() {
<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,
})}
/>
@ -55,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,
})}
/>
@ -74,7 +85,10 @@ function YoeSection() {
}
function FullTimeJobFields() {
const { register, setValue } = 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">
@ -107,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,
})}
/>
@ -138,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,
})}
/>
@ -151,7 +169,11 @@ function FullTimeJobFields() {
}
function InternshipJobFields() {
const { register, setValue } = 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">
@ -182,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">
@ -238,7 +264,7 @@ function CurrentJobSection() {
<RadioList.Item
key="Internship"
label="Internship"
value={JobType.Internship}
value={JobType.Intern}
/>
</FormRadioList>
</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,
} 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 {
emptyOption,
FieldError,
@ -30,10 +26,11 @@ 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';
@ -48,18 +45,21 @@ function FullTimeOfferDetailsForm({
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 (
@ -67,21 +67,21 @@ 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,
})}
/>
@ -95,11 +95,11 @@ function FullTimeOfferDetailsForm({
/>
</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,
})}
/>
@ -133,24 +133,32 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.totalCompensation.currency`, {
required: FieldError.Required,
})}
{...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`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
{...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">
@ -161,20 +169,23 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.base.currency`, {
required: FieldError.Required,
})}
{...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,
@ -187,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,
@ -215,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,
@ -273,7 +284,7 @@ function InternshipOfferDetailsForm({
remove,
}: InternshipOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{
offers: Array<InternshipOfferDetailsFormData>;
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
@ -283,22 +294,22 @@ 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,
})}
@ -327,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>
@ -366,20 +378,25 @@ function InternshipOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.monthlySalary.currency`, {
required: FieldError.Required,
})}
{...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,
@ -468,7 +485,7 @@ export default function OfferDetailsForm() {
const toggleJobType = () => {
remove();
if (jobType === JobType.FullTime) {
setJobType(JobType.Internship);
setJobType(JobType.Intern);
append(defaultInternshipOfferValues);
} else {
setJobType(JobType.FullTime);
@ -477,9 +494,7 @@ export default function OfferDetailsForm() {
};
const switchJobTypeLabel = () =>
jobType === JobType.FullTime
? JobTypeLabel.INTERNSHIP
: JobTypeLabel.FULLTIME;
jobType === JobType.FullTime ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
return (
<div className="mb-5">
@ -504,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 {
@ -24,106 +24,87 @@ export enum EducationBackgroundType {
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,17 +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() {
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;
@ -141,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,

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