From a6b2f3b6207228d24cb0483c70d9229fa14dfe4a Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 1 Oct 2026 17:18:46 -0400 Subject: [PATCH 01/13] holder for benchmarking logs --- src/backend/src/controllers/projects.controllers.ts | 5 +++++ src/backend/src/prisma/seed-config.json | 2 +- src/backend/src/services/projects.services.ts | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index 6eaff761c3..41d56285ae 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -16,8 +16,13 @@ import BillOfMaterialsService from '../services/boms.services.js'; export default class ProjectsController { static async getAllProjectsGantt(req: Request, res: Response, next: NextFunction) { try { + const startTime = Date.now(); const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt(req.organization, req.currentCar?.carId); + console.log(`Service call (query + transform) took: ${Date.now() - startTime}ms`); + + const beforeSend = Date.now(); res.status(200).json(projects); + console.log(`res.json (serialize + ETag check) took: ${Date.now() - beforeSend}ms`); } catch (error: unknown) { next(error); } diff --git a/src/backend/src/prisma/seed-config.json b/src/backend/src/prisma/seed-config.json index 3ad0e33952..8b85b97087 100644 --- a/src/backend/src/prisma/seed-config.json +++ b/src/backend/src/prisma/seed-config.json @@ -19,7 +19,7 @@ ] }, "project": { - "projectsPerCar": 5 + "projectsPerCar": 50 }, "user": { "totalUsers": 350 diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index eb1c9d2726..02afd3e385 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -56,6 +56,8 @@ export default class ProjectsService { static async getAllProjectsGantt(organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, ...(carId && { carId }) }, + // load relations with one query per level instead of a single deeply nested join query + relationLoadStrategy: 'query', ...getProjectGanttQueryArgs(organization.organizationId) }); From fbd710954f6c31fea7e9bdbbdd362d3330de3a0f Mon Sep 17 00:00:00 2001 From: getheobald Date: Sun, 4 Oct 2026 23:51:55 -0400 Subject: [PATCH 02/13] query changes weee --- .../src/controllers/projects.controllers.ts | 5 -- .../src/prisma-query-args/event.query-args.ts | 15 ++++ .../prisma-query-args/projects.query-args.ts | 28 +++++-- .../src/prisma-query-args/tasks.query-args.ts | 25 +++++- .../work-packages.query-args.ts | 76 +++++++++++++++++-- .../src/prisma/factories/event.factory.ts | 10 ++- .../migration.sql | 2 + src/backend/src/prisma/schema.prisma | 1 + src/backend/src/prisma/seed-config.json | 9 ++- src/backend/src/prisma/seed-config.ts | 4 + src/backend/src/prisma/seed/event.process.ts | 30 ++++++-- src/backend/src/services/projects.services.ts | 2 +- .../src/transformers/calendar.transformer.ts | 7 +- .../description-bullets.transformer.ts | 9 ++- .../src/transformers/tasks.transformer.ts | 5 +- .../transformers/work-packages.transformer.ts | 9 ++- 16 files changed, 199 insertions(+), 38 deletions(-) create mode 100644 src/backend/src/prisma/migrations/20261005023559_schedule_slot_event_id_index/migration.sql diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index 41d56285ae..6eaff761c3 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -16,13 +16,8 @@ import BillOfMaterialsService from '../services/boms.services.js'; export default class ProjectsController { static async getAllProjectsGantt(req: Request, res: Response, next: NextFunction) { try { - const startTime = Date.now(); const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt(req.organization, req.currentCar?.carId); - console.log(`Service call (query + transform) took: ${Date.now() - startTime}ms`); - - const beforeSend = Date.now(); res.status(200).json(projects); - console.log(`res.json (serialize + ETag check) took: ${Date.now() - beforeSend}ms`); } catch (error: unknown) { next(error); } diff --git a/src/backend/src/prisma-query-args/event.query-args.ts b/src/backend/src/prisma-query-args/event.query-args.ts index 2384f45740..36662ba628 100644 --- a/src/backend/src/prisma-query-args/event.query-args.ts +++ b/src/backend/src/prisma-query-args/event.query-args.ts @@ -5,6 +5,21 @@ export type EventQueryArgs = ReturnType; export type EventWithMembersQueryArgs = ReturnType; +export type EventPreviewQueryArgs = ReturnType; + +// only the fields eventPreviewTransformer reads +export const getEventPreviewQueryArgs = (organizationId: string) => + Prisma.validator()({ + select: { + eventId: true, + title: true, + status: true, + initialDateScheduled: true, + scheduledTimes: true, + userCreated: getUserQueryArgs(organizationId) + } + }); + export const getEventQueryArgs = (organizationId: string) => Prisma.validator()({ include: { diff --git a/src/backend/src/prisma-query-args/projects.query-args.ts b/src/backend/src/prisma-query-args/projects.query-args.ts index dfb71643fa..8cf507494d 100644 --- a/src/backend/src/prisma-query-args/projects.query-args.ts +++ b/src/backend/src/prisma-query-args/projects.query-args.ts @@ -2,9 +2,13 @@ import { Prisma, Task_Status } from '@prisma/client'; import { getUserQueryArgs } from './user.query-args.js'; import { getDescriptionBulletQueryArgs } from './description-bullets.query-args.js'; import { getTeamPreviewQueryArgs } from './teams.query-args.js'; -import { getTaskQueryArgs } from './tasks.query-args.js'; +import { getTaskGanttQueryArgs, getTaskQueryArgs } from './tasks.query-args.js'; import { getLinkQueryArgs } from './links.query-args.js'; -import { getWorkPackageQueryArgs, getWorkPackagePreviewQueryArgs } from './work-packages.query-args.js'; +import { + getWorkPackageQueryArgs, + getWorkPackageGanttQueryArgs, + getWorkPackagePreviewQueryArgs +} from './work-packages.query-args.js'; export type ProjectQueryArgs = ReturnType; @@ -46,7 +50,12 @@ export const getProjectQueryArgs = (organizationId: string) => export const getProjectGanttQueryArgs = (organizationId: string) => Prisma.validator()({ - include: { + // select rather than include so unused project columns (summary, carId, ...) aren't fetched + select: { + projectId: true, + wbsElementId: true, + budget: true, + abbreviation: true, wbsElement: { include: { lead: getUserQueryArgs(organizationId), @@ -55,7 +64,8 @@ export const getProjectGanttQueryArgs = (organizationId: string) => where: { dateDeleted: null }, - ...getTaskQueryArgs() + orderBy: [{ dateCreated: 'asc' }, { taskId: 'asc' }], + ...getTaskGanttQueryArgs() } } }, @@ -63,7 +73,8 @@ export const getProjectGanttQueryArgs = (organizationId: string) => select: { teamId: true, teamName: true - } + }, + orderBy: [{ teamName: 'asc' }, { teamId: 'asc' }] }, workPackages: { where: { @@ -71,9 +82,10 @@ export const getProjectGanttQueryArgs = (organizationId: string) => dateDeleted: null } }, - ...getWorkPackageQueryArgs(organizationId) - }, - favoritedBy: getUserQueryArgs(organizationId) + // orderInProject can repeat after a work package is deleted, so tie-break on the (unique per project) wp number + orderBy: [{ orderInProject: 'asc' }, { wbsElement: { workPackageNumber: 'asc' } }], + ...getWorkPackageGanttQueryArgs(organizationId) + } } }); diff --git a/src/backend/src/prisma-query-args/tasks.query-args.ts b/src/backend/src/prisma-query-args/tasks.query-args.ts index 473099e573..3b39ac76df 100644 --- a/src/backend/src/prisma-query-args/tasks.query-args.ts +++ b/src/backend/src/prisma-query-args/tasks.query-args.ts @@ -2,6 +2,7 @@ import { Prisma } from '@prisma/client'; import { getUserPreviewWithEmailQueryArgs, getUserQueryArgs } from './user.query-args.js'; export type TaskQueryArgs = ReturnType; +export type TaskGanttQueryArgs = ReturnType; export type TaskPreviewQueryArgs = ReturnType; export type CalendarTaskQueryArgs = ReturnType; export type TaskLabelQueryArgs = ReturnType; @@ -54,12 +55,32 @@ export const getTaskQueryArgs = () => wbsElement: getBlockingWorkPackagesArgs(), createdBy: getUserPreviewWithEmailQueryArgs(), deletedBy: getUserPreviewWithEmailQueryArgs(), - assignees: getUserPreviewWithEmailQueryArgs(), - labels: getTaskLabelQueryArgs(), + assignees: { + ...getUserPreviewWithEmailQueryArgs(), + orderBy: [{ firstName: 'asc' }, { lastName: 'asc' }, { userId: 'asc' }] + }, + labels: { ...getTaskLabelQueryArgs(), orderBy: [{ name: 'asc' }, { taskLabelId: 'asc' }] }, blockedBy: getTaskBlockedByQueryArgs() } }); +// same output as getTaskQueryArgs through taskTransformer, but only fetches the wbs element fields it reads +export const getTaskGanttQueryArgs = () => + Prisma.validator()({ + include: { + ...getTaskQueryArgs().include, + wbsElement: { + select: { + carNumber: true, + projectNumber: true, + workPackageNumber: true, + name: true, + workPackage: getBlockingWorkPackagesArgs().include.workPackage + } + } + } + }); + export const getCalendarTaskQueryArgs = () => Prisma.validator()({ include: { diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index 36ffe33223..6a10185b8c 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -2,9 +2,10 @@ import { Prisma } from '@prisma/client'; import { getUserPreviewQueryArgs, getUserQueryArgs } from './user.query-args.js'; import { getDescriptionBulletQueryArgs } from './description-bullets.query-args.js'; import { getLinkQueryArgs } from './links.query-args.js'; -import { getEventQueryArgs } from './event.query-args.js'; +import { getEventPreviewQueryArgs, getEventQueryArgs } from './event.query-args.js'; export type WorkPackageQueryArgs = ReturnType; +export type WorkPackageGanttQueryArgs = ReturnType; export type WorkPackagePreviewQueryArgs = ReturnType; export const getWorkPackageQueryArgs = (organizationId: string) => @@ -16,7 +17,8 @@ export const getWorkPackageQueryArgs = (organizationId: string) => teams: { include: { teamType: true - } + }, + orderBy: [{ teamName: 'asc' }, { teamId: 'asc' }] } } }, @@ -27,17 +29,79 @@ export const getWorkPackageQueryArgs = (organizationId: string) => changes: { where: { changeRequest: { dateDeleted: null } }, include: { implementer: getUserQueryArgs(organizationId), changeRequest: true }, - orderBy: { dateImplemented: 'asc' } + orderBy: [{ dateImplemented: 'asc' }, { changeId: 'asc' }] + }, + blocking: { + where: { wbsElement: { dateDeleted: null } }, + include: { wbsElement: true }, + orderBy: [ + { wbsElement: { carNumber: 'asc' } }, + { wbsElement: { projectNumber: 'asc' } }, + { wbsElement: { workPackageNumber: 'asc' } } + ] }, - blocking: { where: { wbsElement: { dateDeleted: null } }, include: { wbsElement: true } }, - descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) } + descriptionBullets: { + where: { dateDeleted: null }, + orderBy: [{ dateAdded: 'asc' }, { descriptionId: 'asc' }], + ...getDescriptionBulletQueryArgs(organizationId) + } } }, - blockedBy: { where: { dateDeleted: null } }, + blockedBy: { + where: { dateDeleted: null }, + orderBy: [{ carNumber: 'asc' }, { projectNumber: 'asc' }, { workPackageNumber: 'asc' }] + }, events: { where: { dateDeleted: null }, ...getEventQueryArgs(organizationId) } } }); +// same output as getWorkPackageQueryArgs through workPackageTransformer, but only fetches what the gantt reads +export const getWorkPackageGanttQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + project: { + select: { + wbsElement: { select: { name: true } }, + teams: { + select: { teamType: true }, + orderBy: [{ teamName: 'asc' }, { teamId: 'asc' }] + } + } + }, + wbsElement: { + include: { + lead: getUserQueryArgs(organizationId), + manager: getUserQueryArgs(organizationId), + changes: { + where: { changeRequest: { dateDeleted: null } }, + include: { implementer: getUserQueryArgs(organizationId), changeRequest: { select: { identifier: true } } }, + orderBy: [{ dateImplemented: 'asc' }, { changeId: 'asc' }] + }, + blocking: { + where: { wbsElement: { dateDeleted: null } }, + select: { wbsElement: { select: { carNumber: true, projectNumber: true, workPackageNumber: true } } }, + orderBy: [ + { wbsElement: { carNumber: 'asc' } }, + { wbsElement: { projectNumber: 'asc' } }, + { wbsElement: { workPackageNumber: 'asc' } } + ] + }, + descriptionBullets: { + where: { dateDeleted: null }, + orderBy: [{ dateAdded: 'asc' }, { descriptionId: 'asc' }], + select: { ...getDescriptionBulletQueryArgs(organizationId).select, userChecked: getUserPreviewQueryArgs() } + } + } + }, + blockedBy: { + where: { dateDeleted: null }, + select: { carNumber: true, projectNumber: true, workPackageNumber: true }, + orderBy: [{ carNumber: 'asc' }, { projectNumber: 'asc' }, { workPackageNumber: 'asc' }] + }, + events: { where: { dateDeleted: null }, ...getEventPreviewQueryArgs(organizationId) } + } + }); + export const getWorkPackagePreviewQueryArgs = () => Prisma.validator()({ select: { diff --git a/src/backend/src/prisma/factories/event.factory.ts b/src/backend/src/prisma/factories/event.factory.ts index fe3c8b8655..71c27ac3ae 100644 --- a/src/backend/src/prisma/factories/event.factory.ts +++ b/src/backend/src/prisma/factories/event.factory.ts @@ -3,6 +3,7 @@ import { Conflict_Status, Event_Status, Prisma } from '@prisma/client'; import { arrayOrNull } from '../utils/arrays.js'; import { DAY_MS, MINUTE_MS } from '../dates.js'; import { addDaysToDate } from 'shared'; +import { seedConfig } from '../seed-config.js'; type ConflictResult = { approved: Conflict_Status; @@ -57,6 +58,9 @@ const CONFIRMED_WINDOW = 14; export const generateEventCount = (faker: Faker): number => faker.number.int({ min: 1, max: EVENTS_PER_PROJECT }); +export const generateEventWorkPackageLinkCount = (faker: Faker): number => + faker.helpers.weightedArrayElement(seedConfig.event.workPackageLinkWeights); + export const generateScheduleSlotCount = (faker: Faker, title: string): number => { const isRecurring = title.includes('Weekly') || title.includes('Meeting') || title.includes('Office Hours') || title.includes('Bay Time'); @@ -210,7 +214,8 @@ export const eventCreateInput = ( confirmedMemberIds: string[], deniedMemberIds: string[], dateCreated: Date, - approvalRequiredFromUserId?: string + approvalRequiredFromUserId?: string, + workPackageIds: string[] = [] ): Prisma.EventCreateInput => ({ title, status, @@ -237,6 +242,9 @@ export const eventCreateInput = ( ...(eventType.questionDocument && questionDocumentLink ? { questionDocumentLink } : {}), ...(approved !== Conflict_Status.NO_CONFLICT && approvalRequiredFromUserId ? { approvalRequiredBy: { connect: { userId: approvalRequiredFromUserId } } } + : {}), + ...(workPackageIds.length > 0 + ? { workPackages: { connect: workPackageIds.map((workPackageId) => ({ workPackageId })) } } : {}) }); diff --git a/src/backend/src/prisma/migrations/20261005023559_schedule_slot_event_id_index/migration.sql b/src/backend/src/prisma/migrations/20261005023559_schedule_slot_event_id_index/migration.sql new file mode 100644 index 0000000000..d43590f3c8 --- /dev/null +++ b/src/backend/src/prisma/migrations/20261005023559_schedule_slot_event_id_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Schedule_Slot_eventId_idx" ON "Schedule_Slot"("eventId"); diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index cc2fde9198..0ad204f187 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1155,6 +1155,7 @@ model Schedule_Slot { @@index([endTime]) @@index([startTime]) + @@index([eventId]) } enum Conflict_Status { diff --git a/src/backend/src/prisma/seed-config.json b/src/backend/src/prisma/seed-config.json index 8b85b97087..78f40a124a 100644 --- a/src/backend/src/prisma/seed-config.json +++ b/src/backend/src/prisma/seed-config.json @@ -19,7 +19,7 @@ ] }, "project": { - "projectsPerCar": 50 + "projectsPerCar": 5 }, "user": { "totalUsers": 350 @@ -47,6 +47,13 @@ { "weight": 5, "value": 8 } ] }, + "event": { + "workPackageLinkWeights": [ + { "weight": 40, "value": 0 }, + { "weight": 45, "value": 1 }, + { "weight": 15, "value": 2 } + ] + }, "changeRequest": { "projectCountWeights": [ { "weight": 15, "value": 0 }, diff --git a/src/backend/src/prisma/seed-config.ts b/src/backend/src/prisma/seed-config.ts index 36b441af3f..e7cf9932aa 100644 --- a/src/backend/src/prisma/seed-config.ts +++ b/src/backend/src/prisma/seed-config.ts @@ -64,6 +64,10 @@ export interface SeedConfig { task: { countForProject: WeightedCount[]; }; + event: { + // how many of its project's work packages each event is linked to (capped at how many the project has) + workPackageLinkWeights: WeightedValue[]; + }; } export const seedConfig: SeedConfig = JSON.parse( diff --git a/src/backend/src/prisma/seed/event.process.ts b/src/backend/src/prisma/seed/event.process.ts index f120b46799..e85e2c58d4 100644 --- a/src/backend/src/prisma/seed/event.process.ts +++ b/src/backend/src/prisma/seed/event.process.ts @@ -5,7 +5,8 @@ import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; import { TeamOutput, TeamProcess } from './team.process.js'; import { TeamJoinRequestProcess } from './team-join-request.process.js'; import { ProjectOutput, ProjectProcess } from './project.process.js'; -import { CarOutput } from '../context.js'; +import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; +import { CarOutput, WorkPackageContext } from '../context.js'; import { CarProcess } from './car.process.js'; import { DAYS_AFTER_NO_EVENT, @@ -18,6 +19,7 @@ import { generateEventDescription, generateEventStatus, generateEventTitle, + generateEventWorkPackageLinkCount, generateInitialDateOffset, generateLocation, generateQuestionDocumentLink, @@ -33,7 +35,13 @@ import { addDaysToDate } from 'shared'; import { clampDate, DAY_MS, daysBetween } from '../dates.js'; import { Event_Status } from '@prisma/client'; -type EventInput = OrganizationOutput & UsersOutput & ConfigDataOutput & TeamOutput & CarOutput & ProjectOutput; +type EventInput = OrganizationOutput & + UsersOutput & + ConfigDataOutput & + TeamOutput & + CarOutput & + ProjectOutput & + WorkPackageOutput; export class EventProcess extends SeedProcess> { dependencies() { @@ -46,7 +54,8 @@ export class EventProcess extends SeedProcess> // process picks event attendees from the `members` pool. TeamJoinRequestProcess, CarProcess, - ProjectProcess + ProjectProcess, + WorkPackageProcess ]; } @@ -59,7 +68,8 @@ export class EventProcess extends SeedProcess> appAdmins, members, teams, - eventTypes + eventTypes, + workPackagesByProjectId }: EventInput): Promise> { const { organizationId } = organization; const creators = [...leadership, ...heads, ...admins, ...appAdmins]; @@ -75,6 +85,7 @@ export class EventProcess extends SeedProcess> return this.generateEventsForProject( organizationId, project.wbsElement.name, + workPackagesByProjectId[project.projectId] ?? [], timeline, creators, allUsers, @@ -92,6 +103,7 @@ export class EventProcess extends SeedProcess> private async generateEventsForProject( organizationId: string, projectName: string, + projectWorkPackages: WorkPackageContext[], timeline: { start: Date; end: Date }, creators: UsersOutput['leadership'], allUsers: UsersOutput['members'], @@ -147,6 +159,13 @@ export class EventProcess extends SeedProcess> // Unused. For reference if later reused. const deniedMemberIds: string[] = []; + const workPackageIds = this.faker.helpers + .arrayElements( + projectWorkPackages, + Math.min(generateEventWorkPackageLinkCount(this.faker), projectWorkPackages.length) + ) + .map(({ workPackage }) => workPackage.workPackageId); + const event = await this.prisma.event.create({ data: eventCreateInput( eventType, @@ -165,7 +184,8 @@ export class EventProcess extends SeedProcess> confirmedMemberIds, deniedMemberIds, dateCreated, - approvalRequiredFromUserId + approvalRequiredFromUserId, + workPackageIds ) }); diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 02afd3e385..0d66de3ff7 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -56,7 +56,7 @@ export default class ProjectsService { static async getAllProjectsGantt(organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, ...(carId && { carId }) }, - // load relations with one query per level instead of a single deeply nested join query + orderBy: [{ wbsElement: { carNumber: 'asc' } }, { wbsElement: { projectNumber: 'asc' } }], relationLoadStrategy: 'query', ...getProjectGanttQueryArgs(organization.organizationId) }); diff --git a/src/backend/src/transformers/calendar.transformer.ts b/src/backend/src/transformers/calendar.transformer.ts index fd01618f20..fd74860f28 100644 --- a/src/backend/src/transformers/calendar.transformer.ts +++ b/src/backend/src/transformers/calendar.transformer.ts @@ -21,7 +21,7 @@ import { } from 'shared'; import { CalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js'; import { EventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js'; -import { EventQueryArgs, EventWithMembersQueryArgs } from '../prisma-query-args/event.query-args.js'; +import { EventPreviewQueryArgs, EventQueryArgs, EventWithMembersQueryArgs } from '../prisma-query-args/event.query-args.js'; import { ShopMachineryQueryArgs, MachineryQueryArgs } from '../prisma-query-args/machinery.query-args.js'; import { ShopQueryArgs } from '../prisma-query-args/shop.query-args.js'; import { userTransformer, userWithScheduleSettingsTransformer } from './user.transformer.js'; @@ -185,7 +185,10 @@ export const eventWithMembersTransformer = (event: Prisma.EventGetPayload, wbsName: string): EventPreview => { +export const eventPreviewTransformer = ( + event: Prisma.EventGetPayload, + wbsName: string +): EventPreview => { // Use first scheduled time's startTime, or fall back to initialDateScheduled (for confirmation events), or current date const dateScheduled = (event.scheduledTimes.length > 0 && event.scheduledTimes[0].startTime ? event.scheduledTimes[0].startTime : null) ?? diff --git a/src/backend/src/transformers/description-bullets.transformer.ts b/src/backend/src/transformers/description-bullets.transformer.ts index dd1adce64f..14dfca92dc 100644 --- a/src/backend/src/transformers/description-bullets.transformer.ts +++ b/src/backend/src/transformers/description-bullets.transformer.ts @@ -2,9 +2,12 @@ import { Prisma } from '@prisma/client'; import { DescriptionBullet } from 'shared'; import { DescriptionBulletQueryArgs } from '../prisma-query-args/description-bullets.query-args.js'; -const descriptionBulletTransformer = ( - descBullet: Prisma.Description_BulletGetPayload -): DescriptionBullet => { +// only the preview fields of userChecked are read, so callers may fetch a lighter user shape +type DescriptionBulletInput = Omit, 'userChecked'> & { + userChecked: { userId: string; firstName: string; lastName: string } | null; +}; + +const descriptionBulletTransformer = (descBullet: DescriptionBulletInput): DescriptionBullet => { return { id: descBullet.descriptionId, detail: descBullet.detail, diff --git a/src/backend/src/transformers/tasks.transformer.ts b/src/backend/src/transformers/tasks.transformer.ts index 984e5ad3c9..748712e165 100644 --- a/src/backend/src/transformers/tasks.transformer.ts +++ b/src/backend/src/transformers/tasks.transformer.ts @@ -6,7 +6,7 @@ import { userPreviewWithEmailTransformer } from './user.transformer.js'; import { CalendarTaskQueryArgs, TaskLabelQueryArgs, - TaskQueryArgs, + TaskGanttQueryArgs, TaskPreviewQueryArgs, TaskBlockedByQueryArgs, BlockingWorkPackagesQueryArgs @@ -42,7 +42,8 @@ export const getActiveTaskBlockerNames = ( ...getBlockingWorkPackagePreviews(wbsElement).map((wp) => wp.name) ]; -export const taskTransformer = (task: Prisma.TaskGetPayload): Task => { +// typed against the gantt shape, which is only what's read here; the full TaskQueryArgs payload also fits +export const taskTransformer = (task: Prisma.TaskGetPayload): Task => { const wbsNum = wbsNumOf(task.wbsElement); return { taskId: task.taskId, diff --git a/src/backend/src/transformers/work-packages.transformer.ts b/src/backend/src/transformers/work-packages.transformer.ts index e5b5c5d807..921ae88028 100644 --- a/src/backend/src/transformers/work-packages.transformer.ts +++ b/src/backend/src/transformers/work-packages.transformer.ts @@ -3,11 +3,16 @@ import { calculateEndDate, RetrospectiveWorkPackage, WorkPackage, WorkPackagePre import descriptionBulletTransformer from '../transformers/description-bullets.transformer.js'; import { convertStatus, wbsNumOf } from '../utils/utils.js'; import { userTransformer } from './user.transformer.js'; -import { WorkPackageQueryArgs, WorkPackagePreviewQueryArgs } from '../prisma-query-args/work-packages.query-args.js'; +import { + WorkPackageQueryArgs, + WorkPackageGanttQueryArgs, + WorkPackagePreviewQueryArgs +} from '../prisma-query-args/work-packages.query-args.js'; import { teamTypeTransformer } from './team-types.transformer.js'; import { eventPreviewTransformer } from './calendar.transformer.js'; -const workPackageTransformer = (wpInput: Prisma.Work_PackageGetPayload): WorkPackage => { +// typed against the gantt shape, which is only what's read here; the full WorkPackageQueryArgs payload also fits +const workPackageTransformer = (wpInput: Prisma.Work_PackageGetPayload): WorkPackage => { const wbsNum = wbsNumOf(wpInput.wbsElement); return { wbsElementId: wpInput.wbsElementId, From 1746ead518d5af1ebb7a9a555cb52d442f4ee02b Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 9 Oct 2026 15:03:54 -0400 Subject: [PATCH 03/13] editing early return fix --- src/backend/src/services/projects.services.ts | 1 - .../GanttTimeLineChangeModal.tsx | 74 ++++++++----------- 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 0d66de3ff7..e52c48eb0c 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -57,7 +57,6 @@ export default class ProjectsService { const projects = await prisma.project.findMany({ where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, ...(carId && { carId }) }, orderBy: [{ wbsElement: { carNumber: 'asc' } }, { wbsElement: { projectNumber: 'asc' } }], - relationLoadStrategy: 'query', ...getProjectGanttQueryArgs(organization.organizationId) }); diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx index 61608ec74f..383f9cf32b 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx @@ -1,5 +1,5 @@ -import { Box, FormControl, InputLabel, TextField, Typography } from '@mui/material'; -import { dateToMidnightUTC, Link, LinkCreateArgs, ProjectGantt, Task, WbsElementPreview, WorkPackage } from 'shared'; +import { Box, TextField, Typography } from '@mui/material'; +import { dateToMidnightUTC, ProjectGantt, Task, WbsElementPreview, WorkPackage } from 'shared'; import { useState } from 'react'; import dayjs from 'dayjs'; import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../../hooks/change-requests.hooks'; @@ -47,14 +47,6 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim return `${dayjs(startDate).format('MMMM D, YYYY')} - ${dayjs(endDate).format('MMMM D, YYYY')}`; }; - const transformLinkToLinkCreateArgs = (link: Link): LinkCreateArgs => { - return { - linkId: link.linkId, - linkTypeName: link.linkType.name, - url: link.url - }; - }; - const project = change.element as ProjectGantt; const editedWorkPackages: WorkPackage[] = []; @@ -98,45 +90,45 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim }); const handleSubmit = async () => { - if (editedWorkPackages.length > 0) { + if (editedWorkPackages.length > 0 && !explanationForChange) { return; } try { - if (editedWorkPackages.length > 0) { + // one change request per edited work package, scoped to that work package (like the work package edit form), + // so approving it edits the existing work package rather than creating a new one under the project. + // only the timeline comes from the gantt; everything else comes from the full project fetched above + let createdChangeRequests = 0; + for (const workPackage of editedWorkPackages) { + const existingWorkPackage = originalProject.workPackages.find((wp) => wp.id === workPackage.id); + if (!existingWorkPackage) continue; + const payload: CreateStandardChangeRequestPayload = { - wbsNum: change.element.wbsNum, + wbsNum: existingWorkPackage.wbsNum, why: explanationForChange, - projectProposedChanges: { - workPackageProposedChanges: editedWorkPackages.map((workPackage) => { - const duration = dayjs(workPackage.endDate).diff(dayjs(workPackage.startDate), 'week'); - return { - name: workPackage.name, - stage: workPackage.stage, - duration, - startDate: dateToMidnightUTC(change.newStart).toISOString(), - blockedBy: workPackage.blockedBy, - descriptionBullets: workPackage.descriptionBullets, - leadId: workPackage.lead ? workPackage.lead.userId : undefined, - managerId: workPackage.manager ? workPackage.manager.userId : undefined, - links: workPackage.links.map(transformLinkToLinkCreateArgs) - }; - }), - budget: originalProject.budget, - summary: originalProject.summary, - teamIds: originalProject.teams.map((team) => team.teamId), - name: originalProject.name, - descriptionBullets: originalProject.descriptionBullets, - links: originalProject.links.map(transformLinkToLinkCreateArgs), - leadId: originalProject.lead?.userId, - managerId: originalProject.manager?.userId, - carNumber: originalProject.wbsNum.carNumber + workPackageProposedChanges: { + name: existingWorkPackage.name, + stage: existingWorkPackage.stage, + duration: dayjs(workPackage.endDate).diff(dayjs(workPackage.startDate), 'week'), + startDate: dateToMidnightUTC(workPackage.startDate).toISOString(), + blockedBy: existingWorkPackage.blockedBy, + descriptionBullets: existingWorkPackage.descriptionBullets.map(({ id, detail, type }) => ({ id, detail, type })), + leadId: existingWorkPackage.lead?.userId, + managerId: existingWorkPackage.manager?.userId, + links: [] } }; - await createStandardChangeRequest(payload); - toast.success('Change Request Created Successfully!'); + try { + await createStandardChangeRequest(payload); + createdChangeRequests++; + } catch (error) { + toast.error( + `Failed to create a change request for ${existingWorkPackage.name}: ${error instanceof Error ? error.message : error}` + ); + } } + if (createdChangeRequests > 0) toast.success(`Created ${createdChangeRequests} change request(s)`); createdWorkPackages.forEach(async (workPackage) => { const duration = dayjs(workPackage.endDate).diff(dayjs(workPackage.startDate), 'week'); @@ -227,13 +219,9 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim {`New: ${changeInTimeline(change.newStart, change.newEnd)}`} {editedWorkPackages.length > 0 && ( - - Reason for Change - Date: Fri, 9 Oct 2026 19:06:00 -0400 Subject: [PATCH 04/13] one slot only, pick earliest date --- src/backend/src/prisma-query-args/event.query-args.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/prisma-query-args/event.query-args.ts b/src/backend/src/prisma-query-args/event.query-args.ts index 36662ba628..5918a5fd42 100644 --- a/src/backend/src/prisma-query-args/event.query-args.ts +++ b/src/backend/src/prisma-query-args/event.query-args.ts @@ -15,7 +15,7 @@ export const getEventPreviewQueryArgs = (organizationId: string) => title: true, status: true, initialDateScheduled: true, - scheduledTimes: true, + scheduledTimes: { select: { startTime: true }, orderBy: { startTime: 'asc' }, take: 1 }, // preview shows one date, so fetch earliest slot userCreated: getUserQueryArgs(organizationId) } }); From 9dafc85eda31cca96eb85f138044354bcc288659 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 9 Oct 2026 19:13:14 -0400 Subject: [PATCH 05/13] remove unnecessary ordering --- .../prisma-query-args/projects.query-args.ts | 3 +- .../src/prisma-query-args/tasks.query-args.ts | 7 +-- .../work-packages.query-args.ts | 44 ++++--------------- 3 files changed, 12 insertions(+), 42 deletions(-) diff --git a/src/backend/src/prisma-query-args/projects.query-args.ts b/src/backend/src/prisma-query-args/projects.query-args.ts index 8cf507494d..175f437e17 100644 --- a/src/backend/src/prisma-query-args/projects.query-args.ts +++ b/src/backend/src/prisma-query-args/projects.query-args.ts @@ -73,8 +73,7 @@ export const getProjectGanttQueryArgs = (organizationId: string) => select: { teamId: true, teamName: true - }, - orderBy: [{ teamName: 'asc' }, { teamId: 'asc' }] + } }, workPackages: { where: { diff --git a/src/backend/src/prisma-query-args/tasks.query-args.ts b/src/backend/src/prisma-query-args/tasks.query-args.ts index 3b39ac76df..3473cc92d0 100644 --- a/src/backend/src/prisma-query-args/tasks.query-args.ts +++ b/src/backend/src/prisma-query-args/tasks.query-args.ts @@ -55,11 +55,8 @@ export const getTaskQueryArgs = () => wbsElement: getBlockingWorkPackagesArgs(), createdBy: getUserPreviewWithEmailQueryArgs(), deletedBy: getUserPreviewWithEmailQueryArgs(), - assignees: { - ...getUserPreviewWithEmailQueryArgs(), - orderBy: [{ firstName: 'asc' }, { lastName: 'asc' }, { userId: 'asc' }] - }, - labels: { ...getTaskLabelQueryArgs(), orderBy: [{ name: 'asc' }, { taskLabelId: 'asc' }] }, + assignees: getUserPreviewWithEmailQueryArgs(), + labels: getTaskLabelQueryArgs(), blockedBy: getTaskBlockedByQueryArgs() } }); diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index 6a10185b8c..75567f90ef 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -17,8 +17,7 @@ export const getWorkPackageQueryArgs = (organizationId: string) => teams: { include: { teamType: true - }, - orderBy: [{ teamName: 'asc' }, { teamId: 'asc' }] + } } } }, @@ -29,28 +28,13 @@ export const getWorkPackageQueryArgs = (organizationId: string) => changes: { where: { changeRequest: { dateDeleted: null } }, include: { implementer: getUserQueryArgs(organizationId), changeRequest: true }, - orderBy: [{ dateImplemented: 'asc' }, { changeId: 'asc' }] - }, - blocking: { - where: { wbsElement: { dateDeleted: null } }, - include: { wbsElement: true }, - orderBy: [ - { wbsElement: { carNumber: 'asc' } }, - { wbsElement: { projectNumber: 'asc' } }, - { wbsElement: { workPackageNumber: 'asc' } } - ] + orderBy: { dateImplemented: 'asc' } }, - descriptionBullets: { - where: { dateDeleted: null }, - orderBy: [{ dateAdded: 'asc' }, { descriptionId: 'asc' }], - ...getDescriptionBulletQueryArgs(organizationId) - } + blocking: { where: { wbsElement: { dateDeleted: null } }, include: { wbsElement: true } }, + descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) } } }, - blockedBy: { - where: { dateDeleted: null }, - orderBy: [{ carNumber: 'asc' }, { projectNumber: 'asc' }, { workPackageNumber: 'asc' }] - }, + blockedBy: { where: { dateDeleted: null } }, events: { where: { dateDeleted: null }, ...getEventQueryArgs(organizationId) } } }); @@ -62,10 +46,7 @@ export const getWorkPackageGanttQueryArgs = (organizationId: string) => project: { select: { wbsElement: { select: { name: true } }, - teams: { - select: { teamType: true }, - orderBy: [{ teamName: 'asc' }, { teamId: 'asc' }] - } + teams: { select: { teamType: true } } } }, wbsElement: { @@ -75,28 +56,21 @@ export const getWorkPackageGanttQueryArgs = (organizationId: string) => changes: { where: { changeRequest: { dateDeleted: null } }, include: { implementer: getUserQueryArgs(organizationId), changeRequest: { select: { identifier: true } } }, - orderBy: [{ dateImplemented: 'asc' }, { changeId: 'asc' }] + orderBy: { dateImplemented: 'asc' } }, blocking: { where: { wbsElement: { dateDeleted: null } }, - select: { wbsElement: { select: { carNumber: true, projectNumber: true, workPackageNumber: true } } }, - orderBy: [ - { wbsElement: { carNumber: 'asc' } }, - { wbsElement: { projectNumber: 'asc' } }, - { wbsElement: { workPackageNumber: 'asc' } } - ] + select: { wbsElement: { select: { carNumber: true, projectNumber: true, workPackageNumber: true } } } }, descriptionBullets: { where: { dateDeleted: null }, - orderBy: [{ dateAdded: 'asc' }, { descriptionId: 'asc' }], select: { ...getDescriptionBulletQueryArgs(organizationId).select, userChecked: getUserPreviewQueryArgs() } } } }, blockedBy: { where: { dateDeleted: null }, - select: { carNumber: true, projectNumber: true, workPackageNumber: true }, - orderBy: [{ carNumber: 'asc' }, { projectNumber: 'asc' }, { workPackageNumber: 'asc' }] + select: { carNumber: true, projectNumber: true, workPackageNumber: true } }, events: { where: { dateDeleted: null }, ...getEventPreviewQueryArgs(organizationId) } } From 664c4041d054627b725c9089bccb606cca6e5344 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 9 Oct 2026 19:14:54 -0400 Subject: [PATCH 06/13] assignee ordering fix --- .../ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx index 383f9cf32b..545034229a 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx @@ -81,7 +81,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim existingTask.startDate?.getTime() !== task.startDate?.getTime() || existingTask.deadline?.getTime() !== task.deadline?.getTime() || existingTask.assignees.length !== task.assignees.length || - existingTask.assignees.some((assignee, index) => assignee.userId !== task.assignees[index]?.userId); + existingTask.assignees.some((assignee) => !task.assignees.some((a) => a.userId === assignee.userId)); if (hasChanges) { editedTasks.push(task); From e35659d0e8da60e41a74ecf90c9ecaed70573386 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 9 Oct 2026 19:25:03 -0400 Subject: [PATCH 07/13] remove deleted by, have prisma get type from query args --- src/backend/src/prisma-query-args/tasks.query-args.ts | 8 ++++++-- src/backend/src/prisma-query-args/user.query-args.ts | 2 ++ .../src/transformers/description-bullets.transformer.ts | 3 ++- src/backend/src/transformers/tasks.transformer.ts | 7 +++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/backend/src/prisma-query-args/tasks.query-args.ts b/src/backend/src/prisma-query-args/tasks.query-args.ts index 3473cc92d0..a31a5b212e 100644 --- a/src/backend/src/prisma-query-args/tasks.query-args.ts +++ b/src/backend/src/prisma-query-args/tasks.query-args.ts @@ -61,11 +61,15 @@ export const getTaskQueryArgs = () => } }); -// same output as getTaskQueryArgs through taskTransformer, but only fetches the wbs element fields it reads +// same output as getTaskQueryArgs through taskTransformer for non-deleted tasks (the gantt only loads those, so +// deletedBy is skipped), and only fetches the wbs element fields taskTransformer reads export const getTaskGanttQueryArgs = () => Prisma.validator()({ include: { - ...getTaskQueryArgs().include, + createdBy: getUserPreviewWithEmailQueryArgs(), + assignees: getUserPreviewWithEmailQueryArgs(), + labels: getTaskLabelQueryArgs(), + blockedBy: getTaskBlockedByQueryArgs(), wbsElement: { select: { carNumber: true, diff --git a/src/backend/src/prisma-query-args/user.query-args.ts b/src/backend/src/prisma-query-args/user.query-args.ts index 288b64b22f..4cd58e919a 100644 --- a/src/backend/src/prisma-query-args/user.query-args.ts +++ b/src/backend/src/prisma-query-args/user.query-args.ts @@ -2,6 +2,8 @@ import { Prisma } from '@prisma/client'; export type UserQueryArgs = ReturnType; +export type UserPreviewQueryArgs = ReturnType; + export type UserPreviewWithEmailQueryArgs = ReturnType; export type UserWithSettingsQueryArgs = ReturnType; diff --git a/src/backend/src/transformers/description-bullets.transformer.ts b/src/backend/src/transformers/description-bullets.transformer.ts index 14dfca92dc..962ce64c2f 100644 --- a/src/backend/src/transformers/description-bullets.transformer.ts +++ b/src/backend/src/transformers/description-bullets.transformer.ts @@ -1,10 +1,11 @@ import { Prisma } from '@prisma/client'; import { DescriptionBullet } from 'shared'; import { DescriptionBulletQueryArgs } from '../prisma-query-args/description-bullets.query-args.js'; +import { UserPreviewQueryArgs } from '../prisma-query-args/user.query-args.js'; // only the preview fields of userChecked are read, so callers may fetch a lighter user shape type DescriptionBulletInput = Omit, 'userChecked'> & { - userChecked: { userId: string; firstName: string; lastName: string } | null; + userChecked: Prisma.UserGetPayload | null; }; const descriptionBulletTransformer = (descBullet: DescriptionBulletInput): DescriptionBullet => { diff --git a/src/backend/src/transformers/tasks.transformer.ts b/src/backend/src/transformers/tasks.transformer.ts index 748712e165..6ed288b81e 100644 --- a/src/backend/src/transformers/tasks.transformer.ts +++ b/src/backend/src/transformers/tasks.transformer.ts @@ -6,6 +6,7 @@ import { userPreviewWithEmailTransformer } from './user.transformer.js'; import { CalendarTaskQueryArgs, TaskLabelQueryArgs, + TaskQueryArgs, TaskGanttQueryArgs, TaskPreviewQueryArgs, TaskBlockedByQueryArgs, @@ -42,8 +43,10 @@ export const getActiveTaskBlockerNames = ( ...getBlockingWorkPackagePreviews(wbsElement).map((wp) => wp.name) ]; -// typed against the gantt shape, which is only what's read here; the full TaskQueryArgs payload also fits -export const taskTransformer = (task: Prisma.TaskGetPayload): Task => { +// typed against the gantt shape plus an optional deletedBy (the gantt doesn't fetch it); the full TaskQueryArgs payload also fits +export const taskTransformer = ( + task: Prisma.TaskGetPayload & Partial, 'deletedBy'>> +): Task => { const wbsNum = wbsNumOf(task.wbsElement); return { taskId: task.taskId, From e28e46814dce387a404c08993c6397ec8782e421 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 9 Oct 2026 20:01:55 -0400 Subject: [PATCH 08/13] gantt base type --- .../src/controllers/projects.controllers.ts | 7 +- .../gantt-chart.query-args.ts | 78 +++++++++++++++++++ .../src/prisma-query-args/tasks.query-args.ts | 7 +- src/backend/src/services/projects.services.ts | 14 ++-- .../transformers/gantt-chart.transformer.ts | 74 ++++++++++++++++++ .../src/transformers/tasks.transformer.ts | 10 ++- src/frontend/src/apis/projects.api.ts | 9 ++- .../transformers/projects.transformers.ts | 25 +++++- .../work-packages.transformers.ts | 19 ++++- src/frontend/src/hooks/projects.hooks.ts | 4 +- .../ProjectGanttChart/AddGanttTaskModal.tsx | 4 +- .../GanttProjectCreateModal.tsx | 9 ++- .../GanttTimeLineChangeModal.tsx | 8 +- .../ProjectGanttChartPage.tsx | 54 ++++++------- src/frontend/src/utils/gantt.utils.tsx | 52 ++++++------- src/shared/src/types/project-types.ts | 24 ++++++ 16 files changed, 306 insertions(+), 92 deletions(-) create mode 100644 src/backend/src/prisma-query-args/gantt-chart.query-args.ts create mode 100644 src/backend/src/transformers/gantt-chart.transformer.ts diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index 6eaff761c3..76c261550c 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -3,7 +3,7 @@ import { MaterialType, Project, ProjectOverview, - ProjectGantt, + GanttChartProject, ProjectPreview, validateWBS, WbsNumber, @@ -16,7 +16,10 @@ import BillOfMaterialsService from '../services/boms.services.js'; export default class ProjectsController { static async getAllProjectsGantt(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt(req.organization, req.currentCar?.carId); + const projects: GanttChartProject[] = await ProjectsService.getAllProjectsGantt( + req.organization, + req.currentCar?.carId + ); res.status(200).json(projects); } catch (error: unknown) { next(error); diff --git a/src/backend/src/prisma-query-args/gantt-chart.query-args.ts b/src/backend/src/prisma-query-args/gantt-chart.query-args.ts new file mode 100644 index 0000000000..492351a2d0 --- /dev/null +++ b/src/backend/src/prisma-query-args/gantt-chart.query-args.ts @@ -0,0 +1,78 @@ +import { Prisma } from '@prisma/client'; +import { getUserPreviewQueryArgs } from './user.query-args.js'; +import { getTaskGanttQueryArgs } from './tasks.query-args.js'; + +// Base gantt chart query: only what the gantt chart reads (see the GanttChart* types in shared). +// Other gantt views (e.g. retrospective) can extend these instead of starting from the full project query. + +export type GanttChartEventQueryArgs = ReturnType; +export type GanttChartWorkPackageQueryArgs = ReturnType; +export type GanttChartProjectQueryArgs = ReturnType; + +const wbsNumberSelect = { carNumber: true, projectNumber: true, workPackageNumber: true } as const; + +export const getGanttChartEventQueryArgs = () => + Prisma.validator()({ + select: { + eventId: true, + status: true, + initialDateScheduled: true, + // the event marker shows one date, so just fetch the earliest slot + scheduledTimes: { select: { startTime: true }, orderBy: { startTime: 'asc' }, take: 1 } + } + }); + +export const getGanttChartWorkPackageQueryArgs = () => + Prisma.validator()({ + select: { + workPackageId: true, + wbsElementId: true, + projectId: true, + startDate: true, + duration: true, + stage: true, + wbsElement: { + select: { + ...wbsNumberSelect, + name: true, + status: true, + dateCreated: true, + dateDeleted: true, + lead: getUserPreviewQueryArgs(), + manager: getUserPreviewQueryArgs(), + blocking: { where: { wbsElement: { dateDeleted: null } }, select: { wbsElement: { select: wbsNumberSelect } } } + } + }, + events: { where: { dateDeleted: null }, ...getGanttChartEventQueryArgs() } + } + }); + +export const getGanttChartProjectQueryArgs = () => + Prisma.validator()({ + select: { + projectId: true, + wbsElementId: true, + wbsElement: { + select: { + ...wbsNumberSelect, + name: true, + dateCreated: true, + dateDeleted: true, + lead: getUserPreviewQueryArgs(), + manager: getUserPreviewQueryArgs(), + tasks: { + where: { dateDeleted: null }, + orderBy: [{ dateCreated: 'asc' }, { taskId: 'asc' }], + ...getTaskGanttQueryArgs() + } + } + }, + teams: { select: { teamId: true } }, + workPackages: { + where: { wbsElement: { dateDeleted: null } }, + // orderInProject can repeat after a work package is deleted, so tie-break on the (unique per project) wp number + orderBy: [{ orderInProject: 'asc' }, { wbsElement: { workPackageNumber: 'asc' } }], + ...getGanttChartWorkPackageQueryArgs() + } + } + }); diff --git a/src/backend/src/prisma-query-args/tasks.query-args.ts b/src/backend/src/prisma-query-args/tasks.query-args.ts index a31a5b212e..179a9f30a1 100644 --- a/src/backend/src/prisma-query-args/tasks.query-args.ts +++ b/src/backend/src/prisma-query-args/tasks.query-args.ts @@ -61,8 +61,8 @@ export const getTaskQueryArgs = () => } }); -// same output as getTaskQueryArgs through taskTransformer for non-deleted tasks (the gantt only loads those, so -// deletedBy is skipped), and only fetches the wbs element fields taskTransformer reads +// same output as getTaskQueryArgs through taskTransformer for the gantt's tasks, which are non-deleted (so deletedBy is +// skipped) and project-level (a project's wbs element has no work package, so its blocking work packages are skipped) export const getTaskGanttQueryArgs = () => Prisma.validator()({ include: { @@ -75,8 +75,7 @@ export const getTaskGanttQueryArgs = () => carNumber: true, projectNumber: true, workPackageNumber: true, - name: true, - workPackage: getBlockingWorkPackagesArgs().include.workPackage + name: true } } } diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index e52c48eb0c..86660f2182 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -8,7 +8,7 @@ import { LinkType, Project, ProjectOverview, - ProjectGantt, + GanttChartProject, ProjectPreview, ProjectDropdownItem, WbsNumber, @@ -20,9 +20,9 @@ import { getProjectDropdownQueryArgs } from '../prisma-query-args/dropdown.query import { projectDropdownTransformer } from '../transformers/dropdown.transformer.js'; import projectTransformer, { projectOverviewTransformer, - projectGanttTransformer, projectPreviewTransformer } from '../transformers/projects.transformer.js'; +import { ganttChartProjectTransformer } from '../transformers/gantt-chart.transformer.js'; import { validateChangeRequestAccepted } from '../utils/change-requests.utils.js'; import { AccessDeniedAdminOnlyException, @@ -38,11 +38,11 @@ import { wbsNumOf } from '../utils/utils.js'; import WorkPackagesService from './work-packages.services.js'; import { userHasPermission } from '../utils/users.utils.js'; import { - getProjectGanttQueryArgs, getProjectOverviewQueryArgs, getProjectPreviewQueryArgs, getProjectQueryArgs } from '../prisma-query-args/projects.query-args.js'; +import { getGanttChartProjectQueryArgs } from '../prisma-query-args/gantt-chart.query-args.js'; import { getLinkQueryArgs } from '../prisma-query-args/links.query-args.js'; import { getDescriptionBulletQueryArgs } from '../prisma-query-args/description-bullets.query-args.js'; @@ -51,16 +51,16 @@ export default class ProjectsService { * Get all the non deleted projects in the database for the given organization * @param organization the organization the user is currently in * @param carId optional car id to filter projects by - * @returns all the projects with query args for use in the gantt chart + * @returns all the projects, with only the fields the gantt chart uses */ - static async getAllProjectsGantt(organization: Organization, carId?: string): Promise { + static async getAllProjectsGantt(organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, ...(carId && { carId }) }, orderBy: [{ wbsElement: { carNumber: 'asc' } }, { wbsElement: { projectNumber: 'asc' } }], - ...getProjectGanttQueryArgs(organization.organizationId) + ...getGanttChartProjectQueryArgs() }); - return projects.map(projectGanttTransformer); + return projects.map(ganttChartProjectTransformer); } /** diff --git a/src/backend/src/transformers/gantt-chart.transformer.ts b/src/backend/src/transformers/gantt-chart.transformer.ts new file mode 100644 index 0000000000..18d99cc541 --- /dev/null +++ b/src/backend/src/transformers/gantt-chart.transformer.ts @@ -0,0 +1,74 @@ +import { Prisma } from '@prisma/client'; +import { + calculateEndDate, + calculateProjectStartDate, + EventStatus, + GanttChartEvent, + GanttChartProject, + GanttChartWorkPackage, + WorkPackageStage +} from 'shared'; +import { + GanttChartEventQueryArgs, + GanttChartProjectQueryArgs, + GanttChartWorkPackageQueryArgs +} from '../prisma-query-args/gantt-chart.query-args.js'; +import { convertStatus, wbsNumOf } from '../utils/utils.js'; +import { calculateProjectStatus } from '../utils/projects.utils.js'; +import taskTransformer from './tasks.transformer.js'; + +export const ganttChartEventTransformer = ( + event: Prisma.EventGetPayload, + wbsName: string +): GanttChartEvent => ({ + eventId: event.eventId, + // earliest scheduled slot, or fall back to initialDateScheduled (for confirmation events), or the current date + dateScheduled: event.scheduledTimes[0]?.startTime ?? event.initialDateScheduled ?? new Date(), + status: event.status as EventStatus, + wbsName +}); + +export const ganttChartWorkPackageTransformer = ( + workPackage: Prisma.Work_PackageGetPayload, + projectName: string +): GanttChartWorkPackage => { + const { wbsElement } = workPackage; + return { + id: workPackage.workPackageId, + wbsElementId: workPackage.wbsElementId, + projectId: workPackage.projectId, + wbsNum: wbsNumOf(wbsElement), + name: wbsElement.name, + status: convertStatus(wbsElement.status), + dateCreated: wbsElement.dateCreated, + deleted: wbsElement.dateDeleted !== null, + lead: wbsElement.lead ?? undefined, + manager: wbsElement.manager ?? undefined, + startDate: workPackage.startDate, + endDate: calculateEndDate(workPackage.startDate, workPackage.duration), + stage: (workPackage.stage as WorkPackageStage) || undefined, + blocking: wbsElement.blocking.map((blocked) => wbsNumOf(blocked.wbsElement)), + events: workPackage.events.map((event) => ganttChartEventTransformer(event, `${projectName} - ${wbsElement.name}`)) + }; +}; + +export const ganttChartProjectTransformer = ( + project: Prisma.ProjectGetPayload +): GanttChartProject => { + const { wbsElement } = project; + return { + id: project.projectId, + wbsElementId: project.wbsElementId, + wbsNum: wbsNumOf(wbsElement), + name: wbsElement.name, + status: calculateProjectStatus(project), + dateCreated: wbsElement.dateCreated, + deleted: !!wbsElement.dateDeleted, + lead: wbsElement.lead ?? undefined, + manager: wbsElement.manager ?? undefined, + startDate: calculateProjectStartDate(project.workPackages), + teams: project.teams, + tasks: wbsElement.tasks.map(taskTransformer), + workPackages: project.workPackages.map((workPackage) => ganttChartWorkPackageTransformer(workPackage, wbsElement.name)) + }; +}; diff --git a/src/backend/src/transformers/tasks.transformer.ts b/src/backend/src/transformers/tasks.transformer.ts index 6ed288b81e..df0d04b895 100644 --- a/src/backend/src/transformers/tasks.transformer.ts +++ b/src/backend/src/transformers/tasks.transformer.ts @@ -21,7 +21,7 @@ export const taskBlockedByTransformer = (task: Prisma.TaskGetPayload, 'workPackage'> + wbsElement: Partial, 'workPackage'>> ): BlockingWorkPackagePreview[] => (wbsElement.workPackage?.blockedBy ?? []) .filter((blocker) => blocker.tasks.some((t) => t.status !== 'DONE')) @@ -43,9 +43,13 @@ export const getActiveTaskBlockerNames = ( ...getBlockingWorkPackagePreviews(wbsElement).map((wp) => wp.name) ]; -// typed against the gantt shape plus an optional deletedBy (the gantt doesn't fetch it); the full TaskQueryArgs payload also fits +// typed against the gantt shape plus the optional deletedBy and wbsElement.workPackage the gantt doesn't fetch; +// the full TaskQueryArgs payload also fits export const taskTransformer = ( - task: Prisma.TaskGetPayload & Partial, 'deletedBy'>> + task: Prisma.TaskGetPayload & + Partial, 'deletedBy'>> & { + wbsElement: Partial, 'workPackage'>>; + } ): Task => { const wbsNum = wbsNumOf(task.wbsElement); return { diff --git a/src/frontend/src/apis/projects.api.ts b/src/frontend/src/apis/projects.api.ts index b6f2cc8b82..3a78c8c195 100644 --- a/src/frontend/src/apis/projects.api.ts +++ b/src/frontend/src/apis/projects.api.ts @@ -12,7 +12,7 @@ import { Project, WbsNumber, WorkPackageTemplate, - ProjectGantt, + GanttChartProject, ProjectPreview, ProjectOverview } from 'shared'; @@ -22,7 +22,8 @@ import { projectPreviewTransformer, projectTransformer, projectOverviewTransformer, - projectGanttTransformer + projectGanttTransformer, + ganttChartProjectTransformer } from './transformers/projects.transformers'; import { CreateSingleProjectPayload, EditSingleProjectPayload } from '../utils/types'; @@ -32,8 +33,8 @@ import { CreateSingleProjectPayload, EditSingleProjectPayload } from '../utils/t * so we bypass the global car filter using overrideCarId: 'all-cars' */ export const getAllProjectsGantt = () => { - return axios.get(apiUrls.allProjectsGantt(), { - transformResponse: (data) => JSON.parse(data).map(projectGanttTransformer), + return axios.get(apiUrls.allProjectsGantt(), { + transformResponse: (data) => JSON.parse(data).map(ganttChartProjectTransformer), overrideCarId: 'all-cars' }); }; diff --git a/src/frontend/src/apis/transformers/projects.transformers.ts b/src/frontend/src/apis/transformers/projects.transformers.ts index 78ef0f5cd7..8f6060df1f 100644 --- a/src/frontend/src/apis/transformers/projects.transformers.ts +++ b/src/frontend/src/apis/transformers/projects.transformers.ts @@ -6,6 +6,7 @@ import { dbDateToLocalDate, DescriptionBullet, + GanttChartProject, Project, ProjectOverview, ProjectGantt, @@ -14,7 +15,11 @@ import { } from 'shared'; import { implementedChangeTransformer } from './change-requests.transformers'; import { taskTransformer } from './tasks.transformers'; -import { retrospectiveWorkPackageTransformer, workPackageTransformer } from './work-packages.transformers'; +import { + ganttChartWorkPackageTransformer, + retrospectiveWorkPackageTransformer, + workPackageTransformer +} from './work-packages.transformers'; /** * Transforms a description bullet to ensure deep field transformation of date objects. @@ -61,6 +66,24 @@ export const retrospectiveProjectPreviewTransformer = ( }; }; +/** + * Transforms a gantt chart project to ensure deep field transformation of date objects. + * Generic so re-parsing a richer project (e.g. one created locally on the gantt) keeps its other fields. + * + * @param project Incoming gantt chart project object supplied by the HTTP response. + * @returns Properly transformed gantt chart project object. + */ +export const ganttChartProjectTransformer = (project: T): T => { + return { + ...project, + dateCreated: new Date(project.dateCreated), + startDate: project.startDate ? new Date(project.startDate) : undefined, + endDate: project.endDate ? new Date(project.endDate) : undefined, + workPackages: project.workPackages.map(ganttChartWorkPackageTransformer), + tasks: project.tasks.map(taskTransformer) + }; +}; + export const projectGanttTransformer = (project: ProjectGantt): ProjectGantt => { return { ...project, diff --git a/src/frontend/src/apis/transformers/work-packages.transformers.ts b/src/frontend/src/apis/transformers/work-packages.transformers.ts index 6c2a9fc04d..0616d8463f 100644 --- a/src/frontend/src/apis/transformers/work-packages.transformers.ts +++ b/src/frontend/src/apis/transformers/work-packages.transformers.ts @@ -3,11 +3,28 @@ * See the LICENSE file in the repository root folder for details. */ -import { dbDateToLocalDate, RetrospectiveWorkPackage, WorkPackage, WorkPackagePreview } from 'shared'; +import { dbDateToLocalDate, GanttChartWorkPackage, RetrospectiveWorkPackage, WorkPackage, WorkPackagePreview } from 'shared'; import { implementedChangeTransformer } from './change-requests.transformers'; import { descriptionBulletTransformer } from './projects.transformers'; import { eventPreviewTransformer } from './calendar.transformer'; +/** + * Transforms a gantt chart work package to ensure deep field transformation of date objects. + * Generic so re-parsing a richer work package (e.g. one created locally on the gantt) keeps its other fields. + * + * @param workPackage Incoming gantt chart work package object supplied by the HTTP response. + * @returns Properly transformed gantt chart work package object. + */ +export const ganttChartWorkPackageTransformer = (workPackage: T): T => { + return { + ...workPackage, + dateCreated: new Date(workPackage.dateCreated), + startDate: dbDateToLocalDate(new Date(workPackage.startDate)), + endDate: dbDateToLocalDate(new Date(workPackage.endDate)), + events: workPackage.events.map((event) => ({ ...event, dateScheduled: new Date(event.dateScheduled) })) + }; +}; + /** * Transforms a work package to ensure deep field transformation of date objects. * diff --git a/src/frontend/src/hooks/projects.hooks.ts b/src/frontend/src/hooks/projects.hooks.ts index 5f49d5a89c..455abb3da3 100644 --- a/src/frontend/src/hooks/projects.hooks.ts +++ b/src/frontend/src/hooks/projects.hooks.ts @@ -11,7 +11,7 @@ import { LinkTypeCreatePayload, Project, ProjectOverview, - ProjectGantt, + GanttChartProject, ProjectPreview, WbsNumber, WorkPackageTemplate @@ -46,7 +46,7 @@ import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; * Custom React Hook to supply all projects with Gantt querry args */ export const useAllProjectsGantt = () => { - return useQuery(['projects', 'gantt-all'], async () => { + return useQuery(['projects', 'gantt-all'], async () => { const { data } = await getAllProjectsGantt(); return data; }); diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx index aa7abab49f..61febed7d2 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { yupResolver } from '@hookform/resolvers/yup'; import { Autocomplete, Box, Chip, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField } from '@mui/material'; import { Controller, useForm } from 'react-hook-form'; -import { countWords, isUnderWordCount, TaskLabel, TaskPriority, TaskStatus, WorkPackage, WbsNumber } from 'shared'; +import { countWords, isUnderWordCount, GanttChartWorkPackage, TaskLabel, TaskPriority, TaskStatus, WbsNumber } from 'shared'; import * as yup from 'yup'; import NERFormModal from '../../../components/NERFormModal'; import { useAllMembers } from '../../../hooks/users.hooks'; @@ -47,7 +47,7 @@ interface AddGanttTaskModalProps { showModal: boolean; handleClose: () => void; addTask: (task: CreateTaskFormData) => void; - workPackages: WorkPackage[]; + workPackages: GanttChartWorkPackage[]; } const AddGanttTaskModal: React.FC = ({ showModal, handleClose, addTask, workPackages }) => { diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx index f38bf977aa..da747805c7 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx @@ -1,5 +1,5 @@ import { Box, Typography } from '@mui/material'; -import { dateToMidnightUTC, ProjectGantt } from 'shared'; +import { dateToMidnightUTC, GanttChartProject } from 'shared'; import dayjs from 'dayjs'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import { useToast } from '../../../../hooks/toasts.hooks'; @@ -19,7 +19,7 @@ export const GanttProjectCreateModal = ({ change, handleClose, open }: GanttProj const { isLoading, mutateAsync: createProject } = useCreateSingleProject(); const { isLoading: workPackageIsLoading, mutateAsync: createWorkPackage } = useCreateSingleWorkPackage(); const { isLoading: isLoadingTaskCreate, mutateAsync: createSingleTask } = useCreateTask(); - const project = change.element as ProjectGantt; + const project = change.element as GanttChartProject; const startDate = getProjectStartDate(project); const latestEndDate = getProjectEndDate(project); @@ -49,8 +49,9 @@ export const GanttProjectCreateModal = ({ change, handleClose, open }: GanttProj startDate: dateToMidnightUTC(workPackage.startDate).toISOString(), duration: dayjs(workPackage.endDate).diff(dayjs(workPackage.startDate), 'week'), crId: undefined, - blockedBy: workPackage.blockedBy, - descriptionBullets: workPackage.descriptionBullets, + // this project and its work packages were all just created on the gantt, so they have no blockers or bullets yet + blockedBy: [], + descriptionBullets: [], stage: workPackage.stage ?? 'NONE' })); diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx index 545034229a..5146067e93 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx @@ -1,5 +1,5 @@ import { Box, TextField, Typography } from '@mui/material'; -import { dateToMidnightUTC, ProjectGantt, Task, WbsElementPreview, WorkPackage } from 'shared'; +import { dateToMidnightUTC, GanttChartProject, GanttChartWorkPackage, Task, WbsElementPreview } from 'shared'; import { useState } from 'react'; import dayjs from 'dayjs'; import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../../hooks/change-requests.hooks'; @@ -47,10 +47,10 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim return `${dayjs(startDate).format('MMMM D, YYYY')} - ${dayjs(endDate).format('MMMM D, YYYY')}`; }; - const project = change.element as ProjectGantt; + const project = change.element as GanttChartProject; - const editedWorkPackages: WorkPackage[] = []; - const createdWorkPackages: WorkPackage[] = []; + const editedWorkPackages: GanttChartWorkPackage[] = []; + const createdWorkPackages: GanttChartWorkPackage[] = []; project.workPackages.forEach((workPackage) => { if (workPackage.wbsElementId === '-1') { createdWorkPackages.push(workPackage); diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index d079a2a2e6..422c3d92d2 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -30,7 +30,8 @@ import GanttChartColorLegend from './GanttChartColorLegend'; import GanttChartFiltersButton from './GanttChartFiltersButton'; import GanttChart from '../GanttChart/GanttChart'; import { - ProjectGantt, + GanttChartProject, + GanttChartWorkPackage, Task, TaskLabel, TaskPriority, @@ -40,7 +41,6 @@ import { WbsElementPreview, WbsElementStatus, wbsPipe, - WorkPackage, WorkPackageStage, WbsNumber } from 'shared'; @@ -55,7 +55,7 @@ import { GanttRequestChangeModal } from './ProjectGanttChangeModals/GanttRequest import { useToast } from '../../../hooks/toasts.hooks'; import { v4 as uuidv4 } from 'uuid'; import { projectWbsPipe } from '../../../utils/pipes'; -import { projectGanttTransformer } from '../../../apis/transformers/projects.transformers'; +import { ganttChartProjectTransformer } from '../../../apis/transformers/projects.transformers'; import { useCurrentUser } from '../../../hooks/users.hooks'; const getElementId = (element: WbsElementPreview | Task) => { @@ -83,18 +83,18 @@ const ProjectGanttChartPage: FC = () => { const { selectedCar, allCars, isLoading: carFilterLoading } = useGlobalCarFilter(); const { isLoading: teamsIsLoading, isError: teamsIsError, data: teams, error: teamsError } = useAllTeams(); const [searchText, setSearchText] = useState(''); - const [addedProjects, setAddedProjects] = useState([]); + const [addedProjects, setAddedProjects] = useState([]); const [showAddProjectModal, setShowAddProjectModal] = useState(false); const [showAddWorkPackageModal, setShowAddWorkPackageModal] = useState(false); const [showAddTaskModal, setShowAddTaskModal] = useState(false); const [showSelectionModal, setShowSelectionModal] = useState(false); const [ganttChanges, setGanttChanges] = useState[]>([]); const [requestEventChanges, setRequestEventChanges] = useState[]>([]); - const [selectedProject, setSelectedProject] = useState(undefined); + const [selectedProject, setSelectedProject] = useState(undefined); const [selectedTeam, setSelectedTeam] = useState(undefined); const [collections, setCollections] = useState[]>([]); - const [allProjects, setAllProjects] = useState([]); - const [editedProjects, setEditedProjects] = useState([]); + const [allProjects, setAllProjects] = useState([]); + const [editedProjects, setEditedProjects] = useState([]); const user = useCurrentUser(); /******************** Filters ***************************/ const { filters, setFilters } = useGanttFilters('project-gantt'); @@ -108,15 +108,15 @@ const ProjectGanttChartPage: FC = () => { useEffect(() => { const requestRefresh = ( - projects: ProjectGantt[], + projects: GanttChartProject[], teams: TeamPreview[], - editedProjects: ProjectGantt[], - addedProjects: ProjectGantt[], + editedProjects: GanttChartProject[], + addedProjects: GanttChartProject[], filters: GanttFilters, searchText: string ) => { - let allProjects: ProjectGantt[] = JSON.parse(JSON.stringify(projects.concat(addedProjects))).map( - projectGanttTransformer + let allProjects: GanttChartProject[] = JSON.parse(JSON.stringify(projects.concat(addedProjects))).map( + ganttChartProjectTransformer ); allProjects = allProjects.map((project) => { @@ -131,7 +131,7 @@ const ProjectGanttChartPage: FC = () => { filters, searchText, transformProjectToGanttTask, - projectGanttTransformer + ganttChartProjectTransformer ) ); }; @@ -266,34 +266,26 @@ const ProjectGanttChartPage: FC = () => { const handleAddWorkPackageInfo = ( workPackageInfo: { name: string; stage?: WorkPackageStage }, - parentProject: ProjectGantt + parentProject: GanttChartProject ) => { const newWorkPackageNumber = parentProject.workPackages.length + 1; const id = uuidv4(); - const workPackage: WorkPackage = { + const workPackage: GanttChartWorkPackage = { id, projectId: parentProject.id, name: workPackageInfo.name, startDate: new Date(), endDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), - blockedBy: [], wbsNum: { carNumber: parentProject.wbsNum.carNumber, projectNumber: parentProject.wbsNum.projectNumber, workPackageNumber: newWorkPackageNumber }, stage: workPackageInfo.stage, - projectName: parentProject.name, status: WbsElementStatus.Inactive, - orderInProject: newWorkPackageNumber, - duration: 1, blocking: [], - descriptionBullets: [], - links: [], wbsElementId: '-1', dateCreated: new Date(), - teamTypes: [], - changes: [], events: [], deleted: false }; @@ -334,7 +326,7 @@ const ProjectGanttChartPage: FC = () => { deadline: Date | null; wpWbsNum?: WbsNumber; }, - parentProject: ProjectGantt + parentProject: GanttChartProject ) => { const taskId = uuidv4(); @@ -382,7 +374,7 @@ const ProjectGanttChartPage: FC = () => { projectInfo: { name: string; carNumber: number }, selectedTeam: { teamId: string; teamName: string } ) => { - const mockProject: ProjectGantt = { + const mockProject: GanttChartProject = { id: uuidv4(), name: projectInfo.name, wbsNum: { @@ -394,9 +386,7 @@ const ProjectGanttChartPage: FC = () => { workPackages: [], deleted: false, tasks: [], - budget: 0, teams: [selectedTeam], - duration: 1, wbsElementId: '-1', dateCreated: new Date() }; @@ -405,7 +395,7 @@ const ProjectGanttChartPage: FC = () => { addNewProjectHandler(mockProject); // Create a RequestEventChange for the modal system - const requestChange: RequestEventChange = { + const requestChange: RequestEventChange = { changeId: uuidv4(), element: mockProject, newStart: new Date(), @@ -537,11 +527,11 @@ const ProjectGanttChartPage: FC = () => { } }; - const addNewProjectHandler = (project: ProjectGantt) => { + const addNewProjectHandler = (project: GanttChartProject) => { setAddedProjects((prev) => [...prev, project]); }; - const addNewWorkPackageHandler = (workPackage: WorkPackage) => { + const addNewWorkPackageHandler = (workPackage: GanttChartWorkPackage) => { const editedParentProject = editedProjects.find((project) => project.id === workPackage.projectId); // check for an already edited project if (editedParentProject) { editedParentProject.workPackages.push(workPackage); @@ -555,7 +545,7 @@ const ProjectGanttChartPage: FC = () => { const originalProject = projects.find((project) => project.id === workPackage.projectId); // Check for an unedited original project if (originalProject) { - const copy = projectGanttTransformer(JSON.parse(JSON.stringify(originalProject))); // Need to maintain integrity of original projects + const copy = ganttChartProjectTransformer(JSON.parse(JSON.stringify(originalProject))); // Need to maintain integrity of original projects copy.workPackages.push(workPackage); setEditedProjects((prev) => [...prev, copy]); } @@ -577,7 +567,7 @@ const ProjectGanttChartPage: FC = () => { const originalProject = projects.find((project) => project.id === projectId); // Check for an unedited original project if (originalProject) { - const copy = projectGanttTransformer(JSON.parse(JSON.stringify(originalProject))); // Need to maintain integrity of original projects + const copy = ganttChartProjectTransformer(JSON.parse(JSON.stringify(originalProject))); // Need to maintain integrity of original projects copy.tasks.push(task); setEditedProjects((prev) => [...prev, copy]); } diff --git a/src/frontend/src/utils/gantt.utils.tsx b/src/frontend/src/utils/gantt.utils.tsx index 9ae388f9bb..a0c1c7acb7 100644 --- a/src/frontend/src/utils/gantt.utils.tsx +++ b/src/frontend/src/utils/gantt.utils.tsx @@ -5,34 +5,34 @@ import { addWeeksToDate, - EventPreview, EventStatus, + GanttChartEvent, + GanttChartProject, + GanttChartWorkPackage, isWorkPackage, - ProjectGantt, RetrospectiveProjectPreview, RetrospectiveWorkPackage, Task, TaskPriority, TaskStatus, TeamPreview, - User, + UserPreview, validateWBS, WbsElementPreview, WbsElementStatus, WbsNumber, wbsPipe, - WorkPackage, WorkPackageStage } from 'shared'; import { fullNamePipe, projectWbsPipe } from './pipes'; import dayjs from 'dayjs'; import { deepOrange, green, grey, indigo, orange, pink } from '@mui/material/colors'; -import { projectGanttTransformer } from '../apis/transformers/projects.transformers'; +import { ganttChartProjectTransformer } from '../apis/transformers/projects.transformers'; import { ReactNode, useEffect, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { Typography, useTheme } from '@mui/material'; import { routes } from './routes'; -import { workPackageTransformer } from '../apis/transformers/work-packages.transformers'; +import { ganttChartWorkPackageTransformer } from '../apis/transformers/work-packages.transformers'; import { useHistory } from 'react-router-dom'; import { useQuery } from '../hooks/utils.hooks'; @@ -133,7 +133,7 @@ export type RequestEventChange = { type: 'create-task' | 'edit-task'; }; -export const getProjectStartDate = (project: ProjectGantt): Date => { +export const getProjectStartDate = (project: GanttChartProject): Date => { const candidates: Date[] = [ ...project.workPackages.map((wp) => wp.startDate), ...project.tasks.map((t) => t.startDate).filter((d): d is Date => !!d) @@ -142,7 +142,7 @@ export const getProjectStartDate = (project: ProjectGantt): Date => { return new Date(Math.min(...candidates.map((d) => d.valueOf()))); }; -export const getProjectEndDate = (project: ProjectGantt): Date => { +export const getProjectEndDate = (project: GanttChartProject): Date => { const candidates: Date[] = [ ...project.workPackages.map((wp) => wp.endDate), ...project.tasks.map((t) => t.deadline).filter((d): d is Date => !!d) @@ -151,7 +151,7 @@ export const getProjectEndDate = (project: ProjectGantt): Date => { return new Date(Math.max(...candidates.map((d) => d.valueOf()))); }; -export const transformDesignReviewEventToGanttEvent = (event: EventPreview): GanttEvent => { +export const transformDesignReviewEventToGanttEvent = (event: GanttChartEvent): GanttEvent => { return { date: event.dateScheduled, color: ganttDesignReviewEventStatusColorPipe(event.status), @@ -167,8 +167,8 @@ export const transformDesignReviewEventToGanttEvent = (event: EventPreview): Gan * @param changeToApply The change to apply to all the blocked work package */ const applyChangesToBlockedBy = ( - initialWorkPackage: WorkPackage, - totalWorkPackages: WorkPackage[], + initialWorkPackage: GanttChartWorkPackage, + totalWorkPackages: GanttChartWorkPackage[], changeToApply: GanttChange ) => { const updatedBlockingWbsNums: Set = new Set(); @@ -214,10 +214,10 @@ const applyChangesToBlockedBy = ( export const applyChangesToWBSElement = ( ganttChanges: GanttChange[], wbsElement: WbsElementPreview | Task, - parentProject: ProjectGantt -): { updatedProject: ProjectGantt; updatedElement: WbsElementPreview | Task } => { + parentProject: GanttChartProject +): { updatedProject: GanttChartProject; updatedElement: WbsElementPreview | Task } => { const updatedElement = { ...wbsElement }; - const copiedProject = projectGanttTransformer(JSON.parse(JSON.stringify(parentProject))); + const copiedProject: GanttChartProject = ganttChartProjectTransformer(JSON.parse(JSON.stringify(parentProject))); // Check if it's a Task if ((updatedElement as Task).taskId !== undefined) { @@ -248,7 +248,7 @@ export const applyChangesToWBSElement = ( if ((updatedElement as WbsElementPreview).wbsNum !== undefined && isWorkPackage(updatedElement as WbsElementPreview)) { // If its a work package were gonna loop through and see if we need to apply changes - const workPackage = workPackageTransformer(JSON.parse(JSON.stringify(updatedElement))); + const workPackage: GanttChartWorkPackage = ganttChartWorkPackageTransformer(JSON.parse(JSON.stringify(updatedElement))); for (const change of ganttChanges) { if (wbsPipe(change.element.wbsNum) === wbsPipe(wbsElement.wbsNum)) { // If the change is for this work package then were gonna apply it @@ -291,14 +291,14 @@ export interface GanttTask extends GanttTaskData {} * @param searchText The search text to apply * @param team The team the projects are on */ -export const filterGanttProjects = ( +export const filterGanttProjects = ( projects: T[], ganttFilters: GanttFilters, searchText: string, team: TeamPreview, reparser: (project: T) => T ) => { - let deepCopy: ProjectGantt[] = JSON.parse(JSON.stringify(projects)).map(reparser); + let deepCopy: GanttChartProject[] = JSON.parse(JSON.stringify(projects)).map(reparser); // Show only projects on this team deepCopy = deepCopy.filter((project) => project.teams.some((projectTeam) => projectTeam.teamId === team.teamId)); @@ -377,7 +377,7 @@ export const buildGanttSearchParams = (ganttFilters: GanttFilters, additionalPar return newParams; }; -const UserDisplay = ({ user, label }: { user?: User; label: string }) => { +const UserDisplay = ({ user, label }: { user?: UserPreview; label: string }) => { const theme = useTheme(); return ( @@ -386,7 +386,7 @@ const UserDisplay = ({ user, label }: { user?: User; label: string }) => { ); }; -const getBlockingGanttTasks = ( +const getBlockingGanttTasks = ( workPackage: T, allWorkPackages: T[], transformation: (wp: T, all: T[]) => GanttTaskData @@ -429,7 +429,7 @@ export const transformTaskToGanttTask = (task: T, end: Date): Ga }; }; -export const transformWorkPackageToGanttTask = ( +export const transformWorkPackageToGanttTask = ( workPackage: T, allWorkPackages: T[] ): GanttTask => { @@ -460,7 +460,7 @@ export const transformWorkPackageToGanttTask = ( }; export const transformProjectToGanttTask = ( - project: ProjectGantt, + project: GanttChartProject, hideTasks: boolean = false ): GanttTask => { const startDate = getProjectStartDate(project); @@ -525,7 +525,7 @@ export const transformRetrospectiveWorkPackageToGanttTask = ( }; }; -export const constructCollectionsFromTeamPreviewAndProjects = ( +export const constructCollectionsFromTeamPreviewAndProjects = ( teams: TeamPreview[], projects: T[], filters: GanttFilters, @@ -533,7 +533,7 @@ export const constructCollectionsFromTeamPreviewAndProjects = GanttTaskData, reparser: (project: T) => T ): GanttCollection[] => { - const projectMap = new Map(); + const projectMap = new Map(); projects.forEach((project) => { project.teams.forEach((team) => { if (projectMap.has(team.teamId)) { @@ -659,8 +659,8 @@ export const isHighlightedChangeOnGanttTask = ( }; export const constructFinalizedChanges = ( - originalProjects: ProjectGantt[], - updatedProjects: ProjectGantt[], + originalProjects: GanttChartProject[], + updatedProjects: GanttChartProject[], changes: GanttChange[] ) => { const aggregatedSet: Set = new Set(); @@ -701,7 +701,7 @@ export const constructFinalizedChanges = ( return eventChanges; }; -export const isProjectPreview = (wbsPreview: WbsElementPreview | Task): wbsPreview is ProjectGantt => { +export const isProjectPreview = (wbsPreview: WbsElementPreview | Task): wbsPreview is GanttChartProject => { return 'workPackages' in wbsPreview; }; diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 9694ad371b..0ec7115270 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -74,6 +74,30 @@ export interface ProjectGantt extends WbsElementPreview { manager?: User; } +/** + * The base gantt chart shapes: only what the gantt chart reads. Full ProjectGantt / WorkPackage / + * RetrospectiveProjectPreview objects also satisfy them, so the gantt helpers serve every gantt view, + * and views that need more (e.g. retrospective) extend them. + */ +export type GanttChartEvent = Pick; + +export interface GanttChartWorkPackage extends WbsElementPreview { + projectId: string; + startDate: Date; + endDate: Date; + stage?: WorkPackageStage; + blocking: WbsNumber[]; + events: GanttChartEvent[]; +} + +export interface GanttChartProject extends WbsElementPreview { + startDate?: Date; + endDate?: Date; + teams: { teamId: string }[]; + workPackages: GanttChartWorkPackage[]; + tasks: Task[]; +} + export interface ProjectPreview extends WbsElementPreview { startDate?: Date; endDate?: Date; From 55482329d079b1104339f4350104e62955d40031 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 15 Oct 2026 18:44:38 -0400 Subject: [PATCH 09/13] slim task type --- .../gantt-chart.query-args.ts | 20 ++++++- .../transformers/gantt-chart.transformer.ts | 17 +++++- .../transformers/projects.transformers.ts | 4 +- .../apis/transformers/tasks.transformers.ts | 17 +++++- .../GanttProjectCreateModal.tsx | 6 +- .../GanttRequestChangeModal.tsx | 4 +- .../GanttTimeLineChangeModal.tsx | 55 ++++++++++--------- .../ProjectGanttChartPage.tsx | 35 ++++++------ .../pages/RetrospectivePage/Retrospective.tsx | 4 +- src/frontend/src/utils/gantt.utils.tsx | 38 ++++++++----- src/frontend/src/utils/pipes.ts | 2 +- src/shared/src/types/project-types.ts | 16 +++++- 12 files changed, 147 insertions(+), 71 deletions(-) diff --git a/src/backend/src/prisma-query-args/gantt-chart.query-args.ts b/src/backend/src/prisma-query-args/gantt-chart.query-args.ts index 492351a2d0..645d76fba5 100644 --- a/src/backend/src/prisma-query-args/gantt-chart.query-args.ts +++ b/src/backend/src/prisma-query-args/gantt-chart.query-args.ts @@ -1,15 +1,31 @@ import { Prisma } from '@prisma/client'; import { getUserPreviewQueryArgs } from './user.query-args.js'; -import { getTaskGanttQueryArgs } from './tasks.query-args.js'; // Base gantt chart query: only what the gantt chart reads (see the GanttChart* types in shared). // Other gantt views (e.g. retrospective) can extend these instead of starting from the full project query. +export type GanttChartTaskQueryArgs = ReturnType; export type GanttChartEventQueryArgs = ReturnType; export type GanttChartWorkPackageQueryArgs = ReturnType; export type GanttChartProjectQueryArgs = ReturnType; const wbsNumberSelect = { carNumber: true, projectNumber: true, workPackageNumber: true } as const; +const userNameSelect = { select: { firstName: true, lastName: true } } as const; + +// a task bar and its tooltip; saving an edited task reads its other fields from the full project +export const getGanttChartTaskQueryArgs = () => + Prisma.validator()({ + select: { + taskId: true, + title: true, + status: true, + startDate: true, + deadline: true, + wbsElement: { select: wbsNumberSelect }, + createdBy: userNameSelect, + assignees: userNameSelect + } + }); export const getGanttChartEventQueryArgs = () => Prisma.validator()({ @@ -63,7 +79,7 @@ export const getGanttChartProjectQueryArgs = () => tasks: { where: { dateDeleted: null }, orderBy: [{ dateCreated: 'asc' }, { taskId: 'asc' }], - ...getTaskGanttQueryArgs() + ...getGanttChartTaskQueryArgs() } } }, diff --git a/src/backend/src/transformers/gantt-chart.transformer.ts b/src/backend/src/transformers/gantt-chart.transformer.ts index 18d99cc541..defa87a969 100644 --- a/src/backend/src/transformers/gantt-chart.transformer.ts +++ b/src/backend/src/transformers/gantt-chart.transformer.ts @@ -5,17 +5,30 @@ import { EventStatus, GanttChartEvent, GanttChartProject, + GanttChartTask, GanttChartWorkPackage, WorkPackageStage } from 'shared'; import { GanttChartEventQueryArgs, GanttChartProjectQueryArgs, + GanttChartTaskQueryArgs, GanttChartWorkPackageQueryArgs } from '../prisma-query-args/gantt-chart.query-args.js'; import { convertStatus, wbsNumOf } from '../utils/utils.js'; import { calculateProjectStatus } from '../utils/projects.utils.js'; -import taskTransformer from './tasks.transformer.js'; +import { convertTaskStatus } from '../utils/tasks.utils.js'; + +export const ganttChartTaskTransformer = (task: Prisma.TaskGetPayload): GanttChartTask => ({ + taskId: task.taskId, + wbsNum: wbsNumOf(task.wbsElement), + title: task.title, + status: convertTaskStatus(task.status), + startDate: task.startDate ?? undefined, + deadline: task.deadline ?? undefined, + createdBy: task.createdBy, + assignees: task.assignees +}); export const ganttChartEventTransformer = ( event: Prisma.EventGetPayload, @@ -68,7 +81,7 @@ export const ganttChartProjectTransformer = ( manager: wbsElement.manager ?? undefined, startDate: calculateProjectStartDate(project.workPackages), teams: project.teams, - tasks: wbsElement.tasks.map(taskTransformer), + tasks: wbsElement.tasks.map(ganttChartTaskTransformer), workPackages: project.workPackages.map((workPackage) => ganttChartWorkPackageTransformer(workPackage, wbsElement.name)) }; }; diff --git a/src/frontend/src/apis/transformers/projects.transformers.ts b/src/frontend/src/apis/transformers/projects.transformers.ts index 8f6060df1f..4daa7565cb 100644 --- a/src/frontend/src/apis/transformers/projects.transformers.ts +++ b/src/frontend/src/apis/transformers/projects.transformers.ts @@ -14,7 +14,7 @@ import { RetrospectiveProjectPreview } from 'shared'; import { implementedChangeTransformer } from './change-requests.transformers'; -import { taskTransformer } from './tasks.transformers'; +import { ganttChartTaskTransformer, taskTransformer } from './tasks.transformers'; import { ganttChartWorkPackageTransformer, retrospectiveWorkPackageTransformer, @@ -80,7 +80,7 @@ export const ganttChartProjectTransformer = (projec startDate: project.startDate ? new Date(project.startDate) : undefined, endDate: project.endDate ? new Date(project.endDate) : undefined, workPackages: project.workPackages.map(ganttChartWorkPackageTransformer), - tasks: project.tasks.map(taskTransformer) + tasks: project.tasks.map(ganttChartTaskTransformer) }; }; diff --git a/src/frontend/src/apis/transformers/tasks.transformers.ts b/src/frontend/src/apis/transformers/tasks.transformers.ts index c0a8d7e854..66ac71b566 100644 --- a/src/frontend/src/apis/transformers/tasks.transformers.ts +++ b/src/frontend/src/apis/transformers/tasks.transformers.ts @@ -1,4 +1,19 @@ -import { dbDateToLocalDate, Task, TaskLabel } from 'shared'; +import { dbDateToLocalDate, GanttChartTask, Task, TaskLabel } from 'shared'; + +/** + * Transforms a gantt chart task to ensure deep field transformation of date objects. + * Generic so re-parsing a richer task (e.g. one created locally on the gantt) keeps its other fields. + * + * @param task Incoming gantt chart task object supplied by the HTTP response. + * @returns Properly transformed gantt chart task object. + */ +export const ganttChartTaskTransformer = (task: T): T => { + return { + ...task, + deadline: task.deadline ? dbDateToLocalDate(new Date(task.deadline)) : undefined, + startDate: task.startDate ? dbDateToLocalDate(new Date(task.startDate)) : undefined + }; +}; /** * Transforms a task to ensure deep field transformation of date objects. diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx index da747805c7..d6e685e71f 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import { useToast } from '../../../../hooks/toasts.hooks'; import { NERDraggableFormModal } from '../../../../components/NERDraggableFormModal'; -import { getProjectStartDate, getProjectEndDate } from '../../../../utils/gantt.utils'; +import { getProjectStartDate, getProjectEndDate, isCreatableTask } from '../../../../utils/gantt.utils'; import { useCreateSingleProject } from '../../../../hooks/projects.hooks'; import { CreateSingleProjectPayload } from '../../../../utils/types'; import { WorkPackageApiInputs } from '../../../../apis/work-packages.api'; @@ -66,6 +66,10 @@ export const GanttProjectCreateModal = ({ change, handleClose, open }: GanttProj if (project.tasks && project.tasks.length > 0) { for (const task of project.tasks) { + if (!isCreatableTask(task)) { + toast.error(`Failed to create task: ${task.title}`); + continue; + } try { await createSingleTask({ wbsNum: task.wbsNum, diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttRequestChangeModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttRequestChangeModal.tsx index b9c006d78e..7452b6c4b0 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttRequestChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttRequestChangeModal.tsx @@ -1,10 +1,10 @@ -import { Task, WbsElementPreview } from 'shared'; +import { GanttChartTask, WbsElementPreview } from 'shared'; import { RequestEventChange } from '../../../../utils/gantt.utils'; import { GanttProjectCreateModal } from './GanttProjectCreateModal'; import { GanttTimeLineChangeModal } from './GanttTimeLineChangeModal'; export interface GanttRequestChangeModalProps { - change: RequestEventChange; + change: RequestEventChange; handleClose: (didCancel: boolean) => void; open: boolean; } diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx index 5146067e93..a80a87e97c 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx @@ -1,5 +1,13 @@ import { Box, TextField, Typography } from '@mui/material'; -import { dateToMidnightUTC, GanttChartProject, GanttChartWorkPackage, Task, WbsElementPreview } from 'shared'; +import { + dateToMidnightUTC, + GanttChartProject, + GanttChartTask, + GanttChartWorkPackage, + Task, + WbsElementPreview +} from 'shared'; +import { isCreatableTask } from '../../../../utils/gantt.utils'; import { useState } from 'react'; import dayjs from 'dayjs'; import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../../hooks/change-requests.hooks'; @@ -66,26 +74,19 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim } }); - const editedTasks: Task[] = []; - const createdTasks: Task[] = []; + // the gantt can only move or resize an existing task, so only its dates can differ. Saving sends the rest of the task + // from the freshly fetched project, so an edit made elsewhere since the gantt loaded isn't overwritten + const editedTasks: { task: GanttChartTask; existingTask: Task }[] = []; + const createdTasks: GanttChartTask[] = []; project.tasks?.forEach((task) => { const existingTask = originalProject.tasks.find((t) => t.taskId === task.taskId); if (!existingTask) { createdTasks.push(task); - } else { - const hasChanges = - existingTask.title !== task.title || - existingTask.notes !== task.notes || - existingTask.priority !== task.priority || - existingTask.status !== task.status || - existingTask.startDate?.getTime() !== task.startDate?.getTime() || - existingTask.deadline?.getTime() !== task.deadline?.getTime() || - existingTask.assignees.length !== task.assignees.length || - existingTask.assignees.some((assignee) => !task.assignees.some((a) => a.userId === assignee.userId)); - - if (hasChanges) { - editedTasks.push(task); - } + } else if ( + existingTask.startDate?.getTime() !== task.startDate?.getTime() || + existingTask.deadline?.getTime() !== task.deadline?.getTime() + ) { + editedTasks.push({ task, existingTask }); } }); @@ -146,6 +147,10 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim if (createdTasks.length > 0) { for (const task of createdTasks) { + if (!isCreatableTask(task)) { + toast.error(`Failed to create task: ${task.title}`); + continue; + } const taskPayload: CreateTaskPayload = { wbsNum: task.wbsNum, title: task.title, @@ -170,15 +175,15 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim } if (editedTasks.length > 0) { - for (const task of editedTasks) { + for (const { task, existingTask } of editedTasks) { const taskPayload: TaskPayload = { - wbsNum: task.wbsNum, - taskId: task.taskId, - title: task.title, - priority: task.priority, - notes: task.notes || '', - labelIds: task.labels.map((l) => l.taskLabelId), - blockedByIds: task.blockedBy.map((b) => b.taskId), + wbsNum: existingTask.wbsNum, + taskId: existingTask.taskId, + title: existingTask.title, + priority: existingTask.priority, + notes: existingTask.notes || '', + labelIds: existingTask.labels.map((l) => l.taskLabelId), + blockedByIds: existingTask.blockedBy.map((b) => b.taskId), deadline: task.deadline, startDate: task.startDate }; diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index 422c3d92d2..fca6cf1033 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -30,6 +30,7 @@ import GanttChartColorLegend from './GanttChartColorLegend'; import GanttChartFiltersButton from './GanttChartFiltersButton'; import GanttChart from '../GanttChart/GanttChart'; import { + GanttChartTask, GanttChartProject, GanttChartWorkPackage, Task, @@ -58,8 +59,8 @@ import { projectWbsPipe } from '../../../utils/pipes'; import { ganttChartProjectTransformer } from '../../../apis/transformers/projects.transformers'; import { useCurrentUser } from '../../../hooks/users.hooks'; -const getElementId = (element: WbsElementPreview | Task) => { - return (element as WbsElementPreview).id ?? (element as Task).taskId; +const getElementId = (element: WbsElementPreview | GanttChartTask) => { + return (element as WbsElementPreview).id ?? (element as GanttChartTask).taskId; }; const ProjectGanttChartPage: FC = () => { @@ -88,11 +89,13 @@ const ProjectGanttChartPage: FC = () => { const [showAddWorkPackageModal, setShowAddWorkPackageModal] = useState(false); const [showAddTaskModal, setShowAddTaskModal] = useState(false); const [showSelectionModal, setShowSelectionModal] = useState(false); - const [ganttChanges, setGanttChanges] = useState[]>([]); - const [requestEventChanges, setRequestEventChanges] = useState[]>([]); + const [ganttChanges, setGanttChanges] = useState[]>([]); + const [requestEventChanges, setRequestEventChanges] = useState[]>( + [] + ); const [selectedProject, setSelectedProject] = useState(undefined); const [selectedTeam, setSelectedTeam] = useState(undefined); - const [collections, setCollections] = useState[]>([]); + const [collections, setCollections] = useState[]>([]); const [allProjects, setAllProjects] = useState([]); const [editedProjects, setEditedProjects] = useState([]); const user = useCurrentUser(); @@ -244,7 +247,7 @@ const ProjectGanttChartPage: FC = () => { /* **************************************************** */ /* ****************** Editability ********************* */ - const handleCancel = (_collection?: GanttCollection) => { + const handleCancel = (_collection?: GanttCollection) => { //TODO Filter by gantt collection setAddedProjects([]); setEditedProjects([]); @@ -252,14 +255,14 @@ const ProjectGanttChartPage: FC = () => { setSelectedProject(undefined); }; - const onAddNewSubtask = (parent: GanttTask) => { + const onAddNewSubtask = (parent: GanttTask) => { if (isProjectPreview(parent.element)) { setSelectedProject(parent.element); setShowSelectionModal(true); } }; - const onAddNewTask = (collection: GanttCollection) => { + const onAddNewTask = (collection: GanttCollection) => { setSelectedTeam(collection.element); setShowAddProjectModal(true); }; @@ -407,11 +410,11 @@ const ProjectGanttChartPage: FC = () => { setSelectedTeam(undefined); }; - const createChange = (change: GanttChange) => { + const createChange = (change: GanttChange) => { setGanttChanges([...ganttChanges, change]); }; - const createChangeHandler = (change: GanttChange) => { + const createChangeHandler = (change: GanttChange) => { const parentProject = allProjects.find((project) => wbsPipe(project.wbsNum) === projectWbsPipe(change.element.wbsNum)); // Find the project that either the change is on, or the changes work package is a part of if (!parentProject) return; @@ -504,7 +507,7 @@ const ProjectGanttChartPage: FC = () => { ); }; - const reverseEventChange = (change: RequestEventChange) => { + const reverseEventChange = (change: RequestEventChange) => { const { element } = change; switch (change.type) { case 'create-task': @@ -515,7 +518,7 @@ const ProjectGanttChartPage: FC = () => { } }; - const removeActiveModal = (change: RequestEventChange, cancelled: boolean) => { + const removeActiveModal = (change: RequestEventChange, cancelled: boolean) => { const newChanges = requestEventChanges.filter((newChange) => newChange.changeId !== change.changeId); setRequestEventChanges(newChanges); if (newChanges.length === 0) { @@ -576,15 +579,15 @@ const ProjectGanttChartPage: FC = () => { }; const highlightProjectComparator = ( - highlightedElement: WbsElementPreview | Task, - wbsElement: WbsElementPreview | Task + highlightedElement: WbsElementPreview | GanttChartTask, + wbsElement: WbsElementPreview | GanttChartTask ) => { return projectWbsPipe(highlightedElement.wbsNum) === projectWbsPipe(wbsElement.wbsNum); }; const highlightWorkPackageComparator = ( - highlightedElement: WbsElementPreview | Task, - wbsElement: WbsElementPreview | Task + highlightedElement: WbsElementPreview | GanttChartTask, + wbsElement: WbsElementPreview | GanttChartTask ) => { return wbsPipe(highlightedElement.wbsNum) === wbsPipe(wbsElement.wbsNum); }; diff --git a/src/frontend/src/pages/RetrospectivePage/Retrospective.tsx b/src/frontend/src/pages/RetrospectivePage/Retrospective.tsx index 40a9f4a78e..ddae759986 100644 --- a/src/frontend/src/pages/RetrospectivePage/Retrospective.tsx +++ b/src/frontend/src/pages/RetrospectivePage/Retrospective.tsx @@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom'; import { useGetAllCars } from '../../hooks/cars.hooks'; import { useAllTeamTypes } from '../../hooks/team-types.hooks'; import { ChangeEvent, useEffect, useState } from 'react'; -import { RetrospectiveProjectPreview, Task, TeamPreview, TeamType, WbsElementPreview } from 'shared'; +import { GanttChartTask, RetrospectiveProjectPreview, TeamPreview, TeamType, WbsElementPreview } from 'shared'; import { constructCollectionsFromTeamPreviewAndProjects, GanttCollection, @@ -41,7 +41,7 @@ const RetrospectivePage = () => { const { isLoading: carsIsLoading, isError: carsIsError, data: cars, error: carsError } = useGetAllCars(); const [searchText, setSearchText] = useState(''); - const [collections, setCollections] = useState[]>([]); + const [collections, setCollections] = useState[]>([]); const { data: projects, diff --git a/src/frontend/src/utils/gantt.utils.tsx b/src/frontend/src/utils/gantt.utils.tsx index a0c1c7acb7..10c085dab5 100644 --- a/src/frontend/src/utils/gantt.utils.tsx +++ b/src/frontend/src/utils/gantt.utils.tsx @@ -8,6 +8,7 @@ import { EventStatus, GanttChartEvent, GanttChartProject, + GanttChartTask, GanttChartWorkPackage, isWorkPackage, RetrospectiveProjectPreview, @@ -169,7 +170,7 @@ export const transformDesignReviewEventToGanttEvent = (event: GanttChartEvent): const applyChangesToBlockedBy = ( initialWorkPackage: GanttChartWorkPackage, totalWorkPackages: GanttChartWorkPackage[], - changeToApply: GanttChange + changeToApply: GanttChange ) => { const updatedBlockingWbsNums: Set = new Set(); @@ -212,18 +213,18 @@ const applyChangesToBlockedBy = ( * @param parentProject The parent project of the wbs element, itself if it is a project */ export const applyChangesToWBSElement = ( - ganttChanges: GanttChange[], - wbsElement: WbsElementPreview | Task, + ganttChanges: GanttChange[], + wbsElement: WbsElementPreview | GanttChartTask, parentProject: GanttChartProject -): { updatedProject: GanttChartProject; updatedElement: WbsElementPreview | Task } => { +): { updatedProject: GanttChartProject; updatedElement: WbsElementPreview | GanttChartTask } => { const updatedElement = { ...wbsElement }; const copiedProject: GanttChartProject = ganttChartProjectTransformer(JSON.parse(JSON.stringify(parentProject))); // Check if it's a Task - if ((updatedElement as Task).taskId !== undefined) { - const task = updatedElement as Task; + if ((updatedElement as GanttChartTask).taskId !== undefined) { + const task = updatedElement as GanttChartTask; for (const change of ganttChanges) { - if ((change.element as Task).taskId === task.taskId) { + if ((change.element as GanttChartTask).taskId === task.taskId) { // Apply changes to the task if (change.type === 'shift-by-days') { // For tasks, we shift the deadline by the specified number of days @@ -402,7 +403,7 @@ const getBlockingGanttTasks = ( .filter((wp) => !!wp); }; -export const transformTaskToGanttTask = (task: T, end: Date): GanttTask => { +export const transformTaskToGanttTask = (task: T, end: Date): GanttTask => { return { id: task.taskId, element: task, @@ -462,7 +463,7 @@ export const transformWorkPackageToGanttTask = export const transformProjectToGanttTask = ( project: GanttChartProject, hideTasks: boolean = false -): GanttTask => { +): GanttTask => { const startDate = getProjectStartDate(project); const endDate = getProjectEndDate(project); @@ -498,7 +499,7 @@ export const transformProjectToGanttTask = ( export const transformRetrospectiveProjectToGanttTask = ( project: RetrospectiveProjectPreview, showTasks: boolean = true -): GanttTask => { +): GanttTask => { return { ...transformProjectToGanttTask(project, showTasks), children: project.workPackages @@ -530,9 +531,9 @@ export const constructCollectionsFromTeamPreviewAndProjects = GanttTaskData, + projectTransformation: (project: T, hideTasks?: boolean) => GanttTaskData, reparser: (project: T) => T -): GanttCollection[] => { +): GanttCollection[] => { const projectMap = new Map(); projects.forEach((project) => { project.teams.forEach((team) => { @@ -547,7 +548,7 @@ export const constructCollectionsFromTeamPreviewAndProjects = ({ - id: uuidv4(), + id: team.teamId, // TEMP click-through only (#4477) — revert before committing element: team, tasks: filterGanttProjects((projectMap.get(team.teamId) ?? []) as T[], filters, searchText, team, reparser).map( (project) => projectTransformation(project as T, hideTasks) @@ -661,7 +662,7 @@ export const isHighlightedChangeOnGanttTask = ( export const constructFinalizedChanges = ( originalProjects: GanttChartProject[], updatedProjects: GanttChartProject[], - changes: GanttChange[] + changes: GanttChange[] ) => { const aggregatedSet: Set = new Set(); @@ -701,7 +702,14 @@ export const constructFinalizedChanges = ( return eventChanges; }; -export const isProjectPreview = (wbsPreview: WbsElementPreview | Task): wbsPreview is GanttChartProject => { +/** + * Tasks created on the gantt are full Tasks (built in the page's handleAddTaskInfo), while tasks loaded for the gantt + * only carry what the chart shows. Creating a task needs its full fields, so this checks they're actually there. + */ +export const isCreatableTask = (task: GanttChartTask | Task): task is Task => + 'notes' in task && 'labels' in task && 'blockedBy' in task && 'priority' in task; + +export const isProjectPreview = (wbsPreview: WbsElementPreview | GanttChartTask): wbsPreview is GanttChartProject => { return 'workPackages' in wbsPreview; }; diff --git a/src/frontend/src/utils/pipes.ts b/src/frontend/src/utils/pipes.ts index 6a74cf036d..82bc1b1bda 100644 --- a/src/frontend/src/utils/pipes.ts +++ b/src/frontend/src/utils/pipes.ts @@ -50,7 +50,7 @@ export const projectWbsPipe = (wbsNum: WbsNumber) => { }; /** Display user by their name "Joe Shmoe" */ -export const fullNamePipe = (user?: UserPreview) => { +export const fullNamePipe = (user?: Pick) => { return user ? `${user.firstName} ${user.lastName}` : emDashPipe(''); }; diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 0ec7115270..90cfe02031 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -5,7 +5,7 @@ import { EventPreview, TeamType } from './calendar-types.js'; import { ImplementedChange } from './change-request-types.js'; -import { Task } from './task-types.js'; +import { Task, TaskStatus } from './task-types.js'; import { TeamPreview } from './team-types.js'; import { User, UserPreview } from './user-types.js'; import { WorkPackageStage } from './work-package-types.js'; @@ -90,12 +90,24 @@ export interface GanttChartWorkPackage extends WbsElementPreview { events: GanttChartEvent[]; } +// a task bar and its tooltip; saving edits reads everything else from the full project +export interface GanttChartTask { + taskId: string; + wbsNum: WbsNumber; + title: string; + status: TaskStatus; + startDate?: Date; + deadline?: Date; + createdBy: Pick; + assignees: Pick[]; +} + export interface GanttChartProject extends WbsElementPreview { startDate?: Date; endDate?: Date; teams: { teamId: string }[]; workPackages: GanttChartWorkPackage[]; - tasks: Task[]; + tasks: GanttChartTask[]; } export interface ProjectPreview extends WbsElementPreview { From c98d014e21ca97228f1dc57b1f188cc45d78f865 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 15 Oct 2026 18:56:12 -0400 Subject: [PATCH 10/13] bar and CR modal stacking bug fix --- src/frontend/src/components/NERDraggableFormModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/components/NERDraggableFormModal.tsx b/src/frontend/src/components/NERDraggableFormModal.tsx index 4546c0c389..5402df98f8 100644 --- a/src/frontend/src/components/NERDraggableFormModal.tsx +++ b/src/frontend/src/components/NERDraggableFormModal.tsx @@ -48,7 +48,8 @@ export const NERDraggableFormModal = ({ left: '50%', backgroundColor: theme.palette.background.paper, boxShadow: 24, - zIndex: 6, + // above page content (the gantt's own layers go up to 6), at MUI's standard modal level + zIndex: theme.zIndex.modal, width: '40%', borderRadius: '8px' }} From f04e02deb5094ae231084ec8ec3202fe61282c2c Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 15 Oct 2026 19:19:27 -0400 Subject: [PATCH 11/13] revert temp change --- src/frontend/src/utils/gantt.utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/utils/gantt.utils.tsx b/src/frontend/src/utils/gantt.utils.tsx index 10c085dab5..2eccedd0ae 100644 --- a/src/frontend/src/utils/gantt.utils.tsx +++ b/src/frontend/src/utils/gantt.utils.tsx @@ -548,7 +548,7 @@ export const constructCollectionsFromTeamPreviewAndProjects = ({ - id: team.teamId, // TEMP click-through only (#4477) — revert before committing + id: uuidv4(), element: team, tasks: filterGanttProjects((projectMap.get(team.teamId) ?? []) as T[], filters, searchText, team, reparser).map( (project) => projectTransformation(project as T, hideTasks) From 15273251b2487ebcb012cf2ac5be05df06b909ed Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 15 Oct 2026 19:49:10 -0400 Subject: [PATCH 12/13] one-line fix for #4477 drag-to-edit bug --- src/frontend/src/utils/gantt.utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/utils/gantt.utils.tsx b/src/frontend/src/utils/gantt.utils.tsx index 2eccedd0ae..cadd1be234 100644 --- a/src/frontend/src/utils/gantt.utils.tsx +++ b/src/frontend/src/utils/gantt.utils.tsx @@ -548,7 +548,7 @@ export const constructCollectionsFromTeamPreviewAndProjects = ({ - id: uuidv4(), + id: team.teamId, element: team, tasks: filterGanttProjects((projectMap.get(team.teamId) ?? []) as T[], filters, searchText, team, reparser).map( (project) => projectTransformation(project as T, hideTasks) From 85a42c3c584a167166d16ffcc610c5f21945a0c4 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 15 Oct 2026 20:11:00 -0400 Subject: [PATCH 13/13] revert to userpreviewqueryargs and revert fullnamepipe accordingly --- src/backend/src/prisma-query-args/gantt-chart.query-args.ts | 5 ++--- src/frontend/src/utils/pipes.ts | 2 +- src/shared/src/types/project-types.ts | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/backend/src/prisma-query-args/gantt-chart.query-args.ts b/src/backend/src/prisma-query-args/gantt-chart.query-args.ts index 645d76fba5..12ed022899 100644 --- a/src/backend/src/prisma-query-args/gantt-chart.query-args.ts +++ b/src/backend/src/prisma-query-args/gantt-chart.query-args.ts @@ -10,7 +10,6 @@ export type GanttChartWorkPackageQueryArgs = ReturnType; const wbsNumberSelect = { carNumber: true, projectNumber: true, workPackageNumber: true } as const; -const userNameSelect = { select: { firstName: true, lastName: true } } as const; // a task bar and its tooltip; saving an edited task reads its other fields from the full project export const getGanttChartTaskQueryArgs = () => @@ -22,8 +21,8 @@ export const getGanttChartTaskQueryArgs = () => startDate: true, deadline: true, wbsElement: { select: wbsNumberSelect }, - createdBy: userNameSelect, - assignees: userNameSelect + createdBy: getUserPreviewQueryArgs(), + assignees: getUserPreviewQueryArgs() } }); diff --git a/src/frontend/src/utils/pipes.ts b/src/frontend/src/utils/pipes.ts index 82bc1b1bda..6a74cf036d 100644 --- a/src/frontend/src/utils/pipes.ts +++ b/src/frontend/src/utils/pipes.ts @@ -50,7 +50,7 @@ export const projectWbsPipe = (wbsNum: WbsNumber) => { }; /** Display user by their name "Joe Shmoe" */ -export const fullNamePipe = (user?: Pick) => { +export const fullNamePipe = (user?: UserPreview) => { return user ? `${user.firstName} ${user.lastName}` : emDashPipe(''); }; diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 90cfe02031..b67faf1d75 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -98,8 +98,8 @@ export interface GanttChartTask { status: TaskStatus; startDate?: Date; deadline?: Date; - createdBy: Pick; - assignees: Pick[]; + createdBy: UserPreview; + assignees: UserPreview[]; } export interface GanttChartProject extends WbsElementPreview {