diff --git a/backend/lcfs/db/migrations/env.py b/backend/lcfs/db/migrations/env.py index e28e674e0..9926b50eb 100644 --- a/backend/lcfs/db/migrations/env.py +++ b/backend/lcfs/db/migrations/env.py @@ -34,6 +34,7 @@ "mv_org_compliance_report_count", "transaction_status_view", "mv_compliance_report_count", + "mv_fuel_code_count", ] diff --git a/backend/lcfs/db/migrations/versions/2025-01-14-23-47_8119d12538df.py b/backend/lcfs/db/migrations/versions/2025-01-14-23-47_8119d12538df.py new file mode 100644 index 000000000..2a5a46a06 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2025-01-14-23-47_8119d12538df.py @@ -0,0 +1,75 @@ +"""mv for fuel code count + +Revision ID: 8119d12538df +Revises: d25e7c47659e +Create Date: 2025-01-14 23:47:28.504150 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8119d12538df" +down_revision = "fe03799b4018" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.execute( + """ + CREATE MATERIALIZED VIEW mv_fuel_code_count AS + SELECT + CASE fuel_status_id + WHEN 1 THEN 'Draft' + END as status, + COUNT(*) as count + FROM fuel_code + WHERE fuel_status_id = 1 + GROUP BY fuel_status_id; + """ + ) + + op.execute( + """ + CREATE UNIQUE INDEX mv_fuel_code_count_idx + ON mv_fuel_code_count (status); + """ + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION refresh_mv_fuel_code_count() + RETURNS TRIGGER AS $$ + BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY mv_fuel_code_count; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + """ + ) + + op.execute( + """ + CREATE TRIGGER refresh_mv_fuel_code_count_after_change + AFTER INSERT OR UPDATE OR DELETE ON fuel_code + FOR EACH STATEMENT EXECUTE FUNCTION refresh_mv_fuel_code_count(); + """ + ) + + # Refresh the materialized view to include existing fuel codes + op.execute( + "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_fuel_code_count;" + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.execute( + "DROP TRIGGER IF EXISTS refresh_mv_fuel_code_count_after_change ON fuel_code;") + op.execute("DROP FUNCTION IF EXISTS refresh_mv_fuel_code_count();") + op.execute("DROP MATERIALIZED VIEW IF EXISTS mv_fuel_code_count;") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/fuel/FuelCodeCountView.py b/backend/lcfs/db/models/fuel/FuelCodeCountView.py new file mode 100644 index 000000000..711650769 --- /dev/null +++ b/backend/lcfs/db/models/fuel/FuelCodeCountView.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String +from lcfs.db.base import BaseModel + + +class FuelCodeCountView(BaseModel): + __tablename__ = "mv_fuel_code_count" + __table_args__ = { + "extend_existing": True, + "comment": "Materialized view for counting fuel code by status", + } + + status = Column( + String, + primary_key=True, + comment="Status name (e.g. draft, approved, deleted)" + ) + count = Column( + Integer, + comment="Count of fuel code for this status" + ) diff --git a/backend/lcfs/db/seeders/dev/fuel_code_seeder.py b/backend/lcfs/db/seeders/dev/fuel_code_seeder.py index 059fda5ad..502de1139 100644 --- a/backend/lcfs/db/seeders/dev/fuel_code_seeder.py +++ b/backend/lcfs/db/seeders/dev/fuel_code_seeder.py @@ -34,9 +34,11 @@ def create_fuel_entry( effective_date, expiration_date, fuel_type_id, + fuel_status_id=2, ): return { **base_fuel_data, # Extend with the base fields + "fuel_status_id": fuel_status_id, "fuel_suffix": fuel_suffix, "company": company, "carbon_intensity": carbon_intensity, @@ -49,6 +51,7 @@ def create_fuel_entry( async def seed_fuel_codes(session): fuel_codes_to_seed = [ create_fuel_entry( + fuel_status_id=1, fuel_suffix="102.5", company="Neste Oil Singapore", carbon_intensity=37.21, @@ -57,6 +60,7 @@ async def seed_fuel_codes(session): fuel_type_id=5, ), create_fuel_entry( + fuel_status_id=1, fuel_suffix="124.4", company="Ag Processing Inc.", carbon_intensity=3.62, @@ -65,6 +69,7 @@ async def seed_fuel_codes(session): fuel_type_id=1, ), create_fuel_entry( + fuel_status_id=1, fuel_suffix="125.4", company="Archer Daniels Midland", carbon_intensity=-2.14, @@ -73,6 +78,7 @@ async def seed_fuel_codes(session): fuel_type_id=1, ), create_fuel_entry( + fuel_status_id=3, fuel_suffix="138.5", company="ADM Agri-Industries Company", carbon_intensity=4.26, @@ -81,6 +87,7 @@ async def seed_fuel_codes(session): fuel_type_id=1, ), create_fuel_entry( + fuel_status_id=3, fuel_suffix="143.4", company="Green Plains Otter Tail LLC", carbon_intensity=44.06, @@ -89,6 +96,7 @@ async def seed_fuel_codes(session): fuel_type_id=4, ), create_fuel_entry( + fuel_status_id=3, fuel_suffix="251.2", company="Incobrasa Industries, Ltd.", carbon_intensity=0.35, diff --git a/backend/lcfs/web/api/dashboard/repo.py b/backend/lcfs/web/api/dashboard/repo.py index aff0f6634..30f838c68 100644 --- a/backend/lcfs/web/api/dashboard/repo.py +++ b/backend/lcfs/web/api/dashboard/repo.py @@ -15,6 +15,7 @@ from lcfs.db.models.compliance.ComplianceReportCountView import ( ComplianceReportCountView, ) +from lcfs.db.models.fuel.FuelCodeCountView import FuelCodeCountView logger = structlog.get_logger(__name__) @@ -89,3 +90,16 @@ async def get_compliance_report_counts(self): return { "pending_reviews": row.pending_reviews } + + @repo_handler + async def get_fuel_code_counts(self): + query = select( + FuelCodeCountView.count + ).where(FuelCodeCountView.status == "Draft") + + result = await self.db.execute(query) + row = result.fetchone() + + return { + "draft_fuel_codes": getattr(row, "count", 0) + } diff --git a/backend/lcfs/web/api/dashboard/schema.py b/backend/lcfs/web/api/dashboard/schema.py index bb0d803d2..65018cd90 100644 --- a/backend/lcfs/web/api/dashboard/schema.py +++ b/backend/lcfs/web/api/dashboard/schema.py @@ -26,3 +26,7 @@ class OrgComplianceReportCountsSchema(BaseSchema): class ComplianceReportCountsSchema(BaseSchema): pending_reviews: int = Field(default=0) + + +class FuelCodeCountsSchema(BaseSchema): + draft_fuel_codes: int = Field(default=0) diff --git a/backend/lcfs/web/api/dashboard/services.py b/backend/lcfs/web/api/dashboard/services.py index 6e433f9b4..ff965460a 100644 --- a/backend/lcfs/web/api/dashboard/services.py +++ b/backend/lcfs/web/api/dashboard/services.py @@ -7,7 +7,8 @@ TransactionCountsSchema, OrganizarionTransactionCountsSchema, OrgComplianceReportCountsSchema, - ComplianceReportCountsSchema + ComplianceReportCountsSchema, + FuelCodeCountsSchema ) logger = structlog.get_logger(__name__) @@ -66,3 +67,13 @@ async def get_compliance_report_counts( return ComplianceReportCountsSchema( pending_reviews=counts.get("pending_reviews", 0) ) + + @service_handler + async def get_fuel_code_counts( + self + ) -> FuelCodeCountsSchema: + counts = await self.repo.get_fuel_code_counts() + + return FuelCodeCountsSchema( + draft_fuel_codes=counts.get("draft_fuel_codes", 0) + ) diff --git a/backend/lcfs/web/api/dashboard/views.py b/backend/lcfs/web/api/dashboard/views.py index cabde7d16..cfcd8003b 100644 --- a/backend/lcfs/web/api/dashboard/views.py +++ b/backend/lcfs/web/api/dashboard/views.py @@ -8,7 +8,8 @@ TransactionCountsSchema, OrganizarionTransactionCountsSchema, OrgComplianceReportCountsSchema, - ComplianceReportCountsSchema + ComplianceReportCountsSchema, + FuelCodeCountsSchema ) from lcfs.db.models.user.Role import RoleEnum @@ -73,3 +74,17 @@ async def get_compliance_report_counts( ): """Endpoint to retrieve count of compliance reports pending review""" return await service.get_compliance_report_counts() + + +@router.get( + "/fuel-code-counts", + response_model=FuelCodeCountsSchema +) +@view_handler([RoleEnum.ANALYST]) +async def get_fuel_code_counts( + request: Request, + service: DashboardServices = Depends(), +): + """Endpoint to retrieve count of compliance reports pending review""" + + return await service.get_fuel_code_counts() diff --git a/backend/lcfs/web/api/fuel_code/repo.py b/backend/lcfs/web/api/fuel_code/repo.py index d05318569..9b4af7b5b 100644 --- a/backend/lcfs/web/api/fuel_code/repo.py +++ b/backend/lcfs/web/api/fuel_code/repo.py @@ -202,7 +202,8 @@ async def get_transport_mode(self, transport_mode_id: int) -> TransportMode: @repo_handler async def get_transport_mode_by_name(self, mode_name: str) -> TransportMode: - query = select(TransportMode).where(TransportMode.transport_mode == mode_name) + query = select(TransportMode).where( + TransportMode.transport_mode == mode_name) result = await self.db.execute(query) transport_mode = result.scalar_one() @@ -247,7 +248,8 @@ async def get_energy_densities(self) -> List[EnergyDensity]: async def get_energy_density(self, fuel_type_id) -> EnergyDensity: """Get the energy density for the specified fuel_type_id""" - stmt = select(EnergyDensity).where(EnergyDensity.fuel_type_id == fuel_type_id) + stmt = select(EnergyDensity).where( + EnergyDensity.fuel_type_id == fuel_type_id) result = await self.db.execute(stmt) energy_density = result.scalars().first() @@ -300,7 +302,8 @@ async def get_fuel_codes_paginated( List[FuelCodeSchema]: A list of fuel codes matching the query. """ delete_status = await self.get_fuel_status_by_status("Deleted") - conditions = [FuelCode.fuel_status_id != delete_status.fuel_code_status_id] + conditions = [FuelCode.fuel_status_id != + delete_status.fuel_code_status_id] for filter in pagination.filters: @@ -341,20 +344,27 @@ async def get_fuel_codes_paginated( field = get_field_for_filter(FuelCode, filter.field) conditions.append( - apply_filter_conditions(field, filter_value, filter_option, filter_type) + apply_filter_conditions( + field, filter_value, filter_option, filter_type) ) # setup pagination - offset = 0 if (pagination.page < 1) else (pagination.page - 1) * pagination.size + offset = 0 if (pagination.page < 1) else ( + pagination.page - 1) * pagination.size limit = pagination.size # Construct the select query with options for eager loading query = ( select(FuelCode) + .join(FuelCode.fuel_code_status) # Add explicit join for status + .join(FuelCode.fuel_code_prefix) # Add explicit join for prefix + .join(FuelCode.fuel_type) # Add explicit join for fuel type .options( - joinedload(FuelCode.fuel_code_status), - joinedload(FuelCode.fuel_code_prefix), - joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_1), - joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_2), + contains_eager(FuelCode.fuel_code_status), + contains_eager(FuelCode.fuel_code_prefix), + contains_eager(FuelCode.fuel_type).joinedload( + FuelType.provision_1), + contains_eager(FuelCode.fuel_type).joinedload( + FuelType.provision_2), joinedload(FuelCode.feedstock_fuel_transport_modes).joinedload( FeedstockFuelTransportMode.feedstock_fuel_transport_mode ), @@ -382,7 +392,8 @@ async def get_fuel_codes_paginated( # Execute the main query to retrieve all fuel codes result = await self.db.execute( - query.offset(offset).limit(limit).order_by(FuelCode.create_date.desc()) + query.offset(offset).limit(limit).order_by( + FuelCode.create_date.desc()) ) fuel_codes = result.unique().scalars().all() return fuel_codes, total_count @@ -417,8 +428,10 @@ async def get_fuel_code(self, fuel_code_id: int) -> FuelCode: joinedload(FuelCode.finished_fuel_transport_modes).joinedload( FinishedFuelTransportMode.finished_fuel_transport_mode ), - joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_1), - joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_2), + joinedload(FuelCode.fuel_type).joinedload( + FuelType.provision_1), + joinedload(FuelCode.fuel_type).joinedload( + FuelType.provision_2), ) .where(FuelCode.fuel_code_id == fuel_code_id) ) @@ -428,7 +441,8 @@ async def get_fuel_code_status( self, fuel_code_status: FuelCodeStatusEnum ) -> FuelCodeStatus: return await self.db.scalar( - select(FuelCodeStatus).where(FuelCodeStatus.status == fuel_code_status) + select(FuelCodeStatus).where( + FuelCodeStatus.status == fuel_code_status) ) @repo_handler @@ -486,7 +500,8 @@ async def get_contact_email_by_company_and_name( .where( and_( func.lower(FuelCode.company) == func.lower(company), - func.lower(FuelCode.contact_name) == func.lower(contact_name), + func.lower(FuelCode.contact_name) == func.lower( + contact_name), ), func.lower(FuelCode.contact_email).like( func.lower(contact_email + "%") @@ -528,8 +543,10 @@ async def get_fuel_code_by_code_prefix( .options( joinedload(FuelCode.fuel_code_status), joinedload(FuelCode.fuel_code_prefix), - joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_1), - joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_2), + joinedload(FuelCode.fuel_type).joinedload( + FuelType.provision_1), + joinedload(FuelCode.fuel_type).joinedload( + FuelType.provision_2), joinedload(FuelCode.feedstock_fuel_transport_modes).joinedload( FeedstockFuelTransportMode.feedstock_fuel_transport_mode ), @@ -670,7 +687,8 @@ async def get_next_available_sub_version_fuel_code_by_prefix( ) result = ( await self.db.execute( - query, {"input_version": int(input_version), "prefix_id": prefix_id} + query, {"input_version": int( + input_version), "prefix_id": prefix_id} ) ).scalar_one_or_none() return self.format_decimal(result) @@ -692,8 +710,10 @@ async def get_latest_fuel_codes(self) -> List[FuelCodeSchema]: joinedload(FuelCode.finished_fuel_transport_modes).joinedload( FinishedFuelTransportMode.finished_fuel_transport_mode ), - joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_1), - joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_2), + joinedload(FuelCode.fuel_type).joinedload( + FuelType.provision_1), + joinedload(FuelCode.fuel_type).joinedload( + FuelType.provision_2), ) .filter(FuelCodeStatus.status != FuelCodeStatusEnum.Deleted) ) diff --git a/frontend/src/assets/locales/en/dashboard.json b/frontend/src/assets/locales/en/dashboard.json index 66527f31e..20ce49790 100644 --- a/frontend/src/assets/locales/en/dashboard.json +++ b/frontend/src/assets/locales/en/dashboard.json @@ -26,6 +26,12 @@ "viewAllTransactions": "View all transactions", "loadingMessage": "Loading transactions card..." }, + "fuelCodes": { + "title": "Fuel Codes", + "thereAre": "There are:", + "fcInProgress": "Fuel Code(s) in progress", + "loadingMessage": "Loading fuel codes card..." + }, "complianceReports": { "title": "Compliance reports", "thereAre": "There are:", diff --git a/frontend/src/constants/routes/apiRoutes.js b/frontend/src/constants/routes/apiRoutes.js index f2cd2a5b4..cf5de482e 100644 --- a/frontend/src/constants/routes/apiRoutes.js +++ b/frontend/src/constants/routes/apiRoutes.js @@ -66,6 +66,7 @@ export const apiRoutes = { allocationAgreementSearch: '/allocation-agreement/search?', OrgComplianceReportCounts: '/dashboard/org-compliance-report-counts', complianceReportCounts: '/dashboard/compliance-report-counts', + fuelCodeCounts: '/dashboard/fuel-code-counts', organizationSearch: '/organizations/search?', getUserActivities: '/users/:userID/activity', getAllUserActivities: '/users/activities/all', diff --git a/frontend/src/hooks/useDashboard.js b/frontend/src/hooks/useDashboard.js index 6a60a94eb..aefed6278 100644 --- a/frontend/src/hooks/useDashboard.js +++ b/frontend/src/hooks/useDashboard.js @@ -70,3 +70,15 @@ export const useComplianceReportCounts = () => { } }) } +export const useFuelCodeCounts = () => { + const client = useApiService() + const path = apiRoutes.fuelCodeCounts + + return useQuery({ + queryKey: ['fuel-code-counts'], + queryFn: async () => { + const response = await client.get(path) + return response.data + } + }) +} diff --git a/frontend/src/views/Dashboard/Dashboard.jsx b/frontend/src/views/Dashboard/Dashboard.jsx index 91991a57d..16e613353 100644 --- a/frontend/src/views/Dashboard/Dashboard.jsx +++ b/frontend/src/views/Dashboard/Dashboard.jsx @@ -19,6 +19,7 @@ import { OrgUserSettingsCard } from './components/cards' import OrganizationsSummaryCard from './components/cards/idir/OrganizationsSummaryCard' +import { FuelCodeCard } from './components/cards/idir/FuelCodeCard' import { ComplianceReportCard } from './components/cards/idir/ComplianceReportCard' export const Dashboard = () => { @@ -68,10 +69,16 @@ export const Dashboard = () => { - - - + + + + + + + + + diff --git a/frontend/src/views/Dashboard/components/cards/idir/ComplianceReportCard.jsx b/frontend/src/views/Dashboard/components/cards/idir/ComplianceReportCard.jsx index 2f1b9b610..f0fb472dd 100644 --- a/frontend/src/views/Dashboard/components/cards/idir/ComplianceReportCard.jsx +++ b/frontend/src/views/Dashboard/components/cards/idir/ComplianceReportCard.jsx @@ -24,7 +24,6 @@ export const ComplianceReportCard = () => { const { t } = useTranslation(['dashboard']) const navigate = useNavigate() const { data: counts, isLoading } = useComplianceReportCounts() - console.log('ComplianceReportCard counts:', counts) const handleNavigation = () => { navigate(ROUTES.REPORTS, { diff --git a/frontend/src/views/Dashboard/components/cards/idir/FuelCodeCard.jsx b/frontend/src/views/Dashboard/components/cards/idir/FuelCodeCard.jsx new file mode 100644 index 000000000..6d6fad491 --- /dev/null +++ b/frontend/src/views/Dashboard/components/cards/idir/FuelCodeCard.jsx @@ -0,0 +1,99 @@ +import BCTypography from '@/components/BCTypography' +import BCWidgetCard from '@/components/BCWidgetCard/BCWidgetCard' +import Loading from '@/components/Loading' +import { ROUTES } from '@/constants/routes' +import { useFuelCodeCounts } from '@/hooks/useDashboard' +import { List, ListItemButton, Stack } from '@mui/material' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' + +const CountDisplay = ({ count }) => ( + + {count} + +) + +export const FuelCodeCard = () => { + const { t } = useTranslation(['dashboard']) + const navigate = useNavigate() + const { data: counts, isLoading } = useFuelCodeCounts() + + const handleNavigation = () => { + navigate(ROUTES.FUELCODES, { + state: { + filters: [ + { + field: 'status', + filter: 'Draft', + filterType: 'text', + type: 'equals' + } + ] + } + }) + } + + const renderLinkWithCount = (text, count, onClick) => { + return ( + <> + {count != null && } + + {text} + + + ) + } + + return ( + + ) : ( + + + {t('dashboard:fuelCodes.thereAre')} + + + + {renderLinkWithCount( + t('dashboard:fuelCodes.fcInProgress'), + counts?.draftFuelCodes || 0, + handleNavigation + )} + + + + ) + } + /> + ) +} diff --git a/frontend/src/views/FuelCodes/FuelCodes.jsx b/frontend/src/views/FuelCodes/FuelCodes.jsx index ca6949df4..1db1bf5d4 100644 --- a/frontend/src/views/FuelCodes/FuelCodes.jsx +++ b/frontend/src/views/FuelCodes/FuelCodes.jsx @@ -141,6 +141,7 @@ const FuelCodesBase = () => { getRowId={getRowId} overlayNoRowsTemplate={t('fuelCode:noFuelCodesFound')} defaultColDef={defaultColDef} + defaultFilterModel={location.state?.filters} onSetResetGrid={handleSetResetGrid} />