mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 04:33:42 +08:00
[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
This commit is contained in:
@ -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,53 +33,62 @@ 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
|
||||
label="Specific YOE 1"
|
||||
type="number"
|
||||
{...register(`background.specificYoes.0.yoe`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Specific Domain 1"
|
||||
placeholder="e.g. Frontend"
|
||||
{...register(`background.specificYoes.0.domain`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||
<FormTextInput
|
||||
label="Specific YOE 2"
|
||||
type="number"
|
||||
{...register(`background.specificYoes.1.yoe`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Specific Domain 2"
|
||||
placeholder="e.g. Backend"
|
||||
{...register(`background.specificYoes.1.domain`)}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<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,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Specific Domain 1"
|
||||
placeholder="e.g. Frontend"
|
||||
{...register(`background.specificYoes.0.domain`)}
|
||||
/>
|
||||
</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,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Specific Domain 2"
|
||||
placeholder="e.g. Backend"
|
||||
{...register(`background.specificYoes.1.domain`)}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</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,14 +96,16 @@ 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
|
||||
@ -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,14 +181,16 @@ 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
|
||||
@ -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`, {
|
||||
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">
|
||||
@ -160,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,
|
||||
@ -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`, {
|
||||
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,
|
||||
@ -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,17 +20,72 @@ export enum EducationBackgroundType {
|
||||
Masters = 'Masters',
|
||||
PhD = 'PhD',
|
||||
Professional = 'Professional',
|
||||
Seconday = 'Secondary',
|
||||
Secondary = 'Secondary',
|
||||
SelfTaught = 'Self-taught',
|
||||
}
|
||||
|
||||
export type Money = {
|
||||
currency: string;
|
||||
value: number;
|
||||
export type OffersProfilePostData = {
|
||||
background: BackgroundPostData;
|
||||
offers: Array<OfferPostData>;
|
||||
};
|
||||
|
||||
type FullTimeJobData = {
|
||||
base: Money;
|
||||
export type OffersProfileFormData = {
|
||||
background: BackgroundPostData;
|
||||
offers: Array<OfferFormData>;
|
||||
};
|
||||
|
||||
export type BackgroundPostData = {
|
||||
educations: Array<EducationPostData>;
|
||||
experiences: Array<ExperiencePostData>;
|
||||
specificYoes: Array<SpecificYoePostData>;
|
||||
totalYoe: number;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type EducationPostData = {
|
||||
endDate?: Date | null;
|
||||
field?: string | null;
|
||||
school?: string | null;
|
||||
startDate?: Date | null;
|
||||
type?: string | null;
|
||||
};
|
||||
|
||||
type SpecificYoePostData = {
|
||||
domain: string;
|
||||
yoe: number;
|
||||
};
|
||||
|
||||
type SpecificYoe = SpecificYoePostData;
|
||||
|
||||
export type OfferPostData = {
|
||||
comments: string;
|
||||
companyId: string;
|
||||
jobType: string;
|
||||
location: string;
|
||||
monthYearReceived: Date;
|
||||
negotiationStrategy: string;
|
||||
offersFullTime?: OfferFullTimePostData | null;
|
||||
offersIntern?: OfferInternPostData | null;
|
||||
};
|
||||
|
||||
export type OfferFormData = Omit<OfferPostData, 'monthYearReceived'> & {
|
||||
monthYearReceived: MonthYear;
|
||||
};
|
||||
|
||||
export type OfferFullTimePostData = {
|
||||
baseSalary: Money;
|
||||
bonus: Money;
|
||||
level: string;
|
||||
specialization: string;
|
||||
@ -39,7 +94,7 @@ type FullTimeJobData = {
|
||||
totalCompensation: Money;
|
||||
};
|
||||
|
||||
type InternshipJobData = {
|
||||
export type OfferInternPostData = {
|
||||
internshipCycle: string;
|
||||
monthlySalary: Money;
|
||||
specialization: string;
|
||||
@ -47,83 +102,9 @@ type InternshipJobData = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
type OfferDetailsGeneralData = {
|
||||
comments: string;
|
||||
companyId: string;
|
||||
jobType: string;
|
||||
location: string;
|
||||
monthYearReceived: MonthYear;
|
||||
negotiationStrategy: string;
|
||||
};
|
||||
|
||||
export type FullTimeOfferDetailsFormData = OfferDetailsGeneralData & {
|
||||
job: FullTimeJobData;
|
||||
};
|
||||
|
||||
export type InternshipOfferDetailsFormData = OfferDetailsGeneralData & {
|
||||
job: InternshipJobData;
|
||||
};
|
||||
|
||||
export type OfferDetailsFormData =
|
||||
| FullTimeOfferDetailsFormData
|
||||
| InternshipOfferDetailsFormData;
|
||||
|
||||
export type OfferDetailsPostData = Omit<
|
||||
OfferDetailsFormData,
|
||||
'monthYearReceived'
|
||||
> & {
|
||||
monthYearReceived: Date;
|
||||
};
|
||||
|
||||
type SpecificYoe = {
|
||||
domain: string;
|
||||
yoe: number;
|
||||
};
|
||||
|
||||
type FullTimeExperience = {
|
||||
level?: string;
|
||||
totalCompensation?: Money;
|
||||
};
|
||||
|
||||
type InternshipExperience = {
|
||||
monthlySalary?: Money;
|
||||
};
|
||||
|
||||
type GeneralExperience = {
|
||||
companyId?: string;
|
||||
durationInMonths?: number;
|
||||
jobType?: string;
|
||||
specialization?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type Experience =
|
||||
| (FullTimeExperience & GeneralExperience)
|
||||
| (GeneralExperience & InternshipExperience);
|
||||
|
||||
type Education = {
|
||||
endDate?: Date;
|
||||
field?: string;
|
||||
school?: string;
|
||||
startDate?: Date;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type BackgroundFormData = {
|
||||
educations: Array<Education>;
|
||||
experiences: Array<Experience>;
|
||||
specificYoes: Array<SpecificYoe>;
|
||||
totalYoe?: number;
|
||||
};
|
||||
|
||||
export type OfferProfileFormData = {
|
||||
background: BackgroundFormData;
|
||||
offers: Array<OfferDetailsFormData>;
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
Reference in New Issue
Block a user