[offers][feat] Add multiple company analysis

pull/473/head
Bryann Yeap Kok Keong 3 years ago committed by Bryann Yeap Kok Keong
parent 68f3c72945
commit 91696571fe

@ -0,0 +1,113 @@
/*
Warnings:
- You are about to drop the column `companyPercentile` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `noOfSimilarCompanyOffers` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `noOfSimilarOffers` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `overallPercentile` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `OffersProfile` table. All the data in the column will be lost.
- You are about to drop the `_TopCompanyOffers` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_TopOverallOffers` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `overallAnalysisUnitId` to the `OffersAnalysis` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `OffersAnalysis` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "OffersProfile" DROP CONSTRAINT "OffersProfile_userId_fkey";
-- DropForeignKey
ALTER TABLE "_TopCompanyOffers" DROP CONSTRAINT "_TopCompanyOffers_A_fkey";
-- DropForeignKey
ALTER TABLE "_TopCompanyOffers" DROP CONSTRAINT "_TopCompanyOffers_B_fkey";
-- DropForeignKey
ALTER TABLE "_TopOverallOffers" DROP CONSTRAINT "_TopOverallOffers_A_fkey";
-- DropForeignKey
ALTER TABLE "_TopOverallOffers" DROP CONSTRAINT "_TopOverallOffers_B_fkey";
-- AlterTable
ALTER TABLE "OffersAnalysis" DROP COLUMN "companyPercentile",
DROP COLUMN "noOfSimilarCompanyOffers",
DROP COLUMN "noOfSimilarOffers",
DROP COLUMN "overallPercentile",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "overallAnalysisUnitId" TEXT NOT NULL,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- AlterTable
ALTER TABLE "OffersProfile" DROP COLUMN "userId";
-- DropTable
DROP TABLE "_TopCompanyOffers";
-- DropTable
DROP TABLE "_TopOverallOffers";
-- CreateTable
CREATE TABLE "OffersAnalysisUnit" (
"id" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"percentile" DOUBLE PRECISION NOT NULL,
"noOfSimilarOffers" INTEGER NOT NULL,
CONSTRAINT "OffersAnalysisUnit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_OffersProfileToUser" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_CompanyAnalysis" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_OffersAnalysisUnitToOffersOffer" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_OffersProfileToUser_AB_unique" ON "_OffersProfileToUser"("A", "B");
-- CreateIndex
CREATE INDEX "_OffersProfileToUser_B_index" ON "_OffersProfileToUser"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_CompanyAnalysis_AB_unique" ON "_CompanyAnalysis"("A", "B");
-- CreateIndex
CREATE INDEX "_CompanyAnalysis_B_index" ON "_CompanyAnalysis"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_OffersAnalysisUnitToOffersOffer_AB_unique" ON "_OffersAnalysisUnitToOffersOffer"("A", "B");
-- CreateIndex
CREATE INDEX "_OffersAnalysisUnitToOffersOffer_B_index" ON "_OffersAnalysisUnitToOffersOffer"("B");
-- AddForeignKey
ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_overallAnalysisUnitId_fkey" FOREIGN KEY ("overallAnalysisUnitId") REFERENCES "OffersAnalysisUnit"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersProfileToUser" ADD CONSTRAINT "_OffersProfileToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersProfileToUser" ADD CONSTRAINT "_OffersProfileToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CompanyAnalysis" ADD CONSTRAINT "_CompanyAnalysis_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CompanyAnalysis" ADD CONSTRAINT "_CompanyAnalysis_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersAnalysisUnit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersAnalysisUnitToOffersOffer" ADD CONSTRAINT "_OffersAnalysisUnitToOffersOffer_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysisUnit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersAnalysisUnitToOffersOffer" ADD CONSTRAINT "_OffersAnalysisUnitToOffersOffer_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -225,8 +225,7 @@ model OffersProfile {
offers OffersOffer[] offers OffersOffer[]
user User? @relation(fields: [userId], references: [id]) users User[]
userId String?
analysis OffersAnalysis? analysis OffersAnalysis?
} }
@ -362,9 +361,8 @@ model OffersOffer {
offersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade) offersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade)
offersFullTimeId String? @unique offersFullTimeId String? @unique
OffersAnalysis OffersAnalysis? @relation("HighestOverallOffer") offersAnalysis OffersAnalysis? @relation("HighestOverallOffer")
OffersAnalysisTopOverallOffers OffersAnalysis[] @relation("TopOverallOffers") offersAnalysisUnit OffersAnalysisUnit[]
OffersAnalysisTopCompanyOffers OffersAnalysis[] @relation("TopCompanyOffers")
} }
model OffersIntern { model OffersIntern {
@ -396,7 +394,9 @@ model OffersFullTime {
} }
model OffersAnalysis { model OffersAnalysis {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String @unique profileId String @unique
@ -405,14 +405,22 @@ model OffersAnalysis {
offerId String @unique offerId String @unique
// OVERALL // OVERALL
overallPercentile Float overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id])
overallAnalysisUnitId String
companyAnalysis OffersAnalysisUnit[] @relation("CompanyAnalysis")
}
model OffersAnalysisUnit {
id String @id @default(cuid())
companyName String
percentile Float
noOfSimilarOffers Int noOfSimilarOffers Int
topOverallOffers OffersOffer[] @relation("TopOverallOffers") topSimilarOffers OffersOffer[]
// Company offersAnalysisOverall OffersAnalysis[] @relation("OverallAnalysis")
companyPercentile Float offersAnalysisCompany OffersAnalysis[] @relation("CompanyAnalysis")
noOfSimilarCompanyOffers Int
topCompanyOffers OffersOffer[] @relation("TopCompanyOffers")
} }
// End of Offers project models. // End of Offers project models.

@ -7,7 +7,7 @@ const navigation: ProductNavigationItems = [
const navigationAuthenticated: ProductNavigationItems = [ const navigationAuthenticated: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' }, { href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/dashboard', name: 'Your repository' }, { href: '/offers/dashboard', name: 'Your dashboard' },
{ href: '/offers/features', name: 'Features' }, { href: '/offers/features', name: 'Features' },
]; ];

@ -6,29 +6,20 @@ import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferProfileCard from './OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../constants'; import { OVERALL_TAB } from '../constants';
import type { import type { AnalysisUnit, ProfileAnalysis } from '~/types/offers';
Analysis,
AnalysisHighestOffer,
ProfileAnalysis,
} from '~/types/offers';
type OfferAnalysisData = {
offer?: AnalysisHighestOffer;
offerAnalysis?: Analysis;
};
type OfferAnalysisContentProps = Readonly<{ type OfferAnalysisContentProps = Readonly<{
analysis: OfferAnalysisData; analysis: AnalysisUnit;
isSubmission: boolean; isSubmission: boolean;
tab: string; tab: string;
}>; }>;
function OfferAnalysisContent({ function OfferAnalysisContent({
analysis: { offer, offerAnalysis }, analysis,
tab, tab,
isSubmission, isSubmission,
}: OfferAnalysisContentProps) { }: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) { if (!analysis || analysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) { if (tab === OVERALL_TAB) {
return ( return (
<p className="m-10"> <p className="m-10">
@ -47,9 +38,8 @@ function OfferAnalysisContent({
return ( return (
<> <>
<OfferPercentileAnalysisText <OfferPercentileAnalysisText
companyName={offer.company.name} analysis={analysis}
isSubmission={isSubmission} isSubmission={isSubmission}
offerAnalysis={offerAnalysis}
tab={tab} tab={tab}
/> />
<p className="mt-5"> <p className="mt-5">
@ -57,7 +47,7 @@ function OfferAnalysisContent({
? 'Here are some of the top offers relevant to you:' ? 'Here are some of the top offers relevant to you:'
: 'Relevant top offers:'} : 'Relevant top offers:'}
</p> </p>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => ( {analysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard <OfferProfileCard
key={topPercentileOffer.id} key={topPercentileOffer.id}
offerProfile={topPercentileOffer} offerProfile={topPercentileOffer}
@ -77,7 +67,7 @@ function OfferAnalysisContent({
} }
type OfferAnalysisProps = Readonly<{ type OfferAnalysisProps = Readonly<{
allAnalysis?: ProfileAnalysis | null; allAnalysis: ProfileAnalysis;
isError: boolean; isError: boolean;
isLoading: boolean; isLoading: boolean;
isSubmission?: boolean; isSubmission?: boolean;
@ -90,61 +80,55 @@ export default function OfferAnalysis({
isSubmission = false, isSubmission = false,
}: OfferAnalysisProps) { }: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB); const [tab, setTab] = useState(OVERALL_TAB);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null); const [analysis, setAnalysis] = useState<AnalysisUnit>(
allAnalysis.overallAnalysis,
);
useEffect(() => { useEffect(() => {
if (tab === OVERALL_TAB) { if (tab === OVERALL_TAB) {
setAnalysis({ setAnalysis(allAnalysis.overallAnalysis);
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.overallAnalysis,
});
} else { } else {
setAnalysis({ setAnalysis(allAnalysis.companyAnalysis[parseInt(tab, 10)]);
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.companyAnalysis[0],
});
} }
}, [tab, allAnalysis]); }, [tab, allAnalysis]);
const tabOptions = [ const companyTabs = allAnalysis.companyAnalysis.map((value, index) => ({
label: value.companyName,
value: `${index}`,
}));
let tabOptions = [
{ {
label: OVERALL_TAB, label: OVERALL_TAB,
value: OVERALL_TAB, value: OVERALL_TAB,
}, },
{
label: allAnalysis?.overallHighestOffer.company.name || '',
value: allAnalysis?.overallHighestOffer.company.id || '',
},
]; ];
tabOptions = tabOptions.concat(companyTabs);
return ( return (
<> <div>
{isError && (
<p className="m-10 text-center">
An error occurred while generating profile analysis.
</p>
)}
{isLoading && <Spinner className="m-10" display="block" size="lg" />} {isLoading && <Spinner className="m-10" display="block" size="lg" />}
{analysis && ( {!isError && !isLoading && (
<div> <div>
{isError && ( <Tabs
<p className="m-10 text-center"> label="Result Navigation"
An error occurred while generating profile analysis. tabs={tabOptions}
</p> value={tab}
)} onChange={setTab}
{!isError && !isLoading && ( />
<div> <HorizontalDivider className="mb-5" />
<Tabs <OfferAnalysisContent
label="Result Navigation" analysis={analysis}
tabs={tabOptions} isSubmission={isSubmission}
value={tab} tab={tab}
onChange={setTab} />
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent
analysis={analysis}
isSubmission={isSubmission}
tab={tab}
/>
</div>
)}
</div> </div>
)} )}
</> </div>
); );
} }

@ -1,18 +1,16 @@
import { OVERALL_TAB } from '../constants'; import { OVERALL_TAB } from '../constants';
import type { Analysis } from '~/types/offers'; import type { AnalysisUnit } from '~/types/offers';
type OfferPercentileAnalysisTextProps = Readonly<{ type OfferPercentileAnalysisTextProps = Readonly<{
companyName: string; analysis: AnalysisUnit;
isSubmission: boolean; isSubmission: boolean;
offerAnalysis: Analysis;
tab: string; tab: string;
}>; }>;
export default function OfferPercentileAnalysisText({ export default function OfferPercentileAnalysisText({
tab, tab,
companyName, analysis: { noOfOffers, percentile, companyName },
offerAnalysis: { noOfOffers, percentile },
isSubmission, isSubmission,
}: OfferPercentileAnalysisTextProps) { }: OfferPercentileAnalysisTextProps) {
return tab === OVERALL_TAB ? ( return tab === OVERALL_TAB ? (

@ -47,11 +47,13 @@ export default function OfferProfileCard({
</div> </div>
<div className="col-span-10"> <div className="col-span-10">
<p className="font-bold">{profileName}</p> <p className="font-bold">{profileName}</p>
<div className="flex flex-row"> {previousCompanies.length > 0 && (
<BuildingOffice2Icon className="mr-2 h-5" /> <div className="flex flex-row">
<span className="mr-2 font-bold">Current:</span> <BuildingOffice2Icon className="mr-2 h-5" />
<span>{previousCompanies[0]}</span> <span className="mr-2 font-bold">Current:</span>
</div> <span>{previousCompanies[0]}</span>
</div>
)}
<div className="flex flex-row"> <div className="flex flex-row">
<CalendarDaysIcon className="mr-2 h-5" /> <CalendarDaysIcon className="mr-2 h-5" />
<span className="mr-2 font-bold">YOE:</span> <span className="mr-2 font-bold">YOE:</span>

@ -34,7 +34,7 @@ export default function OffersProfileSave({
}, },
onSuccess: () => { onSuccess: () => {
showToast({ showToast({
title: `Saved to your repository!`, title: `Saved to your dashboard!`,
variant: 'success', variant: 'success',
}); });
}, },
@ -95,8 +95,8 @@ export default function OffersProfileSave({
</div> </div>
<p className="mb-5 text-slate-900"> <p className="mb-5 text-slate-900">
If you do not want to keep the edit link, you can opt to save this If you do not want to keep the edit link, you can opt to save this
profile under your account's respository. It will still only be profile under your account's dashboard. It will still only be editable
editable by you. by you.
</p> </p>
<div className="mb-20"> <div className="mb-20">
<Button <Button

@ -17,13 +17,17 @@ export default function OffersSubmissionAnalysis({
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900"> <h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result Result
</h5> </h5>
<OfferAnalysis {!analysis && (
key={3} <p className="mb-8 text-center">Error generating analysis.</p>
allAnalysis={analysis} )}
isError={isError} {analysis && (
isLoading={isLoading} <OfferAnalysis
isSubmission={true} key={3}
/> allAnalysis={analysis}
isError={isError}
isLoading={isLoading}
/>
)}
</div> </div>
); );
} }

@ -115,7 +115,15 @@ function ProfileAnalysis({
return ( return (
<div className="mx-8 my-4"> <div className="mx-8 my-4">
<OfferAnalysis allAnalysis={analysis} isError={false} isLoading={false} /> {!analysis ? (
<p>No analysis available.</p>
) : (
<OfferAnalysis
allAnalysis={analysis}
isError={false}
isLoading={false}
/>
)}
{isEditable && ( {isEditable && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button

@ -41,7 +41,7 @@ export default function ProfileHeader({
setSelectedTab, setSelectedTab,
}: ProfileHeaderProps) { }: ProfileHeaderProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
// Const [saved, setSaved] = useState(isSaved); const [saved, setSaved] = useState(isSaved);
const router = useRouter(); const router = useRouter();
const trpcContext = trpc.useContext(); const trpcContext = trpc.useContext();
const { offerProfileId = '', token = '' } = router.query; const { offerProfileId = '', token = '' } = router.query;
@ -60,7 +60,7 @@ export default function ProfileHeader({
}); });
}, },
onSuccess: () => { onSuccess: () => {
// SetSaved(true); setSaved(true);
showToast({ showToast({
title: `Saved to dashboard!`, title: `Saved to dashboard!`,
variant: 'success', variant: 'success',
@ -79,7 +79,7 @@ export default function ProfileHeader({
}); });
}, },
onSuccess: () => { onSuccess: () => {
// SetSaved(false); setSaved(false);
showToast({ showToast({
title: `Removed from dashboard!`, title: `Removed from dashboard!`,
variant: 'success', variant: 'success',
@ -90,7 +90,7 @@ export default function ProfileHeader({
); );
const toggleSaved = () => { const toggleSaved = () => {
if (isSaved) { if (saved) {
unsaveMutation.mutate({ profileId: offerProfileId as string }); unsaveMutation.mutate({ profileId: offerProfileId as string });
} else { } else {
saveMutation.mutate({ saveMutation.mutate({
@ -111,10 +111,10 @@ export default function ProfileHeader({
disabled={ disabled={
isLoading || saveMutation.isLoading || unsaveMutation.isLoading isLoading || saveMutation.isLoading || unsaveMutation.isLoading
} }
icon={isSaved ? BookmarkIconSolid : BookmarkIconOutline} icon={saved ? BookmarkIconSolid : BookmarkIconOutline}
isLabelHidden={true} isLabelHidden={true}
isLoading={saveMutation.isLoading || unsaveMutation.isLoading} isLoading={saveMutation.isLoading || unsaveMutation.isLoading}
label={isSaved ? 'Remove from account' : 'Save to your account'} label={saved ? 'Remove from account' : 'Save to your account'}
size="md" size="md"
variant="tertiary" variant="tertiary"
onClick={toggleSaved} onClick={toggleSaved}

@ -1,6 +1,7 @@
import type { import type {
Company, Company,
OffersAnalysis, OffersAnalysis,
OffersAnalysisUnit,
OffersBackground, OffersBackground,
OffersCurrency, OffersCurrency,
OffersEducation, OffersEducation,
@ -18,9 +19,9 @@ import { TRPCError } from '@trpc/server';
import type { import type {
AddToProfileResponse, AddToProfileResponse,
Analysis,
AnalysisHighestOffer, AnalysisHighestOffer,
AnalysisOffer, AnalysisOffer,
AnalysisUnit,
Background, Background,
CreateOfferProfileResponse, CreateOfferProfileResponse,
DashboardOffer, DashboardOffer,
@ -111,32 +112,33 @@ const analysisOfferDtoMapper = (
return analysisOfferDto; return analysisOfferDto;
}; };
const analysisDtoMapper = ( const analysisUnitDtoMapper = (
noOfOffers: number, analysisUnit: OffersAnalysisUnit & {
percentile: number, topSimilarOffers: Array<
topPercentileOffers: Array< OffersOffer & {
OffersOffer & { company: Company;
company: Company; offersFullTime:
offersFullTime: | (OffersFullTime & { totalCompensation: OffersCurrency })
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null; | null;
}; offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
} profile: OffersProfile & {
>, background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
},
) => { ) => {
const analysisDto: Analysis = { const analysisDto: AnalysisUnit = {
noOfOffers, companyName: analysisUnit.companyName,
percentile, noOfOffers: analysisUnit.noOfSimilarOffers,
topPercentileOffers: topPercentileOffers.map((offer) => percentile: analysisUnit.percentile,
topPercentileOffers: analysisUnit.topSimilarOffers.map((offer) =>
analysisOfferDtoMapper(offer), analysisOfferDtoMapper(offer),
), ),
}; };
@ -166,6 +168,52 @@ const analysisHighestOfferDtoMapper = (
export const profileAnalysisDtoMapper = ( export const profileAnalysisDtoMapper = (
analysis: analysis:
| (OffersAnalysis & { | (OffersAnalysis & {
companyAnalysis: Array<
OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
}
>;
overallAnalysis: OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
};
overallHighestOffer: OffersOffer & { overallHighestOffer: OffersOffer & {
company: Company; company: Company;
offersFullTime: offersFullTime:
@ -176,46 +224,6 @@ export const profileAnalysisDtoMapper = (
| null; | null;
profile: OffersProfile & { background: OffersBackground | null }; profile: OffersProfile & { background: OffersBackground | null };
}; };
topCompanyOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
topOverallOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
}) })
| null, | null,
) => { ) => {
@ -224,23 +232,17 @@ export const profileAnalysisDtoMapper = (
} }
const profileAnalysisDto: ProfileAnalysis = { const profileAnalysisDto: ProfileAnalysis = {
companyAnalysis: [ companyAnalysis: analysis.companyAnalysis.map((analysisUnit) =>
analysisDtoMapper( analysisUnitDtoMapper(analysisUnit),
analysis.noOfSimilarCompanyOffers,
analysis.companyPercentile,
analysis.topCompanyOffers,
),
],
id: analysis.id,
overallAnalysis: analysisDtoMapper(
analysis.noOfSimilarOffers,
analysis.overallPercentile,
analysis.topOverallOffers,
), ),
createdAt: analysis.createdAt,
id: analysis.id,
overallAnalysis: analysisUnitDtoMapper(analysis.overallAnalysis),
overallHighestOffer: analysisHighestOfferDtoMapper( overallHighestOffer: analysisHighestOfferDtoMapper(
analysis.overallHighestOffer, analysis.overallHighestOffer,
), ),
profileId: analysis.profileId, profileId: analysis.profileId,
updatedAt: analysis.updatedAt,
}; };
return profileAnalysisDto; return profileAnalysisDto;
}; };
@ -442,6 +444,52 @@ export const profileDtoMapper = (
profile: OffersProfile & { profile: OffersProfile & {
analysis: analysis:
| (OffersAnalysis & { | (OffersAnalysis & {
companyAnalysis: Array<
OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
}
>;
overallAnalysis: OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
};
overallHighestOffer: OffersOffer & { overallHighestOffer: OffersOffer & {
company: Company; company: Company;
offersFullTime: offersFullTime:
@ -452,46 +500,6 @@ export const profileDtoMapper = (
| null; | null;
profile: OffersProfile & { background: OffersBackground | null }; profile: OffersProfile & { background: OffersBackground | null };
}; };
topCompanyOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
topOverallOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
}) })
| null; | null;
background: background:
@ -528,7 +536,7 @@ export const profileDtoMapper = (
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null; offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
} }
>; >;
user: User | null; users: Array<User>;
}, },
inputToken: string | undefined, inputToken: string | undefined,
inputUserId: string | null | undefined, inputUserId: string | null | undefined,
@ -548,18 +556,12 @@ export const profileDtoMapper = (
profileDto.editToken = profile.editToken ?? null; profileDto.editToken = profile.editToken ?? null;
profileDto.isEditable = true; profileDto.isEditable = true;
const users = profile.user; const { users } = profile;
// TODO: BRYANN UNCOMMENT THIS ONCE U CHANGE THE SCHEMA
// for (let i = 0; i < users.length; i++) {
// if (users[i].id === inputUserId) {
// profileDto.isSaved = true
// }
// }
// TODO: REMOVE THIS ONCE U CHANGE THE SCHEMA for (let i = 0; i < users.length; i++) {
if (users?.id === inputUserId) { if (users[i].id === inputUserId) {
profileDto.isSaved = true; profileDto.isSaved = true;
}
} }
} }

@ -71,11 +71,11 @@ export default function ProfilesDashboard() {
{!userProfilesQuery.isLoading && ( {!userProfilesQuery.isLoading && (
<div className="mt-8 overflow-y-auto"> <div className="mt-8 overflow-y-auto">
<h1 className="mx-auto mb-4 w-3/4 text-start text-4xl font-bold text-slate-900"> <h1 className="mx-auto mb-4 w-3/4 text-start text-4xl font-bold text-slate-900">
Your repository Your dashboard
</h1> </h1>
<p className="mx-auto w-3/4 text-start text-xl text-slate-900"> <p className="mx-auto w-3/4 text-start text-xl text-slate-900">
Save your offer profiles to respository to easily access and edit Save your offer profiles to dashboard to easily access and edit them
them later. later.
</p> </p>
<div className="justfy-center mt-8 flex w-screen"> <div className="justfy-center mt-8 flex w-screen">
<ul className="mx-auto w-3/4 space-y-3" role="list"> <ul className="mx-auto w-3/4 space-y-3" role="list">

@ -1,5 +1,6 @@
import Error from 'next/error'; import Error from 'next/error';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { Spinner, useToast } from '@tih/ui'; import { Spinner, useToast } from '@tih/ui';
@ -34,11 +35,16 @@ export default function OfferProfile() {
ProfileDetailTab.OFFERS, ProfileDetailTab.OFFERS,
); );
const [analysis, setAnalysis] = useState<ProfileAnalysis>(); const [analysis, setAnalysis] = useState<ProfileAnalysis>();
const { data: session } = useSession();
const getProfileQuery = trpc.useQuery( const getProfileQuery = trpc.useQuery(
[ [
'offers.profile.listOne', 'offers.profile.listOne',
{ profileId: offerProfileId as string, token: token as string }, {
profileId: offerProfileId as string,
token: token as string,
userId: session?.user?.id,
},
], ],
{ {
enabled: typeof offerProfileId === 'string', enabled: typeof offerProfileId === 'string',

@ -14,20 +14,17 @@ import { profileAnalysisDtoMapper } from '~/mappers/offers-mappers';
import { createRouter } from '../context'; import { createRouter } from '../context';
type Offer = OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
};
const searchOfferPercentile = ( const searchOfferPercentile = (
offer: OffersOffer & { offer: Offer,
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency | null;
bonus: OffersCurrency | null;
stocks: OffersCurrency | null;
totalCompensation: OffersCurrency;
})
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
},
similarOffers: Array< similarOffers: Array<
OffersOffer & { OffersOffer & {
company: Company; company: Company;
@ -58,46 +55,62 @@ export const offersAnalysisRouter = createRouter()
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const analysis = await ctx.prisma.offersAnalysis.findFirst({ const analysis = await ctx.prisma.offersAnalysis.findFirst({
include: { include: {
overallHighestOffer: { companyAnalysis: {
include: { include: {
company: true, topSimilarOffers: {
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: { include: {
monthlySalary: true, company: true,
}, offersFullTime: {
}, include: {
profile: { totalCompensation: true,
include: { },
background: true, },
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
}, },
}, },
}, },
}, },
topCompanyOffers: { overallAnalysis: {
include: { include: {
company: true, topSimilarOffers: {
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: { include: {
monthlySalary: true, company: true,
}, offersFullTime: {
}, include: {
profile: { totalCompensation: true,
include: { },
background: { },
offersIntern: {
include: { include: {
experiences: { monthlySalary: true,
},
},
profile: {
include: {
background: {
include: { include: {
company: true, experiences: {
include: {
company: true,
},
},
}, },
}, },
}, },
@ -106,7 +119,7 @@ export const offersAnalysisRouter = createRouter()
}, },
}, },
}, },
topOverallOffers: { overallHighestOffer: {
include: { include: {
company: true, company: true,
offersFullTime: { offersFullTime: {
@ -121,15 +134,7 @@ export const offersAnalysisRouter = createRouter()
}, },
profile: { profile: {
include: { include: {
background: { background: true,
include: {
experiences: {
include: {
company: true,
},
},
},
},
}, },
}, },
}, },
@ -310,11 +315,57 @@ export const offersAnalysisRouter = createRouter()
}, },
}); });
let similarCompanyOffers = similarOffers.filter( // COMPANY ANALYSIS
(offer) => offer.companyId === overallHighestOffer.companyId, const companyMap = new Map<string, Offer>();
offers.forEach((offer) => {
if (companyMap.get(offer.companyId) == null) {
companyMap.set(offer.companyId, offer);
}
});
const companyAnalysis = Array.from(companyMap.values()).map(
(companyOffer) => {
// TODO: Refactor calculating analysis into a function
let similarCompanyOffers = similarOffers.filter(
(offer) => offer.companyId === companyOffer.companyId,
);
const companyIndex = searchOfferPercentile(
companyOffer,
similarCompanyOffers,
);
const companyPercentile =
similarCompanyOffers.length <= 1
? 100
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// Get top offers (excluding user's offer)
similarCompanyOffers = similarCompanyOffers.filter(
(offer) => offer.id !== companyOffer.id,
);
const noOfSimilarCompanyOffers = similarCompanyOffers.length;
const similarCompanyOffers90PercentileIndex = Math.ceil(
noOfSimilarCompanyOffers * 0.1,
);
const topPercentileCompanyOffers =
noOfSimilarCompanyOffers > 2
? similarCompanyOffers.slice(
similarCompanyOffers90PercentileIndex,
similarCompanyOffers90PercentileIndex + 2,
)
: similarCompanyOffers;
return {
companyName: companyOffer.company.name,
noOfSimilarOffers: noOfSimilarCompanyOffers,
percentile: companyPercentile,
topSimilarOffers: topPercentileCompanyOffers,
};
},
); );
// CALCULATE PERCENTILES // OVERALL ANALYSIS
const overallIndex = searchOfferPercentile( const overallIndex = searchOfferPercentile(
overallHighestOffer, overallHighestOffer,
similarOffers, similarOffers,
@ -324,23 +375,9 @@ export const offersAnalysisRouter = createRouter()
? 100 ? 100
: 100 - (100 * overallIndex) / (similarOffers.length - 1); : 100 - (100 * overallIndex) / (similarOffers.length - 1);
const companyIndex = searchOfferPercentile(
overallHighestOffer,
similarCompanyOffers,
);
const companyPercentile =
similarCompanyOffers.length <= 1
? 100
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
// e.g. If there only 4 offers, it gives the 2nd and 3rd offer
similarOffers = similarOffers.filter( similarOffers = similarOffers.filter(
(offer) => offer.id !== overallHighestOffer.id, (offer) => offer.id !== overallHighestOffer.id,
); );
similarCompanyOffers = similarCompanyOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
);
const noOfSimilarOffers = similarOffers.length; const noOfSimilarOffers = similarOffers.length;
const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1); const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1);
@ -352,86 +389,102 @@ export const offersAnalysisRouter = createRouter()
) )
: similarOffers; : similarOffers;
const noOfSimilarCompanyOffers = similarCompanyOffers.length;
const similarCompanyOffers90PercentileIndex = Math.ceil(
noOfSimilarCompanyOffers * 0.1,
);
const topPercentileCompanyOffers =
noOfSimilarCompanyOffers > 2
? similarCompanyOffers.slice(
similarCompanyOffers90PercentileIndex,
similarCompanyOffers90PercentileIndex + 2,
)
: similarCompanyOffers;
const analysis = await ctx.prisma.offersAnalysis.create({ const analysis = await ctx.prisma.offersAnalysis.create({
data: { data: {
companyPercentile, companyAnalysis: {
noOfSimilarCompanyOffers, create: companyAnalysis.map((analysisUnit) => {
noOfSimilarOffers, return {
companyName: analysisUnit.companyName,
noOfSimilarOffers: analysisUnit.noOfSimilarOffers,
percentile: analysisUnit.percentile,
topSimilarOffers: {
connect: analysisUnit.topSimilarOffers.map((offer) => {
return { id: offer.id };
}),
},
};
}),
},
overallAnalysis: {
create: {
companyName: overallHighestOffer.company.name,
noOfSimilarOffers,
percentile: overallPercentile,
topSimilarOffers: {
connect: topPercentileOffers.map((offer) => {
return { id: offer.id };
}),
},
},
},
overallHighestOffer: { overallHighestOffer: {
connect: { connect: {
id: overallHighestOffer.id, id: overallHighestOffer.id,
}, },
}, },
overallPercentile,
profile: { profile: {
connect: { connect: {
id: input.profileId, id: input.profileId,
}, },
}, },
topCompanyOffers: {
connect: topPercentileCompanyOffers.map((offer) => {
return { id: offer.id };
}),
},
topOverallOffers: {
connect: topPercentileOffers.map((offer) => {
return { id: offer.id };
}),
},
}, },
include: { include: {
overallHighestOffer: { companyAnalysis: {
include: { include: {
company: true, topSimilarOffers: {
offersFullTime: {
include: { include: {
totalCompensation: true, company: true,
}, offersFullTime: {
}, include: {
offersIntern: { totalCompensation: true,
include: { },
monthlySalary: true, },
}, offersIntern: {
}, include: {
profile: { monthlySalary: true,
include: { },
background: true, },
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
}, },
}, },
}, },
}, },
topCompanyOffers: { overallAnalysis: {
include: { include: {
company: true, topSimilarOffers: {
offersFullTime: {
include: { include: {
totalCompensation: true, company: true,
}, offersFullTime: {
}, include: {
offersIntern: { totalCompensation: true,
include: { },
monthlySalary: true, },
}, offersIntern: {
}, include: {
profile: { monthlySalary: true,
include: { },
background: { },
profile: {
include: { include: {
experiences: { background: {
include: { include: {
company: true, experiences: {
include: {
company: true,
},
},
}, },
}, },
}, },
@ -440,7 +493,7 @@ export const offersAnalysisRouter = createRouter()
}, },
}, },
}, },
topOverallOffers: { overallHighestOffer: {
include: { include: {
company: true, company: true,
offersFullTime: { offersFullTime: {
@ -455,15 +508,7 @@ export const offersAnalysisRouter = createRouter()
}, },
profile: { profile: {
include: { include: {
background: { background: true,
include: {
experiences: {
include: {
company: true,
},
},
},
},
}, },
}, },
}, },

@ -128,46 +128,62 @@ export const offersProfileRouter = createRouter()
include: { include: {
analysis: { analysis: {
include: { include: {
overallHighestOffer: { companyAnalysis: {
include: { include: {
company: true, topSimilarOffers: {
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: { include: {
monthlySalary: true, company: true,
}, offersFullTime: {
}, include: {
profile: { totalCompensation: true,
include: { },
background: true, },
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
}, },
}, },
}, },
}, },
topCompanyOffers: { overallAnalysis: {
include: { include: {
company: true, topSimilarOffers: {
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: { include: {
monthlySalary: true, company: true,
}, offersFullTime: {
},
profile: {
include: {
background: {
include: { include: {
experiences: { totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: { include: {
company: true, experiences: {
include: {
company: true,
},
},
}, },
}, },
}, },
@ -176,7 +192,7 @@ export const offersProfileRouter = createRouter()
}, },
}, },
}, },
topOverallOffers: { overallHighestOffer: {
include: { include: {
company: true, company: true,
offersFullTime: { offersFullTime: {
@ -191,15 +207,7 @@ export const offersProfileRouter = createRouter()
}, },
profile: { profile: {
include: { include: {
background: { background: true,
include: {
experiences: {
include: {
company: true,
},
},
},
},
}, },
}, },
}, },
@ -244,7 +252,7 @@ export const offersProfileRouter = createRouter()
}, },
}, },
}, },
user: true, users: true,
}, },
where: { where: {
id: input.profileId, id: input.profileId,
@ -409,7 +417,7 @@ export const offersProfileRouter = createRouter()
message: 'Missing fields in background experiences.', message: 'Missing fields in background experiences.',
}); });
}), }),
) ),
}, },
specificYoes: { specificYoes: {
create: input.background.specificYoes.map((x) => { create: input.background.specificYoes.map((x) => {

@ -3,129 +3,130 @@ import * as trpc from '@trpc/server';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { import {
addToProfileResponseMapper, getUserProfileResponseMapper, addToProfileResponseMapper,
getUserProfileResponseMapper,
} from '~/mappers/offers-mappers'; } from '~/mappers/offers-mappers';
import { createProtectedRouter } from '../context'; import { createProtectedRouter } from '../context';
export const offersUserProfileRouter = createProtectedRouter() export const offersUserProfileRouter = createProtectedRouter()
.mutation('addToUserProfile', { .mutation('addToUserProfile', {
input: z.object({ input: z.object({
profileId: z.string(), profileId: z.string(),
token: z.string(), token: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({ const profile = await ctx.prisma.offersProfile.findFirst({
where: { where: {
id: input.profileId, id: input.profileId,
}, },
}); });
const profileEditToken = profile?.editToken; const profileEditToken = profile?.editToken;
if (profileEditToken === input.token) { if (profileEditToken === input.token) {
const userId = ctx.session.user.id;
const updated = await ctx.prisma.offersProfile.update({
data: {
users: {
connect: {
id: userId,
},
},
},
where: {
id: input.profileId,
},
});
const userId = ctx.session.user.id return addToProfileResponseMapper(updated);
const updated = await ctx.prisma.offersProfile.update({ }
data: {
user: { throw new trpc.TRPCError({
connect: { code: 'UNAUTHORIZED',
id: userId, message: 'Invalid token.',
});
},
})
.query('getUserProfiles', {
async resolve({ ctx }) {
const userId = ctx.session.user.id;
const result = await ctx.prisma.user.findFirst({
include: {
OffersProfile: {
include: {
offers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
}, },
},
offersIntern: {
include: {
monthlySalary: true,
}, },
},
}, },
where: { },
id: input.profileId, },
}, },
});
return addToProfileResponseMapper(updated);
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid token.',
});
}, },
}) where: {
.query('getUserProfiles', { id: userId,
async resolve({ ctx }) { },
const userId = ctx.session.user.id });
const result = await ctx.prisma.user.findFirst({
include: {
OffersProfile: {
include: {
offers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true
}
},
offersIntern: {
include: {
monthlySalary: true
}
}
}
}
}
}
},
where: {
id: userId
}
})
return getUserProfileResponseMapper(result)
}
})
.mutation('removeFromUserProfile', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id
const profiles = await ctx.prisma.user.findFirst({ return getUserProfileResponseMapper(result);
include: { },
OffersProfile: true })
}, .mutation('removeFromUserProfile', {
where: { input: z.object({
id: userId profileId: z.string(),
} }),
}) async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
// Validation const profiles = await ctx.prisma.user.findFirst({
let doesProfileExist = false; include: {
OffersProfile: true,
},
where: {
id: userId,
},
});
if (profiles?.OffersProfile) { // Validation
for (let i = 0; i < profiles.OffersProfile.length; i++) { let doesProfileExist = false;
if (profiles.OffersProfile[i].id === input.profileId) {
doesProfileExist = true
}
}
}
if (!doesProfileExist) { if (profiles?.OffersProfile) {
throw new TRPCError({ for (let i = 0; i < profiles.OffersProfile.length; i++) {
code: 'NOT_FOUND', if (profiles.OffersProfile[i].id === input.profileId) {
message: 'No such profile id saved.' doesProfileExist = true;
}) }
} }
}
await ctx.prisma.user.update({ if (!doesProfileExist) {
data: { throw new TRPCError({
OffersProfile: { code: 'NOT_FOUND',
disconnect: [{ message: 'No such profile id saved.',
id: input.profileId });
}] }
}
},
where: {
id: userId
}
})
} await ctx.prisma.user.update({
}) data: {
OffersProfile: {
disconnect: [
{
id: input.profileId,
},
],
},
},
where: {
id: userId,
},
});
},
});

@ -143,14 +143,17 @@ export type OffersDiscussion = {
}; };
export type ProfileAnalysis = { export type ProfileAnalysis = {
companyAnalysis: Array<Analysis>; companyAnalysis: Array<AnalysisUnit>;
createdAt: Date;
id: string; id: string;
overallAnalysis: Analysis; overallAnalysis: AnalysisUnit;
overallHighestOffer: AnalysisHighestOffer; overallHighestOffer: AnalysisHighestOffer;
profileId: string; profileId: string;
updatedAt: Date;
}; };
export type Analysis = { export type AnalysisUnit = {
companyName: string;
noOfOffers: number; noOfOffers: number;
percentile: number; percentile: number;
topPercentileOffers: Array<AnalysisOffer>; topPercentileOffers: Array<AnalysisOffer>;

Loading…
Cancel
Save