mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-27 20:22:33 +08:00
[offers][feat] Add analysis to offers profile page (#416)
This commit is contained in:
@ -110,9 +110,30 @@ export const educationFieldOptions = [
|
||||
];
|
||||
|
||||
export enum FieldError {
|
||||
NonNegativeNumber = 'Please fill in a non-negative number in this field.',
|
||||
Number = 'Please fill in a number in this field.',
|
||||
Required = 'Please fill in this field.',
|
||||
NON_NEGATIVE_NUMBER = 'Please fill in a non-negative number in this field.',
|
||||
NUMBER = 'Please fill in a number in this field.',
|
||||
REQUIRED = 'Please fill in this field.',
|
||||
}
|
||||
|
||||
export const OVERALL_TAB = 'Overall';
|
||||
|
||||
export enum ProfileDetailTab {
|
||||
ANALYSIS = 'Offer Engine Analysis',
|
||||
BACKGROUND = 'Background',
|
||||
OFFERS = 'Offers',
|
||||
}
|
||||
|
||||
export const profileDetailTabs = [
|
||||
{
|
||||
label: ProfileDetailTab.OFFERS,
|
||||
value: ProfileDetailTab.OFFERS,
|
||||
},
|
||||
{
|
||||
label: ProfileDetailTab.BACKGROUND,
|
||||
value: ProfileDetailTab.BACKGROUND,
|
||||
},
|
||||
{
|
||||
label: ProfileDetailTab.ANALYSIS,
|
||||
value: ProfileDetailTab.ANALYSIS,
|
||||
},
|
||||
];
|
||||
|
@ -2,11 +2,9 @@ import { useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
|
||||
import OfferProfileCard from './OfferProfileCard';
|
||||
import { OVERALL_TAB } from '../../constants';
|
||||
import { OVERALL_TAB } from '../constants';
|
||||
|
||||
import type {
|
||||
Analysis,
|
||||
@ -29,10 +27,18 @@ function OfferAnalysisContent({
|
||||
tab,
|
||||
}: OfferAnalysisContentProps) {
|
||||
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) {
|
||||
if (tab === OVERALL_TAB) {
|
||||
return (
|
||||
<p className="m-10">
|
||||
You are the first to submit an offer for your job title and YOE! Check
|
||||
back later when there are more submissions.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="m-10">
|
||||
You are the first to submit an offer for these companies! Check back
|
||||
later when there are more submissions.
|
||||
You are the first to submit an offer for this company, job title and
|
||||
YOE! Check back later when there are more submissions.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@ -55,12 +61,17 @@ function OfferAnalysisContent({
|
||||
}
|
||||
|
||||
type OfferAnalysisProps = Readonly<{
|
||||
profileId?: string;
|
||||
allAnalysis?: ProfileAnalysis | null;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
}>;
|
||||
|
||||
export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
|
||||
export default function OfferAnalysis({
|
||||
allAnalysis,
|
||||
isError,
|
||||
isLoading,
|
||||
}: OfferAnalysisProps) {
|
||||
const [tab, setTab] = useState(OVERALL_TAB);
|
||||
const [allAnalysis, setAllAnalysis] = useState<ProfileAnalysis | null>(null);
|
||||
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -77,22 +88,6 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
|
||||
}
|
||||
}, [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,
|
||||
@ -107,18 +102,13 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
|
||||
return (
|
||||
analysis && (
|
||||
<div>
|
||||
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
|
||||
Result
|
||||
</h5>
|
||||
{getAnalysisResult.isError && (
|
||||
{isError && (
|
||||
<p className="m-10 text-center">
|
||||
An error occurred while generating profile analysis.
|
||||
</p>
|
||||
)}
|
||||
{getAnalysisResult.isLoading && (
|
||||
<Spinner className="m-10" display="block" size="lg" />
|
||||
)}
|
||||
{!getAnalysisResult.isError && !getAnalysisResult.isLoading && (
|
||||
{isLoading && <Spinner className="m-10" display="block" size="lg" />}
|
||||
{!isError && !isLoading && (
|
||||
<div>
|
||||
<Tabs
|
||||
label="Result Navigation"
|
@ -0,0 +1,29 @@
|
||||
import { OVERALL_TAB } from '../constants';
|
||||
|
||||
import type { Analysis } from '~/types/offers';
|
||||
|
||||
type OfferPercentileAnalysisTextProps = Readonly<{
|
||||
companyName: string;
|
||||
offerAnalysis: Analysis;
|
||||
tab: string;
|
||||
}>;
|
||||
|
||||
export default function OfferPercentileAnalysisText({
|
||||
tab,
|
||||
companyName,
|
||||
offerAnalysis: { noOfOffers, percentile },
|
||||
}: OfferPercentileAnalysisTextProps) {
|
||||
return tab === OVERALL_TAB ? (
|
||||
<p>
|
||||
Your highest offer is from <b>{companyName}</b>, which is{' '}
|
||||
<b>{percentile.toFixed(1)}</b> percentile out of <b>{noOfOffers}</b>{' '}
|
||||
offers received for the same job title and YOE(±1) in the last year.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Your offer from <b>{companyName}</b> is <b>{percentile.toFixed(1)}</b>{' '}
|
||||
percentile out of <b>{noOfOffers}</b> offers received in {companyName} for
|
||||
the same job title and YOE(±1) in the last year.
|
||||
</p>
|
||||
);
|
||||
}
|
@ -1,10 +1,14 @@
|
||||
import {
|
||||
BuildingOffice2Icon,
|
||||
CalendarDaysIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { JobType } from '@prisma/client';
|
||||
|
||||
import { HorizontalDivider } from '~/../../../packages/ui/dist';
|
||||
import { convertMoneyToString } from '~/utils/offers/currency';
|
||||
import { formatDate } from '~/utils/offers/time';
|
||||
|
||||
import ProfilePhotoHolder from '../../profile/ProfilePhotoHolder';
|
||||
import ProfilePhotoHolder from '../profile/ProfilePhotoHolder';
|
||||
|
||||
import type { AnalysisOffer } from '~/types/offers';
|
||||
|
||||
@ -27,29 +31,37 @@ export default function OfferProfileCard({
|
||||
},
|
||||
}: 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">
|
||||
<div className="my-5 block rounded-lg bg-white p-4 px-8 shadow-md">
|
||||
<div className="flex items-center gap-x-5">
|
||||
<div>
|
||||
<ProfilePhotoHolder size="sm" />
|
||||
</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>
|
||||
<p className="font-bold">{profileName}</p>
|
||||
<div className="flex flex-row">
|
||||
<BuildingOffice2Icon className="mr-2 h-5" />
|
||||
<span className="mr-2 font-bold">Current:</span>
|
||||
<span>{previousCompanies[0]}</span>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<CalendarDaysIcon className="mr-2 h-5" />
|
||||
<span className="mr-2 font-bold">YOE:</span>
|
||||
<span>{totalYoe}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HorizontalDivider />
|
||||
<div className="grid grid-flow-col grid-cols-2 gap-x-10">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="col-span-1 row-span-3">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-xs ">
|
||||
<p className="font-bold">{title}</p>
|
||||
<p>
|
||||
Company: {company.name}, {location}
|
||||
</p>
|
||||
<p className="text-xs ">Level: {level}</p>
|
||||
<p>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">{formatDate(monthYearReceived)}</p>
|
||||
<p className="text-end text-xl">
|
||||
{jobType === JobType.FULLTIME
|
||||
? `${convertMoneyToString(income)} / year`
|
@ -16,7 +16,7 @@ type OfferProfileSaveProps = Readonly<{
|
||||
token?: string;
|
||||
}>;
|
||||
|
||||
export default function OfferProfileSave({
|
||||
export default function OffersProfileSave({
|
||||
profileId,
|
||||
token,
|
||||
}: OfferProfileSaveProps) {
|
@ -6,8 +6,7 @@ import { JobType } from '@prisma/client';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
|
||||
import OfferAnalysis from '~/components/offers/offersSubmission/analysis/OfferAnalysis';
|
||||
import OfferProfileSave from '~/components/offers/offersSubmission/OfferProfileSave';
|
||||
import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
|
||||
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
|
||||
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
|
||||
import type {
|
||||
@ -20,7 +19,12 @@ 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';
|
||||
import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
|
||||
|
||||
import type {
|
||||
CreateOfferProfileResponse,
|
||||
ProfileAnalysis,
|
||||
} from '~/types/offers';
|
||||
|
||||
const defaultOfferValues = {
|
||||
comments: '',
|
||||
@ -78,6 +82,7 @@ export default function OffersSubmissionForm({
|
||||
id: profileId || '',
|
||||
token: token || '',
|
||||
});
|
||||
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToTop = () =>
|
||||
@ -88,6 +93,18 @@ export default function OffersSubmissionForm({
|
||||
});
|
||||
const { handleSubmit, trigger } = formMethods;
|
||||
|
||||
const generateAnalysisMutation = trpc.useMutation(
|
||||
['offers.analysis.generate'],
|
||||
{
|
||||
onError(error) {
|
||||
console.error(error.message);
|
||||
},
|
||||
onSuccess(data) {
|
||||
setAnalysis(data);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const formSteps: Array<FormStep> = [
|
||||
{
|
||||
component: (
|
||||
@ -107,14 +124,21 @@ export default function OffersSubmissionForm({
|
||||
label: 'Background',
|
||||
},
|
||||
{
|
||||
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />,
|
||||
component: (
|
||||
<OfferAnalysis
|
||||
key={2}
|
||||
allAnalysis={analysis}
|
||||
isError={generateAnalysisMutation.isError}
|
||||
isLoading={generateAnalysisMutation.isLoading}
|
||||
/>
|
||||
),
|
||||
hasNext: true,
|
||||
hasPrevious: false,
|
||||
label: 'Analysis',
|
||||
},
|
||||
{
|
||||
component: (
|
||||
<OfferProfileSave
|
||||
<OffersProfileSave
|
||||
key={3}
|
||||
profileId={createProfileResponse.id || ''}
|
||||
token={createProfileResponse.token}
|
||||
@ -144,15 +168,6 @@ export default function OffersSubmissionForm({
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const generateAnalysisMutation = trpc.useMutation(
|
||||
['offers.analysis.generate'],
|
||||
{
|
||||
onError(error) {
|
||||
console.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const mutationpath =
|
||||
profileId && token ? 'offers.profile.update' : 'offers.profile.create';
|
||||
|
||||
|
@ -1,27 +0,0 @@
|
||||
import type { Analysis } from '~/types/offers';
|
||||
|
||||
type OfferPercentileAnalysisTextProps = Readonly<{
|
||||
companyName: string;
|
||||
offerAnalysis: Analysis;
|
||||
tab: string;
|
||||
}>;
|
||||
|
||||
export default function OfferPercentileAnalysisText({
|
||||
tab,
|
||||
companyName,
|
||||
offerAnalysis: { noOfOffers, percentile },
|
||||
}: OfferPercentileAnalysisTextProps) {
|
||||
return tab === 'Overall' ? (
|
||||
<p>
|
||||
Your highest offer is from <b>{companyName}</b>, which is{' '}
|
||||
<b>{percentile}</b> percentile out of <b>{noOfOffers}</b> offers received
|
||||
for the same job title and YOE(+/-1) in the last year.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Your offer from <b>{companyName}</b> is <b>{percentile}</b> percentile out
|
||||
of <b>{noOfOffers}</b> offers received in {companyName} for the same job
|
||||
title and YOE(+/-1) in the last year.
|
||||
</p>
|
||||
);
|
||||
}
|
@ -39,8 +39,8 @@ function YoeSection() {
|
||||
required={true}
|
||||
type="number"
|
||||
{...register(`background.totalYoe`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
required: FieldError.Required,
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
required: FieldError.REQUIRED,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -52,7 +52,7 @@ function YoeSection() {
|
||||
label="Specific YOE 1"
|
||||
type="number"
|
||||
{...register(`background.specificYoes.0.yoe`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -68,7 +68,7 @@ function YoeSection() {
|
||||
label="Specific YOE 2"
|
||||
type="number"
|
||||
{...register(`background.specificYoes.1.yoe`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -128,7 +128,7 @@ function FullTimeJobFields() {
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`background.experiences.0.totalCompensation.value`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -158,7 +158,7 @@ function FullTimeJobFields() {
|
||||
label="Duration (months)"
|
||||
type="number"
|
||||
{...register(`background.experiences.0.durationInMonths`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -211,7 +211,7 @@ function InternshipJobFields() {
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`background.experiences.0.monthlySalary.value`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
|
@ -72,7 +72,7 @@ function FullTimeOfferDetailsForm({
|
||||
placeholder={emptyOption}
|
||||
required={true}
|
||||
{...register(`offers.${index}.offersFullTime.title`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
@ -81,7 +81,7 @@ function FullTimeOfferDetailsForm({
|
||||
placeholder="e.g. Front End"
|
||||
required={true}
|
||||
{...register(`offers.${index}.offersFullTime.specialization`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@ -99,7 +99,7 @@ function FullTimeOfferDetailsForm({
|
||||
placeholder="e.g. L4, Junior"
|
||||
required={true}
|
||||
{...register(`offers.${index}.offersFullTime.level`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@ -112,7 +112,7 @@ function FullTimeOfferDetailsForm({
|
||||
placeholder={emptyOption}
|
||||
required={true}
|
||||
{...register(`offers.${index}.location`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
<FormMonthYearPicker
|
||||
@ -120,7 +120,7 @@ function FullTimeOfferDetailsForm({
|
||||
monthRequired={true}
|
||||
yearLabel=""
|
||||
{...register(`offers.${index}.monthYearReceived`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@ -135,7 +135,7 @@ function FullTimeOfferDetailsForm({
|
||||
{...register(
|
||||
`offers.${index}.offersFullTime.totalCompensation.currency`,
|
||||
{
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
@ -153,8 +153,8 @@ function FullTimeOfferDetailsForm({
|
||||
{...register(
|
||||
`offers.${index}.offersFullTime.totalCompensation.value`,
|
||||
{
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
required: FieldError.Required,
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
required: FieldError.REQUIRED,
|
||||
valueAsNumber: true,
|
||||
},
|
||||
)}
|
||||
@ -171,7 +171,7 @@ function FullTimeOfferDetailsForm({
|
||||
{...register(
|
||||
`offers.${index}.offersFullTime.baseSalary.currency`,
|
||||
{
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
@ -185,8 +185,8 @@ function FullTimeOfferDetailsForm({
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`offers.${index}.offersFullTime.baseSalary.value`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
required: FieldError.Required,
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
required: FieldError.REQUIRED,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -198,7 +198,7 @@ function FullTimeOfferDetailsForm({
|
||||
label="Currency"
|
||||
options={CURRENCY_OPTIONS}
|
||||
{...register(`offers.${index}.offersFullTime.bonus.currency`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
@ -211,8 +211,8 @@ function FullTimeOfferDetailsForm({
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`offers.${index}.offersFullTime.bonus.value`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
required: FieldError.Required,
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
required: FieldError.REQUIRED,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -226,7 +226,7 @@ function FullTimeOfferDetailsForm({
|
||||
label="Currency"
|
||||
options={CURRENCY_OPTIONS}
|
||||
{...register(`offers.${index}.offersFullTime.stocks.currency`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
@ -239,8 +239,8 @@ function FullTimeOfferDetailsForm({
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`offers.${index}.offersFullTime.stocks.value`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
required: FieldError.Required,
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
required: FieldError.REQUIRED,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -300,7 +300,7 @@ function InternshipOfferDetailsForm({
|
||||
required={true}
|
||||
{...register(`offers.${index}.offersIntern.title`, {
|
||||
minLength: 1,
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
@ -310,7 +310,7 @@ function InternshipOfferDetailsForm({
|
||||
required={true}
|
||||
{...register(`offers.${index}.offersIntern.specialization`, {
|
||||
minLength: 1,
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@ -330,7 +330,7 @@ function InternshipOfferDetailsForm({
|
||||
placeholder={emptyOption}
|
||||
required={true}
|
||||
{...register(`offers.${index}.location`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@ -343,7 +343,7 @@ function InternshipOfferDetailsForm({
|
||||
placeholder={emptyOption}
|
||||
required={true}
|
||||
{...register(`offers.${index}.offersIntern.internshipCycle`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
<FormSelect
|
||||
@ -354,7 +354,7 @@ function InternshipOfferDetailsForm({
|
||||
placeholder={emptyOption}
|
||||
required={true}
|
||||
{...register(`offers.${index}.offersIntern.startYear`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
@ -365,7 +365,7 @@ function InternshipOfferDetailsForm({
|
||||
monthRequired={true}
|
||||
yearLabel=""
|
||||
{...register(`offers.${index}.monthYearReceived`, {
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@ -380,7 +380,7 @@ function InternshipOfferDetailsForm({
|
||||
{...register(
|
||||
`offers.${index}.offersIntern.monthlySalary.currency`,
|
||||
{
|
||||
required: FieldError.Required,
|
||||
required: FieldError.REQUIRED,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
@ -396,8 +396,8 @@ function InternshipOfferDetailsForm({
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`offers.${index}.offersIntern.monthlySalary.value`, {
|
||||
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||
required: FieldError.Required,
|
||||
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
|
||||
required: FieldError.REQUIRED,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
|
@ -58,52 +58,64 @@ export default function OfferCard({
|
||||
}
|
||||
|
||||
function BottomSection() {
|
||||
return (
|
||||
<div className="px-8">
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-row">
|
||||
<CurrencyDollarIcon className="mr-1 h-5" />
|
||||
<p>
|
||||
{totalCompensation
|
||||
? `TC: ${totalCompensation}`
|
||||
: `Monthly Salary: ${monthlySalary}`}
|
||||
</p>
|
||||
</div>
|
||||
if (
|
||||
!totalCompensation &&
|
||||
!monthlySalary &&
|
||||
!negotiationStrategy &&
|
||||
!otherComment
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
{totalCompensation && (
|
||||
<div className="ml-6 flex flex-row font-light text-gray-400">
|
||||
<p>
|
||||
Base / year: {base} ⋅ Stocks / year: {stocks} ⋅ Bonus / year:{' '}
|
||||
{bonus}
|
||||
</p>
|
||||
return (
|
||||
<>
|
||||
<HorizontalDivider />
|
||||
<div className="px-8">
|
||||
<div className="flex flex-col py-2">
|
||||
{totalCompensation ||
|
||||
(monthlySalary && (
|
||||
<div className="flex flex-row">
|
||||
<CurrencyDollarIcon className="mr-1 h-5" />
|
||||
<p>
|
||||
{totalCompensation && `TC: ${totalCompensation}`}
|
||||
{monthlySalary && `Monthly Salary: ${monthlySalary}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{totalCompensation && (
|
||||
<div className="ml-6 flex flex-row font-light text-gray-400">
|
||||
<p>
|
||||
Base / year: {base} ⋅ Stocks / year: {stocks} ⋅ Bonus / year:{' '}
|
||||
{bonus}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{negotiationStrategy && (
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-row">
|
||||
<ScaleIcon className="h-5 w-5" />
|
||||
<span className="overflow-wrap ml-2">
|
||||
"{negotiationStrategy}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{otherComment && (
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-row">
|
||||
<ChatBubbleBottomCenterTextIcon className="h-5 w-5" />
|
||||
<span className="overflow-wrap ml-2">"{otherComment}"</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{negotiationStrategy && (
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-row">
|
||||
<ScaleIcon className="h-5 w-5" />
|
||||
<span className="overflow-wrap ml-2">
|
||||
"{negotiationStrategy}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{otherComment && (
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-row">
|
||||
<ChatBubbleBottomCenterTextIcon className="h-8 w-8" />
|
||||
<span className="overflow-wrap ml-2">"{otherComment}"</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md">
|
||||
<UpperSection />
|
||||
<HorizontalDivider />
|
||||
<BottomSection />
|
||||
</div>
|
||||
);
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { AcademicCapIcon, BriefcaseIcon } from '@heroicons/react/24/outline';
|
||||
import { Spinner } from '@tih/ui';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowPathIcon,
|
||||
BriefcaseIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Button, Spinner } from '@tih/ui';
|
||||
|
||||
import EducationCard from '~/components/offers/profile/EducationCard';
|
||||
import OfferCard from '~/components/offers/profile/OfferCard';
|
||||
@ -8,22 +13,143 @@ import type {
|
||||
OfferDisplayData,
|
||||
} from '~/components/offers/types';
|
||||
|
||||
import type { ProfileAnalysis } from '~/types/offers';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
type ProfileHeaderProps = Readonly<{
|
||||
import { ProfileDetailTab } from '../constants';
|
||||
import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
|
||||
|
||||
import { ProfileAnalysis } from '~/types/offers';
|
||||
|
||||
type ProfileOffersProps = Readonly<{
|
||||
offers: Array<OfferDisplayData>;
|
||||
}>;
|
||||
|
||||
function ProfileOffers({ offers }: ProfileOffersProps) {
|
||||
if (offers.length !== 0) {
|
||||
return (
|
||||
<>
|
||||
{offers.map((offer) => (
|
||||
<OfferCard key={offer.id} offer={offer} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mx-8 my-4 flex flex-row">
|
||||
<BriefcaseIcon className="mr-1 h-5" />
|
||||
<span className="font-bold">No offer is attached.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileBackgroundProps = Readonly<{
|
||||
background?: BackgroundDisplayData;
|
||||
}>;
|
||||
|
||||
function ProfileBackground({ background }: ProfileBackgroundProps) {
|
||||
if (!background?.experiences?.length && !background?.educations?.length) {
|
||||
return (
|
||||
<div className="mx-8 my-4">
|
||||
<p>No background information available.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{background?.experiences?.length > 0 && (
|
||||
<>
|
||||
<div className="mx-8 my-4 flex flex-row">
|
||||
<BriefcaseIcon className="mr-1 h-5" />
|
||||
<span className="font-bold">Work Experience</span>
|
||||
</div>
|
||||
<OfferCard offer={background.experiences[0]} />
|
||||
</>
|
||||
)}
|
||||
{background?.educations?.length > 0 && (
|
||||
<>
|
||||
<div className="mx-8 my-4 flex flex-row">
|
||||
<AcademicCapIcon className="mr-1 h-5" />
|
||||
<span className="font-bold">Education</span>
|
||||
</div>
|
||||
<EducationCard education={background.educations[0]} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileAnalysisProps = Readonly<{
|
||||
analysis?: ProfileAnalysis;
|
||||
isEditable: boolean;
|
||||
profileId: string;
|
||||
}>;
|
||||
|
||||
function ProfileAnalysis({
|
||||
analysis: profileAnalysis,
|
||||
profileId,
|
||||
isEditable,
|
||||
}: ProfileAnalysisProps) {
|
||||
const [analysis, setAnalysis] = useState(profileAnalysis);
|
||||
const generateAnalysisMutation = trpc.useMutation(
|
||||
['offers.analysis.generate'],
|
||||
{
|
||||
onError(error) {
|
||||
console.error(error.message);
|
||||
},
|
||||
onSuccess(data) {
|
||||
if (data) {
|
||||
setAnalysis(data);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (generateAnalysisMutation.isLoading) {
|
||||
return (
|
||||
<div className="col-span-10 pt-4">
|
||||
<Spinner display="block" size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-8 my-4">
|
||||
<OfferAnalysis allAnalysis={analysis} isError={false} isLoading={false} />
|
||||
{isEditable && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={ArrowPathIcon}
|
||||
label="Refresh Analysis"
|
||||
variant="secondary"
|
||||
onClick={() => generateAnalysisMutation.mutate({ profileId })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileDetailsProps = Readonly<{
|
||||
analysis?: ProfileAnalysis;
|
||||
background?: BackgroundDisplayData;
|
||||
isEditable: boolean;
|
||||
isLoading: boolean;
|
||||
offers: Array<OfferDisplayData>;
|
||||
selectedTab: string;
|
||||
profileId: string;
|
||||
selectedTab: ProfileDetailTab;
|
||||
}>;
|
||||
|
||||
export default function ProfileDetails({
|
||||
analysis,
|
||||
background,
|
||||
isLoading,
|
||||
offers,
|
||||
selectedTab,
|
||||
}: ProfileHeaderProps) {
|
||||
profileId,
|
||||
isEditable,
|
||||
}: ProfileDetailsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="col-span-10 pt-4">
|
||||
@ -31,46 +157,20 @@ export default function ProfileDetails({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (selectedTab === 'offers') {
|
||||
if (offers.length !== 0) {
|
||||
return (
|
||||
<>
|
||||
{offers.map((offer) => (
|
||||
<OfferCard key={offer.id} offer={offer} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (selectedTab === ProfileDetailTab.OFFERS) {
|
||||
return <ProfileOffers offers={offers} />;
|
||||
}
|
||||
if (selectedTab === ProfileDetailTab.BACKGROUND) {
|
||||
return <ProfileBackground background={background} />;
|
||||
}
|
||||
if (selectedTab === ProfileDetailTab.ANALYSIS) {
|
||||
return (
|
||||
<div className="mx-8 my-4 flex flex-row">
|
||||
<BriefcaseIcon className="mr-1 h-5" />
|
||||
<span className="font-bold">No offer is attached.</span>
|
||||
</div>
|
||||
<ProfileAnalysis
|
||||
analysis={analysis}
|
||||
isEditable={isEditable}
|
||||
profileId={profileId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (selectedTab === 'background') {
|
||||
return (
|
||||
<>
|
||||
{background?.experiences && background?.experiences.length > 0 && (
|
||||
<>
|
||||
<div className="mx-8 my-4 flex flex-row">
|
||||
<BriefcaseIcon className="mr-1 h-5" />
|
||||
<span className="font-bold">Work Experience</span>
|
||||
</div>
|
||||
<OfferCard offer={background.experiences[0]} />
|
||||
</>
|
||||
)}
|
||||
{background?.educations && background?.educations.length > 0 && (
|
||||
<>
|
||||
<div className="mx-8 my-4 flex flex-row">
|
||||
<AcademicCapIcon className="mr-1 h-5" />
|
||||
<span className="font-bold">Education</span>
|
||||
</div>
|
||||
<EducationCard education={background.educations[0]} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <div>Detail page for {selectedTab}</div>;
|
||||
return null;
|
||||
}
|
||||
|
@ -13,13 +13,16 @@ import type { BackgroundDisplayData } from '~/components/offers/types';
|
||||
|
||||
import { getProfileEditPath } from '~/utils/offers/link';
|
||||
|
||||
import type { ProfileDetailTab } from '../constants';
|
||||
import { profileDetailTabs } from '../constants';
|
||||
|
||||
type ProfileHeaderProps = Readonly<{
|
||||
background?: BackgroundDisplayData;
|
||||
handleDelete: () => void;
|
||||
isEditable: boolean;
|
||||
isLoading: boolean;
|
||||
selectedTab: string;
|
||||
setSelectedTab: (tab: string) => void;
|
||||
selectedTab: ProfileDetailTab;
|
||||
setSelectedTab: (tab: ProfileDetailTab) => void;
|
||||
}>;
|
||||
|
||||
export default function ProfileHeader({
|
||||
@ -139,9 +142,9 @@ export default function ProfileHeader({
|
||||
<BuildingOffice2Icon className="mr-2.5 h-5" />
|
||||
<span className="mr-2 font-bold">Current:</span>
|
||||
<span>
|
||||
{`${experiences[0]?.companyName || ''} ${
|
||||
experiences[0]?.jobLevel || ''
|
||||
} ${experiences[0]?.jobTitle || ''}`}
|
||||
{`${experiences[0].companyName || ''} ${
|
||||
experiences[0].jobLevel || ''
|
||||
} ${experiences[0].jobTitle || ''}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@ -165,20 +168,7 @@ export default function ProfileHeader({
|
||||
<div className="mt-8">
|
||||
<Tabs
|
||||
label="Profile Detail Navigation"
|
||||
tabs={[
|
||||
{
|
||||
label: 'Offers',
|
||||
value: 'offers',
|
||||
},
|
||||
{
|
||||
label: 'Background',
|
||||
value: 'background',
|
||||
},
|
||||
{
|
||||
label: 'Offer Engine Analysis',
|
||||
value: 'offerEngineAnalysis',
|
||||
},
|
||||
]}
|
||||
tabs={profileDetailTabs}
|
||||
value={selectedTab}
|
||||
onChange={(value) => setSelectedTab(value)}
|
||||
/>
|
||||
|
@ -2,6 +2,7 @@ import Error from 'next/error';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ProfileDetailTab } from '~/components/offers/constants';
|
||||
import ProfileComments from '~/components/offers/profile/ProfileComments';
|
||||
import ProfileDetails from '~/components/offers/profile/ProfileDetails';
|
||||
import ProfileHeader from '~/components/offers/profile/ProfileHeader';
|
||||
@ -27,7 +28,9 @@ export default function OfferProfile() {
|
||||
const [background, setBackground] = useState<BackgroundDisplayData>();
|
||||
const [offers, setOffers] = useState<Array<OfferDisplayData>>([]);
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState('offers');
|
||||
const [selectedTab, setSelectedTab] = useState<ProfileDetailTab>(
|
||||
ProfileDetailTab.OFFERS,
|
||||
);
|
||||
const [analysis, setAnalysis] = useState<ProfileAnalysis>();
|
||||
|
||||
const getProfileQuery = trpc.useQuery(
|
||||
@ -163,8 +166,10 @@ export default function OfferProfile() {
|
||||
<ProfileDetails
|
||||
analysis={analysis}
|
||||
background={background}
|
||||
isEditable={isEditable}
|
||||
isLoading={getProfileQuery.isLoading}
|
||||
offers={offers}
|
||||
profileId={offerProfileId as string}
|
||||
selectedTab={selectedTab}
|
||||
/>
|
||||
</div>
|
||||
|
@ -321,18 +321,18 @@ export const offersAnalysisRouter = createRouter()
|
||||
similarOffers,
|
||||
);
|
||||
const overallPercentile =
|
||||
similarOffers.length === 0
|
||||
similarOffers.length <= 1
|
||||
? 100
|
||||
: (100 * overallIndex) / similarOffers.length;
|
||||
: 100 - (100 * overallIndex) / (similarOffers.length - 1);
|
||||
|
||||
const companyIndex = searchOfferPercentile(
|
||||
overallHighestOffer,
|
||||
similarCompanyOffers,
|
||||
);
|
||||
const companyPercentile =
|
||||
similarCompanyOffers.length === 0
|
||||
similarCompanyOffers.length <= 1
|
||||
? 100
|
||||
: (100 * companyIndex) / similarCompanyOffers.length;
|
||||
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
|
||||
|
||||
// FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
|
||||
// e.g. If there only 4 offers, it gives the 2nd and 3rd offer
|
||||
|
Reference in New Issue
Block a user