diff --git a/backend/lcfs/services/rabbitmq/report_consumer.py b/backend/lcfs/services/rabbitmq/report_consumer.py index f03df28ee..551f3781b 100644 --- a/backend/lcfs/services/rabbitmq/report_consumer.py +++ b/backend/lcfs/services/rabbitmq/report_consumer.py @@ -156,6 +156,9 @@ async def handle_message( ) user = await UserRepository(db=session).get_user_by_id(user_id) + if not user: + logger.error(f"Cannot parse Report {legacy_id} from TFRS, no user with ID {user_id}") + if action == "Created": await self._handle_created( org_id, diff --git a/backend/lcfs/services/s3/client.py b/backend/lcfs/services/s3/client.py index 260f8b0ea..5d25ff689 100644 --- a/backend/lcfs/services/s3/client.py +++ b/backend/lcfs/services/s3/client.py @@ -1,5 +1,9 @@ +from fastapi import HTTPException import os import uuid +from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum +from lcfs.db.models.user.Role import RoleEnum +from lcfs.web.api.compliance_report.services import ComplianceReportServices from fastapi import Depends from pydantic.v1 import ValidationError from sqlalchemy import select @@ -26,13 +30,34 @@ def __init__( db: AsyncSession = Depends(get_async_db_session), clamav_service: ClamAVService = Depends(), s3_client=Depends(get_s3_client), + compliance_report_service: ComplianceReportServices = Depends(), ): self.db = db self.clamav_service = clamav_service self.s3_client = s3_client + self.compliance_report_service = compliance_report_service @repo_handler - async def upload_file(self, file, parent_id: str, parent_type="compliance_report"): + async def upload_file( + self, file, parent_id: str, parent_type="compliance_report", user=None + ): + compliance_report = ( + await self.compliance_report_service.get_compliance_report_by_id(parent_id) + ) + if not compliance_report: + raise HTTPException(status_code=404, detail="Compliance report not found") + + # Check if the user is a supplier and the compliance report status is different from Draft + if ( + RoleEnum.SUPPLIER in user.role_names + and compliance_report.current_status.status + != ComplianceReportStatusEnum.Draft.value + ): + raise HTTPException( + status_code=400, + detail="Suppliers can only upload files when the compliance report status is Draft", + ) + file_id = uuid.uuid4() file_key = f"{settings.s3_docs_path}/{parent_type}/{parent_id}/{file_id}" diff --git a/backend/lcfs/tests/fuel_export/test_fuel_exports_repo.py b/backend/lcfs/tests/fuel_export/test_fuel_exports_repo.py index 3850fd34d..539bb834a 100644 --- a/backend/lcfs/tests/fuel_export/test_fuel_exports_repo.py +++ b/backend/lcfs/tests/fuel_export/test_fuel_exports_repo.py @@ -214,7 +214,7 @@ async def test_update_fuel_export_success(fuel_export_repo, mock_db): ) updated_fuel_export = FuelExport(fuel_export_id=1) - mock_db.merge = MagicMock(return_value=updated_fuel_export) + mock_db.merge = AsyncMock(return_value=updated_fuel_export) mock_db.flush = AsyncMock() mock_db.refresh = AsyncMock() diff --git a/backend/lcfs/web/api/base.py b/backend/lcfs/web/api/base.py index ecb1d3693..288f38923 100644 --- a/backend/lcfs/web/api/base.py +++ b/backend/lcfs/web/api/base.py @@ -121,9 +121,6 @@ def validate_pagination(pagination: PaginationRequestSchema): Args: pagination (PaginationRequestSchema): The pagination object to validate. """ - logger.info("Validating pagination") - logger.debug("Pagination details", pagination=pagination) - if not pagination.page or pagination.page < 1: pagination.page = 1 if not pagination.size or pagination.size < 1: @@ -373,24 +370,55 @@ async def lcfs_cache_key_builder( # Return the cache key return cache_key + class NotificationTypeEnum(Enum): - BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT = "BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT" - BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL = "BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL" + BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT = ( + "BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT" + ) + BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL = ( + "BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL" + ) BCEID__TRANSFER__DIRECTOR_DECISION = "BCEID__TRANSFER__DIRECTOR_DECISION" BCEID__TRANSFER__PARTNER_ACTIONS = "BCEID__TRANSFER__PARTNER_ACTIONS" - IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION = "IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION" - IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION = "IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION" - IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW = "IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW" - IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST = "IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST" - IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED = "IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED" - IDIR_ANALYST__TRANSFER__RESCINDED_ACTION = "IDIR_ANALYST__TRANSFER__RESCINDED_ACTION" - IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW = "IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW" - IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION = "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION" - IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT = "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT" - IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW = "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW" - IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION = "IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION" - IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION = "IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION" - IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION = "IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION" + IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION = ( + "IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION" + ) + IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION = ( + "IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION" + ) + IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW = ( + "IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW" + ) + IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST = ( + "IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST" + ) + IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED = ( + "IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED" + ) + IDIR_ANALYST__TRANSFER__RESCINDED_ACTION = ( + "IDIR_ANALYST__TRANSFER__RESCINDED_ACTION" + ) + IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW = ( + "IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW" + ) + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION = ( + "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION" + ) + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT = ( + "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT" + ) + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW = ( + "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW" + ) + IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION = ( + "IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION" + ) + IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION = ( + "IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION" + ) + IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION = ( + "IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION" + ) def __str__(self): - return self.value \ No newline at end of file + return self.value diff --git a/backend/lcfs/web/api/compliance_report/schema.py b/backend/lcfs/web/api/compliance_report/schema.py index 34696dee2..e839bee11 100644 --- a/backend/lcfs/web/api/compliance_report/schema.py +++ b/backend/lcfs/web/api/compliance_report/schema.py @@ -40,7 +40,6 @@ class CompliancePeriodSchema(BaseSchema): display_order: Optional[int] = None - class SummarySchema(BaseSchema): summary_id: int is_locked: bool @@ -154,6 +153,7 @@ class ComplianceReportBaseSchema(BaseSchema): update_date: Optional[datetime] = None history: Optional[List[ComplianceReportHistorySchema]] = None has_supplemental: bool + legacy_id: Optional[int] = None class ChainedComplianceReportSchema(BaseSchema): diff --git a/backend/lcfs/web/api/document/views.py b/backend/lcfs/web/api/document/views.py index 456419da6..9f1cc026a 100644 --- a/backend/lcfs/web/api/document/views.py +++ b/backend/lcfs/web/api/document/views.py @@ -1,3 +1,6 @@ +from http.client import HTTPException +from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum +from lcfs.web.api.compliance_report.services import ComplianceReportServices import structlog from typing import List @@ -40,7 +43,7 @@ async def get_all_documents( response_model=FileResponseSchema, status_code=status.HTTP_201_CREATED, ) -@view_handler([RoleEnum.SUPPLIER]) +@view_handler([RoleEnum.SUPPLIER, RoleEnum.ANALYST]) async def upload_file( request: Request, parent_id: int, @@ -48,7 +51,9 @@ async def upload_file( file: UploadFile = File(...), document_service: DocumentService = Depends(), ) -> FileResponseSchema: - document = await document_service.upload_file(file, parent_id, parent_type) + document = await document_service.upload_file( + file, parent_id, parent_type, request.user + ) return FileResponseSchema.model_validate(document) diff --git a/backend/lcfs/web/api/fuel_export/repo.py b/backend/lcfs/web/api/fuel_export/repo.py index d09a546dc..510af73b6 100644 --- a/backend/lcfs/web/api/fuel_export/repo.py +++ b/backend/lcfs/web/api/fuel_export/repo.py @@ -233,7 +233,7 @@ async def update_fuel_export(self, fuel_export: FuelExport) -> FuelExport: """ Update an existing fuel supply row in the database. """ - updated_fuel_export = self.db.merge(fuel_export) + updated_fuel_export = await self.db.merge(fuel_export) await self.db.flush() await self.db.refresh( updated_fuel_export, diff --git a/backend/lcfs/web/api/fuel_supply/repo.py b/backend/lcfs/web/api/fuel_supply/repo.py index 77a8c3852..dca195539 100644 --- a/backend/lcfs/web/api/fuel_supply/repo.py +++ b/backend/lcfs/web/api/fuel_supply/repo.py @@ -443,20 +443,23 @@ async def get_effective_fuel_supplies( query = ( select(FuelSupply) .options( - # Use joinedload for scalar relationships - joinedload(FuelSupply.fuel_code).options( - joinedload(FuelCode.fuel_code_status), - joinedload(FuelCode.fuel_code_prefix), + # Use selectinload for collections + selectinload(FuelSupply.fuel_code).options( + selectinload(FuelCode.fuel_code_status), + selectinload(FuelCode.fuel_code_prefix), ), - joinedload(FuelSupply.fuel_category).options( - joinedload(FuelCategory.target_carbon_intensities), - joinedload(FuelCategory.energy_effectiveness_ratio), + # Use selectinload for one-to-many relationships + selectinload(FuelSupply.fuel_category).options( + selectinload(FuelCategory.target_carbon_intensities), + selectinload(FuelCategory.energy_effectiveness_ratio), ), + # Use joinedload for many-to-one relationships joinedload(FuelSupply.fuel_type).options( joinedload(FuelType.energy_density), joinedload(FuelType.additional_carbon_intensity), joinedload(FuelType.energy_effectiveness_ratio), ), + # Use joinedload for single relationships joinedload(FuelSupply.provision_of_the_act), selectinload(FuelSupply.end_use_type), ) @@ -467,6 +470,7 @@ async def get_effective_fuel_supplies( FuelSupply.version == valid_fuel_supplies_subq.c.max_version, user_type_priority == valid_fuel_supplies_subq.c.max_role_priority, ), + isouter=False # Explicit inner join ) .order_by(FuelSupply.create_date.asc()) ) diff --git a/backend/lcfs/web/api/user/services.py b/backend/lcfs/web/api/user/services.py index bd539f6bb..f6d21fa08 100644 --- a/backend/lcfs/web/api/user/services.py +++ b/backend/lcfs/web/api/user/services.py @@ -125,14 +125,14 @@ async def get_all_users(self, pagination: PaginationRequestSchema) -> UsersSchem Get all users """ users, total_count = await self.repo.get_users_paginated(pagination=pagination) - if len(users) == 0: - raise DataNotFoundException("No users found") return UsersSchema( pagination=PaginationResponseSchema( total=total_count, page=pagination.page, size=pagination.size, - total_pages=math.ceil(total_count / pagination.size), + total_pages=( + math.ceil(total_count / pagination.size) if total_count > 0 else 0 + ), ), users=users, ) diff --git a/frontend/public/config/config.js b/frontend/public/config/config.js index a53256ce3..63704496a 100644 --- a/frontend/public/config/config.js +++ b/frontend/public/config/config.js @@ -1,5 +1,6 @@ export const config = { api_base: 'http://localhost:8000/api', + tfrs_base: 'http://localhost:3000', keycloak: { REALM: 'standard', CLIENT_ID: 'low-carbon-fuel-standard-5147', diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2e6c33946..27d484513 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,6 +35,8 @@ import { AddEditFuelExports } from './views/FuelExports/AddEditFuelExports' import { AddEditAllocationAgreements } from './views/AllocationAgreements/AddEditAllocationAgreements' import { logout } from '@/utils/keycloak.js' import { CompareReports } from '@/views/CompareReports/CompareReports' +import { ViewLegacyComplianceReport } from '@/views/ComplianceReports/ViewLegacyComplianceReport.jsx' +import { ComplianceReportViewSelector } from '@/views/ComplianceReports/ComplianceReportViewSelector.jsx' const router = createBrowserRouter([ { @@ -196,7 +198,7 @@ const router = createBrowserRouter([ }, { path: ROUTES.REPORTS_VIEW, - element: , + element: , handle: { title: '' } }, { diff --git a/frontend/src/assets/locales/en/fuelCode.json b/frontend/src/assets/locales/en/fuelCode.json index 84f150236..e1948199c 100644 --- a/frontend/src/assets/locales/en/fuelCode.json +++ b/frontend/src/assets/locales/en/fuelCode.json @@ -6,6 +6,7 @@ "fuelCodeDownloadFailMsg": "Failed to download fuel code information.", "fuelCodeLoadFailMsg": "Failed to load fuel code information.", "newFuelCodeTitle": "Add new fuel code(s)", + "fuelCodeEntryGuide": "Draft fuel codes are saved automatically when all mandatory fields have been entered and validated. Click “Approve code” to make it available in compliance reporting.", "editFuelCodeTitle": "Edit draft fuel code", "viewFuelCodeTitle": "View fuel code", "approvedFuelCodeTitle": "Approved draft fuel code", diff --git a/frontend/src/assets/locales/en/reports.json b/frontend/src/assets/locales/en/reports.json index 597eab678..91ced911b 100644 --- a/frontend/src/assets/locales/en/reports.json +++ b/frontend/src/assets/locales/en/reports.json @@ -158,5 +158,6 @@ "totalValue": "Total Value" }, "summaryLoadingMsg": "Loading compliance report summary...", - "noSigningAuthorityTooltip": "Signing authority role required." + "noSigningAuthorityTooltip": "Signing authority role required.", + "viewLegacyBtn": "View Full Historical Report in TFRS" } diff --git a/frontend/src/constants/config.js b/frontend/src/constants/config.js index 8dfec5e6c..3fc0c851f 100644 --- a/frontend/src/constants/config.js +++ b/frontend/src/constants/config.js @@ -39,6 +39,7 @@ export const FEATURE_FLAGS = { export const CONFIG = { API_BASE: getApiBaseUrl(), + TFRS_BASE: window.lcfs_config.tfrs_base, KEYCLOAK: { REALM: window.lcfs_config.keycloak.REALM ?? 'standard', CLIENT_ID: diff --git a/frontend/src/constants/routes/routes.js b/frontend/src/constants/routes/routes.js index 78c3978a8..040be0f9b 100644 --- a/frontend/src/constants/routes/routes.js +++ b/frontend/src/constants/routes/routes.js @@ -36,13 +36,13 @@ export const ORGANIZATIONS_EDITUSER = `${ORGANIZATIONS_VIEWUSER}/edit-user` export const REPORTS = '/compliance-reporting' export const REPORTS_VIEW = `${REPORTS}/:compliancePeriod/:complianceReportId` -export const REPORTS_COMPARE = `/compare-reporting` export const REPORTS_ADD_SUPPLY_OF_FUEL = `${REPORTS_VIEW}/supply-of-fuel` export const REPORTS_ADD_FINAL_SUPPLY_EQUIPMENTS = `${REPORTS_VIEW}/final-supply-equipments` export const REPORTS_ADD_ALLOCATION_AGREEMENTS = `${REPORTS_VIEW}/allocation-agreements` export const REPORTS_ADD_NOTIONAL_TRANSFERS = `${REPORTS_VIEW}/notional-transfers` export const REPORTS_ADD_OTHER_USE_FUELS = `${REPORTS_VIEW}/fuels-other-use` export const REPORTS_ADD_FUEL_EXPORTS = `${REPORTS_VIEW}/fuel-exports` +export const REPORTS_COMPARE = '/compare-reporting' export const NOTIFICATIONS = '/notifications' export const NOTIFICATIONS_SETTINGS = `${NOTIFICATIONS}/configure` diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js index 78ed0e0e6..ebcdaeef6 100644 --- a/frontend/src/utils/formatters.js +++ b/frontend/src/utils/formatters.js @@ -7,9 +7,14 @@ import { ROLES_BADGE_SIZE } from '@/constants/common' * @param {Object|number|string|null} params - The input parameter which can be an object with a `value` property, a number, or a string. * @param {number|string} [params.value] - The value to be formatted, if params is an object. * @param {boolean} [useParentheses=false] - Whether to use parentheses for negative numbers. + * @param maxDecimals the max number of decimals to return * @returns {string} - The formatted number as a string, or the original value if it cannot be parsed as a number. */ -export const numberFormatter = (params, useParentheses = false) => { +export const numberFormatter = ( + params, + useParentheses = false, + maxDecimals = 10 +) => { if (params == null || (typeof params === 'object' && params.value == null)) return '' @@ -21,7 +26,7 @@ export const numberFormatter = (params, useParentheses = false) => { const absValue = Math.abs(parsedValue) const formattedValue = absValue.toLocaleString(undefined, { minimumFractionDigits: 0, - maximumFractionDigits: 10 + maximumFractionDigits: maxDecimals }) if (parsedValue < 0) { diff --git a/frontend/src/views/ComplianceReports/ComplianceReportViewSelector.jsx b/frontend/src/views/ComplianceReports/ComplianceReportViewSelector.jsx new file mode 100644 index 000000000..04cb92111 --- /dev/null +++ b/frontend/src/views/ComplianceReports/ComplianceReportViewSelector.jsx @@ -0,0 +1,40 @@ +import { useGetComplianceReport } from '@/hooks/useComplianceReports.js' +import { useCurrentUser } from '@/hooks/useCurrentUser.js' +import Loading from '@/components/Loading.jsx' +import { ViewLegacyComplianceReport } from '@/views/ComplianceReports/ViewLegacyComplianceReport.jsx' +import { useParams } from 'react-router-dom' +import { EditViewComplianceReport } from '@/views/ComplianceReports/EditViewComplianceReport.jsx' + +export const ComplianceReportViewSelector = () => { + const { complianceReportId } = useParams() + const { data: currentUser, isLoading: isCurrentUserLoading } = + useCurrentUser() + + const { + data: reportData, + isLoading: isReportLoading, + isError, + error + } = useGetComplianceReport( + currentUser?.organization?.organizationId, + complianceReportId + ) + + if (isReportLoading || isCurrentUserLoading) { + return + } + + return reportData.report.legacyId ? ( + + ) : ( + + ) +} diff --git a/frontend/src/views/ComplianceReports/ComplianceReports.jsx b/frontend/src/views/ComplianceReports/ComplianceReports.jsx index 973b6f46b..504d42990 100644 --- a/frontend/src/views/ComplianceReports/ComplianceReports.jsx +++ b/frontend/src/views/ComplianceReports/ComplianceReports.jsx @@ -4,20 +4,20 @@ import BCBox from '@/components/BCBox' import BCAlert from '@/components/BCAlert' import BCDataGridServer from '@/components/BCDataGrid/BCDataGridServer' // react components -import { useEffect, useMemo, useRef, useState, useCallback } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' // Services import { Role } from '@/components/Role' // constants import { roles } from '@/constants/roles' -import { ROUTES, apiRoutes } from '@/constants/routes' +import { apiRoutes, ROUTES } from '@/constants/routes' import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' // hooks import { useCurrentUser } from '@/hooks/useCurrentUser' import { useCreateComplianceReport } from '@/hooks/useComplianceReports' // internal components -import { reportsColDefs, defaultSortModel } from './components/_schema' +import { defaultSortModel, reportsColDefs } from './components/_schema' import { NewComplianceReportButton } from './components/NewComplianceReportButton' import BCTypography from '@/components/BCTypography' @@ -44,15 +44,13 @@ export const ComplianceReports = () => { (params) => params.data.complianceReportId.toString(), [] ) - // eslint-disable-next-line react-hooks/exhaustive-deps const handleRowClicked = useCallback( ({ data }) => { - navigate( - ROUTES.REPORTS_VIEW.replace( - ':compliancePeriod', - data.compliancePeriod.description - ).replace(':complianceReportId', data.complianceReportId) - ) + const mappedRoute = ROUTES.REPORTS_VIEW.replace( + ':compliancePeriod', + data.compliancePeriod.description + ).replace(':complianceReportId', data.complianceReportId) + navigate(mappedRoute) }, [navigate] ) diff --git a/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx b/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx index 30b2925f0..3b85bf216 100644 --- a/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx +++ b/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx @@ -7,6 +7,7 @@ import BCModal from '@/components/BCModal' import BCButton from '@/components/BCButton' import Loading from '@/components/Loading' import { Role } from '@/components/Role' +import { roles } from '@/constants/roles' import { Fab, Stack, Tooltip } from '@mui/material' import BCTypography from '@/components/BCTypography' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -18,10 +19,7 @@ import { useTranslation } from 'react-i18next' import { useCurrentUser } from '@/hooks/useCurrentUser' import { useOrganization } from '@/hooks/useOrganization' import { Introduction } from './components/Introduction' -import { - useGetComplianceReport, - useUpdateComplianceReport -} from '@/hooks/useComplianceReports' +import { useUpdateComplianceReport } from '@/hooks/useComplianceReports' import ComplianceReportSummary from './components/ComplianceReportSummary' import ReportDetails from './components/ReportDetails' import { buttonClusterConfigFn } from './buttonConfigs' @@ -35,7 +33,7 @@ const iconStyle = { height: '2rem', color: colors.white.main } -export const EditViewComplianceReport = () => { +export const EditViewComplianceReport = ({ reportData, isError, error }) => { const { t } = useTranslation(['common', 'report']) const location = useLocation() const [modalData, setModalData] = useState(null) @@ -83,16 +81,8 @@ export const EditViewComplianceReport = () => { hasRoles } = useCurrentUser() const isGovernmentUser = currentUser?.isGovernmentUser - const { - data: reportData, - isLoading: isReportLoading, - isError, - error - } = useGetComplianceReport( - currentUser?.organization?.organizationId, - complianceReportId - ) - + const isAnalystRole = currentUser?.roles?.some(role => role.name === roles.analyst) || false; + const currentStatus = reportData?.report.currentStatus?.status const { data: orgData, isLoading } = useOrganization( reportData?.report.organizationId @@ -158,7 +148,7 @@ export const EditViewComplianceReport = () => { } }, [location.state, isError, error]) - if (isLoading || isReportLoading || isCurrentUserLoading) { + if (isLoading || isCurrentUserLoading) { return } @@ -221,7 +211,7 @@ export const EditViewComplianceReport = () => { {!location.state?.newReport && ( <> - + { + const { t } = useTranslation(['common', 'report']) + const [modalData, setModalData] = useState(null) + const alertRef = useRef() + + const [isScrollingUp, setIsScrollingUp] = useState(false) + const [lastScrollTop, setLastScrollTop] = useState(0) + + const scrollToTopOrBottom = () => { + if (isScrollingUp) { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }) + } else { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth' + }) + } + } + + const handleScroll = useCallback(() => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop + setIsScrollingUp(scrollTop < lastScrollTop || scrollTop === 0) + setLastScrollTop(scrollTop) + }, [lastScrollTop]) + + useEffect(() => { + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, [handleScroll]) + + const { data: currentUser, isLoading: isCurrentUserLoading } = + useCurrentUser() + const isGovernmentUser = currentUser?.isGovernmentUser + + const currentStatus = reportData.report.currentStatus?.status + const { data: orgData, isLoading } = useOrganization( + reportData.report.organizationId + ) + + if (isLoading || isCurrentUserLoading) { + return + } + + if (isError) { + return ( + <> + + {t('report:errorRetrieving')} + + ) + } + + return ( + <> + + + setModalData(null)} + data={modalData} + /> + + + {reportData?.report.compliancePeriod.description}  + {t('report:complianceReport')} - + {reportData?.report.nickname} + + + Status: {currentStatus} + + + + + + + + {isScrollingUp ? ( + + ) : ( + + )} + + + + + ) +} diff --git a/frontend/src/views/ComplianceReports/__tests__/ComplianceReportViewSelector.test.jsx b/frontend/src/views/ComplianceReports/__tests__/ComplianceReportViewSelector.test.jsx new file mode 100644 index 000000000..4ceada497 --- /dev/null +++ b/frontend/src/views/ComplianceReports/__tests__/ComplianceReportViewSelector.test.jsx @@ -0,0 +1,146 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { ComplianceReportViewSelector } from '../ComplianceReportViewSelector.jsx' +import * as useComplianceReportsHook from '@/hooks/useComplianceReports' +import * as useCurrentUserHook from '@/hooks/useCurrentUser' +import { wrapper } from '@/tests/utils/wrapper' + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useParams: vi.fn() + } +}) + +vi.mock('@/hooks/useComplianceReports') +vi.mock('@/hooks/useCurrentUser') + +vi.mock('@/components/Loading', () => ({ + default: () =>
Loading...
+})) + +vi.mock('@/views/ComplianceReports/ViewLegacyComplianceReport', () => ({ + ViewLegacyComplianceReport: () =>
Legacy Report View
+})) + +vi.mock('@/views/ComplianceReports/EditViewComplianceReport', () => ({ + EditViewComplianceReport: () =>
Edit Compliance Report
+})) + +// Import useParams after mocking so it's already a mock +import { useParams } from 'react-router-dom' + +describe('ViewComplianceReportBrancher', () => { + const setupMocks = ({ + currentUser = { organization: { organizationId: '123' } }, + isCurrentUserLoading = false, + reportData = { + report: { + legacyId: null, + currentStatus: { status: 'DRAFT' } + } + }, + isReportLoading = false, + isError = false, + error = null, + complianceReportId = '123' + } = {}) => { + // Set the return value for useParams + useParams.mockReturnValue({ complianceReportId }) + + // Mock useCurrentUser + useCurrentUserHook.useCurrentUser.mockReturnValue({ + data: currentUser, + isLoading: isCurrentUserLoading + }) + + // Mock useGetComplianceReport + useComplianceReportsHook.useGetComplianceReport.mockReturnValue({ + data: reportData, + isLoading: isReportLoading, + isError, + error + }) + } + + beforeEach(() => { + vi.resetAllMocks() + }) + + it('renders loading when user is loading', async () => { + setupMocks({ isCurrentUserLoading: true }) + render(, { wrapper }) + + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument() + }) + }) + + it('renders loading when report is loading', async () => { + setupMocks({ isReportLoading: true }) + render(, { wrapper }) + + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument() + }) + }) + + it('renders ViewLegacyComplianceReport when legacyId is present', async () => { + setupMocks({ + reportData: { + report: { + legacyId: 999, + currentStatus: { status: 'DRAFT' } + } + } + }) + + render(, { wrapper }) + + await waitFor(() => { + expect(screen.getByText('Legacy Report View')).toBeInTheDocument() + expect( + screen.queryByText('Edit Compliance Report') + ).not.toBeInTheDocument() + }) + }) + + it('renders EditViewComplianceReport when legacyId is null/undefined', async () => { + setupMocks({ + reportData: { + report: { + currentStatus: { status: 'DRAFT' } + // No legacyId means it should render the EditViewComplianceReport + } + } + }) + + render(, { wrapper }) + + await waitFor(() => { + expect(screen.getByText('Edit Compliance Report')).toBeInTheDocument() + expect(screen.queryByText('Legacy Report View')).not.toBeInTheDocument() + }) + }) + + it('passes error and isError props to the rendered component', async () => { + const testError = { message: 'Test error' } + setupMocks({ + isError: true, + error: testError, + reportData: { + report: { + currentStatus: { status: 'DRAFT' } + } + } + }) + + render(, { wrapper }) + + await waitFor(() => { + expect(screen.getByText('Edit Compliance Report')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/views/ComplianceReports/__tests__/EditViewComplianceReports.test.jsx b/frontend/src/views/ComplianceReports/__tests__/EditViewComplianceReports.test.jsx index cb50dbee6..a3e38b0df 100644 --- a/frontend/src/views/ComplianceReports/__tests__/EditViewComplianceReports.test.jsx +++ b/frontend/src/views/ComplianceReports/__tests__/EditViewComplianceReports.test.jsx @@ -2,9 +2,9 @@ import React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { EditViewComplianceReport } from '../EditViewComplianceReport' -import * as useComplianceReportsHook from '@/hooks/useComplianceReports' import * as useCurrentUserHook from '@/hooks/useCurrentUser' import * as useOrganizationHook from '@/hooks/useOrganization' +import * as useComplianceReportsHook from '@/hooks/useComplianceReports' import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' import { wrapper } from '@/tests/utils/wrapper' @@ -89,17 +89,17 @@ describe('EditViewComplianceReport', () => { isLoading: false, hasRoles: mockHasRoles }, - complianceReport: { - data: { - report: { - organizationId: '123', - currentStatus: { status: COMPLIANCE_REPORT_STATUSES.DRAFT } - }, - chain: [] + reportData: { + report: { + organizationId: '123', + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.DRAFT }, + history: [], + nickname: 'Test Report' }, - isLoading: false, - isError: false + chain: [] }, + isError: false, + error: null, organization: { data: { name: 'Test Org', @@ -117,10 +117,6 @@ describe('EditViewComplianceReport', () => { } }, isLoading: false - }, - createSupplementalReport: { - mutate: vi.fn(), - isLoading: false } } @@ -131,9 +127,6 @@ describe('EditViewComplianceReport', () => { vi.mocked(useCurrentUserHook.useCurrentUser).mockReturnValue( mocks.currentUser ) - vi.mocked(useComplianceReportsHook.useGetComplianceReport).mockReturnValue( - mocks.complianceReport - ) vi.mocked(useOrganizationHook.useOrganization).mockReturnValue( mocks.organization ) @@ -142,23 +135,43 @@ describe('EditViewComplianceReport', () => { ).mockReturnValue({ mutate: vi.fn() }) vi.mocked( useComplianceReportsHook.useCreateSupplementalReport - ).mockReturnValue(mocks.createSupplementalReport) + ).mockReturnValue({ + mutate: vi.fn(), + isLoading: false + }) + + return mocks } beforeEach(() => { vi.resetAllMocks() - setupMocks() }) it('renders the component', async () => { - render(, { wrapper }) + const mocks = setupMocks() + render( + , + { wrapper } + ) await waitFor(() => { expect(screen.getByText(/2023.*complianceReport/i)).toBeInTheDocument() }) }) it('renders report components', async () => { - render(, { wrapper }) + const mocks = setupMocks() + render( + , + { wrapper } + ) await waitFor(() => { expect(screen.getByText('Report Details')).toBeInTheDocument() expect(screen.getByText('Compliance Report Summary')).toBeInTheDocument() @@ -167,44 +180,61 @@ describe('EditViewComplianceReport', () => { }) it('displays an alert message when location state has a message', async () => { - setupMocks({ + const mocks = setupMocks({ useLocation: { state: { message: 'Test alert', severity: 'success' } } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect(screen.getByText('Test alert')).toBeInTheDocument() }) }) it('displays an error message when there is an error fetching the report', async () => { - setupMocks({ - complianceReport: { - isError: true, - error: { message: 'Error fetching report' } - } + const mocks = setupMocks({ + isError: true, + error: { message: 'Error fetching report' } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect(screen.getByText('Error fetching report')).toBeInTheDocument() }) }) it('displays the correct buttons for Submitted status with Analyst role', async () => { - setupMocks({ - complianceReport: { - data: { - report: { - currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED } - }, - chain: [] - } + const mocks = setupMocks({ + reportData: { + report: { + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED } + }, + chain: [] }, currentUser: { data: { isGovernmentUser: true }, hasRoles: (role) => role === 'Analyst' } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect( screen.getByText('report:actionBtns.recommendReportAnalystBtn') @@ -213,23 +243,28 @@ describe('EditViewComplianceReport', () => { }) it('displays the correct buttons for Recommended by Analyst status with Compliance Manager role', async () => { - setupMocks({ - complianceReport: { - data: { - report: { - currentStatus: { - status: COMPLIANCE_REPORT_STATUSES.RECOMMENDED_BY_ANALYST - } - }, - chain: [] - } + const mocks = setupMocks({ + reportData: { + report: { + currentStatus: { + status: COMPLIANCE_REPORT_STATUSES.RECOMMENDED_BY_ANALYST + } + }, + chain: [] }, currentUser: { data: { isGovernmentUser: true }, hasRoles: (role) => role === 'Compliance Manager' } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect( screen.getByText('report:actionBtns.recommendReportManagerBtn') @@ -241,23 +276,28 @@ describe('EditViewComplianceReport', () => { }) it('displays the correct buttons for Recommended by Manager status with Director role', async () => { - setupMocks({ - complianceReport: { - data: { - report: { - currentStatus: { - status: COMPLIANCE_REPORT_STATUSES.RECOMMENDED_BY_MANAGER - } - }, - chain: [] - } + const mocks = setupMocks({ + reportData: { + report: { + currentStatus: { + status: COMPLIANCE_REPORT_STATUSES.RECOMMENDED_BY_MANAGER + } + }, + chain: [] }, currentUser: { data: { isGovernmentUser: true }, hasRoles: (role) => role === 'Director' } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect( screen.getByText('report:actionBtns.assessReportBtn') @@ -269,21 +309,26 @@ describe('EditViewComplianceReport', () => { }) it('displays the correct buttons for Assessed status with Analyst role', async () => { - setupMocks({ - complianceReport: { - data: { - report: { - currentStatus: { status: COMPLIANCE_REPORT_STATUSES.ASSESSED } - }, - chain: [] - } + const mocks = setupMocks({ + reportData: { + report: { + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.ASSESSED } + }, + chain: [] }, currentUser: { data: { isGovernmentUser: true }, hasRoles: (role) => role === 'Analyst' } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect( screen.getByText('report:actionBtns.reAssessReportBtn') @@ -292,18 +337,23 @@ describe('EditViewComplianceReport', () => { }) it('does not display action buttons for non-government users on submitted reports', async () => { - setupMocks({ - complianceReport: { - data: { - report: { - currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED } - }, - chain: [] - } + const mocks = setupMocks({ + reportData: { + report: { + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED } + }, + chain: [] }, currentUser: { data: { isGovernmentUser: false }, hasRoles: () => false } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect( screen.queryByText('report:actionBtns.recommendReportAnalystBtn') @@ -321,20 +371,34 @@ describe('EditViewComplianceReport', () => { }) it('displays internal comments section for government users', async () => { - setupMocks({ + const mocks = setupMocks({ currentUser: { data: { isGovernmentUser: true }, hasRoles: () => true } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect(screen.getByText('report:internalComments')).toBeInTheDocument() }) }) it('does not display internal comments section for non-government users', async () => { - setupMocks({ + const mocks = setupMocks({ currentUser: { data: { isGovernmentUser: false }, hasRoles: () => false } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect( screen.queryByText('report:internalComments') @@ -343,17 +407,22 @@ describe('EditViewComplianceReport', () => { }) it('displays ActivityListCard for Draft status', async () => { - setupMocks({ - complianceReport: { - data: { - report: { - currentStatus: { status: COMPLIANCE_REPORT_STATUSES.DRAFT } - }, - chain: [] - } + const mocks = setupMocks({ + reportData: { + report: { + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.DRAFT } + }, + chain: [] } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect(screen.getByText('Activity Links List')).toBeInTheDocument() }) @@ -373,8 +442,8 @@ describe('EditViewComplianceReport', () => { } ] - vi.mocked(useComplianceReportsHook.useGetComplianceReport).mockReturnValue({ - data: { + const mocks = setupMocks({ + reportData: { report: { currentStatus: { status: COMPLIANCE_REPORT_STATUSES.ASSESSED }, history: historyMock @@ -389,12 +458,17 @@ describe('EditViewComplianceReport', () => { currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED } } ] - }, - isLoading: false, - isError: false + } }) - render(, { wrapper }) + render( + , + { wrapper } + ) await waitFor(() => { expect(screen.getByText('report:assessment')).toBeInTheDocument() expect(screen.getByText('report:reportHistory')).toBeInTheDocument() @@ -405,7 +479,15 @@ describe('EditViewComplianceReport', () => { }) it('displays scroll-to-top button when scrolling down', async () => { - render(, { wrapper }) + const mocks = setupMocks() + render( + , + { wrapper } + ) await waitFor(() => { fireEvent.scroll(window, { target: { pageYOffset: 100 } }) expect(screen.getByLabelText('scroll to bottom')).toBeInTheDocument() @@ -413,7 +495,15 @@ describe('EditViewComplianceReport', () => { }) it('displays scroll-to-bottom button when at the top of the page', async () => { - render(, { wrapper }) + const mocks = setupMocks() + render( + , + { wrapper } + ) await waitFor(() => { fireEvent.scroll(window, { target: { pageYOffset: 0 } }) expect(screen.getByLabelText('scroll to top')).toBeInTheDocument() diff --git a/frontend/src/views/ComplianceReports/__tests__/HistoryCard.test.jsx b/frontend/src/views/ComplianceReports/__tests__/HistoryCard.test.jsx new file mode 100644 index 000000000..defa965c7 --- /dev/null +++ b/frontend/src/views/ComplianceReports/__tests__/HistoryCard.test.jsx @@ -0,0 +1,171 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' +import { HistoryCard } from '@/views/ComplianceReports/components/HistoryCard.jsx' +import { wrapper } from '@/tests/utils/wrapper.jsx' + +import * as useCurrentUserHook from '@/hooks/useCurrentUser' + +// Mock useCurrentUser +vi.mock('@/hooks/useCurrentUser', () => ({ + useCurrentUser: vi.fn(() => ({ + data: { isGovernmentUser: false }, + isLoading: false + })) +})) + +// Mock translation +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, opts) => { + if (opts && opts.createDate && opts.firstName && opts.lastName) { + return `${key}: ${opts.firstName} ${opts.lastName} - ${opts.createDate}` + } + return key + } + }) +})) + +// Mock timezoneFormatter +vi.mock('@/utils/formatters', () => ({ + timezoneFormatter: vi.fn(({ value }) => `formatted-${value}`) +})) + +describe('HistoryCard', () => { + const defaultReport = { + version: 0, + compliancePeriod: { description: '2024' }, + nickname: 'My Nickname', + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.DRAFT }, + history: [] + } + + const renderComponent = (overrides = {}) => { + return render(, { + wrapper + }) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders without history', async () => { + renderComponent() + // Only the accordion header should be present + await waitFor(() => { + expect(screen.queryByRole('listitem')).not.toBeInTheDocument() + }) + }) + + it('displays compliancePeriod.description and current status if version=0', async () => { + renderComponent({ + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED } + }) + await waitFor(() => { + expect( + screen.getByText(/2024 Compliance Report: SUBMITTED/i) + ).toBeInTheDocument() + }) + }) + + it('displays nickname and current status if version > 0', async () => { + renderComponent({ + version: 1, + nickname: 'My Cool Nickname', + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED } + }) + await waitFor(() => { + expect( + screen.getByText(/My Cool Nickname: SUBMITTED/i) + ).toBeInTheDocument() + }) + }) + + it('sorts history in descending order by createDate and filters out DRAFT', async () => { + const history = [ + { + status: { status: COMPLIANCE_REPORT_STATUSES.DRAFT }, + createDate: '2024-10-02', + userProfile: { firstName: 'Draft', lastName: 'User' } + }, + { + status: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED }, + createDate: '2024-10-01T10:00:00Z', + userProfile: { firstName: 'John', lastName: 'Doe' } + }, + { + status: { status: COMPLIANCE_REPORT_STATUSES.RECOMMENDED_BY_ANALYST }, + createDate: '2024-10-03T15:00:00Z', + userProfile: { firstName: 'Jane', lastName: 'Smith' } + } + ] + renderComponent({ + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED }, + history + }) + + // SUBMITTED first because 2024-10-03 is later than 2024-10-01 + await waitFor(() => { + const items = screen.getAllByTestId('list-item') + expect(items.length).toBe(2) // DRAFT is filtered out + // The first item should be RECOMMENDED_BY_ANALYST (2024-10-03) + expect(items[0].textContent).toContain( + 'report:complianceReportHistory.Recommended by analyst: Jane Smith' + ) + // The second item should be SUBMITTED (2024-10-01) + expect(items[1].textContent).toContain( + 'report:complianceReportHistory.Submitted: John Doe' + ) + }) + }) + + it('replaces ASSESSED with AssessedBy if user is not government', async () => { + const history = [ + { + status: { status: COMPLIANCE_REPORT_STATUSES.ASSESSED }, + createDate: '2024-10-01T10:00:00Z', + userProfile: { firstName: 'John', lastName: 'Doe' } + } + ] + renderComponent({ + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.ASSESSED }, + history + }) + + await waitFor(() => { + const item = screen.getByTestId('list-item') + // Should have replaced ASSESSED with AssessedBy + expect(item.textContent).toContain( + 'report:complianceReportHistory.AssessedBy: John Doe - formatted-2024-10-01T10:00:00Z' + ) + }) + }) + + it('does not replace ASSESSED with AssessedBy if user is government', async () => { + useCurrentUserHook.useCurrentUser.mockReturnValueOnce({ + data: { isGovernmentUser: true }, + isLoading: false + }) + const history = [ + { + status: { status: COMPLIANCE_REPORT_STATUSES.ASSESSED }, + createDate: '2024-10-01T10:00:00Z', + userProfile: { firstName: 'John', lastName: 'Doe' } + } + ] + renderComponent({ + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.ASSESSED }, + history + }) + + await waitFor(() => { + const item = screen.getByTestId('list-item') + // Should NOT have replaced ASSESSED with AssessedBy + expect(item.textContent).toContain( + 'report:complianceReportHistory.Assessed: John Doe - formatted-2024-10-01T10:00:00Z' + ) + }) + }) +}) diff --git a/frontend/src/views/ComplianceReports/__tests__/ViewLegacyComplianceReport.test.jsx b/frontend/src/views/ComplianceReports/__tests__/ViewLegacyComplianceReport.test.jsx new file mode 100644 index 000000000..4394e044d --- /dev/null +++ b/frontend/src/views/ComplianceReports/__tests__/ViewLegacyComplianceReport.test.jsx @@ -0,0 +1,149 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' +import { CONFIG } from '@/constants/config' +import { LegacyAssessmentCard } from '@/views/ComplianceReports/components/LegacyAssessmentCard.jsx' +import { wrapper } from '@/tests/utils/wrapper.jsx' + +// Mock useTranslation +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => key + }) +})) + +// Mock HistoryCard +vi.mock('@/views/ComplianceReports/components/HistoryCard.jsx', () => ({ + HistoryCard: ({ report }) =>
HistoryCard - Version {report.version}
+})) + +// Mock window.open +global.open = vi.fn() + +describe('LegacyAssessmentCard', () => { + const setup = (overrides = {}) => { + const defaultProps = { + orgData: { + name: 'Test Org', + orgAddress: { + addressLine1: '123 Test St', + city: 'Test City', + state: 'TS', + postalCode: '12345' + }, + orgAttorneyAddress: { + addressLine1: '456 Law St', + city: 'Law City', + state: 'LS', + postalCode: '67890' + } + }, + hasSupplemental: false, + isGovernmentUser: false, + currentStatus: COMPLIANCE_REPORT_STATUSES.DRAFT, + legacyReportId: '999', + chain: [] + } + + const props = { ...defaultProps, ...overrides } + + return render(, { wrapper }) + } + + beforeEach(() => { + vi.resetAllMocks() + }) + + it('renders the organization details and addresses', () => { + setup() + expect(screen.getByText('report:orgDetails')).toBeInTheDocument() + expect(screen.getByText('Test Org')).toBeInTheDocument() + expect(screen.getByText(/report:serviceAddrLabel/)).toBeInTheDocument() + expect(screen.getByText(/report:bcAddrLabel/)).toBeInTheDocument() + }) + + it('displays contact instructions for non-government users', () => { + setup({ isGovernmentUser: false }) + expect( + screen.getByText('report:contactForAddrChange', { exact: false }) + ).toBeInTheDocument() + }) + + it('does not display contact instructions for government users', () => { + setup({ isGovernmentUser: true }) + expect( + screen.queryByText('report:contactForAddrChange', { exact: false }) + ).not.toBeInTheDocument() + }) + + it('displays the supplemental warning text', () => { + setup() + expect(screen.getByText('report:supplementalWarning')).toBeInTheDocument() + }) + + it('displays "View Legacy" button which opens the legacy report link on click', async () => { + setup({ legacyReportId: '999' }) + const viewBtn = screen.getByText('report:viewLegacyBtn') + expect(viewBtn).toBeInTheDocument() + fireEvent.click(viewBtn) + await waitFor(() => { + expect(global.open).toHaveBeenCalledWith( + `${CONFIG.TFRS_BASE}/compliance_reporting/edit/999/intro`, + '_blank' + ) + }) + }) + + it('displays history when chain is not empty', () => { + setup({ + chain: [ + { + version: 0, + report: { + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.SUBMITTED } + } + }, + { + version: 1, + report: { + currentStatus: { status: COMPLIANCE_REPORT_STATUSES.ASSESSED } + } + } + ] + }) + expect(screen.getByText('report:reportHistory')).toBeInTheDocument() + expect(screen.getByText('HistoryCard - Version 0')).toBeInTheDocument() + expect(screen.getByText('HistoryCard - Version 1')).toBeInTheDocument() + }) + + it('shows "report:assessment" title when currentStatus is ASSESSED', () => { + setup({ currentStatus: COMPLIANCE_REPORT_STATUSES.ASSESSED }) + expect(screen.getByText('report:assessment')).toBeInTheDocument() + }) + + it('shows "report:assessment" title for government users even if not assessed', () => { + setup({ + isGovernmentUser: true, + currentStatus: COMPLIANCE_REPORT_STATUSES.DRAFT + }) + expect(screen.getByText('report:assessment')).toBeInTheDocument() + }) + + it('shows "report:assessment" title if supplemental is true', () => { + setup({ + hasSupplemental: true, + currentStatus: COMPLIANCE_REPORT_STATUSES.DRAFT + }) + expect(screen.getByText('report:assessment')).toBeInTheDocument() + }) + + it('shows "report:orgDetails" title if not assessed, not government user, and no supplemental', () => { + setup({ + isGovernmentUser: false, + hasSupplemental: false, + currentStatus: COMPLIANCE_REPORT_STATUSES.DRAFT + }) + expect(screen.getByText('report:orgDetails')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/views/ComplianceReports/components/AssessmentCard.jsx b/frontend/src/views/ComplianceReports/components/AssessmentCard.jsx index 1b69bcd17..9c55d4117 100644 --- a/frontend/src/views/ComplianceReports/components/AssessmentCard.jsx +++ b/frontend/src/views/ComplianceReports/components/AssessmentCard.jsx @@ -6,119 +6,14 @@ import { StyledListItem } from '@/components/StyledListItem' import { roles } from '@/constants/roles' import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' import { useCreateSupplementalReport } from '@/hooks/useComplianceReports' -import { useCurrentUser } from '@/hooks/useCurrentUser' import { constructAddress } from '@/utils/constructAddress' -import { timezoneFormatter } from '@/utils/formatters' import AssignmentIcon from '@mui/icons-material/Assignment' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import { List, ListItemText, Stack, styled } from '@mui/material' -import MuiAccordion from '@mui/material/Accordion' -import MuiAccordionDetails from '@mui/material/AccordionDetails' -import MuiAccordionSummary, { - accordionSummaryClasses -} from '@mui/material/AccordionSummary' +import { List, ListItemText, Stack } from '@mui/material' import Box from '@mui/material/Box' -import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { FEATURE_FLAGS, isFeatureEnabled } from '@/constants/config.js' - -const Accordion = styled((props) => ( - -))(() => ({ - border: `none`, - '&::before': { - display: 'none' - } -})) - -const AccordionSummary = styled((props) => ( - } - {...props} - /> -))(() => ({ - minHeight: 'unset', - padding: 0, - flexDirection: 'row-reverse', - [`& .${accordionSummaryClasses.content}`]: { - margin: 0 - }, - [`& .${accordionSummaryClasses.expanded}`]: { - margin: 0 - } -})) - -const AccordionDetails = styled(MuiAccordionDetails)(() => ({ - paddingLeft: '1rem', - paddingTop: 0, - paddingBottom: 0 -})) - -const HistoryCard = ({ report }) => { - const { data: currentUser } = useCurrentUser() - const isGovernmentUser = currentUser?.isGovernmentUser - const { t } = useTranslation(['report']) - const filteredHistory = useMemo(() => { - if (!report.history || report.history.length === 0) { - return [] - } - // Sort the history array by date in descending order - return [...report.history] - .sort((a, b) => { - return new Date(b.createDate) - new Date(a.createDate) - }) - .map((item) => { - if ( - item.status.status === COMPLIANCE_REPORT_STATUSES.ASSESSED && - !isGovernmentUser - ) { - item.status.status = 'AssessedBy' - } - return item - }) - .filter((item) => item.status.status !== COMPLIANCE_REPORT_STATUSES.DRAFT) - }, [isGovernmentUser, report.history]) - return ( - - } - aria-controls="panel1-content" - > - - {report.version === 0 - ? `${report.compliancePeriod.description} Compliance Report` - : report.nickname} - : {report.currentStatus.status} - - - - - {filteredHistory.map((item, index) => ( - - - - - - ))} - - - - ) -} +import { HistoryCard } from '@/views/ComplianceReports/components/HistoryCard.jsx' export const AssessmentCard = ({ orgData, diff --git a/frontend/src/views/ComplianceReports/components/HistoryCard.jsx b/frontend/src/views/ComplianceReports/components/HistoryCard.jsx new file mode 100644 index 000000000..fb8a755a8 --- /dev/null +++ b/frontend/src/views/ComplianceReports/components/HistoryCard.jsx @@ -0,0 +1,114 @@ +import React, { useMemo } from 'react' +import { List, ListItemText, styled } from '@mui/material' +import MuiAccordion from '@mui/material/Accordion' +import MuiAccordionSummary, { + accordionSummaryClasses +} from '@mui/material/AccordionSummary' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import MuiAccordionDetails from '@mui/material/AccordionDetails' +import { useCurrentUser } from '@/hooks/useCurrentUser.js' +import { useTranslation } from 'react-i18next' +import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses.js' +import BCTypography from '@/components/BCTypography/index.jsx' +import { StyledListItem } from '@/components/StyledListItem.jsx' +import { timezoneFormatter } from '@/utils/formatters.js' + +const Accordion = styled((props) => ( + +))(() => ({ + border: `none`, + '&::before': { + display: 'none' + } +})) + +const AccordionSummary = styled((props) => ( + } + {...props} + /> +))(() => ({ + minHeight: 'unset', + padding: 0, + flexDirection: 'row-reverse', + [`& .${accordionSummaryClasses.content}`]: { + margin: 0 + }, + [`& .${accordionSummaryClasses.expanded}`]: { + margin: 0 + } +})) + +const AccordionDetails = styled(MuiAccordionDetails)(() => ({ + paddingLeft: '1rem', + paddingTop: 0, + paddingBottom: 0 +})) + +export const HistoryCard = ({ report }) => { + const { data: currentUser } = useCurrentUser() + const isGovernmentUser = currentUser?.isGovernmentUser + const { t } = useTranslation(['report']) + const filteredHistory = useMemo(() => { + if (!report.history || report.history.length === 0) { + return [] + } + // Sort the history array by date in descending order + return [...report.history] + .sort((a, b) => { + return new Date(b.createDate) - new Date(a.createDate) + }) + .map((item) => { + if ( + item.status.status === COMPLIANCE_REPORT_STATUSES.ASSESSED && + !isGovernmentUser + ) { + item.status.status = 'AssessedBy' + } + return item + }) + .filter((item) => item.status.status !== COMPLIANCE_REPORT_STATUSES.DRAFT) + }, [isGovernmentUser, report.history]) + return ( + + } + aria-controls="panel1-content" + > + + {report.version === 0 + ? `${report.compliancePeriod.description} Compliance Report` + : report.nickname} + : {report.currentStatus.status} + + + + + {filteredHistory.map((item, index) => ( + + + + + + ))} + + + + ) +} diff --git a/frontend/src/views/ComplianceReports/components/LegacyAssessmentCard.jsx b/frontend/src/views/ComplianceReports/components/LegacyAssessmentCard.jsx new file mode 100644 index 000000000..697a17b4f --- /dev/null +++ b/frontend/src/views/ComplianceReports/components/LegacyAssessmentCard.jsx @@ -0,0 +1,128 @@ +import BCButton from '@/components/BCButton' +import BCTypography from '@/components/BCTypography' +import BCWidgetCard from '@/components/BCWidgetCard/BCWidgetCard' +import { StyledListItem } from '@/components/StyledListItem' +import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' +import { constructAddress } from '@/utils/constructAddress' +import AssignmentIcon from '@mui/icons-material/Assignment' +import { List, ListItemText, Stack } from '@mui/material' +import Box from '@mui/material/Box' +import { useTranslation } from 'react-i18next' +import { CONFIG } from '@/constants/config' +import { HistoryCard } from '@/views/ComplianceReports/components/HistoryCard.jsx' + +export const LegacyAssessmentCard = ({ + orgData, + hasSupplemental, + isGovernmentUser, + currentStatus, + legacyReportId, + chain +}) => { + const { t } = useTranslation(['report']) + + const viewLegacyReport = () => { + window.open( + `${CONFIG.TFRS_BASE}/compliance_reporting/edit/${legacyReportId}/intro`, + '_blank' + ) + } + return ( + <> + + + + {orgData?.name} + + + + + {t('report:serviceAddrLabel')}: + {orgData && constructAddress(orgData.orgAddress)} + + + + + {t('report:bcAddrLabel')}:{' '} + {orgData && constructAddress(orgData.orgAttorneyAddress)} + + + + + {!isGovernmentUser && ( + + )} + {!!chain.length && ( + <> + + {t('report:reportHistory')} + + {chain.map((report) => ( + + ))} + + )} + + + {t('report:supplementalWarning')} + + + } + sx={{ mt: 2 }} + onClick={viewLegacyReport} + > + {t('report:viewLegacyBtn')} + + + + + } + /> + + {t('report:questions')} + + + + ) +} diff --git a/frontend/src/views/ComplianceReports/components/ReportDetails.jsx b/frontend/src/views/ComplianceReports/components/ReportDetails.jsx index 351110c15..f14545f29 100644 --- a/frontend/src/views/ComplianceReports/components/ReportDetails.jsx +++ b/frontend/src/views/ComplianceReports/components/ReportDetails.jsx @@ -32,13 +32,20 @@ import { FuelExportSummary } from '@/views/FuelExports/FuelExportSummary' import { SupportingDocumentSummary } from '@/views/SupportingDocuments/SupportingDocumentSummary' import DocumentUploadDialog from '@/components/Documents/DocumentUploadDialog' import { useComplianceReportDocuments } from '@/hooks/useComplianceReports' +import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' -const ReportDetails = ({ currentStatus = 'Draft' }) => { +const ReportDetails = ({ currentStatus = 'Draft', isAnalystRole }) => { const { t } = useTranslation() const { compliancePeriod, complianceReportId } = useParams() const navigate = useNavigate() const [isFileDialogOpen, setFileDialogOpen] = useState(false) + const editSupportingDocs = useMemo(() => { + return isAnalystRole && ( + currentStatus === COMPLIANCE_REPORT_STATUSES.SUBMITTED || + currentStatus === COMPLIANCE_REPORT_STATUSES.ASSESSED + ) || currentStatus === COMPLIANCE_REPORT_STATUSES.DRAFT; + }, [isAnalystRole, currentStatus]); const isArrayEmpty = useCallback((data) => { if (Array.isArray(data)) { @@ -64,11 +71,22 @@ const ReportDetails = ({ currentStatus = 'Draft' }) => { }, useFetch: useComplianceReportDocuments, component: (data) => ( - - ) + <> + + { + setFileDialogOpen(false) + }} + /> + + ), + condition: true }, { name: t('report:activityLists.supplyOfFuel'), @@ -171,9 +189,13 @@ const ReportDetails = ({ currentStatus = 'Draft' }) => { ] ) - const [expanded, setExpanded] = useState(() => - activityList.map((_, index) => `panel${index}`) - ) + const [expanded, setExpanded] = useState(activityList.map((activity, index) => { + if (activity.name === t('report:supportingDocs')) { + return isArrayEmpty(activity.useFetch(complianceReportId).data) ? '' : `panel${index}` + } + return `panel${index}` + }).filter(Boolean)) // Initialize with panels that should be open by default + const [allExpanded, setAllExpanded] = useState(true) const handleChange = (panel) => (event, isExpanded) => { @@ -217,11 +239,10 @@ const ReportDetails = ({ currentStatus = 'Draft' }) => { {activityList.map((activity, index) => { const { data, error, isLoading } = activity.useFetch(complianceReportId) return ( - data && - !isArrayEmpty(data) && ( + (data && !isArrayEmpty(data) || activity.name === t('report:supportingDocs')) && ( { component="div" > {activity.name}   - {currentStatus === 'Draft' && ( + {editSupportingDocs && ( <> diff --git a/frontend/src/views/ComplianceReports/components/_schema.jsx b/frontend/src/views/ComplianceReports/components/_schema.jsx index e83fcdc19..e7f1546fb 100644 --- a/frontend/src/views/ComplianceReports/components/_schema.jsx +++ b/frontend/src/views/ComplianceReports/components/_schema.jsx @@ -1,6 +1,6 @@ import { BCColumnSetFilter } from '@/components/BCDataGrid/components' import { SUMMARY } from '@/constants/common' -import { ReportsStatusRenderer, LinkRenderer } from '@/utils/grid/cellRenderers' +import { ReportsStatusRenderer } from '@/utils/grid/cellRenderers' import { timezoneFormatter } from '@/utils/formatters' export const reportsColDefs = (t, bceidRole) => [ @@ -8,11 +8,6 @@ export const reportsColDefs = (t, bceidRole) => [ field: 'compliancePeriod', headerName: t('report:reportColLabels.compliancePeriod'), width: 210, - cellRenderer: LinkRenderer, - cellRendererParams: { - url: ({ data }) => - `${data.compliancePeriod?.description}/${data.complianceReportId}` - }, valueGetter: ({ data }) => data.compliancePeriod?.description || '', filterParams: { buttons: ['clear'] @@ -23,11 +18,6 @@ export const reportsColDefs = (t, bceidRole) => [ headerName: t('report:reportColLabels.organization'), flex: 2, hide: bceidRole, - cellRenderer: LinkRenderer, - cellRendererParams: { - url: ({ data }) => - `${data.compliancePeriod?.description}/${data.complianceReportId}` - }, valueGetter: ({ data }) => data.organization?.name || '' }, { diff --git a/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx b/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx index ca05f1413..1e54f01bc 100644 --- a/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx +++ b/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx @@ -103,12 +103,14 @@ const AddEditFuelCodeBase = () => { if (existingFuelCode) { const transformedData = { ...existingFuelCode, - feedstockFuelTransportMode: existingFuelCode.feedstockFuelTransportModes.map( - (mode) => mode.feedstockFuelTransportMode.transportMode - ), - finishedFuelTransportMode: existingFuelCode.finishedFuelTransportModes.map( - (mode) => mode.finishedFuelTransportMode.transportMode - ) + feedstockFuelTransportMode: + existingFuelCode.feedstockFuelTransportModes.map( + (mode) => mode.feedstockFuelTransportMode.transportMode + ), + finishedFuelTransportMode: + existingFuelCode.finishedFuelTransportModes.map( + (mode) => mode.finishedFuelTransportMode.transportMode + ) } setRowData([transformedData]) } else { @@ -452,6 +454,12 @@ const AddEditFuelCodeBase = () => { {existingFuelCode?.fuelCodeStatus.status === FUEL_CODE_STATUSES.APPROVED && t('fuelCode:viewFuelCodeTitle')} + + {(!existingFuelCode || + existingFuelCode?.fuelCodeStatus.status === + FUEL_CODE_STATUSES.DRAFT) && + t('fuelCode:fuelCodeEntryGuide')} +