diff --git a/apps/portal/package.json b/apps/portal/package.json index 88ad7dfd..208b1940 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -15,6 +15,7 @@ "@headlessui/react": "^1.7.3", "@heroicons/react": "^2.0.11", "@next-auth/prisma-adapter": "^1.0.4", + "@popperjs/core": "^2.11.6", "@prisma/client": "^4.4.0", "@supabase/supabase-js": "^1.35.7", "@tih/ui": "*", @@ -30,8 +31,11 @@ "next-auth": "~4.10.3", "react": "18.2.0", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.36.1", "react-pdf": "^5.7.2", + "react-popper": "^2.3.0", + "react-popper-tooltip": "^4.4.2", "react-query": "^3.39.2", "superjson": "^1.10.0", "zod": "^3.18.0" diff --git a/apps/portal/prisma/migrations/20221010055218_update_question_encounter_to_use_company_id/migration.sql b/apps/portal/prisma/migrations/20221010055218_update_question_encounter_to_use_company_id/migration.sql new file mode 100644 index 00000000..c79e0b9c --- /dev/null +++ b/apps/portal/prisma/migrations/20221010055218_update_question_encounter_to_use_company_id/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `company` on the `QuestionsQuestionEncounter` table. All the data in the column will be lost. + - Added the required column `companyId` to the `QuestionsQuestionEncounter` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "QuestionsQuestionEncounter" DROP COLUMN "company", +ADD COLUMN "companyId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "QuestionsQuestionEncounter" ADD CONSTRAINT "QuestionsQuestionEncounter_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/portal/prisma/migrations/20221014192315_/migration.sql b/apps/portal/prisma/migrations/20221014192315_/migration.sql new file mode 100644 index 00000000..20352390 --- /dev/null +++ b/apps/portal/prisma/migrations/20221014192315_/migration.sql @@ -0,0 +1,60 @@ +-- CreateTable +CREATE TABLE "OffersAnalysis" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "offerId" TEXT NOT NULL, + "overallPercentile" INTEGER NOT NULL, + "noOfSimilarOffers" INTEGER NOT NULL, + "companyPercentile" INTEGER NOT NULL, + "noOfSimilarCompanyOffers" INTEGER NOT NULL, + + CONSTRAINT "OffersAnalysis_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_TopOverallOffers" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_TopCompanyOffers" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersAnalysis_profileId_key" ON "OffersAnalysis"("profileId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersAnalysis_offerId_key" ON "OffersAnalysis"("offerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_TopOverallOffers_AB_unique" ON "_TopOverallOffers"("A", "B"); + +-- CreateIndex +CREATE INDEX "_TopOverallOffers_B_index" ON "_TopOverallOffers"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_TopCompanyOffers_AB_unique" ON "_TopCompanyOffers"("A", "B"); + +-- CreateIndex +CREATE INDEX "_TopCompanyOffers_B_index" ON "_TopCompanyOffers"("B"); + +-- AddForeignKey +ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "OffersProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "OffersOffer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_TopOverallOffers" ADD CONSTRAINT "_TopOverallOffers_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysis"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_TopOverallOffers" ADD CONSTRAINT "_TopOverallOffers_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_TopCompanyOffers" ADD CONSTRAINT "_TopCompanyOffers_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysis"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_TopCompanyOffers" ADD CONSTRAINT "_TopCompanyOffers_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/migrations/20221014205230_/migration.sql b/apps/portal/prisma/migrations/20221014205230_/migration.sql new file mode 100644 index 00000000..aec827dc --- /dev/null +++ b/apps/portal/prisma/migrations/20221014205230_/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "OffersAnalysis" ALTER COLUMN "overallPercentile" SET DATA TYPE DOUBLE PRECISION, +ALTER COLUMN "companyPercentile" SET DATA TYPE DOUBLE PRECISION; diff --git a/apps/portal/prisma/migrations/20221014211740_/migration.sql b/apps/portal/prisma/migrations/20221014211740_/migration.sql new file mode 100644 index 00000000..1c960010 --- /dev/null +++ b/apps/portal/prisma/migrations/20221014211740_/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "OffersAnalysis" DROP CONSTRAINT "OffersAnalysis_offerId_fkey"; + +-- DropForeignKey +ALTER TABLE "OffersAnalysis" DROP CONSTRAINT "OffersAnalysis_profileId_fkey"; + +-- AddForeignKey +ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "OffersProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/migrations/20221019104025_update_offers_remove_optional/migration.sql b/apps/portal/prisma/migrations/20221019104025_update_offers_remove_optional/migration.sql new file mode 100644 index 00000000..ec31acd3 --- /dev/null +++ b/apps/portal/prisma/migrations/20221019104025_update_offers_remove_optional/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `totalYoe` on table `OffersBackground` required. This step will fail if there are existing NULL values in that column. + - Made the column `negotiationStrategy` on table `OffersOffer` required. This step will fail if there are existing NULL values in that column. + - Made the column `comments` on table `OffersOffer` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "OffersBackground" ALTER COLUMN "totalYoe" SET NOT NULL; + +-- AlterTable +ALTER TABLE "OffersOffer" ALTER COLUMN "negotiationStrategy" SET NOT NULL, +ALTER COLUMN "comments" SET NOT NULL; diff --git a/apps/portal/prisma/migrations/20221020101123_add_resume_comment_parent/migration.sql b/apps/portal/prisma/migrations/20221020101123_add_resume_comment_parent/migration.sql new file mode 100644 index 00000000..018ad4cd --- /dev/null +++ b/apps/portal/prisma/migrations/20221020101123_add_resume_comment_parent/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "ResumesComment" ADD COLUMN "parentId" TEXT; + +-- AddForeignKey +ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "ResumesComment"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/portal/prisma/migrations/20221020115540_/migration.sql b/apps/portal/prisma/migrations/20221020115540_/migration.sql new file mode 100644 index 00000000..a70f2aa5 --- /dev/null +++ b/apps/portal/prisma/migrations/20221020115540_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OffersExperience" ADD COLUMN "location" TEXT; diff --git a/apps/portal/prisma/migrations/20221021150358_add_vote_count_and_last_seen/migration.sql b/apps/portal/prisma/migrations/20221021150358_add_vote_count_and_last_seen/migration.sql new file mode 100644 index 00000000..a6319a17 --- /dev/null +++ b/apps/portal/prisma/migrations/20221021150358_add_vote_count_and_last_seen/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Added the required column `upvotes` to the `QuestionsQuestion` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "QuestionsQuestion" ADD COLUMN "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "upvotes" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "QuestionsQuestionEncounter" ADD COLUMN "netVotes" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/portal/prisma/migrations/20221021151424_delete_extra_encounter_fields/migration.sql b/apps/portal/prisma/migrations/20221021151424_delete_extra_encounter_fields/migration.sql new file mode 100644 index 00000000..ef9e4229 --- /dev/null +++ b/apps/portal/prisma/migrations/20221021151424_delete_extra_encounter_fields/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `netVotes` on the `QuestionsQuestionEncounter` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "QuestionsQuestion" ALTER COLUMN "lastSeenAt" DROP DEFAULT, +ALTER COLUMN "upvotes" SET DEFAULT 0; + +-- AlterTable +ALTER TABLE "QuestionsQuestionEncounter" DROP COLUMN "netVotes"; diff --git a/apps/portal/prisma/migrations/20221021155717_add_sorting_index/migration.sql b/apps/portal/prisma/migrations/20221021155717_add_sorting_index/migration.sql new file mode 100644 index 00000000..6ae3366a --- /dev/null +++ b/apps/portal/prisma/migrations/20221021155717_add_sorting_index/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "QuestionsQuestion_lastSeenAt_id_idx" ON "QuestionsQuestion"("lastSeenAt", "id"); + +-- CreateIndex +CREATE INDEX "QuestionsQuestion_upvotes_id_idx" ON "QuestionsQuestion"("upvotes", "id"); diff --git a/apps/portal/prisma/migrations/20221021231817_/migration.sql b/apps/portal/prisma/migrations/20221021231817_/migration.sql new file mode 100644 index 00000000..3820338d --- /dev/null +++ b/apps/portal/prisma/migrations/20221021231817_/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Added the required column `baseValue` to the `OffersCurrency` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `OffersCurrency` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "OffersCurrency" ADD COLUMN "baseCurrency" TEXT NOT NULL DEFAULT 'USD', +ADD COLUMN "baseValue" INTEGER NOT NULL, +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; diff --git a/apps/portal/prisma/migrations/20221021233952_change_currency_values_to_float/migration.sql b/apps/portal/prisma/migrations/20221021233952_change_currency_values_to_float/migration.sql new file mode 100644 index 00000000..089e963d --- /dev/null +++ b/apps/portal/prisma/migrations/20221021233952_change_currency_values_to_float/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "OffersCurrency" ALTER COLUMN "value" SET DATA TYPE DOUBLE PRECISION, +ALTER COLUMN "baseValue" SET DATA TYPE DOUBLE PRECISION; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 4a89b7e4..04f4af77 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -1,106 +1,107 @@ // Refer to the Prisma schema docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") } // Necessary for NextAuth. model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) } model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId String - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - accounts Account[] - sessions Session[] - todos Todo[] - resumesResumes ResumesResume[] - resumesStars ResumesStar[] - resumesComments ResumesComment[] - resumesCommentVotes ResumesCommentVote[] - questionsQuestions QuestionsQuestion[] - questionsQuestionEncounters QuestionsQuestionEncounter[] - questionsQuestionVotes QuestionsQuestionVote[] - questionsQuestionComments QuestionsQuestionComment[] - questionsQuestionCommentVotes QuestionsQuestionCommentVote[] - questionsAnswers QuestionsAnswer[] - questionsAnswerVotes QuestionsAnswerVote[] - questionsAnswerComments QuestionsAnswerComment[] - questionsAnswerCommentVotes QuestionsAnswerCommentVote[] - OffersProfile OffersProfile[] - offersDiscussion OffersReply[] - QuestionsList QuestionsList[] + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + todos Todo[] + resumesResumes ResumesResume[] + resumesStars ResumesStar[] + resumesComments ResumesComment[] + resumesCommentVotes ResumesCommentVote[] + questionsQuestions QuestionsQuestion[] + questionsQuestionEncounters QuestionsQuestionEncounter[] + questionsQuestionVotes QuestionsQuestionVote[] + questionsQuestionComments QuestionsQuestionComment[] + questionsQuestionCommentVotes QuestionsQuestionCommentVote[] + questionsAnswers QuestionsAnswer[] + questionsAnswerVotes QuestionsAnswerVote[] + questionsAnswerComments QuestionsAnswerComment[] + questionsAnswerCommentVotes QuestionsAnswerCommentVote[] + OffersProfile OffersProfile[] + offersDiscussion OffersReply[] } enum Vote { - UPVOTE - DOWNVOTE + UPVOTE + DOWNVOTE } model VerificationToken { - identifier String - token String @unique - expires DateTime + identifier String + token String @unique + expires DateTime - @@unique([identifier, token]) + @@unique([identifier, token]) } model Todo { - id String @id @default(cuid()) - userId String - text String @db.Text - status TodoStatus @default(INCOMPLETE) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + text String @db.Text + status TodoStatus @default(INCOMPLETE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum TodoStatus { - INCOMPLETE - COMPLETE + INCOMPLETE + COMPLETE } model Company { - id String @id @default(cuid()) - name String @db.Text - slug String @unique - description String? @db.Text - logoUrl String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - OffersExperience OffersExperience[] - OffersOffer OffersOffer[] + id String @id @default(cuid()) + name String @db.Text + slug String @unique + description String? @db.Text + logoUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + questionsQuestionEncounter QuestionsQuestionEncounter[] + OffersExperience OffersExperience[] + OffersOffer OffersOffer[] } // Start of Resumes project models. @@ -108,65 +109,68 @@ model Company { // use camelCase for field names, and try to name them consistently // across all models in this file. model ResumesResume { - id String @id @default(cuid()) - userId String - title String @db.Text - // TODO: Update role, experience, location to use Enums - role String @db.Text - experience String @db.Text - location String @db.Text - url String - additionalInfo String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - stars ResumesStar[] - comments ResumesComment[] + id String @id @default(cuid()) + userId String + title String @db.Text + // TODO: Update role, experience, location to use Enums + role String @db.Text + experience String @db.Text + location String @db.Text + url String + additionalInfo String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + stars ResumesStar[] + comments ResumesComment[] } model ResumesStar { - id String @id @default(cuid()) - userId String - resumeId String - createdAt DateTime @default(now()) - resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + resumeId String + createdAt DateTime @default(now()) + resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([userId, resumeId]) + @@unique([userId, resumeId]) } model ResumesComment { - id String @id @default(cuid()) - userId String - resumeId String - description String @db.Text - section ResumesSection - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) - votes ResumesCommentVote[] - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + resumeId String + parentId String? + description String @db.Text + section ResumesSection + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + votes ResumesCommentVote[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + parent ResumesComment? @relation("parentComment", fields: [parentId], references: [id]) + children ResumesComment[] @relation("parentComment") } enum ResumesSection { - GENERAL - EDUCATION - EXPERIENCE - PROJECTS - SKILLS + GENERAL + EDUCATION + EXPERIENCE + PROJECTS + SKILLS } model ResumesCommentVote { - id String @id @default(cuid()) - userId String - commentId String - value Vote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + commentId String + value Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([userId, commentId]) + @@unique([userId, commentId]) } // End of Resumes project models. @@ -177,176 +181,209 @@ model ResumesCommentVote { // across all models in this file. model OffersProfile { - id String @id @default(cuid()) - profileName String @unique - createdAt DateTime @default(now()) + id String @id @default(cuid()) + profileName String @unique + createdAt DateTime @default(now()) + + background OffersBackground? - background OffersBackground? + editToken String - editToken String + discussion OffersReply[] - discussion OffersReply[] + offers OffersOffer[] - offers OffersOffer[] + user User? @relation(fields: [userId], references: [id]) + userId String? - user User? @relation(fields: [userId], references: [id]) - userId String? + analysis OffersAnalysis? } model OffersBackground { - id String @id @default(cuid()) + id String @id @default(cuid()) - totalYoe Int? - specificYoes OffersSpecificYoe[] + totalYoe Int + specificYoes OffersSpecificYoe[] - experiences OffersExperience[] // For extensibility in the future + experiences OffersExperience[] - educations OffersEducation[] // For extensibility in the future + educations OffersEducation[] - profile OffersProfile @relation(fields: [offersProfileId], references: [id], onDelete: Cascade) - offersProfileId String @unique + profile OffersProfile @relation(fields: [offersProfileId], references: [id], onDelete: Cascade) + offersProfileId String @unique } model OffersSpecificYoe { - id String @id @default(cuid()) + id String @id @default(cuid()) - yoe Int - domain String + yoe Int + domain String - background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade) - backgroundId String + background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade) + backgroundId String } model OffersExperience { - id String @id @default(cuid()) + id String @id @default(cuid()) - company Company? @relation(fields: [companyId], references: [id]) - companyId String? + company Company? @relation(fields: [companyId], references: [id]) + companyId String? - jobType JobType? - title String? + jobType JobType? + title String? - // Add more fields - durationInMonths Int? - specialization String? + // Add more fields + durationInMonths Int? + specialization String? + location String? - // FULLTIME fields - level String? - totalCompensation OffersCurrency? @relation("ExperienceTotalCompensation", fields: [totalCompensationId], references: [id]) - totalCompensationId String? @unique + // FULLTIME fields + level String? + totalCompensation OffersCurrency? @relation("ExperienceTotalCompensation", fields: [totalCompensationId], references: [id]) + totalCompensationId String? @unique - // INTERN fields - monthlySalary OffersCurrency? @relation("ExperienceMonthlySalary", fields: [monthlySalaryId], references: [id]) - monthlySalaryId String? @unique + // INTERN fields + monthlySalary OffersCurrency? @relation("ExperienceMonthlySalary", fields: [monthlySalaryId], references: [id]) + monthlySalaryId String? @unique - background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade) - backgroundId String + background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade) + backgroundId String } model OffersCurrency { - id String @id @default(cuid()) - value Int - currency String + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + value Float + currency String + + baseValue Float + baseCurrency String @default("USD") - // Experience - OffersExperienceTotalCompensation OffersExperience? @relation("ExperienceTotalCompensation") - OffersExperienceMonthlySalary OffersExperience? @relation("ExperienceMonthlySalary") + // Experience + OffersExperienceTotalCompensation OffersExperience? @relation("ExperienceTotalCompensation") + OffersExperienceMonthlySalary OffersExperience? @relation("ExperienceMonthlySalary") - // Full Time - OffersTotalCompensation OffersFullTime? @relation("OfferTotalCompensation") - OffersBaseSalary OffersFullTime? @relation("OfferBaseSalary") - OffersBonus OffersFullTime? @relation("OfferBonus") - OffersStocks OffersFullTime? @relation("OfferStocks") + // Full Time + OffersTotalCompensation OffersFullTime? @relation("OfferTotalCompensation") + OffersBaseSalary OffersFullTime? @relation("OfferBaseSalary") + OffersBonus OffersFullTime? @relation("OfferBonus") + OffersStocks OffersFullTime? @relation("OfferStocks") - // Intern - OffersMonthlySalary OffersIntern? + // Intern + OffersMonthlySalary OffersIntern? } enum JobType { - INTERN - FULLTIME + INTERN + FULLTIME } model OffersEducation { - id String @id @default(cuid()) - type String? - field String? + id String @id @default(cuid()) + type String? + field String? - school String? - startDate DateTime? - endDate DateTime? + school String? + startDate DateTime? + endDate DateTime? - background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade) - backgroundId String + background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade) + backgroundId String } model OffersReply { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - message String + id String @id @default(cuid()) + createdAt DateTime @default(now()) + message String - replyingToId String? - replyingTo OffersReply? @relation("ReplyThread", fields: [replyingToId], references: [id]) - replies OffersReply[] @relation("ReplyThread") + replyingToId String? + replyingTo OffersReply? @relation("ReplyThread", fields: [replyingToId], references: [id]) + replies OffersReply[] @relation("ReplyThread") - profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) - profileId String + profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + profileId String - user User? @relation(fields: [userId], references: [id]) - userId String? + user User? @relation(fields: [userId], references: [id]) + userId String? } model OffersOffer { - id String @id @default(cuid()) + id String @id @default(cuid()) - profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) - profileId String + profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + profileId String - company Company @relation(fields: [companyId], references: [id]) - companyId String + company Company @relation(fields: [companyId], references: [id]) + companyId String - monthYearReceived DateTime - location String - negotiationStrategy String? - comments String? + monthYearReceived DateTime + location String + negotiationStrategy String + comments String - jobType JobType + jobType JobType - OffersIntern OffersIntern? @relation(fields: [offersInternId], references: [id], onDelete: Cascade) - offersInternId String? @unique + offersIntern OffersIntern? @relation(fields: [offersInternId], references: [id], onDelete: Cascade) + offersInternId String? @unique - OffersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade) - offersFullTimeId String? @unique + offersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade) + offersFullTimeId String? @unique + + OffersAnalysis OffersAnalysis? @relation("HighestOverallOffer") + OffersAnalysisTopOverallOffers OffersAnalysis[] @relation("TopOverallOffers") + OffersAnalysisTopCompanyOffers OffersAnalysis[] @relation("TopCompanyOffers") } model OffersIntern { - id String @id @default(cuid()) + id String @id @default(cuid()) - title String - specialization String - internshipCycle String - startYear Int - monthlySalary OffersCurrency @relation(fields: [monthlySalaryId], references: [id], onDelete: Cascade) - monthlySalaryId String @unique + title String + specialization String + internshipCycle String + startYear Int + monthlySalary OffersCurrency @relation(fields: [monthlySalaryId], references: [id], onDelete: Cascade) + monthlySalaryId String @unique - OffersOffer OffersOffer? + OffersOffer OffersOffer? } model OffersFullTime { - id String @id @default(cuid()) - title String - specialization String - level String - totalCompensation OffersCurrency @relation("OfferTotalCompensation", fields: [totalCompensationId], references: [id], onDelete: Cascade) - totalCompensationId String @unique - baseSalary OffersCurrency @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id], onDelete: Cascade) - baseSalaryId String @unique - bonus OffersCurrency @relation("OfferBonus", fields: [bonusId], references: [id], onDelete: Cascade) - bonusId String @unique - stocks OffersCurrency @relation("OfferStocks", fields: [stocksId], references: [id], onDelete: Cascade) - stocksId String @unique - - OffersOffer OffersOffer? + id String @id @default(cuid()) + title String + specialization String + level String + totalCompensation OffersCurrency @relation("OfferTotalCompensation", fields: [totalCompensationId], references: [id], onDelete: Cascade) + totalCompensationId String @unique + baseSalary OffersCurrency @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id], onDelete: Cascade) + baseSalaryId String @unique + bonus OffersCurrency @relation("OfferBonus", fields: [bonusId], references: [id], onDelete: Cascade) + bonusId String @unique + stocks OffersCurrency @relation("OfferStocks", fields: [stocksId], references: [id], onDelete: Cascade) + stocksId String @unique + + OffersOffer OffersOffer? +} + +model OffersAnalysis { + id String @id @default(cuid()) + + profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + profileId String @unique + + overallHighestOffer OffersOffer @relation("HighestOverallOffer", fields: [offerId], references: [id], onDelete: Cascade) + offerId String @unique + + // OVERALL + overallPercentile Float + noOfSimilarOffers Int + topOverallOffers OffersOffer[] @relation("TopOverallOffers") + + // Company + companyPercentile Float + noOfSimilarCompanyOffers Int + topCompanyOffers OffersOffer[] @relation("TopCompanyOffers") } // End of Offers project models. @@ -357,163 +394,168 @@ model OffersFullTime { // across all models in this file. enum QuestionsQuestionType { - CODING - SYSTEM_DESIGN - BEHAVIORAL + CODING + SYSTEM_DESIGN + BEHAVIORAL } model QuestionsQuestion { - id String @id @default(cuid()) - userId String? - content String @db.Text - questionType QuestionsQuestionType - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String? + content String @db.Text + questionType QuestionsQuestionType + lastSeenAt DateTime + upvotes Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + encounters QuestionsQuestionEncounter[] + votes QuestionsQuestionVote[] + comments QuestionsQuestionComment[] + answers QuestionsAnswer[] - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - encounters QuestionsQuestionEncounter[] - votes QuestionsQuestionVote[] - comments QuestionsQuestionComment[] - answers QuestionsAnswer[] - listQuestionEntries QuestionsListQuestion[] + @@index([lastSeenAt, id]) + @@index([upvotes, id]) } model QuestionsQuestionEncounter { - id String @id @default(cuid()) - questionId String - userId String? - // TODO: sync with models - company String @db.Text - location String @db.Text - role String @db.Text - seenAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + questionId String + userId String? + // TODO: sync with models (location, role) + companyId String + location String @db.Text + role String @db.Text + seenAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) } model QuestionsQuestionVote { - id String @id @default(cuid()) - questionId String - userId String? - vote Vote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + questionId String + userId String? + vote Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) - @@unique([questionId, userId]) + @@unique([questionId, userId]) } model QuestionsQuestionComment { - id String @id @default(cuid()) - questionId String - userId String? - content String @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + questionId String + userId String? + content String @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) - votes QuestionsQuestionCommentVote[] + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) + votes QuestionsQuestionCommentVote[] } model QuestionsQuestionCommentVote { - id String @id @default(cuid()) - questionCommentId String - userId String? - vote Vote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + questionCommentId String + userId String? + vote Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - comment QuestionsQuestionComment @relation(fields: [questionCommentId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + comment QuestionsQuestionComment @relation(fields: [questionCommentId], references: [id], onDelete: Cascade) - @@unique([questionCommentId, userId]) + @@unique([questionCommentId, userId]) } model QuestionsAnswer { - id String @id @default(cuid()) - questionId String - userId String? - content String @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + questionId String + userId String? + content String @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) - votes QuestionsAnswerVote[] - comments QuestionsAnswerComment[] + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) + votes QuestionsAnswerVote[] + comments QuestionsAnswerComment[] } model QuestionsAnswerVote { - id String @id @default(cuid()) - answerId String - userId String? - vote Vote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + answerId String + userId String? + vote Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade) - @@unique([answerId, userId]) + @@unique([answerId, userId]) } model QuestionsAnswerComment { - id String @id @default(cuid()) - answerId String - userId String? - content String @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + answerId String + userId String? + content String @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade) - votes QuestionsAnswerCommentVote[] + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade) + votes QuestionsAnswerCommentVote[] } model QuestionsAnswerCommentVote { - id String @id @default(cuid()) - answerCommentId String - userId String? - vote Vote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + answerCommentId String + userId String? + vote Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - comment QuestionsAnswerComment @relation(fields: [answerCommentId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + comment QuestionsAnswerComment @relation(fields: [answerCommentId], references: [id], onDelete: Cascade) - @@unique([answerCommentId, userId]) + @@unique([answerCommentId, userId]) } model QuestionsList { - id String @id @default(cuid()) - userId String - name String @db.VarChar(256) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + name String @db.VarChar(256) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - questionEntries QuestionsListQuestion[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + questionEntries QuestionsListQuestion[] - @@unique([userId, name]) + @@unique([userId, name]) } model QuestionsListQuestionEntry { - id String @id @default(cuid()) - listId String - questionId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + listId String + questionId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - list QuestionsList @relation(fields: [listId], references: [id], onDelete: Cascade) - question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) + list QuestionsList @relation(fields: [listId], references: [id], onDelete: Cascade) + question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) - @@unique([listId, questionId]) + @@unique([listId, questionId]) } // End of Questions project models. diff --git a/apps/portal/prisma/seed.ts b/apps/portal/prisma/seed.ts index c31d6705..0e736d19 100644 --- a/apps/portal/prisma/seed.ts +++ b/apps/portal/prisma/seed.ts @@ -35,34 +35,6 @@ const COMPANIES = [ }, ]; -const OFFER_PROFILES = [ - { - id: 'cl91v97ex000109mt7fka5rto', - profileName: 'battery-horse-stable-cow', - editToken: 'cl91ulmhg000009l86o45aspt', - }, - { - id: 'cl91v9iw2000209mtautgdnxq', - profileName: 'house-zebra-fast-giraffe', - editToken: 'cl91umigc000109l80f1tcqe8', - }, - { - id: 'cl91v9m3y000309mt1ctw55wi', - profileName: 'keyboard-mouse-lazy-cat', - editToken: 'cl91ummoa000209l87q2b8hl7', - }, - { - id: 'cl91v9p09000409mt5rvoasf1', - profileName: 'router-hen-bright-pig', - editToken: 'cl91umqa3000309l87jyefe9k', - }, - { - id: 'cl91v9uda000509mt5i5fez3v', - profileName: 'screen-ant-dirty-bird', - editToken: 'cl91umuj9000409l87ez85vmg', - }, -]; - async function main() { console.log('Seeding started...'); await Promise.all([ @@ -73,13 +45,6 @@ async function main() { create: company, }); }), - OFFER_PROFILES.map(async (offerProfile) => { - await prisma.offersProfile.upsert({ - where: { profileName: offerProfile.profileName }, - update: offerProfile, - create: offerProfile, - }); - }), ]); console.log('Seeding completed.'); } diff --git a/apps/portal/src/components/offers/constants.ts b/apps/portal/src/components/offers/constants.ts index e360a505..10e3b0b1 100644 --- a/apps/portal/src/components/offers/constants.ts +++ b/apps/portal/src/components/offers/constants.ts @@ -5,43 +5,20 @@ export const emptyOption = '----'; // TODO: use enums export const titleOptions = [ { - label: 'Software engineer', - value: 'Software engineer', + label: 'Software Engineer', + value: 'Software Engineer', }, { - label: 'Frontend engineer', - value: 'Frontend engineer', + label: 'Frontend Engineer', + value: 'Frontend Engineer', }, { - label: 'Backend engineer', - value: 'Backend engineer', + label: 'Backend Engineer', + value: 'Backend Engineer', }, { - label: 'Full-stack engineer', - value: 'Full-stack engineer', - }, -]; - -export const companyOptions = [ - { - label: 'Amazon', - value: 'cl93patjt0000txewdi601mub', - }, - { - label: 'Microsoft', - value: 'cl93patjt0001txewkglfjsro', - }, - { - label: 'Apple', - value: 'cl93patjt0002txewf3ug54m8', - }, - { - label: 'Google', - value: 'cl93patjt0003txewyiaky7xx', - }, - { - label: 'Meta', - value: 'cl93patjt0004txew88wkcqpu', + label: 'Full-stack Engineer', + value: 'Full-stack Engineer', }, ]; @@ -86,26 +63,26 @@ export const internshipCycleOptions = [ export const yearOptions = [ { label: '2021', - value: '2021', + value: 2021, }, { label: '2022', - value: '2022', + value: 2022, }, { label: '2023', - value: '2023', + value: 2023, }, { label: '2024', - value: '2024', + value: 2024, }, ]; export const educationLevelOptions = Object.entries( EducationBackgroundType, -).map(([key, value]) => ({ - label: key, +).map(([, value]) => ({ + label: value, value, })); @@ -118,14 +95,45 @@ export const educationFieldOptions = [ label: 'Information Security', value: 'Information Security', }, + { + label: 'Information Systems', + value: 'Information Systems', + }, { label: 'Business Analytics', value: 'Business Analytics', }, + { + label: 'Data Science and Analytics', + value: 'Data Science and Analytics', + }, ]; export enum FieldError { - NonNegativeNumber = 'Please fill in a non-negative number in this field.', - Number = 'Please fill in a number in this field.', - Required = 'Please fill in this field.', + NON_NEGATIVE_NUMBER = 'Please fill in a non-negative number in this field.', + NUMBER = 'Please fill in a number in this field.', + REQUIRED = 'Please fill in this field.', } + +export const OVERALL_TAB = 'Overall'; + +export enum ProfileDetailTab { + ANALYSIS = 'Offer Engine Analysis', + BACKGROUND = 'Background', + OFFERS = 'Offers', +} + +export const profileDetailTabs = [ + { + label: ProfileDetailTab.OFFERS, + value: ProfileDetailTab.OFFERS, + }, + { + label: ProfileDetailTab.BACKGROUND, + value: ProfileDetailTab.BACKGROUND, + }, + { + label: ProfileDetailTab.ANALYSIS, + value: ProfileDetailTab.ANALYSIS, + }, +]; diff --git a/apps/portal/src/components/offers/forms/components/FormMonthYearPicker.tsx b/apps/portal/src/components/offers/forms/FormMonthYearPicker.tsx similarity index 91% rename from apps/portal/src/components/offers/forms/components/FormMonthYearPicker.tsx rename to apps/portal/src/components/offers/forms/FormMonthYearPicker.tsx index 2b6c414a..ca036b0e 100644 --- a/apps/portal/src/components/offers/forms/components/FormMonthYearPicker.tsx +++ b/apps/portal/src/components/offers/forms/FormMonthYearPicker.tsx @@ -4,7 +4,7 @@ import { useFormContext, useWatch } from 'react-hook-form'; import MonthYearPicker from '~/components/shared/MonthYearPicker'; -import { getCurrentMonth, getCurrentYear } from '../../../../utils/offers/time'; +import { getCurrentMonth, getCurrentYear } from '../../../utils/offers/time'; type MonthYearPickerProps = ComponentProps; diff --git a/apps/portal/src/components/offers/forms/components/FormRadioList.tsx b/apps/portal/src/components/offers/forms/FormRadioList.tsx similarity index 100% rename from apps/portal/src/components/offers/forms/components/FormRadioList.tsx rename to apps/portal/src/components/offers/forms/FormRadioList.tsx diff --git a/apps/portal/src/components/offers/forms/components/FormSelect.tsx b/apps/portal/src/components/offers/forms/FormSelect.tsx similarity index 100% rename from apps/portal/src/components/offers/forms/components/FormSelect.tsx rename to apps/portal/src/components/offers/forms/FormSelect.tsx diff --git a/apps/portal/src/components/offers/forms/components/FormTextArea.tsx b/apps/portal/src/components/offers/forms/FormTextArea.tsx similarity index 100% rename from apps/portal/src/components/offers/forms/components/FormTextArea.tsx rename to apps/portal/src/components/offers/forms/FormTextArea.tsx diff --git a/apps/portal/src/components/offers/forms/components/FormTextInput.tsx b/apps/portal/src/components/offers/forms/FormTextInput.tsx similarity index 100% rename from apps/portal/src/components/offers/forms/components/FormTextInput.tsx rename to apps/portal/src/components/offers/forms/FormTextInput.tsx diff --git a/apps/portal/src/components/offers/forms/OfferAnalysis.tsx b/apps/portal/src/components/offers/forms/OfferAnalysis.tsx deleted file mode 100644 index c3625d6d..00000000 --- a/apps/portal/src/components/offers/forms/OfferAnalysis.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useState } from 'react'; -import { UserCircleIcon } from '@heroicons/react/20/solid'; -import { HorizontalDivider, Tabs } from '@tih/ui'; - -const tabs = [ - { - label: 'Overall', - value: 'overall', - }, - { - label: 'Shopee', - value: 'company-id', - }, -]; - -function OfferPercentileAnalysis() { - const result = { - company: 'Shopee', - numberOfOffers: 105, - percentile: 56, - }; - - return ( -

- Your highest offer is from {result.company}, which is {result.percentile}{' '} - percentile out of {result.numberOfOffers} offers received in Singapore for - the same job type, same level, and same YOE in the last year. -

- ); -} - -function OfferProfileCard() { - return ( -
-
-
- -
-
-

profile-name

-

Previous company: Meta, Singapore

-

YOE: 4 years

-
-
- - -
-
-

Software engineer

-

Company: Google, Singapore

-

Level: G4

-
-
-

Sept 2022

-

$125,000 / year

-
-
-
- ); -} - -function TopOfferProfileList() { - return ( - <> - - - - ); -} - -function OfferAnalysisContent() { - return ( - <> - - - - ); -} - -export default function OfferAnalysis() { - const [tab, setTab] = useState('Overall'); - - return ( -
-
- Result -
-
- - - -
-
- ); -} diff --git a/apps/portal/src/components/offers/offerAnalysis/OfferAnalysis.tsx b/apps/portal/src/components/offers/offerAnalysis/OfferAnalysis.tsx new file mode 100644 index 00000000..67c9c9e1 --- /dev/null +++ b/apps/portal/src/components/offers/offerAnalysis/OfferAnalysis.tsx @@ -0,0 +1,126 @@ +import { useEffect } from 'react'; +import { useState } from 'react'; +import { HorizontalDivider, Spinner, Tabs } from '@tih/ui'; + +import OfferPercentileAnalysisText from './OfferPercentileAnalysisText'; +import OfferProfileCard from './OfferProfileCard'; +import { OVERALL_TAB } from '../constants'; + +import type { + Analysis, + AnalysisHighestOffer, + ProfileAnalysis, +} from '~/types/offers'; + +type OfferAnalysisData = { + offer?: AnalysisHighestOffer; + offerAnalysis?: Analysis; +}; + +type OfferAnalysisContentProps = Readonly<{ + analysis: OfferAnalysisData; + tab: string; +}>; + +function OfferAnalysisContent({ + analysis: { offer, offerAnalysis }, + tab, +}: OfferAnalysisContentProps) { + if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) { + if (tab === OVERALL_TAB) { + return ( +

+ You are the first to submit an offer for your job title and YOE! Check + back later when there are more submissions. +

+ ); + } + return ( +

+ You are the first to submit an offer for this company, job title and + YOE! Check back later when there are more submissions. +

+ ); + } + return ( + <> + +

Here are some of the top offers relevant to you:

+ {offerAnalysis.topPercentileOffers.map((topPercentileOffer) => ( + + ))} + + ); +} + +type OfferAnalysisProps = Readonly<{ + allAnalysis?: ProfileAnalysis | null; + isError: boolean; + isLoading: boolean; +}>; + +export default function OfferAnalysis({ + allAnalysis, + isError, + isLoading, +}: OfferAnalysisProps) { + const [tab, setTab] = useState(OVERALL_TAB); + const [analysis, setAnalysis] = useState(null); + + useEffect(() => { + if (tab === OVERALL_TAB) { + setAnalysis({ + offer: allAnalysis?.overallHighestOffer, + offerAnalysis: allAnalysis?.overallAnalysis, + }); + } else { + setAnalysis({ + offer: allAnalysis?.overallHighestOffer, + offerAnalysis: allAnalysis?.companyAnalysis[0], + }); + } + }, [tab, allAnalysis]); + + const tabOptions = [ + { + label: OVERALL_TAB, + value: OVERALL_TAB, + }, + { + label: allAnalysis?.overallHighestOffer.company.name || '', + value: allAnalysis?.overallHighestOffer.company.id || '', + }, + ]; + + return ( + analysis && ( +
+ {isError && ( +

+ An error occurred while generating profile analysis. +

+ )} + {isLoading && } + {!isError && !isLoading && ( +
+ + + +
+ )} +
+ ) + ); +} diff --git a/apps/portal/src/components/offers/offerAnalysis/OfferPercentileAnalysisText.tsx b/apps/portal/src/components/offers/offerAnalysis/OfferPercentileAnalysisText.tsx new file mode 100644 index 00000000..d61af844 --- /dev/null +++ b/apps/portal/src/components/offers/offerAnalysis/OfferPercentileAnalysisText.tsx @@ -0,0 +1,29 @@ +import { OVERALL_TAB } from '../constants'; + +import type { Analysis } from '~/types/offers'; + +type OfferPercentileAnalysisTextProps = Readonly<{ + companyName: string; + offerAnalysis: Analysis; + tab: string; +}>; + +export default function OfferPercentileAnalysisText({ + tab, + companyName, + offerAnalysis: { noOfOffers, percentile }, +}: OfferPercentileAnalysisTextProps) { + return tab === OVERALL_TAB ? ( +

+ Your highest offer is from {companyName}, which is{' '} + {percentile.toFixed(1)} percentile out of {noOfOffers}{' '} + offers received for the same job title and YOE(±1) in the last year. +

+ ) : ( +

+ Your offer from {companyName} is {percentile.toFixed(1)}{' '} + percentile out of {noOfOffers} offers received in {companyName} for + the same job title and YOE(±1) in the last year. +

+ ); +} diff --git a/apps/portal/src/components/offers/offerAnalysis/OfferProfileCard.tsx b/apps/portal/src/components/offers/offerAnalysis/OfferProfileCard.tsx new file mode 100644 index 00000000..af786c4b --- /dev/null +++ b/apps/portal/src/components/offers/offerAnalysis/OfferProfileCard.tsx @@ -0,0 +1,74 @@ +import { + BuildingOffice2Icon, + CalendarDaysIcon, +} from '@heroicons/react/24/outline'; +import { JobType } from '@prisma/client'; + +import { HorizontalDivider } from '~/../../../packages/ui/dist'; +import { convertMoneyToString } from '~/utils/offers/currency'; +import { formatDate } from '~/utils/offers/time'; + +import ProfilePhotoHolder from '../profile/ProfilePhotoHolder'; + +import type { AnalysisOffer } from '~/types/offers'; + +type OfferProfileCardProps = Readonly<{ + offerProfile: AnalysisOffer; +}>; + +export default function OfferProfileCard({ + offerProfile: { + company, + income, + profileName, + totalYoe, + level, + monthYearReceived, + jobType, + location, + title, + previousCompanies, + }, +}: OfferProfileCardProps) { + return ( +
+
+
+ +
+
+

{profileName}

+
+ + Current: + {previousCompanies[0]} +
+
+ + YOE: + {totalYoe} +
+
+
+ + +
+
+

{title}

+

+ Company: {company.name}, {location} +

+

Level: {level}

+
+
+

{formatDate(monthYearReceived)}

+

+ {jobType === JobType.FULLTIME + ? `${convertMoneyToString(income)} / year` + : `${convertMoneyToString(income)} / month`} +

+
+
+
+ ); +} diff --git a/apps/portal/src/components/offers/forms/OfferProfileSave.tsx b/apps/portal/src/components/offers/offersSubmission/OffersProfileSave.tsx similarity index 66% rename from apps/portal/src/components/offers/forms/OfferProfileSave.tsx rename to apps/portal/src/components/offers/offersSubmission/OffersProfileSave.tsx index 866fa8e7..f113ffdb 100644 --- a/apps/portal/src/components/offers/forms/OfferProfileSave.tsx +++ b/apps/portal/src/components/offers/offersSubmission/OffersProfileSave.tsx @@ -1,13 +1,30 @@ +import { useRouter } from 'next/router'; import { useState } from 'react'; import { setTimeout } from 'timers'; import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid'; import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline'; 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 OffersProfileSave({ + profileId, + token, +}: OfferProfileSaveProps) { const [linkCopied, setLinkCopied] = useState(false); const [isSaving, setSaving] = useState(false); const [isSaved, setSaved] = useState(false); + const router = useRouter(); + const saveProfile = () => { setSaving(true); setTimeout(() => { @@ -27,13 +44,13 @@ export default function OfferProfileSave() { To keep you offer profile strictly anonymous, only people who have the link below can edit it.

-
+
-
+
{linkCopied && (

Link copied to clipboard!

)} @@ -52,20 +71,26 @@ export default function OfferProfileSave() {

If you do not want to keep the edit link, you can opt to save this - profile under your user accont. It will still only be editable by you. + profile under your user account. It will still only be editable by + you.

-
-
diff --git a/apps/portal/src/components/offers/offersSubmission/OffersSubmissionForm.tsx b/apps/portal/src/components/offers/offersSubmission/OffersSubmissionForm.tsx new file mode 100644 index 00000000..95d43be8 --- /dev/null +++ b/apps/portal/src/components/offers/offersSubmission/OffersSubmissionForm.tsx @@ -0,0 +1,263 @@ +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 { JobType } from '@prisma/client'; +import { Button } from '@tih/ui'; + +import { Breadcrumbs } from '~/components/offers/Breadcrumb'; +import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave'; +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 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 OfferAnalysis from '../offerAnalysis/OfferAnalysis'; + +import type { + CreateOfferProfileResponse, + ProfileAnalysis, +} 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({ + id: profileId || '', + token: token || '', + }); + const [analysis, setAnalysis] = useState(null); + + const pageRef = useRef(null); + const scrollToTop = () => + pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 }); + const formMethods = useForm({ + defaultValues: initialOfferProfileValues, + mode: 'all', + }); + const { handleSubmit, trigger } = formMethods; + + const generateAnalysisMutation = trpc.useMutation( + ['offers.analysis.generate'], + { + onError(error) { + console.error(error.message); + }, + onSuccess(data) { + setAnalysis(data); + }, + }, + ); + + const formSteps: Array = [ + { + component: ( + + ), + hasNext: true, + hasPrevious: false, + label: 'Offer details', + }, + { + component: , + hasNext: false, + hasPrevious: true, + label: 'Background', + }, + { + component: ( + + ), + hasNext: true, + hasPrevious: false, + label: 'Analysis', + }, + { + component: ( + + ), + 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 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 = 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 ( +
+
+
+
+ +
+ +
+ {formSteps[formStep].component} + {/*
{JSON.stringify(formMethods.watch(), null, 2)}
*/} + {formSteps[formStep].hasNext && ( +
+
+ )} + {formStep === 1 && ( +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/components/offers/forms/BackgroundForm.tsx b/apps/portal/src/components/offers/offersSubmission/submissionForm/BackgroundForm.tsx similarity index 63% rename from apps/portal/src/components/offers/forms/BackgroundForm.tsx rename to apps/portal/src/components/offers/offersSubmission/submissionForm/BackgroundForm.tsx index 61a1c3fe..aaa25e54 100644 --- a/apps/portal/src/components/offers/forms/BackgroundForm.tsx +++ b/apps/portal/src/components/offers/offersSubmission/submissionForm/BackgroundForm.tsx @@ -1,22 +1,29 @@ import { useFormContext, useWatch } from 'react-hook-form'; +import { JobType } from '@prisma/client'; import { Collapsible, RadioList } from '@tih/ui'; import { - companyOptions, educationFieldOptions, educationLevelOptions, + emptyOption, + FieldError, locationOptions, titleOptions, } from '~/components/offers/constants'; -import FormRadioList from '~/components/offers/forms/components/FormRadioList'; -import FormSelect from '~/components/offers/forms/components/FormSelect'; -import FormTextInput from '~/components/offers/forms/components/FormTextInput'; -import { JobType } from '~/components/offers/types'; +import type { BackgroundPostData } from '~/components/offers/types'; +import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; +import FormRadioList from '../../forms/FormRadioList'; +import FormSelect from '../../forms/FormSelect'; +import FormTextInput from '../../forms/FormTextInput'; + function YoeSection() { - const { register } = useFormContext(); + const { register, formState } = useFormContext<{ + background: BackgroundPostData; + }>(); + const backgroundFields = formState.errors.background; return ( <>
@@ -26,53 +33,62 @@ function YoeSection() {
-
- -
- - -
-
- - -
-
-
+ +
+ + +
+
+ + +
+
); } function FullTimeJobFields() { - const { register } = useFormContext(); + const { register, setValue, formState } = useFormContext<{ + background: BackgroundPostData; + }>(); + const experiencesField = formState.errors.background?.experiences?.[0]; return ( <>
@@ -80,14 +96,16 @@ function FullTimeJobFields() { display="block" label="Title" options={titleOptions} + placeholder={emptyOption} {...register(`background.experiences.0.title`)} /> - +
+ + setValue(`background.experiences.0.companyId`, value) + } + /> +
} endAddOnType="element" + errorMessage={experiencesField?.totalCompensation?.value?.message} label="Total Compensation (Annual)" placeholder="0.00" startAddOn="$" startAddOnType="label" type="number" {...register(`background.experiences.0.totalCompensation.value`, { + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, valueAsNumber: true, })} /> @@ -134,9 +154,11 @@ function FullTimeJobFields() { {...register(`background.experiences.0.location`)} /> @@ -147,7 +169,11 @@ function FullTimeJobFields() { } function InternshipJobFields() { - const { register } = useFormContext(); + const { register, setValue, formState } = useFormContext<{ + background: BackgroundPostData; + }>(); + const experiencesField = formState.errors.background?.experiences?.[0]; + return ( <>
@@ -155,14 +181,16 @@ function InternshipJobFields() { display="block" label="Title" options={titleOptions} + placeholder={emptyOption} {...register(`background.experiences.0.title`)} /> - +
+ + setValue(`background.experiences.0.companyId`, value) + } + /> +
} endAddOnType="element" + errorMessage={experiencesField?.monthlySalary?.value?.message} label="Salary (Monthly)" placeholder="0.00" startAddOn="$" startAddOnType="label" type="number" - {...register(`background.experiences.0.monthlySalary.value`)} + {...register(`background.experiences.0.monthlySalary.value`, { + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + valueAsNumber: true, + })} />
@@ -195,6 +227,7 @@ function InternshipJobFields() { display="block" label="Location" options={locationOptions} + placeholder={emptyOption} {...register(`background.experiences.0.location`)} />
@@ -206,7 +239,7 @@ function InternshipJobFields() { function CurrentJobSection() { const { register } = useFormContext(); const watchJobType = useWatch({ - defaultValue: JobType.FullTime, + defaultValue: JobType.FULLTIME, name: 'background.experiences.0.jobType', }); @@ -218,7 +251,7 @@ function CurrentJobSection() {
- {watchJobType === JobType.FullTime ? ( + {watchJobType === JobType.FULLTIME ? ( ) : ( @@ -258,12 +291,14 @@ function EducationSection() { display="block" label="Education Level" options={educationLevelOptions} + placeholder={emptyOption} {...register(`background.educations.0.type`)} />
@@ -287,9 +322,9 @@ export default function BackgroundForm() {
Help us better gauge your offers
-
- This section is optional, but your background information helps us - benchmark your offers. +
+ This section is mostly optional, but your background information helps + us benchmark your offers.
diff --git a/apps/portal/src/components/offers/forms/OfferDetailsForm.tsx b/apps/portal/src/components/offers/offersSubmission/submissionForm/OfferDetailsForm.tsx similarity index 60% rename from apps/portal/src/components/offers/forms/OfferDetailsForm.tsx rename to apps/portal/src/components/offers/offersSubmission/submissionForm/OfferDetailsForm.tsx index 9ff722fb..e7529c42 100644 --- a/apps/portal/src/components/offers/forms/OfferDetailsForm.tsx +++ b/apps/portal/src/components/offers/offersSubmission/submissionForm/OfferDetailsForm.tsx @@ -1,60 +1,64 @@ import { useEffect, useState } from 'react'; -import type { FieldValues, UseFieldArrayReturn } from 'react-hook-form'; +import type { + FieldValues, + UseFieldArrayRemove, + UseFieldArrayReturn, +} from 'react-hook-form'; import { useWatch } from 'react-hook-form'; import { useFormContext } from 'react-hook-form'; import { useFieldArray } from 'react-hook-form'; import { PlusIcon } from '@heroicons/react/20/solid'; import { TrashIcon } from '@heroicons/react/24/outline'; +import { JobType } from '@prisma/client'; import { Button, Dialog } from '@tih/ui'; +import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; + import { defaultFullTimeOfferValues, defaultInternshipOfferValues, -} from '~/pages/offers/submit'; - -import FormMonthYearPicker from './components/FormMonthYearPicker'; -import FormSelect from './components/FormSelect'; -import FormTextArea from './components/FormTextArea'; -import FormTextInput from './components/FormTextInput'; +} from '../OffersSubmissionForm'; import { - companyOptions, emptyOption, FieldError, internshipCycleOptions, locationOptions, titleOptions, yearOptions, -} from '../constants'; -import type { - FullTimeOfferDetailsFormData, - InternshipOfferDetailsFormData, -} from '../types'; -import { JobTypeLabel } from '../types'; -import { JobType } from '../types'; -import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum'; +} from '../../constants'; +import FormMonthYearPicker from '../../forms/FormMonthYearPicker'; +import FormSelect from '../../forms/FormSelect'; +import FormTextArea from '../../forms/FormTextArea'; +import FormTextInput from '../../forms/FormTextInput'; +import type { OfferFormData } from '../../types'; +import { JobTypeLabel } from '../../types'; +import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum'; type FullTimeOfferDetailsFormProps = Readonly<{ index: number; - setDialogOpen: (isOpen: boolean) => void; + remove: UseFieldArrayRemove; }>; function FullTimeOfferDetailsForm({ index, - setDialogOpen, + remove, }: FullTimeOfferDetailsFormProps) { const { register, formState, setValue } = useFormContext<{ - offers: Array; + offers: Array; }>(); const offerFields = formState.errors.offers?.[index]; const watchCurrency = useWatch({ - name: `offers.${index}.job.totalCompensation.currency`, + name: `offers.${index}.offersFullTime.totalCompensation.currency`, }); useEffect(() => { - setValue(`offers.${index}.job.base.currency`, watchCurrency); - setValue(`offers.${index}.job.bonus.currency`, watchCurrency); - setValue(`offers.${index}.job.stocks.currency`, watchCurrency); + setValue( + `offers.${index}.offersFullTime.baseSalary.currency`, + watchCurrency, + ); + setValue(`offers.${index}.offersFullTime.bonus.currency`, watchCurrency); + setValue(`offers.${index}.offersFullTime.stocks.currency`, watchCurrency); }, [watchCurrency, index, setValue]); return ( @@ -62,48 +66,44 @@ function FullTimeOfferDetailsForm({
-
- +
+
+ + setValue(`offers.${index}.companyId`, value) + } + /> +
-
+
@@ -132,24 +132,32 @@ function FullTimeOfferDetailsForm({ isLabelHidden={true} label="Currency" options={CURRENCY_OPTIONS} - {...register(`offers.${index}.job.totalCompensation.currency`, { - required: FieldError.Required, - })} + {...register( + `offers.${index}.offersFullTime.totalCompensation.currency`, + { + required: FieldError.REQUIRED, + }, + )} /> } endAddOnType="element" - errorMessage={offerFields?.job?.totalCompensation?.value?.message} + errorMessage={ + offerFields?.offersFullTime?.totalCompensation?.value?.message + } label="Total Compensation (Annual)" placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" - {...register(`offers.${index}.job.totalCompensation.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, - valueAsNumber: true, - })} + {...register( + `offers.${index}.offersFullTime.totalCompensation.value`, + { + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, + valueAsNumber: true, + }, + )} />
@@ -160,22 +168,25 @@ function FullTimeOfferDetailsForm({ isLabelHidden={true} label="Currency" options={CURRENCY_OPTIONS} - {...register(`offers.${index}.job.base.currency`, { - required: FieldError.Required, - })} + {...register( + `offers.${index}.offersFullTime.baseSalary.currency`, + { + required: FieldError.REQUIRED, + }, + )} /> } endAddOnType="element" - errorMessage={offerFields?.job?.base?.value?.message} + errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message} label="Base Salary (Annual)" placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" - {...register(`offers.${index}.job.base.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + {...register(`offers.${index}.offersFullTime.baseSalary.value`, { + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, })} /> @@ -186,22 +197,22 @@ function FullTimeOfferDetailsForm({ isLabelHidden={true} label="Currency" options={CURRENCY_OPTIONS} - {...register(`offers.${index}.job.bonus.currency`, { - required: FieldError.Required, + {...register(`offers.${index}.offersFullTime.bonus.currency`, { + required: FieldError.REQUIRED, })} /> } endAddOnType="element" - errorMessage={offerFields?.job?.bonus?.value?.message} + errorMessage={offerFields?.offersFullTime?.bonus?.value?.message} label="Bonus (Annual)" placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" - {...register(`offers.${index}.job.bonus.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + {...register(`offers.${index}.offersFullTime.bonus.value`, { + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, })} /> @@ -214,22 +225,22 @@ function FullTimeOfferDetailsForm({ isLabelHidden={true} label="Currency" options={CURRENCY_OPTIONS} - {...register(`offers.${index}.job.stocks.currency`, { - required: FieldError.Required, + {...register(`offers.${index}.offersFullTime.stocks.currency`, { + required: FieldError.REQUIRED, })} /> } endAddOnType="element" - errorMessage={offerFields?.job?.stocks?.value?.message} + errorMessage={offerFields?.offersFullTime?.stocks?.value?.message} label="Stocks (Annual)" placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" - {...register(`offers.${index}.job.stocks.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + {...register(`offers.${index}.offersFullTime.stocks.value`, { + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, })} /> @@ -254,7 +265,7 @@ function FullTimeOfferDetailsForm({ icon={TrashIcon} label="Delete" variant="secondary" - onClick={() => setDialogOpen(true)} + onClick={() => remove(index)} /> )}
@@ -264,15 +275,15 @@ function FullTimeOfferDetailsForm({ type InternshipOfferDetailsFormProps = Readonly<{ index: number; - setDialogOpen: (isOpen: boolean) => void; + remove: UseFieldArrayRemove; }>; function InternshipOfferDetailsForm({ index, - setDialogOpen, + remove, }: InternshipOfferDetailsFormProps) { - const { register, formState } = useFormContext<{ - offers: Array; + const { register, formState, setValue } = useFormContext<{ + offers: Array; }>(); const offerFields = formState.errors.offers?.[index]; @@ -282,39 +293,35 @@ function InternshipOfferDetailsForm({
- +
+ + setValue(`offers.${index}.companyId`, value) + } + /> +
@@ -357,7 +365,7 @@ function InternshipOfferDetailsForm({ monthRequired={true} yearLabel="" {...register(`offers.${index}.monthYearReceived`, { - required: FieldError.Required, + required: FieldError.REQUIRED, })} />
@@ -369,22 +377,27 @@ function InternshipOfferDetailsForm({ isLabelHidden={true} label="Currency" options={CURRENCY_OPTIONS} - {...register(`offers.${index}.job.monthlySalary.currency`, { - required: FieldError.Required, - })} + {...register( + `offers.${index}.offersIntern.monthlySalary.currency`, + { + required: FieldError.REQUIRED, + }, + )} /> } endAddOnType="element" - errorMessage={offerFields?.job?.monthlySalary?.value?.message} + errorMessage={ + offerFields?.offersIntern?.monthlySalary?.value?.message + } label="Salary (Monthly)" placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" - {...register(`offers.${index}.job.monthlySalary.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + {...register(`offers.${index}.offersIntern.monthlySalary.value`, { + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, })} /> @@ -410,7 +423,7 @@ function InternshipOfferDetailsForm({ label="Delete" variant="secondary" onClick={() => { - setDialogOpen(true); + remove(index); }} /> )} @@ -429,52 +442,17 @@ function OfferDetailsFormArray({ jobType, }: OfferDetailsFormArrayProps) { const { append, remove, fields } = fieldArrayValues; - const [isDialogOpen, setDialogOpen] = useState(false); return (
{fields.map((item, index) => { return (
- {jobType === JobType.FullTime ? ( - + {jobType === JobType.FULLTIME ? ( + ) : ( - + )} - { - remove(index); - setDialogOpen(false); - }} - /> - } - secondaryButton={ -
); })} @@ -486,7 +464,7 @@ function OfferDetailsFormArray({ variant="tertiary" onClick={() => append( - jobType === JobType.FullTime + jobType === JobType.FULLTIME ? defaultFullTimeOfferValues : defaultInternshipOfferValues, ) @@ -496,27 +474,32 @@ function OfferDetailsFormArray({ ); } -export default function OfferDetailsForm() { - const [jobType, setJobType] = useState(JobType.FullTime); +type OfferDetailsFormProps = Readonly<{ + defaultJobType?: JobType; +}>; + +export default function OfferDetailsForm({ + defaultJobType = JobType.FULLTIME, +}: OfferDetailsFormProps) { + const [jobType, setJobType] = useState(defaultJobType); const [isDialogOpen, setDialogOpen] = useState(false); const { control } = useFormContext(); const fieldArrayValues = useFieldArray({ control, name: 'offers' }); + const { append, remove } = fieldArrayValues; const toggleJobType = () => { - fieldArrayValues.remove(); - if (jobType === JobType.FullTime) { - setJobType(JobType.Internship); - fieldArrayValues.append(defaultInternshipOfferValues); + remove(); + if (jobType === JobType.FULLTIME) { + setJobType(JobType.INTERN); + append(defaultInternshipOfferValues); } else { - setJobType(JobType.FullTime); - fieldArrayValues.append(defaultFullTimeOfferValues); + setJobType(JobType.FULLTIME); + append(defaultFullTimeOfferValues); } }; const switchJobTypeLabel = () => - jobType === JobType.FullTime - ? JobTypeLabel.INTERNSHIP - : JobTypeLabel.FULLTIME; + jobType === JobType.FULLTIME ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME; return (
@@ -529,9 +512,9 @@ export default function OfferDetailsForm() { display="block" label={JobTypeLabel.FULLTIME} size="md" - variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'} + variant={jobType === JobType.FULLTIME ? 'secondary' : 'tertiary'} onClick={() => { - if (jobType === JobType.FullTime) { + if (jobType === JobType.FULLTIME) { return; } setDialogOpen(true); @@ -541,11 +524,11 @@ export default function OfferDetailsForm() {
{(startDate || endDate) && (
-

{`${startDate ? startDate : 'N/A'} - ${ - endDate ? endDate : 'N/A' - }`}

+

{`${startDate || 'N/A'} - ${endDate || 'N/A'}`}

)}
diff --git a/apps/portal/src/components/offers/profile/OfferCard.tsx b/apps/portal/src/components/offers/profile/OfferCard.tsx index 8b3c9566..e9d2d7f2 100644 --- a/apps/portal/src/components/offers/profile/OfferCard.tsx +++ b/apps/portal/src/components/offers/profile/OfferCard.tsx @@ -6,10 +6,10 @@ import { } from '@heroicons/react/24/outline'; import { HorizontalDivider } from '@tih/ui'; -import type { OfferEntity } from '~/components/offers/types'; +import type { OfferDisplayData } from '~/components/offers/types'; type Props = Readonly<{ - offer: OfferEntity; + offer: OfferDisplayData; }>; export default function OfferCard({ @@ -58,52 +58,64 @@ export default function OfferCard({ } function BottomSection() { + if ( + !totalCompensation && + !monthlySalary && + !negotiationStrategy && + !otherComment + ) { + return null; + } + return ( -
-
-
- -

- {totalCompensation - ? `TC: ${totalCompensation}` - : `Monthly Salary: ${monthlySalary}`} -

+ <> + +
+
+ {totalCompensation || + (monthlySalary && ( +
+ +

+ {totalCompensation && `TC: ${totalCompensation}`} + {monthlySalary && `Monthly Salary: ${monthlySalary}`} +

+
+ ))} + {totalCompensation && ( +
+

+ Base / year: {base} ⋅ Stocks / year: {stocks} ⋅ Bonus / year:{' '} + {bonus} +

+
+ )}
- - {totalCompensation && ( -
-

- Base / year: {base} ⋅ Stocks / year: {stocks} ⋅ Bonus / year:{' '} - {bonus} -

+ {negotiationStrategy && ( +
+
+ + + "{negotiationStrategy}" + +
)} -
- {negotiationStrategy && ( -
-
- - - "{negotiationStrategy}" - + {otherComment && ( +
+
+ + "{otherComment}" +
-
- )} - {otherComment && ( -
-
- - "{otherComment}" -
-
- )} -
+ )} +
+ ); } return (
-
); diff --git a/apps/portal/src/components/offers/profile/ProfileComments.tsx b/apps/portal/src/components/offers/profile/ProfileComments.tsx index a0a47d43..d30645a8 100644 --- a/apps/portal/src/components/offers/profile/ProfileComments.tsx +++ b/apps/portal/src/components/offers/profile/ProfileComments.tsx @@ -1,21 +1,90 @@ +import { signIn, useSession } from 'next-auth/react'; +import { useState } from 'react'; import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline'; -import { Button, Spinner } from '@tih/ui'; +import { Button, HorizontalDivider, Spinner, TextArea } from '@tih/ui'; + +import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard'; + +import { copyProfileLink } from '~/utils/offers/link'; +import { trpc } from '~/utils/trpc'; + +import type { OffersDiscussion, Reply } from '~/types/offers'; type ProfileHeaderProps = Readonly<{ - handleCopyEditLink: () => void; - handleCopyPublicLink: () => void; isDisabled: boolean; isEditable: boolean; isLoading: boolean; + profileId: string; + profileName?: string; + token?: string; }>; export default function ProfileComments({ - handleCopyEditLink, - handleCopyPublicLink, isDisabled, isEditable, isLoading, + profileId, + profileName, + token, }: ProfileHeaderProps) { + const { data: session, status } = useSession(); + const [currentReply, setCurrentReply] = useState(''); + const [replies, setReplies] = useState>(); + + const commentsQuery = trpc.useQuery( + ['offers.comments.getComments', { profileId }], + { + onSuccess(response: OffersDiscussion) { + setReplies(response.data); + }, + }, + ); + + const trpcContext = trpc.useContext(); + const createCommentMutation = trpc.useMutation(['offers.comments.create'], { + onSuccess() { + trpcContext.invalidateQueries([ + 'offers.comments.getComments', + { profileId }, + ]); + }, + }); + + function handleComment(message: string) { + if (isEditable) { + // If it is with edit permission, send comment to API with username = null + createCommentMutation.mutate( + { + message, + profileId, + token, + }, + { + onSuccess: () => { + setCurrentReply(''); + }, + }, + ); + } else if (status === 'authenticated') { + // If not the OP and logged in, send comment to API + createCommentMutation.mutate( + { + message, + profileId, + userId: session.user?.id, + }, + { + onSuccess: () => { + setCurrentReply(''); + }, + }, + ); + } else { + // If not the OP and not logged in, direct users to log in + signIn(); + } + } + if (isLoading) { return (
@@ -24,7 +93,7 @@ export default function ProfileComments({ ); } return ( -
+
{isEditable && (
-

- Discussions feature coming soon -

- {/*