[offers][feat] Integrate offers profile edit ()

* [offers][fix] Fix offer analysis and save

* [offers][fix] Fix profile view page

* [offers][feat] Add offers profile edit
pull/406/head
Ai Ling 2 years ago committed by GitHub
parent 0adec461d0
commit 11df1e1f1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,20 +5,20 @@ export const emptyOption = '----';
// TODO: use enums // TODO: use enums
export const titleOptions = [ export const titleOptions = [
{ {
label: 'Software engineer', label: 'Software Engineer',
value: 'Software engineer', value: 'Software Engineer',
}, },
{ {
label: 'Frontend engineer', label: 'Frontend Engineer',
value: 'Frontend engineer', value: 'Frontend Engineer',
}, },
{ {
label: 'Backend engineer', label: 'Backend Engineer',
value: 'Backend engineer', value: 'Backend Engineer',
}, },
{ {
label: 'Full-stack engineer', label: 'Full-stack Engineer',
value: 'Full-stack engineer', value: 'Full-stack Engineer',
}, },
]; ];
@ -95,10 +95,18 @@ export const educationFieldOptions = [
label: 'Information Security', label: 'Information Security',
value: 'Information Security', value: 'Information Security',
}, },
{
label: 'Information Systems',
value: 'Information Systems',
},
{ {
label: 'Business Analytics', label: 'Business Analytics',
value: 'Business Analytics', value: 'Business Analytics',
}, },
{
label: 'Data Science and Analytics',
value: 'Data Science and Analytics',
},
]; ];
export enum FieldError { export enum FieldError {

@ -1,13 +1,30 @@
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { setTimeout } from 'timers'; import { setTimeout } from 'timers';
import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid'; import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline'; import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui'; import { Button, TextInput } from '@tih/ui';
export default function OfferProfileSave() { import {
copyProfileLink,
getProfileLink,
getProfilePath,
} from '~/utils/offers/link';
type OfferProfileSaveProps = Readonly<{
profileId: string;
token?: string;
}>;
export default function OfferProfileSave({
profileId,
token,
}: OfferProfileSaveProps) {
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const [isSaving, setSaving] = useState(false); const [isSaving, setSaving] = useState(false);
const [isSaved, setSaved] = useState(false); const [isSaved, setSaved] = useState(false);
const router = useRouter();
const saveProfile = () => { const saveProfile = () => {
setSaving(true); setSaving(true);
setTimeout(() => { setTimeout(() => {
@ -27,13 +44,13 @@ export default function OfferProfileSave() {
To keep you offer profile strictly anonymous, only people who have the To keep you offer profile strictly anonymous, only people who have the
link below can edit it. link below can edit it.
</p> </p>
<div className="mb-20 grid grid-cols-12 gap-4"> <div className="mb-5 grid grid-cols-12 gap-4">
<div className="col-span-11"> <div className="col-span-11">
<TextInput <TextInput
disabled={true} disabled={true}
isLabelHidden={true} isLabelHidden={true}
label="Edit link" label="Edit link"
value="link.myprofile-auto-generate..." value={getProfileLink(profileId, token)}
/> />
</div> </div>
<Button <Button
@ -41,10 +58,12 @@ export default function OfferProfileSave() {
isLabelHidden={true} isLabelHidden={true}
label="Copy" label="Copy"
variant="primary" variant="primary"
onClick={() => setLinkCopied(true)} onClick={() => {
copyProfileLink(profileId, token), setLinkCopied(true);
}}
/> />
</div> </div>
<div className="mb-5"> <div className="mb-20">
{linkCopied && ( {linkCopied && (
<p className="text-purple-700">Link copied to clipboard!</p> <p className="text-purple-700">Link copied to clipboard!</p>
)} )}
@ -60,13 +79,18 @@ export default function OfferProfileSave() {
disabled={isSaved} disabled={isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon} icon={isSaved ? CheckIcon : BookmarkSquareIcon}
isLoading={isSaving} isLoading={isSaving}
label="Save to user profile" label={isSaved ? 'Saved to user profile' : 'Save to user profile'}
variant="primary" variant="primary"
onClick={saveProfile} onClick={saveProfile}
/> />
</div> </div>
<div className="mb-10"> <div className="mb-10">
<Button icon={EyeIcon} label="View your profile" variant="special" /> <Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div> </div>
</div> </div>
</div> </div>

@ -0,0 +1,243 @@
import { useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
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 BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type {
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
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: '',
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
month: getCurrentMonth() as Month,
year: getCurrentYear(),
},
negotiationStrategy: '',
};
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FullTime,
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
specificYoes: [],
totalYoe: 0,
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
type Props = Readonly<{
initialOfferProfileValues?: OffersProfileFormData;
profileId?: string;
token?: string;
}>;
export default function OffersSubmissionForm({
initialOfferProfileValues = defaultOfferProfileValues,
profileId,
token,
}: Props) {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>({
id: profileId || '',
token: token || '',
});
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: initialOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />,
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: (
<OfferProfileSave
key={3}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
/>
),
hasNext: false,
hasPrevious: false,
label: 'Save',
},
];
const formStepsLabels = formSteps.map((step) => step.label);
const nextStep = async (currStep: number) => {
if (currStep === 0) {
const result = await trigger('offers');
if (!result) {
return;
}
}
setFormStep(formStep + 1);
scrollToTop();
};
const previousStep = () => {
setFormStep(formStep - 1);
scrollToTop();
};
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const mutationpath =
profileId && token ? 'offers.profile.update' : 'offers.profile.create';
const createOrUpdateMutation = trpc.useMutation([mutationpath], {
onError(error) {
console.error(error.message);
},
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
}
data = removeInvalidMoneyData(data);
const background = cleanObject(data.background);
background.specificYoes = data.background.specificYoes.filter(
(specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
);
if (Object.entries(background.experiences[0]).length === 1) {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,
offer.monthYearReceived.month - 1, // Convert month to monthIndex
),
}));
if (profileId && token) {
createOrUpdateMutation.mutate({
background,
id: profileId,
offers,
token,
});
} else {
createOrUpdateMutation.mutate({ background, offers });
}
};
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
}

@ -1,13 +1,12 @@
import Error from 'next/error';
import { useEffect } from 'react'; 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 { trpc } from '~/utils/trpc';
import OfferPercentileAnalysis from '../analysis/OfferPercentileAnalysis'; import OfferPercentileAnalysis from './OfferPercentileAnalysis';
import OfferProfileCard from '../analysis/OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../constants'; import { OVERALL_TAB } from '../../constants';
import type { import type {
Analysis, Analysis,
@ -105,34 +104,32 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
]; ];
return ( return (
<> analysis && (
{getAnalysisResult.isError && ( <div>
<Error <h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
statusCode={404} Result
title="An error occurred while generating profile analysis." </h5>
/> {getAnalysisResult.isError && (
)} <p className="m-10 text-center">
{!getAnalysisResult.isError && analysis && ( An error occurred while generating profile analysis.
<div> </p>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900"> )}
Result {getAnalysisResult.isLoading && (
</h5> <Spinner className="m-10" display="block" size="lg" />
{getAnalysisResult.isLoading ? ( )}
<Spinner className="m-10" display="block" size="lg" /> {!getAnalysisResult.isError && !getAnalysisResult.isLoading && (
) : ( <div>
<div> <Tabs
<Tabs label="Result Navigation"
label="Result Navigation" tabs={tabOptions}
tabs={tabOptions} value={tab}
value={tab} onChange={setTab}
onChange={setTab} />
/> <HorizontalDivider className="mb-5" />
<HorizontalDivider className="mb-5" /> <OfferAnalysisContent analysis={analysis} tab={tab} />
<OfferAnalysisContent analysis={analysis} tab={tab} /> </div>
</div> )}
)} </div>
</div> )
)}
</>
); );
} }

@ -3,7 +3,7 @@ import { UserCircleIcon } from '@heroicons/react/24/outline';
import { HorizontalDivider } from '~/../../../packages/ui/dist'; import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import { JobType } from '../types'; import { JobType } from '../../types';
import type { AnalysisOffer } from '~/types/offers'; import type { AnalysisOffer } from '~/types/offers';

@ -15,9 +15,9 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
import FormRadioList from '../forms/FormRadioList'; import FormRadioList from '../../forms/FormRadioList';
import FormSelect from '../forms/FormSelect'; import FormSelect from '../../forms/FormSelect';
import FormTextInput from '../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
function YoeSection() { function YoeSection() {
const { register, formState } = useFormContext<{ const { register, formState } = useFormContext<{

@ -16,8 +16,7 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { import {
defaultFullTimeOfferValues, defaultFullTimeOfferValues,
defaultInternshipOfferValues, defaultInternshipOfferValues,
} from '~/pages/offers/submit'; } from '../OffersSubmissionForm';
import { import {
emptyOption, emptyOption,
FieldError, FieldError,
@ -25,15 +24,15 @@ import {
locationOptions, locationOptions,
titleOptions, titleOptions,
yearOptions, yearOptions,
} from '../constants'; } from '../../constants';
import FormMonthYearPicker from '../forms/FormMonthYearPicker'; import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormSelect from '../forms/FormSelect'; import FormSelect from '../../forms/FormSelect';
import FormTextArea from '../forms/FormTextArea'; import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
import type { OfferFormData } from '../types'; import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../types'; import { JobTypeLabel } from '../../types';
import { JobType } from '../types'; import { JobType } from '../../types';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{ type FullTimeOfferDetailsFormProps = Readonly<{
index: number; index: number;

@ -5,6 +5,7 @@ import { Button, HorizontalDivider, Spinner, TextArea } from '@tih/ui';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard'; import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import { copyProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { OffersDiscussion, Reply } from '~/types/offers'; import type { OffersDiscussion, Reply } from '~/types/offers';
@ -84,19 +85,6 @@ export default function ProfileComments({
} }
} }
function handleCopyEditLink() {
// TODO: Add notification
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}?token=${token}`,
);
}
function handleCopyPublicLink() {
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}`,
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
@ -116,7 +104,7 @@ export default function ProfileComments({
label="Copy profile edit link" label="Copy profile edit link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={handleCopyEditLink} onClick={() => copyProfileLink(profileId, token)}
/> />
)} )}
<Button <Button
@ -127,7 +115,7 @@ export default function ProfileComments({
label="Copy public link" label="Copy public link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={handleCopyPublicLink} onClick={() => copyProfileLink(profileId)}
/> />
</div> </div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2> <h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>

@ -27,10 +27,10 @@ export default function ProfileDetails({
); );
} }
if (selectedTab === 'offers') { if (selectedTab === 'offers') {
if (offers && offers.length !== 0) { if (offers.length !== 0) {
return ( return (
<> <>
{[...offers].map((offer) => ( {offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} /> <OfferCard key={offer.id} offer={offer} />
))} ))}
</> </>

@ -1,3 +1,4 @@
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
BookmarkSquareIcon, BookmarkSquareIcon,
@ -11,6 +12,8 @@ import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder'; import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundCard } from '~/components/offers/types'; import type { BackgroundCard } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
background?: BackgroundCard; background?: BackgroundCard;
handleDelete: () => void; handleDelete: () => void;
@ -29,6 +32,12 @@ export default function ProfileHeader({
setSelectedTab, setSelectedTab,
}: ProfileHeaderProps) { }: ProfileHeaderProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const router = useRouter();
const { offerProfileId = '', token = '' } = router.query;
const handleEditClick = () => {
router.push(getProfileEditPath(offerProfileId as string, token as string));
};
function renderActionList() { function renderActionList() {
return ( return (
@ -48,6 +57,7 @@ export default function ProfileHeader({
label="Edit" label="Edit"
size="md" size="md"
variant="tertiary" variant="tertiary"
onClick={handleEditClick}
/> />
<Button <Button
disabled={isLoading} disabled={isLoading}
@ -119,9 +129,11 @@ export default function ProfileHeader({
<div className="flex flex-row"> <div className="flex flex-row">
<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>{`${background?.experiences[0].companyName ?? '-'} ${ <span>
background?.experiences[0].jobLevel {`${background?.experiences[0]?.companyName ?? '-'} ${
} ${background?.experiences[0].jobTitle}`}</span> background?.experiences[0]?.jobLevel || ''
} ${background?.experiences[0]?.jobTitle || ''}`}
</span>
</div> </div>
<div className="flex flex-row"> <div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" /> <CalendarDaysIcon className="mr-2.5 h-5" />

@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { convertCurrencyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import type { DashboardOffer } from '~/types/offers'; import type { DashboardOffer } from '~/types/offers';
@ -21,7 +21,7 @@ export default function OfferTableRow({
</th> </th>
<td className="py-4 px-6">{title}</td> <td className="py-4 px-6">{title}</td>
<td className="py-4 px-6">{totalYoe}</td> <td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertCurrencyToString(income)}</td> <td className="py-4 px-6">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td> <td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="space-x-4 py-4 px-6"> <td className="space-x-4 py-4 px-6">
<Link <Link

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Select } from '@tih/ui'; import { Select } from '@tih/ui';
import { titleOptions } from '~/components/offers/constants';
import OffersTitle from '~/components/offers/OffersTitle'; import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable'; import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -20,24 +21,7 @@ export default function OffersHomePage() {
<Select <Select
isLabelHidden={true} isLabelHidden={true}
label="Select a job title" label="Select a job title"
options={[ options={titleOptions}
{
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
]}
value={jobTitleFilter} value={jobTitleFilter}
onChange={setjobTitleFilter} onChange={setjobTitleFilter}
/> />

@ -7,7 +7,8 @@ import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader'; import ProfileHeader from '~/components/offers/profile/ProfileHeader';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types'; import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
import { convertCurrencyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -38,7 +39,7 @@ export default function OfferProfile() {
} }
// If the profile is not editable with a wrong token, redirect to the profile page // If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') { if (!data?.isEditable && token !== '') {
router.push(`/offers/profile/${offerProfileId}`); router.push(getProfilePath(offerProfileId as string));
} }
setIsEditable(data?.isEditable ?? false); setIsEditable(data?.isEditable ?? false);
@ -48,10 +49,8 @@ export default function OfferProfile() {
? data?.offers.map((res: ProfileOffer) => { ? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) { if (res.offersFullTime) {
const filteredOffer: OfferEntity = { const filteredOffer: OfferEntity = {
base: convertCurrencyToString( base: convertMoneyToString(res.offersFullTime.baseSalary),
res.offersFullTime.baseSalary, bonus: convertMoneyToString(res.offersFullTime.bonus),
),
bonus: convertCurrencyToString(res.offersFullTime.bonus),
companyName: res.company.name, companyName: res.company.name,
id: res.offersFullTime.id, id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level, jobLevel: res.offersFullTime.level,
@ -60,12 +59,11 @@ export default function OfferProfile() {
negotiationStrategy: res.negotiationStrategy || '', negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '', otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived), receivedMonth: formatDate(res.monthYearReceived),
stocks: convertCurrencyToString(res.offersFullTime.stocks), stocks: convertMoneyToString(res.offersFullTime.stocks),
totalCompensation: convertCurrencyToString( totalCompensation: convertMoneyToString(
res.offersFullTime.totalCompensation, res.offersFullTime.totalCompensation,
), ),
}; };
return filteredOffer; return filteredOffer;
} }
const filteredOffer: OfferEntity = { const filteredOffer: OfferEntity = {
@ -73,7 +71,7 @@ export default function OfferProfile() {
id: res.offersIntern!.id, id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title, jobTitle: res.offersIntern!.title,
location: res.location, location: res.location,
monthlySalary: convertCurrencyToString( monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary, res.offersIntern!.monthlySalary,
), ),
negotiationStrategy: res.negotiationStrategy || '', negotiationStrategy: res.negotiationStrategy || '',
@ -88,46 +86,29 @@ export default function OfferProfile() {
if (data?.background) { if (data?.background) {
const transformedBackground = { const transformedBackground = {
educations: [ educations: data.background.educations.map((education) => ({
{ endDate: education.endDate ? formatDate(education.endDate) : '-',
endDate: data?.background.educations[0].endDate field: education.field || '-',
? formatDate(data.background.educations[0].endDate) school: education.school || '-',
: '-', startDate: education.startDate
field: data.background.educations[0].field || '-', ? formatDate(education.startDate)
school: data.background.educations[0].school || '-', : '-',
startDate: data.background.educations[0].startDate type: education.type || '-',
? formatDate(data.background.educations[0].startDate) })),
: '-', experiences: data.background.experiences.map((experience) => ({
type: data.background.educations[0].type || '-', companyName: experience.company?.name ?? '-',
}, duration: String(experience.durationInMonths) ?? '-',
], jobLevel: experience.level ?? '',
experiences: [ jobTitle: experience.title ?? '-',
data.background.experiences && monthlySalary: experience.monthlySalary
data.background.experiences.length > 0 ? convertMoneyToString(experience.monthlySalary)
? { : '-',
companyName: totalCompensation: experience.totalCompensation
data.background.experiences[0].company?.name ?? '-', ? convertMoneyToString(experience.totalCompensation)
duration: : '-',
String(data.background.experiences[0].durationInMonths) ?? })),
'-',
jobLevel: data.background.experiences[0].level ?? '',
jobTitle: data.background.experiences[0].title ?? '-',
monthlySalary: data.background.experiences[0].monthlySalary
? convertCurrencyToString(
data.background.experiences[0].monthlySalary,
)
: '-',
totalCompensation: data.background.experiences[0]
.totalCompensation
? convertCurrencyToString(
data.background.experiences[0].totalCompensation,
)
: '-',
}
: {},
],
profileName: data.profileName, profileName: data.profileName,
specificYoes: data.background.specificYoes ?? [], specificYoes: data.background.specificYoes,
totalYoe: String(data.background.totalYoe) || '-', totalYoe: String(data.background.totalYoe) || '-',
}; };
setBackground(transformedBackground); setBackground(transformedBackground);

@ -0,0 +1,79 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
import type { OffersProfileFormData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import { Spinner } from '~/../../../packages/ui/dist';
import { getProfilePath } from '~/utils/offers/link';
import { convertToMonthYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
export default function OffersEditPage() {
const [initialData, setInitialData] = useState<OffersProfileFormData>();
const router = useRouter();
const { offerProfileId, token = '' } = router.query;
const getProfileResult = trpc.useQuery(
[
'offers.profile.listOne',
{ profileId: offerProfileId as string, token: token as string },
],
{
onError(error) {
console.error(error.message);
},
onSuccess(data) {
const { educations, experiences, specificYoes, totalYoe } =
data.background!;
setInitialData({
background: {
educations,
experiences:
experiences.length === 0
? [{ jobType: JobType.FullTime }]
: experiences,
specificYoes,
totalYoe,
},
offers: data.offers.map((offer) => ({
comments: offer.comments,
companyId: offer.company.id,
id: offer.id,
jobType: offer.jobType,
location: offer.location,
monthYearReceived: convertToMonthYear(offer.monthYearReceived),
negotiationStrategy: offer.negotiationStrategy,
offersFullTime: offer.offersFullTime,
offersIntern: offer.offersIntern,
})),
});
},
},
);
const profile = getProfileResult.data;
if (profile && !profile.isEditable) {
router.push(getProfilePath(profile.id));
}
return (
<>
{getProfileResult.isLoading && (
<div className="flex w-full justify-center">
<Spinner className="m-10" display="block" size="lg" />
</div>
)}
{!getProfileResult.isLoading && (
<OffersSubmissionForm
initialOfferProfileValues={initialData}
profileId={profile?.id}
token={profile?.editToken || undefined}
/>
)}
</>
);
}

@ -1,215 +1,5 @@
import { useRef, useState } from 'react'; import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
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 {
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
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: '',
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
month: getCurrentMonth() as Month,
year: getCurrentYear(),
},
negotiationStrategy: '',
};
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FullTime,
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
specificYoes: [],
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
export default function OffersSubmissionPage() { export default function OffersSubmissionPage() {
const [formStep, setFormStep] = useState(0); return <OffersSubmissionForm />;
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>();
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: defaultOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: (
<OfferAnalysis key={2} profileId={createProfileResponse?.id} />
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: <OfferProfileSave key={3} />,
hasNext: false,
hasPrevious: false,
label: 'Save',
},
];
const formStepsLabels = formSteps.map((step) => step.label);
const nextStep = async (currStep: number) => {
if (currStep === 0) {
const result = await trigger('offers');
if (!result) {
return;
}
}
setFormStep(formStep + 1);
scrollToTop();
};
const previousStep = () => {
setFormStep(formStep - 1);
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(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
}
data = removeInvalidMoneyData(data);
const background = cleanObject(data.background);
background.specificYoes = data.background.specificYoes.filter(
(specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
);
if (Object.entries(background.experiences[0]).length === 1) {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,
offer.monthYearReceived.month,
),
}));
const postData = { background, offers };
createMutation.mutate(postData);
};
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
} }

@ -216,7 +216,7 @@ export const offersAnalysisRouter = createRouter()
// TODO: Shift yoe out of background to make it mandatory // TODO: Shift yoe out of background to make it mandatory
if ( if (
!overallHighestOffer.profile.background || !overallHighestOffer.profile.background ||
!overallHighestOffer.profile.background.totalYoe overallHighestOffer.profile.background.totalYoe === undefined
) { ) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',

@ -1,6 +1,6 @@
import type { Money } from '~/components/offers/types'; import type { Money } from '~/components/offers/types';
export function convertCurrencyToString({ currency, value }: Money) { export function convertMoneyToString({ currency, value }: Money) {
if (!value) { if (!value) {
return '-'; return '-';
} }

@ -0,0 +1,19 @@
export function getProfileLink(profileId: string, token?: string) {
return `${window.location.origin}${getProfilePath(profileId, token)}`;
}
export function copyProfileLink(profileId: string, token?: string) {
// TODO: Add notification
navigator.clipboard.writeText(getProfileLink(profileId, token));
}
export function getProfilePath(profileId: string, token?: string) {
if (token) {
return `/offers/profile/${profileId}?token=${token}`;
}
return `/offers/profile/${profileId}`;
}
export function getProfileEditPath(profileId: string, token: string) {
return `/offers/profile/edit/${profileId}?token=${token}`;
}

@ -32,22 +32,20 @@ export function timeSinceNow(date: Date | number | string) {
export function formatDate(value: Date | number | string) { export function formatDate(value: Date | number | string) {
const date = new Date(value); const date = new Date(value);
// Const day = date.toLocaleString('default', { day: '2-digit' });
const month = date.toLocaleString('default', { month: 'short' }); const month = date.toLocaleString('default', { month: 'short' });
const year = date.toLocaleString('default', { year: 'numeric' }); const year = date.toLocaleString('default', { year: 'numeric' });
return `${month} ${year}`; return `${month} ${year}`;
} }
export function formatMonthYear({ month, year }: MonthYear) {
const monthString = month < 10 ? month.toString() : `0${month}`;
const yearString = year.toString();
return `${monthString}/${yearString}`;
}
export function getCurrentMonth() { export function getCurrentMonth() {
return getMonth(Date.now()); // `getMonth` returns a zero-based month index
return getMonth(Date.now()) + 1;
} }
export function getCurrentYear() { export function getCurrentYear() {
return getYear(Date.now()); return getYear(Date.now());
} }
export function convertToMonthYear(date: Date) {
return { month: date.getMonth() + 1, year: date.getFullYear() } as MonthYear;
}

Loading…
Cancel
Save