diff --git a/backend/lcfs/tests/compliance_report/test_summary_service.py b/backend/lcfs/tests/compliance_report/test_summary_service.py index efc5f51ce..30cf918ef 100644 --- a/backend/lcfs/tests/compliance_report/test_summary_service.py +++ b/backend/lcfs/tests/compliance_report/test_summary_service.py @@ -810,8 +810,7 @@ async def test_calculate_fuel_quantities_renewable( ): # Create a mock repository mock_repo.aggregate_fuel_supplies.return_value = {"gasoline": 200.0} - mock_repo.aggregate_other_uses.return_value = {"diesel": 75.0} - mock_repo.aggregate_allocation_agreements.return_value = {"jet-fuel": 25.0} + mock_repo.aggregate_other_uses.return_value = {"diesel": 75.0, "jet-fuel": 25.0} # Define test inputs compliance_report_id = 2 @@ -830,7 +829,4 @@ async def test_calculate_fuel_quantities_renewable( mock_repo.aggregate_other_uses.assert_awaited_once_with( compliance_report_id, fossil_derived ) - mock_repo.aggregate_allocation_agreements.assert_awaited_once_with( - compliance_report_id - ) assert result == {"gasoline": 200.0, "diesel": 75.0, "jet-fuel": 25.0} diff --git a/backend/lcfs/tests/fuel_supply/test_fuel_supplies_validation.py b/backend/lcfs/tests/fuel_supply/test_fuel_supplies_validation.py index 747416563..eefa25c91 100644 --- a/backend/lcfs/tests/fuel_supply/test_fuel_supplies_validation.py +++ b/backend/lcfs/tests/fuel_supply/test_fuel_supplies_validation.py @@ -1,26 +1,30 @@ from unittest.mock import MagicMock, AsyncMock - import pytest -from fastapi import Request +from fastapi.exceptions import RequestValidationError from lcfs.web.api.fuel_supply.repo import FuelSupplyRepository +from lcfs.web.api.fuel_code.repo import FuelCodeRepository from lcfs.web.api.fuel_supply.schema import FuelSupplyCreateUpdateSchema from lcfs.web.api.fuel_supply.validation import FuelSupplyValidation @pytest.fixture def fuel_supply_validation(): + # Mock repositories mock_fs_repo = MagicMock(spec=FuelSupplyRepository) - request = MagicMock(spec=Request) + mock_fc_repo = MagicMock(spec=FuelCodeRepository) + + # Create the validation instance with mocked repositories validation = FuelSupplyValidation( - request=request, fs_repo=mock_fs_repo + fs_repo=mock_fs_repo, + fc_repo=mock_fc_repo, ) - return validation, mock_fs_repo + return validation, mock_fs_repo, mock_fc_repo @pytest.mark.anyio async def test_check_duplicate(fuel_supply_validation): - validation, mock_fs_repo = fuel_supply_validation + validation, mock_fs_repo, _ = fuel_supply_validation fuel_supply_data = FuelSupplyCreateUpdateSchema( compliance_report_id=1, fuel_type_id=1, @@ -29,9 +33,83 @@ async def test_check_duplicate(fuel_supply_validation): quantity=2000, units="L", ) + mock_fs_repo.check_duplicate = AsyncMock(return_value=True) result = await validation.check_duplicate(fuel_supply_data) assert result is True mock_fs_repo.check_duplicate.assert_awaited_once_with(fuel_supply_data) + + +@pytest.mark.anyio +async def test_validate_other_recognized_type(fuel_supply_validation): + validation, _, mock_fc_repo = fuel_supply_validation + # Mock a recognized fuel type (unrecognized = False) + mock_fc_repo.get_fuel_type_by_id = AsyncMock( + return_value=MagicMock(unrecognized=False) + ) + + fuel_supply_data = FuelSupplyCreateUpdateSchema( + compliance_report_id=1, + fuel_type_id=1, # Some recognized type ID + fuel_category_id=1, + provision_of_the_act_id=1, + quantity=2000, + units="L", + ) + + # Should not raise any error as fuel_type_other is not needed for recognized type + await validation.validate_other(fuel_supply_data) + + +@pytest.mark.anyio +async def test_validate_other_unrecognized_type_with_other(fuel_supply_validation): + validation, _, mock_fc_repo = fuel_supply_validation + # Mock an unrecognized fuel type + mock_fc_repo.get_fuel_type_by_id = AsyncMock( + return_value=MagicMock(unrecognized=True) + ) + + # Provide fuel_type_other since it's unrecognized + fuel_supply_data = FuelSupplyCreateUpdateSchema( + compliance_report_id=1, + fuel_type_id=99, # Assume 99 is unrecognized "Other" type + fuel_category_id=1, + provision_of_the_act_id=1, + quantity=2000, + units="L", + fuel_type_other="Some other fuel", + ) + + # Should not raise an error since fuel_type_other is provided + await validation.validate_other(fuel_supply_data) + + +@pytest.mark.anyio +async def test_validate_other_unrecognized_type_missing_other(fuel_supply_validation): + validation, _, mock_fc_repo = fuel_supply_validation + # Mock an unrecognized fuel type + mock_fc_repo.get_fuel_type_by_id = AsyncMock( + return_value=MagicMock(unrecognized=True) + ) + + # Missing fuel_type_other + fuel_supply_data = FuelSupplyCreateUpdateSchema( + compliance_report_id=1, + fuel_type_id=99, # Assume 99 is unrecognized "Other" type + fuel_category_id=1, + provision_of_the_act_id=1, + quantity=2000, + units="L", + ) + + # Should raise RequestValidationError since fuel_type_other is required + with pytest.raises(RequestValidationError) as exc: + await validation.validate_other(fuel_supply_data) + + # Assert that the error message is as expected + errors = exc.value.errors() + assert len(errors) == 1 + assert errors[0]["loc"] == ("fuelTypeOther",) + assert "required when using Other" in errors[0]["msg"] diff --git a/backend/lcfs/tests/internal_comment/test_internal_comment.py b/backend/lcfs/tests/internal_comment/test_internal_comment.py index dfae4ca5a..046327621 100644 --- a/backend/lcfs/tests/internal_comment/test_internal_comment.py +++ b/backend/lcfs/tests/internal_comment/test_internal_comment.py @@ -4,6 +4,7 @@ from httpx import AsyncClient from datetime import datetime +from lcfs.db.models import UserProfile from lcfs.db.models.transfer.Transfer import Transfer, TransferRecommendationEnum from lcfs.db.models.initiative_agreement.InitiativeAgreement import InitiativeAgreement from lcfs.db.models.admin_adjustment.AdminAdjustment import AdminAdjustment @@ -334,6 +335,13 @@ async def test_get_internal_comments_multiple_comments( ) await add_models([transfer]) + user = UserProfile( + keycloak_username="IDIRUSER", + first_name="Test", + last_name="User", + ) + await add_models([user]) + comments = [] for i in range(3): internal_comment = InternalComment( diff --git a/backend/lcfs/tests/user/test_user_views.py b/backend/lcfs/tests/user/test_user_views.py index 1c68b120d..24c9e303f 100644 --- a/backend/lcfs/tests/user/test_user_views.py +++ b/backend/lcfs/tests/user/test_user_views.py @@ -125,7 +125,7 @@ async def test_get_user_activities_as_manage_users_same_org( add_models, ): # Mock the current user as a user with MANAGE_USERS - set_mock_user(fastapi_app, [RoleEnum.MANAGE_USERS]) + set_mock_user(fastapi_app, [RoleEnum.ADMINISTRATOR, RoleEnum.MANAGE_USERS]) # Assuming target user with user_profile_id=3 exists and is in organization_id=1 target_user_id = 1 diff --git a/backend/lcfs/web/api/final_supply_equipment/repo.py b/backend/lcfs/web/api/final_supply_equipment/repo.py index b3680584f..f2353b624 100644 --- a/backend/lcfs/web/api/final_supply_equipment/repo.py +++ b/backend/lcfs/web/api/final_supply_equipment/repo.py @@ -201,7 +201,7 @@ async def get_fse_list(self, report_id: int) -> List[FinalSupplyEquipment]: joinedload(FinalSupplyEquipment.level_of_equipment), ) .where(FinalSupplyEquipment.compliance_report_id == report_id) - .order_by(FinalSupplyEquipment.final_supply_equipment_id) + .order_by(FinalSupplyEquipment.create_date.asc()) ) return result.unique().scalars().all() @@ -232,7 +232,7 @@ async def get_fse_paginated( result = await self.db.execute( query.offset(offset) .limit(limit) - .order_by(FinalSupplyEquipment.create_date.desc()) + .order_by(FinalSupplyEquipment.create_date.asc()) ) final_supply_equipments = result.unique().scalars().all() return final_supply_equipments, total_count @@ -356,7 +356,7 @@ async def increment_seq_by_org_and_postal_code( sequence_number = 1 return sequence_number - + @repo_handler async def check_uniques_of_fse_row(self, row: FinalSupplyEquipmentCreateSchema) -> bool: """ diff --git a/backend/lcfs/web/api/fuel_supply/validation.py b/backend/lcfs/web/api/fuel_supply/validation.py index bdc2ba2b4..dc065e06a 100644 --- a/backend/lcfs/web/api/fuel_supply/validation.py +++ b/backend/lcfs/web/api/fuel_supply/validation.py @@ -1,5 +1,7 @@ -from fastapi import Depends, Request +from fastapi import Depends +from fastapi.exceptions import RequestValidationError +from lcfs.web.api.fuel_code.repo import FuelCodeRepository from lcfs.web.api.fuel_supply.repo import FuelSupplyRepository from lcfs.web.api.fuel_supply.schema import FuelSupplyCreateUpdateSchema @@ -7,11 +9,26 @@ class FuelSupplyValidation: def __init__( self, - request: Request = None, fs_repo: FuelSupplyRepository = Depends(FuelSupplyRepository), + fc_repo: FuelCodeRepository = Depends(FuelCodeRepository), ): self.fs_repo = fs_repo - self.request = request + self.fc_repo = fc_repo async def check_duplicate(self, fuel_supply: FuelSupplyCreateUpdateSchema): return await self.fs_repo.check_duplicate(fuel_supply) + + async def validate_other(self, fuel_supply: FuelSupplyCreateUpdateSchema): + fuel_type = await self.fc_repo.get_fuel_type_by_id(fuel_supply.fuel_type_id) + + if fuel_type.unrecognized: + if not fuel_supply.fuel_type_other: + raise RequestValidationError( + [ + { + "loc": ("fuelTypeOther",), + "msg": "required when using Other", + "type": "value_error", + } + ] + ) diff --git a/backend/lcfs/web/api/fuel_supply/views.py b/backend/lcfs/web/api/fuel_supply/views.py index 52c1b8b82..3f5674408 100644 --- a/backend/lcfs/web/api/fuel_supply/views.py +++ b/backend/lcfs/web/api/fuel_supply/views.py @@ -95,6 +95,7 @@ async def save_fuel_supply_row( return await action_service.delete_fuel_supply(request_data, current_user_type) else: duplicate_id = await fs_validate.check_duplicate(request_data) + await fs_validate.validate_other(request_data) if duplicate_id is not None: duplicate_response = format_duplicate_error(duplicate_id) return duplicate_response diff --git a/backend/lcfs/web/api/internal_comment/services.py b/backend/lcfs/web/api/internal_comment/services.py index 965d03e8e..c051f03cb 100644 --- a/backend/lcfs/web/api/internal_comment/services.py +++ b/backend/lcfs/web/api/internal_comment/services.py @@ -73,7 +73,10 @@ async def get_internal_comments( List[InternalCommentResponseSchema]: A list of internal comments as data transfer objects. """ comments = await self.repo.get_internal_comments(entity_type, entity_id) - return [InternalCommentResponseSchema.from_orm(comment) for comment in comments] + return [ + InternalCommentResponseSchema.model_validate(comment) + for comment in comments + ] @service_handler async def get_internal_comment_by_id( diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 30ff2d5f2..30466bfa8 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -21,13 +21,6 @@ class NotificationUserProfileSchema(BaseSchema): organization_id: Optional[int] = None is_government: bool = False - @model_validator(mode="before") - def update_government_profile(cls, data): - if data.is_government: - data.first_name = "Government of B.C." - data.last_name = "" - return data - @computed_field @property def full_name(self) -> str: diff --git a/backend/lcfs/web/core/decorators.py b/backend/lcfs/web/core/decorators.py index e67d9afca..0ccfc9a3b 100644 --- a/backend/lcfs/web/core/decorators.py +++ b/backend/lcfs/web/core/decorators.py @@ -9,6 +9,7 @@ import os from fastapi import HTTPException, Request +from fastapi.exceptions import RequestValidationError from lcfs.services.clamav.client import VirusScanException from lcfs.web.exception.exceptions import ( @@ -191,6 +192,8 @@ async def wrapper(request: Request, *args, **kwargs): status_code=422, detail="Viruses detected in file, please upload another", ) + except RequestValidationError as e: + raise e except Exception as e: context = extract_context() log_unhandled_exception(logger, e, context, "view", func=func) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fdce412f9..3fd41e4ec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,6 +40,7 @@ "material-ui-popup-state": "^5.0.10", "moment": "^2.30.1", "mui-daterange-picker-plus": "^1.0.4", + "notistack": "^3.0.1", "papaparse": "^5.4.1", "pretty-bytes": "^6.1.1", "quill": "^2.0.2", @@ -13052,6 +13053,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -16449,6 +16458,36 @@ "node": ">=0.10.0" } }, + "node_modules/notistack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz", + "integrity": "sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/notistack/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6e8783097..d6bec5c8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,6 +62,7 @@ "material-ui-popup-state": "^5.0.10", "moment": "^2.30.1", "mui-daterange-picker-plus": "^1.0.4", + "notistack": "^3.0.1", "papaparse": "^5.4.1", "pretty-bytes": "^6.1.1", "quill": "^2.0.2", diff --git a/frontend/src/assets/locales/en/finalSupplyEquipment.json b/frontend/src/assets/locales/en/finalSupplyEquipment.json index d48862f59..d8ed2de94 100644 --- a/frontend/src/assets/locales/en/finalSupplyEquipment.json +++ b/frontend/src/assets/locales/en/finalSupplyEquipment.json @@ -2,6 +2,7 @@ "fseTitle": "Final supply equipment (FSE)", "addFSErowsTitle": "Add new final supply equipment(s) (FSE)", "fseSubtitle": "Report dates of supply for your FSE. If your billing location is different from your equipment location provided below use the Notes field. Use the Notes field if you use any Other options.", + "reportingResponsibilityInfo": "If you are reporting on behalf of an FSE for which you hold allocated reporting responsibility, please list the utility account holder's organization name associated with the specific station, rather than your own organization's name.", "newFinalSupplyEquipmentBtn": "New final supply equipment", "noFinalSupplyEquipmentsFound": "No final supply equipments found", "finalSupplyEquipmentDownloadBtn": "Download as Excel", diff --git a/frontend/src/assets/locales/en/fuelExport.json b/frontend/src/assets/locales/en/fuelExport.json index d5cf55dc6..3050cb2da 100644 --- a/frontend/src/assets/locales/en/fuelExport.json +++ b/frontend/src/assets/locales/en/fuelExport.json @@ -12,10 +12,10 @@ "fuelExportColLabels": { "complianceReportId": "Compliance Report ID", "fuelExportId": "Fuel export ID", - "fuelTypeId": "Fuel type", + "fuelType": "Fuel type", "exportDate": "Export date", "fuelTypeOther": "Fuel type other", - "fuelCategoryId": "Fuel catgory", + "fuelCategory": "Fuel catgory", "endUse": "End use", "provisionOfTheActId": "Determining carbon intensity", "fuelCode": "Fuel code", diff --git a/frontend/src/components/BCDataGrid/BCGridEditor.jsx b/frontend/src/components/BCDataGrid/BCGridEditor.jsx index cdc02be64..03c9b4649 100644 --- a/frontend/src/components/BCDataGrid/BCGridEditor.jsx +++ b/frontend/src/components/BCDataGrid/BCGridEditor.jsx @@ -27,6 +27,7 @@ import { BCAlert2 } from '@/components/BCAlert' * @property {React.Ref} gridRef * @property {Function} handlePaste * @property {Function} onAction + * @property {Function} onAddRows * * @param {BCGridEditorProps & GridOptions} props * @returns {JSX.Element} @@ -44,6 +45,7 @@ export const BCGridEditor = ({ saveButtonProps = { enabled: false }, + onAddRows, ...props }) => { const localRef = useRef(null) @@ -59,32 +61,36 @@ export const BCGridEditor = ({ if (!firstEditableColumnRef.current) { const columns = ref.current.api.getAllDisplayedColumns() - firstEditableColumnRef.current = columns.find(col => - col.colDef.editable !== false && - !['action', 'checkbox'].includes(col.colDef.field) + firstEditableColumnRef.current = columns.find( + (col) => + col.colDef.editable !== false && + !['action', 'checkbox'].includes(col.colDef.field) ) } return firstEditableColumnRef.current }, []) // Helper function to start editing first editable cell in a row - const startEditingFirstEditableCell = useCallback((rowIndex) => { - if (!ref.current?.api) return + const startEditingFirstEditableCell = useCallback( + (rowIndex) => { + if (!ref.current?.api) return - // Ensure we have the first editable column - const firstEditableColumn = findFirstEditableColumn() - if (!firstEditableColumn) return + // Ensure we have the first editable column + const firstEditableColumn = findFirstEditableColumn() + if (!firstEditableColumn) return - // Use setTimeout to ensure the grid is ready - setTimeout(() => { - ref.current.api.ensureIndexVisible(rowIndex) - ref.current.api.setFocusedCell(rowIndex, firstEditableColumn.getColId()) - ref.current.api.startEditingCell({ - rowIndex, - colKey: firstEditableColumn.getColId() - }) - }, 100) - }, [findFirstEditableColumn]) + // Use setTimeout to ensure the grid is ready + setTimeout(() => { + ref.current.api.ensureIndexVisible(rowIndex) + ref.current.api.setFocusedCell(rowIndex, firstEditableColumn.getColId()) + ref.current.api.startEditingCell({ + rowIndex, + colKey: firstEditableColumn.getColId() + }) + }, 100) + }, + [findFirstEditableColumn] + ) const handleExcelPaste = useCallback( (params) => { @@ -175,11 +181,18 @@ export const BCGridEditor = ({ params.event.target.dataset.action && onAction ) { - const transaction = await onAction(params.event.target.dataset.action, params) - // Focus and edit the first editable column of the duplicated row - if (transaction?.add.length > 0) { - const duplicatedRowNode = transaction.add[0] - startEditingFirstEditableCell(duplicatedRowNode.rowIndex) + const action = params.event.target.dataset.action + const transaction = await onAction(action, params) + + // Apply the transaction if it exists + if (transaction?.add?.length > 0) { + const res = ref.current.api.applyTransaction(transaction) + + // Focus and edit the first editable column of the added rows + if (res.add && res.add.length > 0) { + const firstNewRow = res.add[0] + startEditingFirstEditableCell(firstNewRow.rowIndex) + } } } } @@ -192,28 +205,45 @@ export const BCGridEditor = ({ setAnchorEl(null) } - const handleAddRows = useCallback((numRows) => { - let newRows = [] - if (props.onAddRows) { - newRows = props.onAddRows(numRows) - } else { - newRows = Array(numRows) - .fill() - .map(() => ({ id: uuid() })) - } + const handleAddRowsInternal = useCallback( + async (numRows) => { + let newRows = [] - // Add the new rows - ref.current.api.applyTransaction({ - add: newRows, - addIndex: ref.current.api.getDisplayedRowCount() - }) + if (onAction) { + try { + for (let i = 0; i < numRows; i++) { + const transaction = await onAction('add') + if (transaction?.add?.length > 0) { + newRows = [...newRows, ...transaction.add] + } + } + } catch (error) { + console.error('Error during onAction add:', error) + } + } - // Focus and start editing the first new row - const firstNewRowIndex = ref.current.api.getDisplayedRowCount() - numRows - startEditingFirstEditableCell(firstNewRowIndex) + // Default logic if onAction doesn't return rows + if (newRows.length === 0) { + newRows = Array(numRows) + .fill() + .map(() => ({ id: uuid() })) + } - setAnchorEl(null) - }, [props.onAddRows, startEditingFirstEditableCell]) + // Apply the new rows to the grid + const result = ref.current.api.applyTransaction({ + add: newRows, + addIndex: ref.current.api.getDisplayedRowCount() + }) + + // Focus the first editable cell in the first new row + if (result.add && result.add.length > 0) { + startEditingFirstEditableCell(result.add[0].rowIndex) + } + + setAnchorEl(null) + }, + [onAction, startEditingFirstEditableCell] + ) const isGridValid = () => { let isValid = true @@ -238,24 +268,25 @@ export const BCGridEditor = ({ setShowCloseModal(true) } const hasRequiredHeaderComponent = useCallback(() => { - const columnDefs = ref.current?.api?.getColumnDefs() || []; + const columnDefs = ref.current?.api?.getColumnDefs() || [] // Check if any column has `headerComponent` matching "RequiredHeader" - return columnDefs.some( - colDef => colDef.headerComponent?.name === 'RequiredHeader' - ) || columnDefs.some(colDef => !!colDef.headerComponent) + return ( + columnDefs.some( + (colDef) => colDef.headerComponent?.name === 'RequiredHeader' + ) || columnDefs.some((colDef) => !!colDef.headerComponent) + ) }, [ref]) - return ( - {hasRequiredHeaderComponent() && + {hasRequiredHeaderComponent() && ( - } + )} ) } - onClick={addMultiRow ? handleAddRowsClick : () => handleAddRows(1)} + onClick={ + addMultiRow ? handleAddRowsClick : () => handleAddRowsInternal(1) + } > Add row @@ -301,9 +334,15 @@ export const BCGridEditor = ({ } }} > - handleAddRows(1)}>1 row - handleAddRows(5)}>5 rows - handleAddRows(10)}>10 rows + handleAddRowsInternal(1)}> + 1 row + + handleAddRowsInternal(5)}> + 5 rows + + handleAddRowsInternal(10)}> + 10 rows + )} @@ -345,8 +384,16 @@ BCGridEditor.propTypes = { alertRef: PropTypes.shape({ current: PropTypes.any }), handlePaste: PropTypes.func, onAction: PropTypes.func, + onAddRows: PropTypes.func, onRowEditingStopped: PropTypes.func, onCellValueChanged: PropTypes.func, showAddRowsButton: PropTypes.bool, - onAddRows: PropTypes.func + addMultiRow: PropTypes.bool, + saveButtonProps: PropTypes.shape({ + enabled: PropTypes.bool, + text: PropTypes.string, + onSave: PropTypes.func, + confirmText: PropTypes.string, + confirmLabel: PropTypes.string + }) } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index acb541131..509ac3e8e 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -8,6 +8,7 @@ import { KeycloakProvider } from '@/components/KeycloakProvider' import { getKeycloak } from '@/utils/keycloak' import { LocalizationProvider } from '@mui/x-date-pickers' import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3' +import { SnackbarProvider } from 'notistack' const queryClient = new QueryClient() const keycloak = getKeycloak() @@ -20,8 +21,10 @@ if (root) { - - + + + + diff --git a/frontend/src/services/useApiService.js b/frontend/src/services/useApiService.js index a8525e2ae..7b4fdca4b 100644 --- a/frontend/src/services/useApiService.js +++ b/frontend/src/services/useApiService.js @@ -2,9 +2,11 @@ import { useMemo } from 'react' import axios from 'axios' import { useKeycloak } from '@react-keycloak/web' import { CONFIG } from '@/constants/config' +import { useSnackbar } from 'notistack' export const useApiService = (opts = {}) => { const { keycloak } = useKeycloak() + const { enqueueSnackbar } = useSnackbar() // useMemo to memoize the apiService instance const apiService = useMemo(() => { @@ -25,6 +27,24 @@ export const useApiService = (opts = {}) => { } ) + // Add response interceptor + instance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status >= 400) { + console.error( + 'API Error:', + error.response.status, + error.response.data + ) + enqueueSnackbar(`${error.response.status} error`, { + autoHideDuration: 5000, + variant: 'error' + }) + } + } + ) + // Download method instance.download = async (url, params = {}) => { try { diff --git a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx index 6a79fd45e..779d430e5 100644 --- a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx +++ b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx @@ -15,6 +15,7 @@ import { useGetAllocationAgreements, useSaveAllocationAgreement } from '@/hooks/useAllocationAgreement' +import { useCurrentUser } from '@/hooks/useCurrentUser' import { v4 as uuid } from 'uuid' import * as ROUTES from '@/constants/routes/routes.js' import { DEFAULT_CI_FUEL } from '@/constants/common' @@ -31,6 +32,7 @@ export const AddEditAllocationAgreements = () => { const params = useParams() const { complianceReportId, compliancePeriod } = params const navigate = useNavigate() + const { data: currentUser } = useCurrentUser() const { data: optionsData, @@ -117,9 +119,9 @@ export const AddEditAllocationAgreements = () => { ) useEffect(() => { - const updatedColumnDefs = allocationAgreementColDefs(optionsData, errors) + const updatedColumnDefs = allocationAgreementColDefs(optionsData, errors, currentUser) setColumnDefs(updatedColumnDefs) - }, [errors, optionsData]) + }, [errors, optionsData, currentUser]) useEffect(() => { if ( @@ -173,6 +175,18 @@ export const AddEditAllocationAgreements = () => { async (params) => { if (params.oldValue === params.newValue) return + // User cannot select their own organization as the transaction partner + if (params.colDef.field === 'transactionPartner') { + if (params.newValue === currentUser.organization.name) { + alertRef.current?.triggerAlert({ + message: 'You cannot select your own organization as the transaction partner.', + severity: 'error' + }) + params.node.setDataValue('transactionPartner', '') + return + } + } + const isValid = validate( params, (value) => { diff --git a/frontend/src/views/AllocationAgreements/_schema.jsx b/frontend/src/views/AllocationAgreements/_schema.jsx index e9d6e29d6..26ef36700 100644 --- a/frontend/src/views/AllocationAgreements/_schema.jsx +++ b/frontend/src/views/AllocationAgreements/_schema.jsx @@ -21,7 +21,7 @@ import { export const PROVISION_APPROVED_FUEL_CODE = 'Fuel code - section 19 (b) (i)' -export const allocationAgreementColDefs = (optionsData, errors) => [ +export const allocationAgreementColDefs = (optionsData, errors, currentUser) => [ validation, actions({ enableDuplicate: false, @@ -89,8 +89,11 @@ export const allocationAgreementColDefs = (optionsData, errors) => [ let path = apiRoutes.organizationSearch path += 'org_name=' + queryKey[1] const response = await client.get(path) - params.node.data.apiDataCache = response.data - return response.data + const filteredData = response.data.filter( + (org) => org.name !== currentUser.organization.name + ) + params.node.data.apiDataCache = filteredData + return filteredData }, title: 'transactionPartner', api: params.api, diff --git a/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx b/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx index 2244efea4..2e9285580 100644 --- a/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx +++ b/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx @@ -282,11 +282,19 @@ export const AddEditFinalSupplyEquipments = () => { {t('finalSupplyEquipment:fseSubtitle')} + + {t('finalSupplyEquipment:reportingResponsibilityInfo')} + { const [alertMessage, setAlertMessage] = useState('') const [alertSeverity, setAlertSeverity] = useState('info') - const [gridKey, setGridKey] = useState(`final-supply-equipments-grid`) + const [gridKey, setGridKey] = useState('final-supply-equipments-grid') const { complianceReportId, compliancePeriod } = useParams() const gridRef = useRef() @@ -72,8 +73,7 @@ export const FinalSupplyEquipmentSummary = ({ data }) => { 'finalSupplyEquipment:finalSupplyEquipmentColLabels.kwhUsage' ), field: 'kwhUsage', - valueFormatter: (params) => - params.value ? params.value.toFixed(2) : '0.00' + valueFormatter: numberFormatter }, { headerName: t( diff --git a/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx b/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx index c06a73a7b..ca05f1413 100644 --- a/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx +++ b/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx @@ -38,9 +38,15 @@ const AddEditFuelCodeBase = () => { const [columnDefs, setColumnDefs] = useState([]) const [isGridReady, setGridReady] = useState(false) const [modalData, setModalData] = useState(null) + const [initialized, setInitialized] = useState(false) const { hasRoles } = useCurrentUser() - const { data: optionsData, isLoading, isFetched } = useFuelCodeOptions() + const { + data: optionsData, + isLoading, + isFetched, + refetch: refetchOptions + } = useFuelCodeOptions() const { mutateAsync: updateFuelCode } = useUpdateFuelCode(fuelCodeID) const { mutateAsync: createFuelCode } = useCreateFuelCode() const { mutateAsync: deleteFuelCode } = useDeleteFuelCode() @@ -51,6 +57,35 @@ const AddEditFuelCodeBase = () => { refetch } = useGetFuelCode(fuelCodeID) + useEffect(() => { + // Only initialize rowData once when all data is available and the grid is ready + if (!initialized && isFetched && !isLoadingExistingCode && isGridReady) { + if (existingFuelCode) { + setRowData([existingFuelCode]) + } else { + const defaultPrefix = optionsData?.fuelCodePrefixes?.find( + (item) => item.prefix === 'BCLCF' + ) + setRowData([ + { + id: uuid(), + prefixId: defaultPrefix?.fuelCodePrefixId || 1, + prefix: defaultPrefix?.prefix || 'BCLCF', + fuelSuffix: defaultPrefix?.nextFuelCode + } + ]) + } + setInitialized(true) + } + }, [ + initialized, + isFetched, + isLoadingExistingCode, + isGridReady, + existingFuelCode, + optionsData + ]) + useEffect(() => { if (optionsData) { const updatedColumnDefs = fuelCodeColDefs( @@ -66,7 +101,16 @@ const AddEditFuelCodeBase = () => { useEffect(() => { if (existingFuelCode) { - setRowData([existingFuelCode]) + const transformedData = { + ...existingFuelCode, + feedstockFuelTransportMode: existingFuelCode.feedstockFuelTransportModes.map( + (mode) => mode.feedstockFuelTransportMode.transportMode + ), + finishedFuelTransportMode: existingFuelCode.finishedFuelTransportModes.map( + (mode) => mode.finishedFuelTransportMode.transportMode + ) + } + setRowData([transformedData]) } else { setRowData([ { @@ -100,7 +144,7 @@ const AddEditFuelCodeBase = () => { if (params.colDef.field === 'prefix') { updatedData.fuelSuffix = optionsData?.fuelCodePrefixes?.find( (item) => item.prefix === params.newValue - ).nextFuelCode + )?.nextFuelCode } params.api.applyTransaction({ update: [updatedData] }) @@ -213,8 +257,8 @@ const AddEditFuelCodeBase = () => { } catch (error) { setErrors({ [params.node.data.id]: - error.response.data?.errors && - error.response.data?.errors[0]?.fields + error.response?.data?.errors && + error.response.data.errors[0]?.fields }) updatedData = { @@ -244,7 +288,7 @@ const AddEditFuelCodeBase = () => { params.node.updateData(updatedData) }, - [updateFuelCode, t] + [updateFuelCode, t, createFuelCode] ) const handlePaste = useCallback( @@ -311,8 +355,16 @@ const AddEditFuelCodeBase = () => { ) const duplicateFuelCode = async (params) => { - const rowData = { - ...params.data, + const originalData = params.data + const originalPrefix = originalData.prefix || 'BCLCF' + + const updatedOptions = await refetchOptions() + const selectedPrefix = updatedOptions.data.fuelCodePrefixes?.find( + (p) => p.prefix === originalPrefix + ) + + const newRow = { + ...originalData, id: uuid(), fuelCodeId: null, modified: true, @@ -320,10 +372,18 @@ const AddEditFuelCodeBase = () => { validationStatus: 'error', validationMsg: 'Fill in the missing fields' } + + if (selectedPrefix) { + newRow.prefixId = selectedPrefix.fuelCodePrefixId + newRow.prefix = selectedPrefix.prefix + newRow.fuelSuffix = selectedPrefix.nextFuelCode + } + if (params.api) { - if (params.data.fuelCodeId) { + if (originalData.fuelCodeId) { try { - const response = await updateFuelCode(rowData) + // If the original was a saved row, create a new code in the backend + const response = await createFuelCode(newRow) const updatedData = { ...response.data, id: uuid(), @@ -331,23 +391,13 @@ const AddEditFuelCodeBase = () => { isValid: false, validationStatus: 'error' } - params.api.applyTransaction({ - add: [updatedData], - addIndex: params.node?.rowIndex + 1 - }) - params.api.refreshCells() - alertRef.current?.triggerAlert({ - message: 'Row duplicated successfully.', - severity: 'success' - }) + return { add: [updatedData] } } catch (error) { handleError(error, `Error duplicating row: ${error.message}`) } } else { - params.api.applyTransaction({ - add: [rowData], - addIndex: params.node?.rowIndex + 1 - }) + // If the original row wasn’t saved, just return the transaction + return { add: [newRow] } } } } @@ -359,12 +409,31 @@ const AddEditFuelCodeBase = () => { const onAction = useCallback( async (action, params) => { if (action === 'duplicate') { - await duplicateFuelCode(params) + return await duplicateFuelCode(params) } else if (action === 'delete') { await openDeleteModal(params.data.fuelCodeId, params) + } else if (action === 'add') { + // Refetch options to get updated nextFuelCode + const updatedOptions = await refetchOptions() + const defaultPrefix = updatedOptions.data.fuelCodePrefixes.find( + (item) => item.prefix === 'BCLCF' + ) + + const newRow = { + id: uuid(), + prefixId: defaultPrefix.fuelCodePrefixId, + prefix: defaultPrefix.prefix, + fuelSuffix: defaultPrefix.nextFuelCode, + modified: true, + validationStatus: 'error', + validationMsg: 'Fill in missing fields' + } + + // Return a transaction (no resetting rowData) + return { add: [newRow] } } }, - [updateFuelCode, deleteFuelCode] + [duplicateFuelCode, refetchOptions] ) if (isLoading || isLoadingExistingCode) { @@ -390,7 +459,7 @@ const AddEditFuelCodeBase = () => { columnDefs={columnDefs} defaultColDef={defaultColDef} onGridReady={onGridReady} - rowData={rowData} + rowData={rowData} // Only set once, do not update again onCellValueChanged={onCellValueChanged} onCellEditingStopped={onCellEditingStopped} onAction={onAction} diff --git a/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx b/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx index 66d033af6..8e335ba2e 100644 --- a/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx +++ b/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx @@ -97,20 +97,13 @@ export const fuelCodeColDefs = (optionsData, errors, isCreate, canEdit) => [ const selectedPrefix = optionsData?.fuelCodePrefixes?.find( (obj) => obj.prefix === params.newValue ) - params.data.fuelCodePrefixId = selectedPrefix.fuelCodePrefixId + if (selectedPrefix) { + params.data.prefixId = selectedPrefix.fuelCodePrefixId + params.data.fuelCodePrefixId = selectedPrefix.fuelCodePrefixId + params.data.fuelCodePrefix = selectedPrefix.fuelCodePrefix - params.data.fuelSuffix = optionsData?.fuelCodePrefixes?.find( - (obj) => obj.prefix === params.newValue - )?.nextFuelCode - params.data.company = undefined - params.data.fuel = undefined - params.data.feedstock = undefined - params.data.feedstockLocation = undefined - params.data.feedstockFuelTransportMode = [] - params.data.finishedFuelTransportMode = [] - params.data.formerCompany = undefined - params.data.contactName = undefined - params.data.contactEmail = undefined + params.data.fuelSuffix = selectedPrefix.nextFuelCode + } } return true }, diff --git a/frontend/src/views/FuelExports/_schema.jsx b/frontend/src/views/FuelExports/_schema.jsx index d826fa567..60656d905 100644 --- a/frontend/src/views/FuelExports/_schema.jsx +++ b/frontend/src/views/FuelExports/_schema.jsx @@ -113,7 +113,7 @@ export const fuelExportColDefs = (optionsData, errors, gridReady) => [ { field: 'fuelType', headerComponent: RequiredHeader, - headerName: i18n.t('fuelExport:fuelExportColLabels.fuelTypeId'), + headerName: i18n.t('fuelExport:fuelExportColLabels.fuelType'), cellEditor: AutocompleteCellEditor, cellRenderer: (params) => params.value || @@ -182,7 +182,7 @@ export const fuelExportColDefs = (optionsData, errors, gridReady) => [ { field: 'fuelCategory', headerComponent: RequiredHeader, - headerName: i18n.t('fuelExport:fuelExportColLabels.fuelCategoryId'), + headerName: i18n.t('fuelExport:fuelExportColLabels.fuelCategory'), cellEditor: AutocompleteCellEditor, cellRenderer: (params) => params.value || diff --git a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx index 5d8073495..812192fa9 100644 --- a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx +++ b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx @@ -12,6 +12,7 @@ import { useGetAllNotionalTransfers, useSaveNotionalTransfer } from '@/hooks/useNotionalTransfer' +import { useCurrentUser } from '@/hooks/useCurrentUser' import { v4 as uuid } from 'uuid' import { BCGridEditor } from '@/components/BCDataGrid/BCGridEditor' import { useApiService } from '@/services/useApiService' @@ -37,6 +38,7 @@ export const AddEditNotionalTransfers = () => { useGetAllNotionalTransfers(complianceReportId) const { mutateAsync: saveRow } = useSaveNotionalTransfer() const navigate = useNavigate() + const { data: currentUser } = useCurrentUser() useEffect(() => { if (location?.state?.message) { @@ -115,6 +117,18 @@ export const AddEditNotionalTransfers = () => { async (params) => { if (params.oldValue === params.newValue) return + // User cannot select their own organization as the transaction partner + if (params.colDef.field === 'legalName') { + if (params.newValue === currentUser.organization.name) { + alertRef.current?.triggerAlert({ + message: 'You cannot select your own organization as the transaction partner.', + severity: 'error' + }) + params.node.setDataValue('legalName', '') + return + } + } + const isValid = validate( params, (value) => { @@ -223,10 +237,10 @@ export const AddEditNotionalTransfers = () => { useEffect(() => { if (!optionsLoading) { - const updatedColumnDefs = notionalTransferColDefs(optionsData, errors) + const updatedColumnDefs = notionalTransferColDefs(optionsData, errors, currentUser) setColumnDefs(updatedColumnDefs) } - }, [errors, optionsData, optionsLoading]) + }, [errors, optionsData, optionsLoading, currentUser]) const handleNavigateBack = useCallback(() => { navigate( diff --git a/frontend/src/views/NotionalTransfers/_schema.jsx b/frontend/src/views/NotionalTransfers/_schema.jsx index 6064debe4..a7a92353f 100644 --- a/frontend/src/views/NotionalTransfers/_schema.jsx +++ b/frontend/src/views/NotionalTransfers/_schema.jsx @@ -12,7 +12,7 @@ import { formatNumberWithCommas as valueFormatter } from '@/utils/formatters' import { apiRoutes } from '@/constants/routes' import { StandardCellErrors } from '@/utils/grid/errorRenderers' -export const notionalTransferColDefs = (optionsData, errors) => [ +export const notionalTransferColDefs = (optionsData, errors, currentUser) => [ validation, actions({ enableDuplicate: false, @@ -45,8 +45,11 @@ export const notionalTransferColDefs = (optionsData, errors) => [ let path = apiRoutes.organizationSearch path += 'org_name=' + queryKey[1] const response = await client.get(path) - params.node.data.apiDataCache = response.data - return response.data + const filteredData = response.data.filter( + (org) => org.name !== currentUser.organization.name + ) + params.node.data.apiDataCache = filteredData + return filteredData }, title: 'legalName', api: params.api diff --git a/frontend/src/views/Transfers/AddEditViewTransfer.jsx b/frontend/src/views/Transfers/AddEditViewTransfer.jsx index 2968b16a8..4dfb34c09 100644 --- a/frontend/src/views/Transfers/AddEditViewTransfer.jsx +++ b/frontend/src/views/Transfers/AddEditViewTransfer.jsx @@ -7,7 +7,7 @@ import { useNavigate, useParams } from 'react-router-dom' -import { roles } from '@/constants/roles' +import { roles, govRoles } from '@/constants/roles' import { ROUTES } from '@/constants/routes' import { TRANSACTIONS } from '@/constants/routes/routes' import { TRANSFER_STATUSES } from '@/constants/statuses' @@ -47,6 +47,7 @@ import { buttonClusterConfigFn } from './buttonConfigs' import { CategoryCheckbox } from './components/CategoryCheckbox' import { Recommendation } from './components/Recommendation' import SigningAuthority from './components/SigningAuthority' +import InternalComments from '@/components/InternalComments' export const AddEditViewTransfer = () => { const queryClient = useQueryClient() @@ -444,6 +445,20 @@ export const AddEditViewTransfer = () => { )} + {/* Internal Comments */} + {!editorMode && ( + + {transferId && ( + + + + )} + + )} + {/* Signing Authority Confirmation show it to FromOrg user when in draft and ToOrg when in Sent status */} {(!currentStatus || (currentStatus === TRANSFER_STATUSES.DRAFT && diff --git a/frontend/src/views/Transfers/components/TransferView.jsx b/frontend/src/views/Transfers/components/TransferView.jsx index cd837d0e8..b44013d8f 100644 --- a/frontend/src/views/Transfers/components/TransferView.jsx +++ b/frontend/src/views/Transfers/components/TransferView.jsx @@ -1,7 +1,6 @@ import BCBox from '@/components/BCBox' -import InternalComments from '@/components/InternalComments' -import { Role } from '@/components/Role' -import { roles, govRoles } from '@/constants/roles' + +import { roles } from '@/constants/roles' import { TRANSFER_STATUSES, getAllTerminalTransferStatuses @@ -89,13 +88,6 @@ export const TransferView = ({ transferId, editorMode, transferData }) => { /> )} - {/* Internal Comments */} - - {transferId && ( - - )} - - {/* List of attachments */} {/* */}