diff --git a/src/back-end/lib/db/proposal/sprint-with-us.ts b/src/back-end/lib/db/proposal/sprint-with-us.ts index 4b7f929f4..87a20d360 100644 --- a/src/back-end/lib/db/proposal/sprint-with-us.ts +++ b/src/back-end/lib/db/proposal/sprint-with-us.ts @@ -762,6 +762,42 @@ export const readManyProposalTeamQuestionResponses = tryDb< return valid(results); }); +export const readOneSWUProposalSlim = tryDb< + [Id, AuthenticatedSession], + SWUProposalSlim | null +>(async (connection, id, session) => { + const result = await generateSWUProposalQuery(connection) + .where({ "proposals.id": id }) + .first(); + + if (result) { + // Fetch submittedAt date if applicable + result.submittedAt = await getSWUProposalSubmittedAt(connection, result); + + // Fetch team questions (scores only included if admin/owner) + const canReadScores = await readSWUProposalScore( + connection, + session, + result.opportunity, + result.id, + result.status, + result.organization + ); + + // Check for permissions on viewing scores and rank + if (canReadScores) { + // Set scores and rankings + await calculateScores(connection, session, result.opportunity, [result]); + } + } + + return valid( + result + ? await rawSWUProposalSlimToSWUProposalSlim(connection, result, session) + : null + ); +}); + export const readOneSWUProposal = tryDb< [Id, AuthenticatedSession], SWUProposal | null diff --git a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts index 63de4ebd8..a008bf98e 100644 --- a/src/back-end/lib/db/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/db/question-evaluation/sprint-with-us.ts @@ -3,7 +3,12 @@ import { readOneSWUOpportunity } from "back-end/lib/db/opportunity/sprint-with-us"; import { getValidValue, isInvalid, valid } from "shared/lib/validation"; -import { Connection, readOneSWUProposal, tryDb } from "back-end/lib/db"; +import { + Connection, + Transaction, + readOneSWUProposalSlim, + tryDb +} from "back-end/lib/db"; import { AuthenticatedSession, Session, @@ -13,9 +18,12 @@ import { Id } from "shared/lib/types"; import { SWUEvaluationPanelMember } from "shared/lib/resources/opportunity/sprint-with-us"; import { CreateRequestBody, + CreateSWUTeamQuestionResponseEvaluationScoreBody, SWUTeamQuestionResponseEvaluation, SWUTeamQuestionResponseEvaluationScores, - SWUTeamQuestionResponseEvaluationStatus + SWUTeamQuestionResponseEvaluationStatus, + SWUTeamQuestionResponseEvaluationType, + UpdateEditRequestBody } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { generateUuid } from "back-end/lib"; @@ -24,9 +32,14 @@ export interface CreateSWUTeamQuestionResponseEvaluationParams evaluationPanelMember: Id; } +interface UpdateSWUTeamQuestionResponseEvaluationParams + extends UpdateEditRequestBody { + id: Id; +} + interface SWUTeamQuestionResponseEvaluationStatusRecord { id: Id; - opportunity: Id; + teamQuestionResponseEvaluation: Id; createdAt: Date; createdBy: Id; status: SWUTeamQuestionResponseEvaluationStatus; @@ -49,7 +62,7 @@ export type RawSWUTeamQuestionResponseEvaluationScores = teamQuestionResponseEvaluation: Id; }; -async function RawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( +async function rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( connection: Connection, session: Session, raw: RawSWUTeamQuestionResponseEvaluation @@ -63,7 +76,7 @@ async function RawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation const proposal = session && getValidValue( - await readOneSWUProposal(connection, proposalId, session), + await readOneSWUProposalSlim(connection, proposalId, session), null ); if (!proposal) { @@ -119,6 +132,158 @@ export const isSWUOpportunityEvaluationPanelEvaluator = export const isSWUOpportunityEvaluationPanelChair = makeIsSWUOpportunityEvaluationPanelMember((epm) => epm.chair); +export const readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus = + tryDb<[AuthenticatedSession, Id], SWUTeamQuestionResponseEvaluation[]>( + async (connection, session, id) => { + const query = generateSWUTeamQuestionResponseEvaluationQuery( + connection + ).where({ + "evaluations.proposal": id, + "evaluations.type": SWUTeamQuestionResponseEvaluationType.Individual + }); + + const results = await Promise.all( + ( + await query + ).map(async (result: RawSWUTeamQuestionResponseEvaluation) => { + result.scores = + getValidValue( + await readManyTeamQuestionResponseEvaluationScores( + connection, + result.id + ), + [] + ) ?? []; + return result; + }) + ); + + if (!results) { + throw new Error("unable to read evaluations"); + } + + return valid( + await Promise.all( + results.map( + async (result) => + await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( + connection, + session, + result + ) + ) + ) + ); + } + ); + +export const readManySWUTeamQuestionResponseEvaluations = tryDb< + [AuthenticatedSession, Id, boolean], + SWUTeamQuestionResponseEvaluation[] +>(async (connection, session, id, isConsensus) => { + const query = generateSWUTeamQuestionResponseEvaluationQuery(connection) + .join("swuProposals", "swuProposals.id", "=", "evaluations.proposal") + .where({ "swuProposals.opportunity": id }); + + // If not reading consensus evaluations, scope results to those they have + // authored + if (!isConsensus) { + query + .join( + "swuEvaluationPanelMembers", + "swuEvaluationPanelMembers.id", + "=", + "evaluations.evaluationPanelMember" + ) + .andWhere({ "swuEvaluationPanelMembers.user": session.user.id }); + } + + const results = await Promise.all( + ( + await query + ).map(async (result: RawSWUTeamQuestionResponseEvaluation) => { + result.scores = + getValidValue( + await readManyTeamQuestionResponseEvaluationScores( + connection, + result.id + ), + [] + ) ?? []; + return result; + }) + ); + + if (!results) { + throw new Error("unable to read evaluations"); + } + + return valid( + await Promise.all( + results.map( + async (result) => + await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( + connection, + session, + result + ) + ) + ) + ); +}); + +export const readOwnSWUTeamQuestionResponseEvaluations = tryDb< + [AuthenticatedSession], + SWUTeamQuestionResponseEvaluation[] +>(async (connection, session) => { + const evaluations = await generateSWUTeamQuestionResponseEvaluationQuery( + connection + ) + .join( + "evaluationPanelMembers epm", + "epm.id", + "=", + "evaluations.evaluationPanelMember" + ) + .andWhere({ "epm.user": session.user.id }); + + return valid( + await Promise.all( + evaluations.map( + async (result) => + await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( + connection, + session, + result + ) + ) + ) + ); +}); + +export const readOneSWUTeamQuestionResponseEvaluationByProposalAndEvaluationPanelMember = + tryDb<[Id, Id, SWUTeamQuestionResponseEvaluationType, Session], Id | null>( + async (connection, proposalId, evaluationPanelMemberId, type, session) => { + if (!session) { + return valid(null); + } + const result = ( + await connection( + "swuTeamQuestionResponseEvaluations" + ) + .where({ + proposal: proposalId, + evaluationPanelMember: evaluationPanelMemberId, + type + }) + .select("id") + .first() + )?.id; + + return valid(result ? result : null); + } + ); + export const readManyTeamQuestionResponseEvaluationScores = tryDb< [Id], RawSWUTeamQuestionResponseEvaluationScores[] @@ -159,7 +324,7 @@ export const readOneSWUTeamQuestionResponseEvaluation = tryDb< return valid( result - ? await RawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( + ? await rawTeamQuestionResponseEvaluationToTeamQuestionResponseEvaluation( connection, session, result @@ -197,23 +362,22 @@ export const createSWUTeamQuestionResponseEvaluation = tryDb< } // Create a evaluation status record - const [evaluationStatusRecord] = await connection< - SWUTeamQuestionResponseEvaluationStatusRecord & { - teamQuestionResponseEvaluation: Id; - } - >("swuTeamQuestionResponseEvaluationStatuses") - .transacting(trx) - .insert( - { - id: generateUuid(), - teamQuestionResponseEvaluation: evaluationRootRecord.id, - status, - createdAt: now, - createdBy: session.user.id, - note: "" - }, - "*" - ); + const [evaluationStatusRecord] = + await connection( + "swuTeamQuestionResponseEvaluationStatuses" + ) + .transacting(trx) + .insert( + { + id: generateUuid(), + teamQuestionResponseEvaluation: evaluationRootRecord.id, + status, + createdAt: now, + createdBy: session.user.id, + note: "" + }, + "*" + ); if (!evaluationStatusRecord) { throw new Error("unable to create team question evaluation status"); @@ -242,11 +406,132 @@ export const createSWUTeamQuestionResponseEvaluation = tryDb< session ); if (isInvalid(dbResult) || !dbResult.value) { - throw new Error("unable to create proposal"); + throw new Error("unable to create team question evaluation"); } return valid(dbResult.value); }); +export const updateSWUTeamQuestionResponseEvaluation = tryDb< + [UpdateSWUTeamQuestionResponseEvaluationParams, AuthenticatedSession], + SWUTeamQuestionResponseEvaluation +>(async (connection, proposal, session) => { + const now = new Date(); + const { id, scores } = proposal; + return valid( + await connection.transaction(async (trx) => { + // Update timestamp + const [result] = await connection( + "swuTeamQuestionResponseEvaluations" + ) + .transacting(trx) + .where({ id }) + .update( + { + updatedAt: now + }, + "*" + ); + + if (!result) { + throw new Error("unable to update team question evaluation"); + } + // Update scores + await updateSWUTeamQuestionResponseEvaluationScores( + trx, + result.id, + scores + ); + + const dbResult = await readOneSWUTeamQuestionResponseEvaluation( + trx, + result.id, + session + ); + if (isInvalid(dbResult) || !dbResult.value) { + throw new Error("unable to update team question evaluation"); + } + return dbResult.value; + }) + ); +}); + +async function updateSWUTeamQuestionResponseEvaluationScores( + connection: Transaction, + evaluationId: Id, + scores: CreateSWUTeamQuestionResponseEvaluationScoreBody[] +): Promise { + // Remove existing and recreate + await connection("swuTeamQuestionResponseEvaluationScores") + .where({ teamQuestionResponseEvaluation: evaluationId }) + .delete(); + + for (const score of scores) { + await connection< + SWUTeamQuestionResponseEvaluationScores & { + teamQuestionResponseEvaluation: Id; + } + >("swuTeamQuestionResponseEvaluationScores").insert({ + ...score, + teamQuestionResponseEvaluation: evaluationId + }); + } +} + +export const updateSWUTeamQuestionResponseEvaluationStatus = tryDb< + [Id, SWUTeamQuestionResponseEvaluationStatus, string, AuthenticatedSession], + SWUTeamQuestionResponseEvaluation +>(async (connection, evaluationId, status, note, session) => { + const now = new Date(); + return valid( + await connection.transaction(async (trx) => { + const [statusRecord] = + await connection( + "swuTeamQuestionResponseEvaluationStatuses" + ) + .transacting(trx) + .insert( + { + id: generateUuid(), + teamQuestionResponseEvaluation: evaluationId, + createdAt: now, + createdBy: session.user.id, + status, + note + }, + "*" + ); + + // Update proposal root record + await connection( + "swuTeamQuestionResponseEvaluations" + ) + .transacting(trx) + .where({ id: evaluationId }) + .update( + { + updatedAt: now + }, + "*" + ); + + if (!statusRecord) { + throw new Error("unable to update team question evaluation"); + } + + const dbResult = await readOneSWUTeamQuestionResponseEvaluation( + trx, + statusRecord.teamQuestionResponseEvaluation, + session + ); + if (isInvalid(dbResult) || !dbResult.value) { + throw new Error("unable to update team question evaluation"); + } + + return dbResult.value; + }) + ); +}); + function generateSWUTeamQuestionResponseEvaluationQuery( connection: Connection ) { diff --git a/src/back-end/lib/permissions.ts b/src/back-end/lib/permissions.ts index 581e5b878..1f9d2ff23 100644 --- a/src/back-end/lib/permissions.ts +++ b/src/back-end/lib/permissions.ts @@ -25,6 +25,7 @@ import { } from "shared/lib/resources/opportunity/code-with-us"; import { CreateSWUOpportunityStatus, + doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations, doesSWUOpportunityStatusAllowGovToViewProposals, SWUOpportunity, SWUOpportunityStatus @@ -67,6 +68,10 @@ import { isSWUOpportunityEvaluationPanelChair, isSWUOpportunityEvaluationPanelEvaluator } from "./db/question-evaluation/sprint-with-us"; +import { + SWUTeamQuestionResponseEvaluation, + SWUTeamQuestionResponseEvaluationType +} from "shared/lib/resources/question-evaluation/sprint-with-us"; export const ERROR_MESSAGE = "You do not have permission to perform this action."; @@ -869,6 +874,100 @@ export async function deleteSWUProposal( // SWU Team Question Response Evaluations +export async function readOneSWUTeamQuestionResponseEvaluation( + connection: Connection, + session: Session, + evaluation: SWUTeamQuestionResponseEvaluation +): Promise { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations( + evaluation.proposal.opportunity.status + ) || + (evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual && + evaluation.evaluationPanelMember.user.id === session.user.id) || + (evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelConsensus && + ((await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + evaluation.proposal.opportunity.id + )) || + (await isSWUOpportunityEvaluationPanelChair( + connection, + session, + evaluation.proposal.opportunity.id + ))))) + ); +} + +export async function readManySWUTeamQuestionResponseEvaluations( + connection: Connection, + session: Session, + opportunity: SWUOpportunity, + isConsensus: boolean +): Promise { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations( + opportunity.status + ) || + (isConsensus + ? (await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + opportunity.id + )) || + (await isSWUOpportunityEvaluationPanelChair( + connection, + session, + opportunity.id + )) + : // Filtered to authored evaluations elsewhere when evaluation is + // individual + await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + opportunity.id + ))) + ); +} + +export async function readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus( + connection: Connection, + session: Session, + proposal: SWUProposal +): Promise { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + (doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations( + proposal.opportunity.status + ) || + (proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && + ((await isSWUOpportunityEvaluationPanelEvaluator( + connection, + session, + proposal.opportunity.id + )) || + (await isSWUOpportunityEvaluationPanelChair( + connection, + session, + proposal.opportunity.id + ))))) + ); +} + +export function readOwnSWUTeamQuestionResponseEvaluations( + session: Session +): boolean { + return isGovernment(session) || isAdmin(session); +} + export async function createSWUTeamQuestionResponseEvaluation( connection: Connection, session: Session, @@ -877,15 +976,13 @@ export async function createSWUTeamQuestionResponseEvaluation( return ( !!session && (isAdmin(session) || isGovernment(session)) && - ((proposal.opportunity.status === - SWUOpportunityStatus.TeamQuestionsPanelEvaluation && + ((proposal.status === SWUProposalStatus.TeamQuestionsPanelIndividual && (await isSWUOpportunityEvaluationPanelEvaluator( connection, session, proposal.opportunity.id ))) || - (proposal.opportunity.status === - SWUOpportunityStatus.TeamQuestionsPanelConsensus && + (proposal.status === SWUProposalStatus.TeamQuestionsPanelConsensus && (await isSWUOpportunityEvaluationPanelChair( connection, session, @@ -894,6 +991,40 @@ export async function createSWUTeamQuestionResponseEvaluation( ); } +export function editSWUTeamQuestionResponseEvaluation( + session: Session, + evaluation: SWUTeamQuestionResponseEvaluation +): boolean { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + evaluation.evaluationPanelMember.user.id === session.user.id && + ((evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual) || + (evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelConsensus && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Consensus)) + ); +} + +export function submitSWUTeamQuestionResponseEvaluation( + session: Session, + evaluation: SWUTeamQuestionResponseEvaluation +): boolean { + return ( + !!session && + (isAdmin(session) || isGovernment(session)) && + evaluation.evaluationPanelMember.user.id === session.user.id && + ((evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Individual) || + (evaluation.proposal.status === + SWUProposalStatus.TeamQuestionsPanelConsensus && + evaluation.type === SWUTeamQuestionResponseEvaluationType.Consensus)) + ); +} + // TWU Opportunities export function createTWUOpportunity( diff --git a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts index ef55b1f3a..47e032bee 100644 --- a/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/back-end/lib/resources/question-evaluation/sprint-with-us.ts @@ -5,21 +5,31 @@ import { JsonResponseBody, basicResponse, makeJsonResponseBody, + nullRequestBodyHandler, wrapRespond } from "back-end/lib/server"; -import { validateSWUProposalId } from "back-end/lib/validation"; +import { + validateSWUOpportunityId, + validateSWUProposalId, + validateSWUTeamQuestionResponseEvaluationId +} from "back-end/lib/validation"; import { get, omit } from "lodash"; import { getString } from "shared/lib"; import { + CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors, CreateValidationErrors, SWUTeamQuestionResponseEvaluation, SWUTeamQuestionResponseEvaluationStatus, SWUTeamQuestionResponseEvaluationType, - CreateRequestBody as SharedCreateRequestBody + CreateRequestBody as SharedCreateRequestBody, + UpdateRequestBody as SharedUpdateRequestBody, + UpdateValidationErrors, + isValidStatusChange } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { AuthenticatedSession, Session } from "shared/lib/resources/session"; -import { Id } from "shared/lib/types"; +import { ADT, Id, adt } from "shared/lib/types"; import { + Validation, getInvalidValue, invalid, isInvalid, @@ -33,13 +43,159 @@ interface ValidatedCreateRequestBody extends SharedCreateRequestBody { evaluationPanelMember: Id; } +interface ValidatedUpdateRequestBody { + session: AuthenticatedSession; + body: ADT<"edit", ValidatedUpdateEditRequestBody> | ADT<"submit", string>; +} + +type ValidatedUpdateEditRequestBody = Omit< + ValidatedCreateRequestBody, + "proposal" | "evaluationPanelMember" | "status" | "type" | "session" +>; + type CreateRequestBody = Omit & { status: string; type: string; }; +type UpdateRequestBody = SharedUpdateRequestBody | null; + const routeNamespace = "question-evaluations/sprint-with-us"; +const readMany: crud.ReadMany = ( + connection: db.Connection +) => { + return nullRequestBodyHandler< + JsonResponseBody, + Session + >(async (request) => { + const respond = ( + code: number, + body: SWUTeamQuestionResponseEvaluation[] | string[] + ) => basicResponse(code, request.session, makeJsonResponseBody(body)); + if (request.query.proposal) { + if (!permissions.isSignedIn(request.session)) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + + const validatedSWUProposal = await validateSWUProposalId( + connection, + request.query.proposal, + request.session + ); + if (isInvalid(validatedSWUProposal)) { + return respond(404, ["Sprint With Us proposal not found."]); + } + + if ( + !(await permissions.readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus( + connection, + request.session, + validatedSWUProposal.value + )) + ) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const dbResult = + await db.readManyIndividualSWUTeamQuestionResponseEvaluationsForConsensus( + connection, + request.session, + request.query.proposal + ); + if (isInvalid(dbResult)) { + return respond(503, [db.ERROR_MESSAGE]); + } + return respond(200, dbResult.value); + } else if (request.query.opportunity) { + if (!permissions.isSignedIn(request.session)) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + + const validatedSWUOpportunity = await validateSWUOpportunityId( + connection, + request.query.opportunity, + request.session + ); + if (isInvalid(validatedSWUOpportunity)) { + return respond(404, ["Sprint With Us opportunity not found."]); + } + + const isConsensus = Boolean(request.query.consensus); + if ( + !(await permissions.readManySWUTeamQuestionResponseEvaluations( + connection, + request.session, + validatedSWUOpportunity.value, + isConsensus + )) + ) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const dbResult = await db.readManySWUTeamQuestionResponseEvaluations( + connection, + request.session, + request.query.opportunity, + isConsensus + ); + if (isInvalid(dbResult)) { + return respond(503, [db.ERROR_MESSAGE]); + } + return respond(200, dbResult.value); + } else { + if ( + !permissions.isSignedIn(request.session) || + !permissions.readOwnSWUTeamQuestionResponseEvaluations(request.session) + ) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const dbResult = await db.readOwnSWUTeamQuestionResponseEvaluations( + connection, + request.session + ); + if (isInvalid(dbResult)) { + return respond(503, [db.ERROR_MESSAGE]); + } + return respond(200, dbResult.value); + } + }); +}; + +const readOne: crud.ReadOne = ( + connection: db.Connection +) => { + return nullRequestBodyHandler< + JsonResponseBody, + Session + >(async (request) => { + const respond = ( + code: number, + body: SWUTeamQuestionResponseEvaluation | string[] + ) => basicResponse(code, request.session, makeJsonResponseBody(body)); + if (!permissions.isSignedIn(request.session)) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + const validatedSWUTeamQuestionResponseEvaluation = + await validateSWUTeamQuestionResponseEvaluationId( + connection, + request.params.id, + request.session + ); + if (isInvalid(validatedSWUTeamQuestionResponseEvaluation)) { + return respond(404, ["Evaluation not found."]); + } + if ( + !(await permissions.readOneSWUTeamQuestionResponseEvaluation( + connection, + request.session, + validatedSWUTeamQuestionResponseEvaluation.value + )) + ) { + return respond(401, [permissions.ERROR_MESSAGE]); + } + return respond(200, validatedSWUTeamQuestionResponseEvaluation.value); + }); +}; + const create: crud.Create< Session, db.Connection, @@ -71,7 +227,7 @@ const create: crud.Create< questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationType( type, [ - SWUTeamQuestionResponseEvaluationType.Conensus, + SWUTeamQuestionResponseEvaluationType.Consensus, SWUTeamQuestionResponseEvaluationType.Individual ] ); @@ -127,10 +283,38 @@ const create: crud.Create< }); } + // Check for existing evaluation on this proposal, authored by this user + const dbResultEvaluation = + await db.readOneSWUTeamQuestionResponseEvaluationByProposalAndEvaluationPanelMember( + connection, + validatedSWUProposal.value.id, + validatedSWUPanelEvaluationPanelMember.value.id, + validatedType.value, + request.session + ); + if (isInvalid(dbResultEvaluation)) { + return invalid({ + database: [db.ERROR_MESSAGE] + }); + } + if (dbResultEvaluation.value) { + return invalid({ + conflict: [ + "You already have an team question evaluation for this proposal." + ] + }); + } + + const fullOpportunity = await db.readOneSWUOpportunity( + connection, + validatedSWUProposal.value.opportunity.id, + request.session + ); + const validatedScores = questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( scores, - validatedSWUProposal.value.teamQuestionResponses + fullOpportunity.value?.teamQuestions ?? [] ); if (isValid(validatedScores)) { @@ -186,9 +370,235 @@ const create: crud.Create< }; }; +const update: crud.Update< + Session, + db.Connection, + UpdateRequestBody, + ValidatedUpdateRequestBody, + UpdateValidationErrors +> = (connection: db.Connection) => { + return { + async parseRequestBody(request) { + const body = request.body.tag === "json" ? request.body.value : {}; + const tag = get(body, "tag"); + const value: unknown = get(body, "value"); + switch (tag) { + case "edit": + return adt("edit", { + scores: get(value, "scores") + }); + case "submit": + return adt("submit", getString(body, "value", "")); + default: + return null; + } + }, + async validateRequestBody(request) { + if (!request.body) { + return invalid({ evaluation: adt("parseFailure" as const) }); + } + if (!permissions.isSignedIn(request.session)) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + const validatedSWUTeamQuestionResponseEvaluation = + await validateSWUTeamQuestionResponseEvaluationId( + connection, + request.params.id, + request.session + ); + if (isInvalid(validatedSWUTeamQuestionResponseEvaluation)) { + return invalid({ + notFound: getInvalidValue( + validatedSWUTeamQuestionResponseEvaluation, + undefined + ) + }); + } + + if ( + !permissions.editSWUTeamQuestionResponseEvaluation( + request.session, + validatedSWUTeamQuestionResponseEvaluation.value + ) + ) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + + switch (request.body.tag) { + case "edit": { + const scores = request.body.value.scores; + + // Only drafts and submitted consensuses can be edited + if ( + validatedSWUTeamQuestionResponseEvaluation.value.status !== + SWUTeamQuestionResponseEvaluationStatus.Draft && + validatedSWUTeamQuestionResponseEvaluation.value.type !== + SWUTeamQuestionResponseEvaluationType.Consensus + ) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + + const fullOpportunity = await db.readOneSWUOpportunity( + connection, + validatedSWUTeamQuestionResponseEvaluation.value.proposal + .opportunity.id, + request.session + ); + + const validatedScores = + questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( + scores, + fullOpportunity.value?.teamQuestions ?? [] + ); + + if (isValid(validatedScores)) { + return valid({ + session: request.session, + body: adt( + "edit" as const, + { + scores: validatedScores.value + } as ValidatedUpdateEditRequestBody + ) + }); + } else { + return invalid({ + evaluation: adt("edit" as const, { + scores: getInvalidValue(validatedScores, undefined) + }) + }); + } + } + case "submit": { + if ( + !isValidStatusChange( + validatedSWUTeamQuestionResponseEvaluation.value.status, + SWUTeamQuestionResponseEvaluationStatus.Submitted + ) + ) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + + const fullOpportunity = await db.readOneSWUOpportunity( + connection, + validatedSWUTeamQuestionResponseEvaluation.value.proposal + .opportunity.id, + request.session + ); + + const validatedScores = + questionEvaluationValidation.validateSWUTeamQuestionResponseEvaluationScores( + validatedSWUTeamQuestionResponseEvaluation.value.scores, + fullOpportunity.value?.teamQuestions ?? [] + ); + + if ( + isInvalid< + CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors[] + >(validatedScores) || + validatedScores.value.length !== + fullOpportunity.value?.teamQuestions.length + ) { + return invalid({ + evaluation: adt("submit" as const, [ + "This evaluation could not be submitted for review because it is incomplete. Please edit, complete and save the appropriate form before trying to submit it again." + ]) + }); + } + + if ( + !permissions.submitSWUTeamQuestionResponseEvaluation( + request.session, + validatedSWUTeamQuestionResponseEvaluation.value + ) + ) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + + const validatedSubmissionNote = + questionEvaluationValidation.validateNote(request.body.value); + if (isInvalid(validatedSubmissionNote)) { + return invalid({ + evaluation: adt("submit" as const, validatedSubmissionNote.value) + }); + } + return valid({ + session: request.session, + body: adt("submit" as const, validatedSubmissionNote.value) + } as ValidatedUpdateRequestBody); + } + default: + return invalid({ evaluation: adt("parseFailure" as const) }); + } + }, + respond: wrapRespond< + ValidatedUpdateRequestBody, + UpdateValidationErrors, + JsonResponseBody, + JsonResponseBody, + Session + >({ + valid: async (request) => { + let dbResult: Validation; + const { session, body } = request.body; + switch (body.tag) { + case "edit": + dbResult = await db.updateSWUTeamQuestionResponseEvaluation( + connection, + { ...body.value, id: request.params.id }, + session + ); + break; + case "submit": + dbResult = await db.updateSWUTeamQuestionResponseEvaluationStatus( + connection, + request.params.id, + SWUTeamQuestionResponseEvaluationStatus.Submitted, + body.value, + session + ); + break; + } + if (isInvalid(dbResult)) { + return basicResponse( + 503, + request.session, + makeJsonResponseBody({ database: [db.ERROR_MESSAGE] }) + ); + } + return basicResponse( + 200, + request.session, + makeJsonResponseBody(dbResult.value) + ); + }, + invalid: async (request) => { + return basicResponse( + 400, + request.session, + makeJsonResponseBody(request.body) + ); + } + }) + }; +}; + const resource: crud.BasicCrudResource = { routeNamespace, - create + readOne, + readMany, + create, + update }; export default resource; diff --git a/src/back-end/lib/validation.ts b/src/back-end/lib/validation.ts index dbad9cd08..fc0cfce91 100644 --- a/src/back-end/lib/validation.ts +++ b/src/back-end/lib/validation.ts @@ -80,6 +80,7 @@ import { validateSWUEvaluationPanelMemberChair, validateSWUEvaluationPanelMemberEvaluator } from "shared/lib/validation/opportunity/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; /** * TWU - Team With Us Validation @@ -1014,6 +1015,38 @@ export async function validateDraftProposalOrganization( ); } +export async function validateSWUTeamQuestionResponseEvaluationId( + connection: db.Connection, + evaluationId: Id, + session: AuthenticatedSession +): Promise> { + try { + const validatedId = validateUUID(evaluationId); + if (isInvalid(validatedId)) { + return validatedId; + } + const dbResult = await db.readOneSWUTeamQuestionResponseEvaluation( + connection, + evaluationId, + session + ); + if (isInvalid(dbResult)) { + return invalid([db.ERROR_MESSAGE]); + } + const evaluation = dbResult.value; + if (!evaluation) { + return invalid([ + "The specified team question response evaluation was not found." + ]); + } + return valid(evaluation); + } catch (exception) { + return invalid([ + "Please select a valid team question response evaluation." + ]); + } +} + export async function validateContentId( connection: db.Connection, contentId: Id, diff --git a/src/front-end/typescript/lib/app/router.ts b/src/front-end/typescript/lib/app/router.ts index da1190294..86b778f21 100644 --- a/src/front-end/typescript/lib/app/router.ts +++ b/src/front-end/typescript/lib/app/router.ts @@ -133,6 +133,68 @@ const router: router_.Router = { }; } }, + { + path: prefixPath( + "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/question-evaluations/individual/create" + ), + makeRoute({ params, query }) { + return { + tag: "questionEvaluationIndividualSWUCreate", + value: { + proposalId: params.proposalId || "", + opportunityId: params.opportunityId || "", + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + } + }; + } + }, + { + path: prefixPath( + "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/question-evaluations/individual/:evaluationId/edit" + ), + makeRoute({ params, query }) { + return { + tag: "questionEvaluationIndividualSWUEdit", + value: { + proposalId: params.proposalId || "", + opportunityId: params.opportunityId || "", + evaluationId: params.evaluationId || "", + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + } + }; + } + }, + { + path: prefixPath( + "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/question-evaluations/consensus/create" + ), + makeRoute({ params, query }) { + return { + tag: "questionEvaluationConsensusSWUCreate", + value: { + proposalId: params.proposalId || "", + opportunityId: params.opportunityId || "", + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + } + }; + } + }, + { + path: prefixPath( + "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/question-evaluations/consensus/:evaluationId/edit" + ), + makeRoute({ params, query }) { + return { + tag: "questionEvaluationConsensusSWUEdit", + value: { + proposalId: params.proposalId || "", + opportunityId: params.opportunityId || "", + evaluationId: params.evaluationId || "", + tab: SWUProposalViewTab.parseTabId(query.tab) || undefined + } + }; + } + }, { path: prefixPath( "/opportunities/sprint-with-us/:opportunityId/proposals/:proposalId/export" @@ -724,6 +786,46 @@ const router: router_.Router = { route.value.tab ? `?tab=${route.value.tab}` : "" }` ); + case "questionEvaluationIndividualSWUCreate": + return prefixPath( + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${ + route.value.proposalId + }/question-evaluations/individual/create${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` + ); + case "questionEvaluationIndividualSWUEdit": + return prefixPath( + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${ + route.value.proposalId + }/question-evaluations/individual/${route.value.evaluationId}/edit${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` + ); + case "questionEvaluationConsensusSWUCreate": + return prefixPath( + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${ + route.value.proposalId + }/question-evaluations/consensus/create${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` + ); + case "questionEvaluationConsensusSWUEdit": + return prefixPath( + `/opportunities/sprint-with-us/${ + route.value.opportunityId + }/proposals/${ + route.value.proposalId + }/question-evaluations/consensus/${route.value.evaluationId}/edit${ + route.value.tab ? `?tab=${route.value.tab}` : "" + }` + ); case "proposalSWUExportOne": return prefixPath( `/opportunities/sprint-with-us/${route.value.opportunityId}/proposals/${route.value.proposalId}/export` diff --git a/src/front-end/typescript/lib/app/types.ts b/src/front-end/typescript/lib/app/types.ts index 706daa7aa..14a063641 100644 --- a/src/front-end/typescript/lib/app/types.ts +++ b/src/front-end/typescript/lib/app/types.ts @@ -45,6 +45,10 @@ import * as PageProposalSWUCreate from "front-end/lib/pages/proposal/sprint-with import * as PageProposalSWUEdit from "front-end/lib/pages/proposal/sprint-with-us/edit"; import * as PageProposalSWUExportAll from "front-end/lib/pages/proposal/sprint-with-us/export/all"; import * as PageProposalSWUExportOne from "front-end/lib/pages/proposal/sprint-with-us/export/one"; +import * as PageEvaluationIndividualSWUCreate from "front-end/lib/pages/question-evaluation/sprint-with-us/create-individual"; +import * as PageEvaluationIndividualSWUEdit from "front-end/lib/pages/question-evaluation/sprint-with-us/edit-individual"; +import * as PageEvaluationConsensusSWUCreate from "front-end/lib/pages/question-evaluation/sprint-with-us/create-consensus"; +import * as PageEvaluationConsensusSWUEdit from "front-end/lib/pages/question-evaluation/sprint-with-us/edit-individual"; import * as PageProposalSWUView from "front-end/lib/pages/proposal/sprint-with-us/view"; import * as PageSignIn from "front-end/lib/pages/sign-in"; import * as PageSignOut from "front-end/lib/pages/sign-out"; @@ -90,6 +94,22 @@ export type Route = | ADT<"proposalSWUCreate", PageProposalSWUCreate.RouteParams> | ADT<"proposalSWUEdit", PageProposalSWUEdit.RouteParams> | ADT<"proposalSWUView", PageProposalSWUView.RouteParams> + | ADT< + "questionEvaluationIndividualSWUCreate", + PageEvaluationIndividualSWUCreate.RouteParams + > + | ADT< + "questionEvaluationIndividualSWUEdit", + PageEvaluationIndividualSWUEdit.RouteParams + > + | ADT< + "questionEvaluationConsensusSWUCreate", + PageEvaluationConsensusSWUCreate.RouteParams + > + | ADT< + "questionEvaluationConsensusSWUEdit", + PageEvaluationConsensusSWUEdit.RouteParams + > | ADT<"proposalSWUExportOne", PageProposalSWUExportOne.RouteParams> | ADT<"proposalSWUExportAll", PageProposalSWUExportAll.RouteParams> | ADT<"opportunitySWUCreate", PageOpportunitySWUCreate.RouteParams> diff --git a/src/front-end/typescript/lib/app/update.ts b/src/front-end/typescript/lib/app/update.ts index 837685b5f..7e3fa28d8 100644 --- a/src/front-end/typescript/lib/app/update.ts +++ b/src/front-end/typescript/lib/app/update.ts @@ -54,6 +54,10 @@ import * as PageProposalSWUEdit from "front-end/lib/pages/proposal/sprint-with-u import * as PageProposalSWUExportAll from "front-end/lib/pages/proposal/sprint-with-us/export/all"; import * as PageProposalSWUExportOne from "front-end/lib/pages/proposal/sprint-with-us/export/one"; import * as PageProposalSWUView from "front-end/lib/pages/proposal/sprint-with-us/view"; +import * as PageQuestionEvaluationIndividualSWUCreate from "front-end/lib/pages/question-evaluation/sprint-with-us/create-individual"; +import * as PageQuestionEvaluationIndividualSWUEdit from "front-end/lib/pages/question-evaluation/sprint-with-us/edit-individual"; +import * as PageQuestionEvaluationConsensusSWUCreate from "front-end/lib/pages/question-evaluation/sprint-with-us/create-consensus"; +import * as PageQuestionEvaluationConsensusSWUEdit from "front-end/lib/pages/question-evaluation/sprint-with-us/edit-individual"; import * as PageProposalTWUCreate from "front-end/lib/pages/proposal/team-with-us/create"; import * as PageProposalTWUView from "front-end/lib/pages/proposal/team-with-us/view"; import * as PageProposalTWUEdit from "front-end/lib/pages/proposal/team-with-us/edit"; @@ -181,6 +185,50 @@ function initPage( return adt("pageProposalSWUView", value) as Msg; } }); + case "questionEvaluationIndividualSWUCreate": + return component.app.initPage({ + ...defaultPageInitParams, + pageStatePath: ["pages", "proposalSWUView"], + pageRouteParams: route.value, + pageInit: PageQuestionEvaluationIndividualSWUCreate.component.init, + pageGetMetadata: PageProposalSWUView.component.getMetadata, + mapPageMsg(value) { + return adt("pageProposalSWUView", value) as Msg; + } + }); + case "questionEvaluationIndividualSWUEdit": + return component.app.initPage({ + ...defaultPageInitParams, + pageStatePath: ["pages", "proposalSWUView"], + pageRouteParams: route.value, + pageInit: PageQuestionEvaluationIndividualSWUEdit.component.init, + pageGetMetadata: PageProposalSWUView.component.getMetadata, + mapPageMsg(value) { + return adt("pageProposalSWUView", value) as Msg; + } + }); + case "questionEvaluationConsensusSWUCreate": + return component.app.initPage({ + ...defaultPageInitParams, + pageStatePath: ["pages", "proposalSWUView"], + pageRouteParams: route.value, + pageInit: PageQuestionEvaluationConsensusSWUCreate.component.init, + pageGetMetadata: PageProposalSWUView.component.getMetadata, + mapPageMsg(value) { + return adt("pageProposalSWUView", value) as Msg; + } + }); + case "questionEvaluationConsensusSWUEdit": + return component.app.initPage({ + ...defaultPageInitParams, + pageStatePath: ["pages", "proposalSWUView"], + pageRouteParams: route.value, + pageInit: PageQuestionEvaluationConsensusSWUEdit.component.init, + pageGetMetadata: PageProposalSWUView.component.getMetadata, + mapPageMsg(value) { + return adt("pageProposalSWUView", value) as Msg; + } + }); case "opportunitySWUEdit": return component.app.initPage({ diff --git a/src/front-end/typescript/lib/app/view/index.tsx b/src/front-end/typescript/lib/app/view/index.tsx index 13f99f834..ac1dab03b 100644 --- a/src/front-end/typescript/lib/app/view/index.tsx +++ b/src/front-end/typescript/lib/app/view/index.tsx @@ -261,6 +261,10 @@ function pageToViewPageProps( (value) => ({ tag: "pageProposalSWUEdit", value }) ); + case "questionEvaluationIndividualSWUCreate": + case "questionEvaluationIndividualSWUEdit": + case "questionEvaluationConsensusSWUCreate": + case "questionEvaluationConsensusSWUEdit": case "proposalSWUView": return makeViewPageProps( props, diff --git a/src/front-end/typescript/lib/http/api/index.ts b/src/front-end/typescript/lib/http/api/index.ts index 54d676d39..d771233f1 100644 --- a/src/front-end/typescript/lib/http/api/index.ts +++ b/src/front-end/typescript/lib/http/api/index.ts @@ -60,3 +60,4 @@ export * as proposals from "front-end/lib/http/api/proposal"; export * as sessions from "front-end/lib/http/api/session"; export * as subscribers from "front-end/lib/http/api/subscribers"; export * as users from "front-end/lib/http/api/user"; +export * as evaluations from "front-end/lib/http/api/question-evaluation"; diff --git a/src/front-end/typescript/lib/http/api/question-evaluation/index.ts b/src/front-end/typescript/lib/http/api/question-evaluation/index.ts new file mode 100644 index 000000000..41b10a8e8 --- /dev/null +++ b/src/front-end/typescript/lib/http/api/question-evaluation/index.ts @@ -0,0 +1 @@ +export * as swu from "front-end/lib/http/api/question-evaluation/sprint-with-us"; diff --git a/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts b/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts new file mode 100644 index 000000000..694b530db --- /dev/null +++ b/src/front-end/typescript/lib/http/api/question-evaluation/sprint-with-us.ts @@ -0,0 +1,100 @@ +import * as crud from "front-end/lib/http/crud"; +import * as Resource from "shared/lib/resources/question-evaluation/sprint-with-us"; +import { compareNumbers } from "shared/lib"; +import { Id } from "shared/lib/types"; + +const NAMESPACE = "question-evaluations/sprint-with-us"; + +export function create(): crud.CreateAction< + Resource.CreateRequestBody, + Resource.SWUTeamQuestionResponseEvaluation, + Resource.CreateValidationErrors, + Msg +> { + return crud.makeCreateAction( + NAMESPACE, + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation + ); +} + +/** + * Parses URL parameters prior to creating a read request for many SWU proposals + * + * @param opportunityId + * @param consensus + */ +export function readMany({ + opportunityId, + proposalId, + consensus +}: { + opportunityId?: Id; + proposalId?: Id; + consensus?: boolean; +} = {}): crud.ReadManyAction< + Resource.SWUTeamQuestionResponseEvaluation, + string[], + Msg +> { + const params = new URLSearchParams({ + opportunity: + opportunityId !== undefined + ? window.encodeURIComponent(opportunityId) + : "", + proposal: + proposalId !== undefined ? window.encodeURIComponent(proposalId) : "" + }); + if (consensus) { + params.append("consensus", "true"); + } + return crud.makeReadManyAction( + NAMESPACE, + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation, + params.toString() + ); +} + +export function readOne(): crud.ReadOneAction< + Resource.SWUTeamQuestionResponseEvaluation, + string[], + Msg +> { + return crud.makeReadOneAction( + NAMESPACE, + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation + ); +} + +export function update(): crud.UpdateAction< + Resource.UpdateRequestBody, + Resource.SWUTeamQuestionResponseEvaluation, + Resource.UpdateValidationErrors, + Msg +> { + return crud.makeUpdateAction( + NAMESPACE, + rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation + ); +} + +// Raw Conversion + +interface RawSWUTeamQuestionResponseEvaluation + extends Omit< + Resource.SWUTeamQuestionResponseEvaluation, + "createdAt" | "updatedAt" + > { + createdAt: string; + updatedAt: string; +} + +function rawSWUTeamQuestionResponseEvaluationToSWUTeamQuestionResponseEvaluation( + raw: RawSWUTeamQuestionResponseEvaluation +): Resource.SWUTeamQuestionResponseEvaluation { + return { + ...raw, + createdAt: new Date(raw.createdAt), + updatedAt: new Date(raw.updatedAt), + scores: raw.scores.sort((a, b) => compareNumbers(a.order, b.order)) + }; +} diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx index 43a53a89d..fcf06751f 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/index.tsx @@ -26,6 +26,7 @@ import { User, UserType, isAdmin } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; import { SWUProposalSlim } from "shared/lib/resources/proposal/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; interface ValidState extends Tab.ParentState { opportunity: SWUOpportunity | null; @@ -47,6 +48,7 @@ export type InnerMsg_ = Tab.ParentInnerMsg< Tab.TabId, api.ResponseValidation, api.ResponseValidation, + api.ResponseValidation, User ] > @@ -97,7 +99,7 @@ function makeInit(): component_.page.Init< (msg) => adt("sidebar", msg) as Msg ), ...component_.cmd.mapMany(tabCmds, (msg) => adt("tab", msg) as Msg), - component_.cmd.join( + component_.cmd.join3( api.opportunities.swu.readOne()( routeParams.opportunityId, (response) => response @@ -107,12 +109,19 @@ function makeInit(): component_.page.Init< (response) => response ) : component_.cmd.dispatch(valid([])), - (opportunity, proposals) => + Tab.shouldLoadEvaluationsForTab(tabId) + ? api.evaluations.swu.readMany({ + opportunityId: routeParams.opportunityId, + consensus: tabId === "consensus" + })((response) => response) + : component_.cmd.dispatch(valid([])), + (opportunity, proposals, evaluations) => adt("onInitResponse", [ routePath, tabId, opportunity, proposals, + evaluations, shared.sessionUser ]) as Msg ) @@ -159,6 +168,7 @@ function makeComponent(): component_.page.Component< tabId, opportunityResponse, proposalsResponse, + evaluationsResponse, viewerUser ] = msg.value; // If the opportunity request failed, then show the "Not Found" page. @@ -179,6 +189,7 @@ function makeComponent(): component_.page.Component< } const opportunity = opportunityResponse.value; const proposals = api.getValidValue(proposalsResponse, []); + const evaluations = api.getValidValue(evaluationsResponse, []); // Tab Permissions const evaluationPanelMember = opportunity.evaluationPanel?.find( @@ -223,7 +234,11 @@ function makeComponent(): component_.page.Component< ), component_.cmd.dispatch( component_.page.mapMsg( - tabComponent.onInitResponse([opportunity, proposals]), + tabComponent.onInitResponse([ + opportunity, + proposals, + evaluations + ]), (msg) => adt("tab", msg) ) as Msg ) diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts index d99a7d8fd..280e1209b 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/index.ts @@ -5,6 +5,7 @@ import * as AddendaTab from "front-end/lib/pages/opportunity/sprint-with-us/edit import * as CodeChallengeTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/code-challenge"; import * as HistoryTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/history"; import * as OpportunityTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/opportunity"; +import * as OverviewTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/overview"; import * as ProposalsTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/proposals"; import * as SummaryTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/summary"; import * as TeamQuestionsTab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab/team-questions"; @@ -20,6 +21,7 @@ import { User } from "shared/lib/resources/user"; import { adt, Id } from "shared/lib/types"; import { SWUProposalSlim } from "shared/lib/resources/proposal/sprint-with-us"; import { GUIDE_AUDIENCE } from "front-end/lib/pages/guide/view"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; // Parent page types & functions. @@ -48,7 +50,11 @@ export type TabPermissions = { isChair: boolean; }; -export type InitResponse = [SWUOpportunity, SWUProposalSlim[]]; +export type InitResponse = [ + SWUOpportunity, + SWUProposalSlim[], + SWUTeamQuestionResponseEvaluation[] +]; export type Component = TabbedPage.TabComponent< Params, @@ -114,8 +120,8 @@ export interface Tabs { >; overview: TabbedPage.Tab< Params, - SummaryTab.State, - SummaryTab.InnerMsg, + OverviewTab.State, + OverviewTab.InnerMsg, InitResponse >; consensus: TabbedPage.Tab< @@ -231,8 +237,7 @@ export function idToDefinition( } as TabbedPage.TabDefinition; case "overview": return { - // TODO: Create tab - component: SummaryTab.component, + component: OverviewTab.component, icon: "list-check", title: "Overview" } as TabbedPage.TabDefinition; @@ -330,6 +335,7 @@ export function makeSidebarState( export function shouldLoadProposalsForTab(tabId: TabId): boolean { const proposalTabs: TabId[] = [ + "overview", "proposals", "teamQuestions", "codeChallenge", @@ -337,3 +343,8 @@ export function shouldLoadProposalsForTab(tabId: TabId): boolean { ]; return proposalTabs.includes(tabId); } + +export function shouldLoadEvaluationsForTab(tabId: TabId): boolean { + const evaluationTabs: TabId[] = ["overview"]; + return evaluationTabs.includes(tabId); +} diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx new file mode 100644 index 000000000..82f931e5d --- /dev/null +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/edit/tab/overview.tsx @@ -0,0 +1,460 @@ +import { EMPTY_STRING } from "front-end/config"; +import { Route } from "front-end/lib/app/types"; +import * as Table from "front-end/lib/components/table"; +import { + Immutable, + immutable, + component as component_ +} from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import * as Tab from "front-end/lib/pages/opportunity/sprint-with-us/edit/tab"; +import * as toasts from "front-end/lib/pages/opportunity/sprint-with-us/lib/toasts"; +import EditTabHeader from "front-end/lib/pages/opportunity/sprint-with-us/lib/views/edit-tab-header"; +import Link, { routeDest } from "front-end/lib/views/link"; +import ReportCardList, { + ReportCard +} from "front-end/lib/views/report-card-list"; +import React from "react"; +import { Col, Row } from "reactstrap"; +import { + canViewSWUOpportunityTeamQuestionResponseEvaluations, + isSWUOpportunityAcceptingProposals, + SWUOpportunity, + SWUOpportunityStatus +} from "shared/lib/resources/opportunity/sprint-with-us"; +import { + compareSWUProposalAnonymousProponentNumber, + getSWUProponentName, + NUM_SCORE_DECIMALS, + SWUProposalSlim, + UpdateValidationErrors +} from "shared/lib/resources/proposal/sprint-with-us"; +import { + SWUTeamQuestionResponseEvaluation, + canSWUTeamQuestionResponseEvaluationBeSubmitted +} from "shared/lib/resources/question-evaluation/sprint-with-us"; +import { ADT, adt } from "shared/lib/types"; + +export interface State extends Tab.Params { + opportunity: SWUOpportunity | null; + submitLoading: boolean; + canEvaluationsBeSubmitted: boolean; + canViewEvaluations: boolean; + evaluations: SWUTeamQuestionResponseEvaluation[]; + proposals: SWUProposalSlim[]; + table: Immutable; +} + +export type InnerMsg = + | ADT<"onInitResponse", Tab.InitResponse> + | ADT<"table", Table.Msg> + | ADT<"submit"> + | ADT< + "onSubmitResponse", + api.ResponseValidation< + SWUTeamQuestionResponseEvaluation, + UpdateValidationErrors + >[] + >; + +export type Msg = component_.page.Msg; + +const init: component_.base.Init = (params) => { + const [tableState, tableCmds] = Table.init({ + idNamespace: "evaluations-table" + }); + return [ + { + ...params, + opportunity: null, + submitLoading: false, + canViewEvaluations: false, + canEvaluationsBeSubmitted: false, + evaluations: [], + proposals: [], + table: immutable(tableState) + }, + component_.cmd.mapMany(tableCmds, (msg) => adt("table", msg) as Msg) + ]; +}; + +const update: component_.page.Update = ({ + state, + msg +}) => { + switch (msg.tag) { + case "onInitResponse": { + const opportunity = msg.value[0]; + const proposals = msg.value[1].sort((a, b) => + compareSWUProposalAnonymousProponentNumber(a, b) + ); + const evaluations = msg.value[2]; + const canViewEvaluations = + canViewSWUOpportunityTeamQuestionResponseEvaluations(opportunity); + return [ + state + .set("opportunity", opportunity) + .set("evaluations", evaluations) + .set("proposals", proposals) + .set("canViewEvaluations", canViewEvaluations) + // Determine whether the "Submit" button should be shown at all. + // Can be submitted if... + // - Opportunity has the appropriate status; and + // - All questions have been evaluated. + .set( + "canEvaluationsBeSubmitted", + opportunity.status === + SWUOpportunityStatus.EvaluationTeamQuestionsPanel && + evaluations.reduce( + (acc, e) => + acc || + canSWUTeamQuestionResponseEvaluationBeSubmitted( + e, + opportunity + ), + false as boolean + ) + ), + [component_.cmd.dispatch(component_.page.readyMsg())] + ]; + } + + case "submit": { + const opportunity = state.opportunity; + if (!opportunity) return [state, []]; + return [ + state.set("submitLoading", true), + [ + component_.cmd.map( + component_.cmd.sequence( + state.evaluations.map(({ id }) => + api.evaluations.swu.update< + api.ResponseValidation< + SWUTeamQuestionResponseEvaluation, + UpdateValidationErrors + > + >()(id, adt("submit", ""), (response) => response) + ) + ), + (evaluationResponses) => + adt("onSubmitResponse", evaluationResponses) + ) + ] + ]; + } + + case "onSubmitResponse": { + const opportunity = state.opportunity; + if (!opportunity) return [state, []]; + state = state.set("submitLoading", false); + const result = msg.value; + if (result.some((e) => e.tag === "valid" || e.tag === "unhandled")) { + return [ + state, + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt( + "error", + toasts.statusChanged.error(SWUOpportunityStatus.Awarded) + ) + ) + ) + ] + ]; + } + return [ + state, + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt( + "success", + toasts.statusChanged.success(SWUOpportunityStatus.Awarded) + ) + ) + ), + component_.cmd.join3( + api.opportunities.swu.readOne()(opportunity.id, (response) => + api.getValidValue(response, opportunity) + ), + api.proposals.swu.readMany(opportunity.id)((response) => + api.getValidValue(response, state.proposals) + ), + api.evaluations.swu.readMany({ opportunityId: opportunity.id })( + (response) => api.getValidValue(response, state.evaluations) + ), + (newOpp, newProposals, newEvaluations) => + adt("onInitResponse", [ + newOpp, + newProposals, + newEvaluations + ]) as Msg + ) + ] + ]; + } + + case "table": + return component_.base.updateChild({ + state, + childStatePath: ["table"], + childUpdate: Table.update, + childMsg: msg.value, + mapChildMsg: (value) => ({ tag: "table", value }) + }); + + default: + return [state, []]; + } +}; + +const NotAvailable: component_.base.ComponentView = ({ state }) => { + const opportunity = state.opportunity; + if (!opportunity) return null; + if (isSWUOpportunityAcceptingProposals(opportunity)) { + return ( +
+ Evaluations will be displayed here once this opportunity has closed. +
+ ); + } else { + return
No proposals were submitted to this opportunity.
; + } +}; + +const ContextMenuCell: component_.base.View<{ + disabled: boolean; + proposal: SWUProposalSlim; + evaluation?: SWUTeamQuestionResponseEvaluation; +}> = ({ disabled, proposal, evaluation }) => { + const proposalRouteParams = { + proposalId: proposal.id, + opportunityId: proposal.opportunity.id, + tab: "teamQuestions" as const + }; + return evaluation ? ( + + Edit + + ) : ( + + Start Evaluation + + ); +}; + +interface ProponentCellProps { + proposal: SWUProposalSlim; + opportunity: SWUOpportunity; + disabled: boolean; +} + +const ProponentCell: component_.base.View = ({ + proposal, + opportunity, + disabled +}) => { + const proposalRouteParams = { + proposalId: proposal.id, + opportunityId: opportunity.id, + tab: "proposal" as const + }; + return ( +
+ + {getSWUProponentName(proposal)} + + {(() => { + if (!proposal.organization) { + return null; + } + return ( +
+ {proposal.anonymousProponentName} +
+ ); + })()} +
+ ); +}; + +function evaluationTableBodyRows(state: Immutable): Table.BodyRows { + const opportunity = state.opportunity; + if (!opportunity) return []; + const issubmitLoading = !!state.submitLoading; + const isLoading = issubmitLoading; + return state.proposals.map((p) => { + const evaluation = state.evaluations.find((e) => e.proposal.id === p.id); + return [ + { + className: "text-wrap", + children: ( + + ) + }, + ...opportunity.teamQuestions.map((tq) => { + const score = evaluation?.scores[tq.order]?.score; + return { + className: "text-center", + children: ( +
+ {score ? `${score.toFixed(NUM_SCORE_DECIMALS)}` : EMPTY_STRING} +
+ ) + }; + }), + { + className: "text-center", + children: ( + + ) + } + ]; + }); +} + +function evaluationTableHeadCells(state: Immutable): Table.HeadCells { + return [ + { + children: "Participant", + className: "text-nowrap", + style: { width: "100%", minWidth: "200px" } + }, + ...(state.opportunity?.teamQuestions.map((tq) => ({ + children: `Q${tq.order + 1}`, + className: "text-nowrap text-center", + style: { width: "0px" } + })) ?? []), + { + children: "Action", + className: "text-nowrap text-center", + style: { width: "0px" } + } + ]; +} + +const ProponentEvaluations: component_.base.ComponentView = ({ + state, + dispatch +}) => { + return ( + + adt("table" as const, msg) + )} + /> + ); +}; + +const makeCardData = ( + opportunity: SWUOpportunity, + proposals: SWUProposalSlim[] +): ReportCard[] => { + const numProposals = opportunity.reporting?.numProposals || 0; + const [highestScore, averageScore] = proposals.reduce( + ([highest, average], { totalScore }, i) => { + if (!totalScore) { + return [highest, average]; + } + return [ + totalScore > highest ? totalScore : highest, + (average * i + totalScore) / (i + 1) + ]; + }, + [0, 0] + ); + const isAwarded = opportunity.status === SWUOpportunityStatus.Awarded; + return [ + { + icon: "comment-dollar", + name: `Proposal${numProposals === 1 ? "" : "s"}`, + value: numProposals ? String(numProposals) : EMPTY_STRING + }, + { + icon: "star-full", + iconColor: "c-report-card-icon-highlight", + name: "Top Score", + value: + isAwarded && highestScore + ? `${highestScore.toFixed(NUM_SCORE_DECIMALS)}%` + : EMPTY_STRING + }, + { + icon: "star-half", + iconColor: "c-report-card-icon-highlight", + name: "Avg. Score", + value: + isAwarded && averageScore + ? `${averageScore.toFixed(NUM_SCORE_DECIMALS)}%` + : EMPTY_STRING + } + ]; +}; + +const view: component_.page.View = (props) => { + const { state } = props; + const opportunity = state.opportunity; + if (!opportunity) return null; + const cardData = makeCardData(opportunity, state.proposals); + return ( +
+ + + + + + +
+ + +

Overview

+ + + {state.canViewEvaluations && state.proposals.length ? ( + + ) : ( + + )} + +
+
+
+ ); +}; + +export const component: Tab.Component = { + init, + update, + view, + + onInitResponse(response) { + return adt("onInitResponse", response); + } +}; diff --git a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/index.ts b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/index.ts index 31329ade9..7640824cc 100644 --- a/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/index.ts +++ b/src/front-end/typescript/lib/pages/opportunity/sprint-with-us/lib/index.ts @@ -18,9 +18,7 @@ export function swuOpportunityStatusToColor( return "warning"; case SWUOpportunityStatus.Published: return "success"; - case SWUOpportunityStatus.TeamQuestionsPanelEvaluation: - return "warning"; - case SWUOpportunityStatus.TeamQuestionsPanelConsensus: + case SWUOpportunityStatus.EvaluationTeamQuestionsPanel: return "warning"; case SWUOpportunityStatus.EvaluationTeamQuestions: return "warning"; @@ -47,10 +45,8 @@ export function swuOpportunityStatusToTitleCase( return "Under Review"; case SWUOpportunityStatus.Published: return "Published"; - case SWUOpportunityStatus.TeamQuestionsPanelEvaluation: + case SWUOpportunityStatus.EvaluationTeamQuestionsPanel: return "Evaluation"; - case SWUOpportunityStatus.TeamQuestionsPanelConsensus: - return "Consensus"; case SWUOpportunityStatus.EvaluationTeamQuestions: return "Team Questions"; case SWUOpportunityStatus.EvaluationCodeChallenge: diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/index.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/index.ts index b25976dd8..414d780ba 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/index.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/index.ts @@ -18,6 +18,8 @@ export function swuProposalStatusToColor( case SWUProposalStatus.UnderReviewTeamQuestions: case SWUProposalStatus.UnderReviewCodeChallenge: case SWUProposalStatus.UnderReviewTeamScenario: + case SWUProposalStatus.TeamQuestionsPanelIndividual: + case SWUProposalStatus.TeamQuestionsPanelConsensus: return "warning"; case SWUProposalStatus.EvaluatedTeamQuestions: case SWUProposalStatus.EvaluatedCodeChallenge: @@ -40,6 +42,14 @@ export function swuProposalStatusToTitleCase( return "Draft"; case SWUProposalStatus.Submitted: return "Submitted"; + case SWUProposalStatus.TeamQuestionsPanelIndividual: + return viewerUserType === UserType.Vendor + ? "Under Review" + : "Panel Evaluation (Individual)"; + case SWUProposalStatus.TeamQuestionsPanelConsensus: + return viewerUserType === UserType.Vendor + ? "Under Review" + : "Panel Evaluation (Consensus)"; case SWUProposalStatus.UnderReviewTeamQuestions: return viewerUserType === UserType.Vendor ? "Under Review" diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts index a8f21b8e1..fc2affc56 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/lib/toasts.ts @@ -57,6 +57,28 @@ export const scored = { }) }; +export const questionEvaluationDraftCreated = { + success: { + title: "Draft Evaluation Saved", + body: "Your draft Sprint With Us evaluation has been saved. You can return to this page to modify your evaluation prior to submission." + }, + error: { + title: "Unable to Save Draft Evaluation", + body: "Your draft Sprint With Us evaluation could not be saved. Please try again later." + } +}; + +export const questionEvaluationChangesSaved = { + success: { + title: "Evaluation Changes Saved", + body: "Your changes to your Sprint With Us evaluation have been saved." + }, + error: { + title: "Unable to Save Evaluation", + body: "Your changes to your Sprint With Us evaluation could not be saved. Please try again later." + } +}; + export const screenedIn = { success: { title: "Proposal Screened In", diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx index f8cbc837f..ad6160a97 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/index.tsx @@ -26,9 +26,12 @@ import { UserType, User } from "shared/lib/resources/user"; import { adt, ADT, Id } from "shared/lib/types"; import { invalid, valid, Validation } from "shared/lib/validation"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; +import { getTeamQuestionsOpportunityTab } from "./tab/team-questions"; interface ValidState extends Tab.ParentState { proposal: SWUProposal | null; + questionEvaluations: SWUTeamQuestionResponseEvaluation[]; } export type State_ = Validation< @@ -40,7 +43,18 @@ export type State = State_; export type InnerMsg_ = Tab.ParentInnerMsg< K, - ADT<"onInitResponse", [User, RouteParams, SWUProposal, SWUOpportunity]> + ADT< + "onInitResponse", + [ + User, + RouteParams, + SWUProposal, + SWUOpportunity, + boolean, + SWUTeamQuestionResponseEvaluation | undefined, + SWUTeamQuestionResponseEvaluation[] + ] + > >; export type InnerMsg = InnerMsg_; @@ -71,7 +85,8 @@ function makeInit(): component_.page.Init< immutable({ proposal: null, tab: null, - sidebar: null + sidebar: null, + questionEvaluations: [] }) ) as State_, [ @@ -91,7 +106,10 @@ function makeInit(): component_.page.Init< shared.sessionUser, routeParams, proposal, - opportunity + opportunity, + false, + undefined, + [] ]) as Msg; } ) @@ -139,21 +157,35 @@ function makeComponent(): component_.page.Component< extraUpdate: ({ state, msg }) => { switch (msg.tag) { case "onInitResponse": { - const [viewerUser, routeParams, proposal, opportunity] = - msg.value; + const [ + viewerUser, + routeParams, + proposal, + opportunity, + evaluating, + questionEvaluation, + panelQuestionEvaluations + ] = msg.value; // Set up the visible tab state. const tabId = routeParams.tab || "proposal"; // Initialize the sidebar. const [sidebarState, sidebarCmds] = Tab.makeSidebarState( tabId, - proposal + proposal, + getTeamQuestionsOpportunityTab( + evaluating, + panelQuestionEvaluations + ) ); // Initialize the tab. const tabComponent = Tab.idToDefinition(tabId).component; const [tabState, tabCmds] = tabComponent.init({ viewerUser, proposal, - opportunity + opportunity, + evaluating, + questionEvaluation, + panelQuestionEvaluations }); // Everything checks out, return valid state. return [ diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts index 7f8dfb806..db001c073 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/index.ts @@ -9,6 +9,7 @@ import * as TeamScenarioTab from "front-end/lib/pages/proposal/sprint-with-us/vi import { routeDest } from "front-end/lib/views/link"; import { SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; import { SWUProposal } from "shared/lib/resources/proposal/sprint-with-us"; +import { SWUTeamQuestionResponseEvaluation } from "shared/lib/resources/question-evaluation/sprint-with-us"; import { User } from "shared/lib/resources/user"; import { adt } from "shared/lib/types"; @@ -33,6 +34,9 @@ export interface Params { proposal: SWUProposal; opportunity: SWUOpportunity; viewerUser: User; + evaluating: boolean; + questionEvaluation?: SWUTeamQuestionResponseEvaluation; + panelQuestionEvaluations: SWUTeamQuestionResponseEvaluation[]; } export type InitResponse = null; @@ -156,7 +160,8 @@ export function makeSidebarLink( export function makeSidebarState( activeTab: TabId, - proposal: SWUProposal + proposal: SWUProposal, + teamQuestionsTab: "consensus" | "overview" | "teamQuestions" ): component.base.InitReturnValue { return MenuSidebar.init({ backLink: { @@ -170,7 +175,7 @@ export function makeSidebarState( case "teamScenario": return "teamScenario" as const; case "teamQuestions": - return "teamQuestions" as const; + return teamQuestionsTab; case "proposal": case "history": default: @@ -191,3 +196,8 @@ export function makeSidebarState( ] }); } + +export function shouldLoadEvaluationsForTab(tabId: TabId): boolean { + const evaluationTabs: TabId[] = ["teamQuestions"]; + return evaluationTabs.includes(tabId); +} diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/proposal.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/proposal.tsx index db4989099..311a8635d 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/proposal.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/proposal.tsx @@ -518,6 +518,8 @@ export const component: Tab.Component = { onClick: () => dispatch(adt("showModal", "award" as const)) } ]); + case SWUProposalStatus.TeamQuestionsPanelIndividual: + case SWUProposalStatus.TeamQuestionsPanelConsensus: case SWUProposalStatus.Draft: case SWUProposalStatus.Submitted: case SWUProposalStatus.Withdrawn: diff --git a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx index 5d716b8d5..93ad81307 100644 --- a/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx +++ b/src/front-end/typescript/lib/pages/proposal/sprint-with-us/view/tab/team-questions.tsx @@ -1,8 +1,9 @@ import { EMPTY_STRING } from "front-end/config"; -import { makeStartLoading, makeStopLoading } from "front-end/lib"; +// import { makeStartLoading, makeStopLoading } from "front-end/lib"; import { Route } from "front-end/lib/app/types"; import * as FormField from "front-end/lib/components/form-field"; import * as NumberField from "front-end/lib/components/form-field/number"; +import * as LongText from "front-end/lib/components/form-field/long-text"; import { Immutable, immutable, @@ -13,7 +14,7 @@ import * as toasts from "front-end/lib/pages/proposal/sprint-with-us/lib/toasts" import ViewTabHeader from "front-end/lib/pages/proposal/sprint-with-us/lib/views/view-tab-header"; import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; import Accordion from "front-end/lib/views/accordion"; -import { iconLinkSymbol, leftPlacement } from "front-end/lib/views/link"; +// import { iconLinkSymbol, leftPlacement } from "front-end/lib/views/link"; import { ProposalMarkdown } from "front-end/lib/views/markdown"; import ReportCardList from "front-end/lib/views/report-card-list"; import Separator from "front-end/lib/views/separator"; @@ -21,111 +22,199 @@ import React from "react"; import { Alert, Col, Row } from "reactstrap"; import { countWords } from "shared/lib"; import { - canSWUOpportunityBeScreenedInToCodeChallenge, + // canSWUOpportunityBeScreenedInToCodeChallenge, getQuestionByOrder, - hasSWUOpportunityPassedCodeChallenge, + // hasSWUOpportunityPassedCodeChallenge, hasSWUOpportunityPassedTeamQuestions, + hasSWUOpportunityPassedTeamQuestionsEvaluation, SWUOpportunity } from "shared/lib/resources/opportunity/sprint-with-us"; import { NUM_SCORE_DECIMALS, SWUProposal, SWUProposalStatus, - SWUProposalTeamQuestionResponse, - UpdateTeamQuestionScoreBody, - UpdateValidationErrors + // SWUProposalStatus, + SWUProposalTeamQuestionResponse } from "shared/lib/resources/proposal/sprint-with-us"; +import { + CreateValidationErrors, + getEvaluationScoreByOrder, + SWUTeamQuestionResponseEvaluation, + SWUTeamQuestionResponseEvaluationScores, + SWUTeamQuestionResponseEvaluationStatus, + SWUTeamQuestionResponseEvaluationType, + UpdateValidationErrors +} from "shared/lib/resources/question-evaluation/sprint-with-us"; import { adt, ADT } from "shared/lib/types"; import { invalid } from "shared/lib/validation"; -import { validateTeamQuestionScoreScore } from "shared/lib/validation/proposal/sprint-with-us"; +import { + validateSWUTeamQuestionResponseEvaluationScoreNotes, + validateSWUTeamQuestionResponseEvaluationScoreScore +} from "shared/lib/validation/question-evaluation/sprint-with-us"; +import { makeStartLoading, makeStopLoading } from "front-end/lib"; +import { leftPlacement, iconLinkSymbol } from "front-end/lib/views/link"; + +interface EvaluationScore { + score: Immutable; + notes: Immutable; +} -type ModalId = "enterScore"; +type ModalId = ADT<"cancelDraft">; export interface State extends Tab.Params { showModal: ModalId | null; - enterScoreLoading: number; + startEditingLoading: number; + saveLoading: number; screenToFromLoading: number; openAccordions: Set; - scores: Array>; + evaluationScores: EvaluationScore[]; + isEditing: boolean; } export type InnerMsg = | ADT<"toggleAccordion", number> | ADT<"showModal", ModalId> | ADT<"hideModal"> - | ADT<"submitScore"> + | ADT<"startEditing"> | ADT< - "onSubmitScoreResponse", - api.ResponseValidation + "onStartEditingResponse", + api.ResponseValidation + > + | ADT<"cancelEditing"> + | ADT<"cancel"> + | ADT<"saveDraft"> + | ADT< + "onSaveDraftResponse", + api.ResponseValidation< + SWUTeamQuestionResponseEvaluation, + CreateValidationErrors + > + > + | ADT<"saveChanges"> + | ADT< + "onSaveChangesResponse", + api.ResponseValidation< + SWUTeamQuestionResponseEvaluation, + UpdateValidationErrors + > > | ADT<"screenIn"> | ADT<"onScreenInResponse", SWUProposal | null> | ADT<"screenOut"> | ADT<"onScreenOutResponse", SWUProposal | null> - | ADT<"scoreMsg", [number, NumberField.Msg]>; //[index, msg] + | ADT<"scoreMsg", { childMsg: NumberField.Msg; rIndex: number }> + | ADT<"notesMsg", { childMsg: LongText.Msg; rIndex: number }>; export type Msg = component_.page.Msg; -function initScores( +export function getTeamQuestionsOpportunityTab( + evaluating: boolean, + panelEvaluations: SWUTeamQuestionResponseEvaluation[] +) { + return evaluating + ? panelEvaluations.length + ? ("consensus" as const) + : ("overview" as const) + : ("teamQuestions" as const); +} + +function initEvaluationScores( opp: SWUOpportunity, - prop: SWUProposal -): [Immutable[], component_.Cmd[]] { - return (prop.teamQuestionResponses || []).reduce( - ([states, cmds], r, i) => { + prop: SWUProposal, + evaluation?: SWUTeamQuestionResponseEvaluation +): [EvaluationScore[], component_.Cmd[]] { + return prop.teamQuestionResponses.reduce< + [EvaluationScore[], component_.Cmd[]] + >( + ([states, cmds], r, rIndex) => { const question = getQuestionByOrder(opp, r.order); - const [state, cmd] = NumberField.init({ + const score = evaluation + ? getEvaluationScoreByOrder(evaluation, r.order) + : null; + + const [scoreState, scoreCmds] = NumberField.init({ errors: [], validate: (v) => { if (v === null) { return invalid(["Please enter a valid score."]); } - return validateTeamQuestionScoreScore(v, question?.score || 0); + return validateSWUTeamQuestionResponseEvaluationScoreScore( + v, + question?.score || 0 + ); }, child: { step: 0.01, - value: r.score === null || r.score === undefined ? null : r.score, - id: `swu-proposal-question-score-${i}` + value: score?.score ?? null, + id: `swu-proposal-question-evaluation-score-${rIndex}` } }); + const [notesState, notesCmds] = LongText.init({ + errors: [], + validate: validateSWUTeamQuestionResponseEvaluationScoreNotes, + child: { + value: score?.notes ?? "", + id: `swu-proposal-question-evaluation-notes-${rIndex}` + } + }); + return [ - [...states, immutable(state)], + [ + ...states, + { score: immutable(scoreState), notes: immutable(notesState) } + ], [ ...cmds, ...component_.cmd.mapMany( - cmd, - (msg) => adt("scoreMsg", [i, msg]) as Msg + scoreCmds, + (childMsg) => + adt("scoreMsg", { + rIndex, + childMsg + }) as Msg + ), + ...component_.cmd.mapMany( + notesCmds, + (childMsg) => + adt("notesMsg", { + rIndex, + childMsg + }) as Msg ) ] ]; }, - [[], []] as [Immutable[], component_.Cmd[]] + [[], []] ); } -const init: component_.base.Init = (params) => { - const [scoreStates, scoreCmds] = initScores( +export const init: component_.base.Init = (params) => { + const [evaluationScoreStates, evaluationScoreCmds] = initEvaluationScores( params.opportunity, - params.proposal + params.proposal, + params.questionEvaluation ); return [ { ...params, showModal: null, screenToFromLoading: 0, - enterScoreLoading: 0, + saveLoading: 0, + startEditingLoading: 0, openAccordions: new Set( params.proposal.teamQuestionResponses.map((p, i) => i) ), - scores: scoreStates + evaluationScores: evaluationScoreStates, + isEditing: !params.questionEvaluation }, - scoreCmds + evaluationScoreCmds ]; }; -const startScreenToFromLoading = makeStartLoading("screenToFromLoading"); -const stopScreenToFromLoading = makeStopLoading("screenToFromLoading"); -const startEnterScoreLoading = makeStartLoading("enterScoreLoading"); -const stopEnterScoreLoading = makeStopLoading("enterScoreLoading"); +const startSaveLoading = makeStartLoading("saveLoading"); +const stopSaveLoading = makeStopLoading("saveLoading"); +const startStartEditingLoading = makeStartLoading("startEditingLoading"); +const stopStartEditingLoading = makeStopLoading("startEditingLoading"); const update: component_.base.Update = ({ state, msg }) => { switch (msg.tag) { @@ -144,86 +233,159 @@ const update: component_.base.Update = ({ state, msg }) => { case "showModal": return [state.set("showModal", msg.value), []]; case "hideModal": - if (state.enterScoreLoading > 0) { + if (state.saveLoading > 0) { return [state, []]; } return [state.set("showModal", null), []]; - case "submitScore": { - const scores = state.proposal.teamQuestionResponses.reduce( - (acc, r, i) => { - if (!acc) { - return null; - } - const field = state.scores[i]; - if (!field) { - return null; - } - const score = FormField.getValue(field); - if (score === null) { - return null; - } - acc.push({ - order: r.order, - score - }); - return acc; - }, - [] as UpdateTeamQuestionScoreBody[] | null + case "startEditing": { + const evaluation = state.questionEvaluation; + if (!evaluation) return [state, []]; + return [ + startStartEditingLoading(state), + [ + api.evaluations.swu.readOne()(evaluation.id, (result) => + adt("onStartEditingResponse", result) + ) + ] + ]; + } + case "onStartEditingResponse": { + const evaluation = state.questionEvaluation; + if (!evaluation) return [state, []]; + const evaluationResult = msg.value; + state = stopStartEditingLoading(state); + if (!api.isValid(evaluationResult)) { + return [state, []]; + } + const [evaluationScoreStates, evaluationScoreCmds] = initEvaluationScores( + state.opportunity, + state.proposal, + evaluationResult.value + ); + return [ + state + .set("isEditing", true) + .set("evaluationScores", evaluationScoreStates), + evaluationScoreCmds + ]; + } + case "cancelEditing": { + const evaluation = state.evaluationScores; + if (!evaluation) return [state, []]; + const [evaluationScoreStates, evaluationScoreCmds] = initEvaluationScores( + state.opportunity, + state.proposal, + state.questionEvaluation ); + return [ + state + .set("isEditing", false) + .set("evaluationScores", evaluationScoreStates), + evaluationScoreCmds + ]; + } + case "cancel": + return [ + state, + [ + component_.cmd.dispatch( + component_.global.newRouteMsg( + adt("opportunitySWUEdit" as const, { + opportunityId: state.opportunity.id, + tab: (() => + getTeamQuestionsOpportunityTab( + state.evaluating, + state.panelQuestionEvaluations + ))() + }) + ) + ) + ] + ]; + case "saveDraft": { + const scores = getValues(state); if (scores === null) { return [state, []]; } + return [ - startEnterScoreLoading(state), + startSaveLoading(state), [ - api.proposals.swu.update()( - state.proposal.id, - adt("scoreQuestions", scores), - (response) => adt("onSubmitScoreResponse", response) + api.evaluations.swu.create()( + { + proposal: state.proposal.id, + type: + state.proposal.status === + SWUProposalStatus.TeamQuestionsPanelIndividual + ? SWUTeamQuestionResponseEvaluationType.Individual + : SWUTeamQuestionResponseEvaluationType.Consensus, + status: SWUTeamQuestionResponseEvaluationStatus.Draft, + scores + }, + (response) => adt("onSaveDraftResponse", response) ) ] ]; } - case "onSubmitScoreResponse": { - state = stopEnterScoreLoading(state); + case "onSaveDraftResponse": { + state = stopSaveLoading(state); const result = msg.value; switch (result.tag) { case "valid": { - const [scoreStates, scoreCmds] = initScores( - state.opportunity, - result.value - ); + const [evaluationScoreStates, evaluationScoreCmds] = + initEvaluationScores( + state.opportunity, + state.proposal, + result.value + ); return [ - state - .set("scores", scoreStates) - .set("showModal", null) - .set("proposal", result.value), + state.set("evaluationScores", evaluationScoreStates), [ - ...scoreCmds, + ...evaluationScoreCmds, + component_.cmd.dispatch( + component_.global.newRouteMsg( + adt( + result.value.type === + SWUTeamQuestionResponseEvaluationType.Individual + ? "questionEvaluationIndividualSWUEdit" + : "questionEvaluationConsensusSWUEdit", + { + proposalId: state.proposal.id, + opportunityId: state.proposal.opportunity.id, + evaluationId: result.value.id, + tab: "teamQuestions" as const + } + ) as Route + ) + ), component_.cmd.dispatch( component_.global.showToastMsg( - adt("success", toasts.scored.success("Team Questions")) + adt("success", toasts.questionEvaluationDraftCreated.success) ) ) ] ]; } case "invalid": { - let scores = state.scores; - if ( - result.value.proposal && - result.value.proposal.tag === "scoreQuestions" - ) { - scores = result.value.proposal.value.map((e, i) => - FormField.setErrors(scores[i], e.score || []) - ); + let evaluationScores = state.evaluationScores; + if (result.value) { + evaluationScores = (result.value.scores ?? []).map((e, i) => ({ + notes: FormField.setErrors( + evaluationScores[i].notes, + e.notes || [] + ), + score: FormField.setErrors( + evaluationScores[i].score, + e.score || [] + ) + })); } return [ - state.set("scores", scores), + state.set("evaluationScores", evaluationScores), [ component_.cmd.dispatch( component_.global.showToastMsg( - adt("error", toasts.scored.error("Team Questions")) + adt("error", toasts.questionEvaluationDraftCreated.error) ) ) ] @@ -236,102 +398,229 @@ const update: component_.base.Update = ({ state, msg }) => { [ component_.cmd.dispatch( component_.global.showToastMsg( - adt("error", toasts.scored.error("Team Questions")) + adt("error", toasts.questionEvaluationDraftCreated.error) ) ) ] ]; } } - case "screenIn": - return [ - startScreenToFromLoading(state).set("showModal", null), - [ - api.proposals.swu.update()( - state.proposal.id, - adt("screenInToCodeChallenge", ""), - (response) => - adt( - "onScreenInResponse", - api.isValid(response) ? response.value : null - ) - ) - ] - ]; - case "onScreenInResponse": { - state = stopScreenToFromLoading(state); - const proposal = msg.value; - if (proposal) { - const [scoreStates, scoreCmds] = initScores( - state.opportunity, - proposal - ); - return [ - state.set("scores", scoreStates).set("proposal", proposal), - [ - ...scoreCmds, - component_.cmd.dispatch( - component_.global.showToastMsg( - adt("success", toasts.screenedIn.success) - ) - ) - ] - ]; - } else { + case "saveChanges": { + const scores = getValues(state); + if (scores === null) { return [state, []]; } - } - case "screenOut": + return [ - startScreenToFromLoading(state).set("showModal", null), - [ - api.proposals.swu.update()( - state.proposal.id, - adt("screenOutFromCodeChallenge", ""), - (response) => - adt( - "onScreenOutResponse", - api.isValid(response) ? response.value : null + startSaveLoading(state), + state.questionEvaluation + ? [ + api.evaluations.swu.update()( + state.questionEvaluation.id, + adt("edit", { scores }), + (response) => adt("onSaveDraftResponse", response) ) - ) - ] + ] + : [] ]; - case "onScreenOutResponse": { - state = stopScreenToFromLoading(state); - const proposal = msg.value; - if (proposal) { - const [scoreStates, scoreCmds] = initScores( - state.opportunity, - proposal - ); - return [ - state.set("scores", scoreStates).set("proposal", proposal), - [ - ...scoreCmds, - component_.cmd.dispatch( - component_.global.showToastMsg( - adt("success", toasts.screenedOut.success) + } + case "onSaveChangesResponse": { + state = stopSaveLoading(state); + const result = msg.value; + switch (result.tag) { + case "valid": { + const [evaluationScoreStates, evaluationScoreCmds] = + initEvaluationScores( + state.opportunity, + state.proposal, + result.value + ); + return [ + state.set("evaluationScores", evaluationScoreStates), + [ + ...evaluationScoreCmds, + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("success", toasts.questionEvaluationChangesSaved.success) + ) ) - ) - ] - ]; - } else { - return [state, []]; + ] + ]; + } + case "invalid": { + let evaluationScores = state.evaluationScores; + if (result.value.evaluation?.tag === "edit") { + evaluationScores = (result.value.evaluation.value.scores ?? []).map( + (e, i) => ({ + notes: FormField.setErrors( + evaluationScores[i].notes, + e.notes || [] + ), + score: FormField.setErrors( + evaluationScores[i].score, + e.score || [] + ) + }) + ); + } + return [ + state.set("evaluationScores", evaluationScores), + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("error", toasts.questionEvaluationChangesSaved.error) + ) + ) + ] + ]; + } + case "unhandled": + default: + return [ + state, + [ + component_.cmd.dispatch( + component_.global.showToastMsg( + adt("error", toasts.questionEvaluationChangesSaved.error) + ) + ) + ] + ]; } } + // case "screenIn": + // return [ + // startScreenToFromLoading(state).set("showModal", null), + // [ + // api.proposals.swu.update()( + // state.proposal.id, + // adt("screenInToCodeChallenge", ""), + // (response) => + // adt( + // "onScreenInResponse", + // api.isValid(response) ? response.value : null + // ) + // ) + // ] + // ]; + // case "onScreenInResponse": { + // state = stopScreenToFromLoading(state); + // const proposal = msg.value; + // if (proposal) { + // const [scoreStates, scoreCmds] = initScores( + // state.opportunity, + // proposal + // ); + // return [ + // state.set("scores", scoreStates).set("proposal", proposal), + // [ + // ...scoreCmds, + // component_.cmd.dispatch( + // component_.global.showToastMsg( + // adt("success", toasts.screenedIn.success) + // ) + // ) + // ] + // ]; + // } else { + // return [state, []]; + // } + // } + // case "screenOut": + // return [ + // startScreenToFromLoading(state).set("showModal", null), + // [ + // api.proposals.swu.update()( + // state.proposal.id, + // adt("screenOutFromCodeChallenge", ""), + // (response) => + // adt( + // "onScreenOutResponse", + // api.isValid(response) ? response.value : null + // ) + // ) + // ] + // ]; + // case "onScreenOutResponse": { + // state = stopScreenToFromLoading(state); + // const proposal = msg.value; + // if (proposal) { + // const [scoreStates, scoreCmds] = initScores( + // state.opportunity, + // proposal + // ); + // return [ + // state.set("scores", scoreStates).set("proposal", proposal), + // [ + // ...scoreCmds, + // component_.cmd.dispatch( + // component_.global.showToastMsg( + // adt("success", toasts.screenedOut.success) + // ) + // ) + // ] + // ]; + // } else { + // return [state, []]; + // } + // } case "scoreMsg": return component_.base.updateChild({ state, - childStatePath: ["scores", String(msg.value[0])], + childStatePath: ["evaluationScores", String(msg.value.rIndex), "score"], childUpdate: NumberField.update, - childMsg: msg.value[1], - mapChildMsg: (value) => adt("scoreMsg", [msg.value[0], value]) as Msg + childMsg: msg.value.childMsg, + mapChildMsg: (value) => + adt("scoreMsg", { + rIndex: msg.value.rIndex, + childMsg: value + }) as Msg + }); + case "notesMsg": + return component_.base.updateChild({ + state, + childStatePath: ["evaluationScores", String(msg.value.rIndex), "notes"], + childUpdate: LongText.update, + childMsg: msg.value.childMsg, + mapChildMsg: (value) => + adt("notesMsg", { + rIndex: msg.value.rIndex, + childMsg: value + }) as Msg }); default: return [state, []]; } }; +export function getValues( + state: Immutable +): SWUTeamQuestionResponseEvaluationScores[] | null { + return state.proposal.teamQuestionResponses.reduce((acc, r, i) => { + if (!acc) { + return null; + } + const field = state.evaluationScores[i]; + if (!field) { + return null; + } + const score = FormField.getValue(field.score); + if (score === null) { + return null; + } + const notes = FormField.getValue(field.notes); + if (notes === null) { + return null; + } + acc.push({ + order: r.order, + score, + notes + }); + return acc; + }, [] as SWUTeamQuestionResponseEvaluationScores[] | null); +} + interface TeamQuestionResponseViewProps { opportunity: SWUOpportunity; response: SWUProposalTeamQuestionResponse; @@ -385,10 +674,10 @@ const TeamQuestionResponseView: component_.base.View< ); }; -const view: component_.base.ComponentView = ({ - state, - dispatch -}) => { +const TeamQuestionResponsesView: component_.base.View<{ + state: State; + dispatch: component_.base.Dispatch; +}> = ({ state, dispatch }) => { const show = hasSWUOpportunityPassedTeamQuestions(state.opportunity); return (
@@ -440,9 +729,195 @@ const view: component_.base.ComponentView = ({ ); }; +// interface TeamQuestionResponseEvalViewProps +// extends TeamQuestionResponseViewProps { +// individualScores: EvaluationScore[]; +// consensusScore: EvaluationScore; +// } + +// const TeamQuestionsResponseEvalIndividualView: component_.base.View< +// TeamQuestionResponseEvalViewProps +// > = () + +interface TeamQuestionResponseEvalViewProps + extends Omit { + score: EvaluationScore; + proposal: SWUProposal; + dispatch: component_.base.Dispatch; + disabled: boolean; + // panelEvaluationScores: SWUTeamQuestionResponseEvaluation[] +} + +const TeamQuestionResponseEvalView: component_.base.View< + TeamQuestionResponseEvalViewProps +> = ({ + opportunity, + response, + index, + isOpen, + className, + dispatch, + proposal, + score, + disabled +}) => { + const question = getQuestionByOrder(opportunity, response.order); + if (!question) { + return null; + } + return ( + dispatch(adt("toggleAccordion", index))} + color="info" + title={`Question ${index + 1}`} + titleClassName="h3 mb-0" + chevronWidth={1.5} + chevronHeight={1.5} + open={isOpen}> +

{question.question}

+
+
+ {countWords(response.response)} / {question.wordLimit} word + {question.wordLimit === 1 ? "" : "s"} +
+ + | + +
+ {response.score === undefined || response.score === null + ? `Unscored (${question.score} point${ + question.score === 1 ? "" : "s" + } available)` + : `${response.score} / ${question.score} point${ + question.score === 1 ? "" : "s" + }`} +
+
+ +
{question.guideline}
+
+
+ +
+ {proposal.status === SWUProposalStatus.TeamQuestionsPanelIndividual ? ( + + + + adt("notesMsg" as const, { + childMsg: value, + rIndex: index + }) + )} + /> + + + + adt("scoreMsg" as const, { + childMsg: value, + rIndex: index + }) + )} + /> + + + ) : ( +
+ )} + + ); +}; + +const TeamQuestionResponsesEvalView: component_.base.View<{ + state: State; + dispatch: component_.base.Dispatch; +}> = ({ state, dispatch }) => { + const show = hasSWUOpportunityPassedTeamQuestionsEvaluation( + state.opportunity + ); + const isStartEditingLoading = state.startEditingLoading > 0; + const isSaveLoading = state.saveLoading > 0; + const isLoading = isStartEditingLoading || isSaveLoading; + return ( +
+ + {state.proposal.questionsScore !== null && + state.proposal.questionsScore !== undefined ? ( + + + + + + ) : null} +
+ + + {show ? ( +
+

Team Questions{"'"} Responses

+ {state.proposal.teamQuestionResponses.map((r, i, rs) => ( + + ))} +
+ ) : ( + "This proposal's team questions will be available once the opportunity closes." + )} + +
+
+
+ ); +}; + +const view: component_.base.ComponentView = ({ + state, + dispatch +}) => { + return state.evaluating ? ( + + ) : ( + + ); +}; + function isValid(state: Immutable): boolean { - return state.scores.reduce( - (acc, s) => acc && FormField.isValid(s), + return state.evaluationScores.reduce( + (acc, s) => acc && FormField.isValid(s.notes) && FormField.isValid(s.score), true as boolean ); } @@ -457,55 +932,29 @@ export const component: Tab.Component = { }, getModal: (state) => { - const isEnterScoreLoading = state.enterScoreLoading > 0; - const valid = isValid(state); - switch (state.showModal) { - case "enterScore": - return component_.page.modal.show({ - title: "Enter Score", - onCloseMsg: adt("hideModal") as Msg, + if (!state.showModal) { + return component_.page.modal.hide(); + } + switch (state.showModal.tag) { + case "cancelDraft": + return component_.page.modal.show({ + title: "Cancel New Sprint With Us Evaluation?", + body: () => + "Are you sure you want to cancel? Any information you may have entered will be lost if you do so.", + onCloseMsg: adt("hideModal"), actions: [ { - text: "Submit Score", - icon: "star-full", - color: "primary", - button: true, - loading: isEnterScoreLoading, - disabled: isEnterScoreLoading || !valid, - msg: adt("submitScore") + text: "Yes, I want to cancel", + color: "danger", + msg: adt("cancel"), + button: true }, { - text: "Cancel", + text: "Go Back", color: "secondary", - disabled: isEnterScoreLoading, msg: adt("hideModal") } - ], - body: (dispatch) => ( -
-

- Provide a score for each team question response submitted by the - proponent. -

- {state.scores.map((s, i) => { - return ( - adt("scoreMsg" as const, [i, v]) as Msg - )} - state={s} - /> - ); - })} -
- ) + ] }); case null: return component_.page.modal.hide(); @@ -515,57 +964,109 @@ export const component: Tab.Component = { getActions: ({ state, dispatch }) => { const proposal = state.proposal; const propStatus = proposal.status; - const isScreenToFromLoading = state.screenToFromLoading > 0; - switch (propStatus) { - case SWUProposalStatus.UnderReviewTeamQuestions: - return component_.page.actions.links([ - { - children: "Enter Score", - symbol_: leftPlacement(iconLinkSymbol("star-full")), - button: true, - color: "primary", - onClick: () => dispatch(adt("showModal", "enterScore" as const)) - } - ]); - case SWUProposalStatus.EvaluatedTeamQuestions: - return component_.page.actions.links([ - ...(canSWUOpportunityBeScreenedInToCodeChallenge(state.opportunity) - ? [ - { - children: "Screen In", - symbol_: leftPlacement(iconLinkSymbol("stars")), - loading: isScreenToFromLoading, - disabled: isScreenToFromLoading, - button: true, - color: "primary" as const, - onClick: () => dispatch(adt("screenIn" as const)) - } - ] - : []), - { - children: "Edit Score", - symbol_: leftPlacement(iconLinkSymbol("star-full")), - disabled: isScreenToFromLoading, - button: true, - color: "info", - onClick: () => dispatch(adt("showModal", "enterScore" as const)) - } - ]) as component_.page.Actions; - case SWUProposalStatus.UnderReviewCodeChallenge: - if (hasSWUOpportunityPassedCodeChallenge(state.opportunity)) { - return component_.page.actions.none(); + const isSaveLoading = state.saveLoading > 0; + const isStartEditingLoading = state.startEditingLoading > 0; + const isLoading = isSaveLoading || isStartEditingLoading; + const valid = isValid(state); + if (state.isEditing && state.questionEvaluation) { + return component_.page.actions.links([ + { + children: "Save Changes", + symbol_: leftPlacement(iconLinkSymbol("save")), + loading: isSaveLoading, + disabled: isLoading || !valid, + button: true, + color: "success", + onClick: () => dispatch(adt("saveChanges")) + }, + { + children: "Cancel", + color: "c-nav-fg-alt", + disabled: isLoading, + onClick: () => dispatch(adt("cancelEditing") as Msg) } - return component_.page.actions.links([ - { - children: "Screen Out", - symbol_: leftPlacement(iconLinkSymbol("ban")), - loading: isScreenToFromLoading, - disabled: isScreenToFromLoading, - button: true, - color: "danger", - onClick: () => dispatch(adt("screenOut" as const)) - } - ]); + ]); + } + switch (propStatus) { + case SWUProposalStatus.TeamQuestionsPanelIndividual: + return component_.page.actions.links( + state.evaluating + ? state.questionEvaluation + ? state.questionEvaluation.status === + SWUTeamQuestionResponseEvaluationStatus.Draft && + state.questionEvaluation.type === + SWUTeamQuestionResponseEvaluationType.Individual + ? [ + { + children: "Edit", + onClick: () => dispatch(adt("startEditing")), + button: true, + loading: isStartEditingLoading, + disabled: isLoading, + symbol_: leftPlacement(iconLinkSymbol("edit")), + color: "primary" + } + ] + : [] + : [ + { + children: "Save Draft", + symbol_: leftPlacement(iconLinkSymbol("save")), + loading: isSaveLoading, + disabled: isLoading || !valid, + button: true, + color: "success", + onClick: () => dispatch(adt("saveDraft")) + }, + { + children: "Cancel", + color: "c-nav-fg-alt", + disabled: isLoading, + onClick: () => + dispatch(adt("showModal", adt("cancelDraft")) as Msg) + } + ] + : [] + ); + // case SWUProposalStatus.EvaluatedTeamQuestions: + // return component_.page.actions.links([ + // ...(canSWUOpportunityBeScreenedInToCodeChallenge(state.opportunity) + // ? [ + // { + // children: "Screen In", + // symbol_: leftPlacement(iconLinkSymbol("stars")), + // loading: isScreenToFromLoading, + // disabled: isScreenToFromLoading, + // button: true, + // color: "primary" as const, + // onClick: () => dispatch(adt("screenIn" as const)) + // } + // ] + // : []), + // { + // children: "Edit Score", + // symbol_: leftPlacement(iconLinkSymbol("star-full")), + // disabled: isScreenToFromLoading, + // button: true, + // color: "info", + // onClick: () => dispatch(adt("showModal", "enterScore" as const)) + // } + // ]) as component_.page.Actions; + // case SWUProposalStatus.UnderReviewCodeChallenge: + // if (hasSWUOpportunityPassedCodeChallenge(state.opportunity)) { + // return component_.page.actions.none(); + // } + // return component_.page.actions.links([ + // { + // children: "Screen Out", + // symbol_: leftPlacement(iconLinkSymbol("ban")), + // loading: isScreenToFromLoading, + // disabled: isScreenToFromLoading, + // button: true, + // color: "danger", + // onClick: () => dispatch(adt("screenOut" as const)) + // } + // ]); default: return component_.page.actions.none(); } diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx new file mode 100644 index 000000000..d5d74ef62 --- /dev/null +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-consensus.tsx @@ -0,0 +1,86 @@ +import { Route, SharedState } from "front-end/lib/app/types"; +import { immutable, component as component_ } from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import { adt, Id } from "shared/lib/types"; +import { invalid, valid } from "shared/lib/validation"; +import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; +import { + component as proposalComponent, + State_, + InnerMsg_, + Msg +} from "front-end/lib/pages/proposal/sprint-with-us/view"; +import { isUserType } from "front-end/lib/access-control"; +import { UserType } from "shared/lib/resources/user"; + +export type RouteParams = { + opportunityId: Id; + proposalId: Id; + tab?: Tab.TabId; +}; + +function makeInit(): component_.page.Init< + RouteParams, + SharedState, + State_, + InnerMsg_, + Route +> { + return isUserType({ + userType: [UserType.Admin, UserType.Government], + success({ routePath, routeParams, shared }) { + const { opportunityId, proposalId } = routeParams; + return [ + valid( + immutable({ + proposal: null, + tab: null, + sidebar: null + }) + ) as State_, + [ + component_.cmd.join3( + api.proposals.swu.readOne(opportunityId)(proposalId, (response) => + api.isValid(response) ? response.value : null + ), + api.opportunities.swu.readOne()(opportunityId, (response) => + api.isValid(response) ? response.value : null + ), + api.evaluations.swu.readMany({ proposalId })((response) => + api.isValid(response) ? response.value : null + ), + (proposal, opportunity, panelEvaluations) => { + if (!proposal || !opportunity || !panelEvaluations) + return component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ); + return adt("onInitResponse", [ + shared.sessionUser, + routeParams, + proposal, + opportunity, + true, + undefined, + panelEvaluations + ]) as Msg; + } + ) + ] + ]; + }, + fail({ routePath }) { + return [ + invalid(null), + [ + component_.cmd.dispatch( + component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ) + ) + ] + ]; + } + }); +} + +export const component = { ...proposalComponent, init: makeInit() }; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx new file mode 100644 index 000000000..e9997d880 --- /dev/null +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/create-individual.tsx @@ -0,0 +1,83 @@ +import { Route, SharedState } from "front-end/lib/app/types"; +import { immutable, component as component_ } from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import { adt, Id } from "shared/lib/types"; +import { invalid, valid } from "shared/lib/validation"; +import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; +import { + component as proposalComponent, + State_, + InnerMsg_, + Msg +} from "front-end/lib/pages/proposal/sprint-with-us/view"; +import { isUserType } from "front-end/lib/access-control"; +import { UserType } from "shared/lib/resources/user"; + +export type RouteParams = { + opportunityId: Id; + proposalId: Id; + tab?: Tab.TabId; +}; + +function makeInit(): component_.page.Init< + RouteParams, + SharedState, + State_, + InnerMsg_, + Route +> { + return isUserType({ + userType: [UserType.Admin, UserType.Government], + success({ routePath, routeParams, shared }) { + const { opportunityId, proposalId } = routeParams; + return [ + valid( + immutable({ + proposal: null, + tab: null, + sidebar: null + }) + ) as State_, + [ + component_.cmd.join( + api.proposals.swu.readOne(opportunityId)(proposalId, (response) => + api.isValid(response) ? response.value : null + ), + api.opportunities.swu.readOne()(opportunityId, (response) => + api.isValid(response) ? response.value : null + ), + (proposal, opportunity) => { + if (!proposal || !opportunity) + return component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ); + return adt("onInitResponse", [ + shared.sessionUser, + routeParams, + proposal, + opportunity, + true, + undefined, + [] // No evaluations to load + ]) as Msg; + } + ) + ] + ]; + }, + fail({ routePath }) { + return [ + invalid(null), + [ + component_.cmd.dispatch( + component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ) + ) + ] + ]; + } + }); +} + +export const component = { ...proposalComponent, init: makeInit() }; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx new file mode 100644 index 000000000..6de61c458 --- /dev/null +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-consensus.tsx @@ -0,0 +1,90 @@ +import { Route, SharedState } from "front-end/lib/app/types"; +import { immutable, component as component_ } from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import { adt, Id } from "shared/lib/types"; +import { invalid, valid } from "shared/lib/validation"; +import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; +import { + component as proposalComponent, + State_, + InnerMsg_, + Msg +} from "front-end/lib/pages/proposal/sprint-with-us/view"; +import { isUserType } from "front-end/lib/access-control"; +import { UserType } from "shared/lib/resources/user"; + +export type RouteParams = { + opportunityId: Id; + proposalId: Id; + evaluationId: Id; + tab?: Tab.TabId; +}; + +function makeInit(): component_.page.Init< + RouteParams, + SharedState, + State_, + InnerMsg_, + Route +> { + return isUserType({ + userType: [UserType.Admin, UserType.Government], + success({ routePath, routeParams, shared }) { + const { opportunityId, proposalId, evaluationId } = routeParams; + return [ + valid( + immutable({ + proposal: null, + tab: null, + sidebar: null + }) + ) as State_, + [ + component_.cmd.join4( + api.proposals.swu.readOne(opportunityId)(proposalId, (response) => + api.isValid(response) ? response.value : null + ), + api.opportunities.swu.readOne()(opportunityId, (response) => + api.isValid(response) ? response.value : null + ), + api.evaluations.swu.readOne()(evaluationId, (response) => + api.isValid(response) ? response.value : null + ), + api.evaluations.swu.readMany({ proposalId })((response) => + api.isValid(response) ? response.value : null + ), + (proposal, opportunity, evaluation, panelEvaluations) => { + if (!proposal || !opportunity || !evaluation || !panelEvaluations) + return component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ); + return adt("onInitResponse", [ + shared.sessionUser, + routeParams, + proposal, + opportunity, + true, + evaluation, + panelEvaluations + ]) as Msg; + } + ) + ] + ]; + }, + fail({ routePath }) { + return [ + invalid(null), + [ + component_.cmd.dispatch( + component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ) + ) + ] + ]; + } + }); +} + +export const component = { ...proposalComponent, init: makeInit() }; diff --git a/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx new file mode 100644 index 000000000..59674be00 --- /dev/null +++ b/src/front-end/typescript/lib/pages/question-evaluation/sprint-with-us/edit-individual.tsx @@ -0,0 +1,87 @@ +import { Route, SharedState } from "front-end/lib/app/types"; +import { immutable, component as component_ } from "front-end/lib/framework"; +import * as api from "front-end/lib/http/api"; +import { adt, Id } from "shared/lib/types"; +import { invalid, valid } from "shared/lib/validation"; +import * as Tab from "front-end/lib/pages/proposal/sprint-with-us/view/tab"; +import { + component as proposalComponent, + State_, + InnerMsg_, + Msg +} from "front-end/lib/pages/proposal/sprint-with-us/view"; +import { isUserType } from "front-end/lib/access-control"; +import { UserType } from "shared/lib/resources/user"; + +export type RouteParams = { + opportunityId: Id; + proposalId: Id; + evaluationId: Id; + tab?: Tab.TabId; +}; + +function makeInit(): component_.page.Init< + RouteParams, + SharedState, + State_, + InnerMsg_, + Route +> { + return isUserType({ + userType: [UserType.Admin, UserType.Government], + success({ routePath, routeParams, shared }) { + const { opportunityId, proposalId, evaluationId } = routeParams; + return [ + valid( + immutable({ + proposal: null, + tab: null, + sidebar: null + }) + ) as State_, + [ + component_.cmd.join3( + api.proposals.swu.readOne(opportunityId)(proposalId, (response) => + api.isValid(response) ? response.value : null + ), + api.opportunities.swu.readOne()(opportunityId, (response) => + api.isValid(response) ? response.value : null + ), + api.evaluations.swu.readOne()(evaluationId, (response) => + api.isValid(response) ? response.value : null + ), + (proposal, opportunity, evaluation) => { + if (!proposal || !opportunity || !evaluation) + return component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ); + return adt("onInitResponse", [ + shared.sessionUser, + routeParams, + proposal, + opportunity, + true, + evaluation, + [] + ]) as Msg; + } + ) + ] + ]; + }, + fail({ routePath }) { + return [ + invalid(null), + [ + component_.cmd.dispatch( + component_.global.replaceRouteMsg( + adt("notFound" as const, { path: routePath }) + ) + ) + ] + ]; + } + }); +} + +export const component = { ...proposalComponent, init: makeInit() }; diff --git a/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts b/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts index 4d56c1227..87a11551d 100644 --- a/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts +++ b/src/migrations/tasks/20240718222006_swu-evaluation-tables.ts @@ -8,8 +8,7 @@ enum SWUOpportunityStatus { Draft = "DRAFT", UnderReview = "UNDER_REVIEW", Published = "PUBLISHED", - TeamQuestionsPanelEvaluation = "QUESTIONS_PANEL_EVAL", - TeamQuestionsPanelConsensus = "QUESTIONS_PANEL_CONSENSUS", + EvaluationTeamQuestionsPanel = "EVAL_QUESTIONS_PANEL", EvaluationTeamQuestions = "EVAL_QUESTIONS", EvaluationCodeChallenge = "EVAL_CC", EvaluationTeamScenario = "EVAL_SCENARIO", @@ -30,8 +29,40 @@ enum PreviousSWUOpportunityStatus { Canceled = "CANCELED" } +enum SWUProposalStatus { + Draft = "DRAFT", + Submitted = "SUBMITTED", + TeamQuestionsPanelIndividual = "QUESTIONS_PANEL_INDIVIDUAL", + TeamQuestionsPanelConsensus = "QUESTIONS_PANEL_CONESENSUS", + UnderReviewTeamQuestions = "UNDER_REVIEW_QUESTIONS", + EvaluatedTeamQuestions = "EVALUATED_QUESTIONS", + UnderReviewCodeChallenge = "UNDER_REVIEW_CODE_CHALLENGE", + EvaluatedCodeChallenge = "EVALUATED_CODE_CHALLENGE", + UnderReviewTeamScenario = "UNDER_REVIEW_TEAM_SCENARIO", + EvaluatedTeamScenario = "EVALUATED_TEAM_SCENARIO", + Awarded = "AWARDED", + NotAwarded = "NOT_AWARDED", + Disqualified = "DISQUALIFIED", + Withdrawn = "WITHDRAWN" +} + +enum PreviousSWUProposalStatus { + Draft = "DRAFT", + Submitted = "SUBMITTED", + UnderReviewTeamQuestions = "UNDER_REVIEW_QUESTIONS", + EvaluatedTeamQuestions = "EVALUATED_QUESTIONS", + UnderReviewCodeChallenge = "UNDER_REVIEW_CODE_CHALLENGE", + EvaluatedCodeChallenge = "EVALUATED_CODE_CHALLENGE", + UnderReviewTeamScenario = "UNDER_REVIEW_TEAM_SCENARIO", + EvaluatedTeamScenario = "EVALUATED_TEAM_SCENARIO", + Awarded = "AWARDED", + NotAwarded = "NOT_AWARDED", + Disqualified = "DISQUALIFIED", + Withdrawn = "WITHDRAWN" +} + enum SWUTeamQuestionResponseEvaluationType { - Conensus = "CONSENSUS", + Consensus = "CONSENSUS", Individual = "INDIVIDUAL" } @@ -41,6 +72,20 @@ export enum SWUTeamQuestionResponseEvaluationStatus { } export async function up(connection: Knex): Promise { + await connection.schema.raw( + ' \ + ALTER TABLE "swuProposalStatuses" \ + DROP CONSTRAINT "swuProposalStatuses_status_check" \ + ' + ); + + await connection.schema.raw(` \ + ALTER TABLE "swuProposalStatuses" \ + ADD CONSTRAINT "swuProposalStatuses_status_check" \ + CHECK (status IN ('${Object.values(SWUProposalStatus).join("','")}')) \ + `); + logger.info("Modified constraint on swuProposalStatuses"); + await connection.schema.raw( ' \ ALTER TABLE "swuOpportunityStatuses" \ @@ -118,6 +163,27 @@ export async function up(connection: Knex): Promise { } export async function down(connection: Knex): Promise { + await connection.schema.raw( + ' \ + ALTER TABLE "swuProposalStatuses" \ + DROP CONSTRAINT "swuProposalStatuses_status_check" \ + ' + ); + + await connection("swuProposalStatuses") + .where({ status: "QUESTIONS_PANEL_INDIVIDUAL" }) + .where({ status: "QUESTIONS_PANEL_CONSENSUS" }) + .del(); + + await connection.schema.raw(` \ + ALTER TABLE "swuProposalStatuses" \ + ADD CONSTRAINT "swuProposalStatuses_status_check" \ + CHECK (status IN ('${Object.values(PreviousSWUProposalStatus).join( + "','" + )}')) \ + `); + logger.info("Reverted constraint on swuProposalStatuses"); + await connection.schema.raw( ' \ ALTER TABLE "swuOpportunityStatuses" \ @@ -126,8 +192,7 @@ export async function down(connection: Knex): Promise { ); await connection("swuOpportunityStatuses") - .where({ status: "QUESTIONS_PANEL_EVAL" }) - .orWhere({ status: "QUESTIONS_PANEL_CONSENSUS" }) + .where({ status: "EVAL_QUESTIONS_PANEL" }) .del(); await connection.schema.raw(` \ diff --git a/src/shared/lib/resources/opportunity/sprint-with-us.ts b/src/shared/lib/resources/opportunity/sprint-with-us.ts index 8e2c943d7..4804d5561 100644 --- a/src/shared/lib/resources/opportunity/sprint-with-us.ts +++ b/src/shared/lib/resources/opportunity/sprint-with-us.ts @@ -57,8 +57,7 @@ export enum SWUOpportunityStatus { Draft = "DRAFT", UnderReview = "UNDER_REVIEW", Published = "PUBLISHED", - TeamQuestionsPanelEvaluation = "QUESTIONS_PANEL_EVAL", - TeamQuestionsPanelConsensus = "QUESTIONS_PANEL_CONSENSUS", + EvaluationTeamQuestionsPanel = "EVAL_QUESTIONS_PANEL", EvaluationTeamQuestions = "EVAL_QUESTIONS", EvaluationCodeChallenge = "EVAL_CC", EvaluationTeamScenario = "EVAL_SCENARIO", @@ -113,6 +112,7 @@ export function isSWUOpportunityStatusInEvaluation( s: SWUOpportunityStatus ): boolean { switch (s) { + case SWUOpportunityStatus.EvaluationTeamQuestionsPanel: case SWUOpportunityStatus.EvaluationTeamQuestions: case SWUOpportunityStatus.EvaluationCodeChallenge: case SWUOpportunityStatus.EvaluationTeamScenario: @@ -124,8 +124,7 @@ export function isSWUOpportunityStatusInEvaluation( export const publicOpportunityStatuses: readonly SWUOpportunityStatus[] = [ SWUOpportunityStatus.Published, - SWUOpportunityStatus.TeamQuestionsPanelEvaluation, - SWUOpportunityStatus.TeamQuestionsPanelConsensus, + SWUOpportunityStatus.EvaluationTeamQuestionsPanel, SWUOpportunityStatus.EvaluationTeamQuestions, SWUOpportunityStatus.EvaluationCodeChallenge, SWUOpportunityStatus.EvaluationTeamScenario, @@ -559,6 +558,23 @@ export function canViewSWUOpportunityProposals(o: SWUOpportunity): boolean { ); } +export function canViewSWUOpportunityTeamQuestionResponseEvaluations( + o: SWUOpportunity +): boolean { + // Return true if the opportunity has ever had the `Panel` status. + return ( + !!o.history && + o.history.reduce((acc, record) => { + return ( + acc || + (record.type.tag === "status" && + record.type.value === + SWUOpportunityStatus.EvaluationTeamQuestionsPanel) + ); + }, false as boolean) + ); +} + export function canSWUOpportunityDetailsBeEdited( o: SWUOpportunity, adminsOnly: boolean @@ -614,6 +630,29 @@ export function isSWUOpportunityClosed(o: SWUOpportunity): boolean { ); } +export function hasSWUOpportunityPassedTeamQuestionsEvaluation( + o: Pick +): boolean { + if (!o.history) { + return false; + } + return o.history.reduce((acc, h) => { + if (acc || h.type.tag !== "status") { + return acc; + } + switch (h.type.value) { + case SWUOpportunityStatus.EvaluationTeamQuestionsPanel: + case SWUOpportunityStatus.EvaluationTeamQuestions: + case SWUOpportunityStatus.EvaluationCodeChallenge: + case SWUOpportunityStatus.EvaluationTeamScenario: + case SWUOpportunityStatus.Awarded: + return true; + default: + return false; + } + }, false as boolean); +} + export function hasSWUOpportunityPassedTeamQuestions( o: Pick ): boolean { @@ -703,6 +742,20 @@ export function doesSWUOpportunityStatusAllowGovToViewFullProposal( } } +export function doesSWUOpportunityStatusAllowGovToViewTeamQuestionResponseEvaluations( + s: SWUOpportunityStatus +): boolean { + switch (s) { + case SWUOpportunityStatus.EvaluationTeamQuestions: + case SWUOpportunityStatus.EvaluationCodeChallenge: + case SWUOpportunityStatus.EvaluationTeamScenario: + case SWUOpportunityStatus.Awarded: + return true; + default: + return false; + } +} + export function canViewSWUEvaluationConsensus( s: SWUOpportunityStatus ): boolean { diff --git a/src/shared/lib/resources/proposal/sprint-with-us.ts b/src/shared/lib/resources/proposal/sprint-with-us.ts index d99bf257b..dd528b0ff 100644 --- a/src/shared/lib/resources/proposal/sprint-with-us.ts +++ b/src/shared/lib/resources/proposal/sprint-with-us.ts @@ -50,6 +50,8 @@ export function swuProposalPhaseTypeToTitleCase( export enum SWUProposalStatus { Draft = "DRAFT", Submitted = "SUBMITTED", + TeamQuestionsPanelIndividual = "QUESTIONS_PANEL_INDIVIDUAL", + TeamQuestionsPanelConsensus = "QUESTIONS_PANEL_CONESENSUS", UnderReviewTeamQuestions = "UNDER_REVIEW_QUESTIONS", EvaluatedTeamQuestions = "EVALUATED_QUESTIONS", UnderReviewCodeChallenge = "UNDER_REVIEW_CODE_CHALLENGE", @@ -107,6 +109,8 @@ function quantifySWUProposalStatusForSort(a: SWUProposalStatus): number { return 0; case SWUProposalStatus.NotAwarded: return 1; + case SWUProposalStatus.TeamQuestionsPanelIndividual: + case SWUProposalStatus.TeamQuestionsPanelConsensus: case SWUProposalStatus.UnderReviewTeamQuestions: case SWUProposalStatus.EvaluatedTeamQuestions: case SWUProposalStatus.UnderReviewCodeChallenge: @@ -124,6 +128,20 @@ function quantifySWUProposalStatusForSort(a: SWUProposalStatus): number { } } +function getSWUProposalAnonymousProponentNumber(proposal: SWUProposalSlim) { + return Number(proposal.anonymousProponentName.match(/\d+/)?.at(0)); +} + +export function compareSWUProposalAnonymousProponentNumber( + a: SWUProposalSlim, + b: SWUProposalSlim +) { + return compareNumbers( + getSWUProposalAnonymousProponentNumber(a), + getSWUProposalAnonymousProponentNumber(b) + ); +} + export function compareSWUProposalStatuses( a: SWUProposalStatus, b: SWUProposalStatus diff --git a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts index 51b1ba9dc..e7312348f 100644 --- a/src/shared/lib/resources/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/resources/question-evaluation/sprint-with-us.ts @@ -1,14 +1,17 @@ import { ADT, BodyWithErrors, Id } from "shared/lib/types"; import { ErrorTypeFrom } from "shared/lib/validation"; -import { SWUEvaluationPanelMember } from "src/shared/lib/resources/opportunity/sprint-with-us"; +import { + SWUEvaluationPanelMember, + SWUOpportunity +} from "src/shared/lib/resources/opportunity/sprint-with-us"; import { SWUProposalSlim } from "src/shared/lib/resources/proposal/sprint-with-us"; export function parseSWUTeamQuestionResponseEvaluationType( raw: string ): SWUTeamQuestionResponseEvaluationType | null { switch (raw) { - case SWUTeamQuestionResponseEvaluationType.Conensus: - return SWUTeamQuestionResponseEvaluationType.Conensus; + case SWUTeamQuestionResponseEvaluationType.Consensus: + return SWUTeamQuestionResponseEvaluationType.Consensus; case SWUTeamQuestionResponseEvaluationType.Individual: return SWUTeamQuestionResponseEvaluationType.Individual; default: @@ -30,7 +33,7 @@ export function parseSWUTeamQuestionResponseEvaluationStatus( } export enum SWUTeamQuestionResponseEvaluationType { - Conensus = "CONSENSUS", + Consensus = "CONSENSUS", Individual = "INDIVIDUAL" } @@ -56,6 +59,13 @@ export interface SWUTeamQuestionResponseEvaluation { updatedAt: Date; } +export function getEvaluationScoreByOrder( + evaluation: SWUTeamQuestionResponseEvaluation | null, + order: number +): SWUTeamQuestionResponseEvaluationScores | null { + return evaluation?.scores.find((s) => s.order === order) ?? null; +} + // Create. export type CreateSWUTeamQuestionResponseEvaluationStatus = @@ -90,4 +100,42 @@ export type UpdateRequestBody = | ADT<"edit", UpdateEditRequestBody> | ADT<"submit", string>; -export type UpdateEditRequestBody = CreateRequestBody; +export type UpdateEditRequestBody = Omit< + CreateRequestBody, + "proposal" | "type" | "status" +>; + +type UpdateADTErrors = + | ADT<"edit", UpdateEditValidationErrors> + | ADT<"submit", string[]> + | ADT<"parseFailure">; + +export interface UpdateEditValidationErrors { + scores?: CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors[]; +} + +export interface UpdateValidationErrors extends BodyWithErrors { + evaluation?: UpdateADTErrors; +} + +export function isValidStatusChange( + from: SWUTeamQuestionResponseEvaluationStatus, + to: SWUTeamQuestionResponseEvaluationStatus +): boolean { + switch (from) { + case SWUTeamQuestionResponseEvaluationStatus.Draft: + return SWUTeamQuestionResponseEvaluationStatus.Submitted === to; + default: + return false; + } +} + +export function canSWUTeamQuestionResponseEvaluationBeSubmitted( + e: Pick, + o: Pick +): boolean { + return ( + e.status === SWUTeamQuestionResponseEvaluationStatus.Draft && + o.teamQuestions.length === e.scores.length + ); +} diff --git a/src/shared/lib/validation/question-evaluation/sprint-with-us.ts b/src/shared/lib/validation/question-evaluation/sprint-with-us.ts index 6a910bf7c..3ec9431bb 100644 --- a/src/shared/lib/validation/question-evaluation/sprint-with-us.ts +++ b/src/shared/lib/validation/question-evaluation/sprint-with-us.ts @@ -1,6 +1,6 @@ import { isArray } from "lodash"; import { getNumber, getString } from "shared/lib"; -import { SWUProposalTeamQuestionResponse } from "shared/lib/resources/proposal/sprint-with-us"; +import { SWUTeamQuestion } from "shared/lib/resources/opportunity/sprint-with-us"; import { CreateSWUTeamQuestionResponseEvaluationScoreBody, CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors, @@ -16,8 +16,10 @@ import { isInvalid, valid, validateArrayCustom, + validateGenericString, validateGenericStringWords, validateNumber, + validateNumberWithPrecision, Validation } from "shared/lib/validation"; @@ -58,15 +60,16 @@ export const validateSWUTeamQuestionResponseEvaluationStatus = export function validateSWUTeamQuestionResponseEvaluationScoreOrder( raw: number, - proposalTeamQuestionResponses: SWUProposalTeamQuestionResponse[] + opportunityTeamQuestions: SWUTeamQuestion[] ): Validation { - return validateNumber(raw, 0, proposalTeamQuestionResponses.length, "Order"); + return validateNumber(raw, 0, opportunityTeamQuestions.length, "Order"); } export function validateSWUTeamQuestionResponseEvaluationScoreScore( - raw: number + raw: number, + maxScore: number ): Validation { - return validateNumber(raw, 1, undefined, "Score"); + return validateNumberWithPrecision(raw, 0, maxScore, 2, "Score"); } export function validateSWUTeamQuestionResponseEvaluationScoreNotes( @@ -77,30 +80,31 @@ export function validateSWUTeamQuestionResponseEvaluationScoreNotes( export function validateSWUTeamQuestionResponseEvaluationScore( raw: any, - proposalTeamQuestionResponses: SWUProposalTeamQuestionResponse[] + opportunityTeamQuestions: SWUTeamQuestion[] ): Validation< CreateSWUTeamQuestionResponseEvaluationScoreBody, CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors > { const validatedOrder = validateSWUTeamQuestionResponseEvaluationScoreOrder( getNumber(raw, "order"), - proposalTeamQuestionResponses + opportunityTeamQuestions ); if (isInvalid(validatedOrder)) { return invalid({ order: getInvalidValue(validatedOrder, undefined) }); } - const proposalTeamQuestionResponse = proposalTeamQuestionResponses.find( - (q) => q.order === validatedOrder.value - ); - if (!proposalTeamQuestionResponse) { + const maxScore = + opportunityTeamQuestions.find((q) => q.order === validatedOrder.value) + ?.score || null; + if (!maxScore) { return invalid({ order: ["No matching proposal team question response."] }); } const validatedScore = validateSWUTeamQuestionResponseEvaluationScoreScore( - getNumber(raw, "score") + getNumber(raw, "score"), + maxScore ); if (isInvalid(validatedScore)) { return invalid({ @@ -125,7 +129,7 @@ export function validateSWUTeamQuestionResponseEvaluationScore( export function validateSWUTeamQuestionResponseEvaluationScores( raw: any, - proposalTeamQuestionResponses: SWUProposalTeamQuestionResponse[] + opportunityTeamQuestions: SWUTeamQuestion[] ): ArrayValidation< CreateSWUTeamQuestionResponseEvaluationScoreBody, CreateSWUTeamQuestionResponseEvaluationScoreValidationErrors @@ -144,8 +148,12 @@ export function validateSWUTeamQuestionResponseEvaluationScores( (v) => validateSWUTeamQuestionResponseEvaluationScore( v, - proposalTeamQuestionResponses + opportunityTeamQuestions ), {} ); } + +export function validateNote(raw: string): Validation { + return validateGenericString(raw, "Note", 0, 5000); +}