diff --git a/apps/backend/db_patches/0156_CreateFAPProposalView.sql b/apps/backend/db_patches/0156_CreateFAPProposalView.sql new file mode 100644 index 0000000000..8e19f219cb --- /dev/null +++ b/apps/backend/db_patches/0156_CreateFAPProposalView.sql @@ -0,0 +1,40 @@ +DO +$$ +BEGIN + IF register_patch('0156_CreateFAPProposalView.sql', 'Thomas Cottee Meldrum', 'Create View for FAP data', '2024-04-05') THEN + + CREATE VIEW review_data AS + Select proposal.*, grade.avg as average_grade from ( + Select + fp.proposal_pk, + p.proposal_id, + p.title, + i.name as instrument_name, + chi.availability_time, + tr.time_allocation, + f.fap_id, + fmd.rank_order, + c.call_id, + p.proposer_id, + i.instrument_id, + fp.fap_time_allocation, + p.questionary_id + from fap_proposals as fp + join faps as f on f.fap_id = fp.fap_id + join call c on c.call_id = fp.call_id + join proposals p on p.proposal_pk = fp.proposal_pk + join technical_review tr on tr.proposal_pk = p.proposal_pk and tr.instrument_id = fp.instrument_id + left join fap_meeting_decisions as fmd on fmd.proposal_pk = p.proposal_pk + join call_has_instruments as chi on chi.instrument_id = fp.instrument_id and chi.call_id = c.call_id + join instruments as i on i.instrument_id = chi.instrument_id) as proposal + left join ( + Select fr.proposal_pk, AVG(fr.grade) from + fap_proposals as fp + join fap_reviews as fr on fr.proposal_pk = fp.proposal_pk + group by fr.proposal_pk + ) grade on grade.proposal_pk = proposal.proposal_pk; + + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index ddb07e7efe..40bfc73456 100644 --- a/apps/backend/src/config/Tokens.ts +++ b/apps/backend/src/config/Tokens.ts @@ -40,5 +40,6 @@ export const Tokens = { FapDataColumns: Symbol('FapDataColumns'), FapDataRow: Symbol('FapDataRow'), PopulateRow: Symbol('PopulateRow'), + PopulateCallRow: Symbol('PopulateCallRow'), DownloadService: Symbol('DownloadService'), }; diff --git a/apps/backend/src/config/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index 7ce322b8d8..ae9e03d42a 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -43,7 +43,11 @@ import { createApplicationEventBus } from '../events'; import { ApplicationEvent } from '../events/applicationEvents'; import { DefaultDownloadService } from '../factory/DefaultDownloadService'; import { FapDataColumns } from '../factory/xlsx/FapDataColumns'; -import { getDataRow, populateRow } from '../factory/xlsx/FapDataRow'; +import { + callFapPopulateRow, + getDataRow, + populateRow, +} from '../factory/xlsx/FapDataRow'; import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; import { configureBaseEnvironment } from './base/configureBaseEnvironment'; import { Tokens } from './Tokens'; @@ -94,6 +98,7 @@ mapClass(Tokens.MailService, SkipSendMailService); mapValue(Tokens.FapDataColumns, FapDataColumns); mapValue(Tokens.FapDataRow, getDataRow); mapValue(Tokens.PopulateRow, populateRow); +mapValue(Tokens.PopulateCallRow, callFapPopulateRow); mapValue(Tokens.EmailEventHandler, skipEmailHandler); diff --git a/apps/backend/src/config/dependencyConfigE2E.ts b/apps/backend/src/config/dependencyConfigE2E.ts index 61d875ad58..b9b29d5c50 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -39,7 +39,11 @@ import { import { createApplicationEventBus } from '../events'; import { DefaultDownloadService } from '../factory/DefaultDownloadService'; import { FapDataColumns } from '../factory/xlsx/FapDataColumns'; -import { getDataRow, populateRow } from '../factory/xlsx/FapDataRow'; +import { + callFapPopulateRow, + getDataRow, + populateRow, +} from '../factory/xlsx/FapDataRow'; import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; import { configureESSDevelopmentEnvironment } from './ess/configureESSEnvironment'; import { Tokens } from './Tokens'; @@ -86,6 +90,7 @@ mapClass(Tokens.MailService, SkipSendMailService); mapValue(Tokens.FapDataColumns, FapDataColumns); mapValue(Tokens.FapDataRow, getDataRow); mapValue(Tokens.PopulateRow, populateRow); +mapValue(Tokens.PopulateCallRow, callFapPopulateRow); mapValue(Tokens.EmailEventHandler, essEmailHandler); diff --git a/apps/backend/src/config/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index 1ad795c7ba..7d795a9dad 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -39,7 +39,11 @@ import { import { createApplicationEventBus } from '../events'; import { DefaultDownloadService } from '../factory/DefaultDownloadService'; import { FapDataColumns } from '../factory/xlsx/FapDataColumns'; -import { getDataRow, populateRow } from '../factory/xlsx/FapDataRow'; +import { + callFapPopulateRow, + getDataRow, + populateRow, +} from '../factory/xlsx/FapDataRow'; import { EAMAssetRegistrar } from '../services/assetRegistrar/eam/EAMAssetRegistrar'; import { isProduction } from '../utils/helperFunctions'; import { configureESSDevelopmentEnvironment } from './ess/configureESSEnvironment'; @@ -88,6 +92,7 @@ mapClass(Tokens.MailService, SparkPostMailService); mapValue(Tokens.FapDataColumns, FapDataColumns); mapValue(Tokens.FapDataRow, getDataRow); mapValue(Tokens.PopulateRow, populateRow); +mapValue(Tokens.PopulateCallRow, callFapPopulateRow); mapValue(Tokens.EmailEventHandler, essEmailHandler); diff --git a/apps/backend/src/config/dependencyConfigSTFC.ts b/apps/backend/src/config/dependencyConfigSTFC.ts index 65a334ad12..80d1eaea3c 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -40,6 +40,7 @@ import { createApplicationEventBus } from '../events'; import { StfcDownloadService } from '../factory/StfcDownloadService'; import { StfcFapDataColumns } from '../factory/xlsx/stfc/StfcFapDataColumns'; import { + callFapStfcPopulateRow, getStfcDataRow, populateStfcRow, } from '../factory/xlsx/stfc/StfcFapDataRow'; @@ -89,6 +90,7 @@ mapClass(Tokens.MailService, SMTPMailService); mapValue(Tokens.FapDataColumns, StfcFapDataColumns); mapValue(Tokens.FapDataRow, getStfcDataRow); mapValue(Tokens.PopulateRow, populateStfcRow); +mapValue(Tokens.PopulateCallRow, callFapStfcPopulateRow); mapValue(Tokens.EmailEventHandler, stfcEmailHandler); diff --git a/apps/backend/src/datasources/FapDataSource.ts b/apps/backend/src/datasources/FapDataSource.ts index 662a781e68..52046b07ff 100644 --- a/apps/backend/src/datasources/FapDataSource.ts +++ b/apps/backend/src/datasources/FapDataSource.ts @@ -18,7 +18,10 @@ import { import { RemoveProposalsFromFapsArgs } from '../resolvers/mutations/AssignProposalsToFapsMutation'; import { SaveFapMeetingDecisionInput } from '../resolvers/mutations/FapMeetingDecisionMutation'; import { FapsFilter } from '../resolvers/queries/FapsQuery'; -import { AssignProposalsToFapsInput } from './postgres/records'; +import { + FapReviewsRecord, + AssignProposalsToFapsInput, +} from './postgres/records'; export interface FapDataSource { create( @@ -144,6 +147,7 @@ export interface FapDataSource { reviewerId: number, rank: number ): Promise; + getFapReviewData(callId: number, fapId: number): Promise; submitFapMeetings( callId: number, fapId: number, diff --git a/apps/backend/src/datasources/mockups/FapDataSource.ts b/apps/backend/src/datasources/mockups/FapDataSource.ts index fc486779ef..173a306d8b 100644 --- a/apps/backend/src/datasources/mockups/FapDataSource.ts +++ b/apps/backend/src/datasources/mockups/FapDataSource.ts @@ -19,7 +19,10 @@ import { RemoveProposalsFromFapsArgs } from '../../resolvers/mutations/AssignPro import { SaveFapMeetingDecisionInput } from '../../resolvers/mutations/FapMeetingDecisionMutation'; import { FapsFilter } from '../../resolvers/queries/FapsQuery'; import { FapDataSource } from '../FapDataSource'; -import { AssignProposalsToFapsInput } from '../postgres/records'; +import { + FapReviewsRecord, + AssignProposalsToFapsInput, +} from '../postgres/records'; import { basicDummyUser } from './UserDataSource'; export const dummyFap = new Fap( @@ -512,6 +515,10 @@ export class FapDataSourceMock implements FapDataSource { throw new Error('Method not implemented.'); } + getFapReviewData(callId: number, fapId: number): Promise { + throw new Error('Method not implemented.'); + } + submitFapMeetings( callId: number, fapId: number, diff --git a/apps/backend/src/datasources/postgres/FapDataSource.ts b/apps/backend/src/datasources/postgres/FapDataSource.ts index f9e72e0ef5..177163cf28 100644 --- a/apps/backend/src/datasources/postgres/FapDataSource.ts +++ b/apps/backend/src/datasources/postgres/FapDataSource.ts @@ -51,6 +51,7 @@ import { InstitutionRecord, AssignProposalsToFapsInput, CountryRecord, + FapReviewsRecord, } from './records'; @injectable() @@ -1210,6 +1211,17 @@ export default class PostgresFapDataSource implements FapDataSource { }); } + async getFapReviewData( + callId: number, + fapId: number + ): Promise { + return await database + .select('*') + .from('review_data') + .where('fap_id', fapId) + .andWhere('call_id', callId); + } + async submitFapMeetings( callId: number, fapId: number, diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 6113f613cd..14cbd0452d 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -442,6 +442,23 @@ export interface FapAssignmentRecord { readonly rank: number | null; } +export interface FapReviewsRecord { + readonly proposal_pk: number; + readonly proposal_id: number; + readonly title: string; + readonly instrument_name: string; + readonly availability_time: number; + readonly time_allocation: number; + readonly fap_id: number; + readonly rank_order: number; + readonly call_id: number; + readonly proposer_id: number; + readonly instrument_id: number; + readonly fap_time_allocation: number; + readonly average_grade: number; + readonly questionary_id: number; +} + export interface FapReviewerRecord { readonly user_id: number; readonly fap_id: number; diff --git a/apps/backend/src/factory/DownloadService.ts b/apps/backend/src/factory/DownloadService.ts index aa2e562475..e9dde0bab3 100644 --- a/apps/backend/src/factory/DownloadService.ts +++ b/apps/backend/src/factory/DownloadService.ts @@ -15,7 +15,8 @@ export enum DownloadType { export enum XLSXType { PROPOSAL = 'proposal', - Fap = 'fap', + FAP = 'fap', + CALL_FAP = 'call_fap', } export enum PDFType { diff --git a/apps/backend/src/factory/xlsx/FapDataRow.ts b/apps/backend/src/factory/xlsx/FapDataRow.ts index ef69114650..4743b98407 100644 --- a/apps/backend/src/factory/xlsx/FapDataRow.ts +++ b/apps/backend/src/factory/xlsx/FapDataRow.ts @@ -1,29 +1,31 @@ -import { FapProposal } from '../../models/Fap'; -import { FapMeetingDecision } from '../../models/FapMeetingDecision'; -import { InstrumentWithAvailabilityTime } from '../../models/Instrument'; -import { Proposal } from '../../models/Proposal'; -import { TechnicalReview } from '../../models/TechnicalReview'; +import { stripHtml } from 'string-strip-html'; + +import { CallRowObj } from './callFaps'; import { RowObj } from './fap'; export function getDataRow( + proposalPk: number, piName: string, proposalAverageScore: number, - instrument: InstrumentWithAvailabilityTime, - fapMeetingDecision: FapMeetingDecision | null, - proposal: Proposal | null, - technicalReview: TechnicalReview | null, - fapProposal: FapProposal | null -) { + instrumentName: string, + instrumentAvailabilityTime: number, + fapTimeAllocation: number | null, + proposalTitle: string, + proposalId: number | null, + techReviewTimeAllocation: number | null, + propFapRankOrder: number | null +): RowObj { return { - propShortCode: proposal?.proposalId, - propTitle: proposal?.title, + proposalPk: proposalPk, + propShortCode: proposalId?.toString(), + propTitle: proposalTitle, principalInv: piName, - instrName: instrument.name, - instrAvailTime: instrument.availabilityTime, - techReviewTimeAllocation: technicalReview?.timeAllocation, - fapTimeAllocation: fapProposal?.fapTimeAllocation ?? null, - propReviewAvgScore: proposalAverageScore, - propFapRankOrder: fapMeetingDecision?.rankOrder ?? null, + instrName: instrumentName, + instrAvailTime: instrumentAvailabilityTime, + techReviewTimeAllocation: techReviewTimeAllocation, + fapTimeAllocation: fapTimeAllocation ?? null, + propReviewAvgScore: proposalAverageScore ?? 0, + propFapRankOrder: propFapRankOrder ?? null, inAvailZone: null, }; } @@ -42,3 +44,27 @@ export function populateRow(row: RowObj) { row.inAvailZone ?? '', ]; } + +export function callFapPopulateRow(row: CallRowObj): (string | number)[] { + const individualReviews = row.reviews?.flatMap((rev) => [ + rev.grade, + rev.comment && stripHtml(rev.comment).result, + ]); + + return [ + row.propShortCode ?? '', + row.propTitle ?? '', + row.principalInv, + row.instrName ?? '', + row.instrAvailTime ?? '', + row.techReviewTimeAllocation ?? '', + row.fapTimeAllocation ?? row.techReviewTimeAllocation ?? '', + row.propReviewAvgScore ?? '', + row.propFapRankOrder ?? '', + row.inAvailZone ?? '', + row.fapTimeAllocation ?? row.daysRequested ?? '', + row.fapMeetingDecision ?? '', + row.fapMeetingInComment ?? '', + row.fapMeetingExComment ?? '', + ].concat(individualReviews ? individualReviews : []); +} diff --git a/apps/backend/src/factory/xlsx/callFaps.ts b/apps/backend/src/factory/xlsx/callFaps.ts new file mode 100644 index 0000000000..89244ac522 --- /dev/null +++ b/apps/backend/src/factory/xlsx/callFaps.ts @@ -0,0 +1,148 @@ +import { stripHtml } from 'string-strip-html'; +import { container } from 'tsyringe'; + +import baseContext from '../../buildContext'; +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { ProposalEndStatus } from '../../models/Proposal'; +import { UserWithRole } from '../../models/User'; +import { RowObj, collectFapXLSXRowData } from './fap'; +import { callFapPopulateRow } from './FapDataRow'; +import { callFapStfcPopulateRow } from './stfc/StfcFapDataRow'; + +const fapDataSource: FapDataSource = container.resolve(Tokens.FapDataSource); + +const callFapDataRow = container.resolve< + typeof callFapPopulateRow | typeof callFapStfcPopulateRow +>(Tokens.PopulateCallRow); + +const ProposalEndStatusStringValue = { + [ProposalEndStatus.UNSET]: 'Unset', + [ProposalEndStatus.ACCEPTED]: 'Accepted', + [ProposalEndStatus.RESERVED]: 'Reserved', + [ProposalEndStatus.REJECTED]: 'Rejected', +}; + +export type CallRowObj = RowObj & { + fapMeetingDecision?: string | null; + fapMeetingExComment?: string | null; + fapMeetingInComment?: string | null; +}; + +const collectFAPRowData = async ( + fapId: number, + callId: number, + user: UserWithRole +) => { + const data = await collectFapXLSXRowData(fapId, callId, user); + + const extraData = await Promise.all( + data.map(async (sheet) => { + return { + sheetName: sheet.sheetName, + rows: await Promise.all( + sheet.rows.map(async (proposal) => { + const fapMeetingDecision = + await fapDataSource.getProposalsFapMeetingDecisions([ + proposal.proposalPk, + ]); + + return { + ...proposal, + fapMeetingDecision: fapMeetingDecision[0] + ? ProposalEndStatusStringValue[ + fapMeetingDecision[0].recommendation + ] + : null, + fapMeetingExComment: fapMeetingDecision[0] + ? stripHtml(fapMeetingDecision[0].commentForUser).result + : null, + fapMeetingInComment: fapMeetingDecision[0] + ? stripHtml(fapMeetingDecision[0].commentForManagement).result + : null, + }; + }) + ), + }; + }) + ); + + const allRowData = extraData.map((inst) => { + const instName: (string | number)[][] = [[inst.sheetName]]; + + const sortedData = sortByRankOrAverageScore(inst.rows).map( + (row: CallRowObj) => callFapDataRow(row) + ); + + return instName.concat(sortedData); + }); + + return allRowData.length + ? allRowData.reduce((arr, inst) => { + return arr.concat(inst); + }) + : allRowData; +}; + +export const collectCallFapXLSXData = async ( + callId: number, + user: UserWithRole +) => { + const faps = await baseContext.queries.fap.dataSource.getFapsByCallId(callId); + const call = await baseContext.queries.call.get(user, callId); + const filename = `${call?.shortCode}_FAP_Results.xlsx`; + + const baseData = await Promise.all( + faps.map(async (fap) => { + return { + sheetName: fap.code, + rows: await collectFAPRowData(fap.id, callId, user), + }; + }) + ); + + return { data: baseData, filename: filename.replace(/\s+/g, '_') }; +}; + +export const CallExtraFapDataColumns = [ + 'Fap Time allocation', + 'Fap Meeting Decision', + 'Fap Meeting Comment for User', + 'Fap Meeting Internal Comment', +]; + +const sortByRankOrder = (a: RowObj, b: RowObj) => { + if (a.propFapRankOrder === b.propFapRankOrder) { + return -1; + } else if (a.propFapRankOrder === null) { + return 1; + } else if (b.propFapRankOrder === null) { + return -1; + } else { + return a.propFapRankOrder > b.propFapRankOrder ? 1 : -1; + } +}; + +const sortByRankOrAverageScore = (data: RowObj[]) => { + let allocationTimeSum = 0; + + return data + .sort((a, b) => + (a.propReviewAvgScore || 0) > (b.propReviewAvgScore || 0) ? 1 : -1 + ) + .sort(sortByRankOrder) + .map((row) => { + const proposalAllocationTime = + row.fapTimeAllocation !== null + ? row.fapTimeAllocation + : row.techReviewTimeAllocation || 0; + + const isInAvailabilityZone = + allocationTimeSum + proposalAllocationTime <= (row.instrAvailTime || 0); + allocationTimeSum = allocationTimeSum + proposalAllocationTime; + + row.inAvailZone = isInAvailabilityZone ? 'yes' : 'no'; + + return row; + }); +}; diff --git a/apps/backend/src/factory/xlsx/fap.ts b/apps/backend/src/factory/xlsx/fap.ts index 0d147d8fe1..4bd13f24ef 100644 --- a/apps/backend/src/factory/xlsx/fap.ts +++ b/apps/backend/src/factory/xlsx/fap.ts @@ -1,10 +1,12 @@ +import { groupBy } from 'lodash'; import { container } from 'tsyringe'; import baseContext from '../../buildContext'; import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; import { Review } from '../../models/Review'; import { UserWithRole } from '../../models/User'; -import { average, getGrades } from '../../utils/mathFunctions'; +import { collectCallFapXLSXData } from './callFaps'; import { getDataRow } from './FapDataRow'; import { getStfcDataRow } from './stfc/StfcFapDataRow'; @@ -14,6 +16,7 @@ type FapXLSXData = Array<{ }>; export type RowObj = { + proposalPk: number; propShortCode?: string; propTitle?: string; principalInv: string; @@ -38,6 +41,8 @@ const populateRow = container.resolve<(row: RowObj) => (string | number)[]>( Tokens.PopulateRow ); +const fapDataSource: FapDataSource = container.resolve(Tokens.FapDataSource); + const sortByRankOrder = (a: RowObj, b: RowObj) => { if (a.propFapRankOrder === b.propFapRankOrder) { return -1; @@ -74,192 +79,94 @@ const sortByRankOrAverageScore = (data: RowObj[]) => { }); }; -export const collectFaplXLSXData = async ( +export const collectFapXLSXRowData = async ( fapId: number, callId: number, user: UserWithRole -): Promise<{ data: FapXLSXData; filename: string }> => { - const fap = await baseContext.queries.fap.get(user, fapId); - const call = await baseContext.queries.call.get(user, callId); +): Promise<{ sheetName: string; rows: RowObj[] }[]> => { + const baseData = await fapDataSource.getFapReviewData(callId, fapId); - // TODO: decide on filename - const filename = `Fap-${fap?.code}-${call?.shortCode}.xlsx`; - - const instruments = - await baseContext.queries.instrument.getInstrumentsByFapId(user, { - fapId, - callId, - }); + const instrumentData = groupBy(baseData, 'instrument_id'); - if (!instruments) { - throw new Error( - `Fap with ID '${fapId}'/Call with ID '${callId}' not found, or the user has insufficient rights` - ); - } + const out: { sheetName: string; rows: RowObj[] }[] = []; - const instrumentsFapProposals = await Promise.all( - instruments.map((instrument) => { - return baseContext.queries.fap.getFapProposalsByInstrument(user, { - instrumentId: instrument.id, - callId, - fapId, - }); - }) - ); + for (const instrument in instrumentData) { + const records = instrumentData[instrument]; - const instrumentsProposals = await Promise.all( - instrumentsFapProposals.map((fapProposalPks) => { - if (!fapProposalPks) { - const instrumentIds = instruments.map(({ id }) => id).join(', '); + const sheetName = records[0].instrument_name; - throw new Error( - `Fap with ID '${fapId}'/` + - `Call with ID '${callId}/'` + - `Instruments with IDs '${instrumentIds}' not found, or the user has insufficient rights` + const rows = await Promise.all( + records.map(async (proposal) => { + const pi = await baseContext.queries.user.getBasic( + user, + proposal.proposer_id ); - } - - return Promise.all( - fapProposalPks.map(({ proposalPk }) => - baseContext.queries.proposal.dataSource.get(proposalPk) - ) - ); - }) - ); - - const proposalsReviews = await Promise.all( - instrumentsProposals.map((proposals) => { - return Promise.all( - proposals.map((proposal) => - proposal - ? baseContext.queries.review.reviewsForProposal(user, { - proposalPk: proposal.primaryKey, - fapId: fapId, - }) - : null - ) - ); - }) - ); - const proposalsTechnicalReviews = await Promise.all( - instrumentsProposals.map((proposals) => { - return Promise.all( - proposals.map((proposal) => - proposal - ? baseContext.queries.review.technicalReviewsForProposal( - user, - proposal.primaryKey - ) - : null - ) - ); - }) - ); + const piFullName = `${pi?.firstname} ${pi?.lastname}`; - const proposalsFapMeetingDecisions = await Promise.all( - instrumentsProposals.map((proposals) => { - return Promise.all( - proposals.map((proposal) => - proposal - ? baseContext.queries.fap.getProposalFapMeetingDecisions(user, { - proposalPk: proposal.primaryKey, - fapId: fapId, - }) - : null - ) - ); - }) - ); - - const proposalsPrincipalInvestigators = await Promise.all( - instrumentsProposals.map((proposals) => { - return Promise.all( - proposals.map((proposal) => - proposal - ? baseContext.queries.user.getBasic(user, proposal.proposerId) - : null - ) - ); - }) - ); + const proposalAnswers = + await baseContext.queries.questionary.getQuestionarySteps( + user, + proposal.questionary_id + ); - const instrumentProposalsAnswers = await Promise.all( - instrumentsProposals.map((proposals) => { - return Promise.all( - proposals.map((proposal) => - proposal - ? baseContext.queries.questionary.getQuestionarySteps( - user, - proposal.questionaryId - ) - : null - ) - ); - }) - ); + const reviews = await baseContext.queries.review.reviewsForProposal( + user, + { proposalPk: proposal.proposal_pk, fapId: fapId } + ); - const out: FapXLSXData = []; + return fapDataRow( + proposal.proposal_pk, + piFullName, + proposal.average_grade, + proposal.instrument_name, + proposal.availability_time, + proposal.fap_time_allocation, + proposal.title, + proposal.proposal_id, + proposal.time_allocation, + proposal.rank_order, + proposal.proposer_id, + proposalAnswers, + reviews + ); + }) + ); - await Promise.all( - instruments.map(async (instrument, indx) => { - const proposals = instrumentsProposals[indx]; - const proposalReviews = proposalsReviews[indx]; - const proposalPrincipalInvestigators = - proposalsPrincipalInvestigators[indx]; - const technicalReviews = proposalsTechnicalReviews[indx]; - const fapProposals = instrumentsFapProposals[indx]; - const fapMeetingDecisions = proposalsFapMeetingDecisions[indx]; - const proposalsAnswers = instrumentProposalsAnswers[indx]; + out.push({ + sheetName: + // Sheet names can't exceed 31 characters + // use the short code and cut everything after 30 chars + sheetName.substring(0, 30), + rows, + }); + } - const rows = await Promise.all( - proposals.map(async (proposal, pIndx) => { - const { firstname = '', lastname = '' } = - proposalPrincipalInvestigators[pIndx] ?? {}; - const technicalReview = - technicalReviews[pIndx]?.find( - (technicalReview) => - technicalReview.instrumentId === instrument.id - ) || null; - const reviews = proposalReviews[pIndx]; - const fapProposal = fapProposals?.[pIndx]; - const proposalFapMeetingDecisions = fapMeetingDecisions[pIndx]; - const proposalAnswers = proposalsAnswers[pIndx]; + return out; +}; - const proposalAverageScore = average(getGrades(reviews)) || 0; +export const collectFapXLSXData = async ( + fapId: number, + callId: number, + user: UserWithRole +): Promise<{ data: FapXLSXData; filename: string }> => { + collectCallFapXLSXData(fapId, user); - const piFullName = `${firstname} ${lastname}`; - const fapMeetingDecision = - proposalFapMeetingDecisions?.find( - (fmd) => fmd.instrumentId === instrument.id - ) || null; + const fap = await baseContext.queries.fap.get(user, fapId); + const call = await baseContext.queries.call.get(user, callId); + const filename = `Fap-${fap?.code}-${call?.shortCode}.xlsx`; - return fapDataRow( - piFullName, - proposalAverageScore, - instrument, - fapMeetingDecision, - proposal, - technicalReview, - fapProposal ? fapProposal : null, - proposalAnswers, - reviews - ); - }) - ); + const data = await collectFapXLSXRowData(fapId, callId, user); - out.push({ - sheetName: - // Sheet names can't exceed 31 characters - // use the short code and cut everything after 30 chars - instrument.shortCode.substring(0, 30), - rows: sortByRankOrAverageScore(rows).map((row) => populateRow(row)), - }); - }) - ); + const transformedData: FapXLSXData = data.map((sheet) => { + return { + sheetName: sheet.sheetName, + rows: sortByRankOrAverageScore(sheet.rows).map((row) => populateRow(row)), + }; + }); return { - data: out, filename: filename.replace(/\s+/g, '_'), + data: transformedData, }; }; diff --git a/apps/backend/src/factory/xlsx/stfc/StfcFapDataRow.ts b/apps/backend/src/factory/xlsx/stfc/StfcFapDataRow.ts index 7dcfb89d2b..d4f6149c4a 100644 --- a/apps/backend/src/factory/xlsx/stfc/StfcFapDataRow.ts +++ b/apps/backend/src/factory/xlsx/stfc/StfcFapDataRow.ts @@ -1,26 +1,26 @@ import { stripHtml } from 'string-strip-html'; import { StfcUserDataSource } from '../../../datasources/stfc/StfcUserDataSource'; -import { FapProposal } from '../../../models/Fap'; -import { FapMeetingDecision } from '../../../models/FapMeetingDecision'; -import { InstrumentWithAvailabilityTime } from '../../../models/Instrument'; -import { Proposal } from '../../../models/Proposal'; import { QuestionaryStep } from '../../../models/Questionary'; import { Review } from '../../../models/Review'; -import { TechnicalReview } from '../../../models/TechnicalReview'; +import { CallRowObj } from '../callFaps'; import { RowObj } from '../fap'; import { getDataRow } from '../FapDataRow'; const stfcUserDataSource = new StfcUserDataSource(); export async function getStfcDataRow( + proposalPk: number, piName: string, proposalAverageScore: number, - instrument: InstrumentWithAvailabilityTime, - fapMeetingDecision: FapMeetingDecision | null, - proposal: Proposal | null, - technicalReview: TechnicalReview | null, - fapProposal: FapProposal | null, + instrument: string, + instrumentAvailabilityTime: number, + fapTimeAllocation: number | null, + proposalTitle: string, + proposalId: number | null, + technicalReviewTimeAllocation: number | null, + propFapRankOrder: number | null, + proposer_id: number | null, proposalAnswers: QuestionaryStep[] | null, reviews: Review[] | null ) { @@ -30,25 +30,26 @@ export async function getStfcDataRow( ?.value.value; const piDetails = await stfcUserDataSource.getStfcBasicPeopleByUserNumbers([ - proposal?.proposerId.toString() ?? '', + proposer_id?.toString() ?? '', ]); const piCountry = piDetails.find( - (user) => user.userNumber === proposal?.proposerId.toString() + (user) => user.userNumber === proposer_id?.toString() )?.country; return { ...getDataRow( + proposalPk, piName, proposalAverageScore, instrument, - fapMeetingDecision, - proposal, - technicalReview, - fapProposal + instrumentAvailabilityTime, + fapTimeAllocation, + proposalTitle, + proposalId, + technicalReviewTimeAllocation, + propFapRankOrder ), - instrName: instrument.name, - feedback: fapMeetingDecision?.commentForUser, daysRequested, reviews, piCountry: piCountry, @@ -71,3 +72,24 @@ export function populateStfcRow(row: RowObj) { row.propReviewAvgScore ?? '', ].concat(individualReviews ? individualReviews : []); } + +export function callFapStfcPopulateRow(row: CallRowObj): (string | number)[] { + const individualReviews = row.reviews?.flatMap((rev) => [ + rev.grade, + rev.comment && stripHtml(rev.comment).result, + ]); + + return [ + row.propShortCode ?? '', + row.principalInv ?? '', + row.piCountry ?? '', + row.instrName ?? '', + row.daysRequested ?? '', + row.propTitle ?? '', + row.propReviewAvgScore ?? '', + row.fapTimeAllocation ?? row.daysRequested ?? '', + row.fapMeetingDecision ?? '', + row.fapMeetingInComment ?? '', + row.fapMeetingExComment ?? '', + ].concat(individualReviews ? individualReviews : []); +} diff --git a/apps/backend/src/middlewares/factory/xlsx.ts b/apps/backend/src/middlewares/factory/xlsx.ts index 09ee3fe5cf..3b046e545f 100644 --- a/apps/backend/src/middlewares/factory/xlsx.ts +++ b/apps/backend/src/middlewares/factory/xlsx.ts @@ -10,7 +10,11 @@ import { DownloadService, } from '../../factory/DownloadService'; import { getCurrentTimestamp } from '../../factory/util'; -import { collectFaplXLSXData } from '../../factory/xlsx/fap'; +import { + CallExtraFapDataColumns, + collectCallFapXLSXData, +} from '../../factory/xlsx/callFaps'; +import { collectFapXLSXData } from '../../factory/xlsx/fap'; import { collectProposalXLSXData, defaultProposalDataColumns, @@ -79,7 +83,7 @@ router.get(`/${XLSXType.PROPOSAL}/:proposal_pks`, async (req, res, next) => { } }); -router.get(`/${XLSXType.Fap}/:fap_id/call/:call_id`, async (req, res, next) => { +router.get(`/${XLSXType.FAP}/:fap_id/call/:call_id`, async (req, res, next) => { try { if (!req.user) { throw new Error('Not authorized'); @@ -99,7 +103,7 @@ router.get(`/${XLSXType.Fap}/:fap_id/call/:call_id`, async (req, res, next) => { ); } - const { data, filename } = await collectFaplXLSXData( + const { data, filename } = await collectFapXLSXData( fapId, callId, userWithRole @@ -114,7 +118,49 @@ router.get(`/${XLSXType.Fap}/:fap_id/call/:call_id`, async (req, res, next) => { const userRole = req.user.currentRole; downloadService.callFactoryService( DownloadType.XLSX, - XLSXType.Fap, + XLSXType.FAP, + { data, meta, userRole }, + req, + res, + next + ); + } catch (e) { + next(e); + } +}); + +router.get(`/${XLSXType.CALL_FAP}/:call_id`, async (req, res, next) => { + try { + if (!req.user) { + throw new Error('Not authorized'); + } + + const userWithRole = { + ...req.user.user, + currentRole: req.user.currentRole, + }; + + const callId = parseInt(req.params.call_id); + + if (isNaN(+callId)) { + throw new Error(`Invalid call ID: Call ${req.params.call_id}`); + } + + const { data, filename } = await collectCallFapXLSXData( + callId, + userWithRole + ); + + const meta: XLSXMetaBase = { + singleFilename: filename, + collectionFilename: filename, + columns: fapDataColumns.concat(CallExtraFapDataColumns), + }; + + const userRole = req.user.currentRole; + downloadService.callFactoryService( + DownloadType.XLSX, + XLSXType.CALL_FAP, { data, meta, userRole }, req, res, diff --git a/apps/e2e/cypress.config.ts b/apps/e2e/cypress.config.ts index f3d3917f22..14cb9fb4ab 100644 --- a/apps/e2e/cypress.config.ts +++ b/apps/e2e/cypress.config.ts @@ -4,7 +4,12 @@ import { createServer, Server } from 'http'; import webpackPreprocessor from '@cypress/webpack-preprocessor'; import { defineConfig } from 'cypress'; -import { downloadFile, readPdf, unzip } from './cypress/support/fileUtilTasks'; +import { + convertXlsxToJson, + downloadFile, + readPdf, + unzip, +} from './cypress/support/fileUtilTasks'; function replaceLastOccurrenceInString( string: string, @@ -106,6 +111,8 @@ module.exports = defineConfig({ }); on('task', { unzip }); + + on('task', { convertXlsxToJson }); }, }, }); diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index 32d571a250..03f9294636 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -17,6 +17,8 @@ import initialDBData from '../support/initialDBData'; import settings from '../support/settings'; import { updatedCall, closedCall } from '../support/utils'; +faker.seed(1); + const fapMembers = { chair: initialDBData.users.user2, secretary: initialDBData.users.user3, @@ -2676,6 +2678,156 @@ context('Fap meeting components tests', () => { proposal1.title ); }); + + it('Officer should be be able to download of all FAP meetings in excel', () => { + cy.createProposal({ callId: initialDBData.call.id }).then((result) => { + const createdProposal = result.createProposal; + + cy.wrap(createdProposal.proposalId).as('proposal2Id'); + + if (createdProposal) { + cy.updateProposal({ + proposalPk: createdProposal.primaryKey, + title: proposal2.title, + abstract: proposal2.abstract, + proposerId: initialDBData.users.user1.id, + }); + + cy.assignProposalsToInstruments({ + instrumentIds: [createdInstrumentId], + proposalPks: [createdProposal.primaryKey], + }); + cy.addProposalTechnicalReview({ + proposalPk: createdProposal.primaryKey, + status: TechnicalReviewStatus.FEASIBLE, + timeAllocation: 5, + submitted: true, + reviewerId: 0, + instrumentId: createdInstrumentId, + }); + + cy.assignProposalsToFaps({ + fapInstruments: [ + { instrumentId: createdInstrumentId, fapId: createdFapId }, + ], + proposalPks: [createdProposal.primaryKey], + }); + + cy.assignReviewersToFap({ + fapId: createdFapId, + memberIds: [fapMembers.reviewer2.id], + }); + cy.assignFapReviewersToProposals({ + assignments: { + memberId: fapMembers.reviewer2.id, + proposalPk: firstCreatedProposalPk, + }, + fapId: createdFapId, + }); + cy.assignFapReviewersToProposals({ + assignments: { + memberId: fapMembers.reviewer.id, + proposalPk: createdProposal.primaryKey, + }, + fapId: createdFapId, + }); + cy.assignFapReviewersToProposals({ + assignments: { + memberId: fapMembers.reviewer2.id, + proposalPk: createdProposal.primaryKey, + }, + fapId: createdFapId, + }); + + // Manually changing the proposal status to be shown in the Faps. --------> + cy.changeProposalsStatus({ + statusId: initialDBData.proposalStatuses.fapReview.id, + proposalPks: [createdProposal.primaryKey], + }); + + cy.getProposalReviews({ + proposalPk: firstCreatedProposalPk, + }).then(({ proposalReviews }) => { + if (proposalReviews) { + proposalReviews.forEach((review, index) => { + cy.updateReview({ + reviewID: review.id, + comment: faker.random.words(5), + // NOTE: Make first proposal with lower standard deviation. Grades are 2 and 4 + grade: index ? 2 : 4, + status: ReviewStatus.SUBMITTED, + fapID: createdFapId, + }); + }); + } + }); + + cy.getProposalReviews({ + proposalPk: createdProposal.primaryKey, + }).then(({ proposalReviews }) => { + if (proposalReviews) { + proposalReviews.forEach((review, index) => { + cy.updateReview({ + reviewID: review.id, + comment: faker.random.words(5), + // NOTE: Make second proposal with higher standard deviation. Grades are 1 and 5 + grade: index ? 1 : 5, + status: ReviewStatus.SUBMITTED, + fapID: createdFapId, + }); + }); + } + }); + + cy.saveFapMeetingDecision({ + saveFapMeetingDecisionInput: { + commentForManagement: 'test', + commentForUser: 'test', + proposalPk: firstCreatedProposalPk, + submitted: true, + recommendation: ProposalEndStatus.ACCEPTED, + instrumentId: createdInstrumentId, + fapId: createdFapId, + }, + }); + + cy.saveFapMeetingDecision({ + saveFapMeetingDecisionInput: { + commentForManagement: 'test2', + commentForUser: 'test2', + proposalPk: createdProposal.primaryKey, + submitted: true, + recommendation: ProposalEndStatus.ACCEPTED, + instrumentId: createdInstrumentId, + fapId: createdFapId, + }, + }); + } + }); + + cy.login('officer'); + cy.visit(`/Calls`); + + cy.contains(updatedCall.shortCode) + .parent() + .find('[aria-label="Export Fap Data"]') + .click(); + + const downloadsFolder = Cypress.config('downloadsFolder'); + const fileName = `${updatedCall.shortCode}_FAP_Results.xlsx`; + + cy.readFile(`${downloadsFolder}/${fileName}`) + .should('exist') + .then(() => { + cy.task('convertXlsxToJson', `${downloadsFolder}/${fileName}`).then( + (actualExport) => { + cy.fixture('exampleCallFapExport.json').then((expectedExport) => { + expect(expectedExport).to.deep.equal(actualExport); + }); + } + ); + }); + }); }); describe('Fap Chair role', () => { diff --git a/apps/e2e/cypress/fixtures/exampleCallFapExport.json b/apps/e2e/cypress/fixtures/exampleCallFapExport.json new file mode 100644 index 0000000000..531a998b03 --- /dev/null +++ b/apps/e2e/cypress/fixtures/exampleCallFapExport.json @@ -0,0 +1,35 @@ +[ + { "Proposal Reference Number": "local lumen" }, + { + "Proposal Reference Number": "567122", + "Proposal Title": "lumen proofread hertz", + "Principal Investigator": "Carl Carlsson", + "Instrument": "local lumen", + "Instrument available time": 20, + "Technical review allocated time": 25, + "Fap allocated time": 25, + "Average Score": 3, + "Current rank": "", + "Is in availability zone": "no", + "Fap Time allocation": "", + "Fap Meeting Decision": "Accepted", + "Fap Meeting Comment for User": "test", + "Fap Meeting Internal Comment": "test" + }, + { + "Proposal Reference Number": "701367", + "Proposal Title": "web Connecticut driver", + "Principal Investigator": "Carl Carlsson", + "Instrument": "local lumen", + "Instrument available time": 20, + "Technical review allocated time": 5, + "Fap allocated time": 5, + "Average Score": 3, + "Current rank": "", + "Is in availability zone": "no", + "Fap Time allocation": "", + "Fap Meeting Decision": "Accepted", + "Fap Meeting Comment for User": "test2", + "Fap Meeting Internal Comment": "test2" + } +] diff --git a/apps/e2e/cypress/support/fileUtilTasks.ts b/apps/e2e/cypress/support/fileUtilTasks.ts index f9f33fec9d..3b8506a0b4 100644 --- a/apps/e2e/cypress/support/fileUtilTasks.ts +++ b/apps/e2e/cypress/support/fileUtilTasks.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import fetch from 'cross-fetch'; import pdf from 'pdf-parse'; +import * as xlsx from 'xlsx'; export function downloadFile(args: { url: string; @@ -59,3 +60,11 @@ export const unzip = (args: { source: string; destination: string }) => { return 'Files extracted to' + args.destination; }; + +export const convertXlsxToJson = (filePath: string) => { + const workbook = xlsx.readFile(filePath); + const worksheet = workbook.Sheets[workbook.SheetNames[0]]; + const jsonData = xlsx.utils.sheet_to_json(worksheet); + + return jsonData; +}; diff --git a/apps/e2e/package-lock.json b/apps/e2e/package-lock.json index 399278c7ff..041254bd35 100644 --- a/apps/e2e/package-lock.json +++ b/apps/e2e/package-lock.json @@ -18,7 +18,8 @@ "jwt-decode": "^4.0.0", "luxon": "^3.4.4", "pdf-parse": "^1.1.1", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@cypress/webpack-preprocessor": "^6.0.0", @@ -2859,6 +2860,14 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/adm-zip": { "version": "0.4.13", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.13.tgz", @@ -3457,6 +3466,18 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3553,6 +3574,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3639,6 +3668,17 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-fetch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", @@ -4652,6 +4692,14 @@ "node": ">= 0.12" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -6838,6 +6886,17 @@ "source-map": "^0.6.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/sshpk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", @@ -7632,6 +7691,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -7653,6 +7728,26 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -9770,6 +9865,11 @@ "dev": true, "requires": {} }, + "adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==" + }, "adm-zip": { "version": "0.4.13", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.13.tgz", @@ -10163,6 +10263,15 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" }, + "cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "requires": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + } + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -10229,6 +10338,11 @@ "string-width": "^4.2.0" } }, + "codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -10299,6 +10413,11 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, + "crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==" + }, "cross-fetch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", @@ -11080,6 +11199,11 @@ "mime-types": "^2.1.12" } }, + "frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==" + }, "fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -12628,6 +12752,14 @@ "source-map": "^0.6.0" } }, + "ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "requires": { + "frac": "~1.1.2" + } + }, "sshpk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", @@ -13182,6 +13314,16 @@ "has-tostringtag": "^1.0.0" } }, + "wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==" + }, + "word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==" + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -13197,6 +13339,20 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "requires": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + } + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/apps/e2e/package.json b/apps/e2e/package.json index beb636a89f..aa23916b27 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -14,7 +14,8 @@ "jwt-decode": "^4.0.0", "luxon": "^3.4.4", "pdf-parse": "^1.1.1", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@cypress/webpack-preprocessor": "^6.0.0", diff --git a/apps/frontend/src/components/call/CallsTable.tsx b/apps/frontend/src/components/call/CallsTable.tsx index 7ab3f2cc2e..dc36ad5f9c 100644 --- a/apps/frontend/src/components/call/CallsTable.tsx +++ b/apps/frontend/src/components/call/CallsTable.tsx @@ -1,5 +1,6 @@ import { Column } from '@material-table/core'; import Archive from '@mui/icons-material/Archive'; +import GridOnIcon from '@mui/icons-material/GridOn'; import Unarchive from '@mui/icons-material/Unarchive'; import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; @@ -24,6 +25,7 @@ import { import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; import { useCallsData } from 'hooks/call/useCallsData'; import { useCheckAccess } from 'hooks/common/useCheckAccess'; +import { useDownloadXLSXCallFap } from 'hooks/fap/useDownloadXLSXCallFap'; import { tableIcons } from 'utils/materialIcons'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import { FunctionType } from 'utils/utilTypes'; @@ -71,6 +73,7 @@ const CallsTable = ({ confirm }: WithConfirmProps) => { ...DefaultQueryParams, callStatus: defaultCallStatusQueryFilter, }); + const exportFapData = useDownloadXLSXCallFap(); const { loadingCalls, @@ -346,6 +349,12 @@ const CallsTable = ({ confirm }: WithConfirmProps) => { onClick: (): void => changeCallActiveStatus(rowData as Call), position: 'row', }), + (rowData) => ({ + icon: GridOnIcon, + tooltip: `Export Fap Data`, + onClick: (): void => exportFapData(rowData.id, rowData.shortCode), + position: 'row', + }), ]} urlQueryParams={urlQueryParams} setUrlQueryParams={setUrlQueryParams} diff --git a/apps/frontend/src/context/DownloadContextProvider.tsx b/apps/frontend/src/context/DownloadContextProvider.tsx index 5344a69478..ede756a401 100644 --- a/apps/frontend/src/context/DownloadContextProvider.tsx +++ b/apps/frontend/src/context/DownloadContextProvider.tsx @@ -114,6 +114,7 @@ export enum PREPARE_DOWNLOAD_TYPE { ZIP_PROPOSAL, XLSX_PROPOSAL, XLSX_FAP, + XLSX_CALL_FAP, } export type DownloadOptions = { @@ -152,6 +153,8 @@ function generateLink( return '/download/pdf/generic-template/' + ids; case PREPARE_DOWNLOAD_TYPE.XLSX_PROPOSAL: return '/download/xlsx/proposal/' + ids; + case PREPARE_DOWNLOAD_TYPE.XLSX_CALL_FAP: + return '/download/xlsx/call_fap/' + ids; case PREPARE_DOWNLOAD_TYPE.XLSX_FAP: const [params] = ids; diff --git a/apps/frontend/src/hooks/fap/useDownloadXLSXCallFap.ts b/apps/frontend/src/hooks/fap/useDownloadXLSXCallFap.ts new file mode 100644 index 0000000000..291642e73b --- /dev/null +++ b/apps/frontend/src/hooks/fap/useDownloadXLSXCallFap.ts @@ -0,0 +1,17 @@ +import { useCallback, useContext } from 'react'; + +import { + DownloadContext, + PREPARE_DOWNLOAD_TYPE, +} from 'context/DownloadContextProvider'; +export function useDownloadXLSXCallFap() { + const { prepareDownload } = useContext(DownloadContext); + const downloadFapXLSX = useCallback( + (callId: number, name: string) => { + prepareDownload(PREPARE_DOWNLOAD_TYPE.XLSX_CALL_FAP, [callId], name); + }, + [prepareDownload] + ); + + return downloadFapXLSX; +}