diff --git a/apps/backend/src/datasources/mockups/FapDataSource.ts b/apps/backend/src/datasources/mockups/FapDataSource.ts index 8c151b1d33..1578efd445 100644 --- a/apps/backend/src/datasources/mockups/FapDataSource.ts +++ b/apps/backend/src/datasources/mockups/FapDataSource.ts @@ -118,7 +118,10 @@ export const dummyFapAssignment = new FapAssignment( false, null, false, - null + null, + 'dummy comment', + 'dummy grade', + 1 ); export const anotherDummyFapAssignment = new FapAssignment( @@ -129,7 +132,10 @@ export const anotherDummyFapAssignment = new FapAssignment( false, null, false, - null + null, + 'dummy comment', + 'dummy grade', + 1 ); export const dummyFapReview = new Review( diff --git a/apps/backend/src/datasources/postgres/FapDataSource.ts b/apps/backend/src/datasources/postgres/FapDataSource.ts index 2942c755e7..7d6da7f7f5 100644 --- a/apps/backend/src/datasources/postgres/FapDataSource.ts +++ b/apps/backend/src/datasources/postgres/FapDataSource.ts @@ -287,13 +287,13 @@ export default class PostgresFapDataSource implements FapDataSource { ): Promise { const fapAssignments: ReviewRecord[] = await database .from('fap_reviews') + .where('fap_id', fapId) + .andWhere('proposal_pk', proposalPk) .modify((query) => { if (reviewerId !== null) { - query.where('user_id', reviewerId); + query.andWhere('user_id', reviewerId); } - }) - .where('fap_id', fapId) - .andWhere('proposal_pk', proposalPk); + }); return fapAssignments.map((fapAssignment) => createFapAssignmentObject(fapAssignment) @@ -1345,20 +1345,20 @@ export default class PostgresFapDataSource implements FapDataSource { fapId: number, instrumentId?: number | null ): Promise { - if (instrumentId) { - return await database - .select('*') - .from('review_data') - .where('fap_id', fapId) - .andWhere('call_id', callId) - .andWhere('instrument_id', instrumentId); - } - - return await database + const query = database .select('*') .from('review_data') - .where('fap_id', fapId) - .andWhere('call_id', callId); + .where('fap_id', fapId); + + if (callId != 0) { + query.andWhere('call_id', callId); + } + + if (instrumentId != null) { + query.andWhere('instrument_id', instrumentId); + } + + return await query; } async submitFapMeetings( diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 4d8963560d..9174153419 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -1191,7 +1191,10 @@ export const createFapAssignmentObject = (fapAssignment: ReviewRecord) => { fapAssignment.reassigned, fapAssignment.date_reassigned, fapAssignment.email_sent, - fapAssignment.rank + fapAssignment.rank, + fapAssignment.comment, + fapAssignment.grade, + fapAssignment.status ); }; diff --git a/apps/backend/src/factory/service.ts b/apps/backend/src/factory/service.ts index 24a048190b..5c45f22e80 100644 --- a/apps/backend/src/factory/service.ts +++ b/apps/backend/src/factory/service.ts @@ -19,6 +19,7 @@ export enum XLSXType { PROPOSAL = 'proposal', FAP = 'fap', CALL_FAP = 'call_fap', + FAP_REVIEWS = 'fap_reviews', TECHNIQUE = 'technique', MANAGEMENT_DECISION = 'management_decision', } diff --git a/apps/backend/src/factory/xlsx/FapDataRow.ts b/apps/backend/src/factory/xlsx/FapDataRow.ts index c3e90ab32d..1c1112f085 100644 --- a/apps/backend/src/factory/xlsx/FapDataRow.ts +++ b/apps/backend/src/factory/xlsx/FapDataRow.ts @@ -23,6 +23,37 @@ export type FapDataRowInput = { export type FapDataRow = (input: FapDataRowInput) => RowObj | Promise; +export type ReviewRowInput = { + proposalId: number | null; + title: string | null; + instrumentName: string | null; + dateAssigned: Date | string | null; + rank: number | null; + grade: string | null; + comment: string | null; + status: string | null; +}; + +export const buildReviewRow = ({ + proposalId, + title, + instrumentName, + dateAssigned, + rank, + grade, + comment, + status, +}: ReviewRowInput): Array => [ + proposalId ?? '-', + title ?? '-', + instrumentName ?? '-', + dateAssigned ? String(dateAssigned) : '-', + rank ?? '-', + grade ?? '-', + comment ?? '-', + status ?? '-', +]; + export function getDataRow({ proposalPk, piName, diff --git a/apps/backend/src/factory/xlsx/fap.ts b/apps/backend/src/factory/xlsx/fap.ts index 358e1846e1..7c78e2c4c9 100644 --- a/apps/backend/src/factory/xlsx/fap.ts +++ b/apps/backend/src/factory/xlsx/fap.ts @@ -1,8 +1,9 @@ import { groupBy } from 'lodash'; +import { DateTime } from 'luxon'; import { container } from 'tsyringe'; import { collectCallFapXLSXData } from './callFaps'; -import { FapDataRow, FapDataRowInput } from './FapDataRow'; +import { buildReviewRow, FapDataRow, FapDataRowInput } from './FapDataRow'; import baseContext from '../../buildContext'; import { Tokens } from '../../config/Tokens'; import { FapDataSource } from '../../datasources/FapDataSource'; @@ -174,3 +175,108 @@ export const collectFapXLSXData = async ( data: transformedData, }; }; +export const collectFapReviewXLSXData = async ( + fapId: number, + callId: number, + reviewerProposals: Record, + user: UserWithRole +): Promise<{ data: FapXLSXData; filename: string }> => { + const reviewData = await fapDataSource.getFapReviewData(callId, fapId); + + const data: FapXLSXData = []; + for (const [reviewerIdString, proposalPks] of Object.entries( + reviewerProposals + )) { + const reviewerId = Number(reviewerIdString); + + const reviewer = await baseContext.queries.user.getBasic(user, reviewerId); + + const reviewerName = + `${reviewer?.firstname ?? ''} ${reviewer?.lastname ?? ''}`.trim(); + const rankedRows: Array<{ + rank: number | null; + grade: number | null; + row: Array; + }> = []; + for (const proposalPk of proposalPks) { + const review = reviewData.find((item) => item.proposal_pk === proposalPk); + + if (!review) { + continue; + } + + const assignments = await fapDataSource.getFapProposalAssignments( + fapId, + proposalPk, + reviewerId + ); + + const assignment = assignments.find( + (item) => item.fapMemberUserId === reviewerId + ); + + if (!assignment) { + continue; + } + + const grade = Number(assignment.grade); + rankedRows.push({ + rank: assignment.rank, + grade: Number.isFinite(grade) ? grade : null, + row: buildReviewRow({ + proposalId: review.proposal_id, + title: review.title ?? '-', + instrumentName: review.instrument_name ?? '-', + dateAssigned: formatDate(assignment.dateAssigned), + rank: assignment.rank ?? null, + grade: assignment.grade, + comment: stripHtml(assignment.comment ?? '-'), + status: assignment.status == 1 ? 'Submitted' : 'Draft', + }), + }); + } + + const rows = rankedRows + .sort((a, b) => { + if (a.rank === null || b.rank === null) { + if (a.rank !== b.rank) { + return a.rank === null ? 1 : -1; + } + } else if (a.rank !== b.rank) { + return a.rank - b.rank; + } + + if (a.grade === null || b.grade === null) { + if (a.grade !== b.grade) { + return a.grade === null ? 1 : -1; + } + } else if (a.grade !== b.grade) { + return b.grade - a.grade; + } + + return 0; + }) + .map(({ row }) => row); + + data.push({ + sheetName: reviewerName.substring(0, 31), + rows, + }); + } + + return { + filename: 'fap_reviews.xlsx', + data, + }; +}; + +const formatDate = (value: string | Date | null | undefined): string => { + if (!value) return '-'; + + const dt = + typeof value === 'string' + ? DateTime.fromISO(value) + : DateTime.fromJSDate(value); + + return dt.isValid ? dt.toFormat('dd-MM-yyyy') : '-'; +}; diff --git a/apps/backend/src/middlewares/factory/xlsx.ts b/apps/backend/src/middlewares/factory/xlsx.ts index 07e5a377bc..b8b5984929 100644 --- a/apps/backend/src/middlewares/factory/xlsx.ts +++ b/apps/backend/src/middlewares/factory/xlsx.ts @@ -14,7 +14,10 @@ import { DefaultCallExtraFapDataColumns, collectCallFapXLSXData, } from '../../factory/xlsx/callFaps'; -import { collectFapXLSXData } from '../../factory/xlsx/fap'; +import { + collectFapXLSXData, + collectFapReviewXLSXData, +} from '../../factory/xlsx/fap'; import { collectManagementDecisionXLSXData } from '../../factory/xlsx/managementDecision'; import { collectProposalXLSXData, @@ -130,6 +133,79 @@ router.get(`/${XLSXType.FAP}/:fap_id/call/:call_id`, async (req, res, next) => { } }); +router.get( + `/${XLSXType.FAP_REVIEWS}/:fap_id/call/:call_id`, + async (req, res, next) => { + try { + if (!req.user) { + throw new Error('Not authorized'); + } + + const userWithRole = { + ...res.locals.agent, + }; + + const fapId = parseInt(req.params.fap_id); + const callId = parseInt(req.params.call_id); + + if (isNaN(fapId) || isNaN(callId)) { + throw new Error( + `Invalid Fap or call ID: Fap ${req.params.fap_id}, Call ${req.params.call_id}` + ); + } + + const reviewerProposalsParam = req.query.reviewerProposals; + + if (typeof reviewerProposalsParam !== 'string') { + throw new Error('Proposal reviewer is required'); + } + + const reviewerProposals: Record = JSON.parse( + reviewerProposalsParam + ); + + const { data, filename } = await collectFapReviewXLSXData( + fapId, + callId, + reviewerProposals, + userWithRole + ); + + const meta: XLSXMetaBase = { + singleFilename: filename, + collectionFilename: filename, + columns: [ + 'Proposal ID', + 'Proposal title', + 'Instrument', + 'Date assigned', + 'Rank', + 'Grade', + 'Comment', + 'Status', + ], + }; + + const userRole = req.user.currentRole; + + callFactoryService( + DownloadType.XLSX, + XLSXType.FAP_REVIEWS, + { + 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) { diff --git a/apps/backend/src/models/Fap.ts b/apps/backend/src/models/Fap.ts index 794c3d8bb1..b570857692 100644 --- a/apps/backend/src/models/Fap.ts +++ b/apps/backend/src/models/Fap.ts @@ -72,6 +72,9 @@ export class FapAssignment { public reassigned: boolean, public dateReassigned: Date | null, public emailSent: boolean, - public rank: number | null + public rank: number | null, + public comment: string, + public grade: string, + public status: number ) {} } diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index 3da8dad698..7c0a99eb09 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -417,17 +417,12 @@ context('Fap reviews tests', () => { 'not.have.class', 'Mui-disabled' ); - cy.get('[data-cy="fap-selection"]').contains(instrument.name); cy.get('[data-cy="fap-selection"]').click(); + cy.get('[data-cy="fap-selection"]').contains(instrument.name); cy.get('[role="listbox"] li[role="option"]').contains(fap1.code).click(); cy.get('[data-cy="submit"]').click(); - cy.notification({ - text: 'Proposal/s assigned to the selected Fap successfully', - variant: 'success', - }); - cy.visit(`/FapPage/${createdFapId}?tab=3`); cy.finishedLoading(); @@ -531,7 +526,7 @@ context('Fap reviews tests', () => { }); cy.login('officer'); - cy.visit(`/FapPage/${createdFapId}?tab=3&pageSize=5`); + cy.visit(`/FapPage/${createdFapId}?tab=3&pa-page=0&pa-pageSize=5`); //should go to the second page cy.get('button[aria-label="Next Page"]').click(); cy.contains(proposal1.title).should('not.exist'); @@ -575,7 +570,9 @@ context('Fap reviews tests', () => { cy.get('[role=presentation]').contains(instrument.name).click(); cy.get('body').type('{esc}'); - cy.get('[data-cy="fap-assignments-table"]').contains(instrument.name); + cy.get('[data-cy="fap-assignments-table"]').contains( + instrument.shortCode + ); cy.get('[data-cy="fap-assignments-table"]').contains( firstCreatedProposalPk ); @@ -1266,7 +1263,7 @@ context('Fap reviews tests', () => { cy.contains(firstCreatedProposalId).should('be.visible'); }); - it.only('Should be able to assign proposals to reviewers in the Reviewers to Assignments view', () => { + it('Should be able to assign proposals to reviewers in the Reviewers to Assignments view', () => { cy.assignProposalsToFaps({ fapInstruments: [ { instrumentId: newlyCreatedInstrumentId, fapId: createdFapId }, @@ -1414,7 +1411,7 @@ context('Fap reviews tests', () => { cy.get('[role="dialog"]').contains('Download PDF'); }); - it('Fap Chair should be able to read/write/submit non-submitted reviews', () => { + it('Fap Chair should be able to read/write non-submitted reviews', () => { cy.assignFapReviewersToProposals({ assignments: { memberId: fapMembers.reviewer.id, @@ -1444,9 +1441,7 @@ context('Fap reviews tests', () => { .click(); cy.get('[data-cy="save-and-continue-button"]').focus().click(); - cy.contains('Submit').click(); - cy.contains('OK').click(); - cy.contains('Submitted').should('be.disabled'); + cy.contains('Submit').should('be.disabled'); cy.visit(`/FapPage/${createdFapId}?tab=3`); cy.finishedLoading(); @@ -1577,9 +1572,7 @@ context('Fap reviews tests', () => { .click(); cy.get('[data-cy="save-and-continue-button"]').focus().click(); - cy.contains('Submit').click(); - cy.contains('OK').click(); - cy.contains('Submitted').should('be.disabled'); + cy.contains('Submit').should('be.disabled'); cy.finishedLoading(); }); @@ -1702,6 +1695,85 @@ context('Fap reviews tests', () => { cy.contains('Technical reviews').click(); cy.contains(comment1).should('exist'); }); + + it('FAP Secretary should be able to download the fap reviews excel sheet', () => { + cy.assignFapReviewersToProposals({ + assignments: [ + { + memberId: fapMembers.reviewer.id, + proposalPk: firstCreatedProposalPk, + rank: 1, + }, + { + memberId: fapMembers.reviewer2.id, + proposalPk: firstCreatedProposalPk, + rank: 2, + }, + ], + fapId: createdFapId, + }); + cy.changeProposalsStatus({ + workflowStatusId: fapReviewWorkflowStatusId, + proposalPks: [firstCreatedProposalPk], + }); + + cy.getProposalReviews({ + proposalPk: firstCreatedProposalPk, + }).then(({ proposalReviews }) => { + if (proposalReviews) { + proposalReviews.forEach((review, index) => { + cy.updateReview({ + reviewID: review.id, + comment: index === 0 ? comment1 : comment2, + grade: index == 0 ? '4' : '6', + status: index == 0 ? ReviewStatus.SUBMITTED : ReviewStatus.DRAFT, + fapID: createdFapId, + questionaryID: review.questionaryID, + }); + }); + } + }); + + cy.login(fapMembers.secretary); + cy.visit(`/FapPage/2?tab=4`); + cy.finishedLoading(); + + cy.contains(fapMembers.reviewer.lastName).should('be.visible'); + cy.contains(fapMembers.reviewer.lastName) + .parent() + .find('input[type="checkbox"]') + .click(); + + cy.contains(fapMembers.reviewer2.lastName).should('be.visible'); + cy.contains(fapMembers.reviewer2.lastName) + .parent() + .find('input[type="checkbox"]') + .click(); + + cy.get('[data-cy="export-reviews-in-excel"]').click(); + cy.readFile(`${Cypress.config('downloadsFolder')}/fap_reviews.xlsx`) + .should('exist') + .then(() => { + cy.task( + 'convertXlsxToJson', + `${Cypress.config('downloadsFolder')}/fap_reviews.xlsx` + ).then((actualExport) => { + const today = new Date(); + const currentDate = [ + String(today.getDate()).padStart(2, '0'), + String(today.getMonth() + 1).padStart(2, '0'), + today.getFullYear(), + ].join('-'); + cy.fixture('exampleFapReviewsExport.json').then( + (expectedExport) => { + expectedExport[0][0]['Date assigned'] = currentDate; + expectedExport[1][0]['Date assigned'] = currentDate; + expect(expectedExport).to.deep.equal(actualExport); + } + ); + }); + }); + }); }); describe('Fap Reviewer role', () => { @@ -4538,7 +4610,7 @@ context( cy.updateQuestionTemplateRelationSettings({ questionId: instrumentPickerQuestionId, templateId: initialDBData.template.id, - config: `{"variant":"dropdown","isMultipleSelect":true,"required":true,"requestTime":false,"readPermissions":[]}`, + config: `{"variant":"dropdown","isMultipleSelect":true,"required":true,"requestTime":false,"instruments": [],"readPermissions":[]}`, dependencies: [], }); @@ -4913,6 +4985,17 @@ context('Fap meeting exports test', () => { cy.task('convertXlsxToJson', `${downloadsFolder}/${fileName}`).then( (actualExport) => { cy.fixture('exampleFapExportSTFC.json').then((expectedExport) => { + expectedExport[0]['Instrument Name'] = instrument1.name; + expectedExport[0]['Proposal Title'] = proposal1.title; + expectedExport[0]['Technical Review Comment'] = comment1; + expectedExport[0]['Reviewer 1 review comment'] = comment1; + expectedExport[0]['Reviewer 2 review comment'] = comment1; + + expectedExport[1]['Instrument Name'] = instrument1.name; + expectedExport[1]['Proposal Title'] = proposal2.title; + expectedExport[1]['Technical Review Comment'] = comment2; + expectedExport[1]['Reviewer 1 review comment'] = comment2; + expectedExport[1]['Reviewer 2 review comment'] = comment2; expect(expectedExport).to.deep.equal(actualExport); }); } @@ -4934,7 +5017,7 @@ context('Fap meeting exports test', () => { cy.contains(updatedCall.shortCode) .parent() - .find('[aria-label="Export Fap Data"]') + .find('[aria-label="Export FAP Data"]') .click(); cy.get('[data-cy=preparing-download-dialog').should('not.exist'); @@ -4950,6 +5033,21 @@ context('Fap meeting exports test', () => { (actualExport) => { cy.fixture('exampleCallFapExportSTFC.json').then( (expectedExport) => { + expectedExport[0]['Proposal Reference Number'] = + instrument1.name; + + expectedExport[1]['Instrument Name'] = instrument1.name; + expectedExport[1]['Proposal Title'] = proposal1.title; + expectedExport[1]['Technical Review Comment'] = comment1; + expectedExport[1]['Reviewer 1 review comment'] = comment1; + expectedExport[1]['Reviewer 2 review comment'] = comment1; + + expectedExport[2]['Instrument Name'] = instrument1.name; + expectedExport[2]['Proposal Title'] = proposal2.title; + expectedExport[2]['Technical Review Comment'] = comment2; + expectedExport[2]['Reviewer 1 review comment'] = comment2; + expectedExport[2]['Reviewer 2 review comment'] = comment2; + expect(expectedExport).to.deep.equal(actualExport); } ); diff --git a/apps/e2e/cypress/fixtures/exampleCallFapExportSTFC.json b/apps/e2e/cypress/fixtures/exampleCallFapExportSTFC.json index 79e3836dac..bd8b458e59 100755 --- a/apps/e2e/cypress/fixtures/exampleCallFapExportSTFC.json +++ b/apps/e2e/cypress/fixtures/exampleCallFapExportSTFC.json @@ -1,6 +1,6 @@ [ { - "Proposal Reference Number": "Central lavender" + "Proposal Reference Number": "" }, { "Proposal Reference Number": "567122", @@ -8,17 +8,17 @@ "Principal Investigator": "Carl Carlsson", "PI Country": "UNITED KINGDOM", "PI Organisation": "STFC", - "Instrument Name": "Central lavender", + "Instrument Name": "", "Requested Time": "", - "Proposal Title": "lumen proofread hertz", - "Technical Review Comment": "earum ante at accusantium crustulum", + "Proposal Title": "", + "Technical Review Comment": "", "Average score": 3, "Reviewer 1": "Nils Nilsson ", "Reviewer 1 score": "4", - "Reviewer 1 review comment": "earum ante at accusantium crustulum", + "Reviewer 1 review comment": "", "Reviewer 2": " Dawson ", "Reviewer 2 score": "2", - "Reviewer 2 review comment": "earum ante at accusantium crustulum", + "Reviewer 2 review comment": "", "Reviewer 3": "No reviewer", "Reviewer 3 score": "-", "Reviewer 3 review comment": "-", @@ -33,17 +33,17 @@ "Principal Investigator": "Carl Carlsson", "PI Country": "UNITED KINGDOM", "PI Organisation": "STFC", - "Instrument Name": "Central lavender", + "Instrument Name": "", "Requested Time": "", - "Proposal Title": "web Connecticut driver", - "Technical Review Comment": "acquiro amita ad dolore cariosus", + "Proposal Title": "", + "Technical Review Comment": "", "Average score": 3, "Reviewer 1": "Nils Nilsson ", "Reviewer 1 score": "5", - "Reviewer 1 review comment": "acquiro amita ad dolore cariosus", + "Reviewer 1 review comment": "", "Reviewer 2": " Dawson ", "Reviewer 2 score": "1", - "Reviewer 2 review comment": "acquiro amita ad dolore cariosus", + "Reviewer 2 review comment": "", "Reviewer 3": "No reviewer", "Reviewer 3 score": "-", "Reviewer 3 review comment": "-", diff --git a/apps/e2e/cypress/fixtures/exampleFapExportSTFC.json b/apps/e2e/cypress/fixtures/exampleFapExportSTFC.json index a4572b0a88..9e6ef17de9 100755 --- a/apps/e2e/cypress/fixtures/exampleFapExportSTFC.json +++ b/apps/e2e/cypress/fixtures/exampleFapExportSTFC.json @@ -5,17 +5,17 @@ "Principal Investigator": "Carl Carlsson", "PI Country": "UNITED KINGDOM", "PI Organisation": "STFC", - "Instrument Name": "Central lavender", + "Instrument Name": "", "Requested Time": "", - "Proposal Title": "lumen proofread hertz", - "Technical Review Comment": "earum ante at accusantium crustulum", + "Proposal Title": "", + "Technical Review Comment": "", "Average score": 3, "Reviewer 1": "Nils Nilsson ", "Reviewer 1 score": "4", - "Reviewer 1 review comment": "earum ante at accusantium crustulum", + "Reviewer 1 review comment": "", "Reviewer 2": " Dawson ", "Reviewer 2 score": "2", - "Reviewer 2 review comment": "earum ante at accusantium crustulum", + "Reviewer 2 review comment": "", "Reviewer 3": "No reviewer", "Reviewer 3 score": "-", "Reviewer 3 review comment": "-" @@ -26,17 +26,17 @@ "Principal Investigator": "Carl Carlsson", "PI Country": "UNITED KINGDOM", "PI Organisation": "STFC", - "Instrument Name": "Central lavender", + "Instrument Name": "", "Requested Time": "", - "Proposal Title": "web Connecticut driver", - "Technical Review Comment": "acquiro amita ad dolore cariosus", + "Proposal Title": "", + "Technical Review Comment": "", "Average score": 3, "Reviewer 1": "Nils Nilsson ", "Reviewer 1 score": "5", - "Reviewer 1 review comment": "acquiro amita ad dolore cariosus", + "Reviewer 1 review comment": "", "Reviewer 2": " Dawson ", "Reviewer 2 score": "1", - "Reviewer 2 review comment": "acquiro amita ad dolore cariosus", + "Reviewer 2 review comment": "", "Reviewer 3": "No reviewer", "Reviewer 3 score": "-", "Reviewer 3 review comment": "-" diff --git a/apps/e2e/cypress/fixtures/exampleFapReviewsExport.json b/apps/e2e/cypress/fixtures/exampleFapReviewsExport.json new file mode 100644 index 0000000000..cd40d91c83 --- /dev/null +++ b/apps/e2e/cypress/fixtures/exampleFapReviewsExport.json @@ -0,0 +1,26 @@ +[ + [ + { + "Proposal ID": "567122", + "Proposal title": "stultus comedo stipes", + "Instrument": "odit subseco", + "Date assigned": "", + "Rank": 1, + "Grade": "4", + "Comment": "turbo ante cibo occaecati volubilis", + "Status": "Submitted" + } + ], + [ + { + "Proposal ID": "567122", + "Proposal title": "stultus comedo stipes", + "Instrument": "odit subseco", + "Date assigned": "", + "Rank": 2, + "Grade": "6", + "Comment": "laudantium accusamus thesaurus candidus timor", + "Status": "Draft" + } + ] +] \ No newline at end of file diff --git a/apps/e2e/cypress/support/fileUtilTasks.ts b/apps/e2e/cypress/support/fileUtilTasks.ts index 0f4d4c03b7..9afee7648a 100644 --- a/apps/e2e/cypress/support/fileUtilTasks.ts +++ b/apps/e2e/cypress/support/fileUtilTasks.ts @@ -77,10 +77,18 @@ export const unzip = (args: { source: string; destination: string }) => { 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); + if (workbook.SheetNames.length === 1) { + const worksheet = workbook.Sheets[workbook.SheetNames[0]]; + const jsonData = xlsx.utils.sheet_to_json(worksheet); - return jsonData; + return jsonData; + } + + return workbook.SheetNames.map((sheetName) => { + const worksheet = workbook.Sheets[sheetName]; + + return xlsx.utils.sheet_to_json(worksheet); + }); }; export const deleteFile = (filePath: string) => { diff --git a/apps/frontend/src/components/fap/Proposals/ReviewersView/FapReviewersAndAssignmentsTable.tsx b/apps/frontend/src/components/fap/Proposals/ReviewersView/FapReviewersAndAssignmentsTable.tsx index f2fdb44abe..f68d20d57c 100644 --- a/apps/frontend/src/components/fap/Proposals/ReviewersView/FapReviewersAndAssignmentsTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/ReviewersView/FapReviewersAndAssignmentsTable.tsx @@ -1,9 +1,11 @@ import MaterialTable, { Action, Column } from '@material-table/core'; import AssignmentInd from '@mui/icons-material/AssignmentInd'; +import GridOnIcon from '@mui/icons-material/GetApp'; import React from 'react'; import { useSearchParams } from 'react-router-dom'; import { Fap } from 'generated/sdk'; +import { useDownloadXLSXFapReviews } from 'hooks/fap/useDownloadXLSXFapReviews'; import { useExpandCollapseAll } from 'hooks/fap/useExpandCollapseAll'; import { FapMember, useFapMembersData } from 'hooks/fap/useFapMembersData'; import { @@ -123,12 +125,15 @@ const FapReviewersAndAssignmentsTable = ({ const page = searchParams.get('ra-page'); const pageSize = searchParams.get('ra-pageSize'); const selection = searchParams.getAll('ra-selection'); + const call = searchParams.get('call'); const { tableRef, expandCollapseAllButton } = useExpandCollapseAll( '[data-cy="fap-reviewers-assignments-table"]', [loadingMembers, loadingFapProposals] ); + const downloadFapReviewsXLSX = useDownloadXLSXFapReviews(); + const reviewersAndProposals: ReviewerAndProposals[] = FapMembersData.map( (member) => { const assignedProposals = fapProposals.FapProposalsData.filter( @@ -241,6 +246,37 @@ const FapReviewersAndAssignmentsTable = ({ setMembersToAssign(fapMemberUsersToAssign); }; + const handleBulkDownloadClick = ( + event: React.MouseEventHandler, + rowData: ReviewerAndProposals | ReviewerAndProposals[] + ) => { + if (!Array.isArray(rowData)) { + return; + } + const reviewerProposalMap = new Map(); + rowData.forEach((reviewer) => { + reviewer.assignedProposals.forEach(({ assignment }) => { + const reviewerId = assignment.fapMemberUserId; + const proposalPk = assignment.proposalPk; + + if (reviewerId === null) { + return; + } + + const proposals = reviewerProposalMap.get(reviewerId) ?? []; + + proposals.push(proposalPk); + + reviewerProposalMap.set(reviewerId, proposals); + }); + }); + const reviewerProposals = Object.fromEntries(reviewerProposalMap); + + downloadFapReviewsXLSX(fap.id, Number(call), 'fap-review.xlsx', { + reviewerProposals, + }); + }; + const tableActions: Action[] = []; tableActions.push({ icon: () => , @@ -248,6 +284,12 @@ const FapReviewersAndAssignmentsTable = ({ onClick: handleAssignProposalsToMembers, position: 'toolbarOnSelect', }); + tableActions.push({ + icon: () => , + tooltip: 'Export reviews in excel', + onClick: handleBulkDownloadClick, + position: 'toolbarOnSelect', + }); const maxPageLength = reviewersAndProposals.length; diff --git a/apps/frontend/src/context/DownloadContextProvider.tsx b/apps/frontend/src/context/DownloadContextProvider.tsx index 77beb19e54..10ef7df3c7 100644 --- a/apps/frontend/src/context/DownloadContextProvider.tsx +++ b/apps/frontend/src/context/DownloadContextProvider.tsx @@ -118,12 +118,19 @@ export enum PREPARE_DOWNLOAD_TYPE { XLSX_PROPOSAL, XLSX_FAP, XLSX_CALL_FAP, + XLSX_FAP_REVIEWS, XLSX_PROPOSAL_TECHNIQUE, XLSX_MANAGEMENT_DECISION, } +export type ReviewerProposalData = { + proposalPks: number[]; + role: string | null; +}; + export type DownloadOptions = { questionIds?: string; + reviewerProposals?: Record; }; export interface DownloadContextData { @@ -174,6 +181,14 @@ function generateLink( const [fapId, callId] = params; return `/download/xlsx/fap/${fapId}/call/${callId}`; + case PREPARE_DOWNLOAD_TYPE.XLSX_FAP_REVIEWS: { + const [fapId, callId] = ids; + const reviewerProposals = encodeURIComponent( + JSON.stringify(options?.reviewerProposals ?? {}) + ); + + return `/download/xlsx/fap_reviews/${fapId}/call/${callId}?reviewerProposals=${reviewerProposals}`; + } case PREPARE_DOWNLOAD_TYPE.ZIP_ATTACHMENT: if (!options?.questionIds) { throw new Error('Question ids are require'); diff --git a/apps/frontend/src/hooks/fap/useDownloadXLSXFapReviews.ts b/apps/frontend/src/hooks/fap/useDownloadXLSXFapReviews.ts new file mode 100644 index 0000000000..5399330eaa --- /dev/null +++ b/apps/frontend/src/hooks/fap/useDownloadXLSXFapReviews.ts @@ -0,0 +1,30 @@ +import { useCallback, useContext } from 'react'; + +import { + DownloadContext, + DownloadOptions, + PREPARE_DOWNLOAD_TYPE, +} from 'context/DownloadContextProvider'; + +export function useDownloadXLSXFapReviews() { + const { prepareDownload } = useContext(DownloadContext); + + const downloadFapReviewsXLSX = useCallback( + ( + fapId: number, + callId: number, + name: string, + options?: DownloadOptions + ) => { + prepareDownload( + PREPARE_DOWNLOAD_TYPE.XLSX_FAP_REVIEWS, + [fapId, callId], + name, + options + ); + }, + [prepareDownload] + ); + + return downloadFapReviewsXLSX; +}