From 7064a1e1e1b4ebf0d5b7006733f6972070a41beb Mon Sep 17 00:00:00 2001 From: Sean Walker Date: Thu, 5 Feb 2026 17:36:15 -0500 Subject: [PATCH 1/4] init --- src/backend/index.ts | 2 + .../src/controllers/finance.controllers.ts | 38 +- .../prospective-sponsor.controllers.ts | 153 +++ .../prospective-sponsor.query-args.ts | 13 + .../migration.sql | 71 ++ src/backend/src/prisma/schema.prisma | 116 ++- src/backend/src/routes/finance.routes.ts | 12 +- .../src/routes/prospective-sponsor.routes.ts | 88 ++ src/backend/src/services/finance.services.ts | 131 ++- .../services/prospective-sponsor.services.ts | 359 +++++++ .../reimbursement-requests.services.ts | 26 +- .../src/transformers/finance.transformer.ts | 7 +- .../prospective-sponsor.transformer.ts | 29 + .../transformers/sponsor-task.transformer.ts | 3 +- src/backend/src/utils/errors.utils.ts | 4 +- .../src/utils/reimbursement-requests.utils.ts | 36 +- src/backend/tests/test-utils.ts | 1 + src/backend/tests/unit/finance.test.ts | 154 ++- .../tests/unit/prospective-sponsor.test.ts | 909 ++++++++++++++++++ src/frontend/src/apis/finance.api.ts | 115 +++ .../prospective-sponsor.transformer.ts | 25 + src/frontend/src/components/NERDataGrid.tsx | 15 +- src/frontend/src/hooks/finance.hooks.ts | 174 +++- .../AcceptProspectiveSponsorModal.tsx | 280 ++++++ .../CreateProspectiveSponsorPage.tsx | 103 ++ .../FinanceComponents/CreateSponsorPage.tsx | 5 +- .../DeleteProspectiveSponsorModal.tsx | 48 + .../EditProspectiveSponsorPage.tsx | 124 +++ .../FinanceComponents/EditSponsorPage.tsx | 5 +- .../ProspectiveSponsorForm.tsx | 457 +++++++++ .../ProspectiveSponsorTasksModal.tsx | 295 ++++++ .../FinanceComponents/SponsorForm.tsx | 55 +- .../FinanceComponents/SponsorTasksModal.tsx | 43 +- .../FinancePage/ProspectiveSponsorsTable.tsx | 334 +++++++ .../src/pages/FinancePage/SponsorsTable.tsx | 19 +- .../FinancePage/VendorsAndSponsorsPage.tsx | 19 +- src/frontend/src/utils/teams.utils.ts | 3 +- src/frontend/src/utils/urls.ts | 26 + src/shared/src/types/finance-types.ts | 38 +- 39 files changed, 4207 insertions(+), 128 deletions(-) create mode 100644 src/backend/src/controllers/prospective-sponsor.controllers.ts create mode 100644 src/backend/src/prisma-query-args/prospective-sponsor.query-args.ts create mode 100644 src/backend/src/prisma/migrations/20260205202908_prospective_sponsors/migration.sql create mode 100644 src/backend/src/routes/prospective-sponsor.routes.ts create mode 100644 src/backend/src/services/prospective-sponsor.services.ts create mode 100644 src/backend/src/transformers/prospective-sponsor.transformer.ts create mode 100644 src/backend/tests/unit/prospective-sponsor.test.ts create mode 100644 src/frontend/src/apis/transformers/prospective-sponsor.transformer.ts create mode 100644 src/frontend/src/pages/FinancePage/FinanceComponents/AcceptProspectiveSponsorModal.tsx create mode 100644 src/frontend/src/pages/FinancePage/FinanceComponents/CreateProspectiveSponsorPage.tsx create mode 100644 src/frontend/src/pages/FinancePage/FinanceComponents/DeleteProspectiveSponsorModal.tsx create mode 100644 src/frontend/src/pages/FinancePage/FinanceComponents/EditProspectiveSponsorPage.tsx create mode 100644 src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorForm.tsx create mode 100644 src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx create mode 100644 src/frontend/src/pages/FinancePage/ProspectiveSponsorsTable.tsx diff --git a/src/backend/index.ts b/src/backend/index.ts index 88b099ddef..a9853968e3 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -25,6 +25,7 @@ import retrospectiveRouter from './src/routes/retrospective.routes.js'; import partsRouter from './src/routes/parts.routes.js'; import financeRouter from './src/routes/finance.routes.js'; import calendarRouter from './src/routes/calendar.routes.js'; +import prospectiveSponsorRouter from './src/routes/prospective-sponsor.routes.js'; const app = express(); @@ -103,6 +104,7 @@ app.use('/retrospective', retrospectiveRouter); app.use('/parts', partsRouter); app.use('/finance', financeRouter); app.use('/calendar', calendarRouter); +app.use('/prospective-sponsors', prospectiveSponsorRouter); app.use('/', (_req, res) => { res.status(200).json('Welcome to FinishLine'); }); diff --git a/src/backend/src/controllers/finance.controllers.ts b/src/backend/src/controllers/finance.controllers.ts index 8db422b04e..70f2e2b165 100644 --- a/src/backend/src/controllers/finance.controllers.ts +++ b/src/backend/src/controllers/finance.controllers.ts @@ -12,7 +12,10 @@ export default class FinanceController { activeYears, sponsorTierId, taxExempt, - sponsorContact, + contactName, + contactEmail, + contactPhone, + contactPosition, sponsorTasks, discountCode, sponsorNotes @@ -27,11 +30,14 @@ export default class FinanceController { activeYears, sponsorTierId, taxExempt, - sponsorContact, + contactName, sponsorTasks, req.organization, discountCode, - sponsorNotes + sponsorNotes, + contactEmail, + contactPhone, + contactPosition ); res.status(200).json(sponsor); } catch (error: unknown) { @@ -327,7 +333,10 @@ export default class FinanceController { joinDate, activeYears, sponsorTierId, - sponsorContact, + contactName, + contactEmail, + contactPhone, + contactPosition, taxExempt, sponsorTasks, discountCode, @@ -344,11 +353,14 @@ export default class FinanceController { joinDate, activeYears, sponsorTierId, - sponsorContact, + contactName, taxExempt, sponsorTasks, discountCode, - sponsorNotes + sponsorNotes, + contactEmail, + contactPhone, + contactPosition ); res.status(200).json(updatedSponsor); @@ -385,4 +397,18 @@ export default class FinanceController { next(error); } } + + static async toggleSponsorTaskDone(req: Request, res: Response, next: NextFunction) { + try { + const { sponsorTaskId } = req.params as Record; + const updatedTask = await FinanceServices.toggleSponsorTaskDone( + req.currentUser, + req.organization, + sponsorTaskId + ); + res.status(200).json(updatedTask); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/controllers/prospective-sponsor.controllers.ts b/src/backend/src/controllers/prospective-sponsor.controllers.ts new file mode 100644 index 0000000000..c520ddd2c1 --- /dev/null +++ b/src/backend/src/controllers/prospective-sponsor.controllers.ts @@ -0,0 +1,153 @@ +import { NextFunction, Request, Response } from 'express'; +import ProspectiveSponsorServices from '../services/prospective-sponsor.services.js'; + +export default class ProspectiveSponsorController { + static async createProspectiveSponsor(req: Request, res: Response, next: NextFunction) { + try { + const { + organizationName, + lastContactDate, + firstContactMethod, + contactName, + contactorUserId, + highlightThresholdDays, + contactEmail, + contactPhone, + contactPosition + } = req.body; + + const prospectiveSponsor = await ProspectiveSponsorServices.createProspectiveSponsor( + req.currentUser, + req.organization, + organizationName, + lastContactDate, + firstContactMethod, + contactName, + contactorUserId, + highlightThresholdDays, + contactEmail, + contactPhone, + contactPosition + ); + res.status(200).json(prospectiveSponsor); + } catch (error: unknown) { + next(error); + } + } + + static async getAllProspectiveSponsors(req: Request, res: Response, next: NextFunction) { + try { + const prospectiveSponsors = await ProspectiveSponsorServices.getAllProspectiveSponsors(req.organization); + res.status(200).json(prospectiveSponsors); + } catch (error: unknown) { + next(error); + } + } + + static async editProspectiveSponsor(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const { + organizationName, + lastContactDate, + status, + firstContactMethod, + contactName, + contactorUserId, + highlightThresholdDays, + contactEmail, + contactPhone, + contactPosition + } = req.body; + + const updatedProspectiveSponsor = await ProspectiveSponsorServices.editProspectiveSponsor( + req.currentUser, + req.organization, + prospectiveSponsorId, + organizationName, + lastContactDate, + status, + firstContactMethod, + contactName, + contactorUserId, + highlightThresholdDays, + contactEmail, + contactPhone, + contactPosition + ); + res.status(200).json(updatedProspectiveSponsor); + } catch (error: unknown) { + next(error); + } + } + + static async deleteProspectiveSponsor(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const deletedProspectiveSponsor = await ProspectiveSponsorServices.deleteProspectiveSponsor( + prospectiveSponsorId, + req.currentUser, + req.organization + ); + res.status(200).json(deletedProspectiveSponsor); + } catch (error: unknown) { + next(error); + } + } + + static async getProspectiveSponsorTasks(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const tasks = await ProspectiveSponsorServices.getProspectiveSponsorTasks( + prospectiveSponsorId, + req.organization.organizationId + ); + res.status(200).json(tasks); + } catch (error: unknown) { + next(error); + } + } + + static async createProspectiveSponsorTask(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const { dueDate, notes, notifyDate, assigneeUserId } = req.body; + + const task = await ProspectiveSponsorServices.createProspectiveSponsorTask( + req.currentUser, + req.organization, + prospectiveSponsorId, + dueDate, + notes, + notifyDate, + assigneeUserId + ); + res.status(200).json(task); + } catch (error: unknown) { + next(error); + } + } + + static async acceptProspectiveSponsor(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const { sponsorTierId, sponsorValue, joinDate, activeYears, taxExempt, discountCode, sponsorNotes } = req.body; + + const acceptedProspectiveSponsor = await ProspectiveSponsorServices.acceptProspectiveSponsor( + req.currentUser, + req.organization, + prospectiveSponsorId, + sponsorTierId, + sponsorValue, + joinDate, + activeYears, + taxExempt, + discountCode, + sponsorNotes + ); + res.status(200).json(acceptedProspectiveSponsor); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/prisma-query-args/prospective-sponsor.query-args.ts b/src/backend/src/prisma-query-args/prospective-sponsor.query-args.ts new file mode 100644 index 0000000000..193fc5e84e --- /dev/null +++ b/src/backend/src/prisma-query-args/prospective-sponsor.query-args.ts @@ -0,0 +1,13 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args.js'; +import { getSponsorTaskQueryArgs } from './sponsor.query.args.js'; + +export type ProspectiveSponsorQueryArgs = ReturnType; + +export const getProspectiveSponsorQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + contactor: getUserQueryArgs(organizationId), + tasks: getSponsorTaskQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma/migrations/20260205202908_prospective_sponsors/migration.sql b/src/backend/src/prisma/migrations/20260205202908_prospective_sponsors/migration.sql new file mode 100644 index 0000000000..725ad438c3 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260205202908_prospective_sponsors/migration.sql @@ -0,0 +1,71 @@ +/* + Warnings: + + - You are about to drop the column `vendorContact` on the `Sponsor` table. All the data in the column will be lost. + - Added the required column `contactName` to the `Sponsor` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "Prospective_Sponsor_Status" AS ENUM ('IN_PROGRESS', 'DECLINED', 'NOT_IN_CONTACT', 'NO_RESPONSE', 'ACCEPTED'); + +-- CreateEnum +CREATE TYPE "First_Contact_Method" AS ENUM ('INBOUND_FORM', 'INBOUND_EMAIL', 'OUTBOUND_EMAIL', 'OTHER'); + +-- DropForeignKey +ALTER TABLE "Sponsor_Task" DROP CONSTRAINT "Sponsor_Task_sponsorId_fkey"; + +-- AlterTable +ALTER TABLE "Sponsor" DROP COLUMN "vendorContact", +ADD COLUMN "contactEmail" TEXT, +ADD COLUMN "contactName" TEXT NOT NULL, +ADD COLUMN "contactPhone" TEXT, +ADD COLUMN "contactPosition" TEXT; + +-- AlterTable +ALTER TABLE "Sponsor_Task" ADD COLUMN "done" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "prospectiveSponsorId" TEXT, +ALTER COLUMN "sponsorId" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "Prospective_Sponsor" ( + "prospectiveSponsorId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "organizationName" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastContactDate" TIMESTAMP(3) NOT NULL, + "highlightThresholdDays" INTEGER NOT NULL DEFAULT 10, + "status" "Prospective_Sponsor_Status" NOT NULL DEFAULT 'IN_PROGRESS', + "firstContactMethod" "First_Contact_Method" NOT NULL, + "contactorUserId" TEXT NOT NULL, + "contactName" TEXT NOT NULL, + "contactEmail" TEXT, + "contactPhone" TEXT, + "contactPosition" TEXT, + "dateDeleted" TIMESTAMP(3), + + CONSTRAINT "Prospective_Sponsor_pkey" PRIMARY KEY ("prospectiveSponsorId") +); + +-- CreateIndex +CREATE INDEX "Prospective_Sponsor_organizationId_idx" ON "Prospective_Sponsor"("organizationId"); + +-- CreateIndex +CREATE INDEX "Prospective_Sponsor_contactorUserId_idx" ON "Prospective_Sponsor"("contactorUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Prospective_Sponsor_organizationName_organizationId_key" ON "Prospective_Sponsor"("organizationName", "organizationId"); + +-- CreateIndex +CREATE INDEX "Sponsor_Task_prospectiveSponsorId_idx" ON "Sponsor_Task"("prospectiveSponsorId"); + +-- AddForeignKey +ALTER TABLE "Sponsor_Task" ADD CONSTRAINT "Sponsor_Task_sponsorId_fkey" FOREIGN KEY ("sponsorId") REFERENCES "Sponsor"("sponsorId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Sponsor_Task" ADD CONSTRAINT "Sponsor_Task_prospectiveSponsorId_fkey" FOREIGN KEY ("prospectiveSponsorId") REFERENCES "Prospective_Sponsor"("prospectiveSponsorId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Prospective_Sponsor" ADD CONSTRAINT "Prospective_Sponsor_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Prospective_Sponsor" ADD CONSTRAINT "Prospective_Sponsor_contactorUserId_fkey" FOREIGN KEY ("contactorUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 3d3ba18d35..755d00728f 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -158,6 +158,21 @@ enum Review_Status { APPROVED } +enum Prospective_Sponsor_Status { + IN_PROGRESS + DECLINED + NOT_IN_CONTACT + NO_RESPONSE + ACCEPTED +} + +enum First_Contact_Method { + INBOUND_FORM + INBOUND_EMAIL + OUTBOUND_EMAIL + OTHER +} + model User { userId String @id @default(uuid()) firstName String @@ -278,6 +293,7 @@ model User { deniedEvents Event[] @relation(name: "deniedEventAttendee") deletedDocuments Document[] @relation(name: "deletedDocuments") createdDocuments Document[] @relation(name: "documentsCreatedBy") + prospectiveSponsorsContacted Prospective_Sponsor[] @relation(name: "prospectiveSponsorContactor") } model Role { @@ -792,24 +808,27 @@ model Vendor { } model Sponsor { - sponsorId String @id @default(uuid()) - name String - organizationId String - organization Organization @relation(fields: [organizationId], references: [organizationId]) - dateCreated DateTime @default(now()) - dateDeleted DateTime? - activeStatus Boolean - vendorContact String - tier Sponsor_Tier @relation(fields: [sponsorTierId], references: [sponsorTierId]) - sponsorTierId String - sponsorValue Int - joinDate DateTime - discountCode String? - activeYears Int[] - taxExempt Boolean - sponsorNotes String? - sponsorTasks Sponsor_Task[] - logoImageId String? + sponsorId String @id @default(uuid()) + name String + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + dateCreated DateTime @default(now()) + dateDeleted DateTime? + activeStatus Boolean + contactName String + contactEmail String? + contactPhone String? + contactPosition String? + tier Sponsor_Tier @relation(fields: [sponsorTierId], references: [sponsorTierId]) + sponsorTierId String + sponsorValue Int + joinDate DateTime + discountCode String? + activeYears Int[] + taxExempt Boolean + sponsorNotes String? + sponsorTasks Sponsor_Task[] + logoImageId String? @@unique([name, organizationId], name: "uniqueSponsor") @@index([sponsorTierId]) @@ -817,17 +836,45 @@ model Sponsor { } model Sponsor_Task { - sponsorTaskId String @id @default(uuid()) - dueDate DateTime - notifyDate DateTime? - assignee User? @relation(fields: [assigneeUserId], references: [userId], name: "assignedSponsorTasks") - assigneeUserId String? - notes String - sponsor Sponsor @relation(fields: [sponsorId], references: [sponsorId]) - sponsorId String - dateDeleted DateTime? + sponsorTaskId String @id @default(uuid()) + dueDate DateTime + notifyDate DateTime? + assignee User? @relation(fields: [assigneeUserId], references: [userId], name: "assignedSponsorTasks") + assigneeUserId String? + notes String + done Boolean @default(false) + sponsor Sponsor? @relation(fields: [sponsorId], references: [sponsorId]) + sponsorId String? + prospectiveSponsor Prospective_Sponsor? @relation(fields: [prospectiveSponsorId], references: [prospectiveSponsorId]) + prospectiveSponsorId String? + dateDeleted DateTime? @@index([sponsorId]) + @@index([prospectiveSponsorId]) +} + +model Prospective_Sponsor { + prospectiveSponsorId String @id @default(uuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + organizationName String + dateCreated DateTime @default(now()) + lastContactDate DateTime + highlightThresholdDays Int @default(10) + status Prospective_Sponsor_Status @default(IN_PROGRESS) + firstContactMethod First_Contact_Method + contactor User @relation(fields: [contactorUserId], references: [userId], name: "prospectiveSponsorContactor") + contactorUserId String + contactName String + contactEmail String? + contactPhone String? + contactPosition String? + dateDeleted DateTime? + tasks Sponsor_Task[] + + @@unique([organizationName, organizationId], name: "uniqueProspectiveSponsor") + @@index([organizationId]) + @@index([contactorUserId]) } model Account_Code { @@ -1027,12 +1074,12 @@ enum DayOfWeek { } model Schedule_Slot { - scheduleSlotId String @id @default(uuid()) - startTime DateTime - endTime DateTime - allDay Boolean @default(false) - eventId String - event Event @relation(fields: [eventId], references: [eventId]) + scheduleSlotId String @id @default(uuid()) + startTime DateTime + endTime DateTime + allDay Boolean @default(false) + eventId String + event Event @relation(fields: [eventId], references: [eventId]) @@index([endTime]) @@index([startTime]) @@ -1088,7 +1135,7 @@ model Event { shops Shop[] machinery Machinery[] workPackages Work_Package[] - documents Document[] + documents Document[] status Event_Status initialDateScheduled DateTime? questionDocumentLink String? @@ -1355,6 +1402,7 @@ model Organization { partTags Part_Tag[] sponsors Sponsor[] sponsorTiers Sponsor_Tier[] + prospectiveSponsors Prospective_Sponsor[] indexCodes Index_Code[] financeDelegates User[] @relation(name: "financeDelegates") guestDefinitions Guest_Definition[] diff --git a/src/backend/src/routes/finance.routes.ts b/src/backend/src/routes/finance.routes.ts index 75d6e60217..20c43cfa27 100644 --- a/src/backend/src/routes/finance.routes.ts +++ b/src/backend/src/routes/finance.routes.ts @@ -22,7 +22,10 @@ financeRouter.post( intMinZero(body('activeYears.*')), nonEmptyString(body('sponsorTierId')), body('taxExempt').isBoolean(), - nonEmptyString(body('sponsorContact')), + nonEmptyString(body('contactName')), + nonEmptyString(body('contactEmail')).optional(), + nonEmptyString(body('contactPhone')).optional(), + nonEmptyString(body('contactPosition')).optional(), body('sponsorTasks').isArray(), isDate(body('sponsorTasks.*.dueDate')), isDate(body('sponsorTasks.*.notifyDate')), @@ -59,6 +62,8 @@ financeRouter.post( financeRouter.post('/sponsorTask/:sponsorTaskId/delete', FinanceController.deleteSponsorTask); +financeRouter.post('/sponsorTask/:sponsorTaskId/toggle-done', FinanceController.toggleSponsorTaskDone); + financeRouter.post( '/sponsor/:sponsorId/sponsorTasks', isDate(body('dueDate')), @@ -139,7 +144,10 @@ financeRouter.post( intMinZero(body('activeYears.*')), nonEmptyString(body('sponsorTierId')), body('taxExempt').isBoolean(), - nonEmptyString(body('sponsorContact')), + nonEmptyString(body('contactName')), + nonEmptyString(body('contactEmail')).optional(), + nonEmptyString(body('contactPhone')).optional(), + nonEmptyString(body('contactPosition')).optional(), body('sponsorTasks').isArray(), isDate(body('sponsorTasks.*.dueDate')), isDate(body('sponsorTasks.*.notifyDate')), diff --git a/src/backend/src/routes/prospective-sponsor.routes.ts b/src/backend/src/routes/prospective-sponsor.routes.ts new file mode 100644 index 0000000000..1476a4caab --- /dev/null +++ b/src/backend/src/routes/prospective-sponsor.routes.ts @@ -0,0 +1,88 @@ +import express from 'express'; +import { body } from 'express-validator'; +import { + nonEmptyString, + validateInputs, + isDate, + isOptionalDate, + intMinZero +} from '../utils/validation.utils.js'; +import ProspectiveSponsorController from '../controllers/prospective-sponsor.controllers.js'; + +const prospectiveSponsorRouter = express.Router(); + +// Create prospective sponsor +prospectiveSponsorRouter.post( + '/create', + nonEmptyString(body('organizationName')), + isDate(body('lastContactDate')), + nonEmptyString(body('firstContactMethod')), + nonEmptyString(body('contactName')), + nonEmptyString(body('contactorUserId')), + intMinZero(body('highlightThresholdDays')).optional(), + nonEmptyString(body('contactEmail')).optional(), + nonEmptyString(body('contactPhone')).optional(), + nonEmptyString(body('contactPosition')).optional(), + validateInputs, + ProspectiveSponsorController.createProspectiveSponsor +); + +// Get all prospective sponsors +prospectiveSponsorRouter.get('/', ProspectiveSponsorController.getAllProspectiveSponsors); + +// Edit prospective sponsor +prospectiveSponsorRouter.post( + '/:prospectiveSponsorId/edit', + nonEmptyString(body('organizationName')), + isDate(body('lastContactDate')), + nonEmptyString(body('status')), + nonEmptyString(body('firstContactMethod')), + nonEmptyString(body('contactName')), + nonEmptyString(body('contactorUserId')), + intMinZero(body('highlightThresholdDays')).optional(), + nonEmptyString(body('contactEmail')).optional(), + nonEmptyString(body('contactPhone')).optional(), + nonEmptyString(body('contactPosition')).optional(), + validateInputs, + ProspectiveSponsorController.editProspectiveSponsor +); + +// Delete prospective sponsor +prospectiveSponsorRouter.post( + '/:prospectiveSponsorId/delete', + ProspectiveSponsorController.deleteProspectiveSponsor +); + +// Get tasks for prospective sponsor +prospectiveSponsorRouter.get( + '/:prospectiveSponsorId/tasks', + ProspectiveSponsorController.getProspectiveSponsorTasks +); + +// Create task for prospective sponsor +prospectiveSponsorRouter.post( + '/:prospectiveSponsorId/tasks', + isDate(body('dueDate')), + nonEmptyString(body('notes')), + isOptionalDate(body('notifyDate')), + nonEmptyString(body('assigneeUserId')).optional(), + validateInputs, + ProspectiveSponsorController.createProspectiveSponsorTask +); + +// Accept prospective sponsor (convert to full sponsor) +prospectiveSponsorRouter.post( + '/:prospectiveSponsorId/accept', + nonEmptyString(body('sponsorTierId')), + body('sponsorValue').isInt(), + isDate(body('joinDate')), + body('activeYears').isArray(), + intMinZero(body('activeYears.*')), + body('taxExempt').isBoolean(), + nonEmptyString(body('discountCode')).optional(), + nonEmptyString(body('sponsorNotes')).optional(), + validateInputs, + ProspectiveSponsorController.acceptProspectiveSponsor +); + +export default prospectiveSponsorRouter; diff --git a/src/backend/src/services/finance.services.ts b/src/backend/src/services/finance.services.ts index 6bd7dcf915..06be9b9b5e 100644 --- a/src/backend/src/services/finance.services.ts +++ b/src/backend/src/services/finance.services.ts @@ -34,6 +34,7 @@ import { getReimbursementRequestWhereInput } from '../utils/finance.utils.js'; import { notifySponsorTaskAssignee } from '../utils/slack.utils.js'; +import { isUserFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; export default class FinanceServices { /** @@ -49,7 +50,10 @@ export default class FinanceServices { * @param taxExempt Boolean indicating if the sponsor is tax-exempt. * @param discountCode The discount code associated with the sponsor. * @param sponsorNotes Additional notes about the sponsor. - * @param sponsorContact The contact information for the sponsor. + * @param contactName The name of the sponsor contact. + * @param contactEmail The email of the sponsor contact. + * @param contactPhone The phone of the sponsor contact. + * @param contactPosition The position of the sponsor contact. * @param sponsorTasks An array of sponsor tasks associated with the sponsor. * @param organization The organization for which the sponsor is being created. * @@ -66,11 +70,14 @@ export default class FinanceServices { activeYears: number[], sponsorTierId: string, taxExempt: boolean, - sponsorContact: string, + contactName: string, sponsorTasks: CreateSponsorTask[], organization: Organization, discountCode?: string, - sponsorNotes?: string + sponsorNotes?: string, + contactEmail?: string, + contactPhone?: string, + contactPosition?: string ) { if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) throw new AccessDeniedException('Only heads can create a sponsor'); @@ -97,7 +104,10 @@ export default class FinanceServices { taxExempt, discountCode, sponsorNotes, - vendorContact: sponsorContact, + contactName, + contactEmail, + contactPhone, + contactPosition, sponsorTasks: { create: sponsorTasks.map((task) => ({ dueDate: task.dueDate, @@ -226,15 +236,17 @@ export default class FinanceServices { notifyDate?: Date, assigneeUserId?: string ): Promise { - if (!(await userHasPermission(submitter.userId, org.organizationId, isHead))) - throw new AccessDeniedException('Only heads can edit sponsor tasks.'); + if (!(await isUserFinanceTeamOrHead(submitter, org.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can edit sponsor tasks'); + } - const oldSponsorTask = await prisma.sponsor_Task.findUnique({ + const oldSponsorTask = await prisma.sponsor_Task.findFirst({ where: { sponsorTaskId, - sponsor: { - organizationId: org.organizationId - } + OR: [ + { sponsor: { organizationId: org.organizationId } }, + { prospectiveSponsor: { organizationId: org.organizationId } } + ] } }); @@ -271,16 +283,22 @@ export default class FinanceServices { }); if (assignee && oldSponsorTask.assigneeUserId !== assigneeUserId) { - const sponsor = await prisma.sponsor.findUnique({ - where: { sponsorId: updatedSponsorTask.sponsorId } - }); - - if (!sponsor) { - throw new NotFoundException('Sponsor', updatedSponsorTask.sponsorId); + // Get sponsor or prospective sponsor name for notification + let sponsorName: string | undefined; + if (updatedSponsorTask.sponsorId) { + const sponsor = await prisma.sponsor.findUnique({ + where: { sponsorId: updatedSponsorTask.sponsorId } + }); + sponsorName = sponsor?.name; + } else if (updatedSponsorTask.prospectiveSponsorId) { + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId: updatedSponsorTask.prospectiveSponsorId } + }); + sponsorName = prospectiveSponsor?.organizationName; } - if (assignee) { - await notifySponsorTaskAssignee(assignee, updatedSponsorTask, sponsor.name); + if (sponsorName && assignee) { + await notifySponsorTaskAssignee(assignee, updatedSponsorTask, sponsorName); } } @@ -326,12 +344,17 @@ export default class FinanceServices { where: { sponsorTaskId, dateDeleted: null } }); - if (!(await userHasPermission(deleter.userId, organization.organizationId, isHead))) { - throw new AccessDeniedException('Only heads can delete sponsor tasks.'); - } - if (!sponsorTask) throw new NotFoundException('SponsorTask', sponsorTaskId); + const hasHeadPermission = await userHasPermission(deleter.userId, organization.organizationId, isHead); + const isAssignee = sponsorTask.assigneeUserId === deleter.userId; + + // Heads can delete any task. Assignees can delete their own tasks. + // Others cannot delete tasks (especially tasks assigned to someone else). + if (!hasHeadPermission && !isAssignee) { + throw new AccessDeniedException('Only heads or the task assignee can delete sponsor tasks'); + } + const deletedSponsorTask = await prisma.sponsor_Task.update({ where: { sponsorTaskId }, data: { dateDeleted: new Date() } @@ -363,8 +386,8 @@ export default class FinanceServices { notifyDate?: Date, assigneeUserId?: string ): Promise { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) { - throw new AccessDeniedException('Only heads can create a sponsor task'); + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can create a sponsor task'); } const sponsor = await prisma.sponsor.findUnique({ where: { sponsorId, organizationId: organization.organizationId } }); @@ -1106,7 +1129,10 @@ export default class FinanceServices { * @param taxExempt Boolean indicating if the sponsor is tax-exempt. * @param discountCode The discount code associated with the sponsor. * @param sponsorNotes Additional notes about the sponsor. - * @param sponsorContact The contact information for the sponsor. + * @param contactName The name of the sponsor contact. + * @param contactEmail The email of the sponsor contact. + * @param contactPhone The phone of the sponsor contact. + * @param contactPosition The position of the sponsor contact. * @param sponsorTasks An array of sponsor tasks associated with the sponsor. * @param organization The organization for which the sponsor is being edited. * @returns the edited sponsor. @@ -1122,11 +1148,14 @@ export default class FinanceServices { joinDate: Date, activeYears: number[], sponsorTierId: string, - sponsorContact: string, + contactName: string, taxExempt: boolean, sponsorTasks: CreateSponsorTask[], discountCode?: string, - sponsorNotes?: string + sponsorNotes?: string, + contactEmail?: string, + contactPhone?: string, + contactPosition?: string ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) throw new AccessDeniedException('Only heads can edit sponsors.'); @@ -1205,7 +1234,10 @@ export default class FinanceServices { }) ) }, - vendorContact: sponsorContact, + contactName, + contactEmail, + contactPhone, + contactPosition, taxExempt, discountCode, sponsorNotes @@ -1216,6 +1248,49 @@ export default class FinanceServices { return sponsorTransformer(updatedSponsor); } + /** + * Toggles the done status of a sponsor task + * @param submitter The user toggling the task + * @param organization The organization the task belongs to + * @param sponsorTaskId The id of the sponsor task to toggle + * @returns The updated sponsor task + */ + static async toggleSponsorTaskDone( + submitter: User, + organization: Organization, + sponsorTaskId: string + ): Promise { + const sponsorTask = await prisma.sponsor_Task.findFirst({ + where: { + sponsorTaskId, + dateDeleted: null, + OR: [ + { sponsor: { organizationId: organization.organizationId } }, + { prospectiveSponsor: { organizationId: organization.organizationId } } + ] + }, + ...getSponsorTaskQueryArgs(organization.organizationId) + }); + + if (!sponsorTask) throw new NotFoundException('SponsorTask', sponsorTaskId); + + // Allow finance team, heads, or the task assignee to toggle done status + const isAssignee = sponsorTask.assigneeUserId === submitter.userId; + const isFinanceOrHead = await isUserFinanceTeamOrHead(submitter, organization.organizationId); + + if (!isAssignee && !isFinanceOrHead) { + throw new AccessDeniedException('Only finance team members, heads, or the task assignee can toggle task status'); + } + + const updatedSponsorTask = await prisma.sponsor_Task.update({ + where: { sponsorTaskId }, + data: { done: !sponsorTask.done }, + ...getSponsorTaskQueryArgs(organization.organizationId) + }); + + return sponsorTaskTransformer(updatedSponsorTask); + } + /** * Gets all sponsor tiers * @param organization organization sponsor tiers belong to diff --git a/src/backend/src/services/prospective-sponsor.services.ts b/src/backend/src/services/prospective-sponsor.services.ts new file mode 100644 index 0000000000..30c6d0f759 --- /dev/null +++ b/src/backend/src/services/prospective-sponsor.services.ts @@ -0,0 +1,359 @@ +import { + FirstContactMethod, + isHead, + ProspectiveSponsor, + ProspectiveSponsorStatus, + SponsorTask, + User +} from 'shared'; +import { Organization, Prospective_Sponsor_Status } from '@prisma/client'; +import { userHasPermission } from '../utils/users.utils.js'; +import { getProspectiveSponsorQueryArgs } from '../prisma-query-args/prospective-sponsor.query-args.js'; +import { getSponsorTaskQueryArgs } from '../prisma-query-args/sponsor.query.args.js'; +import { + AccessDeniedException, + DeletedException, + HttpException, + InvalidOrganizationException, + NotFoundException +} from '../utils/errors.utils.js'; +import prisma from '../prisma/prisma.js'; +import { prospectiveSponsorTransformer } from '../transformers/prospective-sponsor.transformer.js'; +import { sponsorTaskTransformer } from '../transformers/sponsor-task.transformer.js'; +import { notifySponsorTaskAssignee } from '../utils/slack.utils.js'; +import { isUserFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; + +export default class ProspectiveSponsorServices { + /** + * Creates a new prospective sponsor for the given organization. + */ + static async createProspectiveSponsor( + submitter: User, + organization: Organization, + organizationName: string, + lastContactDate: Date, + firstContactMethod: FirstContactMethod, + contactName: string, + contactorUserId: string, + highlightThresholdDays?: number, + contactEmail?: string, + contactPhone?: string, + contactPosition?: string + ): Promise { + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can create prospective sponsors'); + } + + const existingProspectiveSponsor = await prisma.prospective_Sponsor.findFirst({ + where: { + organizationName: { equals: organizationName, mode: 'insensitive' }, + organizationId: organization.organizationId, + dateDeleted: null + } + }); + + if (existingProspectiveSponsor) { + throw new HttpException(400, `A prospective sponsor with the name "${organizationName}" already exists.`); + } + + const contactor = await prisma.user.findUnique({ + where: { userId: contactorUserId } + }); + + if (!contactor) { + throw new NotFoundException('User', contactorUserId); + } + + const prospectiveSponsor = await prisma.prospective_Sponsor.create({ + data: { + organizationName, + lastContactDate, + highlightThresholdDays: highlightThresholdDays ?? 10, + firstContactMethod, + contactorUserId, + contactName, + contactEmail, + contactPhone, + contactPosition, + organizationId: organization.organizationId + }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsorTransformer(prospectiveSponsor); + } + + /** + * Returns all prospective sponsors for the organization. + */ + static async getAllProspectiveSponsors(organization: Organization): Promise { + const prospectiveSponsors = await prisma.prospective_Sponsor.findMany({ + where: { + organizationId: organization.organizationId, + dateDeleted: null + }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsors.map(prospectiveSponsorTransformer); + } + + /** + * Edits a prospective sponsor. + */ + static async editProspectiveSponsor( + submitter: User, + organization: Organization, + prospectiveSponsorId: string, + organizationName: string, + lastContactDate: Date, + status: ProspectiveSponsorStatus, + firstContactMethod: FirstContactMethod, + contactName: string, + contactorUserId: string, + highlightThresholdDays?: number, + contactEmail?: string, + contactPhone?: string, + contactPosition?: string + ): Promise { + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can edit prospective sponsors'); + } + + const oldProspectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId, organizationId: organization.organizationId } + }); + + if (!oldProspectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + if (oldProspectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId); + + if (organizationName !== oldProspectiveSponsor.organizationName) { + const existingProspectiveSponsor = await prisma.prospective_Sponsor.findFirst({ + where: { + organizationName: { equals: organizationName, mode: 'insensitive' }, + organizationId: organization.organizationId, + dateDeleted: null + } + }); + + if (existingProspectiveSponsor) { + throw new HttpException(400, `A prospective sponsor with the name "${organizationName}" already exists.`); + } + } + + const contactor = await prisma.user.findUnique({ + where: { userId: contactorUserId } + }); + + if (!contactor) { + throw new NotFoundException('User', contactorUserId); + } + + const updatedProspectiveSponsor = await prisma.prospective_Sponsor.update({ + where: { prospectiveSponsorId }, + data: { + organizationName, + lastContactDate, + highlightThresholdDays: highlightThresholdDays ?? 10, + status, + firstContactMethod, + contactorUserId, + contactName, + contactEmail, + contactPhone, + contactPosition + }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsorTransformer(updatedProspectiveSponsor); + } + + /** + * Soft deletes a prospective sponsor. + */ + static async deleteProspectiveSponsor( + prospectiveSponsorId: string, + deleter: User, + organization: Organization + ): Promise { + if (!(await userHasPermission(deleter.userId, organization.organizationId, isHead))) { + throw new AccessDeniedException('Only heads can delete prospective sponsors'); + } + + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId } + }); + + if (!prospectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + if (prospectiveSponsor.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('ProspectiveSponsor'); + } + if (prospectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId); + + const deletedProspectiveSponsor = await prisma.prospective_Sponsor.update({ + where: { prospectiveSponsorId }, + data: { dateDeleted: new Date() }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsorTransformer(deletedProspectiveSponsor); + } + + /** + * Gets tasks for a prospective sponsor. + */ + static async getProspectiveSponsorTasks( + prospectiveSponsorId: string, + organizationId: string + ): Promise { + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId, dateDeleted: null } + }); + + if (!prospectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + + const tasks = await prisma.sponsor_Task.findMany({ + where: { + prospectiveSponsorId, + dateDeleted: null + }, + ...getSponsorTaskQueryArgs(organizationId) + }); + + return tasks.map(sponsorTaskTransformer); + } + + /** + * Creates a task for a prospective sponsor. + */ + static async createProspectiveSponsorTask( + submitter: User, + organization: Organization, + prospectiveSponsorId: string, + dueDate: Date, + notes: string, + notifyDate?: Date, + assigneeUserId?: string + ): Promise { + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can create prospective sponsor tasks'); + } + + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId, organizationId: organization.organizationId } + }); + + if (!prospectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + if (prospectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId); + + if (assigneeUserId) { + const assignee = await prisma.user.findUnique({ where: { userId: assigneeUserId } }); + if (!assignee) throw new NotFoundException('User', assigneeUserId); + } + + const createdTask = await prisma.sponsor_Task.create({ + data: { + dueDate, + notifyDate, + assigneeUserId, + notes, + prospectiveSponsorId + }, + ...getSponsorTaskQueryArgs(organization.organizationId) + }); + + if (createdTask.assigneeUserId) { + const assignee = await prisma.user.findUnique({ + where: { userId: createdTask.assigneeUserId }, + include: { userSettings: true } + }); + if (assignee) { + await notifySponsorTaskAssignee(assignee, createdTask, prospectiveSponsor.organizationName); + } + } + + return sponsorTaskTransformer(createdTask); + } + + /** + * Accepts a prospective sponsor and creates a full sponsor record. + */ + static async acceptProspectiveSponsor( + submitter: User, + organization: Organization, + prospectiveSponsorId: string, + sponsorTierId: string, + sponsorValue: number, + joinDate: Date, + activeYears: number[], + taxExempt: boolean, + discountCode?: string, + sponsorNotes?: string + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) { + throw new AccessDeniedException('Only heads can accept prospective sponsors'); + } + + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId, organizationId: organization.organizationId } + }); + + if (!prospectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + if (prospectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId); + if (prospectiveSponsor.status === Prospective_Sponsor_Status.ACCEPTED) { + throw new HttpException(400, 'This prospective sponsor has already been accepted'); + } + + const tier = await prisma.sponsor_Tier.findUnique({ + where: { sponsorTierId, organizationId: organization.organizationId } + }); + + if (!tier) throw new NotFoundException('SponsorTier', sponsorTierId); + + // Check if a sponsor with this name already exists + const existingSponsor = await prisma.sponsor.findFirst({ + where: { + name: { equals: prospectiveSponsor.organizationName, mode: 'insensitive' }, + organizationId: organization.organizationId, + dateDeleted: null + } + }); + + if (existingSponsor) { + throw new HttpException(400, `A sponsor with the name "${prospectiveSponsor.organizationName}" already exists.`); + } + + // Create the sponsor + await prisma.sponsor.create({ + data: { + name: prospectiveSponsor.organizationName, + activeStatus: true, + sponsorValue, + joinDate, + activeYears, + sponsorTierId, + taxExempt, + discountCode, + sponsorNotes, + contactName: prospectiveSponsor.contactName, + contactEmail: prospectiveSponsor.contactEmail, + contactPhone: prospectiveSponsor.contactPhone, + contactPosition: prospectiveSponsor.contactPosition, + organizationId: organization.organizationId + } + }); + + // Soft-delete the prospective sponsor since they're now a full sponsor + const updatedProspectiveSponsor = await prisma.prospective_Sponsor.update({ + where: { prospectiveSponsorId }, + data: { + status: Prospective_Sponsor_Status.ACCEPTED, + dateDeleted: new Date() + }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsorTransformer(updatedProspectiveSponsor); + } +} diff --git a/src/backend/src/services/reimbursement-requests.services.ts b/src/backend/src/services/reimbursement-requests.services.ts index f3f3a57621..4a5183a9e3 100644 --- a/src/backend/src/services/reimbursement-requests.services.ts +++ b/src/backend/src/services/reimbursement-requests.services.ts @@ -34,7 +34,7 @@ import { validateUserEditRRPermissions, validateRefund, validateUserIsPartOfFinanceTeamOrHead, - isUserOnFinanceTeam + isUserFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; import { AccessDeniedAdminOnlyException, @@ -188,10 +188,7 @@ export default class ReimbursementRequestService { * @returns All the reimbursements in the database */ static async getAllReimbursements(user: User, organization: Organization): Promise { - const isUserAuthorized = - (await isUserOnFinanceTeam(user, organization.organizationId)) || - (await userHasPermission(user.userId, organization.organizationId, isHead)); - if (!isUserAuthorized) { + if (!(await isUserFinanceTeamOrHead(user, organization.organizationId))) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -869,10 +866,7 @@ export default class ReimbursementRequestService { * @returns the 'deleted' account code */ static async deleteAccountCode(accountCodeId: string, submitter: User, organization: Organization) { - const isUserAuthorized = - (await isUserOnFinanceTeam(submitter, organization.organizationId)) || - (await userHasPermission(submitter.userId, organization.organizationId, isHead)); - if (!isUserAuthorized) { + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -981,10 +975,7 @@ export default class ReimbursementRequestService { * @returns an array of the prisma version of the reimbursement requests transformed to the shared version */ static async getAllReimbursementRequests(user: User, organization: Organization): Promise { - const isUserAuthorized = - (await isUserOnFinanceTeam(user, organization.organizationId)) || - (await userHasPermission(user.userId, organization.organizationId, isHead)); - if (!isUserAuthorized) { + if (!(await isUserFinanceTeamOrHead(user, organization.organizationId))) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -1511,8 +1502,7 @@ export default class ReimbursementRequestService { const isUserAuthorized = existingVendor.addedByUserId === submitter.userId || - (await isUserOnFinanceTeam(submitter, organization.organizationId)) || - (await userHasPermission(submitter.userId, organization.organizationId, isHead)); + (await isUserFinanceTeamOrHead(submitter, organization.organizationId)); if (!isUserAuthorized) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -1565,8 +1555,7 @@ export default class ReimbursementRequestService { const isUserAuthorized = existingVendor.addedByUserId === submitter.userId || - (await isUserOnFinanceTeam(submitter, organization.organizationId)) || - (await userHasPermission(submitter.userId, organization.organizationId, isHead)); + (await isUserFinanceTeamOrHead(submitter, organization.organizationId)); if (!isUserAuthorized) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -1603,8 +1592,7 @@ export default class ReimbursementRequestService { const isUserAuthorized = existingVendor.addedByUserId === submitter.userId || - (await isUserOnFinanceTeam(submitter, organization.organizationId)) || - (await userHasPermission(submitter.userId, organization.organizationId, isHead)); + (await isUserFinanceTeamOrHead(submitter, organization.organizationId)); if (!isUserAuthorized) { throw new AccessDeniedException(`You are not a member of the finance team!`); } diff --git a/src/backend/src/transformers/finance.transformer.ts b/src/backend/src/transformers/finance.transformer.ts index e7aa8365f3..6e0e805f31 100644 --- a/src/backend/src/transformers/finance.transformer.ts +++ b/src/backend/src/transformers/finance.transformer.ts @@ -6,7 +6,12 @@ import { userTransformer } from './user.transformer.js'; export const sponsorTransformer = (sponsor: Prisma.SponsorGetPayload): Sponsor => { return { ...sponsor, - sponsorContact: sponsor.vendorContact, + contact: { + name: sponsor.contactName, + email: sponsor.contactEmail ?? undefined, + phone: sponsor.contactPhone ?? undefined, + position: sponsor.contactPosition ?? undefined + }, sponsorNotes: sponsor.sponsorNotes ?? undefined, discountCode: sponsor.discountCode ?? undefined, sponsorTasks: sponsor.sponsorTasks.map(sponsorTaskTranformer) diff --git a/src/backend/src/transformers/prospective-sponsor.transformer.ts b/src/backend/src/transformers/prospective-sponsor.transformer.ts new file mode 100644 index 0000000000..875e4a9c0e --- /dev/null +++ b/src/backend/src/transformers/prospective-sponsor.transformer.ts @@ -0,0 +1,29 @@ +import { Prisma } from '@prisma/client'; +import { ProspectiveSponsor, ProspectiveSponsorStatus, FirstContactMethod } from 'shared'; +import { ProspectiveSponsorQueryArgs } from '../prisma-query-args/prospective-sponsor.query-args.js'; +import { userTransformer } from './user.transformer.js'; +import { sponsorTaskTransformer } from './sponsor-task.transformer.js'; + +export const prospectiveSponsorTransformer = ( + prospectiveSponsor: Prisma.Prospective_SponsorGetPayload +): ProspectiveSponsor => { + return { + prospectiveSponsorId: prospectiveSponsor.prospectiveSponsorId, + organizationName: prospectiveSponsor.organizationName, + dateCreated: prospectiveSponsor.dateCreated, + lastContactDate: prospectiveSponsor.lastContactDate, + highlightThresholdDays: prospectiveSponsor.highlightThresholdDays, + status: prospectiveSponsor.status as ProspectiveSponsorStatus, + firstContactMethod: prospectiveSponsor.firstContactMethod as FirstContactMethod, + contactor: userTransformer(prospectiveSponsor.contactor), + contact: { + name: prospectiveSponsor.contactName, + email: prospectiveSponsor.contactEmail ?? undefined, + phone: prospectiveSponsor.contactPhone ?? undefined, + position: prospectiveSponsor.contactPosition ?? undefined + }, + tasks: prospectiveSponsor.tasks.map(sponsorTaskTransformer) + }; +}; + +export default prospectiveSponsorTransformer; diff --git a/src/backend/src/transformers/sponsor-task.transformer.ts b/src/backend/src/transformers/sponsor-task.transformer.ts index 68a30dece7..36e8a2735b 100644 --- a/src/backend/src/transformers/sponsor-task.transformer.ts +++ b/src/backend/src/transformers/sponsor-task.transformer.ts @@ -9,7 +9,8 @@ export const sponsorTaskTransformer = (sponsorTask: Prisma.Sponsor_TaskGetPayloa dueDate: sponsorTask.dueDate, notifyDate: sponsorTask.notifyDate ?? undefined, assignee: sponsorTask.assignee ? userTransformer(sponsorTask.assignee) : undefined, - notes: sponsorTask.notes + notes: sponsorTask.notes, + done: sponsorTask.done }; }; diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index 14cb219315..8ef8e17166 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -210,4 +210,6 @@ export type ExceptionObjectNames = | 'Calendar' | 'Event Type' | 'Event' - | 'Schedule Slot'; + | 'Schedule Slot' + | 'ProspectiveSponsor' + | 'SponsorTier'; diff --git a/src/backend/src/utils/reimbursement-requests.utils.ts b/src/backend/src/utils/reimbursement-requests.utils.ts index b29af44d65..4d23ed084b 100644 --- a/src/backend/src/utils/reimbursement-requests.utils.ts +++ b/src/backend/src/utils/reimbursement-requests.utils.ts @@ -311,12 +311,18 @@ export const createReimbursementProducts = async ( * finance team. */ export const validateUserIsPartOfFinanceTeamOrHead = async (user: User, organizationId: string) => { - const isUserAuthorized = - (await isUserOnFinanceTeam(user, organizationId)) || (await userHasPermission(user.userId, organizationId, isHead)); - - if (!isUserAuthorized) { - throw new AccessDeniedException(`You are not a member of the finance team!`); + // Check isHead first since it doesn't require finance team to exist + if (await userHasPermission(user.userId, organizationId, isHead)) { + return; + } + try { + if (await isUserOnFinanceTeam(user, organizationId)) { + return; + } + } catch { + // Finance team may not exist yet } + throw new AccessDeniedException(`You are not a member of the finance team!`); }; const getFinanceTeam = async (organizationId: string) => { @@ -349,6 +355,26 @@ export const isUserOnFinanceTeam = async (user: User, organizationId: string): P return isUserOnTeam(await getFinanceTeam(organizationId), user); }; +/** + * Checks if a user is on the finance team or is a head. + * Checks isHead first since it doesn't require the finance team to exist. + * + * @param user the user to check + * @param organizationId the organization id + * @returns whether the user is on the finance team or is a head + */ +export const isUserFinanceTeamOrHead = async (user: User, organizationId: string): Promise => { + if (await userHasPermission(user.userId, organizationId, isHead)) { + return true; + } + try { + return await isUserOnFinanceTeam(user, organizationId); + } catch { + // Finance team may not exist yet + return false; + } +}; + /** * Determines if a user is lead or head of the finance team. * diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index cda5e85f94..ee33c7d2ee 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -161,6 +161,7 @@ export const resetUsers = async () => { await prisma.announcement.deleteMany(); await prisma.popUp.deleteMany(); await prisma.sponsor_Task.deleteMany(); + await prisma.prospective_Sponsor.deleteMany(); await prisma.sponsor.deleteMany(); await prisma.sponsor_Tier.deleteMany(); await prisma.reimbursement_Product_Other_Reason.deleteMany(); diff --git a/src/backend/tests/unit/finance.test.ts b/src/backend/tests/unit/finance.test.ts index 1f4e22f66a..c6a7554263 100644 --- a/src/backend/tests/unit/finance.test.ts +++ b/src/backend/tests/unit/finance.test.ts @@ -72,7 +72,7 @@ describe('Finance Tests', () => { expect(result.tier.sponsorTierId).toEqual(sponsorTierId); expect(result.taxExempt).toBe(true); expect(result.discountCode).toEqual('googlecode'); - expect(result.sponsorContact).toEqual('Bill Gates'); + expect(result.contact.name).toEqual('Bill Gates'); expect(result.sponsorTasks).toEqual([]); }); }); @@ -258,7 +258,7 @@ describe('Finance Tests', () => { 'newNotes', new Date(12, 20, 24) ) - ).rejects.toThrow(new AccessDeniedException('Only heads can edit sponsor tasks.')); + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can edit sponsor tasks')); }); it('Edit fails if sponsor task does not exist', async () => { await expect( @@ -389,7 +389,7 @@ describe('Finance Tests', () => { const user = await createTestUser(wonderwomanGuest, orgId); await expect( FinanceServices.createSponsorTask(user, organization, new Date(1, 1, 25), 'notes', 'sponsorId') - ).rejects.toThrow(new AccessDeniedException('Only heads can create a sponsor task')); + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can create a sponsor task')); }); it('Fails when assigned user is not found', async () => { @@ -532,7 +532,7 @@ describe('Finance Tests', () => { expect(updatedSponsor.joinDate).toEqual(new Date(5, 11, 25)); expect(updatedSponsor.activeYears).toEqual([2024, 2025]); expect(updatedSponsor.tier.sponsorTierId).toBe(sponsorTierId); - expect(updatedSponsor.sponsorContact).toBe('New Vendor Contact'); + expect(updatedSponsor.contact.name).toBe('New Vendor Contact'); expect(updatedSponsor.taxExempt).toBe(false); expect(updatedSponsor.discountCode).toBe('New Discount code'); }); @@ -697,7 +697,7 @@ describe('Finance Tests', () => { await createTestUser(theVisitorGuest, orgId), organization ) - ).rejects.toThrow(new AccessDeniedException('Only heads can delete sponsor tasks.')); + ).rejects.toThrow(new AccessDeniedException('Only heads or the task assignee can delete sponsor tasks')); }); it('Delete fails if given sponsor task cannot be found', async () => { await expect(async () => @@ -705,4 +705,148 @@ describe('Finance Tests', () => { ).rejects.toThrow(new NotFoundException('SponsorTask', '123')); }); }); + + describe('Toggle Sponsor Task Done', () => { + it('Succeeds and toggles done from false to true', async () => { + const user = await createTestUser(supermanAdmin, orgId); + const sponsor = await FinanceServices.createSponsor( + user, + 'Tesla', + true, + 5000, + new Date(12, 1, 24), + [2024], + sponsorTierId, + true, + 'Elon Musk', + [], + organization + ); + + const sponsorTask = await FinanceServices.createSponsorTask( + user, + organization, + new Date(1, 2, 3), + 'Test task', + sponsor.sponsorId + ); + + expect(sponsorTask.done).toBe(false); + + const toggledTask = await FinanceServices.toggleSponsorTaskDone( + user, + organization, + sponsorTask.sponsorTaskId + ); + + expect(toggledTask.done).toBe(true); + }); + + it('Succeeds and toggles done from true to false', async () => { + const user = await createTestUser(supermanAdmin, orgId); + const sponsor = await FinanceServices.createSponsor( + user, + 'Tesla', + true, + 5000, + new Date(12, 1, 24), + [2024], + sponsorTierId, + true, + 'Elon Musk', + [], + organization + ); + + const sponsorTask = await FinanceServices.createSponsorTask( + user, + organization, + new Date(1, 2, 3), + 'Test task', + sponsor.sponsorId + ); + + // Toggle to true first + await FinanceServices.toggleSponsorTaskDone(user, organization, sponsorTask.sponsorTaskId); + + // Toggle back to false + const toggledTask = await FinanceServices.toggleSponsorTaskDone( + user, + organization, + sponsorTask.sponsorTaskId + ); + + expect(toggledTask.done).toBe(false); + }); + + it('Fails if user is not a head', async () => { + const head = await createTestUser(supermanAdmin, orgId); + const guest = await createTestUser(theVisitorGuest, orgId); + + const sponsor = await FinanceServices.createSponsor( + head, + 'Tesla', + true, + 5000, + new Date(12, 1, 24), + [2024], + sponsorTierId, + true, + 'Elon Musk', + [], + organization + ); + + const sponsorTask = await FinanceServices.createSponsorTask( + head, + organization, + new Date(1, 2, 3), + 'Test task', + sponsor.sponsorId + ); + + await expect( + FinanceServices.toggleSponsorTaskDone(guest, organization, sponsorTask.sponsorTaskId) + ).rejects.toThrow(new AccessDeniedException('Only finance team members, heads, or the task assignee can toggle task status')); + }); + + it('Fails if sponsor task does not exist', async () => { + const user = await createTestUser(supermanAdmin, orgId); + + await expect( + FinanceServices.toggleSponsorTaskDone(user, organization, 'nonexistent-id') + ).rejects.toThrow(new NotFoundException('SponsorTask', 'nonexistent-id')); + }); + + it('Fails if sponsor task is deleted', async () => { + const user = await createTestUser(supermanAdmin, orgId); + const sponsor = await FinanceServices.createSponsor( + user, + 'Tesla', + true, + 5000, + new Date(12, 1, 24), + [2024], + sponsorTierId, + true, + 'Elon Musk', + [], + organization + ); + + const sponsorTask = await FinanceServices.createSponsorTask( + user, + organization, + new Date(1, 2, 3), + 'Test task', + sponsor.sponsorId + ); + + await FinanceServices.deleteSponsorTask(sponsorTask.sponsorTaskId, user, organization); + + await expect( + FinanceServices.toggleSponsorTaskDone(user, organization, sponsorTask.sponsorTaskId) + ).rejects.toThrow(new NotFoundException('SponsorTask', sponsorTask.sponsorTaskId)); + }); + }); }); diff --git a/src/backend/tests/unit/prospective-sponsor.test.ts b/src/backend/tests/unit/prospective-sponsor.test.ts new file mode 100644 index 0000000000..1f0576470e --- /dev/null +++ b/src/backend/tests/unit/prospective-sponsor.test.ts @@ -0,0 +1,909 @@ +import { Organization } from '@prisma/client'; +import ProspectiveSponsorServices from '../../src/services/prospective-sponsor.services.js'; +import FinanceServices from '../../src/services/finance.services.js'; +import { + AccessDeniedException, + DeletedException, + HttpException, + NotFoundException +} from '../../src/utils/errors.utils.js'; +import { batmanAppAdmin, wonderwomanGuest, supermanAdmin, theVisitorGuest } from '../test-data/users.test-data.js'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; +import prisma from '../../src/prisma/prisma.js'; +import { FirstContactMethod, ProspectiveSponsorStatus } from 'shared'; + +describe('Prospective Sponsor Tests', () => { + let orgId: string; + let organization: Organization; + let sponsorTierId: string; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + const sponsorTier = await prisma.sponsor_Tier.create({ + data: { + name: 'Gold Tier', + colorHexCode: '#FFD700', + organizationId: orgId + } + }); + ({ sponsorTierId } = sponsorTier); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Create Prospective Sponsor', () => { + it('Fails if user is not on finance team or a head', async () => { + const guest = await createTestUser(wonderwomanGuest, orgId); + const contactor = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsor( + guest, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + contactor.userId + ) + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can create prospective sponsors')); + }); + + it('Fails if prospective sponsor with same name already exists', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Doe', + head.userId + ) + ).rejects.toThrow(new HttpException(400, 'A prospective sponsor with the name "Acme Corp" already exists.')); + }); + + it('Fails if contactor user does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + 'nonexistent-user-id' + ) + ).rejects.toThrow(new NotFoundException('User', 'nonexistent-user-id')); + }); + + it('Succeeds and creates a prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const lastContactDate = new Date(2024, 5, 15); + + const result = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + lastContactDate, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + 14, + 'john@acme.com', + '555-1234', + 'CEO' + ); + + expect(result.organizationName).toBe('Acme Corp'); + expect(result.lastContactDate).toEqual(lastContactDate); + expect(result.firstContactMethod).toBe(FirstContactMethod.INBOUND_EMAIL); + expect(result.contact.name).toBe('John Doe'); + expect(result.contact.email).toBe('john@acme.com'); + expect(result.contact.phone).toBe('555-1234'); + expect(result.contact.position).toBe('CEO'); + expect(result.contactor.userId).toBe(head.userId); + expect(result.highlightThresholdDays).toBe(14); + expect(result.status).toBe(ProspectiveSponsorStatus.IN_PROGRESS); + expect(result.tasks).toEqual([]); + }); + + it('Uses default highlight threshold of 10 days', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const result = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + expect(result.highlightThresholdDays).toBe(10); + }); + }); + + describe('Get All Prospective Sponsors', () => { + it('Returns all prospective sponsors for organization', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps1 = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + const ps2 = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Beta Inc', + new Date(), + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Smith', + head.userId + ); + + const results = await ProspectiveSponsorServices.getAllProspectiveSponsors(organization); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.prospectiveSponsorId)).toContain(ps1.prospectiveSponsorId); + expect(results.map((r) => r.prospectiveSponsorId)).toContain(ps2.prospectiveSponsorId); + }); + + it('Does not return deleted prospective sponsors', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps1 = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Beta Inc', + new Date(), + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Smith', + head.userId + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps1.prospectiveSponsorId, head, organization); + + const results = await ProspectiveSponsorServices.getAllProspectiveSponsors(organization); + + expect(results).toHaveLength(1); + expect(results[0].organizationName).toBe('Beta Inc'); + }); + }); + + describe('Edit Prospective Sponsor', () => { + it('Fails if user is not on finance team or a head', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const guest = await createTestUser(wonderwomanGuest, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + guest, + organization, + ps.prospectiveSponsorId, + 'Acme Corp Updated', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ) + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can edit prospective sponsors')); + }); + + it('Fails if prospective sponsor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + 'nonexistent-id', + 'Acme Corp', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Fails if prospective sponsor is deleted', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'Acme Corp Updated', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Fails if new name already exists', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps1 = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Beta Inc', + new Date(), + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Smith', + head.userId + ); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps1.prospectiveSponsorId, + 'Beta Inc', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ) + ).rejects.toThrow(new HttpException(400, 'A prospective sponsor with the name "Beta Inc" already exists.')); + }); + + it('Fails if contactor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'Acme Corp', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + 'nonexistent-user-id' + ) + ).rejects.toThrow(new NotFoundException('User', 'nonexistent-user-id')); + }); + + it('Succeeds and updates prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const newContactor = await createTestUser(supermanAdmin, orgId); + const newLastContactDate = new Date(2024, 8, 20); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + const result = await ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'Acme Corporation', + newLastContactDate, + ProspectiveSponsorStatus.NO_RESPONSE, + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Smith', + newContactor.userId, + 20, + 'jane@acme.com', + '555-5678', + 'CFO' + ); + + expect(result.organizationName).toBe('Acme Corporation'); + expect(result.lastContactDate).toEqual(newLastContactDate); + expect(result.status).toBe(ProspectiveSponsorStatus.NO_RESPONSE); + expect(result.firstContactMethod).toBe(FirstContactMethod.OUTBOUND_EMAIL); + expect(result.contact.name).toBe('Jane Smith'); + expect(result.contact.email).toBe('jane@acme.com'); + expect(result.contact.phone).toBe('555-5678'); + expect(result.contact.position).toBe('CFO'); + expect(result.contactor.userId).toBe(newContactor.userId); + expect(result.highlightThresholdDays).toBe(20); + }); + }); + + describe('Delete Prospective Sponsor', () => { + it('Fails if user is not a head', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const guest = await createTestUser(wonderwomanGuest, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, guest, organization) + ).rejects.toThrow(new AccessDeniedException('Only heads can delete prospective sponsors')); + }); + + it('Fails if prospective sponsor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.deleteProspectiveSponsor('nonexistent-id', head, organization) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Fails if prospective sponsor is already deleted', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + await expect( + ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Succeeds and soft deletes prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + const result = await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + expect(result.prospectiveSponsorId).toBe(ps.prospectiveSponsorId); + + const deletedPs = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId: ps.prospectiveSponsorId } + }); + + expect(deletedPs?.dateDeleted).not.toBeNull(); + }); + }); + + describe('Get Prospective Sponsor Tasks', () => { + it('Fails if prospective sponsor does not exist', async () => { + await expect( + ProspectiveSponsorServices.getProspectiveSponsorTasks('nonexistent-id', orgId) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Returns tasks for prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + const task1 = await ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + new Date(2024, 6, 15), + 'Follow up on initial contact' + ); + + const task2 = await ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + new Date(2024, 7, 1), + 'Send proposal' + ); + + const results = await ProspectiveSponsorServices.getProspectiveSponsorTasks(ps.prospectiveSponsorId, orgId); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.sponsorTaskId)).toContain(task1.sponsorTaskId); + expect(results.map((r) => r.sponsorTaskId)).toContain(task2.sponsorTaskId); + }); + }); + + describe('Create Prospective Sponsor Task', () => { + it('Fails if user is not on finance team or a head', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const guest = await createTestUser(wonderwomanGuest, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsorTask( + guest, + organization, + ps.prospectiveSponsorId, + new Date(), + 'Follow up' + ) + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can create prospective sponsor tasks')); + }); + + it('Fails if prospective sponsor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + 'nonexistent-id', + new Date(), + 'Follow up' + ) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Fails if prospective sponsor is deleted', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + new Date(), + 'Follow up' + ) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Fails if assignee does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + new Date(), + 'Follow up', + undefined, + 'nonexistent-user-id' + ) + ).rejects.toThrow(new NotFoundException('User', 'nonexistent-user-id')); + }); + + it('Succeeds and creates task without assignee', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const dueDate = new Date(2024, 6, 15); + const notifyDate = new Date(2024, 6, 10); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + const result = await ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + dueDate, + 'Follow up on initial contact', + notifyDate + ); + + expect(result.dueDate).toEqual(dueDate); + expect(result.notifyDate).toEqual(notifyDate); + expect(result.notes).toBe('Follow up on initial contact'); + expect(result.assignee).toBeUndefined(); + expect(result.done).toBe(false); + }); + + it('Succeeds and creates task with assignee', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const assignee = await createTestUser(supermanAdmin, orgId); + const dueDate = new Date(2024, 6, 15); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + const result = await ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + dueDate, + 'Follow up on initial contact', + undefined, + assignee.userId + ); + + expect(result.dueDate).toEqual(dueDate); + expect(result.notes).toBe('Follow up on initial contact'); + expect(result.assignee?.userId).toBe(assignee.userId); + }); + }); + + describe('Accept Prospective Sponsor', () => { + it('Fails if user is not a head', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const guest = await createTestUser(wonderwomanGuest, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + guest, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + 5000, + new Date(), + [2024], + false + ) + ).rejects.toThrow(new AccessDeniedException('Only heads can accept prospective sponsors')); + }); + + it('Fails if prospective sponsor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + 'nonexistent-id', + sponsorTierId, + 5000, + new Date(), + [2024], + false + ) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Fails if prospective sponsor is deleted', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + 5000, + new Date(), + [2024], + false + ) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Fails if prospective sponsor is already accepted (soft-deleted)', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + 5000, + new Date(), + [2024], + false + ); + + // After accepting, the prospective sponsor is soft-deleted, so trying to accept again throws DeletedException + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + 10000, + new Date(), + [2024, 2025], + true + ) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Fails if sponsor tier does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'nonexistent-tier-id', + 5000, + new Date(), + [2024], + false + ) + ).rejects.toThrow(new NotFoundException('SponsorTier', 'nonexistent-tier-id')); + }); + + it('Fails if sponsor with same name already exists', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + // Create an existing sponsor with the same name + await FinanceServices.createSponsor( + head, + 'Acme Corp', + true, + 5000, + new Date(), + [2024], + sponsorTierId, + false, + 'Existing Contact', + [], + organization + ); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + 5000, + new Date(), + [2024], + false + ) + ).rejects.toThrow(new HttpException(400, 'A sponsor with the name "Acme Corp" already exists.')); + }); + + it('Succeeds and creates sponsor from prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const joinDate = new Date(2024, 6, 1); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + 14, + 'john@acme.com', + '555-1234', + 'CEO' + ); + + const result = await ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + 10000, + joinDate, + [2024, 2025], + true, + 'ACME10', + 'Great partner!' + ); + + // Check that prospective sponsor status is updated + expect(result.status).toBe(ProspectiveSponsorStatus.ACCEPTED); + + // Verify the sponsor was created + const sponsors = await FinanceServices.getAllSponsors(organization); + const createdSponsor = sponsors.find((s) => s.name === 'Acme Corp'); + + expect(createdSponsor).toBeDefined(); + expect(createdSponsor!.name).toBe('Acme Corp'); + expect(createdSponsor!.activeStatus).toBe(true); + expect(createdSponsor!.sponsorValue).toBe(10000); + expect(createdSponsor!.joinDate).toEqual(joinDate); + expect(createdSponsor!.activeYears).toEqual([2024, 2025]); + expect(createdSponsor!.tier.sponsorTierId).toBe(sponsorTierId); + expect(createdSponsor!.taxExempt).toBe(true); + expect(createdSponsor!.discountCode).toBe('ACME10'); + expect(createdSponsor!.sponsorNotes).toBe('Great partner!'); + expect(createdSponsor!.contact.name).toBe('John Doe'); + expect(createdSponsor!.contact.email).toBe('john@acme.com'); + expect(createdSponsor!.contact.phone).toBe('555-1234'); + expect(createdSponsor!.contact.position).toBe('CEO'); + + // Verify the prospective sponsor was soft-deleted + const allProspectiveSponsors = await ProspectiveSponsorServices.getAllProspectiveSponsors(organization); + const deletedPs = allProspectiveSponsors.find((p) => p.prospectiveSponsorId === ps.prospectiveSponsorId); + expect(deletedPs).toBeUndefined(); // Should not appear in the list since it's soft-deleted + }); + }); +}); diff --git a/src/frontend/src/apis/finance.api.ts b/src/frontend/src/apis/finance.api.ts index 1da8b5de25..3827db6eea 100644 --- a/src/frontend/src/apis/finance.api.ts +++ b/src/frontend/src/apis/finance.api.ts @@ -769,3 +769,118 @@ export const deleteSponsorTier = (sponsorTierId: string) => { export const editSponsorTier = (sponsorTierId: string, formData: SponsorTierPayload) => { return axios.post(apiUrls.editSponsorTier(sponsorTierId), formData); }; + +/** + * Toggles the done status of a sponsor task + * + * @param sponsorTaskId the id of the sponsor task to toggle + * @returns the updated sponsor task + */ +export const toggleSponsorTaskDone = (sponsorTaskId: string) => { + return axios.post(apiUrls.toggleSponsorTaskDone(sponsorTaskId), { + transformResponse: (data: string) => JSON.parse(data) + }); +}; + +/**************** Prospective Sponsors API ****************/ + +import { ProspectiveSponsor, FirstContactMethod, ProspectiveSponsorStatus } from 'shared'; +import { prospectiveSponsorTransformer } from './transformers/prospective-sponsor.transformer'; + +export interface CreateProspectiveSponsorPayload { + organizationName: string; + lastContactDate: Date; + firstContactMethod: FirstContactMethod; + contactName: string; + contactorUserId: string; + highlightThresholdDays?: number; + contactEmail?: string; + contactPhone?: string; + contactPosition?: string; +} + +export interface EditProspectiveSponsorPayload { + organizationName: string; + lastContactDate: Date; + status: ProspectiveSponsorStatus; + firstContactMethod: FirstContactMethod; + contactName: string; + contactorUserId: string; + highlightThresholdDays?: number; + contactEmail?: string; + contactPhone?: string; + contactPosition?: string; +} + +export interface AcceptProspectiveSponsorPayload { + sponsorTierId: string; + sponsorValue: number; + joinDate: Date; + activeYears: number[]; + taxExempt: boolean; + discountCode?: string; + sponsorNotes?: string; +} + +/** + * Get all prospective sponsors + */ +export const getAllProspectiveSponsors = () => { + return axios.get(apiUrls.getAllProspectiveSponsors(), { + transformResponse: (data: string) => JSON.parse(data).map(prospectiveSponsorTransformer) + }); +}; + +/** + * Create a new prospective sponsor + */ +export const createProspectiveSponsor = (payload: CreateProspectiveSponsorPayload) => { + return axios.post(apiUrls.createProspectiveSponsor(), payload, { + transformResponse: (data: string) => prospectiveSponsorTransformer(JSON.parse(data)) + }); +}; + +/** + * Edit a prospective sponsor + */ +export const editProspectiveSponsor = (prospectiveSponsorId: string, payload: EditProspectiveSponsorPayload) => { + return axios.post(apiUrls.editProspectiveSponsor(prospectiveSponsorId), payload, { + transformResponse: (data: string) => prospectiveSponsorTransformer(JSON.parse(data)) + }); +}; + +/** + * Delete a prospective sponsor + */ +export const deleteProspectiveSponsor = (prospectiveSponsorId: string) => { + return axios.post(apiUrls.deleteProspectiveSponsor(prospectiveSponsorId), { + transformResponse: (data: string) => prospectiveSponsorTransformer(JSON.parse(data)) + }); +}; + +/** + * Get tasks for a prospective sponsor + */ +export const getProspectiveSponsorTasks = (prospectiveSponsorId: string) => { + return axios.get(apiUrls.getProspectiveSponsorTasks(prospectiveSponsorId), { + transformResponse: (data: string) => JSON.parse(data) + }); +}; + +/** + * Create a task for a prospective sponsor + */ +export const createProspectiveSponsorTask = (prospectiveSponsorId: string, payload: SponsorTaskPayload) => { + return axios.post(apiUrls.createProspectiveSponsorTask(prospectiveSponsorId), payload, { + transformResponse: (data: string) => JSON.parse(data) + }); +}; + +/** + * Accept a prospective sponsor (convert to full sponsor) + */ +export const acceptProspectiveSponsor = (prospectiveSponsorId: string, payload: AcceptProspectiveSponsorPayload) => { + return axios.post(apiUrls.acceptProspectiveSponsor(prospectiveSponsorId), payload, { + transformResponse: (data: string) => prospectiveSponsorTransformer(JSON.parse(data)) + }); +}; diff --git a/src/frontend/src/apis/transformers/prospective-sponsor.transformer.ts b/src/frontend/src/apis/transformers/prospective-sponsor.transformer.ts new file mode 100644 index 0000000000..e0594b8305 --- /dev/null +++ b/src/frontend/src/apis/transformers/prospective-sponsor.transformer.ts @@ -0,0 +1,25 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { ProspectiveSponsor } from 'shared'; + +/** + * Transforms a prospective sponsor to ensure deep field transformation of date objects. + * + * @param prospectiveSponsor Incoming prospective sponsor object supplied by the HTTP response. + * @returns Properly transformed prospective sponsor object. + */ +export const prospectiveSponsorTransformer = (prospectiveSponsor: ProspectiveSponsor): ProspectiveSponsor => { + return { + ...prospectiveSponsor, + dateCreated: new Date(prospectiveSponsor.dateCreated), + lastContactDate: new Date(prospectiveSponsor.lastContactDate), + tasks: prospectiveSponsor.tasks.map((task) => ({ + ...task, + dueDate: new Date(task.dueDate), + notifyDate: task.notifyDate ? new Date(task.notifyDate) : undefined + })) + }; +}; diff --git a/src/frontend/src/components/NERDataGrid.tsx b/src/frontend/src/components/NERDataGrid.tsx index b8aca1b45e..8ffc4040e0 100644 --- a/src/frontend/src/components/NERDataGrid.tsx +++ b/src/frontend/src/components/NERDataGrid.tsx @@ -25,6 +25,10 @@ interface NERDataGridProps { rowHeight?: number; paperSx?: SxProps; canEditRow?: (row: MapRowResult) => boolean; + // optional function to add custom CSS class names to rows + getRowClassName?: (row: MapRowResult) => string; + // optional custom sx styles for the DataGrid + dataGridSx?: SxProps; } function NERDataGrid({ @@ -42,7 +46,9 @@ function NERDataGrid({ headerHeight = 56, rowHeight = 52, paperSx, - canEditRow + canEditRow, + getRowClassName: customGetRowClassName, + dataGridSx }: NERDataGridProps) { const [searchTerm, setSearchTerm] = useState(''); const [pageSize, setPageSize] = useState(pageSizeDefault); @@ -125,7 +131,9 @@ function NERDataGrid({ getRowClassName={(params) => { const row = params.row as MapRowResult; const editable = canEditRow ? canEditRow(row) : true; - return editable ? 'editable-row' : 'non-editable-row'; + const editableClass = editable ? 'editable-row' : 'non-editable-row'; + const customClass = customGetRowClassName ? customGetRowClassName(row) : ''; + return `${editableClass} ${customClass}`.trim(); }} sx={{ height: '100%', @@ -150,7 +158,8 @@ function NERDataGrid({ }, '& .MuiDataGrid-columnSeparator': { display: 'none' - } + }, + ...dataGridSx }} /> diff --git a/src/frontend/src/hooks/finance.hooks.ts b/src/frontend/src/hooks/finance.hooks.ts index 9b8097c69b..cdeef7f59c 100644 --- a/src/frontend/src/hooks/finance.hooks.ts +++ b/src/frontend/src/hooks/finance.hooks.ts @@ -67,7 +67,18 @@ import { editIndexCode, getCurrentUserAssignedReimbursementRequests, assignMemberToRR, - setTaxExemptStatus + setTaxExemptStatus, + toggleSponsorTaskDone, + getAllProspectiveSponsors, + createProspectiveSponsor, + editProspectiveSponsor, + deleteProspectiveSponsor, + getProspectiveSponsorTasks, + createProspectiveSponsorTask, + acceptProspectiveSponsor, + CreateProspectiveSponsorPayload, + EditProspectiveSponsorPayload, + AcceptProspectiveSponsorPayload } from '../apis/finance.api'; import { IndexCode, @@ -87,7 +98,8 @@ import { ReimbursementRequestComment, ReimbursementRequestData, SpendingBarData, - CreateSponsorTask + CreateSponsorTask, + ProspectiveSponsor } from 'shared'; import { fullNamePipe } from '../utils/pipes'; @@ -163,7 +175,10 @@ export interface SponsorPayload { activeYears: number[]; sponsorTierId: string; taxExempt: boolean; - sponsorContact: string; + contactName: string; + contactEmail?: string; + contactPhone?: string; + contactPosition?: string; sponsorTasks: CreateSponsorTask[]; discountCode?: string; sponsorNotes?: string; @@ -1345,3 +1360,156 @@ export const useEditSponsorTier = (sponsorTierId: string) => { } ); }; + +/** + * Custom React Hook to toggle a sponsor task done status + * + * @returns the updated sponsor task + */ +export const useToggleSponsorTaskDone = () => { + const queryClient = useQueryClient(); + return useMutation( + ['sponsor-task', 'toggle-done'], + async (sponsorTaskId: string) => { + const { data } = await toggleSponsorTaskDone(sponsorTaskId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['sponsor-task']); + queryClient.invalidateQueries(['prospective-sponsor']); + } + } + ); +}; + +/**************** Prospective Sponsors Hooks ****************/ + +/** + * Custom React Hook to get all prospective sponsors + */ +export const useAllProspectiveSponsors = () => { + return useQuery(['prospective-sponsors'], async () => { + const { data } = await getAllProspectiveSponsors(); + return data; + }); +}; + +/** + * Custom React Hook to create a prospective sponsor + */ +export const useCreateProspectiveSponsor = () => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor', 'create'], + async (payload: CreateProspectiveSponsorPayload) => { + const { data } = await createProspectiveSponsor(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsors']); + } + } + ); +}; + +interface EditProspectiveSponsorMutationPayload extends EditProspectiveSponsorPayload { + prospectiveSponsorId: string; +} + +/** + * Custom React Hook to edit a prospective sponsor + */ +export const useEditProspectiveSponsor = () => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor', 'edit'], + async ({ prospectiveSponsorId, ...payload }) => { + const { data } = await editProspectiveSponsor(prospectiveSponsorId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsors']); + } + } + ); +}; + +/** + * Custom React Hook to delete a prospective sponsor + */ +export const useDeleteProspectiveSponsor = () => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor', 'delete'], + async (prospectiveSponsorId: string) => { + const { data } = await deleteProspectiveSponsor(prospectiveSponsorId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsors']); + } + } + ); +}; + +/** + * Custom React Hook to get tasks for a prospective sponsor + */ +export const useProspectiveSponsorTasks = (prospectiveSponsorId: string) => { + return useQuery( + ['prospective-sponsor-tasks', prospectiveSponsorId], + async () => { + const { data } = await getProspectiveSponsorTasks(prospectiveSponsorId); + return data; + }, + { enabled: !!prospectiveSponsorId } + ); +}; + +/** + * Custom React Hook to create a task for a prospective sponsor + */ +export const useCreateProspectiveSponsorTask = (prospectiveSponsorId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor-task', 'create'], + async (payload: SponsorTaskPayload) => { + const { data } = await createProspectiveSponsorTask(prospectiveSponsorId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsor-tasks', prospectiveSponsorId]); + queryClient.invalidateQueries(['prospective-sponsors']); + } + } + ); +}; + +interface AcceptProspectiveSponsorMutationPayload extends AcceptProspectiveSponsorPayload { + prospectiveSponsorId: string; +} + +/** + * Custom React Hook to accept a prospective sponsor (convert to full sponsor) + */ +export const useAcceptProspectiveSponsor = () => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor', 'accept'], + async ({ prospectiveSponsorId, ...payload }) => { + const { data } = await acceptProspectiveSponsor(prospectiveSponsorId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsors']); + queryClient.invalidateQueries(['sponsor']); + } + } + ); +}; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/AcceptProspectiveSponsorModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/AcceptProspectiveSponsorModal.tsx new file mode 100644 index 0000000000..eb2fc61cd5 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/AcceptProspectiveSponsorModal.tsx @@ -0,0 +1,280 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { + Box, + FormControl, + FormHelperText, + Typography, + Select, + MenuItem, + Checkbox, + Autocomplete, + TextField +} from '@mui/material'; +import { DatePicker } from '@mui/x-date-pickers'; +import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; +import { ProspectiveSponsor } from 'shared'; +import NERModal from '../../../components/NERModal'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useAcceptProspectiveSponsor, useGetAllSponsorTiers } from '../../../hooks/finance.hooks'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { useToast } from '../../../hooks/toasts.hooks'; + +interface AcceptProspectiveSponsorModalProps { + handleClose: () => void; + prospectiveSponsor: ProspectiveSponsor; + showModal: boolean; +} + +interface AcceptFormInputs { + sponsorTierId: string; + sponsorValue: number; + joinDate: Date; + activeYears: number[]; + taxExempt: boolean; + discountCode?: string; + sponsorNotes?: string; +} + +const getYears = (startYear = 1950) => { + const currentYear = new Date().getFullYear(); + const years = []; + for (let y = currentYear; y >= startYear; y--) { + years.push(y); + } + return years; +}; + +const acceptSchema = yup.object().shape({ + sponsorTierId: yup.string().required('Sponsor tier is required'), + sponsorValue: yup.number().typeError('Must be a number').required('Sponsor value is required'), + joinDate: yup.date().required('Join date is required'), + activeYears: yup.array().of(yup.number().required()).min(1, 'At least one active year is required').required(), + taxExempt: yup.boolean().required(), + discountCode: yup.string().optional(), + sponsorNotes: yup.string().optional() +}); + +const AcceptProspectiveSponsorModal = ({ + handleClose, + prospectiveSponsor, + showModal +}: AcceptProspectiveSponsorModalProps) => { + const { isLoading, isError, error, mutateAsync } = useAcceptProspectiveSponsor(); + const { + isLoading: tiersLoading, + isError: tiersIsError, + error: tiersError, + data: sponsorTiers + } = useGetAllSponsorTiers(); + const toast = useToast(); + + const [datePickerOpen, setDatePickerOpen] = useState(false); + const yearsOptions = getYears(); + + const { + control, + handleSubmit, + formState: { errors } + } = useForm({ + resolver: yupResolver(acceptSchema), + defaultValues: { + sponsorTierId: '', + sponsorValue: 0, + joinDate: new Date(), + activeYears: [new Date().getFullYear()], + taxExempt: false, + discountCode: '', + sponsorNotes: '' + } + }); + + if (isError) return ; + if (tiersIsError) return ; + if (isLoading || tiersLoading || !sponsorTiers) return ; + + const onSubmit = async (formData: AcceptFormInputs) => { + try { + await mutateAsync({ + prospectiveSponsorId: prospectiveSponsor.prospectiveSponsorId, + sponsorTierId: formData.sponsorTierId, + sponsorValue: formData.sponsorValue, + joinDate: formData.joinDate, + activeYears: formData.activeYears, + taxExempt: formData.taxExempt, + discountCode: formData.discountCode || undefined, + sponsorNotes: formData.sponsorNotes || undefined + }); + toast.success(`${prospectiveSponsor.organizationName} has been added as a sponsor! View them in the Sponsors tab.`); + handleClose(); + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }; + + return ( + + + + + Sponsor Tier:* + + ( + + )} + /> + {errors.sponsorTierId?.message} + + + + + Sponsor Value:* + + } + errorMessage={errors.sponsorValue} + /> + + + + + Join Date:* + + ( + setDatePickerOpen(false)} + onOpen={() => setDatePickerOpen(true)} + onChange={(newValue) => onChange(newValue ?? new Date())} + slotProps={{ + textField: { + size: 'small', + error: !!errors.joinDate, + helperText: errors.joinDate?.message, + onClick: () => setDatePickerOpen(true) + } + }} + /> + )} + /> + + + + + Active Years:* + + ( + option.toString()} + onChange={(_, data) => field.onChange(data)} + size="small" + renderInput={(params) => ( + + )} + isOptionEqualToValue={(option, value) => option === value} + disableCloseOnSelect + /> + )} + /> + {errors.activeYears?.message} + + + + + ( + onChange(e.target.checked)} + sx={{ p: 0 }} + /> + )} + /> + + Tax Exempt + + + + + + + Discount Code: + + + + + + + Notes: + + + + + + ); +}; + +export default AcceptProspectiveSponsorModal; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/CreateProspectiveSponsorPage.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/CreateProspectiveSponsorPage.tsx new file mode 100644 index 0000000000..06f5502d60 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/CreateProspectiveSponsorPage.tsx @@ -0,0 +1,103 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Box } from '@mui/system'; +import { useState } from 'react'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useCreateProspectiveSponsor } from '../../../hooks/finance.hooks'; +import SidePage from './SidePagePopup'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import { + ProspectiveSponsorForm, + ProspectiveSponsorFormInputs, + prospectiveSponsorSchema +} from './ProspectiveSponsorForm'; +import { FirstContactMethod } from 'shared'; + +interface CreateProspectiveSponsorPageProps { + showPage: boolean; + handleClose: () => void; +} + +const CreateProspectiveSponsorPage = ({ showPage, handleClose }: CreateProspectiveSponsorPageProps) => { + const { isLoading, mutateAsync } = useCreateProspectiveSponsor(); + + const { + handleSubmit, + control, + formState: { errors } + } = useForm({ + resolver: yupResolver(prospectiveSponsorSchema), + defaultValues: { + organizationName: '', + lastContactDate: new Date(), + firstContactMethod: '' as FirstContactMethod, + contactName: '', + contactorUserId: '', + highlightThresholdDays: 10, + contactEmail: '', + contactPhone: '', + contactPosition: '', + tasks: [] + } + }); + + const [submitError, setSubmitError] = useState(null); + + if (isLoading) return ; + + const onFormSubmit = async (formData: ProspectiveSponsorFormInputs) => { + try { + setSubmitError(null); + await mutateAsync({ + organizationName: formData.organizationName, + lastContactDate: formData.lastContactDate, + firstContactMethod: formData.firstContactMethod, + contactName: formData.contactName, + contactorUserId: formData.contactorUserId, + highlightThresholdDays: formData.highlightThresholdDays, + contactEmail: formData.contactEmail || undefined, + contactPhone: formData.contactPhone || undefined, + contactPosition: formData.contactPosition || undefined + }); + handleClose(); + } catch (err: unknown) { + if (err instanceof Error) { + setSubmitError(err.message); + } + } + }; + + return ( + + + {submitError && ( + + {submitError} + + )} + + + CLOSE + + + Submit + + + + } + /> + ); +}; + +export default CreateProspectiveSponsorPage; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/CreateSponsorPage.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/CreateSponsorPage.tsx index 8c958e8ae8..973fab110c 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/CreateSponsorPage.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/CreateSponsorPage.tsx @@ -30,7 +30,10 @@ const CreateSponsorPage = ({ showPage, handleClose }: CreateSponsorPageProps) => joinDate: undefined, activeYears: [], sponsorTierId: '', - sponsorContact: '', + contactName: '', + contactEmail: '', + contactPhone: '', + contactPosition: '', taxExempt: false, discountCode: '', sponsorNotes: '', diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteProspectiveSponsorModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteProspectiveSponsorModal.tsx new file mode 100644 index 0000000000..870bd165e5 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteProspectiveSponsorModal.tsx @@ -0,0 +1,48 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useDeleteProspectiveSponsor } from '../../../hooks/finance.hooks'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { ProspectiveSponsor } from 'shared'; +import NERModal from '../../../components/NERModal'; +import { Typography } from '@mui/material'; + +interface DeleteProspectiveSponsorModalProps { + handleClose: () => void; + prospectiveSponsor: ProspectiveSponsor; + showModal: boolean; +} + +const DeleteProspectiveSponsorModal = ({ + handleClose, + prospectiveSponsor, + showModal +}: DeleteProspectiveSponsorModalProps) => { + const { isLoading, isError, error, mutateAsync } = useDeleteProspectiveSponsor(); + + if (isError) return ; + if (isLoading) return ; + + return ( + { + mutateAsync(prospectiveSponsor.prospectiveSponsorId); + handleClose(); + }} + > + + Are you sure you want to delete the prospective sponsor {prospectiveSponsor.organizationName}? + + This action cannot be undone! + + ); +}; + +export default DeleteProspectiveSponsorModal; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/EditProspectiveSponsorPage.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/EditProspectiveSponsorPage.tsx new file mode 100644 index 0000000000..35f4aba68e --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/EditProspectiveSponsorPage.tsx @@ -0,0 +1,124 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Box } from '@mui/system'; +import { useState } from 'react'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useEditProspectiveSponsor } from '../../../hooks/finance.hooks'; +import SidePage from './SidePagePopup'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import { + ProspectiveSponsorForm, + ProspectiveSponsorFormInputs, + prospectiveSponsorSchema +} from './ProspectiveSponsorForm'; +import { ProspectiveSponsor } from 'shared'; + +interface EditProspectiveSponsorPageProps { + showPage: boolean; + handleClose: () => void; + prospectiveSponsor: ProspectiveSponsor; +} + +const EditProspectiveSponsorPage = ({ + showPage, + handleClose, + prospectiveSponsor +}: EditProspectiveSponsorPageProps) => { + const { isLoading, mutateAsync } = useEditProspectiveSponsor(); + + const defaultTasks = prospectiveSponsor.tasks?.map((task) => ({ + sponsorTaskId: task.sponsorTaskId, + dueDate: new Date(task.dueDate), + notifyDate: task.notifyDate ? new Date(task.notifyDate) : undefined, + assigneeUserId: task.assignee?.userId, + notes: task.notes + })) ?? []; + + const { + handleSubmit, + control, + formState: { errors } + } = useForm({ + resolver: yupResolver(prospectiveSponsorSchema), + defaultValues: { + organizationName: prospectiveSponsor.organizationName, + lastContactDate: prospectiveSponsor.lastContactDate, + firstContactMethod: prospectiveSponsor.firstContactMethod, + contactName: prospectiveSponsor.contact.name, + contactorUserId: prospectiveSponsor.contactor.userId, + highlightThresholdDays: prospectiveSponsor.highlightThresholdDays, + contactEmail: prospectiveSponsor.contact.email ?? '', + contactPhone: prospectiveSponsor.contact.phone ?? '', + contactPosition: prospectiveSponsor.contact.position ?? '', + status: prospectiveSponsor.status, + tasks: defaultTasks + } + }); + + const [submitError, setSubmitError] = useState(null); + + if (isLoading) return ; + + const onFormSubmit = async (formData: ProspectiveSponsorFormInputs) => { + try { + setSubmitError(null); + await mutateAsync({ + prospectiveSponsorId: prospectiveSponsor.prospectiveSponsorId, + organizationName: formData.organizationName, + lastContactDate: formData.lastContactDate, + status: formData.status!, + firstContactMethod: formData.firstContactMethod, + contactName: formData.contactName, + contactorUserId: formData.contactorUserId, + highlightThresholdDays: formData.highlightThresholdDays, + contactEmail: formData.contactEmail || undefined, + contactPhone: formData.contactPhone || undefined, + contactPosition: formData.contactPosition || undefined + }); + handleClose(); + } catch (err: unknown) { + if (err instanceof Error) { + setSubmitError(err.message); + } + } + }; + + return ( + + + {submitError && ( + + {submitError} + + )} + + + CLOSE + + + SUBMIT + + + + } + /> + ); +}; + +export default EditProspectiveSponsorPage; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/EditSponsorPage.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/EditSponsorPage.tsx index a9f2a63eeb..195ea30079 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/EditSponsorPage.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/EditSponsorPage.tsx @@ -42,7 +42,10 @@ const EditSponsorPage = ({ showPage, handleClose, sponsor }: EditSponsorPageProp joinDate: sponsor.joinDate, activeYears: sponsor.activeYears, sponsorTierId: sponsor.tier.sponsorTierId, - sponsorContact: sponsor.sponsorContact, + contactName: sponsor.contact.name, + contactEmail: sponsor.contact.email ?? undefined, + contactPhone: sponsor.contact.phone ?? undefined, + contactPosition: sponsor.contact.position ?? undefined, taxExempt: sponsor.taxExempt, discountCode: sponsor.discountCode ?? undefined, sponsorNotes: sponsor.sponsorNotes ?? undefined, diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorForm.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorForm.tsx new file mode 100644 index 0000000000..bc9aa08d11 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorForm.tsx @@ -0,0 +1,457 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import * as yup from 'yup'; +import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form'; +import { + FormControl, + Grid, + FormHelperText, + IconButton, + MenuItem, + Select, + Typography, + TextField, + Box, + Tooltip +} from '@mui/material'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import { useTheme } from '@mui/system'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { DatePicker } from '@mui/x-date-pickers'; +import { useAllMembers } from '../../../hooks/users.hooks'; +import React, { useState } from 'react'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import NERAutocomplete from '../../../components/NERAutocomplete'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { FirstContactMethod, ProspectiveSponsor, ProspectiveSponsorStatus } from 'shared'; + +export interface ProspectiveSponsorFormInputs { + organizationName: string; + lastContactDate: Date; + firstContactMethod: FirstContactMethod; + contactName: string; + contactorUserId: string; + highlightThresholdDays?: number; + contactEmail?: string; + contactPhone?: string; + contactPosition?: string; + status?: ProspectiveSponsorStatus; + tasks: { + sponsorTaskId?: string; + dueDate: Date; + notifyDate?: Date; + assigneeUserId?: string; + notes: string; + }[]; +} + +interface ProspectiveSponsorFormProps { + control: Control; + errors: FieldErrors; + defaultValues?: ProspectiveSponsor; + isEditMode?: boolean; +} + +const firstContactMethodDisplayNames: Record = { + [FirstContactMethod.INBOUND_FORM]: 'Inbound Form', + [FirstContactMethod.INBOUND_EMAIL]: 'Inbound Email', + [FirstContactMethod.OUTBOUND_EMAIL]: 'Outbound Email', + [FirstContactMethod.OTHER]: 'Other' +}; + +const statusDisplayNames: Record = { + [ProspectiveSponsorStatus.IN_PROGRESS]: 'In Progress', + [ProspectiveSponsorStatus.DECLINED]: 'Declined', + [ProspectiveSponsorStatus.NOT_IN_CONTACT]: 'Not In Contact', + [ProspectiveSponsorStatus.NO_RESPONSE]: 'No Response', + [ProspectiveSponsorStatus.ACCEPTED]: 'Accepted' +}; + +export const prospectiveSponsorSchema = yup.object().shape({ + organizationName: yup.string().required('Organization name is required'), + lastContactDate: yup.date().required('Last contact date is required'), + firstContactMethod: yup + .string() + .oneOf(Object.values(FirstContactMethod), 'Please select a contact method') + .required('Please select how first contact was made'), + contactName: yup.string().required('Contact name is required'), + contactorUserId: yup.string().required('Contactor is required'), + highlightThresholdDays: yup.number().positive('Must be positive').optional(), + contactEmail: yup.string().email('Invalid email').optional(), + contactPhone: yup.string().optional(), + contactPosition: yup.string().optional(), + status: yup.string().oneOf(Object.values(ProspectiveSponsorStatus)).optional(), + tasks: yup + .array() + .of( + yup.object().shape({ + sponsorTaskId: yup.string().optional(), + dueDate: yup.date().required('Due date is required'), + notifyDate: yup.date().optional(), + assigneeUserId: yup.string().optional(), + notes: yup.string().required('Notes are required') + }) + ) + .required() +}); + +export const ProspectiveSponsorForm: React.FC = ({ + control, + errors, + defaultValues, + isEditMode = false +}: ProspectiveSponsorFormProps) => { + const theme = useTheme(); + + const [datePickerOpenLastContact, setDatePickerOpenLastContact] = useState(false); + const [datePickerOpenDue, setDatePickerOpenDue] = useState(false); + const [datePickerOpenNotify, setDatePickerOpenNotify] = useState(false); + + const { isLoading: membersLoading, isError: membersIsError, error: membersError, data: members } = useAllMembers(); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'tasks' + }); + + if (membersIsError) return ; + if (membersLoading || !members) return ; + + return ( + + + + + Organization Name:* + + + {errors.organizationName?.message} + + + + + + + Contactor:* + + ( + m.userId === value) ? { label: members.find((m) => m.userId === value)!.firstName + ' ' + members.find((m) => m.userId === value)!.lastName, id: value } : null} + onChange={(_event, newValue) => onChange(newValue ? newValue.id : '')} + options={members.map((m) => ({ label: m.firstName + ' ' + m.lastName, id: m.userId }))} + size="small" + placeholder="Select Contactor" + /> + )} + /> + {errors.contactorUserId?.message} + + + + + + + Last Contact Date:* + + ( + setDatePickerOpenLastContact(false)} + onOpen={() => setDatePickerOpenLastContact(true)} + onChange={(newValue) => onChange(newValue ?? new Date())} + slotProps={{ + textField: { + error: !!errors.lastContactDate, + helperText: errors.lastContactDate?.message, + onClick: () => setDatePickerOpenLastContact(true) + } + }} + /> + )} + /> + + + + + + + + First Contact Method:* + + + + + + ( + + )} + /> + {errors.firstContactMethod?.message} + + + + {isEditMode && ( + + + + Status:* + + ( + + )} + /> + {errors.status?.message} + + + )} + + + + + + Highlight Threshold (Days): + + + + + + + {errors.highlightThresholdDays?.message} + + + + + + Contact Information + + + + + + + Contact Name:* + + + {errors.contactName?.message} + + + + + + + Contact Email: + + + {errors.contactEmail?.message} + + + + + + + Contact Phone: + + + {errors.contactPhone?.message} + + + + + + + Contact Position: + + + {errors.contactPosition?.message} + + + + + + + Tasks: + + {fields.map((item, index) => ( + + + + + Due Date:* + + ( + setDatePickerOpenDue(false)} + onOpen={() => setDatePickerOpenDue(true)} + onChange={(newValue) => onChange(newValue ?? new Date())} + slotProps={{ + textField: { + error: !!errors.tasks?.[index]?.dueDate, + helperText: errors.tasks?.[index]?.dueDate?.message, + onClick: () => setDatePickerOpenDue(true) + } + }} + /> + )} + /> + + + + + + Notify Date: + + ( + setDatePickerOpenNotify(false)} + onOpen={() => setDatePickerOpenNotify(true)} + onChange={(newValue) => onChange(newValue ?? undefined)} + slotProps={{ + textField: { + error: !!errors.tasks?.[index]?.notifyDate, + helperText: errors.tasks?.[index]?.notifyDate?.message, + onClick: () => setDatePickerOpenNotify(true) + } + }} + /> + )} + /> + + + + + + Assign To: + + ( + onChange(newValue ? newValue.id : undefined)} + options={members.map((m) => ({ label: m.firstName + ' ' + m.lastName, id: m.userId }))} + size="small" + placeholder={ + defaultValues?.tasks?.[index]?.assignee + ? defaultValues.tasks[index].assignee!.firstName + + ' ' + + defaultValues.tasks[index].assignee!.lastName + : 'Select Member' + } + /> + )} + /> + + + + + + + Notes:* + + + {errors.tasks?.[index]?.notes?.message} + + + remove(index)}> + + + + + + + ))} + + + append({ + dueDate: new Date(), + notifyDate: undefined, + assigneeUserId: undefined, + notes: '' + }) + } + > + + Add Task + + + + + + ); +}; + +export default prospectiveSponsorSchema; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx new file mode 100644 index 0000000000..49dd3dca06 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx @@ -0,0 +1,295 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React, { useEffect, useState } from 'react'; +import { Box, Typography, TextField, IconButton, Button, Autocomplete, Checkbox } from '@mui/material'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { AddCircle, RemoveCircle } from '@mui/icons-material'; +import { + useCreateProspectiveSponsorTask, + useEditSponsorTask, + useProspectiveSponsorTasks, + useToggleSponsorTaskDone +} from '../../../hooks/finance.hooks'; +import { ProspectiveSponsor, SponsorTask } from 'shared'; +import { useAllMembers } from '../../../hooks/users.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import DeleteSponsorTaskModal from './DeleteSponsorTaskModal'; +import { useToast } from '../../../hooks/toasts.hooks'; +import * as yup from 'yup'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; + +interface ProspectiveSponsorTasksModalProps { + onClose: () => void; + prospectiveSponsor: ProspectiveSponsor; +} + +const ProspectiveSponsorTasksModal: React.FC = ({ + onClose, + prospectiveSponsor +}) => { + const toast = useToast(); + const { data: users, isLoading: usersIsLoading, isError: usersIsError, error: usersError } = useAllMembers(); + const { data: tasks } = useProspectiveSponsorTasks(prospectiveSponsor.prospectiveSponsorId); + const { mutate: createTask } = useCreateProspectiveSponsorTask(prospectiveSponsor.prospectiveSponsorId); + const { mutate: editTask } = useEditSponsorTask(); + const { mutate: toggleDone } = useToggleSponsorTaskDone(); + + const [taskToDelete, setTaskToDelete] = useState(undefined); + + const taskSchema = yup.object().shape({ + sponsorTaskId: yup.string().optional(), + dueDate: yup.date().required('Due date is required'), + notifyDate: yup.date().nullable().optional(), + assignee: yup.string().nullable().optional(), + notes: yup.string().required('Notes is required'), + done: yup.boolean().optional() + }); + + const schema = yup.object().shape({ + tasks: yup.array().of(taskSchema) + }); + + const { + control, + handleSubmit, + reset, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { tasks: [] } + }); + + const { fields, append } = useFieldArray({ control, name: 'tasks' }); + + useEffect(() => { + if (tasks) { + reset({ + tasks: tasks.map((task) => ({ + sponsorTaskId: task.sponsorTaskId, + dueDate: task.dueDate ? new Date(task.dueDate) : new Date(), + notifyDate: task.notifyDate ? new Date(task.notifyDate) : undefined, + assignee: task.assignee?.userId || '', + notes: task.notes || '', + done: task.done || false + })) + }); + } + }, [tasks, reset]); + + const handleSave = handleSubmit(({ tasks }) => { + tasks?.forEach((task) => { + const payload = { + dueDate: task.dueDate, + notifyDate: task.notifyDate || undefined, + assigneeUserId: task.assignee || undefined, + notes: task.notes + }; + if (task.sponsorTaskId) { + try { + editTask({ sponsorTaskId: task.sponsorTaskId, sponsorTaskData: payload }); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + } else { + try { + createTask(payload); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + } + }); + onClose(); + }); + + if (!users || usersIsLoading) return ; + if (usersIsError) return ; + + return ( + + + {['Done', 'Due Date', 'Notify Date', 'Assign to', 'Notes'].map((label) => ( + + {label} + + ))} + + + + {fields.map((item, idx) => ( + + + ( + { + if (item.sponsorTaskId) { + toggleDone(item.sponsorTaskId); + field.onChange(!field.value); + } + }} + /> + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + `${option.firstName} ${option.lastName}`} + isOptionEqualToValue={(option, value) => option.userId === value.userId} + value={users.find((u) => u.userId === field.value) || undefined} + onChange={(_, newValue) => field.onChange(newValue?.userId || '')} + sx={{ width: '100%' }} + renderInput={(params) => ( + + )} + /> + )} + /> + + + ( + + )} + /> + + + {fields[idx].sponsorTaskId && ( + { + const fieldTask = fields[idx]; + const taskToDelete: SponsorTask = { + ...fieldTask, + sponsorTaskId: fieldTask.sponsorTaskId || '', + assignee: fieldTask.assignee ? users.find((u) => u.userId === fieldTask.assignee) : undefined, + notifyDate: fieldTask.notifyDate ?? undefined, + done: fieldTask.done ?? false + }; + setTaskToDelete(taskToDelete); + }} + > + + + )} + + + ))} + + + + + + {taskToDelete && ( + setTaskToDelete(undefined)} sponsorTask={taskToDelete} /> + )} + + ); +}; + +export default ProspectiveSponsorTasksModal; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx index f0c749854d..825b532a4f 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx @@ -51,7 +51,10 @@ const sponsorSchema = yup.object().shape({ .of(yup.number().typeError('Active year must be a number').required('Active year is required')) .required('Active years are required'), sponsorTierId: yup.string().required('Sponsor tier is required'), - sponsorContact: yup.string().required('Sponsor contact is required'), + contactName: yup.string().required('Contact name is required'), + contactEmail: yup.string().email('Invalid email').optional(), + contactPhone: yup.string().optional(), + contactPosition: yup.string().optional(), taxExempt: yup.boolean().required('Tax exempt is required'), discountCode: yup.string().trim().optional(), sponsorNotes: yup.string().trim().optional(), @@ -252,15 +255,57 @@ export const SponsorForm: React.FC = ({ control, errors, defau - Sponsor Contact:* + Contact Name:* - {errors.sponsorContact?.message} + {errors.contactName?.message} + + + + + + Contact Email: + + + {errors.contactEmail?.message} + + + + + + Contact Phone: + + + {errors.contactPhone?.message} + + + + + + Contact Position: + + + {errors.contactPosition?.message} diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorTasksModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorTasksModal.tsx index 5705286e70..aa730ac9c0 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorTasksModal.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorTasksModal.tsx @@ -1,8 +1,13 @@ import React, { useEffect, useState } from 'react'; -import { Box, Typography, TextField, IconButton, Button, Autocomplete } from '@mui/material'; +import { Box, Typography, TextField, IconButton, Button, Autocomplete, Checkbox } from '@mui/material'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import { AddCircle, RemoveCircle } from '@mui/icons-material'; -import { useCreateSponsorTask, useEditSponsorTask, useGetSponsorTasks } from '../../../hooks/finance.hooks'; +import { + useCreateSponsorTask, + useEditSponsorTask, + useGetSponsorTasks, + useToggleSponsorTaskDone +} from '../../../hooks/finance.hooks'; import { Sponsor, SponsorTask } from 'shared'; import { useAllMembers } from '../../../hooks/users.hooks'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -24,6 +29,7 @@ const SponsorTasksModal: React.FC = ({ onClose, sponsor const { data: sponsorTasks } = useGetSponsorTasks(sponsor.sponsorId); const { mutate: createTask } = useCreateSponsorTask(sponsor.sponsorId); const { mutate: editTask } = useEditSponsorTask(); + const { mutate: toggleDone } = useToggleSponsorTaskDone(); const [sponsorTaskToDelete, setSponsorTaskToDelete] = useState(undefined); @@ -32,7 +38,8 @@ const SponsorTasksModal: React.FC = ({ onClose, sponsor dueDate: yup.date().required('Due date is required'), notifyDate: yup.date().nullable().optional(), assignee: yup.string().nullable().optional(), - notes: yup.string().required('Notes is required') + notes: yup.string().required('Notes is required'), + done: yup.boolean().optional() }); const schema = yup.object().shape({ @@ -59,7 +66,8 @@ const SponsorTasksModal: React.FC = ({ onClose, sponsor dueDate: task.dueDate ? new Date(task.dueDate) : new Date(), notifyDate: task.notifyDate ? new Date(task.notifyDate) : undefined, assignee: task.assignee?.userId || '', - notes: task.notes || '' + notes: task.notes || '', + done: task.done || false })) }); } @@ -100,11 +108,11 @@ const SponsorTasksModal: React.FC = ({ onClose, sponsor return ( - {['Due Date', 'Notify Date', 'Assign to', 'Notes'].map((label) => ( + {['Done', 'Due Date', 'Notify Date', 'Assign to', 'Notes'].map((label) => ( {label} @@ -124,6 +132,24 @@ const SponsorTasksModal: React.FC = ({ onClose, sponsor color: '#fff' }} > + + ( + { + if (item.sponsorTaskId) { + toggleDone(item.sponsorTaskId); + field.onChange(!field.value); + } + }} + /> + )} + /> + = ({ onClose, sponsor ...fieldTask, sponsorTaskId: fieldTask.sponsorTaskId || '', assignee: fieldTask.assignee ? users.find((u) => u.userId === fieldTask.assignee) : undefined, - notifyDate: fieldTask.notifyDate ?? undefined + notifyDate: fieldTask.notifyDate ?? undefined, + done: fieldTask.done ?? false }; setSponsorTaskToDelete(taskToDelete); }} @@ -234,7 +261,7 @@ const SponsorTasksModal: React.FC = ({ onClose, sponsor diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx index 49dd3dca06..350583ace2 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx @@ -3,25 +3,10 @@ * See the LICENSE file in the repository root folder for details. */ -import React, { useEffect, useState } from 'react'; -import { Box, Typography, TextField, IconButton, Button, Autocomplete, Checkbox } from '@mui/material'; -import { DatePicker } from '@mui/x-date-pickers/DatePicker'; -import { AddCircle, RemoveCircle } from '@mui/icons-material'; -import { - useCreateProspectiveSponsorTask, - useEditSponsorTask, - useProspectiveSponsorTasks, - useToggleSponsorTaskDone -} from '../../../hooks/finance.hooks'; -import { ProspectiveSponsor, SponsorTask } from 'shared'; -import { useAllMembers } from '../../../hooks/users.hooks'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; -import DeleteSponsorTaskModal from './DeleteSponsorTaskModal'; -import { useToast } from '../../../hooks/toasts.hooks'; -import * as yup from 'yup'; -import { Controller, useFieldArray, useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; +import React from 'react'; +import { ProspectiveSponsor } from 'shared'; +import { useCreateProspectiveSponsorTask, useProspectiveSponsorTasks } from '../../../hooks/finance.hooks'; +import SponsorTasksModal from './SponsorTasksModal'; interface ProspectiveSponsorTasksModalProps { onClose: () => void; @@ -32,264 +17,10 @@ const ProspectiveSponsorTasksModal: React.FC onClose, prospectiveSponsor }) => { - const toast = useToast(); - const { data: users, isLoading: usersIsLoading, isError: usersIsError, error: usersError } = useAllMembers(); const { data: tasks } = useProspectiveSponsorTasks(prospectiveSponsor.prospectiveSponsorId); const { mutate: createTask } = useCreateProspectiveSponsorTask(prospectiveSponsor.prospectiveSponsorId); - const { mutate: editTask } = useEditSponsorTask(); - const { mutate: toggleDone } = useToggleSponsorTaskDone(); - const [taskToDelete, setTaskToDelete] = useState(undefined); - - const taskSchema = yup.object().shape({ - sponsorTaskId: yup.string().optional(), - dueDate: yup.date().required('Due date is required'), - notifyDate: yup.date().nullable().optional(), - assignee: yup.string().nullable().optional(), - notes: yup.string().required('Notes is required'), - done: yup.boolean().optional() - }); - - const schema = yup.object().shape({ - tasks: yup.array().of(taskSchema) - }); - - const { - control, - handleSubmit, - reset, - formState: { errors } - } = useForm({ - resolver: yupResolver(schema), - defaultValues: { tasks: [] } - }); - - const { fields, append } = useFieldArray({ control, name: 'tasks' }); - - useEffect(() => { - if (tasks) { - reset({ - tasks: tasks.map((task) => ({ - sponsorTaskId: task.sponsorTaskId, - dueDate: task.dueDate ? new Date(task.dueDate) : new Date(), - notifyDate: task.notifyDate ? new Date(task.notifyDate) : undefined, - assignee: task.assignee?.userId || '', - notes: task.notes || '', - done: task.done || false - })) - }); - } - }, [tasks, reset]); - - const handleSave = handleSubmit(({ tasks }) => { - tasks?.forEach((task) => { - const payload = { - dueDate: task.dueDate, - notifyDate: task.notifyDate || undefined, - assigneeUserId: task.assignee || undefined, - notes: task.notes - }; - if (task.sponsorTaskId) { - try { - editTask({ sponsorTaskId: task.sponsorTaskId, sponsorTaskData: payload }); - } catch (error: unknown) { - if (error instanceof Error) { - toast.error(error.message); - } - } - } else { - try { - createTask(payload); - } catch (error: unknown) { - if (error instanceof Error) { - toast.error(error.message); - } - } - } - }); - onClose(); - }); - - if (!users || usersIsLoading) return ; - if (usersIsError) return ; - - return ( - - - {['Done', 'Due Date', 'Notify Date', 'Assign to', 'Notes'].map((label) => ( - - {label} - - ))} - - - - {fields.map((item, idx) => ( - - - ( - { - if (item.sponsorTaskId) { - toggleDone(item.sponsorTaskId); - field.onChange(!field.value); - } - }} - /> - )} - /> - - - ( - - )} - /> - - - ( - - )} - /> - - - ( - `${option.firstName} ${option.lastName}`} - isOptionEqualToValue={(option, value) => option.userId === value.userId} - value={users.find((u) => u.userId === field.value) || undefined} - onChange={(_, newValue) => field.onChange(newValue?.userId || '')} - sx={{ width: '100%' }} - renderInput={(params) => ( - - )} - /> - )} - /> - - - ( - - )} - /> - - - {fields[idx].sponsorTaskId && ( - { - const fieldTask = fields[idx]; - const taskToDelete: SponsorTask = { - ...fieldTask, - sponsorTaskId: fieldTask.sponsorTaskId || '', - assignee: fieldTask.assignee ? users.find((u) => u.userId === fieldTask.assignee) : undefined, - notifyDate: fieldTask.notifyDate ?? undefined, - done: fieldTask.done ?? false - }; - setTaskToDelete(taskToDelete); - }} - > - - - )} - - - ))} - - - - - - {taskToDelete && ( - setTaskToDelete(undefined)} sponsorTask={taskToDelete} /> - )} - - ); + return ; }; export default ProspectiveSponsorTasksModal; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx index 825b532a4f..fe5aace9a0 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx @@ -2,33 +2,34 @@ import * as yup from 'yup'; import { SponsorPayload, useGetAllSponsorTiers } from '../../../hooks/finance.hooks'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; -import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form'; +import { Control, Controller, FieldErrors, FieldValues, UseFormSetValue, useFieldArray, useWatch } from 'react-hook-form'; import { FormControl, Grid, FormHelperText, - IconButton, + Button, MenuItem, Select, Typography, Checkbox, Autocomplete, - TextField + TextField, + Chip } from '@mui/material'; import ReactHookTextField from '../../../components/ReactHookTextField'; import { DatePicker } from '@mui/x-date-pickers'; import { useAllMembers } from '../../../hooks/users.hooks'; -import React, { useState } from 'react'; -import { Box, useTheme } from '@mui/system'; -import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; -import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import React, { useEffect, useRef, useState } from 'react'; +import { Box } from '@mui/system'; +import { AddCircle } from '@mui/icons-material'; import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; -import NERAutocomplete from '../../../components/NERAutocomplete'; -import { Sponsor } from 'shared'; +import SponsorTaskCard from './SponsorTaskCard'; +import { Sponsor, SponsorValueType } from 'shared'; interface SponsorFormProps { control: Control; errors: FieldErrors; + setValue: UseFormSetValue; defaultValues?: Sponsor; } @@ -41,43 +42,76 @@ const getYears = (startYear = 1950) => { return years; }; -const sponsorSchema = yup.object().shape({ - name: yup.string().required('Name is required'), - activeStatus: yup.boolean().required('Sponsor status is required'), - sponsorValue: yup.number().typeError('Sponsor value must be a number').required('Sponsor value is required'), - joinDate: yup.date().required('Join date is required'), - activeYears: yup - .array() - .of(yup.number().typeError('Active year must be a number').required('Active year is required')) - .required('Active years are required'), - sponsorTierId: yup.string().required('Sponsor tier is required'), - contactName: yup.string().required('Contact name is required'), - contactEmail: yup.string().email('Invalid email').optional(), - contactPhone: yup.string().optional(), - contactPosition: yup.string().optional(), - taxExempt: yup.boolean().required('Tax exempt is required'), - discountCode: yup.string().trim().optional(), - sponsorNotes: yup.string().trim().optional(), - sponsorTasks: yup - .array() - .of( - yup.object().shape({ - dueDate: yup.date().required('Due date is required'), - notifyDate: yup.date(), - assigneeUserId: yup.string(), - notes: yup.string().required('Notes are required') - }) - ) - .required('Sponsor Tasks are Required') -}); +const VALUE_TYPE_OPTIONS = [ + { value: SponsorValueType.MONETARY, label: 'Monetary' }, + { value: SponsorValueType.STOCK, label: 'Stock/Parts' }, + { value: SponsorValueType.DISCOUNT, label: 'Discount' } +]; -export const SponsorForm: React.FC = ({ control, errors, defaultValues }: SponsorFormProps) => { - const theme = useTheme(); +const sponsorSchema = yup.object().shape( + { + name: yup.string().required('Name is required'), + activeStatus: yup.boolean().required('Sponsor status is required'), + valueTypes: yup + .array() + .of(yup.string().required()) + .min(1, 'At least one value type is required') + .required('Value types are required'), + sponsorValue: yup + .number() + .typeError('Sponsor value must be a number') + .when('valueTypes', { + is: (types: string[]) => types?.includes(SponsorValueType.MONETARY), + then: (schema) => schema.required('Sponsor value is required for monetary sponsors'), + otherwise: (schema) => schema.optional().nullable() + }), + stockDescription: yup.string().trim().optional(), + discountDescription: yup.string().trim().optional(), + joinDate: yup.date().required('Join date is required'), + activeYears: yup + .array() + .of(yup.number().typeError('Active year must be a number').required('Active year is required')) + .required('Active years are required'), + sponsorTierId: yup.string().optional(), + contactName: yup.string().required('Contact name is required'), + contactEmail: yup + .string() + .email('Invalid email') + .when('contactPhone', { + is: (phone: string | undefined) => !phone, + then: (schema) => schema.required('Email or phone is required'), + otherwise: (schema) => schema.optional() + }), + contactPhone: yup + .string() + .when('contactEmail', { + is: (email: string | undefined) => !email, + then: (schema) => schema.required('Email or phone is required'), + otherwise: (schema) => schema.optional() + }), + contactPosition: yup.string().optional(), + taxExempt: yup.boolean().required('Tax exempt is required'), + discountCode: yup.string().trim().optional(), + sponsorNotes: yup.string().trim().optional(), + sponsorTasks: yup + .array() + .of( + yup.object().shape({ + dueDate: yup.date().required('Due date is required'), + notifyDate: yup.date(), + assigneeUserId: yup.string(), + notes: yup.string().required('Notes are required') + }) + ) + .required('Sponsor Tasks are Required') + }, + [['contactEmail', 'contactPhone']] +); + +export const SponsorForm: React.FC = ({ control, errors, setValue, defaultValues }: SponsorFormProps) => { const yearsOptions = getYears(); - const [datePickerOpenNotify, setDatePickerOpenNotify] = useState(false); const [datePickerOpenJoin, setDatePickerOpenJoin] = useState(false); - const [datePickerOpenDue, setDatePickerOpenDue] = useState(false); const { isLoading: membersLoading, isError: membersIsError, error: membersError, data: members } = useAllMembers(); @@ -88,6 +122,24 @@ export const SponsorForm: React.FC = ({ control, errors, defau data: allSponsorTiers } = useGetAllSponsorTiers(); + const watchedValueTypes: string[] = useWatch({ control, name: 'valueTypes' }) ?? []; + const isMonetary = watchedValueTypes.includes(SponsorValueType.MONETARY); + const isStock = watchedValueTypes.includes(SponsorValueType.STOCK); + const isDiscount = watchedValueTypes.includes(SponsorValueType.DISCOUNT); + + const watchedSponsorValue: number | undefined = useWatch({ control, name: 'sponsorValue' }); + const tierManuallySet = useRef(!!defaultValues); + + useEffect(() => { + if (tierManuallySet.current || !allSponsorTiers || allSponsorTiers.length === 0) return; + const value = watchedSponsorValue ?? 0; + const sorted = [...allSponsorTiers].sort((a, b) => b.minSupportValue - a.minSupportValue); + const bestTier = sorted.find((t) => value >= t.minSupportValue); + if (bestTier) { + setValue('sponsorTierId', bestTier.sponsorTierId); + } + }, [watchedSponsorValue, allSponsorTiers, setValue]); + const { fields, append, remove } = useFieldArray({ control, name: 'sponsorTasks' @@ -138,10 +190,42 @@ export const SponsorForm: React.FC = ({ control, errors, defau + - Sponsor Value:* + Value Types:* + + ( + option.label} + value={VALUE_TYPE_OPTIONS.filter((opt) => value?.includes(opt.value))} + onChange={(_, data) => onChange(data.map((d) => d.value))} + renderInput={(params) => ( + + )} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + isOptionEqualToValue={(option, val) => option.value === val.value} + disableCloseOnSelect + /> + )} + /> + {errors.valueTypes?.message} + + + + + + Sponsor Value:{isMonetary ? '*' : ''} = ({ control, errors, defau /> + {isStock && ( + + + + Stock/Parts Description: + + + + + )} + {isDiscount && ( + + + + Discount Description: + + + + + )} @@ -214,7 +330,7 @@ export const SponsorForm: React.FC = ({ control, errors, defau - Sponsor Tier:* + Sponsor Tier: = ({ control, errors, defau