mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-29 05:02:52 +08:00
[offers][feat] Integrate offers create API and fix form UI (#358)
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { HorizontalDivider, Pagination, Select, Tabs } from '@tih/ui';
|
||||
|
||||
import CurrencySelector from '~/components/offers/util/currency/CurrencySelector';
|
||||
import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
|
||||
|
||||
type TableRow = {
|
||||
company: string;
|
||||
|
@ -29,24 +29,24 @@ export const titleOptions = [
|
||||
export const companyOptions = [
|
||||
emptyOption,
|
||||
{
|
||||
label: 'Bytedance',
|
||||
value: 'id-abc123',
|
||||
label: 'Amazon',
|
||||
value: 'cl93patjt0000txewdi601mub',
|
||||
},
|
||||
{
|
||||
label: 'Microsoft',
|
||||
value: 'cl93patjt0001txewkglfjsro',
|
||||
},
|
||||
{
|
||||
label: 'Apple',
|
||||
value: 'cl93patjt0002txewf3ug54m8',
|
||||
},
|
||||
{
|
||||
label: 'Google',
|
||||
value: 'id-abc567',
|
||||
value: 'cl93patjt0003txewyiaky7xx',
|
||||
},
|
||||
{
|
||||
label: 'Meta',
|
||||
value: 'id-abc456',
|
||||
},
|
||||
{
|
||||
label: 'Shopee',
|
||||
value: 'id-abc345',
|
||||
},
|
||||
{
|
||||
label: 'Tik Tok',
|
||||
value: 'id-abc678',
|
||||
value: 'cl93patjt0004txew88wkcqpu',
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Collapsible, RadioList } from '@tih/ui';
|
||||
|
||||
import FormRadioList from './FormRadioList';
|
||||
import FormSelect from './FormSelect';
|
||||
import FormTextInput from './FormTextInput';
|
||||
import FormRadioList from './components/FormRadioList';
|
||||
import FormSelect from './components/FormSelect';
|
||||
import FormTextInput from './components/FormTextInput';
|
||||
import {
|
||||
companyOptions,
|
||||
educationFieldOptions,
|
||||
@ -12,7 +12,7 @@ import {
|
||||
titleOptions,
|
||||
} from '../constants';
|
||||
import { JobType } from '../types';
|
||||
import { CURRENCY_OPTIONS } from '../util/currency/CurrencyEnum';
|
||||
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
|
||||
|
||||
function YoeSection() {
|
||||
const { register } = useFormContext();
|
||||
@ -28,7 +28,9 @@ function YoeSection() {
|
||||
label="Total YOE"
|
||||
placeholder="0"
|
||||
type="number"
|
||||
{...register(`background.totalYoe`)}
|
||||
{...register(`background.totalYoe`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 space-x-3">
|
||||
@ -37,7 +39,9 @@ function YoeSection() {
|
||||
<FormTextInput
|
||||
label="Specific YOE 1"
|
||||
type="number"
|
||||
{...register(`background.specificYoes.0.yoe`)}
|
||||
{...register(`background.specificYoes.0.yoe`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Specific Domain 1"
|
||||
@ -49,7 +53,9 @@ function YoeSection() {
|
||||
<FormTextInput
|
||||
label="Specific YOE 2"
|
||||
type="number"
|
||||
{...register(`background.specificYoes.1.yoe`)}
|
||||
{...register(`background.specificYoes.1.yoe`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Specific Domain 2"
|
||||
@ -73,13 +79,13 @@ function FullTimeJobFields() {
|
||||
display="block"
|
||||
label="Title"
|
||||
options={titleOptions}
|
||||
{...register(`background.experience.title`)}
|
||||
{...register(`background.experiences.0.title`)}
|
||||
/>
|
||||
<FormSelect
|
||||
display="block"
|
||||
label="Company"
|
||||
options={companyOptions}
|
||||
{...register(`background.experience.companyId`)}
|
||||
{...register(`background.experiences.0.companyId`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5 grid grid-cols-1 space-x-3">
|
||||
@ -90,7 +96,9 @@ function FullTimeJobFields() {
|
||||
isLabelHidden={true}
|
||||
label="Currency"
|
||||
options={CURRENCY_OPTIONS}
|
||||
{...register(`background.experience.totalCompensation.currency`)}
|
||||
{...register(
|
||||
`background.experiences.0.totalCompensation.currency`,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
endAddOnType="element"
|
||||
@ -99,7 +107,9 @@ function FullTimeJobFields() {
|
||||
startAddOn="$"
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`background.experience.totalCompensation.value`)}
|
||||
{...register(`background.experiences.0.totalCompensation.value`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Collapsible label="Add more details">
|
||||
@ -107,12 +117,12 @@ function FullTimeJobFields() {
|
||||
<FormTextInput
|
||||
label="Focus / Specialization"
|
||||
placeholder="e.g. Front End"
|
||||
{...register(`background.experience.specialization`)}
|
||||
{...register(`background.experiences.0.specialization`)}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Level"
|
||||
placeholder="e.g. L4, Junior"
|
||||
{...register(`background.experience.level`)}
|
||||
{...register(`background.experiences.0.level`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||
@ -120,12 +130,14 @@ function FullTimeJobFields() {
|
||||
display="block"
|
||||
label="Location"
|
||||
options={locationOptions}
|
||||
{...register(`background.experience.location`)}
|
||||
{...register(`background.experiences.0.location`)}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Duration (months)"
|
||||
type="number"
|
||||
{...register(`background.experience.durationInMonths`)}
|
||||
{...register(`background.experiences.0.durationInMonths`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
@ -142,13 +154,13 @@ function InternshipJobFields() {
|
||||
display="block"
|
||||
label="Title"
|
||||
options={titleOptions}
|
||||
{...register(`background.experience.title`)}
|
||||
{...register(`background.experiences.0.title`)}
|
||||
/>
|
||||
<FormSelect
|
||||
display="block"
|
||||
label="Company"
|
||||
options={companyOptions}
|
||||
{...register(`background.experience.company`)}
|
||||
{...register(`background.experiences.0.company`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5 grid grid-cols-1 space-x-3">
|
||||
@ -159,7 +171,7 @@ function InternshipJobFields() {
|
||||
isLabelHidden={true}
|
||||
label="Currency"
|
||||
options={CURRENCY_OPTIONS}
|
||||
{...register(`background.experience.monthlySalary.currency`)}
|
||||
{...register(`background.experiences.0.monthlySalary.currency`)}
|
||||
/>
|
||||
}
|
||||
endAddOnType="element"
|
||||
@ -168,7 +180,7 @@ function InternshipJobFields() {
|
||||
startAddOn="$"
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`background.experience.monthlySalary.value`)}
|
||||
{...register(`background.experiences.0.monthlySalary.value`)}
|
||||
/>
|
||||
</div>
|
||||
<Collapsible label="Add more details">
|
||||
@ -176,13 +188,13 @@ function InternshipJobFields() {
|
||||
<FormTextInput
|
||||
label="Focus / Specialization"
|
||||
placeholder="e.g. Front End"
|
||||
{...register(`background.experience.specialization`)}
|
||||
{...register(`background.experiences.0.specialization`)}
|
||||
/>
|
||||
<FormSelect
|
||||
display="block"
|
||||
label="Location"
|
||||
options={locationOptions}
|
||||
{...register(`background.experience.location`)}
|
||||
{...register(`background.experiences.0.location`)}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
@ -194,7 +206,7 @@ function CurrentJobSection() {
|
||||
const { register } = useFormContext();
|
||||
const watchJobType = useWatch({
|
||||
defaultValue: JobType.FullTime,
|
||||
name: 'background.experience.jobType',
|
||||
name: 'background.experiences.0.jobType',
|
||||
});
|
||||
|
||||
return (
|
||||
@ -209,7 +221,7 @@ function CurrentJobSection() {
|
||||
isLabelHidden={true}
|
||||
label="Job Type"
|
||||
orientation="horizontal"
|
||||
{...register('background.experience.jobType')}>
|
||||
{...register('background.experiences.0.jobType')}>
|
||||
<RadioList.Item
|
||||
key="Full-time"
|
||||
label="Full-time"
|
||||
@ -245,13 +257,13 @@ function EducationSection() {
|
||||
display="block"
|
||||
label="Education Level"
|
||||
options={educationLevelOptions}
|
||||
{...register(`background.education.type`)}
|
||||
{...register(`background.educations.0.type`)}
|
||||
/>
|
||||
<FormSelect
|
||||
display="block"
|
||||
label="Field"
|
||||
options={educationFieldOptions}
|
||||
{...register(`background.education.field`)}
|
||||
{...register(`background.educations.0.field`)}
|
||||
/>
|
||||
</div>
|
||||
<Collapsible label="Add more details">
|
||||
@ -259,7 +271,7 @@ function EducationSection() {
|
||||
<FormTextInput
|
||||
label="School"
|
||||
placeholder="e.g. National University of Singapore"
|
||||
{...register(`background.experience.specialization`)}
|
||||
{...register(`background.educations.0.school`)}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
@ -86,8 +86,7 @@ export default function OfferAnalysis() {
|
||||
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
|
||||
Result
|
||||
</h5>
|
||||
|
||||
<div className="mx-40">
|
||||
<div>
|
||||
<Tabs
|
||||
label="Result Navigation"
|
||||
tabs={tabs}
|
||||
|
@ -10,9 +10,10 @@ import { PlusIcon } from '@heroicons/react/20/solid';
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
import FormSelect from './FormSelect';
|
||||
import FormTextArea from './FormTextArea';
|
||||
import FormTextInput from './FormTextInput';
|
||||
import FormMonthYearPicker from './components/FormMonthYearPicker';
|
||||
import FormSelect from './components/FormSelect';
|
||||
import FormTextArea from './components/FormTextArea';
|
||||
import FormTextInput from './components/FormTextInput';
|
||||
import {
|
||||
companyOptions,
|
||||
internshipCycleOptions,
|
||||
@ -20,9 +21,9 @@ import {
|
||||
titleOptions,
|
||||
yearOptions,
|
||||
} from '../constants';
|
||||
import type { FullTimeOfferFormData, InternshipOfferFormData } from '../types';
|
||||
import type { OfferDetailsFormData } from '../types';
|
||||
import { JobType } from '../types';
|
||||
import { CURRENCY_OPTIONS } from '../util/currency/CurrencyEnum';
|
||||
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
|
||||
|
||||
type FullTimeOfferDetailsFormProps = Readonly<{
|
||||
index: number;
|
||||
@ -34,7 +35,7 @@ function FullTimeOfferDetailsForm({
|
||||
remove,
|
||||
}: FullTimeOfferDetailsFormProps) {
|
||||
const { register } = useFormContext<{
|
||||
offers: Array<FullTimeOfferFormData>;
|
||||
offers: Array<OfferDetailsFormData>;
|
||||
}>();
|
||||
|
||||
return (
|
||||
@ -81,10 +82,7 @@ function FullTimeOfferDetailsForm({
|
||||
required={true}
|
||||
{...register(`offers.${index}.location`, { required: true })}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Month Received"
|
||||
placeholder="MMM/YYYY"
|
||||
required={true}
|
||||
<FormMonthYearPicker
|
||||
{...register(`offers.${index}.monthYearReceived`, { required: true })}
|
||||
/>
|
||||
</div>
|
||||
@ -110,6 +108,7 @@ function FullTimeOfferDetailsForm({
|
||||
type="number"
|
||||
{...register(`offers.${index}.job.totalCompensation.value`, {
|
||||
required: true,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@ -121,7 +120,9 @@ function FullTimeOfferDetailsForm({
|
||||
isLabelHidden={true}
|
||||
label="Currency"
|
||||
options={CURRENCY_OPTIONS}
|
||||
{...register(`offers.${index}.job.base.currency`)}
|
||||
{...register(`offers.${index}.job.base.currency`, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
endAddOnType="element"
|
||||
@ -131,7 +132,10 @@ function FullTimeOfferDetailsForm({
|
||||
startAddOn="$"
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`offers.${index}.job.base.value`)}
|
||||
{...register(`offers.${index}.job.base.value`, {
|
||||
required: true,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
endAddOn={
|
||||
@ -140,7 +144,9 @@ function FullTimeOfferDetailsForm({
|
||||
isLabelHidden={true}
|
||||
label="Currency"
|
||||
options={CURRENCY_OPTIONS}
|
||||
{...register(`offers.${index}.job.bonus.currency`)}
|
||||
{...register(`offers.${index}.job.bonus.currency`, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
endAddOnType="element"
|
||||
@ -150,7 +156,10 @@ function FullTimeOfferDetailsForm({
|
||||
startAddOn="$"
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`offers.${index}.job.bonus.value`)}
|
||||
{...register(`offers.${index}.job.bonus.value`, {
|
||||
required: true,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||
@ -161,7 +170,9 @@ function FullTimeOfferDetailsForm({
|
||||
isLabelHidden={true}
|
||||
label="Currency"
|
||||
options={CURRENCY_OPTIONS}
|
||||
{...register(`offers.${index}.job.stocks.currency`)}
|
||||
{...register(`offers.${index}.job.stocks.currency`, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
endAddOnType="element"
|
||||
@ -171,7 +182,10 @@ function FullTimeOfferDetailsForm({
|
||||
startAddOn="$"
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`offers.${index}.job.stocks.value`)}
|
||||
{...register(`offers.${index}.job.stocks.value`, {
|
||||
required: true,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
@ -251,7 +265,7 @@ function InternshipOfferDetailsForm({
|
||||
remove,
|
||||
}: InternshipOfferDetailsFormProps) {
|
||||
const { register } = useFormContext<{
|
||||
offers: Array<InternshipOfferFormData>;
|
||||
offers: Array<OfferDetailsFormData>;
|
||||
}>();
|
||||
|
||||
return (
|
||||
@ -262,13 +276,19 @@ function InternshipOfferDetailsForm({
|
||||
label="Title"
|
||||
options={titleOptions}
|
||||
required={true}
|
||||
{...register(`offers.${index}.job.title`)}
|
||||
{...register(`offers.${index}.job.title`, {
|
||||
minLength: 1,
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
<FormTextInput
|
||||
label="Focus / Specialization"
|
||||
placeholder="e.g. Front End"
|
||||
required={true}
|
||||
{...register(`offers.${index}.job.specialization`)}
|
||||
{...register(`offers.${index}.job.specialization`, {
|
||||
minLength: 1,
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||
@ -277,40 +297,44 @@ function InternshipOfferDetailsForm({
|
||||
label="Company"
|
||||
options={companyOptions}
|
||||
required={true}
|
||||
value="Shopee"
|
||||
{...register(`offers.${index}.companyId`)}
|
||||
{...register(`offers.${index}.companyId`, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
<FormSelect
|
||||
display="block"
|
||||
label="Location"
|
||||
options={locationOptions}
|
||||
required={true}
|
||||
value="Singapore, Singapore"
|
||||
{...register(`offers.${index}.location`)}
|
||||
{...register(`offers.${index}.location`, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5 grid grid-cols-3 space-x-3">
|
||||
<FormTextInput
|
||||
label="Date Received"
|
||||
placeholder="MMM/YYYY"
|
||||
required={true}
|
||||
{...register(`offers.${index}.monthYearReceived`)}
|
||||
/>
|
||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||
<FormSelect
|
||||
display="block"
|
||||
label="Internship Cycle"
|
||||
options={internshipCycleOptions}
|
||||
required={true}
|
||||
value="Summer"
|
||||
{...register(`offers.${index}.job.internshipCycle`)}
|
||||
{...register(`offers.${index}.job.internshipCycle`, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
<FormSelect
|
||||
display="block"
|
||||
label="Internship Year"
|
||||
options={yearOptions}
|
||||
required={true}
|
||||
value="2023"
|
||||
{...register(`offers.${index}.job.startYear`)}
|
||||
{...register(`offers.${index}.job.startYear`, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5 flex items-center space-x-9">
|
||||
<p className="text-sm">Date received:</p>
|
||||
<FormMonthYearPicker
|
||||
{...register(`offers.${index}.monthYearReceived`, { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
@ -321,7 +345,9 @@ function InternshipOfferDetailsForm({
|
||||
isLabelHidden={true}
|
||||
label="Currency"
|
||||
options={CURRENCY_OPTIONS}
|
||||
{...register(`offers.${index}.job.monthlySalary.currency`)}
|
||||
{...register(`offers.${index}.job.monthlySalary.currency`, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
endAddOnType="element"
|
||||
@ -331,7 +357,10 @@ function InternshipOfferDetailsForm({
|
||||
startAddOn="$"
|
||||
startAddOnType="label"
|
||||
type="number"
|
||||
{...register(`offers.${index}.job.monthlySalary.value`)}
|
||||
{...register(`offers.${index}.job.monthlySalary.value`, {
|
||||
required: true,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
|
@ -0,0 +1,37 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import MonthYearPicker from '~/components/shared/MonthYearPicker';
|
||||
|
||||
import { getCurrentMonth, getCurrentYear } from '../../../../utils/offers/time';
|
||||
|
||||
type MonthYearPickerProps = ComponentProps<typeof MonthYearPicker>;
|
||||
|
||||
type FormMonthYearPickerProps = Omit<
|
||||
MonthYearPickerProps,
|
||||
'onChange' | 'value'
|
||||
> & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function FormMonthYearPicker({
|
||||
name,
|
||||
...rest
|
||||
}: FormMonthYearPickerProps) {
|
||||
const { setValue } = useFormContext();
|
||||
|
||||
const value = useWatch({
|
||||
defaultValue: { month: getCurrentMonth(), year: getCurrentYear() },
|
||||
name,
|
||||
});
|
||||
|
||||
return (
|
||||
<MonthYearPicker
|
||||
{...(rest as MonthYearPickerProps)}
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
setValue(name, val);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { RadioList } from '@tih/ui';
|
||||
|
||||
@ -7,7 +6,7 @@ type RadioListProps = ComponentProps<typeof RadioList>;
|
||||
|
||||
type FormRadioListProps = Omit<RadioListProps, 'onChange'>;
|
||||
|
||||
function FormRadioListWithRef({ name, ...rest }: FormRadioListProps) {
|
||||
export default function FormRadioList({ name, ...rest }: FormRadioListProps) {
|
||||
const { setValue } = useFormContext();
|
||||
return (
|
||||
<RadioList
|
||||
@ -17,7 +16,3 @@ function FormRadioListWithRef({ name, ...rest }: FormRadioListProps) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const FormRadioList = forwardRef(FormRadioListWithRef);
|
||||
|
||||
export default FormRadioList;
|
@ -1,4 +1,6 @@
|
||||
/* eslint-disable no-shadow */
|
||||
import type { MonthYear } from '../shared/MonthYearPicker';
|
||||
|
||||
/*
|
||||
* Offer Profile
|
||||
*/
|
||||
@ -18,7 +20,7 @@ export enum EducationBackgroundType {
|
||||
SelfTaught = 'Self-taught',
|
||||
}
|
||||
|
||||
type Money = {
|
||||
export type Money = {
|
||||
currency: string;
|
||||
value: number;
|
||||
};
|
||||
@ -33,16 +35,6 @@ type FullTimeJobData = {
|
||||
totalCompensation: Money;
|
||||
};
|
||||
|
||||
export type FullTimeOfferFormData = {
|
||||
comments: string;
|
||||
companyId: string;
|
||||
job: FullTimeJobData;
|
||||
jobType: string;
|
||||
location: string;
|
||||
monthYearReceived: string;
|
||||
negotiationStrategy: string;
|
||||
};
|
||||
|
||||
type InternshipJobData = {
|
||||
internshipCycle: string;
|
||||
monthlySalary: Money;
|
||||
@ -51,17 +43,22 @@ type InternshipJobData = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type InternshipOfferFormData = {
|
||||
export type OfferDetailsFormData = {
|
||||
comments: string;
|
||||
companyId: string;
|
||||
job: InternshipJobData;
|
||||
job: FullTimeJobData | InternshipJobData;
|
||||
jobType: string;
|
||||
location: string;
|
||||
monthYearReceived: string;
|
||||
monthYearReceived: MonthYear;
|
||||
negotiationStrategy: string;
|
||||
};
|
||||
|
||||
type OfferDetailsFormData = FullTimeOfferFormData | InternshipOfferFormData;
|
||||
export type OfferDetailsPostData = Omit<
|
||||
OfferDetailsFormData,
|
||||
'monthYearReceived'
|
||||
> & {
|
||||
monthYearReceived: Date;
|
||||
};
|
||||
|
||||
type SpecificYoe = {
|
||||
domain: string;
|
||||
@ -98,8 +95,8 @@ type Education = {
|
||||
};
|
||||
|
||||
type BackgroundFormData = {
|
||||
education: Education;
|
||||
experience: Experience;
|
||||
educations: Array<Education>;
|
||||
experiences: Array<Experience>;
|
||||
specificYoes: Array<SpecificYoe>;
|
||||
totalYoe: number;
|
||||
};
|
||||
@ -108,3 +105,8 @@ export type SubmitOfferFormData = {
|
||||
background: BackgroundFormData;
|
||||
offers: Array<OfferDetailsFormData>;
|
||||
};
|
||||
|
||||
export type OfferPostData = {
|
||||
background: BackgroundFormData;
|
||||
offers: Array<OfferDetailsPostData>;
|
||||
};
|
||||
|
@ -1,7 +0,0 @@
|
||||
export function formatDate(value: Date | number | string) {
|
||||
const date = new Date(value);
|
||||
// Const day = date.toLocaleString('default', { day: '2-digit' });
|
||||
const month = date.toLocaleString('default', { month: 'short' });
|
||||
const year = date.toLocaleString('default', { year: 'numeric' });
|
||||
return `${month} ${year}`;
|
||||
}
|
@ -3,9 +3,11 @@ import { Select } from '@tih/ui';
|
||||
|
||||
import OffersTable from '~/components/offers/OffersTable';
|
||||
import OffersTitle from '~/components/offers/OffersTitle';
|
||||
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
|
||||
|
||||
export default function OffersHomePage() {
|
||||
const [jobTitleFilter, setjobTitleFilter] = useState('Software engineers');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [companyFilter, setCompanyFilter] = useState('All companies');
|
||||
|
||||
return (
|
||||
@ -13,7 +15,7 @@ export default function OffersHomePage() {
|
||||
<div className="grid-rows grid h-1/2 bg-gray-100">
|
||||
<OffersTitle />
|
||||
<div className="flex items-start justify-center">
|
||||
<div className="mt-4 flex items-center">
|
||||
<div className="mt-4 flex items-end">
|
||||
Viewing offers for
|
||||
<div className="mx-4">
|
||||
<Select
|
||||
@ -43,25 +45,8 @@ export default function OffersHomePage() {
|
||||
</div>
|
||||
in
|
||||
<div className="ml-4">
|
||||
<Select
|
||||
isLabelHidden={true}
|
||||
label="Select a company"
|
||||
options={[
|
||||
{
|
||||
label: 'All companies',
|
||||
value: 'All companies',
|
||||
},
|
||||
{
|
||||
label: 'Shopee',
|
||||
value: 'Shopee',
|
||||
},
|
||||
{
|
||||
label: 'Meta',
|
||||
value: 'Meta',
|
||||
},
|
||||
]}
|
||||
value={companyFilter}
|
||||
onChange={setCompanyFilter}
|
||||
<CompaniesTypeahead
|
||||
onSelect={({ value }) => setCompanyFilter(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -8,7 +8,15 @@ import BackgroundForm from '~/components/offers/forms/BackgroundForm';
|
||||
import OfferAnalysis from '~/components/offers/forms/OfferAnalysis';
|
||||
import OfferDetailsForm from '~/components/offers/forms/OfferDetailsForm';
|
||||
import OfferProfileSave from '~/components/offers/forms/OfferProfileSave';
|
||||
import type { SubmitOfferFormData } from '~/components/offers/types';
|
||||
import type {
|
||||
OfferDetailsFormData,
|
||||
SubmitOfferFormData,
|
||||
} 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';
|
||||
|
||||
function Breadcrumbs() {
|
||||
return (
|
||||
@ -23,53 +31,94 @@ const defaultOfferValues = {
|
||||
{
|
||||
comments: '',
|
||||
companyId: '',
|
||||
job: {
|
||||
base: {
|
||||
currency: 'USD',
|
||||
value: 0,
|
||||
},
|
||||
bonus: {
|
||||
currency: 'USD',
|
||||
value: 0,
|
||||
},
|
||||
level: '',
|
||||
specialization: '',
|
||||
stocks: {
|
||||
currency: 'USD',
|
||||
value: 0,
|
||||
},
|
||||
title: '',
|
||||
totalCompensation: {
|
||||
currency: 'USD',
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
job: {},
|
||||
jobType: 'FULLTIME',
|
||||
location: '',
|
||||
monthYearReceived: '',
|
||||
monthYearReceived: {
|
||||
month: getCurrentMonth() as Month,
|
||||
year: getCurrentYear(),
|
||||
},
|
||||
negotiationStrategy: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type FormStep = {
|
||||
component: JSX.Element;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
};
|
||||
|
||||
export default function OffersSubmissionPage() {
|
||||
const [formStep, setFormStep] = useState(0);
|
||||
const formMethods = useForm<SubmitOfferFormData>({
|
||||
defaultValues: defaultOfferValues,
|
||||
mode: 'all',
|
||||
});
|
||||
const { handleSubmit, trigger } = formMethods;
|
||||
|
||||
const nextStep = () => setFormStep(formStep + 1);
|
||||
const previousStep = () => setFormStep(formStep - 1);
|
||||
|
||||
const formComponents = [
|
||||
<OfferDetailsForm key={0} />,
|
||||
<BackgroundForm key={1} />,
|
||||
<OfferAnalysis key={2} />,
|
||||
<OfferProfileSave key={3} />,
|
||||
const formSteps: Array<FormStep> = [
|
||||
{
|
||||
component: <OfferDetailsForm key={0} />,
|
||||
hasNext: true,
|
||||
hasPrevious: false,
|
||||
},
|
||||
{
|
||||
component: <BackgroundForm key={1} />,
|
||||
hasNext: false,
|
||||
hasPrevious: true,
|
||||
},
|
||||
{ component: <OfferAnalysis key={2} />, hasNext: true, hasPrevious: false },
|
||||
{
|
||||
component: <OfferProfileSave key={3} />,
|
||||
hasNext: false,
|
||||
hasPrevious: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit: SubmitHandler<SubmitOfferFormData> = async () => {
|
||||
nextStep();
|
||||
const nextStep = async (currStep: number) => {
|
||||
if (currStep === 0) {
|
||||
const result = await trigger('offers');
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setFormStep(formStep + 1);
|
||||
};
|
||||
|
||||
const previousStep = () => setFormStep(formStep - 1);
|
||||
|
||||
const createMutation = trpc.useMutation(['offers.profile.create'], {
|
||||
onError(error) {
|
||||
console.error(error.message);
|
||||
},
|
||||
onSuccess() {
|
||||
alert('offer profile submit success!');
|
||||
setFormStep(formStep + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<SubmitOfferFormData> = async (data) => {
|
||||
const result = await trigger();
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
data = removeInvalidMoneyData(data);
|
||||
const background = cleanObject(data.background);
|
||||
const offers = data.offers.map((offer: OfferDetailsFormData) => ({
|
||||
...offer,
|
||||
monthYearReceived: new Date(
|
||||
offer.monthYearReceived.year,
|
||||
offer.monthYearReceived.month,
|
||||
),
|
||||
}));
|
||||
const postData = { background, offers };
|
||||
|
||||
postData.background.specificYoes = data.background.specificYoes.filter(
|
||||
(specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
|
||||
);
|
||||
|
||||
createMutation.mutate(postData);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -78,16 +127,17 @@ export default function OffersSubmissionPage() {
|
||||
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
|
||||
<Breadcrumbs />
|
||||
<FormProvider {...formMethods}>
|
||||
<form onSubmit={formMethods.handleSubmit(onSubmit)}>
|
||||
{formComponents[formStep]}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{formSteps[formStep].component}
|
||||
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
|
||||
{(formStep === 0 || formStep === 2) && (
|
||||
{formSteps[formStep].hasNext && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={false}
|
||||
icon={ArrowRightIcon}
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
onClick={nextStep}
|
||||
onClick={() => nextStep(formStep)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Select } from '@tih/ui';
|
||||
|
||||
import { Currency } from '~/components/offers/util/currency/CurrencyEnum';
|
||||
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
|
||||
|
||||
const currencyOptions = Object.entries(Currency).map(([key, value]) => ({
|
||||
label: key,
|
56
apps/portal/src/utils/offers/form.tsx
Normal file
56
apps/portal/src/utils/offers/form.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Removes empty objects, empty strings, `null`, `undefined`, and `NaN` values from an object.
|
||||
* Does not remove empty arrays.
|
||||
* @param object
|
||||
* @returns object without empty values or objects.
|
||||
*/
|
||||
export function cleanObject(object: any) {
|
||||
Object.entries(object).forEach(([k, v]) => {
|
||||
if ((v && typeof v === 'object') || Array.isArray(v)) {
|
||||
cleanObject(v);
|
||||
}
|
||||
if (
|
||||
(v &&
|
||||
typeof v === 'object' &&
|
||||
!Object.keys(v).length &&
|
||||
!Array.isArray(v)) ||
|
||||
v === null ||
|
||||
v === undefined ||
|
||||
v === '' ||
|
||||
v !== v
|
||||
) {
|
||||
if (Array.isArray(object)) {
|
||||
const index = object.indexOf(v);
|
||||
object.splice(index, 1);
|
||||
} else if (!(v instanceof Date)) {
|
||||
delete object[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
return object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes invalid money data from an object.
|
||||
* If currency is present but value is not present, money object is removed.
|
||||
* @param object
|
||||
* @returns object without invalid money data.
|
||||
*/
|
||||
export function removeInvalidMoneyData(object: any) {
|
||||
Object.entries(object).forEach(([k, v]) => {
|
||||
if ((v && typeof v === 'object') || Array.isArray(v)) {
|
||||
removeInvalidMoneyData(v);
|
||||
}
|
||||
if (k === 'currency') {
|
||||
if (object.value === undefined) {
|
||||
delete object[k];
|
||||
} else if (object.value === null || object.value !== object.value) {
|
||||
delete object[k];
|
||||
delete object.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return object;
|
||||
}
|
25
apps/portal/src/utils/offers/time.tsx
Normal file
25
apps/portal/src/utils/offers/time.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { getMonth, getYear } from 'date-fns';
|
||||
|
||||
import type { MonthYear } from '~/components/shared/MonthYearPicker';
|
||||
|
||||
export function formatDate(value: Date | number | string) {
|
||||
const date = new Date(value);
|
||||
// Const day = date.toLocaleString('default', { day: '2-digit' });
|
||||
const month = date.toLocaleString('default', { month: 'short' });
|
||||
const year = date.toLocaleString('default', { year: 'numeric' });
|
||||
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() {
|
||||
return getMonth(Date.now());
|
||||
}
|
||||
|
||||
export function getCurrentYear() {
|
||||
return getYear(Date.now());
|
||||
}
|
Reference in New Issue
Block a user