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/change-requests.query-args.ts b/src/backend/src/prisma-query-args/change-requests.query-args.ts index b4aa4c9806..9937769410 100644 --- a/src/backend/src/prisma-query-args/change-requests.query-args.ts +++ b/src/backend/src/prisma-query-args/change-requests.query-args.ts @@ -3,7 +3,7 @@ import { getUserQueryArgs } from './user.query-args.js'; import { getWorkPackageQueryArgs } from './work-packages.query-args.js'; import { getReimbursementProductOtherReasonQueryArgs } from './reimbursement-product-other-reason.query-args.js'; import { getAccountCodeQueryArgs } from './account-code.query-args.js'; -import { getTeamQueryArgs } from './teams.query-args.js'; +import { getTeamPreviewQueryArgs } from './teams.query-args.js'; export type ChangeRequestQueryArgs = ReturnType; export type ChangeRequestWithProjectAndWorkPackageQueryArgs = ReturnType< @@ -17,7 +17,7 @@ export type ProjectProposedChangesQueryArgs = ReturnType Prisma.validator()({ include: { - teams: getTeamQueryArgs(organizationId), + teams: getTeamPreviewQueryArgs(organizationId), car: { select: { wbsElement: { select: { carNumber: true } } } }, workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId) } @@ -117,7 +117,7 @@ const getWbsProposedChangesQueryArgs = (organizationId: string) => carId: true, summary: true, budget: true, - teams: getTeamQueryArgs(organizationId), + teams: getTeamPreviewQueryArgs(organizationId), car: { select: { wbsElement: { select: { carNumber: true } } } }, workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId) } 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..5f7db9a4d2 --- /dev/null +++ b/src/backend/src/prisma-query-args/gantt-chart.query-args.ts @@ -0,0 +1,98 @@ +import { Prisma } from '@prisma/client'; +import { getUserPreviewQueryArgs } from './user.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; + +// 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: { carNumber: true, projectNumber: true, workPackageNumber: true } }, + createdBy: getUserPreviewQueryArgs(), + assignees: getUserPreviewQueryArgs() + } + }); + +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: { + carNumber: true, + projectNumber: true, + workPackageNumber: true, + name: true, + status: true, + dateCreated: true, + dateDeleted: true, + lead: getUserPreviewQueryArgs(), + manager: getUserPreviewQueryArgs(), + blocking: { + where: { wbsElement: { dateDeleted: null } }, + select: { wbsElement: { select: { carNumber: true, projectNumber: true, workPackageNumber: true } } } + } + } + }, + events: { where: { dateDeleted: null }, ...getGanttChartEventQueryArgs() } + } + }); + +export const getGanttChartProjectQueryArgs = () => + Prisma.validator()({ + select: { + projectId: true, + wbsElementId: true, + wbsElement: { + select: { + carNumber: true, + projectNumber: true, + workPackageNumber: true, + name: true, + dateCreated: true, + dateDeleted: true, + lead: getUserPreviewQueryArgs(), + manager: getUserPreviewQueryArgs(), + tasks: { + where: { dateDeleted: null }, + orderBy: [{ dateCreated: 'asc' }, { taskId: 'asc' }], + ...getGanttChartTaskQueryArgs() + } + } + }, + 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/projects.query-args.ts b/src/backend/src/prisma-query-args/projects.query-args.ts index dfb71643fa..40c442ee84 100644 --- a/src/backend/src/prisma-query-args/projects.query-args.ts +++ b/src/backend/src/prisma-query-args/projects.query-args.ts @@ -8,8 +8,6 @@ import { getWorkPackageQueryArgs, getWorkPackagePreviewQueryArgs } from './work- export type ProjectQueryArgs = ReturnType; -export type ProjectGanttQueryArgs = ReturnType; - export type ProjectPreviewQueryArgs = ReturnType; export type ProjectOverviewQueryArgs = ReturnType; @@ -44,39 +42,6 @@ export const getProjectQueryArgs = (organizationId: string) => } }); -export const getProjectGanttQueryArgs = (organizationId: string) => - Prisma.validator()({ - include: { - wbsElement: { - include: { - lead: getUserQueryArgs(organizationId), - manager: getUserQueryArgs(organizationId), - tasks: { - where: { - dateDeleted: null - }, - ...getTaskQueryArgs() - } - } - }, - teams: { - select: { - teamId: true, - teamName: true - } - }, - workPackages: { - where: { - wbsElement: { - dateDeleted: null - } - }, - ...getWorkPackageQueryArgs(organizationId) - }, - favoritedBy: getUserQueryArgs(organizationId) - } - }); - export const getProjectPreviewQueryArgs = (organizationId: string) => Prisma.validator()({ select: { diff --git a/src/backend/src/prisma-query-args/teams.query-args.ts b/src/backend/src/prisma-query-args/teams.query-args.ts index abac3f01b8..4937c1dd43 100644 --- a/src/backend/src/prisma-query-args/teams.query-args.ts +++ b/src/backend/src/prisma-query-args/teams.query-args.ts @@ -1,6 +1,6 @@ import { Prisma } from '@prisma/client'; import { getUserQueryArgs } from './user.query-args.js'; -import { getProjectGanttQueryArgs } from './projects.query-args.js'; +import { getGanttChartProjectQueryArgs } from './gantt-chart.query-args.js'; export type TeamQueryArgs = ReturnType; export type TeamBaseQueryArgs = ReturnType; @@ -16,13 +16,14 @@ export const getTeamQueryArgs = (organizationId: string) => leads: getUserQueryArgs(organizationId), userArchived: getUserQueryArgs(organizationId), teamType: { select: { teamTypeId: true, name: true } }, + // the gantt chart's project shape covers everything team pages read, plus the budget they total projects: { where: { wbsElement: { dateDeleted: null } }, - ...getProjectGanttQueryArgs(organizationId) + select: { ...getGanttChartProjectQueryArgs().select, budget: true } } } }); diff --git a/src/backend/src/prisma-query-args/wbs-element-template.query-args.ts b/src/backend/src/prisma-query-args/wbs-element-template.query-args.ts index b53bdea259..79c8aaea0e 100644 --- a/src/backend/src/prisma-query-args/wbs-element-template.query-args.ts +++ b/src/backend/src/prisma-query-args/wbs-element-template.query-args.ts @@ -1,7 +1,7 @@ import { Prisma } from '@prisma/client'; import { getDescriptionBulletQueryArgs } from './description-bullets.query-args.js'; import { getUserQueryArgs } from './user.query-args.js'; -import { getTeamQueryArgs } from './teams.query-args.js'; +import { getTeamPreviewQueryArgs } from './teams.query-args.js'; export type WorkPackageTemplateQueryArgs = ReturnType; export type ProjectTemplateQueryArgs = ReturnType; @@ -42,7 +42,7 @@ export const getProjectTemplateQueryArgs = (organizationId: string) => ...getWorkPackageTemplateQueryArgs(organizationId) }, teams: { - ...getTeamQueryArgs(organizationId) + ...getTeamPreviewQueryArgs(organizationId) } } }); diff --git a/src/backend/src/prisma/factories/event.factory.ts b/src/backend/src/prisma/factories/event.factory.ts index c4e94e452d..50296aa570 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; @@ -56,6 +57,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 40a841e003..8d7b66ed40 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1178,6 +1178,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 3ad0e33952..78f40a124a 100644 --- a/src/backend/src/prisma/seed-config.json +++ b/src/backend/src/prisma/seed-config.json @@ -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 7800787c96..42330d110a 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, @@ -17,6 +18,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]; @@ -88,6 +98,7 @@ export class EventProcess extends SeedProcess> return this.generateEventsForProject( organizationId, project.wbsElement.name, + workPackagesByProjectId[project.projectId] ?? [], timeline, creators, allUsers, @@ -106,6 +117,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'], @@ -162,6 +174,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, @@ -180,7 +199,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 eb1c9d2726..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,15 +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 }) }, - ...getProjectGanttQueryArgs(organization.organizationId) + orderBy: [{ wbsElement: { carNumber: 'asc' } }, { wbsElement: { projectNumber: 'asc' } }], + ...getGanttChartProjectQueryArgs() }); - return projects.map(projectGanttTransformer); + return projects.map(ganttChartProjectTransformer); } /** diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index 5f2f0ff299..f0c621ad09 100644 --- a/src/backend/src/services/tasks.services.ts +++ b/src/backend/src/services/tasks.services.ts @@ -34,7 +34,7 @@ import { } from '../utils/tasks.utils.js'; import { getUsers, userHasPermission } from '../utils/users.utils.js'; import { wbsNumOf } from '../utils/utils.js'; -import { getTeamQueryArgs } from '../prisma-query-args/teams.query-args.js'; +import { getTeamDropdownQueryArgs } from '../prisma-query-args/dropdown.query-args.js'; import { getBlockingWorkPackagesArgs, getCalendarTaskQueryArgs, @@ -86,7 +86,7 @@ export default class TasksService { include: { project: { include: { - teams: getTeamQueryArgs(organization.organizationId), + teams: getTeamDropdownQueryArgs(), wbsElement: true, workPackages: { include: { wbsElement: true } } } @@ -95,7 +95,7 @@ export default class TasksService { include: { project: { include: { - teams: getTeamQueryArgs(organization.organizationId) + teams: getTeamDropdownQueryArgs() } } } diff --git a/src/backend/src/transformers/change-requests.transformer.ts b/src/backend/src/transformers/change-requests.transformer.ts index 20b8304d2c..78df34a815 100644 --- a/src/backend/src/transformers/change-requests.transformer.ts +++ b/src/backend/src/transformers/change-requests.transformer.ts @@ -18,7 +18,7 @@ import { calculateChangeRequestStatus } from '../utils/change-requests.utils.js' import { getDateImplemented } from '../utils/change-requests.utils.js'; import { userTransformer } from './user.transformer.js'; import { descBulletConverter } from '../utils/description-bullets.utils.js'; -import teamTransformer from './teams.transformer.js'; +import { teamPreviewTransformer } from './teams.transformer.js'; import { HttpException } from '../utils/errors.utils.js'; import { ChangeRequestGuestQueryArgs, @@ -46,7 +46,7 @@ const projectProposedChangesTransformer = ( summary: projectProposedChanges.summary, budget: projectProposedChanges.budget, descriptionBullets: wbsProposedChanges.proposedDescriptionBulletChanges.map(descBulletConverter), - teams: projectProposedChanges.teams.map(teamTransformer), + teams: projectProposedChanges.teams.map(teamPreviewTransformer), carNumber: projectProposedChanges.car?.wbsElement.carNumber ?? undefined, workPackageProposedChanges: projectProposedChanges.workPackageProposedChanges.map(workPackageProposedChangesTransformer) }; 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..defa87a969 --- /dev/null +++ b/src/backend/src/transformers/gantt-chart.transformer.ts @@ -0,0 +1,87 @@ +import { Prisma } from '@prisma/client'; +import { + calculateEndDate, + calculateProjectStartDate, + 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 { 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, + 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(ganttChartTaskTransformer), + workPackages: project.workPackages.map((workPackage) => ganttChartWorkPackageTransformer(workPackage, wbsElement.name)) + }; +}; diff --git a/src/backend/src/transformers/projects.transformer.ts b/src/backend/src/transformers/projects.transformer.ts index e4ba1e146f..1475804927 100644 --- a/src/backend/src/transformers/projects.transformer.ts +++ b/src/backend/src/transformers/projects.transformer.ts @@ -19,7 +19,6 @@ import { calculateProjectStatus } from '../utils/projects.utils.js'; import { descBulletConverter } from '../utils/description-bullets.utils.js'; import { userTransformer } from './user.transformer.js'; import { - ProjectGanttQueryArgs, ProjectOverviewQueryArgs, ProjectPreviewQueryArgs, ProjectQueryArgs @@ -68,7 +67,7 @@ const projectTransformer = (project: Prisma.ProjectGetPayload) }; }; -export const projectGanttTransformer = (project: Prisma.ProjectGetPayload): ProjectGantt => { +export const projectGanttTransformer = (project: Prisma.ProjectGetPayload): ProjectGantt => { const { wbsElement } = project; const wbsNum = wbsNumOf(wbsElement); @@ -164,7 +163,7 @@ export type RetrospectiveWorkPackageQueryArgs = Prisma.Work_PackageGetPayload, 'workPackages'> & { +export type RetrospectiveProjectPreviewQueryArgs = Omit, 'workPackages'> & { workPackages: RetrospectiveWorkPackageQueryArgs[]; }; diff --git a/src/backend/src/transformers/teams.transformer.ts b/src/backend/src/transformers/teams.transformer.ts index bda2eb9f74..28093fb7af 100644 --- a/src/backend/src/transformers/teams.transformer.ts +++ b/src/backend/src/transformers/teams.transformer.ts @@ -8,7 +8,7 @@ import { TeamWithProjectsQueryArgs } from '../prisma-query-args/teams.query-args.js'; import { userTransformer } from './user.transformer.js'; -import { projectGanttTransformer } from './projects.transformer.js'; +import { ganttChartProjectTransformer } from './gantt-chart.transformer.js'; import { teamTypeTransformer } from './team-types.transformer.js'; import { wbsNumOf } from '../utils/utils.js'; @@ -20,7 +20,7 @@ const teamTransformer = (team: Prisma.TeamGetPayload): Team => { description: team.description, head: userTransformer(team.head), members: team.members.map(userTransformer), - projects: team.projects.map(projectGanttTransformer), + projects: team.projects.map((project) => ({ ...ganttChartProjectTransformer(project), budget: project.budget })), leads: team.leads.map(userTransformer), userArchived: team.userArchived ? userTransformer(team.userArchived) : undefined, dateArchived: team.dateArchived ?? undefined, diff --git a/src/backend/src/transformers/work-package-template.transformer.ts b/src/backend/src/transformers/work-package-template.transformer.ts index 2ab825ab58..5be667f801 100644 --- a/src/backend/src/transformers/work-package-template.transformer.ts +++ b/src/backend/src/transformers/work-package-template.transformer.ts @@ -7,7 +7,7 @@ import { } from '../prisma-query-args/wbs-element-template.query-args.js'; import descriptionBulletTransformer from './description-bullets.transformer.js'; import { userTransformer } from './user.transformer.js'; -import teamTransformer from './teams.transformer.js'; +import { teamPreviewTransformer } from './teams.transformer.js'; export const workPackageTemplateTransformer = ( wptInput: Prisma.Work_Package_TemplateGetPayload @@ -52,7 +52,7 @@ export const projectTemplateTransformer = ( workPackageTemplates: projectTemplate.workPackageTemplates.map(workPackageTemplateTransformer), descriptionBullets: projectTemplate.wbsElementTemplate.descriptionBullets.map(descriptionBulletTransformer), budget: projectTemplate.budget ?? undefined, - teams: projectTemplate.teams.map(teamTransformer), + teams: projectTemplate.teams.map(teamPreviewTransformer), summary: projectTemplate.summary ?? undefined }; }; 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..4daa7565cb 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, @@ -13,8 +14,12 @@ import { RetrospectiveProjectPreview } from 'shared'; import { implementedChangeTransformer } from './change-requests.transformers'; -import { taskTransformer } from './tasks.transformers'; -import { retrospectiveWorkPackageTransformer, workPackageTransformer } from './work-packages.transformers'; +import { ganttChartTaskTransformer, taskTransformer } from './tasks.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(ganttChartTaskTransformer) + }; +}; + export const projectGanttTransformer = (project: ProjectGantt): ProjectGantt => { return { ...project, 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/apis/transformers/teams.transformers.ts b/src/frontend/src/apis/transformers/teams.transformers.ts index 16c9c5ec4c..0354f29f3c 100644 --- a/src/frontend/src/apis/transformers/teams.transformers.ts +++ b/src/frontend/src/apis/transformers/teams.transformers.ts @@ -1,5 +1,5 @@ import { Team, TeamJoinRequest, TeamPreview, TeamWithProjects } from 'shared'; -import { projectGanttTransformer } from './projects.transformers'; +import { ganttChartProjectTransformer } from './projects.transformers'; /** * Transforms a team to ensure deep field transformation of date objects. @@ -11,7 +11,7 @@ export const teamTransformer = (team: Team): Team => { return { ...team, dateArchived: team.dateArchived ? new Date(team.dateArchived) : undefined, - projects: team.projects.map(projectGanttTransformer) + projects: team.projects.map(ganttChartProjectTransformer) }; }; 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/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' }} 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/hooks/utils.hooks.ts b/src/frontend/src/hooks/utils.hooks.ts index 340018b27b..1f4f8dc8d7 100644 --- a/src/frontend/src/hooks/utils.hooks.ts +++ b/src/frontend/src/hooks/utils.hooks.ts @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; /** @@ -18,3 +18,21 @@ export const useQuery = () => { const { search } = useLocation(); return useMemo(() => new URLSearchParams(search), [search]); }; + +/** + * A custom hook that delays updating a value until it has stopped changing for the given delay. + * Useful for keeping a text input responsive while only running expensive work once typing settles. + * @param value The value to debounce. + * @param delay How long the value must stay unchanged before it is returned, in milliseconds. + * @returns The value, as of `delay` milliseconds after it last changed. + */ +export const useDebounce = (value: T, delay: number = 500): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timeout); + }, [value, delay]); + + return debouncedValue; +}; 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..d6e685e71f 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx @@ -1,10 +1,10 @@ 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'; 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'; @@ -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' })); @@ -65,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 61608ec74f..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, 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, + 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'; @@ -47,18 +55,10 @@ 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 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); @@ -74,69 +74,62 @@ 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, index) => assignee.userId !== task.assignees[index]?.userId); - - if (hasChanges) { - editedTasks.push(task); - } + } else if ( + existingTask.startDate?.getTime() !== task.startDate?.getTime() || + existingTask.deadline?.getTime() !== task.deadline?.getTime() + ) { + editedTasks.push({ task, existingTask }); } }); 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'); @@ -154,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, @@ -178,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 }; @@ -227,13 +224,9 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim {`New: ${changeInTimeline(change.newStart, change.newEnd)}`} {editedWorkPackages.length > 0 && ( - - Reason for Change - { - 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 = () => { @@ -83,18 +85,21 @@ 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 debouncedSearchText = useDebounce(searchText); + 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 [ganttChanges, setGanttChanges] = useState[]>([]); + const [requestEventChanges, setRequestEventChanges] = useState[]>( + [] + ); + const [selectedProject, setSelectedProject] = useState(undefined); const [selectedTeam, setSelectedTeam] = useState(undefined); - const [collections, setCollections] = useState[]>([]); - const [allProjects, setAllProjects] = useState([]); - const [editedProjects, setEditedProjects] = useState([]); + const [collections, setCollections] = useState[]>([]); + const [allProjects, setAllProjects] = useState([]); + const [editedProjects, setEditedProjects] = useState([]); const user = useCurrentUser(); /******************** Filters ***************************/ const { filters, setFilters } = useGanttFilters('project-gantt'); @@ -108,15 +113,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,13 +136,13 @@ const ProjectGanttChartPage: FC = () => { filters, searchText, transformProjectToGanttTask, - projectGanttTransformer + ganttChartProjectTransformer ) ); }; if (projects && teams) { - requestRefresh(projects, teams, editedProjects, addedProjects, { ...filters, showCars }, searchText); + requestRefresh(projects, teams, editedProjects, addedProjects, { ...filters, showCars }, debouncedSearchText); } }, [ teams, @@ -148,7 +153,7 @@ const ProjectGanttChartPage: FC = () => { editedProjects, filters, showCars, - searchText, + debouncedSearchText, history ]); @@ -244,7 +249,7 @@ const ProjectGanttChartPage: FC = () => { /* **************************************************** */ /* ****************** Editability ********************* */ - const handleCancel = (_collection?: GanttCollection) => { + const handleCancel = (_collection?: GanttCollection) => { //TODO Filter by gantt collection setAddedProjects([]); setEditedProjects([]); @@ -252,48 +257,40 @@ 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); }; 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 +331,7 @@ const ProjectGanttChartPage: FC = () => { deadline: Date | null; wpWbsNum?: WbsNumber; }, - parentProject: ProjectGantt + parentProject: GanttChartProject ) => { const taskId = uuidv4(); @@ -382,7 +379,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 +391,7 @@ const ProjectGanttChartPage: FC = () => { workPackages: [], deleted: false, tasks: [], - budget: 0, teams: [selectedTeam], - duration: 1, wbsElementId: '-1', dateCreated: new Date() }; @@ -405,7 +400,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(), @@ -417,11 +412,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; @@ -514,7 +509,7 @@ const ProjectGanttChartPage: FC = () => { ); }; - const reverseEventChange = (change: RequestEventChange) => { + const reverseEventChange = (change: RequestEventChange) => { const { element } = change; switch (change.type) { case 'create-task': @@ -525,7 +520,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) { @@ -537,11 +532,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 +550,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 +572,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]); } @@ -586,15 +581,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/GuestProjectsPage/GuestProjectsCard.tsx b/src/frontend/src/pages/GuestProjectsPage/GuestProjectsCard.tsx index b6f43481e4..6c93a46fd3 100644 --- a/src/frontend/src/pages/GuestProjectsPage/GuestProjectsCard.tsx +++ b/src/frontend/src/pages/GuestProjectsPage/GuestProjectsCard.tsx @@ -1,5 +1,5 @@ import { alpha, Box, Card, CardContent, Chip, Stack, Typography, useTheme, Link } from '@mui/material'; -import { wbsNamePipe, ProjectPreview, wbsPipe, WbsElementStatus } from 'shared'; +import { wbsNamePipe, ProjectPreview, wbsPipe, WbsElementStatus, WorkPackagePreview } from 'shared'; import { datePipe } from '../../utils/pipes'; import { NERButton } from '../../components/NERButton'; import { useSingleProject } from '../../hooks/projects.hooks'; @@ -8,7 +8,8 @@ import ErrorPage from '../ErrorPage'; import { Link as RouterLink } from 'react-router-dom'; interface ProjectCardProps { - project: ProjectPreview; + // only what's read here, so both project previews and team projects fit + project: Pick & { workPackages: Pick[] }; } const GuestProjectsCard: React.FC = ({ project }) => { diff --git a/src/frontend/src/pages/GuestTeamsPage/GuestTeamSpecificPage.tsx b/src/frontend/src/pages/GuestTeamsPage/GuestTeamSpecificPage.tsx index afd525554d..58ea49a5c5 100644 --- a/src/frontend/src/pages/GuestTeamsPage/GuestTeamSpecificPage.tsx +++ b/src/frontend/src/pages/GuestTeamsPage/GuestTeamSpecificPage.tsx @@ -103,7 +103,7 @@ const GuestTeamSpecificPage: React.FC = () => { }} > {activeProjects.map((project) => ( - + ))} 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/pages/TeamsPage/ProjectCardsView.tsx b/src/frontend/src/pages/TeamsPage/ProjectCardsView.tsx index 28959997d0..53ea17d072 100644 --- a/src/frontend/src/pages/TeamsPage/ProjectCardsView.tsx +++ b/src/frontend/src/pages/TeamsPage/ProjectCardsView.tsx @@ -1,5 +1,5 @@ import { Card, CardContent, Typography, Link } from '@mui/material'; -import { ProjectGantt } from 'shared'; +import { TeamProject } from 'shared'; import { routes } from '../../utils/routes'; import { fullNamePipe, wbsPipe } from '../../utils/pipes'; import { useSingleProject } from '../../hooks/projects.hooks'; @@ -8,7 +8,7 @@ import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; interface ActiveProjectCardViewProps { - project: ProjectGantt; + project: TeamProject; } const ActiveProjectCardView: React.FC = ({ project }) => { const { isLoading, isError, error, data } = useSingleProject(project.wbsNum); diff --git a/src/frontend/src/utils/gantt.utils.tsx b/src/frontend/src/utils/gantt.utils.tsx index 9ae388f9bb..cadd1be234 100644 --- a/src/frontend/src/utils/gantt.utils.tsx +++ b/src/frontend/src/utils/gantt.utils.tsx @@ -5,34 +5,35 @@ import { addWeeksToDate, - EventPreview, EventStatus, + GanttChartEvent, + GanttChartProject, + GanttChartTask, + 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 +134,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 +143,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 +152,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,9 +168,9 @@ export const transformDesignReviewEventToGanttEvent = (event: EventPreview): Gan * @param changeToApply The change to apply to all the blocked work package */ const applyChangesToBlockedBy = ( - initialWorkPackage: WorkPackage, - totalWorkPackages: WorkPackage[], - changeToApply: GanttChange + initialWorkPackage: GanttChartWorkPackage, + totalWorkPackages: GanttChartWorkPackage[], + 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, - parentProject: ProjectGantt -): { updatedProject: ProjectGantt; updatedElement: WbsElementPreview | Task } => { + ganttChanges: GanttChange[], + wbsElement: WbsElementPreview | GanttChartTask, + parentProject: GanttChartProject +): { updatedProject: GanttChartProject; updatedElement: WbsElementPreview | GanttChartTask } => { 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) { - 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 @@ -248,7 +249,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 +292,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 +378,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 +387,7 @@ const UserDisplay = ({ user, label }: { user?: User; label: string }) => { ); }; -const getBlockingGanttTasks = ( +const getBlockingGanttTasks = ( workPackage: T, allWorkPackages: T[], transformation: (wp: T, all: T[]) => GanttTaskData @@ -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, @@ -429,7 +430,7 @@ export const transformTaskToGanttTask = (task: T, end: Date): Ga }; }; -export const transformWorkPackageToGanttTask = ( +export const transformWorkPackageToGanttTask = ( workPackage: T, allWorkPackages: T[] ): GanttTask => { @@ -460,9 +461,9 @@ export const transformWorkPackageToGanttTask = ( }; export const transformProjectToGanttTask = ( - project: ProjectGantt, + 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 @@ -525,15 +526,15 @@ export const transformRetrospectiveWorkPackageToGanttTask = ( }; }; -export const constructCollectionsFromTeamPreviewAndProjects = ( +export const constructCollectionsFromTeamPreviewAndProjects = ( teams: TeamPreview[], projects: T[], filters: GanttFilters, searchText: string, - projectTransformation: (project: T, hideTasks?: boolean) => GanttTaskData, + projectTransformation: (project: T, hideTasks?: boolean) => GanttTaskData, reparser: (project: T) => T -): GanttCollection[] => { - const projectMap = new Map(); +): GanttCollection[] => { + const projectMap = new Map(); projects.forEach((project) => { project.teams.forEach((team) => { if (projectMap.has(team.teamId)) { @@ -547,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) @@ -659,9 +660,9 @@ export const isHighlightedChangeOnGanttTask = ( }; export const constructFinalizedChanges = ( - originalProjects: ProjectGantt[], - updatedProjects: ProjectGantt[], - changes: GanttChange[] + originalProjects: GanttChartProject[], + updatedProjects: GanttChartProject[], + changes: GanttChange[] ) => { const aggregatedSet: Set = new Set(); @@ -701,7 +702,14 @@ export const constructFinalizedChanges = ( return eventChanges; }; -export const isProjectPreview = (wbsPreview: WbsElementPreview | Task): wbsPreview is ProjectGantt => { +/** + * 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/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 9694ad371b..b67faf1d75 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'; @@ -74,6 +74,42 @@ 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[]; +} + +// 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: UserPreview; + assignees: UserPreview[]; +} + +export interface GanttChartProject extends WbsElementPreview { + startDate?: Date; + endDate?: Date; + teams: { teamId: string }[]; + workPackages: GanttChartWorkPackage[]; + tasks: GanttChartTask[]; +} + export interface ProjectPreview extends WbsElementPreview { startDate?: Date; endDate?: Date; diff --git a/src/shared/src/types/team-types.ts b/src/shared/src/types/team-types.ts index 63f8fd9b55..f1a80640ee 100644 --- a/src/shared/src/types/team-types.ts +++ b/src/shared/src/types/team-types.ts @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { ProjectGantt, WbsNumber } from './project-types.js'; +import { GanttChartProject, WbsNumber } from './project-types.js'; import { User, UserPreview } from './user-types.js'; export interface TeamBase { @@ -42,8 +42,11 @@ export interface TeamWithProjects { projects: TeamProjectPreview[]; } +// a team's projects use the gantt chart project shape, plus the budget team pages total +export type TeamProject = GanttChartProject & { budget: number }; + export interface Team extends TeamPreview { - projects: ProjectGantt[]; + projects: TeamProject[]; } export type TeamJoinRequestStatus = 'PENDING' | 'APPROVED' | 'DENIED';