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

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

* [offers][fix] Fix profile view page

* [offers][feat] Add offers profile edit
This commit is contained in:
Ai Ling
2022-10-21 02:19:29 +08:00
committed by GitHub
parent 0adec461d0
commit 11df1e1f1c
20 changed files with 496 additions and 374 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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