[offers][feat] Add analysis to offers profile page (#416)

This commit is contained in:
Ai Ling
2022-10-23 18:00:36 +08:00
committed by GitHub
parent 7c63e22b3a
commit c0f92584ef
14 changed files with 375 additions and 228 deletions

View File

@ -110,9 +110,30 @@ export const educationFieldOptions = [
]; ];
export enum FieldError { export enum FieldError {
NonNegativeNumber = 'Please fill in a non-negative number 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.', NUMBER = 'Please fill in a number in this field.',
Required = 'Please fill in this field.', REQUIRED = 'Please fill in this field.',
} }
export const OVERALL_TAB = 'Overall'; 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,
},
];

View File

@ -2,11 +2,9 @@ import { useEffect } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui'; import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import OfferPercentileAnalysisText from './OfferPercentileAnalysisText'; import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferProfileCard from './OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../../constants'; import { OVERALL_TAB } from '../constants';
import type { import type {
Analysis, Analysis,
@ -29,10 +27,18 @@ function OfferAnalysisContent({
tab, tab,
}: OfferAnalysisContentProps) { }: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) { 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 ( return (
<p className="m-10"> <p className="m-10">
You are the first to submit an offer for these companies! Check back You are the first to submit an offer for this company, job title and
later when there are more submissions. YOE! Check back later when there are more submissions.
</p> </p>
); );
} }
@ -55,12 +61,17 @@ function OfferAnalysisContent({
} }
type OfferAnalysisProps = Readonly<{ 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 [tab, setTab] = useState(OVERALL_TAB);
const [allAnalysis, setAllAnalysis] = useState<ProfileAnalysis | null>(null);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null); const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null);
useEffect(() => { useEffect(() => {
@ -77,22 +88,6 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
} }
}, [tab, allAnalysis]); }, [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 = [ const tabOptions = [
{ {
label: OVERALL_TAB, label: OVERALL_TAB,
@ -107,18 +102,13 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
return ( return (
analysis && ( analysis && (
<div> <div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900"> {isError && (
Result
</h5>
{getAnalysisResult.isError && (
<p className="m-10 text-center"> <p className="m-10 text-center">
An error occurred while generating profile analysis. An error occurred while generating profile analysis.
</p> </p>
)} )}
{getAnalysisResult.isLoading && ( {isLoading && <Spinner className="m-10" display="block" size="lg" />}
<Spinner className="m-10" display="block" size="lg" /> {!isError && !isLoading && (
)}
{!getAnalysisResult.isError && !getAnalysisResult.isLoading && (
<div> <div>
<Tabs <Tabs
label="Result Navigation" label="Result Navigation"

View File

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

View File

@ -1,10 +1,14 @@
import {
BuildingOffice2Icon,
CalendarDaysIcon,
} from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { HorizontalDivider } from '~/../../../packages/ui/dist'; import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import ProfilePhotoHolder from '../../profile/ProfilePhotoHolder'; import ProfilePhotoHolder from '../profile/ProfilePhotoHolder';
import type { AnalysisOffer } from '~/types/offers'; import type { AnalysisOffer } from '~/types/offers';
@ -27,29 +31,37 @@ export default function OfferProfileCard({
}, },
}: OfferProfileCardProps) { }: OfferProfileCardProps) {
return ( return (
<div className="my-5 block rounded-lg border p-4"> <div className="my-5 block rounded-lg bg-white p-4 px-8 shadow-md">
<div className="grid grid-flow-col grid-cols-12 gap-x-10"> <div className="flex items-center gap-x-5">
<div className="col-span-1"> <div>
<ProfilePhotoHolder size="sm" /> <ProfilePhotoHolder size="sm" />
</div> </div>
<div className="col-span-10"> <div className="col-span-10">
<p className="text-sm font-semibold">{profileName}</p> <p className="font-bold">{profileName}</p>
<p className="text-xs ">Previous company: {previousCompanies[0]}</p> <div className="flex flex-row">
<p className="text-xs ">YOE: {totalYoe} year(s)</p> <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>
</div> </div>
<HorizontalDivider /> <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"> <div className="col-span-1 row-span-3">
<p className="text-sm font-semibold">{title}</p> <p className="font-bold">{title}</p>
<p className="text-xs "> <p>
Company: {company.name}, {location} Company: {company.name}, {location}
</p> </p>
<p className="text-xs ">Level: {level}</p> <p>Level: {level}</p>
</div> </div>
<div className="col-span-1 row-span-3"> <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"> <p className="text-end text-xl">
{jobType === JobType.FULLTIME {jobType === JobType.FULLTIME
? `${convertMoneyToString(income)} / year` ? `${convertMoneyToString(income)} / year`

View File

@ -16,7 +16,7 @@ type OfferProfileSaveProps = Readonly<{
token?: string; token?: string;
}>; }>;
export default function OfferProfileSave({ export default function OffersProfileSave({
profileId, profileId,
token, token,
}: OfferProfileSaveProps) { }: OfferProfileSaveProps) {

View File

@ -6,8 +6,7 @@ import { JobType } from '@prisma/client';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb'; import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OfferAnalysis from '~/components/offers/offersSubmission/analysis/OfferAnalysis'; import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
import OfferProfileSave from '~/components/offers/offersSubmission/OfferProfileSave';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm'; import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm'; import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type { import type {
@ -20,7 +19,12 @@ import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time'; import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { CreateOfferProfileResponse } from '~/types/offers'; import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
import type {
CreateOfferProfileResponse,
ProfileAnalysis,
} from '~/types/offers';
const defaultOfferValues = { const defaultOfferValues = {
comments: '', comments: '',
@ -78,6 +82,7 @@ export default function OffersSubmissionForm({
id: profileId || '', id: profileId || '',
token: token || '', token: token || '',
}); });
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () => const scrollToTop = () =>
@ -88,6 +93,18 @@ export default function OffersSubmissionForm({
}); });
const { handleSubmit, trigger } = formMethods; 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> = [ const formSteps: Array<FormStep> = [
{ {
component: ( component: (
@ -107,14 +124,21 @@ export default function OffersSubmissionForm({
label: 'Background', label: 'Background',
}, },
{ {
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />, component: (
<OfferAnalysis
key={2}
allAnalysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
/>
),
hasNext: true, hasNext: true,
hasPrevious: false, hasPrevious: false,
label: 'Analysis', label: 'Analysis',
}, },
{ {
component: ( component: (
<OfferProfileSave <OffersProfileSave
key={3} key={3}
profileId={createProfileResponse.id || ''} profileId={createProfileResponse.id || ''}
token={createProfileResponse.token} token={createProfileResponse.token}
@ -144,15 +168,6 @@ export default function OffersSubmissionForm({
scrollToTop(); scrollToTop();
}; };
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const mutationpath = const mutationpath =
profileId && token ? 'offers.profile.update' : 'offers.profile.create'; profileId && token ? 'offers.profile.update' : 'offers.profile.create';

View File

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

View File

@ -39,8 +39,8 @@ function YoeSection() {
required={true} required={true}
type="number" type="number"
{...register(`background.totalYoe`, { {...register(`background.totalYoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -52,7 +52,7 @@ function YoeSection() {
label="Specific YOE 1" label="Specific YOE 1"
type="number" type="number"
{...register(`background.specificYoes.0.yoe`, { {...register(`background.specificYoes.0.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -68,7 +68,7 @@ function YoeSection() {
label="Specific YOE 2" label="Specific YOE 2"
type="number" type="number"
{...register(`background.specificYoes.1.yoe`, { {...register(`background.specificYoes.1.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -128,7 +128,7 @@ function FullTimeJobFields() {
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`background.experiences.0.totalCompensation.value`, { {...register(`background.experiences.0.totalCompensation.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -158,7 +158,7 @@ function FullTimeJobFields() {
label="Duration (months)" label="Duration (months)"
type="number" type="number"
{...register(`background.experiences.0.durationInMonths`, { {...register(`background.experiences.0.durationInMonths`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -211,7 +211,7 @@ function InternshipJobFields() {
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`background.experiences.0.monthlySalary.value`, { {...register(`background.experiences.0.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />

View File

@ -72,7 +72,7 @@ function FullTimeOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.offersFullTime.title`, { {...register(`offers.${index}.offersFullTime.title`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
<FormTextInput <FormTextInput
@ -81,7 +81,7 @@ function FullTimeOfferDetailsForm({
placeholder="e.g. Front End" placeholder="e.g. Front End"
required={true} required={true}
{...register(`offers.${index}.offersFullTime.specialization`, { {...register(`offers.${index}.offersFullTime.specialization`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -99,7 +99,7 @@ function FullTimeOfferDetailsForm({
placeholder="e.g. L4, Junior" placeholder="e.g. L4, Junior"
required={true} required={true}
{...register(`offers.${index}.offersFullTime.level`, { {...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -112,7 +112,7 @@ function FullTimeOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.location`, { {...register(`offers.${index}.location`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
<FormMonthYearPicker <FormMonthYearPicker
@ -120,7 +120,7 @@ function FullTimeOfferDetailsForm({
monthRequired={true} monthRequired={true}
yearLabel="" yearLabel=""
{...register(`offers.${index}.monthYearReceived`, { {...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -135,7 +135,7 @@ function FullTimeOfferDetailsForm({
{...register( {...register(
`offers.${index}.offersFullTime.totalCompensation.currency`, `offers.${index}.offersFullTime.totalCompensation.currency`,
{ {
required: FieldError.Required, required: FieldError.REQUIRED,
}, },
)} )}
/> />
@ -153,8 +153,8 @@ function FullTimeOfferDetailsForm({
{...register( {...register(
`offers.${index}.offersFullTime.totalCompensation.value`, `offers.${index}.offersFullTime.totalCompensation.value`,
{ {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
}, },
)} )}
@ -171,7 +171,7 @@ function FullTimeOfferDetailsForm({
{...register( {...register(
`offers.${index}.offersFullTime.baseSalary.currency`, `offers.${index}.offersFullTime.baseSalary.currency`,
{ {
required: FieldError.Required, required: FieldError.REQUIRED,
}, },
)} )}
/> />
@ -185,8 +185,8 @@ function FullTimeOfferDetailsForm({
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.baseSalary.value`, { {...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -198,7 +198,7 @@ function FullTimeOfferDetailsForm({
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.bonus.currency`, { {...register(`offers.${index}.offersFullTime.bonus.currency`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
} }
@ -211,8 +211,8 @@ function FullTimeOfferDetailsForm({
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.bonus.value`, { {...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -226,7 +226,7 @@ function FullTimeOfferDetailsForm({
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.stocks.currency`, { {...register(`offers.${index}.offersFullTime.stocks.currency`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
} }
@ -239,8 +239,8 @@ function FullTimeOfferDetailsForm({
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.stocks.value`, { {...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -300,7 +300,7 @@ function InternshipOfferDetailsForm({
required={true} required={true}
{...register(`offers.${index}.offersIntern.title`, { {...register(`offers.${index}.offersIntern.title`, {
minLength: 1, minLength: 1,
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
<FormTextInput <FormTextInput
@ -310,7 +310,7 @@ function InternshipOfferDetailsForm({
required={true} required={true}
{...register(`offers.${index}.offersIntern.specialization`, { {...register(`offers.${index}.offersIntern.specialization`, {
minLength: 1, minLength: 1,
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -330,7 +330,7 @@ function InternshipOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.location`, { {...register(`offers.${index}.location`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -343,7 +343,7 @@ function InternshipOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.offersIntern.internshipCycle`, { {...register(`offers.${index}.offersIntern.internshipCycle`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
<FormSelect <FormSelect
@ -354,7 +354,7 @@ function InternshipOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.offersIntern.startYear`, { {...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -365,7 +365,7 @@ function InternshipOfferDetailsForm({
monthRequired={true} monthRequired={true}
yearLabel="" yearLabel=""
{...register(`offers.${index}.monthYearReceived`, { {...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -380,7 +380,7 @@ function InternshipOfferDetailsForm({
{...register( {...register(
`offers.${index}.offersIntern.monthlySalary.currency`, `offers.${index}.offersIntern.monthlySalary.currency`,
{ {
required: FieldError.Required, required: FieldError.REQUIRED,
}, },
)} )}
/> />
@ -396,8 +396,8 @@ function InternshipOfferDetailsForm({
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersIntern.monthlySalary.value`, { {...register(`offers.${index}.offersIntern.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />

View File

@ -58,52 +58,64 @@ export default function OfferCard({
} }
function BottomSection() { function BottomSection() {
return ( if (
<div className="px-8"> !totalCompensation &&
<div className="flex flex-col py-2"> !monthlySalary &&
<div className="flex flex-row"> !negotiationStrategy &&
<CurrencyDollarIcon className="mr-1 h-5" /> !otherComment
<p> ) {
{totalCompensation return null;
? `TC: ${totalCompensation}` }
: `Monthly Salary: ${monthlySalary}`}
</p>
</div>
{totalCompensation && ( return (
<div className="ml-6 flex flex-row font-light text-gray-400"> <>
<p> <HorizontalDivider />
Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '} <div className="px-8">
{bonus} <div className="flex flex-col py-2">
</p> {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>
)} )}
</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 ( return (
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md"> <div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md">
<UpperSection /> <UpperSection />
<HorizontalDivider />
<BottomSection /> <BottomSection />
</div> </div>
); );

View File

@ -1,5 +1,10 @@
import { AcademicCapIcon, BriefcaseIcon } from '@heroicons/react/24/outline'; import { useState } from 'react';
import { Spinner } from '@tih/ui'; import {
AcademicCapIcon,
ArrowPathIcon,
BriefcaseIcon,
} from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
import EducationCard from '~/components/offers/profile/EducationCard'; import EducationCard from '~/components/offers/profile/EducationCard';
import OfferCard from '~/components/offers/profile/OfferCard'; import OfferCard from '~/components/offers/profile/OfferCard';
@ -8,22 +13,143 @@ import type {
OfferDisplayData, OfferDisplayData,
} from '~/components/offers/types'; } 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; analysis?: ProfileAnalysis;
background?: BackgroundDisplayData; background?: BackgroundDisplayData;
isEditable: boolean;
isLoading: boolean; isLoading: boolean;
offers: Array<OfferDisplayData>; offers: Array<OfferDisplayData>;
selectedTab: string; profileId: string;
selectedTab: ProfileDetailTab;
}>; }>;
export default function ProfileDetails({ export default function ProfileDetails({
analysis,
background, background,
isLoading, isLoading,
offers, offers,
selectedTab, selectedTab,
}: ProfileHeaderProps) { profileId,
isEditable,
}: ProfileDetailsProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
@ -31,46 +157,20 @@ export default function ProfileDetails({
</div> </div>
); );
} }
if (selectedTab === 'offers') { if (selectedTab === ProfileDetailTab.OFFERS) {
if (offers.length !== 0) { return <ProfileOffers offers={offers} />;
return ( }
<> if (selectedTab === ProfileDetailTab.BACKGROUND) {
{offers.map((offer) => ( return <ProfileBackground background={background} />;
<OfferCard key={offer.id} offer={offer} /> }
))} if (selectedTab === ProfileDetailTab.ANALYSIS) {
</>
);
}
return ( return (
<div className="mx-8 my-4 flex flex-row"> <ProfileAnalysis
<BriefcaseIcon className="mr-1 h-5" /> analysis={analysis}
<span className="font-bold">No offer is attached.</span> isEditable={isEditable}
</div> profileId={profileId}
/>
); );
} }
if (selectedTab === 'background') { return null;
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>;
} }

View File

@ -13,13 +13,16 @@ import type { BackgroundDisplayData } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link'; import { getProfileEditPath } from '~/utils/offers/link';
import type { ProfileDetailTab } from '../constants';
import { profileDetailTabs } from '../constants';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
background?: BackgroundDisplayData; background?: BackgroundDisplayData;
handleDelete: () => void; handleDelete: () => void;
isEditable: boolean; isEditable: boolean;
isLoading: boolean; isLoading: boolean;
selectedTab: string; selectedTab: ProfileDetailTab;
setSelectedTab: (tab: string) => void; setSelectedTab: (tab: ProfileDetailTab) => void;
}>; }>;
export default function ProfileHeader({ export default function ProfileHeader({
@ -139,9 +142,9 @@ export default function ProfileHeader({
<BuildingOffice2Icon className="mr-2.5 h-5" /> <BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span> <span className="mr-2 font-bold">Current:</span>
<span> <span>
{`${experiences[0]?.companyName || ''} ${ {`${experiences[0].companyName || ''} ${
experiences[0]?.jobLevel || '' experiences[0].jobLevel || ''
} ${experiences[0]?.jobTitle || ''}`} } ${experiences[0].jobTitle || ''}`}
</span> </span>
</div> </div>
)} )}
@ -165,20 +168,7 @@ export default function ProfileHeader({
<div className="mt-8"> <div className="mt-8">
<Tabs <Tabs
label="Profile Detail Navigation" label="Profile Detail Navigation"
tabs={[ tabs={profileDetailTabs}
{
label: 'Offers',
value: 'offers',
},
{
label: 'Background',
value: 'background',
},
{
label: 'Offer Engine Analysis',
value: 'offerEngineAnalysis',
},
]}
value={selectedTab} value={selectedTab}
onChange={(value) => setSelectedTab(value)} onChange={(value) => setSelectedTab(value)}
/> />

View File

@ -2,6 +2,7 @@ import Error from 'next/error';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { ProfileDetailTab } from '~/components/offers/constants';
import ProfileComments from '~/components/offers/profile/ProfileComments'; import ProfileComments from '~/components/offers/profile/ProfileComments';
import ProfileDetails from '~/components/offers/profile/ProfileDetails'; import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader'; import ProfileHeader from '~/components/offers/profile/ProfileHeader';
@ -27,7 +28,9 @@ export default function OfferProfile() {
const [background, setBackground] = useState<BackgroundDisplayData>(); const [background, setBackground] = useState<BackgroundDisplayData>();
const [offers, setOffers] = useState<Array<OfferDisplayData>>([]); const [offers, setOffers] = useState<Array<OfferDisplayData>>([]);
const [selectedTab, setSelectedTab] = useState('offers'); const [selectedTab, setSelectedTab] = useState<ProfileDetailTab>(
ProfileDetailTab.OFFERS,
);
const [analysis, setAnalysis] = useState<ProfileAnalysis>(); const [analysis, setAnalysis] = useState<ProfileAnalysis>();
const getProfileQuery = trpc.useQuery( const getProfileQuery = trpc.useQuery(
@ -163,8 +166,10 @@ export default function OfferProfile() {
<ProfileDetails <ProfileDetails
analysis={analysis} analysis={analysis}
background={background} background={background}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading} isLoading={getProfileQuery.isLoading}
offers={offers} offers={offers}
profileId={offerProfileId as string}
selectedTab={selectedTab} selectedTab={selectedTab}
/> />
</div> </div>

View File

@ -321,18 +321,18 @@ export const offersAnalysisRouter = createRouter()
similarOffers, similarOffers,
); );
const overallPercentile = const overallPercentile =
similarOffers.length === 0 similarOffers.length <= 1
? 100 ? 100
: (100 * overallIndex) / similarOffers.length; : 100 - (100 * overallIndex) / (similarOffers.length - 1);
const companyIndex = searchOfferPercentile( const companyIndex = searchOfferPercentile(
overallHighestOffer, overallHighestOffer,
similarCompanyOffers, similarCompanyOffers,
); );
const companyPercentile = const companyPercentile =
similarCompanyOffers.length === 0 similarCompanyOffers.length <= 1
? 100 ? 100
: (100 * companyIndex) / similarCompanyOffers.length; : 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE // FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
// e.g. If there only 4 offers, it gives the 2nd and 3rd offer // e.g. If there only 4 offers, it gives the 2nd and 3rd offer