From f0e73e7ad561fd5b9a7a51d4b1f76b2ea8fd70e5 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Tue, 14 Oct 2025 16:33:09 +0100 Subject: [PATCH 1/6] feat: legacy fap proposals page --- apps/backend/src/datasources/FapDataSource.ts | 5 + .../src/datasources/mockups/FapDataSource.ts | 8 + .../src/datasources/postgres/FapDataSource.ts | 35 ++ apps/backend/src/queries/FapQueries.spec.ts | 1 + apps/backend/src/queries/FapQueries.ts | 12 +- .../backend/src/resolvers/queries/FapQuery.ts | 2 + .../src/components/call/CreateUpdateCall.tsx | 1 - .../src/components/common/SimpleTabs.tsx | 14 +- apps/frontend/src/components/fap/FapPage.tsx | 24 +- .../Proposals/FapAssignedReviewersTable.tsx | 132 +++--- .../fap/Proposals/FapLegacyProposalsTable.tsx | 431 ++++++++++++++++++ .../FapProposalsAndAssignmentsView.tsx | 5 +- .../fap/Proposals/LegacyFapProposals.tsx | 84 ++++ .../src/graphql/fap/getFapProposals.graphql | 4 +- .../src/hooks/fap/useFapProposalsData.ts | 6 +- 15 files changed, 696 insertions(+), 68 deletions(-) create mode 100644 apps/frontend/src/components/fap/Proposals/FapLegacyProposalsTable.tsx create mode 100644 apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx diff --git a/apps/backend/src/datasources/FapDataSource.ts b/apps/backend/src/datasources/FapDataSource.ts index d782d20f45..495c313d09 100644 --- a/apps/backend/src/datasources/FapDataSource.ts +++ b/apps/backend/src/datasources/FapDataSource.ts @@ -76,6 +76,11 @@ export interface FapDataSource { callId?: number | null; instrumentId?: number | null; }): Promise; + getLegacyFapProposals(filter: { + fapId: number; + callId?: number | null; + instrumentId?: number | null; + }): Promise; getFapUsersByProposalPkAndCallId( proposalPk: number, callId: number diff --git a/apps/backend/src/datasources/mockups/FapDataSource.ts b/apps/backend/src/datasources/mockups/FapDataSource.ts index 0a25578653..cf1475c8e4 100644 --- a/apps/backend/src/datasources/mockups/FapDataSource.ts +++ b/apps/backend/src/datasources/mockups/FapDataSource.ts @@ -239,6 +239,14 @@ export class FapDataSourceMock implements FapDataSource { ); } + getLegacyFapProposals(filter: { + fapId: number; + callId?: number | null; + instrumentId?: number | null; + }): Promise { + throw new Error('Method not implemented.'); + } + updateTimeAllocation( fapId: number, proposalPk: number, diff --git a/apps/backend/src/datasources/postgres/FapDataSource.ts b/apps/backend/src/datasources/postgres/FapDataSource.ts index b9fc23fb1a..cf7b4c0dc0 100644 --- a/apps/backend/src/datasources/postgres/FapDataSource.ts +++ b/apps/backend/src/datasources/postgres/FapDataSource.ts @@ -340,6 +340,41 @@ export default class PostgresFapDataSource implements FapDataSource { ); } + async getLegacyFapProposals(filter: { + fapId: number; + callId?: number | null; + instrumentId?: number | null; + }): Promise { + const fapProposals: FapProposalRecord[] = await database + .select(['fp.*']) + .from('fap_proposals as fp') + .modify((query) => { + query + .join('proposals as p', { + 'p.proposal_pk': 'fp.proposal_pk', + }) + .join('statuses as s', { + 'p.status_id': 's.status_id', + }) + .where(function () { + this.where('p.submitted', true); + }); + + if (filter.callId) { + query.andWhere('fp.call_id', filter.callId); + } + if (filter.instrumentId) { + query.andWhere('fp.instrument_id', filter.instrumentId); + } + }) + .where('fp.fap_id', filter.fapId) + .distinctOn('fp.proposal_pk'); + + return fapProposals.map((fapProposal) => + createFapProposalObject(fapProposal) + ); + } + async getFapUsersByProposalPkAndCallId( proposalPk: number, callId: number diff --git a/apps/backend/src/queries/FapQueries.spec.ts b/apps/backend/src/queries/FapQueries.spec.ts index b326e19cdd..fb04c0e117 100644 --- a/apps/backend/src/queries/FapQueries.spec.ts +++ b/apps/backend/src/queries/FapQueries.spec.ts @@ -73,6 +73,7 @@ describe('Test FapQueries', () => { fapId: 1, callId: 1, instrumentId: 1, + legacy: false, }) ).resolves.toStrictEqual([dummyFapProposal]); }); diff --git a/apps/backend/src/queries/FapQueries.ts b/apps/backend/src/queries/FapQueries.ts index b6ffb2ba0a..14818bef42 100644 --- a/apps/backend/src/queries/FapQueries.ts +++ b/apps/backend/src/queries/FapQueries.ts @@ -69,14 +69,22 @@ export default class FapQueries { fapId, callId, instrumentId, - }: { fapId: number; callId: number | null; instrumentId: number | null } + legacy, + }: { + fapId: number; + callId: number | null; + instrumentId: number | null; + legacy: boolean; + } ) { if ( this.userAuth.isApiToken(agent) || this.userAuth.isUserOfficer(agent) || (await this.userAuth.isMemberOfFap(agent, fapId)) ) { - return this.dataSource.getFapProposals({ fapId, callId, instrumentId }); + return legacy + ? this.dataSource.getLegacyFapProposals({ fapId, callId, instrumentId }) + : this.dataSource.getFapProposals({ fapId, callId, instrumentId }); } else { return null; } diff --git a/apps/backend/src/resolvers/queries/FapQuery.ts b/apps/backend/src/resolvers/queries/FapQuery.ts index 6170ef482b..6e817b25ec 100644 --- a/apps/backend/src/resolvers/queries/FapQuery.ts +++ b/apps/backend/src/resolvers/queries/FapQuery.ts @@ -37,12 +37,14 @@ export class FapQuery { @Arg('callId', () => Int, { nullable: true }) callId: number | null, @Arg('instrumentId', () => Int, { nullable: true }) instrumentId: number | null, + @Arg('legacy', () => Boolean, { nullable: true }) legacy: boolean, @Ctx() context: ResolverContext ): Promise { return context.queries.fap.getFapProposals(context.user, { fapId, callId, instrumentId, + legacy, }); } diff --git a/apps/frontend/src/components/call/CreateUpdateCall.tsx b/apps/frontend/src/components/call/CreateUpdateCall.tsx index 5416554f1d..aa61ca1bcd 100644 --- a/apps/frontend/src/components/call/CreateUpdateCall.tsx +++ b/apps/frontend/src/components/call/CreateUpdateCall.tsx @@ -168,7 +168,6 @@ const CreateUpdateCall = ({ call, close }: CreateUpdateCallProps) => { { - console.log({ values }); if (call) { const { updateCall } = await api({ toastSuccessMessage: 'Call updated successfully!', diff --git a/apps/frontend/src/components/common/SimpleTabs.tsx b/apps/frontend/src/components/common/SimpleTabs.tsx index f1864a72d1..eeb43fda33 100644 --- a/apps/frontend/src/components/common/SimpleTabs.tsx +++ b/apps/frontend/src/components/common/SimpleTabs.tsx @@ -45,6 +45,7 @@ type SimpleTabsProps = { tabPanelPadding?: number | string; orientation?: 'horizontal' | 'vertical'; noItemsText?: string; + clearExtraSearchParams?: boolean; }; const SimpleTabs = ({ @@ -54,6 +55,7 @@ const SimpleTabs = ({ orientation = 'horizontal', tabPanelPadding = 3, noItemsText, + clearExtraSearchParams, ...other }: SimpleTabsProps) => { const theme = useTheme(); @@ -100,7 +102,9 @@ const SimpleTabs = ({ if (isVerticalOrientation) { setSearchParams((searchParam) => { - const searchParamCloned = new URLSearchParams(searchParam); + const searchParamCloned = new URLSearchParams( + clearExtraSearchParams ? {} : searchParam + ); searchParamCloned.delete('verticalTab'); if (tabValue) searchParamCloned.append('verticalTab', String(tabValue)); @@ -108,7 +112,9 @@ const SimpleTabs = ({ }); } else if (isInsideModal) { setSearchParams((searchParam) => { - const searchParamCloned = new URLSearchParams(searchParam); + const searchParamCloned = new URLSearchParams( + clearExtraSearchParams ? {} : searchParam + ); searchParamCloned.delete('modalTab'); if (tabValue) searchParamCloned.append('modalTab', String(tabValue)); @@ -116,7 +122,9 @@ const SimpleTabs = ({ }); } else { setSearchParams((searchParam) => { - const searchParamCloned = new URLSearchParams(searchParam); + const searchParamCloned = new URLSearchParams( + clearExtraSearchParams ? {} : searchParam + ); searchParamCloned.delete('tab'); if (tabValue) searchParamCloned.append('tab', String(tabValue)); diff --git a/apps/frontend/src/components/fap/FapPage.tsx b/apps/frontend/src/components/fap/FapPage.tsx index c38ef6b23b..49f98beceb 100644 --- a/apps/frontend/src/components/fap/FapPage.tsx +++ b/apps/frontend/src/components/fap/FapPage.tsx @@ -17,6 +17,7 @@ import FapGeneralInfo from './General/FapGeneralInfo'; import FapMeetingComponentsView from './MeetingComponents/FapMeetingComponentsView'; import FapMembers from './Members/FapMembers'; import FapProposalsAndAssignmentsView from './Proposals/FapProposalsAndAssignmentsView'; +import LegacyFapProposals from './Proposals/LegacyFapProposals'; const FapPage = () => { const { id } = useParams(); @@ -76,6 +77,15 @@ const FapPage = () => { name: 'Documents', element: , }, + { + name: 'Legacy Proposals', + element: ( + setFap(newFap)} + /> + ), + }, ]; if (isFapChairOrSecretary || isUserOfficer) { @@ -120,6 +130,15 @@ const FapPage = () => { name: 'Meeting Components', element: , }, + { + name: 'Legacy Proposals', + element: ( + setFap(newFap)} + /> + ), + }, ]; } @@ -142,7 +161,10 @@ const FapPage = () => { return ( - tab.name)}> + tab.name)} + clearExtraSearchParams={true} + > {tabs.map((tab, index) => ( {tab.element} ))} diff --git a/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx b/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx index ab2dbb5ca1..e92786f79b 100644 --- a/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx @@ -30,6 +30,7 @@ type FapAssignedReviewersTableProps = { proposalPk: number ) => Promise; updateView: (proposalPk: number) => Promise; + editable?: boolean; }; const assignmentColumns = [ @@ -64,6 +65,7 @@ const FapAssignedReviewersTable = ({ fapSecs, removeAssignedReviewer, updateView, + editable = true, }: FapAssignedReviewersTableProps) => { const { api } = useDataApiWithFeedback(); @@ -142,15 +144,44 @@ const FapAssignedReviewersTable = ({ setOpenProposalPk(null); }; - const editableTableRow = hasAccessRights - ? { - deleteTooltip: () => 'Remove assignment', - onRowDelete: ( - rowAssignmentsData: FapProposalAssignmentType - ): Promise => - removeAssignedReviewer(rowAssignmentsData, fapProposal.proposalPk), - } - : {}; + const editableTableRow = + editable && hasAccessRights + ? { + deleteTooltip: () => 'Remove assignment', + onRowDelete: ( + rowAssignmentsData: FapProposalAssignmentType + ): Promise => + removeAssignedReviewer(rowAssignmentsData, fapProposal.proposalPk), + } + : {}; + + const reviewProposals = ( + review: { + id: number; + status: ReviewStatus | undefined; + } | null + ) => { + if (!review) { + return; + } + + setSearchParams((searchParams) => { + if (review) searchParams.set('reviewerModal', review.id.toString()); + searchParams.set( + 'modalTab', + isDraftStatus(review?.status) + ? reviewProposalTabNames + .indexOf(PROPOSAL_MODAL_TAB_NAMES.GRADE) + .toString() + : reviewProposalTabNames + .indexOf(PROPOSAL_MODAL_TAB_NAMES.PROPOSAL_INFORMATION) + .toString() + ); + + return searchParams; + }); + setOpenProposalPk(fapProposal.proposalPk); + }; return ( ({ - icon: isDraftStatus(rowData?.review?.status) - ? () => - : () => , - onClick: () => { - if (!rowData.review) { - return; - } - - setSearchParams((searchParams) => { - if (rowData.review) - searchParams.set( - 'reviewerModal', - rowData.review.id.toString() - ); - searchParams.set( - 'modalTab', - isDraftStatus(rowData.review?.status) - ? reviewProposalTabNames - .indexOf(PROPOSAL_MODAL_TAB_NAMES.GRADE) - .toString() - : reviewProposalTabNames - .indexOf(PROPOSAL_MODAL_TAB_NAMES.PROPOSAL_INFORMATION) - .toString() - ); - - return searchParams; - }); - setOpenProposalPk(fapProposal.proposalPk); - }, - tooltip: isDraftStatus(rowData.review?.status) - ? 'Grade proposal' - : 'View review', - }), - (rowData) => ({ - icon: () => , - onClick: () => { - setRankReviewer({ - fapReviewId: rowData.review?.id as number, - reviewer: rowData.fapMemberUserId, - rank: rowData.rank, - }); - }, - tooltip: 'Rank Reviewer', - }), - ]} + actions={ + editable + ? [ + (rowData) => ({ + icon: isDraftStatus(rowData?.review?.status) + ? () => + : () => , + onClick: () => reviewProposals(rowData.review), + tooltip: isDraftStatus(rowData.review?.status) + ? 'Grade proposal' + : 'View review', + }), + (rowData) => ({ + icon: () => ( + + ), + onClick: () => { + setRankReviewer({ + fapReviewId: rowData.review?.id as number, + reviewer: rowData.fapMemberUserId, + rank: rowData.rank, + }); + }, + tooltip: 'Rank Reviewer', + }), + ] + : [ + (rowData) => ({ + icon: () => ( + + ), + onClick: () => reviewProposals(rowData.review), + tooltip: 'View review', + }), + ] + } options={{ search: false, paging: false, diff --git a/apps/frontend/src/components/fap/Proposals/FapLegacyProposalsTable.tsx b/apps/frontend/src/components/fap/Proposals/FapLegacyProposalsTable.tsx new file mode 100644 index 0000000000..3dc10d8b40 --- /dev/null +++ b/apps/frontend/src/components/fap/Proposals/FapLegacyProposalsTable.tsx @@ -0,0 +1,431 @@ +import { Action, Column } from '@material-table/core'; +import GetAppIcon from '@mui/icons-material/GetApp'; +import Visibility from '@mui/icons-material/Visibility'; +import { IconButton, Tooltip, Typography } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +import CopyToClipboard from 'components/common/CopyToClipboard'; +import MaterialTable from 'components/common/DenseMaterialTable'; +import FapAssignedReviewersTable from 'components/fap/Proposals/FapAssignedReviewersTable'; +import ProposalReviewContent, { + PROPOSAL_MODAL_TAB_NAMES, +} from 'components/review/ProposalReviewContent'; +import ProposalReviewModal from 'components/review/ProposalReviewModal'; +import { Review, SettingsId, Fap } from 'generated/sdk'; +import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; +import { + FapProposalType, + FapProposalAssignmentType, + FapProposals, +} from 'hooks/fap/useFapProposalsData'; +import { useDownloadPDFProposal } from 'hooks/proposal/useDownloadPDFProposal'; +import { tableIcons } from 'utils/materialIcons'; +import { + average, + getGradesFromReviews, + standardDeviation, +} from 'utils/mathFunctions'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; +import withConfirm, { WithConfirmType } from 'utils/withConfirm'; + +type FapLegacyProposalsTableProps = { + /** Fap we are assigning members to */ + data: Fap; + /** Call this function in case of Fap assigned members update */ + onAssignmentsUpdate: (fap: Fap) => void; + /** Call id that we want to filter by */ + selectedCallId: number | null; + /** Confirmation function that comes from withConfirm HOC */ + confirm: WithConfirmType; + selectedInstrumentId: number | null; + fapProposals: FapProposals; +}; + +const getReviewsFromAssignments = (assignments: FapProposalAssignmentType[]) => + assignments + .map((assignment) => assignment.review) + .filter((review): review is Review => !!review); + +const FapProposalColumns: Column[] = [ + { + title: 'Actions', + cellStyle: { padding: 0, minWidth: 80 }, + sorting: false, + removable: false, + field: 'rowActionButtons', + }, + { + title: 'ID', + field: 'proposal.proposalId', + render: (rawData) => ( + + {rawData.proposal.proposalId || ''} + + ), + }, + { + title: 'Title', + field: 'proposal.title', + }, + { + title: 'Status', + field: 'proposal.status.name', + }, + { + title: 'Date assigned', + field: 'dateAssignedFormatted', + }, + { + title: 'Reviewers', + render: (data) => data.assignments?.length, + }, + { + title: 'Reviews', + render: (rowData) => { + const totalReviews = rowData.assignments?.length; + const gradedProposals = rowData.assignments?.filter( + (assignment) => + assignment.review !== null && assignment.review.grade !== null + ); + const countReviews = gradedProposals?.length || 0; + + return totalReviews === 0 ? '-' : `${countReviews} / ${totalReviews}`; + }, + }, + { + title: 'Average grade', + render: (rowData) => { + const avgGrade = average( + getGradesFromReviews( + getReviewsFromAssignments(rowData.assignments ?? []) + ) + ); + + return avgGrade === 0 ? '-' : `${avgGrade}`; + }, + customSort: (a, b) => + average( + getGradesFromReviews(getReviewsFromAssignments(a.assignments ?? [])) + ) - + average( + getGradesFromReviews(getReviewsFromAssignments(b.assignments ?? [])) + ), + }, + { + title: 'Deviation', + field: 'deviation', + render: (rowData) => { + const stdDeviation = standardDeviation( + getGradesFromReviews( + getReviewsFromAssignments(rowData.assignments ?? []) + ) + ); + + return isNaN(stdDeviation) ? '-' : `${stdDeviation}`; + }, + customSort: (a, b) => + standardDeviation( + getGradesFromReviews(getReviewsFromAssignments(a.assignments ?? [])) + ) - + standardDeviation( + getGradesFromReviews(getReviewsFromAssignments(b.assignments ?? [])) + ), + }, + { + title: 'Instrument', + field: 'instrument.name', + }, +]; + +const FapLegacyProposalsTable = ({ + data, + onAssignmentsUpdate, + fapProposals, +}: FapLegacyProposalsTableProps) => { + const [searchParams, setSearchParams] = useSearchParams(); + const reviewModal = searchParams.get('reviewModal'); + + const { loadingFapProposals, FapProposalsData, setFapProposalsData } = + fapProposals; + const { api } = useDataApiWithFeedback(); + const downloadPDFProposal = useDownloadPDFProposal(); + const { toFormattedDateTime } = useFormattedDateTime({ + settingsFormatToUse: SettingsId.DATE_FORMAT, + }); + const { t } = useTranslation(); + + const translatedColumns = FapProposalColumns.map((column) => + column.title === 'Instrument' + ? { ...column, title: t('instrument') } + : column + ); + + const page = searchParams.get('page'); + const pageSize = searchParams.get('pageSize'); + const selection = searchParams.getAll('selection'); + /** + * NOTE: Custom action buttons are here because when we have them inside actions on the material-table + * and selection flag is true they are not working properly. + */ + const RowActionButtons = (rowData: FapProposalType) => ( + <> + + { + setSearchParams((searchParams) => { + searchParams.set('reviewModal', rowData.proposalPk.toString()); + + return searchParams; + }); + }} + > + + + + + ); + + const handleBulkDownloadClick = ( + event: React.MouseEventHandler, + rowData: FapProposalType | FapProposalType[] + ) => { + if (!Array.isArray(rowData)) { + return; + } + + downloadPDFProposal( + rowData.map((row) => row.proposalPk), + rowData[0].proposal.title + ); + }; + + const initialValues: FapProposalType[] = FapProposalsData; + const tableActions: Action[] = []; + + tableActions.push({ + icon: () => , + tooltip: 'Download proposals', + onClick: handleBulkDownloadClick, + position: 'toolbarOnSelect', + }); + + const ReviewersTable = React.useCallback( + ({ rowData }: Record<'rowData', FapProposalType>) => { + const removeAssignedReviewer = async ( + assignedReviewer: FapProposalAssignmentType, + proposalPk: number + ): Promise => { + await api({ + toastSuccessMessage: 'Reviewer removed', + }).removeMemberFromFapProposal({ + proposalPk, + fapId: data.id, + memberId: assignedReviewer.fapMemberUserId as number, + }); + + setFapProposalsData((fapProposalData) => + fapProposalData.map((proposalItem) => { + if (proposalItem.proposalPk === proposalPk) { + const newAssignments = + proposalItem.assignments?.filter( + (oldAssignment) => + oldAssignment.fapMemberUserId !== + assignedReviewer.fapMemberUserId + ) || []; + + return { + ...proposalItem, + assignments: newAssignments, + }; + } else { + return proposalItem; + } + }) + ); + + onAssignmentsUpdate({ + ...data, + fapChairsCurrentProposalCounts: + data.fapChairsCurrentProposalCounts.map((value) => { + return { + userId: value.userId, + count: + assignedReviewer.fapMemberUserId === value.userId + ? value.count - 1 + : value.count, + }; + }), + fapSecretariesCurrentProposalCounts: + data.fapSecretariesCurrentProposalCounts.map((value) => { + return { + userId: value.userId, + count: + assignedReviewer.fapMemberUserId === value.userId + ? value.count - 1 + : value.count, + }; + }), + }); + }; + + const loadFapProposal = async (proposalPk: number) => { + return api() + .getFapProposal({ fapId: data.id, proposalPk }) + .then((data) => { + return data.fapProposal; + }); + }; + + const updateFapProposalAssignmentsView = async (proposalPk: number) => { + const refreshedFapProposal = await loadFapProposal(proposalPk); + + if (refreshedFapProposal) { + setFapProposalsData((fapProposalsData) => { + return fapProposalsData.map((fapProposal) => ({ + ...fapProposal, + proposal: { + ...fapProposal.proposal, + status: + refreshedFapProposal.proposalPk === fapProposal.proposalPk + ? refreshedFapProposal.proposal.status + : fapProposal.proposal.status, + }, + assignments: + refreshedFapProposal.proposalPk === fapProposal.proposalPk + ? refreshedFapProposal.assignments + : fapProposal.assignments, + })); + }); + } + }; + + return ( + user.id)} + removeAssignedReviewer={removeAssignedReviewer} + updateView={updateFapProposalAssignmentsView} + editable={false} + /> + ); + }, + [setFapProposalsData, data, onAssignmentsUpdate, api] + ); + + const FapProposalsWitIdAndFormattedDate = initialValues.map((fapProposal) => + Object.assign(fapProposal, { + id: fapProposal.proposalPk, + rowActionButtons: RowActionButtons(fapProposal), + dateAssignedFormatted: toFormattedDateTime(fapProposal.dateAssigned), + tableData: { + checked: selection.includes(fapProposal.proposalPk.toString()), + }, + }) + ); + + const maxPageLength = FapProposalsWitIdAndFormattedDate.length; + + const pageSizeOptions = [5, 10, 20, maxPageLength] + .sort((a, b) => a - b) + .filter((n) => n <= maxPageLength); + + return ( + <> + { + setSearchParams((searchParams) => { + searchParams.delete('reviewModal'); + + return searchParams; + }); + }} + > + user.id)} + /> + +
+ + {`${data.code} - ${t('Fap')} Proposals`} + + } + data={FapProposalsWitIdAndFormattedDate} + isLoading={loadingFapProposals} + localization={{ + toolbar: { + nRowsSelected: '{0} proposal(s) selected', + }, + }} + detailPanel={[ + { + tooltip: 'Show Reviewers', + render: ReviewersTable, + }, + ]} + actions={tableActions} + options={{ + search: true, + selection: true, + pageSize: pageSize ? +pageSize : Math.min(10, maxPageLength), + initialPage: page ? +page : 0, + pageSizeOptions: pageSizeOptions, + headerSelectionProps: { + inputProps: { + 'aria-label': 'Select all rows', + id: 'select-all-table-rows', + }, + }, + }} + onPageChange={(page) => { + setSearchParams((searchParams) => { + searchParams.set('page', page.toString()); + + return searchParams; + }); + }} + onRowsPerPageChange={(pageSize) => { + setSearchParams((searchParams) => { + searchParams.set('pageSize', pageSize.toString()); + + return searchParams; + }); + }} + onSelectionChange={(selectedItems) => { + const selectedProposalPks = selectedItems.map( + (item) => item.proposalPk + ); + + setSearchParams((searchParams) => { + searchParams.delete('selection'); + selectedProposalPks.forEach((pk) => + searchParams.append('selection', pk.toString()) + ); + + return searchParams; + }); + }} + /> +
+ + ); +}; + +export default withConfirm(FapLegacyProposalsTable); diff --git a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx index 9443d7df6e..393fab09fa 100644 --- a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx @@ -23,7 +23,10 @@ const FapProposalsAndAssignments = ({ onFapUpdate, fapProposals, }: FapProposalsAndAssignmentsProps) => { - const { loadingCalls, calls } = useCallsData({ fapIds: [fapData.id] }); + const { loadingCalls, calls } = useCallsData({ + fapIds: [fapData.id], + isFapReviewEnded: false, + }); // NOTE: Default null means load all calls if nothing is selected const { loadingInstruments, instruments } = useFapInstruments( fapData.id, diff --git a/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx b/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx new file mode 100644 index 0000000000..e7ccc10917 --- /dev/null +++ b/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx @@ -0,0 +1,84 @@ +import Grid from '@mui/material/Grid'; +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import CallFilter from 'components/common/proposalFilters/CallFilter'; +import InstrumentFilter from 'components/common/proposalFilters/InstrumentFilter'; +import { Fap } from 'generated/sdk'; +import { useCallsData } from 'hooks/call/useCallsData'; +import { useFapProposalsData } from 'hooks/fap/useFapProposalsData'; +import { useFapInstruments } from 'hooks/instrument/useFapInstruments'; + +import FapLegacyProposalsTable from './FapLegacyProposalsTable'; + +type LegacyFapProposalsProps = { + /** Id of the Fap we are assigning members to */ + data: Fap; + onFapUpdate: (fap: Fap) => void; +}; + +const LegacyFapProposals = ({ + data: fapData, + onFapUpdate, +}: LegacyFapProposalsProps) => { + const { loadingCalls, calls } = useCallsData({ + fapIds: [fapData.id], + isFapReviewEnded: true, + }); + // NOTE: Default null means load all calls if nothing is selected + const { loadingInstruments, instruments } = useFapInstruments( + fapData.id, + null + ); + + // Refech fap proposals too keep more the commenly used current fap proposals in memory + + const [searchParams] = useSearchParams(); + const call = searchParams.get('call'); + const instrument = searchParams.get('instrument'); + + const { loadingFapProposals, FapProposalsData, setFapProposalsData } = + useFapProposalsData( + fapData.id, + call ? parseInt(call) : null, + instrument ? parseInt(instrument) : null, + true // legacy flag set to true + ); + + return ( + <> + + + + + + + + + + + ); +}; + +export default LegacyFapProposals; diff --git a/apps/frontend/src/graphql/fap/getFapProposals.graphql b/apps/frontend/src/graphql/fap/getFapProposals.graphql index 3458f38393..df706d1f6d 100644 --- a/apps/frontend/src/graphql/fap/getFapProposals.graphql +++ b/apps/frontend/src/graphql/fap/getFapProposals.graphql @@ -1,5 +1,5 @@ -query getFapProposals($fapId: Int!, $callId: Int, $instrumentId: Int) { - fapProposals(fapId: $fapId, callId: $callId, instrumentId: $instrumentId) { +query getFapProposals($fapId: Int!, $callId: Int, $instrumentId: Int, $legacy: Boolean) { + fapProposals(fapId: $fapId, callId: $callId, instrumentId: $instrumentId, legacy: $legacy) { proposalPk dateAssigned fapId diff --git a/apps/frontend/src/hooks/fap/useFapProposalsData.ts b/apps/frontend/src/hooks/fap/useFapProposalsData.ts index 043e0f846c..760b2c958c 100644 --- a/apps/frontend/src/hooks/fap/useFapProposalsData.ts +++ b/apps/frontend/src/hooks/fap/useFapProposalsData.ts @@ -16,12 +16,14 @@ export type FapProposals = { loadingFapProposals: boolean; FapProposalsData: FapProposalType[]; setFapProposalsData: Dispatch>; + legacy?: boolean; }; export function useFapProposalsData( fapId: number, callId: number | null, - instrumentId: number | null + instrumentId: number | null, + legacy: boolean = false ): FapProposals { const api = useDataApi(); const [FapProposalsData, setFapProposalsData] = useState( @@ -32,7 +34,7 @@ export function useFapProposalsData( let cancelled = false; setLoadingFapProposals(true); api() - .getFapProposals({ fapId, callId, instrumentId }) + .getFapProposals({ fapId, callId, instrumentId, legacy }) .then((data) => { if (cancelled) { return; From c2681bdef9ace1baf1dffb6828fd07b5612873eb Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Wed, 15 Oct 2025 13:26:26 +0100 Subject: [PATCH 2/6] test and privacy --- .../src/datasources/postgres/FapDataSource.ts | 4 ++ apps/backend/src/queries/FapQueries.ts | 29 +++++++- apps/e2e/cypress/e2e/FAPs.cy.ts | 70 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/datasources/postgres/FapDataSource.ts b/apps/backend/src/datasources/postgres/FapDataSource.ts index cf7b4c0dc0..9b0a79be98 100644 --- a/apps/backend/src/datasources/postgres/FapDataSource.ts +++ b/apps/backend/src/datasources/postgres/FapDataSource.ts @@ -356,8 +356,12 @@ export default class PostgresFapDataSource implements FapDataSource { .join('statuses as s', { 'p.status_id': 's.status_id', }) + .join('call as c', { + 'p.call_id': 'c.call_id', + }) .where(function () { this.where('p.submitted', true); + this.andWhere('c.call_fap_review_ended', true); }); if (filter.callId) { diff --git a/apps/backend/src/queries/FapQueries.ts b/apps/backend/src/queries/FapQueries.ts index 14818bef42..b1ce27755f 100644 --- a/apps/backend/src/queries/FapQueries.ts +++ b/apps/backend/src/queries/FapQueries.ts @@ -82,9 +82,32 @@ export default class FapQueries { this.userAuth.isUserOfficer(agent) || (await this.userAuth.isMemberOfFap(agent, fapId)) ) { - return legacy - ? this.dataSource.getLegacyFapProposals({ fapId, callId, instrumentId }) - : this.dataSource.getFapProposals({ fapId, callId, instrumentId }); + const fapProposals = legacy + ? await this.dataSource.getLegacyFapProposals({ + fapId, + callId, + instrumentId, + }) + : await this.dataSource.getFapProposals({ + fapId, + callId, + instrumentId, + }); + + // Remove users proposals from the list if the user is a FAP Reviewer + // This ensures that Reviewers cannot see reviews of there own proposals + // which might contain sensitive information + if (agent?.currentRole?.shortCode === Roles.FAP_REVIEWER) { + const usersProposals = ( + await this.proposalDataSource.getUserProposals(agent.id) + ).map((p) => p.primaryKey); + + return fapProposals.filter((fp) => + usersProposals.includes(fp.proposalPk) + ); + } + + return fapProposals; } else { return null; } diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index 88674d8e2f..ffc9569a98 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -1840,6 +1840,76 @@ context('Fap reviews tests', () => { cy.contains(comment1).should('not.exist'); }); + + it('Fap Reviewer should be see legacy FAP proposals page', () => { + cy.createCall({ + ...updatedCall, + shortCode: 'legacy call', + esiTemplateId: createdEsiTemplateId, + proposalWorkflowId: createdWorkflowId, + faps: [createdFapId], + }).then((result) => { + createdCallId = result.createCall.id; + + cy.assignInstrumentToCall({ + callId: createdCallId, + instrumentFapIds: { instrumentId: newlyCreatedInstrumentId }, + }); + + cy.createProposal({ callId: createdCallId }).then((result) => { + const createdProposal = result.createProposal; + + if (createdProposal) { + secondCreatedProposalPk = createdProposal.primaryKey; + + cy.updateProposal({ + proposalPk: createdProposal.primaryKey, + title: proposal2.title, + abstract: proposal2.abstract, + proposerId: initialDBData.users.user1.id, + }); + + cy.submitProposal({ proposalPk: createdProposal.primaryKey }); + + cy.changeProposalsStatus({ + statusId: initialDBData.proposalStatuses.finished.id, + proposalPks: [secondCreatedProposalPk], + }); + + cy.assignProposalsToInstruments({ + instrumentIds: [newlyCreatedInstrumentId], + proposalPks: [secondCreatedProposalPk], + }); + cy.assignProposalsToFaps({ + fapInstruments: [ + { instrumentId: newlyCreatedInstrumentId, fapId: createdFapId }, + ], + proposalPks: [createdProposal.primaryKey], + }); + } + + cy.updateCall({ + id: createdCallId, + ...closedCall, + shortCode: 'legacy call', + proposalWorkflowId: createdWorkflowId, + esiTemplateId: createdEsiTemplateId, + faps: [createdFapId], + callFapReviewEnded: true, + }); + + cy.visit('/FapPage/' + createdFapId); + + cy.contains(firstCreatedProposalId).should('exist'); + cy.contains(createdProposal.proposalId).should('not.exist'); + + cy.contains('Legacy Proposals').click(); + + cy.contains(createdProposal.proposalId).should('exist'); + cy.contains(firstCreatedProposalId).should('not.exist'); + }); + }); + }); }); }); From 45ec7985108ff351e4cafd0cc0f906986812d4cc Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Thu, 16 Oct 2025 12:01:50 +0100 Subject: [PATCH 3/6] fix tests and tab parameters --- apps/backend/src/queries/FapQueries.ts | 4 ++-- apps/e2e/cypress/e2e/eventLogs.cy.ts | 2 +- .../src/components/common/SimpleTabs.tsx | 14 +++---------- apps/frontend/src/components/fap/FapPage.tsx | 5 +---- .../FapProposalsAndAssignmentsView.tsx | 18 ++++++++++++++-- .../fap/Proposals/LegacyFapProposals.tsx | 21 ++++++++++++++----- 6 files changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/backend/src/queries/FapQueries.ts b/apps/backend/src/queries/FapQueries.ts index b1ce27755f..66bfbbe916 100644 --- a/apps/backend/src/queries/FapQueries.ts +++ b/apps/backend/src/queries/FapQueries.ts @@ -102,8 +102,8 @@ export default class FapQueries { await this.proposalDataSource.getUserProposals(agent.id) ).map((p) => p.primaryKey); - return fapProposals.filter((fp) => - usersProposals.includes(fp.proposalPk) + return fapProposals.filter( + (fp) => !usersProposals.includes(fp.proposalPk) ); } diff --git a/apps/e2e/cypress/e2e/eventLogs.cy.ts b/apps/e2e/cypress/e2e/eventLogs.cy.ts index 2e60b56273..cb582afac3 100644 --- a/apps/e2e/cypress/e2e/eventLogs.cy.ts +++ b/apps/e2e/cypress/e2e/eventLogs.cy.ts @@ -134,7 +134,7 @@ context('Event log tests', () => { cy.get('[data-cy="add-participant-button"]').click(); cy.get('input[type="checkbox"]').eq(0).check(); cy.get('[data-cy="assign-selected-users"]').click(); - cy.get('.MuiTabs-flexContainer > #horizontal-tab-6').click(); + cy.get('.MuiTabs-flexContainer > #horizontal-tab-7').click(); cy.contains('userId:2 impersonating userId:1'); }); }); diff --git a/apps/frontend/src/components/common/SimpleTabs.tsx b/apps/frontend/src/components/common/SimpleTabs.tsx index eeb43fda33..f1864a72d1 100644 --- a/apps/frontend/src/components/common/SimpleTabs.tsx +++ b/apps/frontend/src/components/common/SimpleTabs.tsx @@ -45,7 +45,6 @@ type SimpleTabsProps = { tabPanelPadding?: number | string; orientation?: 'horizontal' | 'vertical'; noItemsText?: string; - clearExtraSearchParams?: boolean; }; const SimpleTabs = ({ @@ -55,7 +54,6 @@ const SimpleTabs = ({ orientation = 'horizontal', tabPanelPadding = 3, noItemsText, - clearExtraSearchParams, ...other }: SimpleTabsProps) => { const theme = useTheme(); @@ -102,9 +100,7 @@ const SimpleTabs = ({ if (isVerticalOrientation) { setSearchParams((searchParam) => { - const searchParamCloned = new URLSearchParams( - clearExtraSearchParams ? {} : searchParam - ); + const searchParamCloned = new URLSearchParams(searchParam); searchParamCloned.delete('verticalTab'); if (tabValue) searchParamCloned.append('verticalTab', String(tabValue)); @@ -112,9 +108,7 @@ const SimpleTabs = ({ }); } else if (isInsideModal) { setSearchParams((searchParam) => { - const searchParamCloned = new URLSearchParams( - clearExtraSearchParams ? {} : searchParam - ); + const searchParamCloned = new URLSearchParams(searchParam); searchParamCloned.delete('modalTab'); if (tabValue) searchParamCloned.append('modalTab', String(tabValue)); @@ -122,9 +116,7 @@ const SimpleTabs = ({ }); } else { setSearchParams((searchParam) => { - const searchParamCloned = new URLSearchParams( - clearExtraSearchParams ? {} : searchParam - ); + const searchParamCloned = new URLSearchParams(searchParam); searchParamCloned.delete('tab'); if (tabValue) searchParamCloned.append('tab', String(tabValue)); diff --git a/apps/frontend/src/components/fap/FapPage.tsx b/apps/frontend/src/components/fap/FapPage.tsx index 49f98beceb..0bc83e5759 100644 --- a/apps/frontend/src/components/fap/FapPage.tsx +++ b/apps/frontend/src/components/fap/FapPage.tsx @@ -161,10 +161,7 @@ const FapPage = () => { return ( - tab.name)} - clearExtraSearchParams={true} - > + tab.name)}> {tabs.map((tab, index) => ( {tab.element} ))} diff --git a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx index 393fab09fa..84ea9d107f 100644 --- a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx @@ -1,5 +1,5 @@ import Grid from '@mui/material/Grid'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import CallFilter from 'components/common/proposalFilters/CallFilter'; @@ -33,10 +33,24 @@ const FapProposalsAndAssignments = ({ null ); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const call = searchParams.get('call'); const instrument = searchParams.get('instrument'); + useEffect(() => { + const currentCall = call ? parseInt(call) : null; + + // If the selected call is not valid, remove it from the URL + console.log(calls); + if (call && !loadingCalls && !calls.find((c) => c.id === currentCall)) { + setSearchParams((searchParams) => { + searchParams.delete('call'); + + return searchParams; + }); + } + }, [call, calls, loadingCalls, searchParams, setSearchParams]); + return ( <> diff --git a/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx b/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx index e7ccc10917..22bbd71ae9 100644 --- a/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx +++ b/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx @@ -1,5 +1,5 @@ import Grid from '@mui/material/Grid'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import CallFilter from 'components/common/proposalFilters/CallFilter'; @@ -30,13 +30,24 @@ const LegacyFapProposals = ({ fapData.id, null ); - - // Refech fap proposals too keep more the commenly used current fap proposals in memory - - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const call = searchParams.get('call'); const instrument = searchParams.get('instrument'); + useEffect(() => { + const currentCall = call ? parseInt(call) : null; + + // If the selected call is not valid, remove it from the URL + if (call && !loadingCalls && !calls.find((c) => c.id === currentCall)) { + setSearchParams((searchParams) => { + searchParams.delete('call'); + + return searchParams; + }); + } + }, [call, calls, searchParams, setSearchParams]); + + // Refetch fap proposals too keep more the commenly used current fap proposals in memory const { loadingFapProposals, FapProposalsData, setFapProposalsData } = useFapProposalsData( fapData.id, From 46a85eb96a1653600da8cddc477b9a338489c61d Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Thu, 16 Oct 2025 12:04:56 +0100 Subject: [PATCH 4/6] update hooks --- .../src/components/fap/Proposals/LegacyFapProposals.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx b/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx index 22bbd71ae9..561992fe3c 100644 --- a/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx +++ b/apps/frontend/src/components/fap/Proposals/LegacyFapProposals.tsx @@ -45,7 +45,7 @@ const LegacyFapProposals = ({ return searchParams; }); } - }, [call, calls, searchParams, setSearchParams]); + }, [call, calls, loadingCalls, searchParams, setSearchParams]); // Refetch fap proposals too keep more the commenly used current fap proposals in memory const { loadingFapProposals, FapProposalsData, setFapProposalsData } = From 8c1e4c34794db25c6d810b378d7d354a48d6249e Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Thu, 16 Oct 2025 14:25:56 +0100 Subject: [PATCH 5/6] fix document store tests --- apps/e2e/cypress/e2e/FAPs.cy.ts | 2 +- apps/e2e/cypress/e2e/generalFaps.cy.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index ffc9569a98..c8c862ccaa 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -1804,7 +1804,7 @@ context('Fap reviews tests', () => { .find('[aria-label="Edit"]') .click(); - cy.get('[role="tablist"] [role="tab"]').should('have.length', 2); + cy.get('[role="tablist"] [role="tab"]').should('have.length', 3); cy.finishedLoading(); diff --git a/apps/e2e/cypress/e2e/generalFaps.cy.ts b/apps/e2e/cypress/e2e/generalFaps.cy.ts index ad9fd1b5c5..80c37a2847 100644 --- a/apps/e2e/cypress/e2e/generalFaps.cy.ts +++ b/apps/e2e/cypress/e2e/generalFaps.cy.ts @@ -729,7 +729,7 @@ context('General facility access panel tests', () => { const fileName1 = 'pdf_5_pages.pdf'; const fileName2 = 'pdf_3_pages.pdf'; cy.login('officer'); - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.intercept({ method: 'POST', @@ -762,7 +762,7 @@ context('General facility access panel tests', () => { cy.contains(fileName2).should('exist'); // Files persist after reload - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.contains(fileName1).should('exist'); cy.contains(fileName2).should('exist'); @@ -773,7 +773,7 @@ context('General facility access panel tests', () => { cy.contains(fileName2).should('not.exist'); // Files removed after reload - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.contains(fileName1).should('not.exist'); cy.contains(fileName2).should('not.exist'); @@ -798,7 +798,7 @@ context('General facility access panel tests', () => { const fileName1 = 'pdf_5_pages.pdf'; cy.login('officer'); - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.intercept({ method: 'POST', @@ -843,7 +843,7 @@ context('General facility access panel tests', () => { const fileName1 = 'pdf_5_pages.pdf'; cy.login('officer'); - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.intercept({ method: 'POST', @@ -885,7 +885,7 @@ context('General facility access panel tests', () => { const fileName1 = 'pdf_5_pages.pdf'; cy.login('officer'); - cy.visit(`/FapPage/1?tab=5`); + cy.visit(`/FapPage/1?tab=6`); cy.intercept({ method: 'POST', From 3dc8687bfed381c407507f0387f20ce4b91ea97a Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Fri, 17 Oct 2025 09:50:44 +0100 Subject: [PATCH 6/6] remove console log --- .../components/fap/Proposals/FapProposalsAndAssignmentsView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx index 84ea9d107f..5ab7de7258 100644 --- a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsView.tsx @@ -41,7 +41,6 @@ const FapProposalsAndAssignments = ({ const currentCall = call ? parseInt(call) : null; // If the selected call is not valid, remove it from the URL - console.log(calls); if (call && !loadingCalls && !calls.find((c) => c.id === currentCall)) { setSearchParams((searchParams) => { searchParams.delete('call');