From 6f383dd2cdf37ea40dd66dc3e8f699e37696bd88 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Mon, 23 Dec 2024 03:38:09 -0800 Subject: [PATCH] Feat: LCFS - Add a Return report to the supplier button #1507 --- .../lcfs/db/models/compliance/listeners.py | 6 -- .../lcfs/web/api/compliance_report/repo.py | 12 ++- .../lcfs/web/api/compliance_report/schema.py | 19 ++++- .../api/compliance_report/update_service.py | 84 +++++++++++++------ backend/lcfs/web/api/notification/schema.py | 3 + frontend/src/assets/locales/en/reports.json | 4 +- .../components/Filters/BCColumnSetFilter.jsx | 14 ++-- .../BCNavbar/components/DefaultNavbarLink.jsx | 7 +- frontend/src/constants/statuses.js | 3 +- frontend/src/hooks/useComplianceReports.js | 2 + .../components/UserProfileActions.jsx | 42 ++++++---- frontend/src/themes/base/globals.js | 3 + .../EditViewComplianceReport.jsx | 44 +++++++--- .../views/ComplianceReports/buttonConfigs.jsx | 43 +++++++--- 14 files changed, 195 insertions(+), 91 deletions(-) diff --git a/backend/lcfs/db/models/compliance/listeners.py b/backend/lcfs/db/models/compliance/listeners.py index 49ab43c34..1ea2b22b8 100644 --- a/backend/lcfs/db/models/compliance/listeners.py +++ b/backend/lcfs/db/models/compliance/listeners.py @@ -9,9 +9,3 @@ def prevent_update_if_locked(mapper, connection, target): raise InvalidRequestError("Cannot update a locked ComplianceReportSummary") -@event.listens_for(ComplianceReportSummary.is_locked, "set") -def prevent_unlock(target, value, oldvalue, initiator): - if oldvalue and not value: - raise InvalidRequestError( - "Cannot unlock a ComplianceReportSummary once it's locked" - ) diff --git a/backend/lcfs/web/api/compliance_report/repo.py b/backend/lcfs/web/api/compliance_report/repo.py index 59696d6ab..bb84ef308 100644 --- a/backend/lcfs/web/api/compliance_report/repo.py +++ b/backend/lcfs/web/api/compliance_report/repo.py @@ -9,7 +9,7 @@ from lcfs.db.models.fuel.FuelType import FuelType from lcfs.db.models.fuel.FuelCategory import FuelCategory from lcfs.db.models.fuel.ExpectedUseType import ExpectedUseType -from sqlalchemy import func, select, and_, asc, desc +from sqlalchemy import func, select, and_, asc, desc, update from sqlalchemy.orm import joinedload from sqlalchemy.ext.asyncio import AsyncSession from fastapi import Depends @@ -570,6 +570,16 @@ async def add_compliance_report_summary( await self.db.refresh(summary) return summary + @repo_handler + async def reset_summary_lock(self, compliance_report_id: int): + query = ( + update(ComplianceReportSummary) + .where(ComplianceReportSummary.compliance_report_id == compliance_report_id) + .values(is_locked=False) + ) + await self.db.execute(query) + return True + @repo_handler async def save_compliance_report_summary( self, summary: ComplianceReportSummaryUpdateSchema diff --git a/backend/lcfs/web/api/compliance_report/schema.py b/backend/lcfs/web/api/compliance_report/schema.py index e839bee11..aa8e34858 100644 --- a/backend/lcfs/web/api/compliance_report/schema.py +++ b/backend/lcfs/web/api/compliance_report/schema.py @@ -1,12 +1,13 @@ from enum import Enum -from typing import ClassVar, Optional, List, Union +from typing import ClassVar, Optional, List from datetime import datetime, date from enum import Enum +from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum from lcfs.web.api.fuel_code.schema import EndUseTypeSchema, EndUserTypeSchema from lcfs.web.api.base import BaseSchema, FilterModel, SortOrder from lcfs.web.api.base import PaginationResponseSchema -from pydantic import Field, Extra +from pydantic import Field """ Base - all shared attributes of a resource @@ -17,6 +18,18 @@ """ +class ReturnStatus(Enum): + ANALYST = "Return to analyst" + MANAGER = "Return to manager" + SUPPLIER = "Return to supplier" + +RETURN_STATUS_MAPPER = { + ReturnStatus.ANALYST.value: ComplianceReportStatusEnum.Submitted.value, + ReturnStatus.MANAGER.value: ComplianceReportStatusEnum.Recommended_by_analyst.value, + ReturnStatus.SUPPLIER.value: ComplianceReportStatusEnum.Draft.value, +} + + class SupplementalInitiatorType(str, Enum): SUPPLIER_SUPPLEMENTAL = "Supplier Supplemental" GOVERNMENT_REASSESSMENT = "Government Reassessment" @@ -45,7 +58,7 @@ class SummarySchema(BaseSchema): is_locked: bool class Config: - extra = Extra.allow + extra = 'allow' class ComplianceReportStatusSchema(BaseSchema): diff --git a/backend/lcfs/web/api/compliance_report/update_service.py b/backend/lcfs/web/api/compliance_report/update_service.py index 1a1d7d9c7..d58c51cff 100644 --- a/backend/lcfs/web/api/compliance_report/update_service.py +++ b/backend/lcfs/web/api/compliance_report/update_service.py @@ -1,4 +1,5 @@ import json +from typing import Tuple from fastapi import Depends, HTTPException, Request from lcfs.web.api.notification.schema import ( COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER, @@ -12,7 +13,11 @@ from lcfs.db.models.transaction.Transaction import TransactionActionEnum from lcfs.db.models.user.Role import RoleEnum from lcfs.web.api.compliance_report.repo import ComplianceReportRepository -from lcfs.web.api.compliance_report.schema import ComplianceReportUpdateSchema +from lcfs.web.api.compliance_report.schema import ( + RETURN_STATUS_MAPPER, + ComplianceReportUpdateSchema, + ReturnStatus, +) from lcfs.web.api.compliance_report.summary_service import ( ComplianceReportSummaryService, ) @@ -39,48 +44,67 @@ def __init__( self.trx_service = trx_service self.notfn_service = notfn_service - async def update_compliance_report( - self, report_id: int, report_data: ComplianceReportUpdateSchema - ) -> ComplianceReport: - """Updates an existing compliance report.""" - RETURN_STATUSES = ["Return to analyst", "Return to manager"] + async def _handle_return_status( + self, report_data: ComplianceReportUpdateSchema + ) -> Tuple[str, bool]: + """Handle return status logic and return new status and change flag.""" + mapped_status = RETURN_STATUS_MAPPER.get(report_data.status) + return mapped_status, False + + async def _check_report_exists(self, report_id: int) -> ComplianceReport: + """Verify report exists and return it.""" report = await self.repo.get_compliance_report_by_id(report_id, is_model=True) if not report: raise DataNotFoundException( f"Compliance report with ID {report_id} not found" ) + return report + + async def update_compliance_report( + self, report_id: int, report_data: ComplianceReportUpdateSchema + ) -> ComplianceReport: + """Updates an existing compliance report.""" + # Get and validate report + report = await self._check_report_exists(report_id) + + # Store original status current_status = report_data.status - # if we're just returning the compliance report back to either compliance manager or analyst, - # then neither history nor any updates to summary is required. - if report_data.status in RETURN_STATUSES: - status_has_changed = False - notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get( - report_data.status + + # Handle status changes + if report_data.status in [status.value for status in ReturnStatus]: + new_status, status_has_changed = await self._handle_return_status( + report_data ) - if report_data.status == "Return to analyst": - report_data.status = ComplianceReportStatusEnum.Submitted.value - else: - report_data.status = ( - ComplianceReportStatusEnum.Recommended_by_analyst.value - ) + report_data.status = new_status + + # Handle "Return to supplier" + if current_status == ReturnStatus.SUPPLIER.value: + await self.repo.reset_summary_lock(report.compliance_report_id) else: + # Handle normal status change status_has_changed = report.current_status.status != getattr( ComplianceReportStatusEnum, report_data.status.replace(" ", "_") ) + + # Get new status object new_status = await self.repo.get_compliance_report_status_by_desc( report_data.status ) - # Update fields + + # Update report report.current_status = new_status report.supplemental_note = report_data.supplemental_note - updated_report = await self.repo.update_compliance_report(report) + + # Handle status change related actions if status_has_changed: await self.handle_status_change(report, new_status.status) # Add history record await self.repo.add_compliance_report_history(report, self.request.user) + # Handle notifications await self._perform_notification_call(report, current_status) + return updated_report async def _perform_notification_call(self, report, status): @@ -102,7 +126,7 @@ async def _perform_notification_call(self, report, status): "status": status.lower(), } notification_data = NotificationMessageSchema( - type=f"Compliance report {status.lower()}", + type=f"Compliance report {status.lower().replace('return', 'returned')}", related_transaction_id=f"CR{report.compliance_report_id}", message=json.dumps(message_data), related_organization_id=report.organization_id, @@ -228,12 +252,18 @@ async def handle_submitted_status(self, report: ComplianceReport): report.summary = new_summary if report.summary.line_20_surplus_deficit_units != 0: - # Create a new reserved transaction for receiving organization - report.transaction = await self.org_service.adjust_balance( - transaction_action=TransactionActionEnum.Reserved, - compliance_units=report.summary.line_20_surplus_deficit_units, - organization_id=report.organization_id, - ) + if report.transaction is not None: + # Update existing transaction + report.transaction.compliance_units = ( + report.summary.line_20_surplus_deficit_units + ) + else: + # Create a new reserved transaction for receiving organization + report.transaction = await self.org_service.adjust_balance( + transaction_action=TransactionActionEnum.Reserved, + compliance_units=report.summary.line_20_surplus_deficit_units, + organization_id=report.organization_id, + ) await self.repo.update_compliance_report(report) return calculated_summary diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 30466bfa8..31f0f7270 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -117,6 +117,9 @@ class NotificationRequestSchema(BaseSchema): "Return to manager": [ NotificationTypeEnum.IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION ], + "Return to supplier": [ + NotificationTypeEnum.BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT + ] } diff --git a/frontend/src/assets/locales/en/reports.json b/frontend/src/assets/locales/en/reports.json index 91ced911b..a9d80c348 100644 --- a/frontend/src/assets/locales/en/reports.json +++ b/frontend/src/assets/locales/en/reports.json @@ -39,17 +39,19 @@ "submitReportBtn": "Submit report", "returnToAnalyst": "Return to analyst", "returnToManager": "Return to compliance manager", + "returnToSupplier": "Return report to the supplier", "recommendReportAnalystBtn": "Recommend to compliance manager", "recommendReportManagerBtn": "Recommend to director", "assessReportBtn": "Issue assessment", "reAssessReportBtn": "Re-assess report" }, - "savedSuccessText": "Compliance report successfully saved", + "savedSuccessText": "Compliance report successfully {{status}}", "submitConfirmText": "Are you sure you want to sign and submit this compliance report?", "deleteConfirmText": "Are you sure you want to delete this compliance report?", "recommendConfirmText": "Are you sure you want to recommend this compliance report?", "returnToAnalystConfirmText": "Are you sure you want to return this compliance report back to analyst?", "returnToManagerConfirmText": "Are you sure you want to return this compliance report back to compliance manager?", + "returnToSupplierConfirmText": "Are you sure you want to return this compliance report back to the supplier?", "assessConfirmText": "Are you sure you want to assess this compliance report?", "uploadLabel": "Upload supporting documents for your report.", "introduction": "Introduction", diff --git a/frontend/src/components/BCDataGrid/components/Filters/BCColumnSetFilter.jsx b/frontend/src/components/BCDataGrid/components/Filters/BCColumnSetFilter.jsx index 2bc840392..fec6ab853 100644 --- a/frontend/src/components/BCDataGrid/components/Filters/BCColumnSetFilter.jsx +++ b/frontend/src/components/BCDataGrid/components/Filters/BCColumnSetFilter.jsx @@ -71,7 +71,12 @@ export const BCColumnSetFilter = forwardRef((props, ref) => { limitTags={1} className="bc-column-set-filter ag-list ag-select-list ag-ltr ag-popup-child ag-popup-positioned-under" role="list-box" - sx={{ width: '100%' }} + sx={{ + width: '100%', + '.MuiInputBase-root': { + borderRadius: 'inherit' + } + }} options={options} loading={optionsIsLoading} autoHighlight @@ -126,13 +131,6 @@ export const BCColumnSetFilter = forwardRef((props, ref) => { BCColumnSetFilter.displayName = 'BCColumnSetFilter' -BCColumnSetFilter.defaultProps = { - // apiQuery: () => ({ data: [], isLoading: false }), - apiOptionField: 'name', - multiple: false, - disableCloseOnSelect: false -} - BCColumnSetFilter.propTypes = { apiQuery: PropTypes.func.isRequired, // react query or a fetch query which will return data, isLoading and Error fields. // for static data, use the following format: diff --git a/frontend/src/components/BCNavbar/components/DefaultNavbarLink.jsx b/frontend/src/components/BCNavbar/components/DefaultNavbarLink.jsx index 347b5d6b3..4031baa69 100644 --- a/frontend/src/components/BCNavbar/components/DefaultNavbarLink.jsx +++ b/frontend/src/components/BCNavbar/components/DefaultNavbarLink.jsx @@ -66,16 +66,17 @@ function DefaultNavbarLink({ onMouseLeave={() => setHover(false)} onClick={onClick} > - {icon && ( + {icon && typeof icon === 'string' ? ( - light ? primary.main : secondary.main, + color: '#fff', verticalAlign: 'middle' }} > {icon} + ) : ( + <>{icon} )} { }, onSettled: () => { queryClient.invalidateQueries(['compliance-report', reportID]) + queryClient.invalidateQueries(['compliance-report-summary', reportID]) + queryClient.invalidateQueries(['compliance-reports']) } }) } diff --git a/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx b/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx index 37ebd0557..cfdf3c4ec 100644 --- a/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx +++ b/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import BCBox from '@/components/BCBox' import BCButton from '@/components/BCButton' import BCTypography from '@/components/BCTypography' +import DefaultNavbarLink from '@/components/BCNavbar/components/DefaultNavbarLink' import { useCurrentUser } from '@/hooks/useCurrentUser' import { useNotificationsCount } from '@/hooks/useNotifications' import { @@ -15,14 +16,13 @@ import { Tooltip } from '@mui/material' import NotificationsIcon from '@mui/icons-material/Notifications' -import { useNavigate, useLocation } from 'react-router-dom' +import { useLocation } from 'react-router-dom' import { ROUTES } from '@/constants/routes' export const UserProfileActions = () => { const { t } = useTranslation() const { data: currentUser } = useCurrentUser() const { keycloak } = useKeycloak() - const navigate = useNavigate() const location = useLocation() // TODO: @@ -44,6 +44,22 @@ export const UserProfileActions = () => { refetch() }, [location, refetch]) + const iconBtn = ( + + 0 ? notificationsCount : null} + color="error" + > + + + + ) + return ( keycloak.authenticated && ( { ) : ( - navigate(ROUTES.NOTIFICATIONS)} - aria-label={t('Notifications')} - > - 0 ? notificationsCount : null - } - color="error" - > - - - + )} li:hover, & > li:focus, & > li:blur': { backgroundColor: primary.light, diff --git a/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx b/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx index 3b85bf216..f5129eb4f 100644 --- a/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx +++ b/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useLocation, useParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useForm } from 'react-hook-form' import { FloatingAlert } from '@/components/BCAlert' import BCBox from '@/components/BCBox' @@ -7,14 +7,13 @@ 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 { roles, govRoles } from '@/constants/roles' import { Fab, Stack, Tooltip } from '@mui/material' import BCTypography from '@/components/BCTypography' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' import colors from '@/themes/base/colors.js' -import { govRoles } from '@/constants/roles' import { useTranslation } from 'react-i18next' import { useCurrentUser } from '@/hooks/useCurrentUser' import { useOrganization } from '@/hooks/useOrganization' @@ -27,6 +26,7 @@ import { ActivityListCard } from './components/ActivityListCard' import { AssessmentCard } from './components/AssessmentCard' import InternalComments from '@/components/InternalComments' import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' +import { ROUTES } from '@/constants/routes' const iconStyle = { width: '2rem', @@ -42,6 +42,7 @@ export const EditViewComplianceReport = ({ reportData, isError, error }) => { const [isSigningAuthorityDeclared, setIsSigningAuthorityDeclared] = useState(false) const alertRef = useRef() + const navigate = useNavigate() const { compliancePeriod, complianceReportId } = useParams() const [isScrollingUp, setIsScrollingUp] = useState(false) @@ -64,8 +65,16 @@ export const EditViewComplianceReport = ({ reportData, isError, error }) => { setInternalComment(newComment) }, []) const handleScroll = useCallback(() => { - const scrollTop = window.pageYOffset || document.documentElement.scrollTop - setIsScrollingUp(scrollTop < lastScrollTop || scrollTop === 0) + const scrollTop = window.scrollY || document.documentElement.scrollTop + const scrollPosition = window.scrollY + window.innerHeight + const documentHeight = document.documentElement.scrollHeight + if (scrollTop === 0) { + setIsScrollingUp(false) + } else if (scrollPosition >= documentHeight - 10) { + setIsScrollingUp(true) + } else { + setIsScrollingUp(scrollTop < lastScrollTop || scrollTop === 0) + } setLastScrollTop(scrollTop) }, [lastScrollTop]) @@ -81,8 +90,9 @@ export const EditViewComplianceReport = ({ reportData, isError, error }) => { hasRoles } = useCurrentUser() const isGovernmentUser = currentUser?.isGovernmentUser - const isAnalystRole = currentUser?.roles?.some(role => role.name === roles.analyst) || false; - + 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 @@ -92,9 +102,14 @@ export const EditViewComplianceReport = ({ reportData, isError, error }) => { { onSuccess: (response) => { setModalData(null) - alertRef.current?.triggerAlert({ - message: t('report:savedSuccessText'), - severity: 'success' + const updatedStatus = JSON.parse(response.config.data)?.status + navigate(ROUTES.REPORTS, { + state: { + message: t('report:savedSuccessText', { + status: updatedStatus.toLowerCase().replace('return', 'returned') + }), + severity: 'success' + } }) }, onError: (error) => { @@ -117,7 +132,7 @@ export const EditViewComplianceReport = ({ reportData, isError, error }) => { t, setModalData, updateComplianceReport, - + compliancePeriod, isGovernmentUser, isSigningAuthorityDeclared }), @@ -127,7 +142,7 @@ export const EditViewComplianceReport = ({ reportData, isError, error }) => { t, setModalData, updateComplianceReport, - + compliancePeriod, isGovernmentUser, isSigningAuthorityDeclared ] @@ -211,7 +226,10 @@ export const EditViewComplianceReport = ({ reportData, isError, error }) => { {!location.state?.newReport && ( <> - + { @@ -120,9 +118,7 @@ export const buttonClusterConfigFn = ({ } }, returnToManager: { - ...outlinedButton( - t('report:actionBtns.returnToManager') - ), + ...outlinedButton(t('report:actionBtns.returnToManager')), id: 'return-report-manager-btn', handler: (formData) => { setModalData({ @@ -138,6 +134,23 @@ export const buttonClusterConfigFn = ({ }) } }, + returnToSupplier: { + ...outlinedButton(t('report:actionBtns.returnToSupplier')), + id: 'return-report-supplier-btn', + handler: (formData) => { + setModalData({ + primaryButtonAction: () => + updateComplianceReport({ + ...formData, + status: COMPLIANCE_REPORT_STATUSES.RETURN_TO_SUPPLIER + }), + primaryButtonText: t('report:actionBtns.returnToSupplier'), + secondaryButtonText: t('cancelBtn'), + title: t('confirmation'), + content: t('report:returnToSupplierConfirmText') + }) + } + }, assessReport: { ...containedButton(t('report:actionBtns.assessReportBtn')), id: 'assess-report-btn', @@ -174,13 +187,21 @@ export const buttonClusterConfigFn = ({ } } + const canReturnToSupplier = () => { + const compliancePeriodYear = parseInt(compliancePeriod) + const deadlineDate = new Date(compliancePeriodYear + 1, 2, 31) // Month is 0-based, so 2 = March + const currentDate = new Date() + return currentDate <= deadlineDate + } + const buttons = { - [COMPLIANCE_REPORT_STATUSES.DRAFT]: [ - reportButtons.submitReport - ], + [COMPLIANCE_REPORT_STATUSES.DRAFT]: [reportButtons.submitReport], [COMPLIANCE_REPORT_STATUSES.SUBMITTED]: [ ...(isGovernmentUser && hasRoles('Analyst') - ? [reportButtons.recommendByAnalyst] + ? [ + reportButtons.recommendByAnalyst, + ...(canReturnToSupplier() ? [reportButtons.returnToSupplier] : []) + ] : []) ], [COMPLIANCE_REPORT_STATUSES.RECOMMENDED_BY_ANALYST]: [