From fff034e8a9ff91bbefed8710b48eb56e833e1c8a Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 20 Feb 2025 06:01:30 -0800 Subject: [PATCH 01/16] Feat: LCFS - Allow Organization details to include International address headquarters #1911 --- .../versions/2025-02-19-04-17_c1e2d64aeea4.py | 37 + .../db/models/organization/Organization.py | 3 + backend/lcfs/web/api/organizations/schema.py | 3 + .../src/assets/locales/en/organization.json | 12 +- .../BCNavbar/components/DefaultNavbarLink.jsx | 168 ++-- .../src/themes/components/form/checkbox.js | 1 + frontend/src/themes/components/form/radio.js | 1 + .../Organizations/AddEditOrg/AddEditOrg.jsx | 847 +--------------- .../AddEditOrg/AddEditOrgForm.jsx | 900 ++++++++++++++++++ .../AddEditOrg/AddressAutocomplete.jsx | 118 +++ .../views/Organizations/AddEditOrg/_schema.js | 16 +- 11 files changed, 1169 insertions(+), 937 deletions(-) create mode 100644 backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py create mode 100644 frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx create mode 100644 frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx diff --git a/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py b/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py new file mode 100644 index 000000000..1843574b4 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py @@ -0,0 +1,37 @@ +"""add record's address to organization + +Revision ID: c1e2d64aeea4 +Revises: 0d5836bb1bf8 +Create Date: 2025-02-19 04:17:03.668963 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "c1e2d64aeea4" +down_revision = "0d5836bb1bf8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "organization", + sa.Column( + "records_address", + sa.String(length=2000), + nullable=True, + comment="Organization's address in BC where records are maintained", + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("organization", "records_address") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/organization/Organization.py b/backend/lcfs/db/models/organization/Organization.py index eac699156..de2608d68 100644 --- a/backend/lcfs/db/models/organization/Organization.py +++ b/backend/lcfs/db/models/organization/Organization.py @@ -93,6 +93,9 @@ class Organization(BaseModel, Auditable, EffectiveDates): Integer, ForeignKey("organization_attorney_address.organization_attorney_address_id"), ) + records_address = Column( + String(2000), comment="Organization's address in BC where records are maintained" + ) org_type = relationship( "OrganizationType", back_populates="organizations", lazy="joined" diff --git a/backend/lcfs/web/api/organizations/schema.py b/backend/lcfs/web/api/organizations/schema.py index 04b198cba..f6b5cb1c0 100644 --- a/backend/lcfs/web/api/organizations/schema.py +++ b/backend/lcfs/web/api/organizations/schema.py @@ -155,6 +155,7 @@ class OrganizationCreateSchema(BaseSchema): has_early_issuance: bool organization_status_id: int organization_type_id: int + records_address: Optional[str] = None address: OrganizationAddressCreateSchema attorney_address: OrganizationAttorneyAddressCreateSchema @@ -168,6 +169,7 @@ class OrganizationUpdateSchema(BaseSchema): has_early_issuance: bool organization_status_id: Optional[int] = None organization_type_id: Optional[int] = None + records_address: Optional[str] = None address: Optional[OrganizationAddressCreateSchema] = [] attorney_address: Optional[OrganizationAttorneyAddressCreateSchema] = [] @@ -181,6 +183,7 @@ class OrganizationResponseSchema(BaseSchema): edrms_record: Optional[str] = None has_early_issuance: bool org_status: Optional[OrganizationStatusSchema] = [] + records_address: Optional[str] = None org_address: Optional[OrganizationAddressSchema] = [] org_attorney_address: Optional[OrganizationAttorneyAddressSchema] = [] diff --git a/frontend/src/assets/locales/en/organization.json b/frontend/src/assets/locales/en/organization.json index 9d173270b..dce0fb897 100644 --- a/frontend/src/assets/locales/en/organization.json +++ b/frontend/src/assets/locales/en/organization.json @@ -26,14 +26,18 @@ "earlyIssuanceLabel": "Enable early issuance reporting", "edrmsLabel": "Organization profile, EDRMS record # (optional)", "serviceAddrLabel": "Address for service (postal address)", - "streetAddrLabel": "Street address / PO box", + "streetAddrLabel": "Street address/PO box", "addrOthLabel": "Address other (optional)", "cityLabel": "City", "provinceLabel": "Province", - "poLabel": "Postal / ZIP code", + "provinceStateLabel": "Province/State", + "poLabel": "Postal code", + "poZipLabel": "Postal code/ZIP code", "cntryLabel": "Country", - "bcAddrLabel": "Address in B.C. (at which records are maintained)", - "bcAddrLabelShort": "Address in B.C. (at which records are maintained)", + "recordsAddrGuide": "Enter full address including postal code (if different than address for service)", + "bcAddrLabel": "Head office (optional)", + "bcAddrLabelShort": "Head address (optional)", + "bcRecordLabel": "Address in B.C. where records are maintained (optional)", "sameAddrLabel": "Same as address for service", "contactMsg": "to update address information.", "usersLabel": "Users", diff --git a/frontend/src/components/BCNavbar/components/DefaultNavbarLink.jsx b/frontend/src/components/BCNavbar/components/DefaultNavbarLink.jsx index 42fb690cd..ce985015d 100644 --- a/frontend/src/components/BCNavbar/components/DefaultNavbarLink.jsx +++ b/frontend/src/components/BCNavbar/components/DefaultNavbarLink.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, forwardRef } from 'react' // prop-types is a library for typechecking of props import PropTypes from 'prop-types' @@ -12,97 +12,93 @@ import Icon from '@mui/material/Icon' import BCBox from '@/components/BCBox' import BCTypography from '@/components/BCTypography' -function DefaultNavbarLink({ - icon, - name, - route, - light, - onClick, - isMobileView, - sx = {} -}) { - const [hover, setHover] = useState(false) - return ( - ({ - cursor: 'pointer', - userSelect: 'none', - minHeight: '2.7rem', - paddingBottom: isMobileView ? '10px' : '15px', - '&:hover': { - borderBottom: isMobileView ? '0' : '6px solid #38598a', - backgroundColor: hover - ? isMobileView - ? 'rgba(0, 0, 0, 0.1)' - : 'rgba(0, 0, 0, 0.2)' - : 'transparent', - paddingBottom: isMobileView ? '10px' : '9px' - }, - '&.active': { - borderBottom: isMobileView ? '0' : '3px solid #fcc219', - borderLeft: isMobileView ? '3px solid #fcc219' : '0', - backgroundColor: isMobileView - ? 'rgba(0, 0, 0, 0.2)' - : 'rgba(0, 0, 0, 0.3)', - paddingBottom: isMobileView ? '11px' : '12px' - }, - transform: 'translateX(0)', - transition: transitions.create('transform', { - easing: transitions.easing.sharp, - duration: transitions.duration.shorter - }), - ...sx - })} - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - onClick={onClick} - > - {icon && typeof icon === 'string' ? ( - - {icon} - - ) : ( - <>{icon} - )} - { + const [hover, setHover] = useState(false) + return ( + ({ + cursor: 'pointer', + userSelect: 'none', + minHeight: '2.7rem', + paddingBottom: isMobileView ? '10px' : '15px', '&:hover': { - textDecoration: 'none' + borderBottom: isMobileView ? '0' : '6px solid #38598a', + backgroundColor: hover + ? isMobileView + ? 'rgba(0, 0, 0, 0.1)' + : 'rgba(0, 0, 0, 0.2)' + : 'transparent', + paddingBottom: isMobileView ? '10px' : '9px' }, - whiteSpace: 'nowrap', - flexShrink: 0 - }} + '&.active': { + borderBottom: isMobileView ? '0' : '3px solid #fcc219', + borderLeft: isMobileView ? '3px solid #fcc219' : '0', + backgroundColor: isMobileView + ? 'rgba(0, 0, 0, 0.2)' + : 'rgba(0, 0, 0, 0.3)', + paddingBottom: isMobileView ? '11px' : '12px' + }, + transform: 'translateX(0)', + transition: transitions.create('transform', { + easing: transitions.easing.sharp, + duration: transitions.duration.shorter + }), + ...sx + })} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + onClick={onClick} > - {name} - - - ) -} + {icon && typeof icon === 'string' ? ( + + {icon} + + ) : ( + <>{icon} + )} + + {name} + + + ) + } +) + +DefaultNavbarLink.displayName = 'DefaultNavbarLink' // Typechecking props for the DefaultNavbarLink DefaultNavbarLink.propTypes = { - icon: PropTypes.string, + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), name: PropTypes.string.isRequired, route: PropTypes.string.isRequired, light: PropTypes.bool, diff --git a/frontend/src/themes/components/form/checkbox.js b/frontend/src/themes/components/form/checkbox.js index 8e3b9865b..e071b01de 100644 --- a/frontend/src/themes/components/form/checkbox.js +++ b/frontend/src/themes/components/form/checkbox.js @@ -10,6 +10,7 @@ const checkbox = { styleOverrides: { root: { padding: 0, + marginTop: 3, '& .MuiSvgIcon-root': { backgroundPosition: 'center', backgroundSize: 'contain', diff --git a/frontend/src/themes/components/form/radio.js b/frontend/src/themes/components/form/radio.js index 15a8536f4..32c47c730 100644 --- a/frontend/src/themes/components/form/radio.js +++ b/frontend/src/themes/components/form/radio.js @@ -7,6 +7,7 @@ const radio = { styleOverrides: { root: { padding: 0, + marginTop: 3, '& .MuiSvgIcon-root': { width: pxToRem(20), height: pxToRem(20), diff --git a/frontend/src/views/Organizations/AddEditOrg/AddEditOrg.jsx b/frontend/src/views/Organizations/AddEditOrg/AddEditOrg.jsx index 408dc54a6..20a91a156 100644 --- a/frontend/src/views/Organizations/AddEditOrg/AddEditOrg.jsx +++ b/frontend/src/views/Organizations/AddEditOrg/AddEditOrg.jsx @@ -1,846 +1,19 @@ -// External Modules -import { faArrowLeft, faFloppyDisk } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { yupResolver } from '@hookform/resolvers/yup' -import { - Box, - Checkbox, - FormControl, - FormControlLabel, - FormLabel, - Grid, - InputLabel, - Paper, - Radio, - RadioGroup, - TextField -} from '@mui/material' -import BCTypography from '@/components/BCTypography' -import { useMutation } from '@tanstack/react-query' -import { useCallback, useEffect, useState } from 'react' -import { useForm, Controller } from 'react-hook-form' +import BCWidgetCard from '@/components/BCWidgetCard/BCWidgetCard' +import { AddEditOrgForm } from './AddEditOrgForm' import { useTranslation } from 'react-i18next' -import { useNavigate, useParams } from 'react-router-dom' -import { schemaValidation } from './_schema' +import { useParams } from 'react-router-dom' -// Internal Modules -import BCAlert from '@/components/BCAlert' -import BCButton from '@/components/BCButton' -import Loading from '@/components/Loading' -import { ROUTES } from '@/constants/routes' -import { useOrganization } from '@/hooks/useOrganization' -import { useApiService } from '@/services/useApiService' - -// Component for adding a new organization export const AddEditOrg = () => { - const { t } = useTranslation(['common', 'org']) - const navigate = useNavigate() - const apiService = useApiService() + const { t } = useTranslation(['common', 'admin']) const { orgID } = useParams() - const { data, isFetched } = useOrganization(orgID, { - enabled: !!orgID, - retry: false - }) - - // State for controlling checkbox behavior - const [sameAsLegalName, setSameAsLegalName] = useState(false) - const [sameAsServiceAddress, setSameAsServiceAddress] = useState(false) - - // useForm hook setup with React Hook Form and Yup for form validation - const { - register, - handleSubmit, - formState: { errors }, - watch, - setValue, - trigger, - reset, - control - } = useForm({ - resolver: yupResolver(schemaValidation) - }) - - useEffect(() => { - if (isFetched && data) { - const shouldSyncNames = data.name === data.operatingName - const shouldSyncAddress = - data.orgAddress?.streetAddress === - data.orgAttorneyAddress?.streetAddress && - data.orgAddress?.addressOther === - data.orgAttorneyAddress?.addressOther && - data.orgAddress?.city === data.orgAttorneyAddress?.city && - data.orgAddress?.postalcodeZipcode === - data.orgAttorneyAddress?.postalcodeZipcode - - reset({ - orgLegalName: data.name, - orgOperatingName: data.operatingName, - orgEmailAddress: data.email, - orgPhoneNumber: data.phone, - orgEDRMSRecord: data.edrmsRecord, - hasEarlyIssuance: data.hasEarlyIssuance ? 'yes' : 'no', - orgRegForTransfers: - data.orgStatus.organizationStatusId === 2 ? '2' : '1', - orgStreetAddress: data.orgAddress.streetAddress, - orgAddressOther: data.orgAddress.addressOther, - orgCity: data.orgAddress.city, - orgPostalCodeZipCode: data.orgAddress.postalcodeZipcode, - orgAttorneyStreetAddress: data.orgAttorneyAddress.streetAddress, - orgAttorneyAddressOther: data.orgAttorneyAddress.addressOther, - orgAttorneyCity: data.orgAttorneyAddress.city, - orgAttorneyPostalCodeZipCode: data.orgAttorneyAddress.postalcodeZipcode - }) - - setSameAsLegalName(shouldSyncNames) - setSameAsServiceAddress(shouldSyncAddress) - } - }, [isFetched, data, reset]) - - // Watching form fields - const orgLegalName = watch('orgLegalName') - const orgStreetAddress = watch('orgStreetAddress') - const orgAddressOther = watch('orgAddressOther') - const orgCity = watch('orgCity') - const orgPostalCodeZipCode = watch('orgPostalCodeZipCode') - - // Set value and trigger validation function - const setValueAndTriggerValidation = useCallback( - (fieldName, value) => { - if (watch(fieldName) !== value) { - setValue(fieldName, value) - if (value.trim().length > 0) { - trigger(fieldName) - } - } - }, - [setValue, trigger, watch] - ) - - // Clear fields function - const clearFields = useCallback( - (fields) => { - fields.forEach((fieldName) => { - if (watch(fieldName)) { - setValue(fieldName, '') - } - }) - }, - [setValue, watch] - ) - - // Function to render form error messages - const renderError = (fieldName, sameAsField = null) => { - // If the sameAsField is provided and is true, hide errors for this field - if (sameAsField && watch(sameAsField)) { - return null - } - return ( - errors[fieldName] && ( - - {errors[fieldName].message} - - ) - ) - } - - // Prepare payload and call mutate function - const onSubmit = async (data) => { - const payload = { - organizationId: orgID, - name: data.orgLegalName, - operatingName: data.orgOperatingName, - email: data.orgEmailAddress, - phone: data.orgPhoneNumber, - edrmsRecord: data.orgEDRMSRecord, - hasEarlyIssuance: data.hasEarlyIssuance === 'yes', - organizationStatusId: parseInt(data.orgRegForTransfers), - organizationTypeId: parseInt(data.orgSupplierType), - address: { - name: data.orgOperatingName, - streetAddress: data.orgStreetAddress, - addressOther: data.orgAddressOther || '', - city: data.orgCity, - provinceState: data.orgProvince || 'BC', - country: data.orgCountry || 'Canada', - postalcodeZipcode: data.orgPostalCodeZipCode - }, - attorneyAddress: { - name: data.orgOperatingName, - streetAddress: data.orgAttorneyStreetAddress, - addressOther: data.orgAttorneyAddressOther || '', - city: data.orgAttorneyCity, - provinceState: data.orgAttorneyProvince || 'BC', - country: data.orgAttorneyCountry || 'Canada', - postalcodeZipcode: data.orgAttorneyPostalCodeZipCode - } - } - - if (orgID) { - updateOrg(payload) - } else { - createOrg(payload) - } - } - - // useMutation hook from React Query for handling API request - const { - mutate: createOrg, - isPending: isCreateOrgPending, - isError: isCreateOrgError - } = useMutation({ - mutationFn: async (userData) => - await apiService.post('/organizations/create', userData), - onSuccess: () => { - // Redirect to Organization route on success - navigate(ROUTES.ORGANIZATIONS, { - state: { - message: 'Organization has been successfully added.', - severity: 'success' - } - }) - }, - onError: (error) => { - // Error handling logic - console.error('Error posting data:', error) - } - }) - - const { - mutate: updateOrg, - isPending: isUpdateOrgPending, - isError: isUpdateOrgError - } = useMutation({ - mutationFn: async (payload) => - await apiService.put(`/organizations/${orgID}`, payload), - onSuccess: () => { - navigate(ROUTES.ORGANIZATIONS, { - state: { - message: 'Organization has been successfully updated.', - severity: 'success' - } - }) - }, - onError: (error) => { - console.error('Error posting data:', error) - } - }) - - // Syncing logic for 'sameAsLegalName' - useEffect(() => { - if (sameAsLegalName) { - setValueAndTriggerValidation('orgOperatingName', orgLegalName) - } else { - if (watch('orgOperatingName') === orgLegalName) { - clearFields(['orgOperatingName']) - } - } - }, [ - sameAsLegalName, - orgLegalName, - setValueAndTriggerValidation, - clearFields, - watch - ]) - - // Syncing logic for 'sameAsServiceAddress' - useEffect(() => { - if (sameAsServiceAddress) { - setValueAndTriggerValidation( - 'orgAttorneyStreetAddress', - watch('orgStreetAddress') - ) - setValueAndTriggerValidation( - 'orgAttorneyAddressOther', - watch('orgAddressOther') - ) - setValueAndTriggerValidation('orgAttorneyCity', watch('orgCity')) - setValueAndTriggerValidation( - 'orgAttorneyPostalCodeZipCode', - watch('orgPostalCodeZipCode') - ) - } else { - if (watch('orgAttorneyStreetAddress') === orgStreetAddress) { - clearFields(['orgAttorneyStreetAddress']) - } - if (watch('orgAttorneyAddressOther') === orgAddressOther) { - clearFields(['orgAttorneyAddressOther']) - } - if (watch('orgAttorneyCity') === orgCity) { - clearFields(['orgAttorneyCity']) - } - if (watch('orgAttorneyPostalCodeZipCode') === orgPostalCodeZipCode) { - clearFields(['orgAttorneyPostalCodeZipCode']) - } - } - }, [ - sameAsServiceAddress, - orgStreetAddress, - orgAddressOther, - orgCity, - orgPostalCodeZipCode, - setValueAndTriggerValidation, - clearFields - ]) - - // Conditional rendering for loading - if (isCreateOrgPending || isUpdateOrgPending) { - return - } - - // Form layout and structure return ( - - {/* Error Alert */} - {(isCreateOrgError || isUpdateOrgError) && ( - {t('common:submitError')} - )} - - - {orgID ? t('org:editOrgTitle') : t('org:addOrgTitle')} - - - {/* Form Fields */} - - - - - - - - - {t('org:legalNameLabel')} - - - - - - - - {t('org:operatingNameLabel')}: - - - - - setSameAsLegalName(e.target.checked) - } - data-test="sameAsLegalName" - /> - } - label={ - - {t('org:sameAsLegalNameLabel')} - - } - /> - - - - - - - {t('org:emailAddrLabel')}: - - - - - - {t('org:phoneNbrLabel')}: - - - - - - - - - - - - - - {t('org:supplierTypLabel')}: - - - - - - - } - label={ - - {t('supplier')} - - } - /> - - {renderError('orgSupplierType')} - - - - - - - - - - - {t('org:regTrnLabel')}: - - - - - ( - - - } - label={ - - {t('yes')} - - } - /> - - } - label={ - - {t('no')} - - } - /> - - )} - > - / - - {renderError('orgRegForTransfers')} - - - - - - - - - - - {t('org:earlyIssuanceLabel')}: - - - - - ( - - - } - label={ - - {t('yes')} - - } - /> - - } - label={ - - {t('no')} - - } - /> - - )} - > - / - - {renderError('hasEarlyIssuance')} - - - - - - - {t('org:edrmsLabel')}: - - - - - - - - - - - - {t('org:serviceAddrLabel')} - - - - {t('org:streetAddrLabel')}: - - - - - - {t('org:addrOthLabel')}: - - - - - - {t('org:cityLabel')}: - - - - - - {t('org:provinceLabel')}: - - - - - - {t('org:cntryLabel')}: - - - - - - {t('org:poLabel')}: - - - - - - - - - {t('org:bcAddrLabel')} - - - - setSameAsServiceAddress(e.target.checked) - } - /> - } - label={ - - {t('org:sameAddrLabel')} - - } - /> - - - - {t('org:streetAddrLabel')}: - - - - - - {t('org:addrOthLabel')}: - - - - - - {t('org:cityLabel')}: - - - - - - {t('org:provinceLabel')}: - - - - - - {t('org:cntryLabel')}: - - - - - - {t('org:poLabel')}: - - - - - - - {/* Action Buttons */} - - - - } - onClick={() => navigate(ROUTES.ORGANIZATIONS)} - > - - {t('backBtn')} - - - - } - > - {t('saveBtn')} - - - - - - + content={} + /> ) } diff --git a/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx b/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx new file mode 100644 index 000000000..a7ee62dcb --- /dev/null +++ b/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx @@ -0,0 +1,900 @@ +// External Modules +import { faArrowLeft, faFloppyDisk } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { yupResolver } from '@hookform/resolvers/yup' +import { + Box, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + Grid, + InputLabel, + Paper, + Radio, + RadioGroup, + TextField +} from '@mui/material' +import BCTypography from '@/components/BCTypography' +import { useMutation } from '@tanstack/react-query' +import { useCallback, useEffect, useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { useNavigate, useParams } from 'react-router-dom' +import { schemaValidation } from './_schema' + +// Internal Modules +import BCAlert from '@/components/BCAlert' +import BCButton from '@/components/BCButton' +import Loading from '@/components/Loading' +import { ROUTES } from '@/constants/routes' +import { useOrganization } from '@/hooks/useOrganization' +import { useApiService } from '@/services/useApiService' +import AddressAutocomplete from './AddressAutocomplete' +import colors from '@/themes/base/colors' + +// Component for adding a new organization +export const AddEditOrgForm = () => { + const { t } = useTranslation(['common', 'org']) + const navigate = useNavigate() + const apiService = useApiService() + const { orgID } = useParams() + + const { data, isFetched } = useOrganization(orgID, { + enabled: !!orgID, + retry: false + }) + + // State for controlling checkbox behavior + const [sameAsLegalName, setSameAsLegalName] = useState(false) + const [sameAsServiceAddress, setSameAsServiceAddress] = useState(false) + + // useForm hook setup with React Hook Form and Yup for form validation + const { + register, + handleSubmit, + formState: { errors }, + watch, + setValue, + trigger, + reset, + control + } = useForm({ + resolver: yupResolver(schemaValidation) + }) + + useEffect(() => { + if (isFetched && data) { + const shouldSyncNames = data.name === data.operatingName + const shouldSyncAddress = + data.orgAddress?.streetAddress === + data.orgAttorneyAddress?.streetAddress && + data.orgAddress?.addressOther === + data.orgAttorneyAddress?.addressOther && + data.orgAddress?.city === data.orgAttorneyAddress?.city && + data.orgAddress?.postalcodeZipcode === + data.orgAttorneyAddress?.postalcodeZipcode + + reset({ + orgLegalName: data.name, + orgOperatingName: data.operatingName, + orgEmailAddress: data.email, + orgPhoneNumber: data.phone, + orgEDRMSRecord: data.edrmsRecord, + recordsAddress: data.recordsAddress || '', + hasEarlyIssuance: data.hasEarlyIssuance ? 'yes' : 'no', + orgRegForTransfers: + data.orgStatus.organizationStatusId === 2 ? '2' : '1', + orgStreetAddress: data.orgAddress.streetAddress, + orgAddressOther: data.orgAddress.addressOther, + orgCity: data.orgAddress.city, + orgPostalCodeZipCode: data.orgAddress.postalcodeZipcode, + orgHeadOfficeStreetAddress: data.orgAttorneyAddress.streetAddress, + orgHeadOfficeAddressOther: data.orgAttorneyAddress.addressOther, + orgHeadOfficeCity: data.orgAttorneyAddress.city, + orgHeadOfficeProvince: data.orgAttorneyAddress.provinceState, + orgHeadOfficeCountry: data.orgAttorneyAddress.country, + orgHeadOfficePostalCodeZipCode: + data.orgAttorneyAddress.postalcodeZipcode + }) + + setSameAsLegalName(shouldSyncNames) + setSameAsServiceAddress(shouldSyncAddress) + } + }, [isFetched, data, reset]) + + // Watching form fields + const orgLegalName = watch('orgLegalName') + const orgStreetAddress = watch('orgStreetAddress') + const orgAddressOther = watch('orgAddressOther') + const orgCity = watch('orgCity') + const orgPostalCodeZipCode = watch('orgPostalCodeZipCode') + + // Set value and trigger validation function + const setValueAndTriggerValidation = useCallback( + (fieldName, value) => { + if (watch(fieldName) !== value) { + setValue(fieldName, value) + if (value.trim().length > 0) { + trigger(fieldName) + } + } + }, + [setValue, trigger, watch] + ) + + // Clear fields function + const clearFields = useCallback( + (fields) => { + fields.forEach((fieldName) => { + if (watch(fieldName)) { + setValue(fieldName, '') + } + }) + }, + [setValue, watch] + ) + + // Function to render form error messages + const renderError = (fieldName, sameAsField = null) => { + // If the sameAsField is provided and is true, hide errors for this field + if (sameAsField && watch(sameAsField)) { + return null + } + return ( + errors[fieldName] && ( + + {errors[fieldName].message} + + ) + ) + } + + // Prepare payload and call mutate function + const onSubmit = async (data) => { + const payload = { + organizationId: orgID, + name: data.orgLegalName, + operatingName: data.orgOperatingName, + email: data.orgEmailAddress, + phone: data.orgPhoneNumber, + edrmsRecord: data.orgEDRMSRecord, + recordsAddress: data.recordsAddress || '', + hasEarlyIssuance: data.hasEarlyIssuance === 'yes', + organizationStatusId: parseInt(data.orgRegForTransfers), + organizationTypeId: parseInt(data.orgSupplierType), + address: { + name: data.orgOperatingName, + streetAddress: data.orgStreetAddress, + addressOther: data.orgAddressOther || '', + city: data.orgCity, + provinceState: data.orgProvince || 'BC', + country: data.orgCountry || 'Canada', + postalcodeZipcode: data.orgPostalCodeZipCode + }, + attorneyAddress: { + name: data.orgOperatingName, + streetAddress: data.orgHeadOfficeStreetAddress, + addressOther: data.orgHeadOfficeAddressOther || '', + city: data.orgHeadOfficeCity, + provinceState: data.orgHeadOfficeProvince || '', + country: data.orgHeadOfficeCountry || '', + postalcodeZipcode: data.orgHeadOfficePostalCodeZipCode + } + } + + if (orgID) { + updateOrg(payload) + } else { + createOrg(payload) + } + } + + // useMutation hook from React Query for handling API request + const { + mutate: createOrg, + isPending: isCreateOrgPending, + isError: isCreateOrgError + } = useMutation({ + mutationFn: async (userData) => + await apiService.post('/organizations/create', userData), + onSuccess: () => { + // Redirect to Organization route on success + navigate(ROUTES.ORGANIZATIONS, { + state: { + message: 'Organization has been successfully added.', + severity: 'success' + } + }) + }, + onError: (error) => { + // Error handling logic + console.error('Error posting data:', error) + } + }) + + const { + mutate: updateOrg, + isPending: isUpdateOrgPending, + isError: isUpdateOrgError + } = useMutation({ + mutationFn: async (payload) => + await apiService.put(`/organizations/${orgID}`, payload), + onSuccess: () => { + navigate(ROUTES.ORGANIZATIONS, { + state: { + message: 'Organization has been successfully updated.', + severity: 'success' + } + }) + }, + onError: (error) => { + console.error('Error posting data:', error) + } + }) + + // Syncing logic for 'sameAsLegalName' + useEffect(() => { + if (sameAsLegalName) { + setValueAndTriggerValidation('orgOperatingName', orgLegalName) + } else { + if (watch('orgOperatingName') === orgLegalName) { + clearFields(['orgOperatingName']) + } + } + }, [ + sameAsLegalName, + orgLegalName, + setValueAndTriggerValidation, + clearFields, + watch + ]) + + // Syncing logic for 'sameAsServiceAddress' + useEffect(() => { + if (sameAsServiceAddress) { + setValueAndTriggerValidation( + 'orgHeadOfficeStreetAddress', + watch('orgStreetAddress') + ) + setValueAndTriggerValidation( + 'orgHeadOfficeAddressOther', + watch('orgAddressOther') + ) + setValueAndTriggerValidation('orgHeadOfficeCity', watch('orgCity')) + setValueAndTriggerValidation( + 'orgHeadOfficePostalCodeZipCode', + watch('orgPostalCodeZipCode') + ) + } else { + if (watch('orgHeadOfficeStreetAddress') === orgStreetAddress) { + clearFields(['orgHeadOfficeStreetAddress']) + } + if (watch('orgHeadOfficeAddressOther') === orgAddressOther) { + clearFields(['orgHeadOfficeAddressOther']) + } + if (watch('orgHeadOfficeCity') === orgCity) { + clearFields(['orgHeadOfficeCity']) + } + if (watch('orgHeadOfficePostalCodeZipCode') === orgPostalCodeZipCode) { + clearFields(['orgHeadOfficePostalCodeZipCode']) + } + } + }, [ + sameAsServiceAddress, + orgStreetAddress, + orgAddressOther, + orgCity, + orgPostalCodeZipCode, + setValueAndTriggerValidation, + clearFields + ]) + + // Conditional rendering for loading + if (isCreateOrgPending || isUpdateOrgPending) { + return + } + + // Form layout and structure + return ( + + {/* Error Alert */} + {(isCreateOrgError || isUpdateOrgError) && ( + {t('common:submitError')} + )} + + {/* Form Fields */} + + + + + + + + + {t('org:legalNameLabel')} + + + + + + + + {t('org:operatingNameLabel')}: + + + + + setSameAsLegalName(e.target.checked) + } + data-test="sameAsLegalName" + /> + } + label={ + + {t('org:sameAsLegalNameLabel')} + + } + /> + + + + + + + {t('org:emailAddrLabel')}: + + + + + + {t('org:phoneNbrLabel')}: + + + + + + + + + + + + + + {t('org:supplierTypLabel')}: + + + + + + + } + label={ + + {t('supplier')} + + } + /> + + {renderError('orgSupplierType')} + + + + + + + + + + + {t('org:regTrnLabel')}: + + + + + ( + + + } + label={ + + {t('yes')} + + } + /> + + } + label={ + + {t('no')} + + } + /> + + )} + > + / + + {renderError('orgRegForTransfers')} + + + + + + + + + + + {t('org:earlyIssuanceLabel')}: + + + + + ( + + + } + label={ + + {t('yes')} + + } + /> + + } + label={ + + {t('no')} + + } + /> + + )} + > + / + + {renderError('hasEarlyIssuance')} + + + + + + + {t('org:edrmsLabel')}: + + + + + + + + + + + + {t('org:serviceAddrLabel')} + + + + {t('org:streetAddrLabel')}: + + {/* { + if (typeof address === 'string') { + setValue('orgStreetAddress', address) + } else { + setValue('orgStreetAddress', address.streetAddress) + setValue('orgCity', address.city) + } + }} + /> */} + + + + + {t('org:addrOthLabel')}: + + + + + + {t('org:cityLabel')}: + + + + + + + {t('org:provinceLabel')}: + + + + + + {t('org:cntryLabel')}: + + + + + + + {t('org:poLabel')}: + + + + + + {t('org:bcRecordLabel')} + + + {t('org:recordsAddrGuide')}: + + } + /> + + + + + + + {t('org:bcAddrLabel')} + + + + setSameAsServiceAddress(e.target.checked) + } + /> + } + label={ + + {t('org:sameAddrLabel')} + + } + /> + + + + {t('org:streetAddrLabel')}: + + + + + + {t('org:addrOthLabel')}: + + + + + + {t('org:cityLabel')}: + + + + + + + {t('org:provinceStateLabel')}: + + + + + + {t('org:cntryLabel')}: + + + + + + + {t('org:poZipLabel')}: + + + + + {/* Action Buttons */} + + + + } + > + {t('saveBtn')} + + + } + onClick={() => navigate(ROUTES.ORGANIZATIONS)} + > + + {t('backBtn')} + + + + + + + + + ) +} diff --git a/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx b/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx new file mode 100644 index 000000000..bb8121b5f --- /dev/null +++ b/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx @@ -0,0 +1,118 @@ +import React, { useState, useEffect, forwardRef } from 'react' +import { TextField, Autocomplete, Box } from '@mui/material' + +const AddressAutocomplete = forwardRef( + ({ value, onChange, onSelectAddress }, ref) => { + const [inputValue, setInputValue] = useState(value || '') + const [options, setOptions] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!inputValue || inputValue.length < 3) { + setOptions([]) + return + } + + const controller = new AbortController() + const signal = controller.signal + + const fetchAddresses = async () => { + setLoading(true) + try { + const response = await fetch( + `https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=parcelPoint&addressString=${encodeURIComponent( + inputValue + )}`, + { signal } + ) + + if (!response.ok) throw new Error('Network response was not ok') + const data = await response.json() + const addresses = data.features.map((feature) => ({ + fullAddress: feature.properties.fullAddress || '', + streetAddress: feature.properties.streetAddress || '', + localityName: feature.properties.localityName || '' + })) + setOptions(addresses.filter((addr) => addr.fullAddress)) + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Error fetching addresses:', error) + } + } + setLoading(false) + } + + const delayDebounceFn = setTimeout(() => { + fetchAddresses() + }, 500) + + return () => { + clearTimeout(delayDebounceFn) + controller.abort() + } + }, [inputValue]) + + return ( + x} + value={value || inputValue} + getOptionLabel={(option) => { + return typeof option === 'string' ? option : option.fullAddress + }} + onInputChange={(event, newInputValue) => { + if (onChange) { + onChange(newInputValue) + } + setInputValue(newInputValue) + }} + onChange={(event, newValue) => { + if (onSelectAddress && newValue) { + if (typeof newValue === 'string') { + onSelectAddress(newValue) + } else { + const [streetAddress, city] = newValue.fullAddress.split(', ') + onSelectAddress({ + fullAddress: newValue.fullAddress, + inputValue, + streetAddress, + city + }) + } + } else if (onChange) { + // Default behavior: just set the field value + onChange( + typeof newValue === 'string' ? newValue : newValue?.fullAddress + ) + } + }} + renderInput={(params) => ( + + + + )} + renderOption={(props, option) => ( +
  • + {option.fullAddress} +
  • + )} + /> + ) + } +) + +AddressAutocomplete.displayName = 'AddressAutocomplete' + +export default AddressAutocomplete diff --git a/frontend/src/views/Organizations/AddEditOrg/_schema.js b/frontend/src/views/Organizations/AddEditOrg/_schema.js index 25c33004a..ce11d57b4 100644 --- a/frontend/src/views/Organizations/AddEditOrg/_schema.js +++ b/frontend/src/views/Organizations/AddEditOrg/_schema.js @@ -32,15 +32,11 @@ export const schemaValidation = Yup.object({ /^((\d{5}-\d{4})|(\d{5})|([A-Z]\d[A-Z]\s?\d[A-Z]\d))$/i, 'Please enter a valid Postal / ZIP Code.' ), - orgAttorneyStreetAddress: Yup.string().required( - 'Street Address / PO Box is required.' - ), - orgAttorneyCity: Yup.string().required('City is required.'), - orgAttorneyPostalCodeZipCode: Yup.string() - .required('Postal / ZIP Code is required.') - .matches( - /^((\d{5}-\d{4})|(\d{5})|([A-Z]\d[A-Z]\s?\d[A-Z]\d))$/i, - 'Please enter a valid Postal / ZIP Code.' - ), + // Head Office fields are now optional + orgHeadOfficeStreetAddress: Yup.string().nullable(), + orgHeadOfficeCity: Yup.string().nullable(), + orgHeadOfficeProvince: Yup.string().nullable(), + orgHeadOfficeCountry: Yup.string().nullable(), + orgHeadOfficePostalCodeZipCode: Yup.string().nullable(), hasEarlyIssuance: Yup.string().required('Early issuance setting is required') }) From 5aea835d35d79ac404900391604c0225e500521f Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 20 Feb 2025 07:42:37 -0800 Subject: [PATCH 02/16] update bceid user end --- .../versions/2025-02-19-04-17_c1e2d64aeea4.py | 51 +++++++++++++- .../ComplianceReportOrganizationSnapshot.py | 5 +- .../web/api/organization_snapshot/schema.py | 3 +- .../web/api/organization_snapshot/services.py | 9 ++- .../src/assets/locales/en/organization.json | 1 + frontend/src/assets/locales/en/reports.json | 5 +- frontend/src/components/BCForm/BCFormText.jsx | 66 ++++++++++++++++--- .../components/OrganizationAddress.jsx | 48 ++++++++++++-- .../ViewOrganization/ViewOrganization.jsx | 6 ++ 9 files changed, 169 insertions(+), 25 deletions(-) diff --git a/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py b/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py index 1843574b4..2f4fa4c99 100644 --- a/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py +++ b/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py @@ -1,18 +1,17 @@ """add record's address to organization Revision ID: c1e2d64aeea4 -Revises: 0d5836bb1bf8 +Revises: 9e1da9e38f20 Create Date: 2025-02-19 04:17:03.668963 """ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = "c1e2d64aeea4" -down_revision = "0d5836bb1bf8" +down_revision = "9e1da9e38f20" branch_labels = None depends_on = None @@ -28,10 +27,56 @@ def upgrade() -> None: comment="Organization's address in BC where records are maintained", ), ) + op.alter_column( + "compliance_report_organization_snapshot", + "service_address", + new_column_name="head_office_address", + existing_type=sa.String(length=500), + existing_nullable=True, + comment="Organization's address in BC", + ) + op.alter_column( + "compliance_report_organization_snapshot", + "bc_address", + new_column_name="service_address", + existing_type=sa.String(length=500), + existing_nullable=True, + comment="Organization's address in BC", + ) + + # Add records_address column + op.add_column( + "compliance_report_organization_snapshot", + sa.Column( + "records_address", + sa.String(length=500), + nullable=True, + comment="Organization's address in BC where records are maintained.", + ), + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.drop_column("organization", "records_address") + op.alter_column( + "compliance_report_organization_snapshot", + "service_address", + new_column_name="bc_address", + existing_type=sa.String(length=500), + existing_nullable=True, + comment="Organization's address in BC", + ) + op.alter_column( + "compliance_report_organization_snapshot", + "head_office_address", + new_column_name="service_address", + existing_type=sa.String(length=500), + existing_nullable=True, + comment="Organization's address in BC", + ) + + # Drop records_address column + op.drop_column("compliance_report_organization_snapshot", "records_address") # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/compliance/ComplianceReportOrganizationSnapshot.py b/backend/lcfs/db/models/compliance/ComplianceReportOrganizationSnapshot.py index 144d47710..f70fee4e9 100644 --- a/backend/lcfs/db/models/compliance/ComplianceReportOrganizationSnapshot.py +++ b/backend/lcfs/db/models/compliance/ComplianceReportOrganizationSnapshot.py @@ -27,12 +27,15 @@ class ComplianceReportOrganizationSnapshot(BaseModel, Auditable): ) email = Column(String(255), nullable=True, comment="Organization's email address") phone = Column(String(50), nullable=True, comment="Organization's phone number") - bc_address = Column( + head_office_address = Column( String(500), nullable=True, comment="Organization's address in BC" ) service_address = Column( String(500), nullable=True, comment="Organization's address for Postal Service" ) + records_address = Column( + String(500), nullable=True, comment="Organization's address in BC where records are maintained." + ) is_edited = Column( Boolean, diff --git a/backend/lcfs/web/api/organization_snapshot/schema.py b/backend/lcfs/web/api/organization_snapshot/schema.py index 8c39a78b6..0be04ffbf 100644 --- a/backend/lcfs/web/api/organization_snapshot/schema.py +++ b/backend/lcfs/web/api/organization_snapshot/schema.py @@ -10,5 +10,6 @@ class OrganizationSnapshotSchema(BaseSchema): operating_name: Optional[str] = None email: Optional[str] = None phone: Optional[str] = None - bc_address: Optional[str] = None + head_office_address: Optional[str] = None + records_address: Optional[str] = None service_address: Optional[str] = None diff --git a/backend/lcfs/web/api/organization_snapshot/services.py b/backend/lcfs/web/api/organization_snapshot/services.py index 9c3aff2f1..05b47b720 100644 --- a/backend/lcfs/web/api/organization_snapshot/services.py +++ b/backend/lcfs/web/api/organization_snapshot/services.py @@ -37,6 +37,7 @@ async def create_organization_snapshot(self, compliance_report_id, organization_ # 2. Derive BC address and service address from OrganizationAddress bc_address = None + head_office_address = None org_address = organization.org_address if organization.org_address: bc_address_parts = [ @@ -60,7 +61,7 @@ async def create_organization_snapshot(self, compliance_report_id, organization_ org_attorney_address.country, org_attorney_address.postalCode_zipCode, ] - service_address = ", ".join(filter(None, service_addr_parts)) + head_office_address = ", ".join(filter(None, service_addr_parts)) # 3. Create the Snapshot org_snapshot = ComplianceReportOrganizationSnapshot( @@ -68,7 +69,8 @@ async def create_organization_snapshot(self, compliance_report_id, organization_ operating_name=organization.operating_name or organization.name, email=organization.email, phone=organization.phone, - bc_address=bc_address, + head_office_address=head_office_address, + records_address=organization.records_address, service_address=service_address, compliance_report_id=compliance_report_id, ) @@ -92,7 +94,8 @@ async def update(self, request_data, compliance_report_id): snapshot.operating_name = request_data.operating_name snapshot.email = request_data.email snapshot.phone = request_data.phone - snapshot.bc_address = request_data.bc_address + snapshot.head_office_address = request_data.head_office_address + snapshot.records_address = request_data.records_address snapshot.service_address = request_data.service_address snapshot.is_edited = True diff --git a/frontend/src/assets/locales/en/organization.json b/frontend/src/assets/locales/en/organization.json index dce0fb897..183ac9fe1 100644 --- a/frontend/src/assets/locales/en/organization.json +++ b/frontend/src/assets/locales/en/organization.json @@ -38,6 +38,7 @@ "bcAddrLabel": "Head office (optional)", "bcAddrLabelShort": "Head address (optional)", "bcRecordLabel": "Address in B.C. where records are maintained (optional)", + "bcRecordLabelShort": "Records address (optional)", "sameAddrLabel": "Same as address for service", "contactMsg": "to update address information.", "usersLabel": "Users", diff --git a/frontend/src/assets/locales/en/reports.json b/frontend/src/assets/locales/en/reports.json index 1ee513691..44cc408e7 100644 --- a/frontend/src/assets/locales/en/reports.json +++ b/frontend/src/assets/locales/en/reports.json @@ -11,7 +11,10 @@ }, "noReportsFound": "No compliance reports found", "serviceAddrLabel": "Address for service", - "bcAddrLabel": "Address in B.C.", + "hoAddrLabel": "Head office (optional)", + "hoAddrLabelView": "Head office address", + "hoAddrLabelEdit": "Head office address (international)", + "bcRecordLabel": "Address in B.C. (where records are maintained)", "activityHdrLabel": "Did {{name}} engage in any of the following activities between January 1, {{period}}, and December 31, {{period}}?", "reportActivities": "Report activities", "orgDetails": "Organization details", diff --git a/frontend/src/components/BCForm/BCFormText.jsx b/frontend/src/components/BCForm/BCFormText.jsx index a75ae2321..03f0fd452 100644 --- a/frontend/src/components/BCForm/BCFormText.jsx +++ b/frontend/src/components/BCForm/BCFormText.jsx @@ -1,9 +1,25 @@ import { Controller } from 'react-hook-form' -import { TextField, InputLabel } from '@mui/material' +import { + TextField, + InputLabel, + FormControlLabel, + Checkbox, + Box +} from '@mui/material' import BCTypography from '@/components/BCTypography' import PropTypes from 'prop-types' -export const BCFormText = ({ name, control, label, optional }) => { +export const BCFormText = ({ + name, + control, + label, + optional, + checkbox, + checkboxLabel, + onCheckboxChange, + isChecked, + disabled +}) => { return ( { }) => ( <> - - {label}  - {optional && ( - - (optional) - + + + {label}  + {optional && ( + + (optional) + + )} + + {checkbox && ( + + } + label={ + + {checkboxLabel} + + } + sx={{ ml: 2 }} + /> )} - + { value={value} fullWidth variant="outlined" + disabled={disabled} /> )} @@ -44,5 +85,10 @@ BCFormText.propTypes = { name: PropTypes.string.isRequired, control: PropTypes.any.isRequired, label: PropTypes.string, - setValue: PropTypes.any + optional: PropTypes.bool, + checkbox: PropTypes.bool, + checkboxLabel: PropTypes.string, + onCheckboxChange: PropTypes.func, + isChecked: PropTypes.bool, + disabled: PropTypes.bool } diff --git a/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx b/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx index 54c2f8d70..ac12526a2 100644 --- a/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx +++ b/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx @@ -21,8 +21,8 @@ export const OrganizationAddress = ({ setIsEditing }) => { const { t } = useTranslation(['common', 'report', 'org']) - const [modalData, setModalData] = useState(null) + const [sameAsService, setSameAsService] = useState(false) const validationSchema = Yup.object({ name: Yup.string().required('Legal name is required.'), @@ -33,8 +33,7 @@ export const OrganizationAddress = ({ email: Yup.string() .required('Email address is required.') .email('Please enter a valid email address.'), - serviceAddress: Yup.string().required('Service Address is required.'), - bcAddress: Yup.string().required('B.C. Address is required.') + serviceAddress: Yup.string().required('Service Address is required.') }) const formFields = (t) => [ @@ -59,15 +58,22 @@ export const OrganizationAddress = ({ label: t('report:serviceAddrLabel') }, { - name: 'bcAddress', - label: t('report:bcAddrLabel') + name: 'recordsAddress', + label: t('report:bcRecordLabel'), + checkbox: true, + checkboxLabel: 'Same as address for service' + }, + { + name: 'headOfficeAddress', + label: isEditing + ? t('report:hoAddrLabelEdit') + : t('report:hoAddrLabelView') } ] const { mutate: updateComplianceReport, isLoading: isUpdating } = useUpdateOrganizationSnapshot(complianceReportId) - // User form hook and form validation const form = useForm({ resolver: yupResolver(validationSchema), mode: 'onChange', @@ -75,6 +81,14 @@ export const OrganizationAddress = ({ }) const { handleSubmit, control, setValue, watch, reset } = form + const serviceAddress = watch('serviceAddress') + + useEffect(() => { + if (sameAsService && serviceAddress) { + setValue('recordsAddress', serviceAddress) + } + }, [sameAsService, serviceAddress, setValue]) + const onSubmit = async (data) => { await updateComplianceReport(data) setIsEditing(false) @@ -83,6 +97,10 @@ export const OrganizationAddress = ({ useEffect(() => { if (snapshotData) { reset(snapshotData) + // Check if addresses are the same and set checkbox accordingly + setSameAsService( + snapshotData.serviceAddress === snapshotData.recordsAddress + ) } }, [reset, snapshotData]) @@ -112,6 +130,13 @@ export const OrganizationAddress = ({ setIsEditing(false) } + const handleSameAddressChange = (event) => { + setSameAsService(event.target.checked) + if (event.target.checked) { + setValue('recordsAddress', serviceAddress) + } + } + return ( {!isEditing && ( @@ -152,6 +177,17 @@ export const OrganizationAddress = ({ label={field.label} name={field.name} optional={field.optional} + checkbox={field.checkbox} + checkboxLabel={field.checkboxLabel} + onCheckboxChange={ + field.name === 'recordsAddress' + ? handleSameAddressChange + : undefined + } + isChecked={ + field.name === 'recordsAddress' ? sameAsService : undefined + } + disabled={field.name === 'recordsAddress' && sameAsService} /> ))} diff --git a/frontend/src/views/Organizations/ViewOrganization/ViewOrganization.jsx b/frontend/src/views/Organizations/ViewOrganization/ViewOrganization.jsx index 1761d5fcb..1e224c527 100644 --- a/frontend/src/views/Organizations/ViewOrganization/ViewOrganization.jsx +++ b/frontend/src/views/Organizations/ViewOrganization/ViewOrganization.jsx @@ -191,6 +191,12 @@ export const ViewOrganization = () => { {t('org:bcAddrLabel')}:{' '} {orgData && constructAddress(orgData?.orgAttorneyAddress)} + {orgData.recordsAddress && ( + + {t('org:bcRecordLabelShort')}:{' '} + {orgData.recordsAddress} + + )} {t('org:regTrnLabel')}:{' '} From ee0494e5a9783317da5364600bcec5befc20f832 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 20 Feb 2025 07:45:47 -0800 Subject: [PATCH 03/16] . --- frontend/src/components/BCForm/BCFormText.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/BCForm/BCFormText.jsx b/frontend/src/components/BCForm/BCFormText.jsx index 03f0fd452..c281a8b58 100644 --- a/frontend/src/components/BCForm/BCFormText.jsx +++ b/frontend/src/components/BCForm/BCFormText.jsx @@ -33,7 +33,7 @@ export const BCFormText = ({ - {label}  + {label}:  {optional && ( (optional) @@ -48,6 +48,7 @@ export const BCFormText = ({ onChange={onCheckboxChange} size="small" sx={{ + marginTop: 0.5, '& .MuiSvgIcon-root': { border: '0.0625rem solid rgb(63, 65, 68)' } @@ -55,7 +56,7 @@ export const BCFormText = ({ /> } label={ - + {checkboxLabel} } From 140ea149bd221446d4dd5b80bef84eaf88f3e879 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 20 Feb 2025 08:48:30 -0800 Subject: [PATCH 04/16] async coordinates --- .../src/components/BCDataGrid/BCGridBase.jsx | 4 +- .../components/BCDataGrid/BCGridEditor.jsx | 6 +-- .../Editors/AsyncSuggestionEditor.jsx | 20 ++++++-- .../Filters/BCSelectFloatingFilter.jsx | 2 +- .../StatusBar/BCPaginationActions.jsx | 12 ++--- .../views/FinalSupplyEquipments/_schema.jsx | 46 +++++++++++++++++-- 6 files changed, 71 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/BCDataGrid/BCGridBase.jsx b/frontend/src/components/BCDataGrid/BCGridBase.jsx index 2f3f7709d..0a6e0b3eb 100644 --- a/frontend/src/components/BCDataGrid/BCGridBase.jsx +++ b/frontend/src/components/BCDataGrid/BCGridBase.jsx @@ -72,7 +72,7 @@ export const BCGridBase = forwardRef( }, [determineHeight]) const clearFilters = useCallback(() => { - const api = gridRef.current?.api + const api = gridRef?.current?.api if (api) { // Clear filter model api.setFilterModel(null) @@ -87,7 +87,7 @@ export const BCGridBase = forwardRef( // Expose clearFilters method through ref useImperativeHandle(ref, () => ({ - ...gridRef.current, + ...gridRef?.current, clearFilters })) diff --git a/frontend/src/components/BCDataGrid/BCGridEditor.jsx b/frontend/src/components/BCDataGrid/BCGridEditor.jsx index 34d8d6a88..c7483bd4d 100644 --- a/frontend/src/components/BCDataGrid/BCGridEditor.jsx +++ b/frontend/src/components/BCDataGrid/BCGridEditor.jsx @@ -200,12 +200,12 @@ export const BCGridEditor = ({ const onCellFocused = (params) => { if (params.column) { // Ensure the focused column is always visible - this.gridApi.ensureColumnVisible(params.column) + params.api?.ensureColumnVisible(params.column) // Scroll to make focused cell align to left - const leftPos = params.column.getLeftPosition() + const leftPos = params.column?.left if (leftPos !== null) { - this.gridApi.horizontalScrollTo(leftPos) + params.api.horizontalScroll.setScrollPosition(leftPos) } } } diff --git a/frontend/src/components/BCDataGrid/components/Editors/AsyncSuggestionEditor.jsx b/frontend/src/components/BCDataGrid/components/Editors/AsyncSuggestionEditor.jsx index 46625717c..8e7d3fb05 100644 --- a/frontend/src/components/BCDataGrid/components/Editors/AsyncSuggestionEditor.jsx +++ b/frontend/src/components/BCDataGrid/components/Editors/AsyncSuggestionEditor.jsx @@ -36,10 +36,10 @@ export const AsyncSuggestionEditor = ({ const [inputValue, setInputValue] = useState('') const apiService = useApiService() - const { data: options, isLoading } = useQuery({ + const { data: options = [], isLoading } = useQuery({ queryKey: [queryKey || 'async-suggestion', inputValue], queryFn: async ({ queryKey }) => queryFn({ client: apiService, queryKey }), - enabled: inputValue.length >= minWords && enabled, + enabled: inputValue?.length >= minWords && enabled, retry: false, refetchOnWindowFocus: false }) @@ -55,7 +55,19 @@ export const AsyncSuggestionEditor = ({ onValueChange(newInputValue) } - const handleKeyDown = (event) => { + const handleChange = (_, newValue) => { + if (typeof newValue === 'string') { + debouncedSetInputValue(newValue) + onValueChange(newValue) + } else if (newValue && typeof newValue === 'object') { + debouncedSetInputValue(newValue[optionLabel]) + onValueChange(newValue) // Set full object if option is an object + } else { + onValueChange('') + } + } + + const handleKeyDown = (event, value) => { if (onKeyDownCapture) { onKeyDownCapture(event) } else if (event.key === 'Tab') { @@ -97,6 +109,8 @@ export const AsyncSuggestionEditor = ({ includeInputInList value={value} onInputChange={handleInputChange} + filterOptions={(x) => x} + onChange={handleChange} // Handles selection and sets correct value onKeyDownCapture={handleKeyDown} loading={isLoading} noOptionsText="No suggestions..." diff --git a/frontend/src/components/BCDataGrid/components/Filters/BCSelectFloatingFilter.jsx b/frontend/src/components/BCDataGrid/components/Filters/BCSelectFloatingFilter.jsx index 3b1c3f854..ab22ca2ca 100644 --- a/frontend/src/components/BCDataGrid/components/Filters/BCSelectFloatingFilter.jsx +++ b/frontend/src/components/BCDataGrid/components/Filters/BCSelectFloatingFilter.jsx @@ -17,7 +17,7 @@ export const BCSelectFloatingFilter = ({ multiple = false, initialSelectedValues = [] }) => { - const [selectedValues, setSelectedValues] = useState([]) + const [selectedValues, setSelectedValues] = useState(multiple ? [] : '') const [options, setOptions] = useState([]) const { data: optionsData, isLoading, isError, error } = optionsQuery(params) diff --git a/frontend/src/components/BCDataGrid/components/StatusBar/BCPaginationActions.jsx b/frontend/src/components/BCDataGrid/components/StatusBar/BCPaginationActions.jsx index a9a9c3509..52ce8ab68 100644 --- a/frontend/src/components/BCDataGrid/components/StatusBar/BCPaginationActions.jsx +++ b/frontend/src/components/BCDataGrid/components/StatusBar/BCPaginationActions.jsx @@ -20,13 +20,13 @@ export function BCPaginationActions({ const [currentPage, setCurrentPage] = useState(page + 1) // Reload grid const reloadGrid = useCallback(() => { - gridRef.current.api.resetColumnState() - gridRef.current.api.setFilterModel(null) + gridRef?.current?.api.resetColumnState() + gridRef?.current?.api.setFilterModel(null) // TODO: clear custom filters }, [gridRef]) const handleCopyData = useCallback(() => { - const selectedRows = gridRef.current.api.getDataAsCsv({ + const selectedRows = gridRef?.current?.api.getDataAsCsv({ allColumns: true, onlySelected: true, skipColumnHeaders: true @@ -36,12 +36,12 @@ export function BCPaginationActions({ const handleDownloadData = useCallback(() => { const rows = [] - gridRef.current.api.forEachNodeAfterFilterAndSort((node) => { + gridRef?.current?.api.forEachNodeAfterFilterAndSort((node) => { rows.push(node.data) }) // Get column definitions and create a mapping from field to headerName - const columnDefs = gridRef.current.api.getColumnDefs() + const columnDefs = gridRef?.current?.api.getColumnDefs() const fieldToHeaderNameMap = columnDefs.reduce((map, colDef) => { map[colDef.field] = colDef.headerName return map @@ -94,7 +94,7 @@ export function BCPaginationActions({ return } setCurrentPage(newPage) - gridRef.current.api.showLoadingOverlay() + gridRef?.current?.api.showLoadingOverlay() onPageChange(event, newPage - 1) }) diff --git a/frontend/src/views/FinalSupplyEquipments/_schema.jsx b/frontend/src/views/FinalSupplyEquipments/_schema.jsx index dbd1dc973..75b0dfb74 100644 --- a/frontend/src/views/FinalSupplyEquipments/_schema.jsx +++ b/frontend/src/views/FinalSupplyEquipments/_schema.jsx @@ -285,8 +285,44 @@ export const finalSupplyEquipmentColDefs = ( headerName: i18n.t( 'finalSupplyEquipment:finalSupplyEquipmentColLabels.streetAddress' ), - cellEditor: 'agTextCellEditor', - cellDataType: 'text', + cellEditor: AsyncSuggestionEditor, + cellEditorParams: (params) => ({ + queryKey: 'fuel-code-search', + queryFn: async ({ queryKey, client }) => { + const response = await fetch( + `https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=parcelPoint&addressString=${encodeURIComponent( + queryKey[1] + )}` + ) + if (!response.ok) throw new Error('Network response was not ok') + const data = await response.json() + return data.features.map((feature) => ({ + label: feature.properties.fullAddress || '', + coordinates: feature.geometry.coordinates + })) + }, + optionLabel: 'label' + }), + valueSetter: async (params) => { + if (params.newValue === '' || params.newValue?.name === '') { + params.data.streetAddress = '' + params.data.city = '' + params.data.latitude = '' + params.data.longitude = '' + } else { + const [street = '', city = '', province = ''] = params.newValue.label + .split(',') + .map((val) => val.trim()) + const [long, lat] = params.newValue.coordinates + params.data.streetAddress = street + params.data.city = city + params.data.latitude = lat + params.data.longitude = long + } + return true + }, + cellDataType: 'object', + suppressKeyboardEvent, cellStyle: (params) => StandardCellWarningAndErrors(params, errors, warnings), minWidth: 260 @@ -337,7 +373,8 @@ export const finalSupplyEquipmentColDefs = ( cellEditor: 'agNumberCellEditor', cellEditorParams: { precision: 6, - max: 1000, + max: 90, + min: -90, showStepperButtons: false }, cellDataType: 'number', @@ -354,7 +391,8 @@ export const finalSupplyEquipmentColDefs = ( cellEditor: 'agNumberCellEditor', cellEditorParams: { precision: 6, - max: 1000, + max: 180, + min: -180, showStepperButtons: false }, cellDataType: 'number', From 88d8ff4dc0e048c192d94c558735b8667c53be36 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Fri, 21 Feb 2025 06:55:36 -0800 Subject: [PATCH 05/16] fix test cases --- .../test_organization_snapshot_views.py | 12 +- .../__tests__/OrganizationAddress.test.jsx | 103 +++++++++++++----- .../views/FinalSupplyEquipments/_schema.jsx | 2 +- .../AddEditOrg/AddEditOrgForm.jsx | 36 +++--- .../AddEditOrg/AddressAutocomplete.jsx | 47 ++++++-- .../__tests__/AddEditUser.test.jsx | 25 +++-- 6 files changed, 154 insertions(+), 71 deletions(-) diff --git a/backend/lcfs/tests/organization_snapshot/test_organization_snapshot_views.py b/backend/lcfs/tests/organization_snapshot/test_organization_snapshot_views.py index 9c08d5262..0f3e3acac 100644 --- a/backend/lcfs/tests/organization_snapshot/test_organization_snapshot_views.py +++ b/backend/lcfs/tests/organization_snapshot/test_organization_snapshot_views.py @@ -47,7 +47,8 @@ async def test_get_snapshot_by_compliance_report_id( operating_name="Example Operating Name", email="example@example.com", phone="123-456-7890", - bc_address="123 BC St.", + head_office_address="123 BC St.", + records_address="789 BC St.", service_address="456 Service Rd.", ) mock_org_snapshot_service.get_by_compliance_report_id = AsyncMock( @@ -61,7 +62,8 @@ async def test_get_snapshot_by_compliance_report_id( "operatingName": "Example Operating Name", "email": "example@example.com", "phone": "123-456-7890", - "bcAddress": "123 BC St.", + "headOfficeAddress": "123 BC St.", + "recordsAddress": "789 BC St.", "serviceAddress": "456 Service Rd.", } @@ -100,7 +102,8 @@ async def test_update_compliance_report_snapshot( operating_name="Updated Operating Name", email="updated@example.com", phone="987-654-3210", - bc_address="789 Updated BC St.", + head_office_address="789 Updated BC St.", + records_address="756 Updated BC St.", service_address="321 Updated Service Rd.", ).model_dump() @@ -111,7 +114,8 @@ async def test_update_compliance_report_snapshot( "operatingName": "Updated Operating Name", "email": "updated@example.com", "phone": "987-654-3210", - "bcAddress": "789 Updated BC St.", + "headOfficeAddress": "789 Updated BC St.", + "recordsAddress": "756 Updated BC St.", "serviceAddress": "321 Updated Service Rd.", } diff --git a/frontend/src/views/ComplianceReports/components/__tests__/OrganizationAddress.test.jsx b/frontend/src/views/ComplianceReports/components/__tests__/OrganizationAddress.test.jsx index 8fd251836..0d049a487 100644 --- a/frontend/src/views/ComplianceReports/components/__tests__/OrganizationAddress.test.jsx +++ b/frontend/src/views/ComplianceReports/components/__tests__/OrganizationAddress.test.jsx @@ -30,8 +30,10 @@ describe('OrganizationAddress', () => { phone: '250-123-4567', email: 'info@acme.com', serviceAddress: '123 Main St.', - bcAddress: '456 BC St.' + recordsAddress: '456 BC St.', + headOfficeAddress: '789 HQ St.' } + setIsEditingMock = vi.fn() mockMutate = vi.fn() @@ -40,9 +42,10 @@ describe('OrganizationAddress', () => { OrganizationSnapshotHooks, 'useOrganizationSnapshot' ).mockReturnValue({ - data: {}, + data: snapshotData, isLoading: false }) + vi.spyOn( OrganizationSnapshotHooks, 'useUpdateOrganizationSnapshot' @@ -68,44 +71,56 @@ describe('OrganizationAddress', () => { expect(screen.getByText(snapshotData.name)).toBeInTheDocument() expect(screen.getByText('org:operatingNameLabel:')).toBeInTheDocument() expect(screen.getByText(snapshotData.operatingName)).toBeInTheDocument() + expect(screen.getByText('org:phoneNbrLabel:')).toBeInTheDocument() + expect(screen.getByText(snapshotData.phone)).toBeInTheDocument() }) - it('renders the form in editing mode', () => { + it('renders the form in editing mode', async () => { render( , { wrapper } ) - // Expect form fields for editing - expect(screen.getByLabelText('org:legalNameLabel')).toBeInTheDocument() - expect(screen.getByLabelText('org:operatingNameLabel')).toBeInTheDocument() - expect(screen.getByLabelText('org:phoneNbrLabel')).toBeInTheDocument() + // Use getByRole with name to find inputs + expect( + screen.getByRole('textbox', { name: /org:legalNameLabel/i }) + ).toHaveValue(snapshotData.name) + expect( + screen.getByRole('textbox', { name: /org:operatingNameLabel/i }) + ).toHaveValue(snapshotData.operatingName) + expect( + screen.getByRole('textbox', { name: /org:phoneNbrLabel/i }) + ).toHaveValue(snapshotData.phone) }) - it('calls mutate on form submit', async () => { + it('calls mutate on form submit with valid data', async () => { + const user = userEvent.setup() + render( , { wrapper } ) - // Change phone - const phoneInput = screen.getByLabelText('org:phoneNbrLabel') - await userEvent.clear(phoneInput) - await userEvent.type(phoneInput, '999-999-9999') + // Update phone with valid format using getByRole + const phoneInput = screen.getByRole('textbox', { + name: /org:phoneNbrLabel/i + }) + await user.clear(phoneInput) + await user.type(phoneInput, '999-999-9999') - // Submit the form + // Submit form const saveButton = screen.getByRole('button', { name: 'saveBtn' }) - await userEvent.click(saveButton) + await user.click(saveButton) // Validate mutate call await waitFor(() => { @@ -117,29 +132,63 @@ describe('OrganizationAddress', () => { }) }) - it('clicking Cancel resets data and exits edit mode', async () => { + it('shows validation errors for invalid data', async () => { + const user = userEvent.setup() + + render( + , + { wrapper } + ) + + // Enter invalid phone number using getByRole + const phoneInput = screen.getByRole('textbox', { + name: /org:phoneNbrLabel/i + }) + await user.clear(phoneInput) + await user.type(phoneInput, '123') // Invalid format + + // Submit form + const saveButton = screen.getByRole('button', { name: 'saveBtn' }) + await user.click(saveButton) + + // Check validation error + expect( + await screen.findByText('Phone number is not valid') + ).toBeInTheDocument() + expect(mockMutate).not.toHaveBeenCalled() + }) + + it('clicking Cancel resets form and exits edit mode', async () => { + const user = userEvent.setup() + render( , { wrapper } ) - // Change phone - const phoneInput = screen.getByLabelText('org:phoneNbrLabel') - await userEvent.clear(phoneInput) - await userEvent.type(phoneInput, '999-999-9999') + // Change form values using getByRole + const phoneInput = screen.getByRole('textbox', { + name: /org:phoneNbrLabel/i + }) + await user.clear(phoneInput) + await user.type(phoneInput, '999-999-9999') - // Cancel + // Click cancel const cancelButton = screen.getByRole('button', { name: 'cancelBtn' }) - await userEvent.click(cancelButton) + await user.click(cancelButton) - // Make sure edit mode is off + // Verify reset and edit mode exit expect(setIsEditingMock).toHaveBeenCalledWith(false) - // (We could confirm phoneInput is reset if the form remains mounted) - expect(phoneInput.value).toBe(snapshotData.phone) + expect(phoneInput).toHaveValue(snapshotData.phone) }) }) diff --git a/frontend/src/views/FinalSupplyEquipments/_schema.jsx b/frontend/src/views/FinalSupplyEquipments/_schema.jsx index 75b0dfb74..d6e74b477 100644 --- a/frontend/src/views/FinalSupplyEquipments/_schema.jsx +++ b/frontend/src/views/FinalSupplyEquipments/_schema.jsx @@ -290,7 +290,7 @@ export const finalSupplyEquipmentColDefs = ( queryKey: 'fuel-code-search', queryFn: async ({ queryKey, client }) => { const response = await fetch( - `https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=parcelPoint&addressString=${encodeURIComponent( + `https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=frontDoorPoint&addressString=${encodeURIComponent( queryKey[1] )}` ) diff --git a/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx b/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx index a7ee62dcb..ed161dc6e 100644 --- a/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx +++ b/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx @@ -593,26 +593,22 @@ export const AddEditOrgForm = () => { {t('org:streetAddrLabel')}: - {/* { - if (typeof address === 'string') { - setValue('orgStreetAddress', address) - } else { - setValue('orgStreetAddress', address.streetAddress) - setValue('orgCity', address.city) - } - }} - /> */} - ( + { + if (typeof address === 'string') { + setValue('orgStreetAddress', address) + } else { + setValue('orgStreetAddress', address.streetAddress) + setValue('orgCity', address.city) + } + }} + /> + )} /> diff --git a/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx b/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx index bb8121b5f..550b68cbf 100644 --- a/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx +++ b/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx @@ -1,5 +1,8 @@ import React, { useState, useEffect, forwardRef } from 'react' -import { TextField, Autocomplete, Box } from '@mui/material' +import { TextField, Autocomplete, Box, Grid } from '@mui/material' +import { LocationOn as LocationOnIcon } from '@mui/icons-material' +import parse from 'autosuggest-highlight/parse' +import match from 'autosuggest-highlight/match' const AddressAutocomplete = forwardRef( ({ value, onChange, onSelectAddress }, ref) => { @@ -20,7 +23,7 @@ const AddressAutocomplete = forwardRef( setLoading(true) try { const response = await fetch( - `https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=parcelPoint&addressString=${encodeURIComponent( + `https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=frontDoorPoint&addressString=${encodeURIComponent( inputValue )}`, { signal } @@ -103,11 +106,41 @@ const AddressAutocomplete = forwardRef( )} - renderOption={(props, option) => ( -
  • - {option.fullAddress} -
  • - )} + renderOption={(props, option) => { + const { key, ...optionProps } = props + const [street, city, province] = option.fullAddress.split(', ') + const matches = match(option.fullAddress, inputValue, { + insideWords: true + }) + + const parts = parse(option.fullAddress, matches) + return ( +
  • + + + + + + {parts.map((part, index) => ( + + {part.text} + + ))} + + +
  • + ) + }} /> ) } diff --git a/frontend/src/views/Users/AddEditUser/__tests__/AddEditUser.test.jsx b/frontend/src/views/Users/AddEditUser/__tests__/AddEditUser.test.jsx index ce4db8dc5..3f2cbcf20 100644 --- a/frontend/src/views/Users/AddEditUser/__tests__/AddEditUser.test.jsx +++ b/frontend/src/views/Users/AddEditUser/__tests__/AddEditUser.test.jsx @@ -35,7 +35,9 @@ vi.mock('@/stores/useUserStore', () => ({ })) async function typeAndValidateTextBox(name, value) { - const textBox = screen.getByRole('textbox', { name }) + const textBox = screen.getByRole('textbox', { + name: new RegExp(`^${name}`, 'i') + }) expect(textBox).toBeInTheDocument() await userEvent.type(textBox, value, { delay: 10 }) expect(textBox).toHaveValue(value) @@ -57,10 +59,9 @@ describe('AddEditUser component', () => { it('renders the form to add IDIR user', async () => { const { container } = render(, { wrapper }) - // Check for the heading with the name "Add user" - const addUserHeading = screen.getByRole('heading', { name: 'Add user' }) - expect(addUserHeading).toBeInTheDocument() - // Check if the container HTML element contains the form + expect( + screen.getByRole('heading', { name: /Add user/i }) + ).toBeInTheDocument() expect(container.querySelector('form#user-form')).toBeInTheDocument() // Check for form fields await typeAndValidateTextBox('First name', 'John') @@ -68,8 +69,8 @@ describe('AddEditUser component', () => { await typeAndValidateTextBox('Job title', 'Analyst') await typeAndValidateTextBox('IDIR user name', 'johndoe') await typeAndValidateTextBox('Email address', 'test@test.com') - await typeAndValidateTextBox('Phone (optional)', '555-555-5555') - await typeAndValidateTextBox('Mobile phone (optional)', '555-555-5555') + await typeAndValidateTextBox('Phone', '555-555-5555') + await typeAndValidateTextBox('Mobile phone', '555-555-5555') const saveButton = screen.getByRole('button', { name: /save/i }) userEvent.click(saveButton) @@ -81,18 +82,18 @@ describe('AddEditUser component', () => { // Check for form fields await typeAndValidateTextBox('First name', 'John') await typeAndValidateTextBox('Last name', 'Doe') - await typeAndValidateTextBox('Job title (optional)', 'Compliance manager') + await typeAndValidateTextBox('Job title', 'Compliance manager') await typeAndValidateTextBox('BCeID Userid', 'johndoe') await typeAndValidateTextBox( 'Email address associated with the BCeID user account', 'test@test.com' ) await typeAndValidateTextBox( - 'Alternate email for notifications (optional)', + 'Alternate email for notifications', 'test@test.com' ) - await typeAndValidateTextBox('Phone (optional)', '555-555-5555') - await typeAndValidateTextBox('Mobile phone (optional)', '555-555-5555') + await typeAndValidateTextBox('Phone', '555-555-5555') + await typeAndValidateTextBox('Mobile phone', '555-555-5555') const saveButton = screen.getByRole('button', { name: /save/i }) userEvent.click(saveButton) @@ -118,7 +119,7 @@ describe('AddEditUser component', () => { ).toBeInTheDocument() expect(await screen.findByText('User name is required')).toBeInTheDocument() const phoneNumber = screen.getByRole('textbox', { - name: 'Phone (optional)' + name: /Phone/i }) await userEvent.type(phoneNumber, '1234') expect( From efa030d0bbc7d62af8f300930ff12dd454b1bc33 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Fri, 21 Feb 2025 08:35:24 -0800 Subject: [PATCH 06/16] fix tests --- .../AddEditOrg/__tests__/AddEditOrg.test.jsx | 20 ++++++++++--------- .../__tests__/AddEditUser.test.jsx | 4 +--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/views/Organizations/AddEditOrg/__tests__/AddEditOrg.test.jsx b/frontend/src/views/Organizations/AddEditOrg/__tests__/AddEditOrg.test.jsx index 8019d6144..f5ff192e3 100644 --- a/frontend/src/views/Organizations/AddEditOrg/__tests__/AddEditOrg.test.jsx +++ b/frontend/src/views/Organizations/AddEditOrg/__tests__/AddEditOrg.test.jsx @@ -1,6 +1,6 @@ import React from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { AddEditOrg } from '../AddEditOrg' +import { AddEditOrgForm } from '../AddEditOrgForm' import { useForm, FormProvider } from 'react-hook-form' import { vi, describe, it, expect, beforeEach } from 'vitest' import { useOrganization } from '@/hooks/useOrganization' @@ -34,6 +34,7 @@ const mockedOrg = { email: 'test@example.com', phone: '123-456-7890', edrmsRecord: 'EDRMS123', + recordsAddress: '789 Test St, City, Province, A1B2C3', orgAddress: { streetAddress: '123 Test St', addressOther: '', @@ -44,7 +45,9 @@ const mockedOrg = { streetAddress: '456 Attorney Rd', addressOther: '', city: 'Attorney City', - postalcodeZipcode: 'D4E5F6' + postalcodeZipcode: 'D4E5F6', + provinceState: 'BC', + country: 'Canada' }, orgStatus: { organizationStatusId: 2 } } @@ -71,7 +74,7 @@ describe('AddEditOrg', () => { useApiService.mockReturnValue(apiSpy) }) - it('renders correctly with provided organization data', () => { + it('renders correctly with provided organization data and maps all address fields correctly', () => { useOrganization.mockReturnValue({ data: mockedOrg, isFetched: true @@ -79,7 +82,7 @@ describe('AddEditOrg', () => { render( - + , { wrapper } ) @@ -95,18 +98,17 @@ describe('AddEditOrg', () => { '123-456-7890' ) expect(screen.getAllByLabelText(/org:streetAddrLabel/i)[0]).toHaveValue( - '123 Test St' + '456 Attorney Rd' ) expect(screen.getAllByLabelText(/org:cityLabel/i)[0]).toHaveValue( 'Test City' ) - expect(screen.getAllByLabelText(/org:poLabel/i)[0]).toHaveValue('A1B2C3') }) it('renders required errors in the form correctly', async () => { render( - + , { wrapper } ) @@ -147,7 +149,7 @@ describe('AddEditOrg', () => { render( - + , { wrapper } ) @@ -176,7 +178,7 @@ describe('AddEditOrg', () => { render( - + , { wrapper } ) diff --git a/frontend/src/views/Users/AddEditUser/__tests__/AddEditUser.test.jsx b/frontend/src/views/Users/AddEditUser/__tests__/AddEditUser.test.jsx index 3f2cbcf20..55309b644 100644 --- a/frontend/src/views/Users/AddEditUser/__tests__/AddEditUser.test.jsx +++ b/frontend/src/views/Users/AddEditUser/__tests__/AddEditUser.test.jsx @@ -118,9 +118,7 @@ describe('AddEditUser component', () => { await screen.findByText('Email address is required.') ).toBeInTheDocument() expect(await screen.findByText('User name is required')).toBeInTheDocument() - const phoneNumber = screen.getByRole('textbox', { - name: /Phone/i - }) + const phoneNumber = screen.getAllByLabelText(/Phone/i)[0] await userEvent.type(phoneNumber, '1234') expect( await screen.findByText('Phone number is not valid') From ace3a2a7ba198b939958843dee02282cf977c771 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 27 Feb 2025 12:06:26 -0800 Subject: [PATCH 07/16] updates --- .../BCForm}/AddressAutocomplete.jsx | 4 +- .../BCForm/BCFormAddressAutocomplete.jsx | 95 +++ frontend/src/components/BCForm/index.js | 2 + frontend/src/themes/base/globals.js | 3 + .../components/OrganizationAddress.jsx | 131 +-- .../FinalSupplyEquipmentSummary.jsx | 21 + .../FinalSupplyEquipments/MapComponent.jsx | 747 ++++++++++++++++++ .../AddEditOrg/AddEditOrgForm.jsx | 3 +- 8 files changed, 955 insertions(+), 51 deletions(-) rename frontend/src/{views/Organizations/AddEditOrg => components/BCForm}/AddressAutocomplete.jsx (98%) create mode 100644 frontend/src/components/BCForm/BCFormAddressAutocomplete.jsx create mode 100644 frontend/src/views/FinalSupplyEquipments/MapComponent.jsx diff --git a/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx b/frontend/src/components/BCForm/AddressAutocomplete.jsx similarity index 98% rename from frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx rename to frontend/src/components/BCForm/AddressAutocomplete.jsx index 550b68cbf..e91d25282 100644 --- a/frontend/src/views/Organizations/AddEditOrg/AddressAutocomplete.jsx +++ b/frontend/src/components/BCForm/AddressAutocomplete.jsx @@ -4,7 +4,7 @@ import { LocationOn as LocationOnIcon } from '@mui/icons-material' import parse from 'autosuggest-highlight/parse' import match from 'autosuggest-highlight/match' -const AddressAutocomplete = forwardRef( +export const AddressAutocomplete = forwardRef( ({ value, onChange, onSelectAddress }, ref) => { const [inputValue, setInputValue] = useState(value || '') const [options, setOptions] = useState([]) @@ -147,5 +147,3 @@ const AddressAutocomplete = forwardRef( ) AddressAutocomplete.displayName = 'AddressAutocomplete' - -export default AddressAutocomplete diff --git a/frontend/src/components/BCForm/BCFormAddressAutocomplete.jsx b/frontend/src/components/BCForm/BCFormAddressAutocomplete.jsx new file mode 100644 index 000000000..b2e14663d --- /dev/null +++ b/frontend/src/components/BCForm/BCFormAddressAutocomplete.jsx @@ -0,0 +1,95 @@ +import React from 'react' +import { Controller } from 'react-hook-form' +import { InputLabel, Box } from '@mui/material' +import BCTypography from '@/components/BCTypography' +import PropTypes from 'prop-types' +import FormControlLabel from '@mui/material/FormControlLabel' +import Checkbox from '@mui/material/Checkbox' +import { AddressAutocomplete } from './AddressAutocomplete' + +export const BCFormAddressAutocomplete = ({ + name, + control, + label, + optional, + checkbox, + checkboxLabel, + onCheckboxChange, + isChecked, + disabled, + onSelectAddress +}) => { + return ( + ( + <> + + + + {label}:  + {optional && ( + + (optional) + + )} + + {checkbox && ( + + } + label={ + + {checkboxLabel} + + } + sx={{ ml: 2 }} + /> + )} + + + + {error && ( + + {error.message} + + )} + + )} + /> + ) +} + +BCFormAddressAutocomplete.propTypes = { + name: PropTypes.string.isRequired, + control: PropTypes.any.isRequired, + label: PropTypes.string, + optional: PropTypes.bool, + checkbox: PropTypes.bool, + checkboxLabel: PropTypes.string, + onCheckboxChange: PropTypes.func, + isChecked: PropTypes.bool, + disabled: PropTypes.bool, + onSelectAddress: PropTypes.func +} diff --git a/frontend/src/components/BCForm/index.js b/frontend/src/components/BCForm/index.js index bce90b3aa..66873c11c 100644 --- a/frontend/src/components/BCForm/index.js +++ b/frontend/src/components/BCForm/index.js @@ -3,3 +3,5 @@ export * from './BCFormCheckbox' export * from './BCFormRadio' export * from './BCFormSelect' export * from './BCFormText' +export * from './AddressAutocomplete' +export * from './BCFormAddressAutocomplete' diff --git a/frontend/src/themes/base/globals.js b/frontend/src/themes/base/globals.js index c12623c25..23f6d1cd3 100644 --- a/frontend/src/themes/base/globals.js +++ b/frontend/src/themes/base/globals.js @@ -249,6 +249,9 @@ const globals = { height: '1.15rem', width: '6rem' }, + 'a .leaflet-attribution-flag': { + visibility: 'hidden' + }, '#link-idir': { textAlign: 'right', color: `${primary.main}`, diff --git a/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx b/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx index ac12526a2..ffe8bf4b6 100644 --- a/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx +++ b/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx @@ -3,7 +3,7 @@ import BCTypography from '@/components/BCTypography' import { useTranslation } from 'react-i18next' import { useUpdateOrganizationSnapshot } from '@/hooks/useOrganizationSnapshot.js' import { FormProvider, useForm } from 'react-hook-form' -import { BCFormText } from '@/components/BCForm/index.js' +import { BCFormText, BCFormAddressAutocomplete } from '@/components/BCForm/index.js' import { yupResolver } from '@hookform/resolvers/yup' import { defaultValues } from '@/views/Users/AddEditUser/_schema.js' import { Box, Stack, List, ListItem } from '@mui/material' @@ -23,6 +23,7 @@ export const OrganizationAddress = ({ const { t } = useTranslation(['common', 'report', 'org']) const [modalData, setModalData] = useState(null) const [sameAsService, setSameAsService] = useState(false) + const [selectedServiceAddress, setSelectedServiceAddress] = useState(null) const validationSchema = Yup.object({ name: Yup.string().required('Legal name is required.'), @@ -36,41 +37,6 @@ export const OrganizationAddress = ({ serviceAddress: Yup.string().required('Service Address is required.') }) - const formFields = (t) => [ - { - name: 'name', - label: t('org:legalNameLabel') - }, - { - name: 'operatingName', - label: t('org:operatingNameLabel') - }, - { - name: 'phone', - label: t('org:phoneNbrLabel') - }, - { - name: 'email', - label: t('org:emailAddrLabel') - }, - { - name: 'serviceAddress', - label: t('report:serviceAddrLabel') - }, - { - name: 'recordsAddress', - label: t('report:bcRecordLabel'), - checkbox: true, - checkboxLabel: 'Same as address for service' - }, - { - name: 'headOfficeAddress', - label: isEditing - ? t('report:hoAddrLabelEdit') - : t('report:hoAddrLabelView') - } - ] - const { mutate: updateComplianceReport, isLoading: isUpdating } = useUpdateOrganizationSnapshot(complianceReportId) @@ -137,6 +103,70 @@ export const OrganizationAddress = ({ } } + const handleSelectServiceAddress = (addressData) => { + setSelectedServiceAddress(addressData) + if (typeof addressData === 'string') { + setValue('serviceAddress', addressData) + } else { + setValue('serviceAddress', addressData.fullAddress) + } + + // If "same as service address" is checked, update records address too + if (sameAsService) { + if (typeof addressData === 'string') { + setValue('recordsAddress', addressData) + } else { + setValue('recordsAddress', addressData.fullAddress) + } + } + } + + // Define which form fields use regular text input vs address autocomplete + const textFormFields = [ + { + name: 'name', + label: t('org:legalNameLabel') + }, + { + name: 'operatingName', + label: t('org:operatingNameLabel') + }, + { + name: 'phone', + label: t('org:phoneNbrLabel') + }, + { + name: 'email', + label: t('org:emailAddrLabel') + }, + { + name: 'headOfficeAddress', + label: isEditing + ? t('report:hoAddrLabelEdit') + : t('report:hoAddrLabelView') + } + ] + + const addressFormFields = [ + { + name: 'serviceAddress', + label: t('report:serviceAddrLabel'), + onSelectAddress: handleSelectServiceAddress + }, + { + name: 'recordsAddress', + label: t('report:bcRecordLabel'), + checkbox: true, + checkboxLabel: 'Same as address for service', + onCheckboxChange: handleSameAddressChange, + isChecked: sameAsService, + disabled: sameAsService + } + ] + + // Combined fields for display in view mode + const allFormFields = [...textFormFields, ...addressFormFields] + return ( {!isEditing && ( @@ -152,7 +182,7 @@ export const OrganizationAddress = ({ } }} > - {formFields(t).map(({ name, label }) => ( + {allFormFields.map(({ name, label }) => ( {label}:{' '} {snapshotData[name] || ( @@ -169,7 +199,8 @@ export const OrganizationAddress = ({
    - {formFields(t).map((field) => ( + {/* Regular text input fields */} + {textFormFields.map((field) => ( + ))} + + {/* Address autocomplete fields */} + {addressFormFields.map((field) => ( + ))} diff --git a/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx b/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx index 685256c5f..f761c34c4 100644 --- a/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx +++ b/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx @@ -10,11 +10,15 @@ import { useLocation, useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses.js' import { finalSupplyEquipmentSummaryColDefs } from '@/views/FinalSupplyEquipments/_schema.jsx' +import MapComponent from './MapComponent' +import FormControlLabel from '@mui/material/FormControlLabel' +import Switch from '@mui/material/Switch' export const FinalSupplyEquipmentSummary = ({ data, status }) => { const [alertMessage, setAlertMessage] = useState('') const [alertSeverity, setAlertSeverity] = useState('info') const [gridKey, setGridKey] = useState('final-supply-equipments-grid') + const [showMap, setShowMap] = useState(false) const { complianceReportId } = useParams() const gridRef = useRef() @@ -91,6 +95,23 @@ export const FinalSupplyEquipmentSummary = ({ data, status }) => { suppressPagination={data.finalSupplyEquipments.length <= 10} /> + <> + {/* Toggle Map Switch */} + setShowMap(!showMap)} + /> + } + label={showMap ? 'Hide Map' : 'Show Map'} + sx={{ mt: 2 }} + /> + + {/* Conditional Rendering of MapComponent */} + {showMap && } + ) } diff --git a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx new file mode 100644 index 000000000..d66894f27 --- /dev/null +++ b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx @@ -0,0 +1,747 @@ +import React, { useEffect, useState, useCallback } from 'react' +import 'leaflet/dist/leaflet.css' +import L from 'leaflet' +import { useGetFinalSupplyEquipments } from '@/hooks/useFinalSupplyEquipment' + +// Fix Leaflet's default icon issue +delete L.Icon.Default.prototype._getIconUrl +L.Icon.Default.mergeOptions({ + iconRetinaUrl: + 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png' +}) + +// Custom marker for locations outside BC +const outsideBCIcon = new L.Icon({ + iconUrl: + 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png', + shadowUrl: + 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34] +}) + +// Custom marker for locations with overlapping periods +const overlapIcon = new L.Icon({ + iconUrl: + 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png', + shadowUrl: + 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34] +}) + +// Loading status icon +const loadingIcon = new L.Icon({ + iconUrl: + 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-grey.png', + shadowUrl: + 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34] +}) + +// Geofencing approach using Nominatim for reverse geocoding +const checkLocationInBC = async (lat, lng) => { + try { + // Using OpenStreetMap's Nominatim service for reverse geocoding + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=10&addressdetails=1`, + { + headers: { + 'User-Agent': 'BC-Travel-Planner/1.0' + } + } + ) + + if (!response.ok) { + throw new Error('Reverse geocoding request failed') + } + + const data = await response.json() + + // Check if the location is in British Columbia + const state = data.address?.state || data.address?.province || '' + const country = data.address?.country || '' + const stateDistrict = data.address?.state_district || '' + + // Return true if it's explicitly BC, or likely in BC based on surrounding data + return ( + (state.toLowerCase().includes('british columbia') || + stateDistrict.toLowerCase().includes('british columbia')) && + country.toLowerCase() === 'canada' + ) + } catch (error) { + console.error('Error checking location with geofencing:', error) + // Fallback to simple boundary check if the API fails + return lat > 48.0 && lat < 60.0 && lng > -139.0 && lng < -114.03 + } +} + +// Batch process location geofencing checks +const batchProcessGeofencing = async (locations) => { + const results = {} + const batchSize = 3 // Process 3 locations at a time + + for (let i = 0; i < locations.length; i += batchSize) { + const batch = locations.slice(i, i + batchSize) + + // Process this batch in parallel + const batchPromises = batch.map(async (loc) => { + const isInBC = await checkLocationInBC(loc.lat, loc.lng) + return { id: loc.id, isInBC } + }) + + const batchResults = await Promise.all(batchPromises) + + // Add batch results to the overall results + batchResults.forEach(({ id, isInBC }) => { + results[id] = isInBC + }) + + // Add a small delay to avoid rate limiting + if (i + batchSize < locations.length) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + return results +} + +// Check if date ranges overlap between two locations +const datesOverlap = (start1, end1, start2, end2) => { + const s1 = new Date(start1) + const e1 = new Date(end1) + const s2 = new Date(start2) + const e2 = new Date(end2) + + return s1 <= e2 && s2 <= e1 +} + +// Find all overlapping periods for a given location, only considering same ID +const findOverlappingPeriods = (currentLoc, allLocations) => { + // Extract the base ID (registrationNbr) from the combined ID + const currentRegNum = currentLoc.id.split('_')[0] + const currentSerialNum = currentLoc.id.split('_')[1] + + return allLocations + .filter((loc) => { + // Split the comparison location's ID + const locRegNum = loc.id.split('_')[0] + const locSerialNum = loc.id.split('_')[1] + + // Only check for overlap if this is the same ID but different record + return ( + currentLoc.uniqueId !== loc.uniqueId && // Different record + currentRegNum === locRegNum && // Same registration number + currentSerialNum === locSerialNum && // Same serial number + datesOverlap( + currentLoc.supplyFromDate, + currentLoc.supplyToDate, + loc.supplyFromDate, + loc.supplyToDate + ) + ) + }) + .map((loc) => ({ + id: loc.id, + uniqueId: loc.uniqueId, + name: loc.name, + regNum: loc.id.split('_')[0], + serialNum: loc.id.split('_')[1], + supplyFromDate: loc.supplyFromDate, + supplyToDate: loc.supplyToDate + })) +} + +// Group locations by their coordinates +const groupLocationsByCoordinates = (locations) => { + const grouped = {} + + locations.forEach((location) => { + // Create a key based on coordinates (rounded to reduce floating point issues) + const key = `${location.lat.toFixed(6)},${location.lng.toFixed(6)}` + + if (!grouped[key]) { + grouped[key] = [] + } + + grouped[key].push(location) + }) + + return grouped +} + +const MapComponent = ({ complianceReportId }) => { + const [locations, setLocations] = useState([]) + const [groupedLocations, setGroupedLocations] = useState({}) + const [error, setError] = useState(null) + const [overlapMap, setOverlapMap] = useState({}) + const [geofencingResults, setGeofencingResults] = useState({}) + const [geofencingStatus, setGeofencingStatus] = useState('idle') + const [overlapStats, setOverlapStats] = useState({ + total: 0, + overlapping: 0, + nonOverlapping: 0, + bcOverlapping: 0, + nonBcOverlapping: 0 + }) + + // Initial pagination state + const [pagination, setPagination] = useState({ + page: 1, + pageSize: 1000 // Adjust as needed based on typical data size + }) + + // Use React Query to fetch data + const { + data: supplyEquipmentData, + isLoading, + isError, + refetch + } = useGetFinalSupplyEquipments(complianceReportId) + + // Transform API data to match the expected format + const transformApiData = useCallback((data) => { + if (!data || !data.finalSupplyEquipments) return [] + + return data.finalSupplyEquipments.map((row, index) => { + // Create a combined ID from registrationNbr and serialNbr + const registrationNbr = row.registrationNbr || 'unknown' + const serialNbr = row.serialNbr || 'unknown' + const combinedId = `${registrationNbr}_${serialNbr}` + + return { + // Store the combined ID as the main identifier + id: combinedId, + // Add a unique identifier for this specific record + uniqueId: `${combinedId}_${index}`, + registrationNbr, + serialNbr, + name: + `${row.streetAddress || ''}, ${row.city || ''}, ${ + row.postalCode || '' + }`.trim() || `Location ${index}`, + lat: parseFloat(row.latitude) || 0, + lng: parseFloat(row.longitude) || 0, + supplyFromDate: + row.supplyFromDate || new Date().toISOString().split('T')[0], + supplyToDate: row.supplyToDate || new Date().toISOString().split('T')[0] + } + }) + }, []) + + // Update locations when data changes + useEffect(() => { + if (supplyEquipmentData) { + const transformedData = transformApiData(supplyEquipmentData) + + if (transformedData.length === 0) { + console.warn('No location data found in API response') + setError(new Error('No location data found')) + } else { + setLocations(transformedData) + + // Group locations by coordinates + const grouped = groupLocationsByCoordinates(transformedData) + setGroupedLocations(grouped) + + setError(null) + } + } + }, [supplyEquipmentData, transformApiData]) + + // Run geofencing when locations are loaded + useEffect(() => { + if (locations.length > 0 && geofencingStatus === 'idle') { + setGeofencingStatus('loading') + + // We only need to check one location per coordinate group + const uniqueLocations = Object.values(groupedLocations).map( + (group) => group[0] + ) + + // Start the geofencing process + batchProcessGeofencing(uniqueLocations) + .then((results) => { + // Expand results to all locations with the same coordinates + const expandedResults = {} + + Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { + // Get the result from the first location in this group + const firstLocId = locGroup[0].id + const isInBC = results[firstLocId] + + // Apply the same result to all locations in this group + locGroup.forEach((loc) => { + expandedResults[loc.id] = isInBC + }) + }) + + setGeofencingResults(expandedResults) + setGeofencingStatus('completed') + console.log('Geofencing results:', expandedResults) + }) + .catch((error) => { + console.error('Error during geofencing:', error) + setGeofencingStatus('error') + }) + } + }, [locations, groupedLocations, geofencingStatus]) + + // Calculate overlaps when locations and geofencing are completed + useEffect(() => { + if (locations.length > 0 && geofencingStatus === 'completed') { + const overlaps = {} + const stats = { + total: locations.length, + overlapping: 0, + nonOverlapping: 0, + bcOverlapping: 0, + nonBcOverlapping: 0 + } + + // Calculate overlaps + locations.forEach((loc) => { + const overlappingPeriods = findOverlappingPeriods(loc, locations) + overlaps[loc.uniqueId] = overlappingPeriods + + const isInBC = geofencingResults[loc.id] + const hasOverlap = overlappingPeriods.length > 0 + + if (hasOverlap) { + stats.overlapping++ + if (isInBC) { + stats.bcOverlapping++ + } else { + stats.nonBcOverlapping++ + } + } else { + stats.nonOverlapping++ + } + }) + + setOverlapMap(overlaps) + setOverlapStats(stats) + console.log('Period overlaps:', overlaps) + console.log('Overlap statistics:', stats) + } + }, [locations, geofencingResults, geofencingStatus]) + + // Initialize and update map + useEffect(() => { + // Only run this on the client side + if (typeof window === 'undefined') return + if (Object.keys(groupedLocations).length === 0) return + + let mapInstance = null + + // Get the container element + const container = document.getElementById('map-container') + if (!container) return + + // Initialize map - center it on a view that shows all of BC + mapInstance = L.map('map-container').setView([53.7267, -127.6476], 5) + + // Add tile layer + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: + '© OpenStreetMap contributors' + }).addTo(mapInstance) + + // Add markers for each location group + const markers = {} + + Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { + const firstLoc = locGroup[0] + const locationCoords = [firstLoc.lat, firstLoc.lng] + + // Get unique registration/serial combinations at this location + const uniqueSupplyUnits = {} + locGroup.forEach((loc) => { + if (!uniqueSupplyUnits[loc.id]) { + uniqueSupplyUnits[loc.id] = { + regNum: loc.registrationNbr, + serialNum: loc.serialNbr, + records: [] + } + } + uniqueSupplyUnits[loc.id].records.push(loc) + }) + + // Start with loading icon for all markers + const marker = L.marker(locationCoords, { + icon: loadingIcon + }).addTo(mapInstance) + + // Store marker reference for updating later + markers[coordKey] = marker + + // Create a popup showing all units at this location + const popupContent = ` + ${firstLoc.name}
    + Total Supply Units at this location: ${ + Object.keys(uniqueSupplyUnits).length + }
    + ${geofencingStatus === 'loading' ? '

    Checking location...

    ' : ''} + ` + + marker.bindPopup(popupContent) + }) + + // Update markers when geofencing results are available + if ( + geofencingStatus === 'completed' && + Object.keys(overlapMap).length > 0 + ) { + Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { + const marker = markers[coordKey] + if (!marker) return + + const firstLoc = locGroup[0] + const isInBC = geofencingResults[firstLoc.id] + + // Check if any locations in this group have overlaps + const hasAnyOverlaps = locGroup.some( + (loc) => + overlapMap[loc.uniqueId] && overlapMap[loc.uniqueId].length > 0 + ) + + // Update marker icon based on geofencing and overlap status + let markerIcon = new L.Icon.Default() + if (!isInBC) { + markerIcon = outsideBCIcon + } else if (hasAnyOverlaps) { + markerIcon = overlapIcon + } + + marker.setIcon(markerIcon) + + // Get unique supply units at this location + const uniqueSupplyUnits = {} + locGroup.forEach((loc) => { + if (!uniqueSupplyUnits[loc.id]) { + uniqueSupplyUnits[loc.id] = { + regNum: loc.registrationNbr, + serialNum: loc.serialNbr, + hasOverlap: false, + records: [] + } + } + + // Check if this specific record has overlaps + const hasOverlap = + overlapMap[loc.uniqueId] && overlapMap[loc.uniqueId].length > 0 + if (hasOverlap) { + uniqueSupplyUnits[loc.id].hasOverlap = true + } + + uniqueSupplyUnits[loc.id].records.push({ + ...loc, + hasOverlap + }) + }) + + // Create updated popup content + let popupContent = ` + ${firstLoc.name}
    + Total Supply Units at this location: ${ + Object.keys(uniqueSupplyUnits).length + } + ${!isInBC ? '

    🚨 Outside BC!

    ' : ''} + ` + + // List all supply units with their IDs and periods + popupContent += ` +

    Supply Units at this location:

    +
    + + + + + + + + + + + ` + + Object.values(uniqueSupplyUnits).forEach((unit) => { + const statusColor = unit.hasOverlap ? 'orange' : 'green' + const statusIcon = unit.hasOverlap ? '⚠️' : '✓' + const statusText = unit.hasOverlap ? 'Period overlap' : 'No overlap' + + // Sort periods by start date + const sortedRecords = [...unit.records].sort( + (a, b) => new Date(a.supplyFromDate) - new Date(b.supplyFromDate) + ) + + // Create a list of all periods for this unit + const periodsHtml = sortedRecords + .map((record) => { + const dateStyle = record.hasOverlap + ? 'color: orange; font-weight: bold;' + : '' + return `
    ${record.supplyFromDate} → ${record.supplyToDate}
    ` + }) + .join('') + + popupContent += ` + + + + + + + ` + + // If there are overlaps, show details + if (unit.hasOverlap) { + // Get all records for this unit that have overlaps + const recordsWithOverlaps = unit.records.filter( + (record) => + overlapMap[record.uniqueId] && + overlapMap[record.uniqueId].length > 0 + ) + + recordsWithOverlaps.forEach((record) => { + popupContent += ` + + + + ` + }) + } + }) + + popupContent += ` + +
    Reg #Serial #PeriodsStatus
    ${unit.regNum}${unit.serialNum} + ${periodsHtml} + + ${statusIcon} ${statusText} +
    +
    +

    Details for period: ${record.supplyFromDate} → ${record.supplyToDate}

    +

    ⚠️ Overlaps with:

    +
      + ` + + overlapMap[record.uniqueId].forEach((overlap) => { + popupContent += ` +
    • + Period: ${overlap.supplyFromDate} → ${overlap.supplyToDate} +
    • + ` + }) + + popupContent += ` +
    +
    +
    +
    + ` + + popupContent += `

    Coordinates: ${firstLoc.lat.toFixed( + 4 + )}, ${firstLoc.lng.toFixed(4)}

    ` + + // Update the popup content + marker.bindPopup(popupContent, { + maxWidth: 400, + maxHeight: 300 + }) + }) + } + + // Set bounds to show all markers + const bounds = Object.values(groupedLocations).map((group) => [ + group[0].lat, + group[0].lng + ]) + if (bounds.length > 0) { + mapInstance.fitBounds(bounds) + } + + // Add legend to map + const legend = L.control({ position: 'bottomright' }) + legend.onAdd = function () { + const div = L.DomUtil.create('div', 'info legend') + div.style.backgroundColor = 'white' + div.style.padding = '10px' + div.style.borderRadius = '5px' + div.style.border = '1px solid #ccc' + + div.innerHTML = ` +
    Legend
    + ${ + geofencingStatus === 'loading' + ? ` +
    + + Checking location... +
    + ` + : '' + } +
    + + Inside BC, no overlaps +
    +
    + + Period overlap (same Reg# & Serial#) +
    +
    + + Outside BC +
    + ` + return div + } + legend.addTo(mapInstance) + + // Cleanup function + return () => { + if (mapInstance) { + mapInstance.remove() + } + } + }, [groupedLocations, geofencingResults, geofencingStatus, overlapMap]) + + // Geofencing status indicator + const GeofencingStatus = () => { + if (geofencingStatus === 'loading') { + return ( +
    +

    🔄 Geofencing in progress...

    +

    + Checking each location to determine if it's inside BC's + boundaries. +

    +
    + ) + } + + if (geofencingStatus === 'error') { + return ( +
    +

    ❌ Geofencing error

    +

    + There was an error checking location boundaries. Using fallback + method. +

    +
    + ) + } + + return null + } + + // Summary of overlapping periods + const OverlapSummary = () => { + if (geofencingStatus !== 'completed') return null + + return ( +
    0 ? 'bg-orange-100' : 'bg-green-100' + } rounded my-4`} + > +

    + {overlapStats.overlapping > 0 + ? '⚠️ Period Overlaps Detected' + : '✓ No Period Overlaps'} +

    + +
    +
    +

    + Total Supply Units: {overlapStats.total} +

    +

    + Units with Overlaps: {overlapStats.overlapping} +

    +

    + Units without Overlaps:{' '} + {overlapStats.nonOverlapping} +

    +
    +
    +

    + BC Units with Overlaps:{' '} + {overlapStats.bcOverlapping} +

    +

    + Outside BC with Overlaps:{' '} + {overlapStats.nonBcOverlapping} +

    +
    +
    +
    + ) + } + + // Show refresh button + const RefreshButton = () => ( + + ) + + if (isLoading) return

    Loading map data...

    + if (isError || error) + return ( +
    +

    + Error: {error?.message || 'Failed to load data'} +

    +

    + Please ensure the API provides location data with latitude, longitude, + and date fields. +

    + +
    + ) + + if ( + !supplyEquipmentData || + !supplyEquipmentData.finalSupplyEquipments || + supplyEquipmentData.finalSupplyEquipments.length === 0 + ) + return ( +
    +

    No location data found.

    +

    API should return data with the following fields:

    +
      +
    • registrationNbr and serialNbr (for ID creation)
    • +
    • streetAddress, city, postalCode (for location name)
    • +
    • latitude and longitude
    • +
    • supplyFromDate and supplyToDate
    • +
    + +
    + ) + + return ( +
    + + + {/* {geofencingStatus === 'completed' && } */} +
    +
    + ) +} + +export default MapComponent diff --git a/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx b/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx index ed161dc6e..9d32573ea 100644 --- a/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx +++ b/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx @@ -30,7 +30,7 @@ import Loading from '@/components/Loading' import { ROUTES } from '@/constants/routes' import { useOrganization } from '@/hooks/useOrganization' import { useApiService } from '@/services/useApiService' -import AddressAutocomplete from './AddressAutocomplete' +import { AddressAutocomplete } from '@/components/BCForm/index.js' import colors from '@/themes/base/colors' // Component for adding a new organization @@ -605,6 +605,7 @@ export const AddEditOrgForm = () => { } else { setValue('orgStreetAddress', address.streetAddress) setValue('orgCity', address.city) + setValue('orgPostalCodeZipCode', '') } }} /> From 8472626fa8ffc75e69cf10baf0c91d9291a09a36 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 27 Feb 2025 12:14:09 -0800 Subject: [PATCH 08/16] enforce postal code addition --- .../components/BCForm/AddressAutocomplete.jsx | 85 ++++++++++++++----- .../BCForm/BCFormAddressAutocomplete.jsx | 54 ++++++++++-- .../components/OrganizationAddress.jsx | 16 +++- 3 files changed, 126 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/BCForm/AddressAutocomplete.jsx b/frontend/src/components/BCForm/AddressAutocomplete.jsx index e91d25282..1c91f161f 100644 --- a/frontend/src/components/BCForm/AddressAutocomplete.jsx +++ b/frontend/src/components/BCForm/AddressAutocomplete.jsx @@ -3,12 +3,14 @@ import { TextField, Autocomplete, Box, Grid } from '@mui/material' import { LocationOn as LocationOnIcon } from '@mui/icons-material' import parse from 'autosuggest-highlight/parse' import match from 'autosuggest-highlight/match' +import BCTypography from '../BCTypography' export const AddressAutocomplete = forwardRef( - ({ value, onChange, onSelectAddress }, ref) => { + ({ value, onChange, onSelectAddress, disabled }, ref) => { const [inputValue, setInputValue] = useState(value || '') const [options, setOptions] = useState([]) const [loading, setLoading] = useState(false) + const [isAddressSelected, setIsAddressSelected] = useState(false) useEffect(() => { if (!inputValue || inputValue.length < 3) { @@ -16,6 +18,16 @@ export const AddressAutocomplete = forwardRef( return } + // Don't fetch if user is just adding postal code to selected address + if ( + isAddressSelected && + inputValue.includes(',') && + (inputValue.endsWith(' ') || + /[A-Za-z][0-9][A-Za-z]/.test(inputValue.slice(-3))) + ) { + return + } + const controller = new AbortController() const signal = controller.signal @@ -53,7 +65,7 @@ export const AddressAutocomplete = forwardRef( clearTimeout(delayDebounceFn) controller.abort() } - }, [inputValue]) + }, [inputValue, isAddressSelected]) return ( x} value={value || inputValue} + disabled={disabled} getOptionLabel={(option) => { return typeof option === 'string' ? option : option.fullAddress }} @@ -80,35 +93,60 @@ export const AddressAutocomplete = forwardRef( onChange(newInputValue) } setInputValue(newInputValue) + + // If user is typing after selecting an address, we'll assume they're + // modifying it (likely adding postal code), so don't trigger a new search + if (isAddressSelected && event && event.type === 'change') { + // Keep isAddressSelected true as they're just modifying it + } else if ( + event && + (event.type === 'click' || event.type === 'change') + ) { + // Reset when user clears the field or starts fresh typing + setIsAddressSelected(false) + } }} onChange={(event, newValue) => { - if (onSelectAddress && newValue) { - if (typeof newValue === 'string') { - onSelectAddress(newValue) - } else { - const [streetAddress, city] = newValue.fullAddress.split(', ') - onSelectAddress({ - fullAddress: newValue.fullAddress, - inputValue, - streetAddress, - city - }) + if (newValue) { + // Mark that an address has been selected + setIsAddressSelected(true) + + if (onSelectAddress) { + if (typeof newValue === 'string') { + onSelectAddress(newValue) + } else { + const [streetAddress, city] = newValue.fullAddress.split(', ') + onSelectAddress({ + fullAddress: newValue.fullAddress, + inputValue, + streetAddress, + city + }) + } + } else if (onChange) { + // Default behavior: just set the field value + onChange( + typeof newValue === 'string' ? newValue : newValue?.fullAddress + ) } - } else if (onChange) { - // Default behavior: just set the field value - onChange( - typeof newValue === 'string' ? newValue : newValue?.fullAddress - ) } }} renderInput={(params) => ( - + )} renderOption={(props, option) => { const { key, ...optionProps } = props - const [street, city, province] = option.fullAddress.split(', ') const matches = match(option.fullAddress, inputValue, { insideWords: true }) @@ -136,6 +174,13 @@ export const AddressAutocomplete = forwardRef( {part.text} ))} + + Select and add postal code + diff --git a/frontend/src/components/BCForm/BCFormAddressAutocomplete.jsx b/frontend/src/components/BCForm/BCFormAddressAutocomplete.jsx index b2e14663d..932792a27 100644 --- a/frontend/src/components/BCForm/BCFormAddressAutocomplete.jsx +++ b/frontend/src/components/BCForm/BCFormAddressAutocomplete.jsx @@ -1,6 +1,7 @@ -import React from 'react' +import React, { useState } from 'react' import { Controller } from 'react-hook-form' import { InputLabel, Box } from '@mui/material' +import InfoIcon from '@mui/icons-material/Info' import BCTypography from '@/components/BCTypography' import PropTypes from 'prop-types' import FormControlLabel from '@mui/material/FormControlLabel' @@ -19,6 +20,8 @@ export const BCFormAddressAutocomplete = ({ disabled, onSelectAddress }) => { + const [showTooltip, setShowTooltip] = useState(false) + return ( - + + { + onChange(newValue) + // Show tooltip when address is selected or changed + if (newValue && newValue.length > 0) { + setShowTooltip(true) + // Hide tooltip after 5 seconds + setTimeout(() => setShowTooltip(false), 5000) + } + }} + onSelectAddress={(addressData) => { + if (onSelectAddress) { + onSelectAddress(addressData) + } + // Show tooltip when an address is selected from dropdown + setShowTooltip(true) + // Hide tooltip after 5 seconds + setTimeout(() => setShowTooltip(false), 5000) + }} + disabled={disabled} + /> + {showTooltip && ( + + + + Please add postal code to the address + + + )} + {error && ( {error.message} diff --git a/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx b/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx index ffe8bf4b6..16950f02e 100644 --- a/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx +++ b/frontend/src/views/ComplianceReports/components/OrganizationAddress.jsx @@ -3,7 +3,10 @@ import BCTypography from '@/components/BCTypography' import { useTranslation } from 'react-i18next' import { useUpdateOrganizationSnapshot } from '@/hooks/useOrganizationSnapshot.js' import { FormProvider, useForm } from 'react-hook-form' -import { BCFormText, BCFormAddressAutocomplete } from '@/components/BCForm/index.js' +import { + BCFormText, + BCFormAddressAutocomplete +} from '@/components/BCForm/index.js' import { yupResolver } from '@hookform/resolvers/yup' import { defaultValues } from '@/views/Users/AddEditUser/_schema.js' import { Box, Stack, List, ListItem } from '@mui/material' @@ -121,6 +124,14 @@ export const OrganizationAddress = ({ } } + const handleSelectRecordsAddress = (addressData) => { + if (typeof addressData === 'string') { + setValue('recordsAddress', addressData) + } else { + setValue('recordsAddress', addressData.fullAddress) + } + } + // Define which form fields use regular text input vs address autocomplete const textFormFields = [ { @@ -160,7 +171,8 @@ export const OrganizationAddress = ({ checkboxLabel: 'Same as address for service', onCheckboxChange: handleSameAddressChange, isChecked: sameAsService, - disabled: sameAsService + disabled: sameAsService, + onSelectAddress: handleSelectRecordsAddress } ] From 040e9866670d765e8ecadc2d08829f03bd57f8fe Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 27 Feb 2025 12:30:46 -0800 Subject: [PATCH 09/16] . --- frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx b/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx index 9d32573ea..688d86a07 100644 --- a/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx +++ b/frontend/src/views/Organizations/AddEditOrg/AddEditOrgForm.jsx @@ -606,6 +606,9 @@ export const AddEditOrgForm = () => { setValue('orgStreetAddress', address.streetAddress) setValue('orgCity', address.city) setValue('orgPostalCodeZipCode', '') + // Trigger validation for the updated fields + trigger('orgStreetAddress') + trigger('orgCity') } }} /> From e8b6815ec2dfaf9b4dc8b1fe3ab41bcf24d21ec6 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 27 Feb 2025 13:49:05 -0800 Subject: [PATCH 10/16] . --- frontend/package-lock.json | 6 ++++++ frontend/package.json | 1 + .../views/FinalSupplyEquipments/MapComponent.jsx | 16 ---------------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ab9d47f67..c4a26186a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,6 +36,7 @@ "esbuild": "^0.25.0", "i18next": "^23.8.2", "keycloak-js": "^26.1.2", + "leaflet": "^1.9.4", "lodash": "^4.17.21", "material-ui-popup-state": "^5.0.10", "mui-daterange-picker-plus": "^1.0.4", @@ -12275,6 +12276,11 @@ "node": "> 0.8" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 387792005..9358ed0e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -58,6 +58,7 @@ "esbuild": "^0.25.0", "i18next": "^23.8.2", "keycloak-js": "^26.1.2", + "leaflet": "^1.9.4", "lodash": "^4.17.21", "material-ui-popup-state": "^5.0.10", "mui-daterange-picker-plus": "^1.0.4", diff --git a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx index d66894f27..2790dbe8e 100644 --- a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx +++ b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx @@ -191,13 +191,6 @@ const MapComponent = ({ complianceReportId }) => { nonBcOverlapping: 0 }) - // Initial pagination state - const [pagination, setPagination] = useState({ - page: 1, - pageSize: 1000 // Adjust as needed based on typical data size - }) - - // Use React Query to fetch data const { data: supplyEquipmentData, isLoading, @@ -216,7 +209,6 @@ const MapComponent = ({ complianceReportId }) => { const combinedId = `${registrationNbr}_${serialNbr}` return { - // Store the combined ID as the main identifier id: combinedId, // Add a unique identifier for this specific record uniqueId: `${combinedId}_${index}`, @@ -259,8 +251,6 @@ const MapComponent = ({ complianceReportId }) => { useEffect(() => { if (locations.length > 0 && geofencingStatus === 'idle') { setGeofencingStatus('loading') - - // We only need to check one location per coordinate group const uniqueLocations = Object.values(groupedLocations).map( (group) => group[0] ) @@ -272,11 +262,8 @@ const MapComponent = ({ complianceReportId }) => { const expandedResults = {} Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { - // Get the result from the first location in this group const firstLocId = locGroup[0].id const isInBC = results[firstLocId] - - // Apply the same result to all locations in this group locGroup.forEach((loc) => { expandedResults[loc.id] = isInBC }) @@ -334,13 +321,10 @@ const MapComponent = ({ complianceReportId }) => { // Initialize and update map useEffect(() => { - // Only run this on the client side if (typeof window === 'undefined') return if (Object.keys(groupedLocations).length === 0) return let mapInstance = null - - // Get the container element const container = document.getElementById('map-container') if (!container) return From edb9432e052fcf47b1603427e0a1934e96360f9b Mon Sep 17 00:00:00 2001 From: prv-proton Date: Fri, 28 Feb 2025 11:44:07 -0800 Subject: [PATCH 11/16] updates --- ...a4.py => 2025-02-26-04-17_c1e2d64aeea4.py} | 4 +- frontend/package-lock.json | 44 +- frontend/package.json | 2 + .../FinalSupplyEquipments/MapComponent.jsx | 973 +++++++++++------- 4 files changed, 640 insertions(+), 383 deletions(-) rename backend/lcfs/db/migrations/versions/{2025-02-19-04-17_c1e2d64aeea4.py => 2025-02-26-04-17_c1e2d64aeea4.py} (97%) diff --git a/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py b/backend/lcfs/db/migrations/versions/2025-02-26-04-17_c1e2d64aeea4.py similarity index 97% rename from backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py rename to backend/lcfs/db/migrations/versions/2025-02-26-04-17_c1e2d64aeea4.py index 2f4fa4c99..50ed184af 100644 --- a/backend/lcfs/db/migrations/versions/2025-02-19-04-17_c1e2d64aeea4.py +++ b/backend/lcfs/db/migrations/versions/2025-02-26-04-17_c1e2d64aeea4.py @@ -1,7 +1,7 @@ """add record's address to organization Revision ID: c1e2d64aeea4 -Revises: 9e1da9e38f20 +Revises: 985de92bdf83 Create Date: 2025-02-19 04:17:03.668963 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "c1e2d64aeea4" -down_revision = "9e1da9e38f20" +down_revision = "985de92bdf83" branch_labels = None depends_on = None diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c4a26186a..e7555b36f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -50,6 +50,8 @@ "react-hook-form": "^7.49.2", "react-i18next": "^14.0.3", "react-input-mask": "^2.0.4", + "react-leaflet": "^4.2.1", + "react-leaflet-custom-control": "^1.4.0", "react-number-format": "^5.4.3", "react-quill": "^2.0.0", "react-router-dom": "^6.21.1", @@ -4127,6 +4129,17 @@ } } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.22.0.tgz", @@ -12279,7 +12292,8 @@ "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", - "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" }, "node_modules/levn": { "version": "0.4.1", @@ -15240,6 +15254,34 @@ "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/react-leaflet-custom-control": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/react-leaflet-custom-control/-/react-leaflet-custom-control-1.4.0.tgz", + "integrity": "sha512-E5sM7avHdFApzXTW7GvdPbhgBonu3j0lr1/DzZWz9OMzGbnSTkYPhE0VIYHAyOBvUoZU/TWjtkyVPS68GyeDiw==", + "license": "MIT", + "dependencies": { + "react-leaflet": "^4.2.1" + }, + "peerDependencies": { + "leaflet": "^1.7.1", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9358ed0e9..832c05936 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -72,6 +72,8 @@ "react-hook-form": "^7.49.2", "react-i18next": "^14.0.3", "react-input-mask": "^2.0.4", + "react-leaflet": "^4.2.1", + "react-leaflet-custom-control": "^1.4.0", "react-number-format": "^5.4.3", "react-quill": "^2.0.0", "react-router-dom": "^6.21.1", diff --git a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx index 2790dbe8e..c3b262ba9 100644 --- a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx +++ b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx @@ -1,7 +1,22 @@ import React, { useEffect, useState, useCallback } from 'react' +import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet' +import { + Paper, + CircularProgress, + Alert, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow +} from '@mui/material' +import Control from 'react-leaflet-custom-control' import 'leaflet/dist/leaflet.css' import L from 'leaflet' import { useGetFinalSupplyEquipments } from '@/hooks/useFinalSupplyEquipment' +import BCTypography from '@/components/BCTypography' +import BCButton from '@/components/BCButton' // Fix Leaflet's default icon issue delete L.Icon.Default.prototype._getIconUrl @@ -12,38 +27,27 @@ L.Icon.Default.mergeOptions({ shadowUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png' }) -// Custom marker for locations outside BC -const outsideBCIcon = new L.Icon({ - iconUrl: - 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png', - shadowUrl: - 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34] -}) - -// Custom marker for locations with overlapping periods -const overlapIcon = new L.Icon({ - iconUrl: - 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png', - shadowUrl: - 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34] -}) +// Create a marker icons map to avoid URL imports +const createMarkerIcon = (color) => { + return new L.Icon({ + // iconUrl: require(`../assets/markers/marker-icon-${color}.png`), + // shadowUrl: require('leaflet/dist/images/marker-shadow.png'), + iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-${color}.png`, + shadowUrl: + 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34] + }) +} -// Loading status icon -const loadingIcon = new L.Icon({ - iconUrl: - 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-grey.png', - shadowUrl: - 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34] -}) +// Prepare marker icons +const markerIcons = { + default: new L.Icon.Default(), + red: createMarkerIcon('red'), + orange: createMarkerIcon('orange'), + grey: createMarkerIcon('grey') +} // Geofencing approach using Nominatim for reverse geocoding const checkLocationInBC = async (lat, lng) => { @@ -176,6 +180,225 @@ const groupLocationsByCoordinates = (locations) => { return grouped } +// Component to fit the map bounds when locations change +const MapBoundsHandler = ({ groupedLocations }) => { + const map = useMap() + + useEffect(() => { + if (Object.keys(groupedLocations).length > 0) { + const bounds = Object.values(groupedLocations).map((group) => [ + group[0].lat, + group[0].lng + ]) + + if (bounds.length > 0) { + map.fitBounds(bounds) + } + } + }, [map, groupedLocations]) + + return null +} + +// Legend component for react-leaflet +const MapLegend = ({ geofencingStatus }) => { + return ( + + + + Legend + + + {geofencingStatus === 'loading' && ( +
    + + Checking location... +
    + )} + +
    + + + Inside BC, no overlaps + +
    +
    + + + Period overlap (same Reg# & Serial#) + +
    +
    + + + Outside BC + +
    +
    +
    + ) +} + +const ExcelStyledTable = ({ uniqueSupplyUnits, overlapMap }) => { + return ( + + + + + {' '} + + Reg # + + + Serial # + + + Periods + + + Status + + + + + {Object.values(uniqueSupplyUnits).map((unit, index) => { + const sortedRecords = [...unit.records].sort( + (a, b) => new Date(a.supplyFromDate) - new Date(b.supplyFromDate) + ) + + return ( + + + + {unit.regNum} + + + {unit.serialNum} + + + {sortedRecords.map((record, idx) => ( +
    + {record.supplyFromDate} → {record.supplyToDate} +
    + ))} +
    + + {unit.hasOverlap ? '⚠️ Period overlap' : '✓ No overlap'} + +
    + + {/* Overlapping Period Details */} + {unit.hasOverlap && + sortedRecords + .filter((record) => record.hasOverlap) + .map((record, idx) => ( + + + Details for period:{' '} + {record.supplyFromDate} → {record.supplyToDate} +
    + + ⚠️ Overlaps with: + +
      + {overlapMap[record.uniqueId].map((overlap, i) => ( +
    • + Period: {overlap.supplyFromDate} →{' '} + {overlap.supplyToDate} +
    • + ))} +
    +
    +
    + ))} +
    + ) + })} +
    +
    +
    + ) +} + const MapComponent = ({ complianceReportId }) => { const [locations, setLocations] = useState([]) const [groupedLocations, setGroupedLocations] = useState({}) @@ -319,308 +542,194 @@ const MapComponent = ({ complianceReportId }) => { } }, [locations, geofencingResults, geofencingStatus]) - // Initialize and update map - useEffect(() => { - if (typeof window === 'undefined') return - if (Object.keys(groupedLocations).length === 0) return - - let mapInstance = null - const container = document.getElementById('map-container') - if (!container) return - - // Initialize map - center it on a view that shows all of BC - mapInstance = L.map('map-container').setView([53.7267, -127.6476], 5) - - // Add tile layer - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: - '© OpenStreetMap contributors' - }).addTo(mapInstance) - - // Add markers for each location group - const markers = {} - - Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { - const firstLoc = locGroup[0] - const locationCoords = [firstLoc.lat, firstLoc.lng] - - // Get unique registration/serial combinations at this location - const uniqueSupplyUnits = {} - locGroup.forEach((loc) => { - if (!uniqueSupplyUnits[loc.id]) { - uniqueSupplyUnits[loc.id] = { - regNum: loc.registrationNbr, - serialNum: loc.serialNbr, - records: [] - } - } - uniqueSupplyUnits[loc.id].records.push(loc) - }) - - // Start with loading icon for all markers - const marker = L.marker(locationCoords, { - icon: loadingIcon - }).addTo(mapInstance) - - // Store marker reference for updating later - markers[coordKey] = marker - - // Create a popup showing all units at this location - const popupContent = ` - ${firstLoc.name}
    - Total Supply Units at this location: ${ - Object.keys(uniqueSupplyUnits).length - }
    - ${geofencingStatus === 'loading' ? '

    Checking location...

    ' : ''} - ` - - marker.bindPopup(popupContent) - }) - - // Update markers when geofencing results are available - if ( - geofencingStatus === 'completed' && - Object.keys(overlapMap).length > 0 - ) { - Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { - const marker = markers[coordKey] - if (!marker) return - - const firstLoc = locGroup[0] - const isInBC = geofencingResults[firstLoc.id] - - // Check if any locations in this group have overlaps - const hasAnyOverlaps = locGroup.some( - (loc) => - overlapMap[loc.uniqueId] && overlapMap[loc.uniqueId].length > 0 - ) - - // Update marker icon based on geofencing and overlap status - let markerIcon = new L.Icon.Default() - if (!isInBC) { - markerIcon = outsideBCIcon - } else if (hasAnyOverlaps) { - markerIcon = overlapIcon + // Generate the popup content for a location group + const generatePopupContent = (coordKey, locGroup) => { + const firstLoc = locGroup[0] + const isInBC = geofencingResults[firstLoc.id] || false + + // Get unique supply units at this location + const uniqueSupplyUnits = {} + locGroup.forEach((loc) => { + if (!uniqueSupplyUnits[loc.id]) { + uniqueSupplyUnits[loc.id] = { + regNum: loc.registrationNbr, + serialNum: loc.serialNbr, + hasOverlap: false, + records: [] } + } - marker.setIcon(markerIcon) - - // Get unique supply units at this location - const uniqueSupplyUnits = {} - locGroup.forEach((loc) => { - if (!uniqueSupplyUnits[loc.id]) { - uniqueSupplyUnits[loc.id] = { - regNum: loc.registrationNbr, - serialNum: loc.serialNbr, - hasOverlap: false, - records: [] - } - } - - // Check if this specific record has overlaps - const hasOverlap = - overlapMap[loc.uniqueId] && overlapMap[loc.uniqueId].length > 0 - if (hasOverlap) { - uniqueSupplyUnits[loc.id].hasOverlap = true - } - - uniqueSupplyUnits[loc.id].records.push({ - ...loc, - hasOverlap - }) - }) - - // Create updated popup content - let popupContent = ` - ${firstLoc.name}
    - Total Supply Units at this location: ${ - Object.keys(uniqueSupplyUnits).length - } - ${!isInBC ? '

    🚨 Outside BC!

    ' : ''} - ` - - // List all supply units with their IDs and periods - popupContent += ` -

    Supply Units at this location:

    -
    - - - - - - - - - - - ` - - Object.values(uniqueSupplyUnits).forEach((unit) => { - const statusColor = unit.hasOverlap ? 'orange' : 'green' - const statusIcon = unit.hasOverlap ? '⚠️' : '✓' - const statusText = unit.hasOverlap ? 'Period overlap' : 'No overlap' - - // Sort periods by start date - const sortedRecords = [...unit.records].sort( - (a, b) => new Date(a.supplyFromDate) - new Date(b.supplyFromDate) - ) - - // Create a list of all periods for this unit - const periodsHtml = sortedRecords - .map((record) => { - const dateStyle = record.hasOverlap - ? 'color: orange; font-weight: bold;' - : '' - return `
    ${record.supplyFromDate} → ${record.supplyToDate}
    ` - }) - .join('') - - popupContent += ` - - - - - - - ` - - // If there are overlaps, show details - if (unit.hasOverlap) { - // Get all records for this unit that have overlaps - const recordsWithOverlaps = unit.records.filter( - (record) => - overlapMap[record.uniqueId] && - overlapMap[record.uniqueId].length > 0 - ) - - recordsWithOverlaps.forEach((record) => { - popupContent += ` - - - - ` - }) - } - }) - - popupContent += ` - -
    Reg #Serial #PeriodsStatus
    ${unit.regNum}${unit.serialNum} - ${periodsHtml} - - ${statusIcon} ${statusText} -
    -
    -

    Details for period: ${record.supplyFromDate} → ${record.supplyToDate}

    -

    ⚠️ Overlaps with:

    -
      - ` - - overlapMap[record.uniqueId].forEach((overlap) => { - popupContent += ` -
    • - Period: ${overlap.supplyFromDate} → ${overlap.supplyToDate} -
    • - ` - }) - - popupContent += ` -
    -
    -
    -
    - ` - - popupContent += `

    Coordinates: ${firstLoc.lat.toFixed( - 4 - )}, ${firstLoc.lng.toFixed(4)}

    ` + // Check if this specific record has overlaps + const hasOverlap = + overlapMap[loc.uniqueId] && overlapMap[loc.uniqueId].length > 0 + if (hasOverlap) { + uniqueSupplyUnits[loc.id].hasOverlap = true + } - // Update the popup content - marker.bindPopup(popupContent, { - maxWidth: 400, - maxHeight: 300 - }) + uniqueSupplyUnits[loc.id].records.push({ + ...loc, + hasOverlap }) - } - - // Set bounds to show all markers - const bounds = Object.values(groupedLocations).map((group) => [ - group[0].lat, - group[0].lng - ]) - if (bounds.length > 0) { - mapInstance.fitBounds(bounds) - } - - // Add legend to map - const legend = L.control({ position: 'bottomright' }) - legend.onAdd = function () { - const div = L.DomUtil.create('div', 'info legend') - div.style.backgroundColor = 'white' - div.style.padding = '10px' - div.style.borderRadius = '5px' - div.style.border = '1px solid #ccc' - - div.innerHTML = ` -
    Legend
    - ${ - geofencingStatus === 'loading' - ? ` -
    - - Checking location... -
    - ` - : '' - } -
    - - Inside BC, no overlaps -
    -
    - - Period overlap (same Reg# & Serial#) -
    -
    - - Outside BC -
    - ` - return div - } - legend.addTo(mapInstance) + }) - // Cleanup function - return () => { - if (mapInstance) { - mapInstance.remove() - } - } - }, [groupedLocations, geofencingResults, geofencingStatus, overlapMap]) + return ( +
    + + {firstLoc.name} + + + + Total Supply Units:{' '} + {Object.keys(uniqueSupplyUnits).length} + + + {!isInBC && ( + + 🚨 Outside BC! + + )} + + + Supply Units at this location: + + + {/* + + + + Reg # + Serial # + Periods + Status + + + + {Object.values(uniqueSupplyUnits).map((unit, index) => { + // Sort periods by start date + const sortedRecords = [...unit.records].sort( + (a, b) => + new Date(a.supplyFromDate) - new Date(b.supplyFromDate) + ) + + return ( + + + {unit.regNum} + {unit.serialNum} + + {sortedRecords.map((record, idx) => ( +
    + {record.supplyFromDate} → {record.supplyToDate} +
    + ))} +
    + + {unit.hasOverlap ? '⚠️ Period overlap' : '✓ No overlap'} + +
    + + {unit.hasOverlap && + sortedRecords + .filter((record) => record.hasOverlap) + .map((record, idx) => ( + + + + Details for period: {record.supplyFromDate} →{' '} + {record.supplyToDate} + + + ⚠️ Overlaps with: + +
      + {overlapMap[record.uniqueId].map( + (overlap, i) => ( +
    • + Period: {overlap.supplyFromDate} →{' '} + {overlap.supplyToDate} +
    • + ) + )} +
    +
    +
    + ))} +
    + ) + })} +
    +
    +
    */} + + + Coordinates: {firstLoc.lat.toFixed(4)}, {firstLoc.lng.toFixed(4)} + +
    + ) + } - // Geofencing status indicator + // Component for Geofencing Status const GeofencingStatus = () => { if (geofencingStatus === 'loading') { return ( -
    -

    🔄 Geofencing in progress...

    -

    + } + > + + Geofencing in progress... + + Checking each location to determine if it's inside BC's boundaries. -

    -
    +
    + ) } if (geofencingStatus === 'error') { return ( -
    -

    ❌ Geofencing error

    -

    + + + Geofencing error + + There was an error checking location boundaries. Using fallback method. -

    -
    + + ) } @@ -632,70 +741,80 @@ const MapComponent = ({ complianceReportId }) => { if (geofencingStatus !== 'completed') return null return ( -
    0 ? 'bg-orange-100' : 'bg-green-100' - } rounded my-4`} + 0 ? 'warning' : 'success'} + sx={{ mb: 2 }} > -

    + {overlapStats.overlapping > 0 - ? '⚠️ Period Overlaps Detected' - : '✓ No Period Overlaps'} -

    - -
    -
    -

    - Total Supply Units: {overlapStats.total} -

    -

    - Units with Overlaps: {overlapStats.overlapping} -

    -

    - Units without Overlaps:{' '} - {overlapStats.nonOverlapping} -

    -
    -
    -

    - BC Units with Overlaps:{' '} - {overlapStats.bcOverlapping} -

    -

    - Outside BC with Overlaps:{' '} - {overlapStats.nonBcOverlapping} -

    -
    + ? 'Period Overlaps Detected' + : 'No Period Overlaps'} + + +
    + + Total Supply Units: {overlapStats.total} + + + Units with Overlaps: {overlapStats.overlapping} + + + Units without Overlaps:{' '} + {overlapStats.nonOverlapping} + + + BC Units with Overlaps:{' '} + {overlapStats.bcOverlapping} + + + Outside BC with Overlaps:{' '} + {overlapStats.nonBcOverlapping} +
    -
    +
    ) } - // Show refresh button - const RefreshButton = () => ( - - ) + if (isLoading) + return ( + } + sx={{ mb: 2 }} + > + Loading map data... + + ) - if (isLoading) return

    Loading map data...

    if (isError || error) return (
    -

    - Error: {error?.message || 'Failed to load data'} -

    -

    - Please ensure the API provides location data with latitude, longitude, - and date fields. -

    - + + + Error: {error?.message || 'Failed to load data'} + + + Please ensure the API provides location data with latitude, + longitude, and date fields. + + + { + refetch() + setGeofencingStatus('idle') + }} + sx={{ mb: 2 }} + > + Refresh Map Data +
    ) @@ -706,24 +825,118 @@ const MapComponent = ({ complianceReportId }) => { ) return (
    -

    No location data found.

    -

    API should return data with the following fields:

    -
      -
    • registrationNbr and serialNbr (for ID creation)
    • -
    • streetAddress, city, postalCode (for location name)
    • -
    • latitude and longitude
    • -
    • supplyFromDate and supplyToDate
    • -
    - + + + No location data found + + + API should return data with the following fields: + +
      +
    • registrationNbr and serialNbr (for ID creation)
    • +
    • streetAddress, city, postalCode (for location name)
    • +
    • latitude and longitude
    • +
    • supplyFromDate and supplyToDate
    • +
    +
    + { + refetch() + setGeofencingStatus('idle') + }} + sx={{ mb: 2 }} + > + Refresh Map Data +
    ) return (
    - + { + refetch() + setGeofencingStatus('idle') + }} + sx={{ mb: 2 }} + > + Refresh Map Data + + + {/* {geofencingStatus === 'completed' && } */} -
    + + + + + + + + + {Object.entries(groupedLocations).map(([coordKey, locGroup]) => { + const firstLoc = locGroup[0] + const position = [firstLoc.lat, firstLoc.lng] + + // Determine marker icon based on geofencing results and overlap status + let icon = markerIcons.grey // Default to loading icon + + if (geofencingStatus === 'completed') { + const isInBC = geofencingResults[firstLoc.id] + + // Check if any locations in this group have overlaps + const hasAnyOverlaps = locGroup.some( + (loc) => + overlapMap[loc.uniqueId] && + overlapMap[loc.uniqueId].length > 0 + ) + + if (!isInBC) { + icon = markerIcons.red + } else if (hasAnyOverlaps) { + icon = markerIcons.orange + } else { + icon = markerIcons.default + } + } + + return ( + + + {geofencingStatus === 'completed' ? ( + generatePopupContent(coordKey, locGroup) + ) : ( +
    + + {firstLoc.name} + + + + Checking location... + +
    + )} +
    +
    + ) + })} +
    +
    ) } From f935c83aeb0b0458b59de5ea03384455d3d97eb5 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Fri, 28 Feb 2025 11:53:08 -0800 Subject: [PATCH 12/16] . --- .../FinalSupplyEquipments/MapComponent.jsx | 106 +----------------- 1 file changed, 2 insertions(+), 104 deletions(-) diff --git a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx index c3b262ba9..2df8d07bc 100644 --- a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx +++ b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx @@ -30,8 +30,6 @@ L.Icon.Default.mergeOptions({ // Create a marker icons map to avoid URL imports const createMarkerIcon = (color) => { return new L.Icon({ - // iconUrl: require(`../assets/markers/marker-icon-${color}.png`), - // shadowUrl: require('leaflet/dist/images/marker-shadow.png'), iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-${color}.png`, shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', @@ -52,7 +50,6 @@ const markerIcons = { // Geofencing approach using Nominatim for reverse geocoding const checkLocationInBC = async (lat, lng) => { try { - // Using OpenStreetMap's Nominatim service for reverse geocoding const response = await fetch( `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=10&addressdetails=1`, { @@ -93,8 +90,6 @@ const batchProcessGeofencing = async (locations) => { for (let i = 0; i < locations.length; i += batchSize) { const batch = locations.slice(i, i + batchSize) - - // Process this batch in parallel const batchPromises = batch.map(async (loc) => { const isInBC = await checkLocationInBC(loc.lat, loc.lng) return { id: loc.id, isInBC } @@ -106,8 +101,6 @@ const batchProcessGeofencing = async (locations) => { batchResults.forEach(({ id, isInBC }) => { results[id] = isInBC }) - - // Add a small delay to avoid rate limiting if (i + batchSize < locations.length) { await new Promise((resolve) => setTimeout(resolve, 1000)) } @@ -128,13 +121,11 @@ const datesOverlap = (start1, end1, start2, end2) => { // Find all overlapping periods for a given location, only considering same ID const findOverlappingPeriods = (currentLoc, allLocations) => { - // Extract the base ID (registrationNbr) from the combined ID const currentRegNum = currentLoc.id.split('_')[0] const currentSerialNum = currentLoc.id.split('_')[1] return allLocations .filter((loc) => { - // Split the comparison location's ID const locRegNum = loc.id.split('_')[0] const locSerialNum = loc.id.split('_')[1] @@ -167,7 +158,6 @@ const groupLocationsByCoordinates = (locations) => { const grouped = {} locations.forEach((location) => { - // Create a key based on coordinates (rounded to reduce floating point issues) const key = `${location.lat.toFixed(6)},${location.lng.toFixed(6)}` if (!grouped[key]) { @@ -432,8 +422,7 @@ const MapComponent = ({ complianceReportId }) => { const combinedId = `${registrationNbr}_${serialNbr}` return { - id: combinedId, - // Add a unique identifier for this specific record + id: row.finalSupplyEquipmentId, uniqueId: `${combinedId}_${index}`, registrationNbr, serialNbr, @@ -450,7 +439,6 @@ const MapComponent = ({ complianceReportId }) => { }) }, []) - // Update locations when data changes useEffect(() => { if (supplyEquipmentData) { const transformedData = transformApiData(supplyEquipmentData) @@ -460,7 +448,6 @@ const MapComponent = ({ complianceReportId }) => { setError(new Error('No location data found')) } else { setLocations(transformedData) - // Group locations by coordinates const grouped = groupLocationsByCoordinates(transformedData) setGroupedLocations(grouped) @@ -478,10 +465,8 @@ const MapComponent = ({ complianceReportId }) => { (group) => group[0] ) - // Start the geofencing process batchProcessGeofencing(uniqueLocations) .then((results) => { - // Expand results to all locations with the same coordinates const expandedResults = {} Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { @@ -514,8 +499,6 @@ const MapComponent = ({ complianceReportId }) => { bcOverlapping: 0, nonBcOverlapping: 0 } - - // Calculate overlaps locations.forEach((loc) => { const overlappingPeriods = findOverlappingPeriods(loc, locations) overlaps[loc.uniqueId] = overlappingPeriods @@ -607,90 +590,6 @@ const MapComponent = ({ complianceReportId }) => { uniqueSupplyUnits={uniqueSupplyUnits} overlapMap={overlapMap} /> - {/* - - - - Reg # - Serial # - Periods - Status - - - - {Object.values(uniqueSupplyUnits).map((unit, index) => { - // Sort periods by start date - const sortedRecords = [...unit.records].sort( - (a, b) => - new Date(a.supplyFromDate) - new Date(b.supplyFromDate) - ) - - return ( - - - {unit.regNum} - {unit.serialNum} - - {sortedRecords.map((record, idx) => ( -
    - {record.supplyFromDate} → {record.supplyToDate} -
    - ))} -
    - - {unit.hasOverlap ? '⚠️ Period overlap' : '✓ No overlap'} - -
    - - {unit.hasOverlap && - sortedRecords - .filter((record) => record.hasOverlap) - .map((record, idx) => ( - - - - Details for period: {record.supplyFromDate} →{' '} - {record.supplyToDate} - - - ⚠️ Overlaps with: - -
      - {overlapMap[record.uniqueId].map( - (overlap, i) => ( -
    • - Period: {overlap.supplyFromDate} →{' '} - {overlap.supplyToDate} -
    • - ) - )} -
    -
    -
    - ))} -
    - ) - })} -
    -
    -
    */} Coordinates: {firstLoc.lat.toFixed(4)}, {firstLoc.lng.toFixed(4)} @@ -739,7 +638,7 @@ const MapComponent = ({ complianceReportId }) => { // Summary of overlapping periods const OverlapSummary = () => { if (geofencingStatus !== 'completed') return null - + console.log('Overlap stats:', overlapStats) return ( 0 ? 'warning' : 'success'} @@ -899,7 +798,6 @@ const MapComponent = ({ complianceReportId }) => { if (geofencingStatus === 'completed') { const isInBC = geofencingResults[firstLoc.id] - // Check if any locations in this group have overlaps const hasAnyOverlaps = locGroup.some( (loc) => overlapMap[loc.uniqueId] && From 54a67e0de3b71fac9ee89d242d964bc6143ce235 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Fri, 28 Feb 2025 11:54:50 -0800 Subject: [PATCH 13/16] id updates --- frontend/src/views/FinalSupplyEquipments/MapComponent.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx index 2df8d07bc..28ea46ed1 100644 --- a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx +++ b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx @@ -422,7 +422,7 @@ const MapComponent = ({ complianceReportId }) => { const combinedId = `${registrationNbr}_${serialNbr}` return { - id: row.finalSupplyEquipmentId, + id: combinedId, uniqueId: `${combinedId}_${index}`, registrationNbr, serialNbr, From 4ae8b2c7f2c920bb903dca0ad7162038e307c82a Mon Sep 17 00:00:00 2001 From: prv-proton Date: Fri, 28 Feb 2025 12:00:29 -0800 Subject: [PATCH 14/16] conflict code issues --- frontend/src/views/FuelExports/AddEditFuelExports.jsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/views/FuelExports/AddEditFuelExports.jsx b/frontend/src/views/FuelExports/AddEditFuelExports.jsx index 0ca2fd45f..6f0ecfda0 100644 --- a/frontend/src/views/FuelExports/AddEditFuelExports.jsx +++ b/frontend/src/views/FuelExports/AddEditFuelExports.jsx @@ -15,12 +15,6 @@ import { useTranslation } from 'react-i18next' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' import { defaultColDef, fuelExportColDefs } from './_schema' -<<<<<<< HEAD -import { handleScheduleDelete, handleScheduleSave } from '@/utils/schedules.js' -import { isArrayEmpty } from '@/utils/array.js' -import { PROVISION_APPROVED_FUEL_CODE } from '@/views/OtherUses/_schema.jsx' -======= ->>>>>>> bcaa34c8 (Sort by creation_date for fuel exports) export const AddEditFuelExports = () => { const [rowData, setRowData] = useState([]) From 365072099dda1a1f439f17d64e2f75aabab714a7 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Fri, 28 Feb 2025 17:21:29 -0800 Subject: [PATCH 15/16] add caching --- frontend/src/hooks/useFinalSupplyEquipment.js | 18 ++++++++++++++++++ .../FinalSupplyEquipments/MapComponent.jsx | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useFinalSupplyEquipment.js b/frontend/src/hooks/useFinalSupplyEquipment.js index 66200e540..ba44e5f11 100644 --- a/frontend/src/hooks/useFinalSupplyEquipment.js +++ b/frontend/src/hooks/useFinalSupplyEquipment.js @@ -27,6 +27,8 @@ export const useGetFinalSupplyEquipments = ( ) return response.data }, + staleTime: 5 * 60 * 1000, + cacheTime: 10 * 60 * 1000, ...options }) } @@ -54,6 +56,14 @@ export const useSaveFinalSupplyEquipment = (complianceReportId, options) => { 'compliance-report-summary', complianceReportId ]) + queryClient.invalidateQueries({ + predicate: (query) => { + return ( + query.queryKey[0] === 'final-supply-equipments' && + query.queryKey[1] === complianceReportId + ) + } + }) } }) } @@ -86,6 +96,14 @@ export const useImportFinalSupplyEquipment = (complianceReportId, options) => { 'final-supply-equipments', complianceReportId ]) + queryClient.invalidateQueries({ + predicate: (query) => { + return ( + query.queryKey[0] === 'final-supply-equipments' && + query.queryKey[1] === complianceReportId + ) + } + }) if (options.onSuccess) { options.onSuccess(data) } diff --git a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx index 28ea46ed1..ada35fe9f 100644 --- a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx +++ b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx @@ -781,7 +781,7 @@ const MapComponent = ({ complianceReportId }) => { style={{ height: '100%', width: '100%' }} > From 7cf2761562cd6a170cbee9c1f5a6abc76d257200 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Mon, 3 Mar 2025 12:00:41 -0800 Subject: [PATCH 16/16] review comments fix --- .../components/BCForm/AddressAutocomplete.jsx | 13 +- .../components/BCWidgetCard/BCWidgetCard.jsx | 2 +- .../InternalComments/InternalCommentList.jsx | 2 +- frontend/src/constants/common.js | 2 + .../FinalSupplyEquipmentSummary.jsx | 4 +- .../FinalSupplyEquipments/GeoMapping.jsx | 287 ++++++ .../FinalSupplyEquipments/MapComponent.jsx | 842 ------------------ .../views/FinalSupplyEquipments/_schema.jsx | 6 +- .../components/MapComponents.jsx | 148 +++ .../components/StatusComponent.jsx | 143 +++ .../components/TableComponent.jsx | 157 ++++ .../FinalSupplyEquipments/components/utils.js | 183 ++++ 12 files changed, 933 insertions(+), 856 deletions(-) create mode 100644 frontend/src/views/FinalSupplyEquipments/GeoMapping.jsx delete mode 100644 frontend/src/views/FinalSupplyEquipments/MapComponent.jsx create mode 100644 frontend/src/views/FinalSupplyEquipments/components/MapComponents.jsx create mode 100644 frontend/src/views/FinalSupplyEquipments/components/StatusComponent.jsx create mode 100644 frontend/src/views/FinalSupplyEquipments/components/TableComponent.jsx create mode 100644 frontend/src/views/FinalSupplyEquipments/components/utils.js diff --git a/frontend/src/components/BCForm/AddressAutocomplete.jsx b/frontend/src/components/BCForm/AddressAutocomplete.jsx index 1c91f161f..a8820b4bf 100644 --- a/frontend/src/components/BCForm/AddressAutocomplete.jsx +++ b/frontend/src/components/BCForm/AddressAutocomplete.jsx @@ -4,6 +4,7 @@ import { LocationOn as LocationOnIcon } from '@mui/icons-material' import parse from 'autosuggest-highlight/parse' import match from 'autosuggest-highlight/match' import BCTypography from '../BCTypography' +import { ADDRESS_SEARCH_URL } from '@/constants/common' export const AddressAutocomplete = forwardRef( ({ value, onChange, onSelectAddress, disabled }, ref) => { @@ -35,10 +36,10 @@ export const AddressAutocomplete = forwardRef( setLoading(true) try { const response = await fetch( - `https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=frontDoorPoint&addressString=${encodeURIComponent( - inputValue - )}`, - { signal } + ADDRESS_SEARCH_URL + encodeURIComponent(inputValue), + { + signal + } ) if (!response.ok) throw new Error('Network response was not ok') @@ -156,7 +157,7 @@ export const AddressAutocomplete = forwardRef(
  • - + Select and add postal code diff --git a/frontend/src/components/BCWidgetCard/BCWidgetCard.jsx b/frontend/src/components/BCWidgetCard/BCWidgetCard.jsx index 22a84fe06..a6f947614 100644 --- a/frontend/src/components/BCWidgetCard/BCWidgetCard.jsx +++ b/frontend/src/components/BCWidgetCard/BCWidgetCard.jsx @@ -104,7 +104,7 @@ BCWidgetCard.propTypes = { title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, content: PropTypes.node.isRequired, subHeader: PropTypes.node, - editButton: PropTypes.object + editButton: PropTypes.oneOf(PropTypes.object, PropTypes.bool) } export default BCWidgetCard diff --git a/frontend/src/components/InternalComments/InternalCommentList.jsx b/frontend/src/components/InternalComments/InternalCommentList.jsx index eb7c0ffe7..8d018991b 100644 --- a/frontend/src/components/InternalComments/InternalCommentList.jsx +++ b/frontend/src/components/InternalComments/InternalCommentList.jsx @@ -153,7 +153,7 @@ const InternalCommentList = ({ {comment.fullName},{' '} diff --git a/frontend/src/constants/common.js b/frontend/src/constants/common.js index cd6e71bcd..ff642ee82 100644 --- a/frontend/src/constants/common.js +++ b/frontend/src/constants/common.js @@ -63,6 +63,8 @@ export const PHONE_REGEX = export const HELP_GUIDE_URL = 'https://www2.gov.bc.ca/gov/content?id=7A58AF3855154747A0793F0C9A6E9089' +export const ADDRESS_SEARCH_URL = + 'https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=frontDoorPoint&addressString=' export const FILTER_KEYS = { COMPLIANCE_REPORT_GRID: 'compliance-reports-grid-filter', diff --git a/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx b/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx index f761c34c4..5287499e1 100644 --- a/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx +++ b/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx @@ -10,7 +10,7 @@ import { useLocation, useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses.js' import { finalSupplyEquipmentSummaryColDefs } from '@/views/FinalSupplyEquipments/_schema.jsx' -import MapComponent from './MapComponent' +import GeoMapping from './GeoMapping' import FormControlLabel from '@mui/material/FormControlLabel' import Switch from '@mui/material/Switch' @@ -110,7 +110,7 @@ export const FinalSupplyEquipmentSummary = ({ data, status }) => { /> {/* Conditional Rendering of MapComponent */} - {showMap && } + {showMap && } ) diff --git a/frontend/src/views/FinalSupplyEquipments/GeoMapping.jsx b/frontend/src/views/FinalSupplyEquipments/GeoMapping.jsx new file mode 100644 index 000000000..b7cbe416d --- /dev/null +++ b/frontend/src/views/FinalSupplyEquipments/GeoMapping.jsx @@ -0,0 +1,287 @@ +import { useState, useEffect } from 'react' +import { MapContainer } from 'react-leaflet' +import { Paper } from '@mui/material' +import BCTypography from '@/components/BCTypography' +import BCButton from '@/components/BCButton' +import 'leaflet/dist/leaflet.css' + +// Import custom components +import { + BaseMap, + MapBoundsHandler, + MapLegend, + MapMarkers +} from './components/MapComponents' +import { + GeofencingStatus, + OverlapSummary, + LoadingState, + ErrorState, + NoDataState +} from './components/StatusComponent' +import { ExcelStyledTable } from './components/TableComponent' +// Import utility functions and services +import { + fixLeafletIcons, + transformApiData, + groupLocationsByCoordinates, + findOverlappingPeriods, + batchProcessGeofencing +} from './components/utils' +import { useGetFinalSupplyEquipments } from '@/hooks/useFinalSupplyEquipment' + +// Fix Leaflet icon issue +fixLeafletIcons() + +const GeoMapping = ({ complianceReportId }) => { + const [locations, setLocations] = useState([]) + const [groupedLocations, setGroupedLocations] = useState({}) + const [error, setError] = useState(null) + const [overlapMap, setOverlapMap] = useState({}) + const [geofencingResults, setGeofencingResults] = useState({}) + const [geofencingStatus, setGeofencingStatus] = useState('idle') + const [overlapStats, setOverlapStats] = useState({ + total: 0, + overlapping: 0, + nonOverlapping: 0, + bcOverlapping: 0, + nonBcOverlapping: 0 + }) + + // Use react-query for fetching data + const { + data: supplyEquipmentData, + isLoading, + isError, + refetch + } = useGetFinalSupplyEquipments(complianceReportId) + + // Reset geofencing status + const resetGeofencing = () => setGeofencingStatus('idle') + + // Process API data when it loads + useEffect(() => { + if (supplyEquipmentData) { + const transformedData = transformApiData(supplyEquipmentData) + + if (transformedData.length === 0) { + console.warn('No location data found in API response') + setError(new Error('No location data found')) + } else { + setLocations(transformedData) + // Group locations by coordinates + const grouped = groupLocationsByCoordinates(transformedData) + setGroupedLocations(grouped) + setError(null) + } + } + }, [supplyEquipmentData]) + + // Run geofencing when locations are loaded + useEffect(() => { + if (locations.length > 0 && geofencingStatus === 'idle') { + setGeofencingStatus('loading') + const uniqueLocations = Object.values(groupedLocations).map( + (group) => group[0] + ) + + batchProcessGeofencing(uniqueLocations) + .then((results) => { + const expandedResults = {} + + Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { + const firstLocId = locGroup[0].id + const isInBC = results[firstLocId] + locGroup.forEach((loc) => { + expandedResults[loc.id] = isInBC + }) + }) + + setGeofencingResults(expandedResults) + setGeofencingStatus('completed') + console.log('Geofencing results:', expandedResults) + }) + .catch((error) => { + console.error('Error during geofencing:', error) + setGeofencingStatus('error') + }) + } + }, [locations, groupedLocations, geofencingStatus]) + + // Calculate overlaps when locations and geofencing are completed + useEffect(() => { + if (locations.length > 0 && geofencingStatus === 'completed') { + const overlaps = {} + const stats = { + total: locations.length, + overlapping: 0, + nonOverlapping: 0, + bcOverlapping: 0, + nonBcOverlapping: 0 + } + + locations.forEach((loc) => { + const overlappingPeriods = findOverlappingPeriods(loc, locations) + overlaps[loc.uniqueId] = overlappingPeriods + + const isInBC = geofencingResults[loc.id] + const hasOverlap = overlappingPeriods.length > 0 + + if (hasOverlap) { + stats.overlapping++ + if (isInBC) { + stats.bcOverlapping++ + } else { + stats.nonBcOverlapping++ + } + } else { + stats.nonOverlapping++ + } + }) + + setOverlapMap(overlaps) + setOverlapStats(stats) + console.log('Period overlaps:', overlaps) + console.log('Overlap statistics:', stats) + } + }, [locations, geofencingResults, geofencingStatus]) + + // Generate the popup content for a location group + const generatePopupContent = (coordKey, locGroup) => { + const firstLoc = locGroup[0] + const isInBC = geofencingResults[firstLoc.id] || false + + // Get unique supply units at this location + const uniqueSupplyUnits = {} + locGroup.forEach((loc) => { + if (!uniqueSupplyUnits[loc.id]) { + uniqueSupplyUnits[loc.id] = { + regNum: loc.registrationNbr, + serialNum: loc.serialNbr, + hasOverlap: false, + records: [] + } + } + + // Check if this specific record has overlaps + const hasOverlap = + overlapMap[loc.uniqueId] && overlapMap[loc.uniqueId].length > 0 + if (hasOverlap) { + uniqueSupplyUnits[loc.id].hasOverlap = true + } + + uniqueSupplyUnits[loc.id].records.push({ + ...loc, + hasOverlap + }) + }) + + return ( +
    + + {firstLoc.name} + + + + Total Supply Units:{' '} + {Object.keys(uniqueSupplyUnits).length} + + + {!isInBC && ( + + 🚨 Outside BC! + + )} + + + Supply Units at this location: + + + + + Coordinates: {firstLoc.lat.toFixed(4)}, {firstLoc.lng.toFixed(4)} + +
    + ) + } + + if (isLoading) { + return + } + + if (isError || error) { + return ( + + ) + } + + if ( + !supplyEquipmentData || + !supplyEquipmentData.finalSupplyEquipments || + supplyEquipmentData.finalSupplyEquipments.length === 0 + ) + return + + return ( +
    + { + refetch() + setGeofencingStatus('idle') + }} + sx={{ mb: 2 }} + > + Refresh Map Data + + + + {/* {geofencingStatus === 'completed' && ( + + )} */} + + + + + + + + +
    + ) +} + +export default GeoMapping diff --git a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx b/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx deleted file mode 100644 index ada35fe9f..000000000 --- a/frontend/src/views/FinalSupplyEquipments/MapComponent.jsx +++ /dev/null @@ -1,842 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react' -import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet' -import { - Paper, - CircularProgress, - Alert, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow -} from '@mui/material' -import Control from 'react-leaflet-custom-control' -import 'leaflet/dist/leaflet.css' -import L from 'leaflet' -import { useGetFinalSupplyEquipments } from '@/hooks/useFinalSupplyEquipment' -import BCTypography from '@/components/BCTypography' -import BCButton from '@/components/BCButton' - -// Fix Leaflet's default icon issue -delete L.Icon.Default.prototype._getIconUrl -L.Icon.Default.mergeOptions({ - iconRetinaUrl: - 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png', - iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png', - shadowUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png' -}) - -// Create a marker icons map to avoid URL imports -const createMarkerIcon = (color) => { - return new L.Icon({ - iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-${color}.png`, - shadowUrl: - 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34] - }) -} - -// Prepare marker icons -const markerIcons = { - default: new L.Icon.Default(), - red: createMarkerIcon('red'), - orange: createMarkerIcon('orange'), - grey: createMarkerIcon('grey') -} - -// Geofencing approach using Nominatim for reverse geocoding -const checkLocationInBC = async (lat, lng) => { - try { - const response = await fetch( - `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=10&addressdetails=1`, - { - headers: { - 'User-Agent': 'BC-Travel-Planner/1.0' - } - } - ) - - if (!response.ok) { - throw new Error('Reverse geocoding request failed') - } - - const data = await response.json() - - // Check if the location is in British Columbia - const state = data.address?.state || data.address?.province || '' - const country = data.address?.country || '' - const stateDistrict = data.address?.state_district || '' - - // Return true if it's explicitly BC, or likely in BC based on surrounding data - return ( - (state.toLowerCase().includes('british columbia') || - stateDistrict.toLowerCase().includes('british columbia')) && - country.toLowerCase() === 'canada' - ) - } catch (error) { - console.error('Error checking location with geofencing:', error) - // Fallback to simple boundary check if the API fails - return lat > 48.0 && lat < 60.0 && lng > -139.0 && lng < -114.03 - } -} - -// Batch process location geofencing checks -const batchProcessGeofencing = async (locations) => { - const results = {} - const batchSize = 3 // Process 3 locations at a time - - for (let i = 0; i < locations.length; i += batchSize) { - const batch = locations.slice(i, i + batchSize) - const batchPromises = batch.map(async (loc) => { - const isInBC = await checkLocationInBC(loc.lat, loc.lng) - return { id: loc.id, isInBC } - }) - - const batchResults = await Promise.all(batchPromises) - - // Add batch results to the overall results - batchResults.forEach(({ id, isInBC }) => { - results[id] = isInBC - }) - if (i + batchSize < locations.length) { - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - } - - return results -} - -// Check if date ranges overlap between two locations -const datesOverlap = (start1, end1, start2, end2) => { - const s1 = new Date(start1) - const e1 = new Date(end1) - const s2 = new Date(start2) - const e2 = new Date(end2) - - return s1 <= e2 && s2 <= e1 -} - -// Find all overlapping periods for a given location, only considering same ID -const findOverlappingPeriods = (currentLoc, allLocations) => { - const currentRegNum = currentLoc.id.split('_')[0] - const currentSerialNum = currentLoc.id.split('_')[1] - - return allLocations - .filter((loc) => { - const locRegNum = loc.id.split('_')[0] - const locSerialNum = loc.id.split('_')[1] - - // Only check for overlap if this is the same ID but different record - return ( - currentLoc.uniqueId !== loc.uniqueId && // Different record - currentRegNum === locRegNum && // Same registration number - currentSerialNum === locSerialNum && // Same serial number - datesOverlap( - currentLoc.supplyFromDate, - currentLoc.supplyToDate, - loc.supplyFromDate, - loc.supplyToDate - ) - ) - }) - .map((loc) => ({ - id: loc.id, - uniqueId: loc.uniqueId, - name: loc.name, - regNum: loc.id.split('_')[0], - serialNum: loc.id.split('_')[1], - supplyFromDate: loc.supplyFromDate, - supplyToDate: loc.supplyToDate - })) -} - -// Group locations by their coordinates -const groupLocationsByCoordinates = (locations) => { - const grouped = {} - - locations.forEach((location) => { - const key = `${location.lat.toFixed(6)},${location.lng.toFixed(6)}` - - if (!grouped[key]) { - grouped[key] = [] - } - - grouped[key].push(location) - }) - - return grouped -} - -// Component to fit the map bounds when locations change -const MapBoundsHandler = ({ groupedLocations }) => { - const map = useMap() - - useEffect(() => { - if (Object.keys(groupedLocations).length > 0) { - const bounds = Object.values(groupedLocations).map((group) => [ - group[0].lat, - group[0].lng - ]) - - if (bounds.length > 0) { - map.fitBounds(bounds) - } - } - }, [map, groupedLocations]) - - return null -} - -// Legend component for react-leaflet -const MapLegend = ({ geofencingStatus }) => { - return ( - - - - Legend - - - {geofencingStatus === 'loading' && ( -
    - - Checking location... -
    - )} - -
    - - - Inside BC, no overlaps - -
    -
    - - - Period overlap (same Reg# & Serial#) - -
    -
    - - - Outside BC - -
    -
    -
    - ) -} - -const ExcelStyledTable = ({ uniqueSupplyUnits, overlapMap }) => { - return ( - - - - - {' '} - - Reg # - - - Serial # - - - Periods - - - Status - - - - - {Object.values(uniqueSupplyUnits).map((unit, index) => { - const sortedRecords = [...unit.records].sort( - (a, b) => new Date(a.supplyFromDate) - new Date(b.supplyFromDate) - ) - - return ( - - - - {unit.regNum} - - - {unit.serialNum} - - - {sortedRecords.map((record, idx) => ( -
    - {record.supplyFromDate} → {record.supplyToDate} -
    - ))} -
    - - {unit.hasOverlap ? '⚠️ Period overlap' : '✓ No overlap'} - -
    - - {/* Overlapping Period Details */} - {unit.hasOverlap && - sortedRecords - .filter((record) => record.hasOverlap) - .map((record, idx) => ( - - - Details for period:{' '} - {record.supplyFromDate} → {record.supplyToDate} -
    - - ⚠️ Overlaps with: - -
      - {overlapMap[record.uniqueId].map((overlap, i) => ( -
    • - Period: {overlap.supplyFromDate} →{' '} - {overlap.supplyToDate} -
    • - ))} -
    -
    -
    - ))} -
    - ) - })} -
    -
    -
    - ) -} - -const MapComponent = ({ complianceReportId }) => { - const [locations, setLocations] = useState([]) - const [groupedLocations, setGroupedLocations] = useState({}) - const [error, setError] = useState(null) - const [overlapMap, setOverlapMap] = useState({}) - const [geofencingResults, setGeofencingResults] = useState({}) - const [geofencingStatus, setGeofencingStatus] = useState('idle') - const [overlapStats, setOverlapStats] = useState({ - total: 0, - overlapping: 0, - nonOverlapping: 0, - bcOverlapping: 0, - nonBcOverlapping: 0 - }) - - const { - data: supplyEquipmentData, - isLoading, - isError, - refetch - } = useGetFinalSupplyEquipments(complianceReportId) - - // Transform API data to match the expected format - const transformApiData = useCallback((data) => { - if (!data || !data.finalSupplyEquipments) return [] - - return data.finalSupplyEquipments.map((row, index) => { - // Create a combined ID from registrationNbr and serialNbr - const registrationNbr = row.registrationNbr || 'unknown' - const serialNbr = row.serialNbr || 'unknown' - const combinedId = `${registrationNbr}_${serialNbr}` - - return { - id: combinedId, - uniqueId: `${combinedId}_${index}`, - registrationNbr, - serialNbr, - name: - `${row.streetAddress || ''}, ${row.city || ''}, ${ - row.postalCode || '' - }`.trim() || `Location ${index}`, - lat: parseFloat(row.latitude) || 0, - lng: parseFloat(row.longitude) || 0, - supplyFromDate: - row.supplyFromDate || new Date().toISOString().split('T')[0], - supplyToDate: row.supplyToDate || new Date().toISOString().split('T')[0] - } - }) - }, []) - - useEffect(() => { - if (supplyEquipmentData) { - const transformedData = transformApiData(supplyEquipmentData) - - if (transformedData.length === 0) { - console.warn('No location data found in API response') - setError(new Error('No location data found')) - } else { - setLocations(transformedData) - // Group locations by coordinates - const grouped = groupLocationsByCoordinates(transformedData) - setGroupedLocations(grouped) - - setError(null) - } - } - }, [supplyEquipmentData, transformApiData]) - - // Run geofencing when locations are loaded - useEffect(() => { - if (locations.length > 0 && geofencingStatus === 'idle') { - setGeofencingStatus('loading') - const uniqueLocations = Object.values(groupedLocations).map( - (group) => group[0] - ) - - batchProcessGeofencing(uniqueLocations) - .then((results) => { - const expandedResults = {} - - Object.entries(groupedLocations).forEach(([coordKey, locGroup]) => { - const firstLocId = locGroup[0].id - const isInBC = results[firstLocId] - locGroup.forEach((loc) => { - expandedResults[loc.id] = isInBC - }) - }) - - setGeofencingResults(expandedResults) - setGeofencingStatus('completed') - console.log('Geofencing results:', expandedResults) - }) - .catch((error) => { - console.error('Error during geofencing:', error) - setGeofencingStatus('error') - }) - } - }, [locations, groupedLocations, geofencingStatus]) - - // Calculate overlaps when locations and geofencing are completed - useEffect(() => { - if (locations.length > 0 && geofencingStatus === 'completed') { - const overlaps = {} - const stats = { - total: locations.length, - overlapping: 0, - nonOverlapping: 0, - bcOverlapping: 0, - nonBcOverlapping: 0 - } - locations.forEach((loc) => { - const overlappingPeriods = findOverlappingPeriods(loc, locations) - overlaps[loc.uniqueId] = overlappingPeriods - - const isInBC = geofencingResults[loc.id] - const hasOverlap = overlappingPeriods.length > 0 - - if (hasOverlap) { - stats.overlapping++ - if (isInBC) { - stats.bcOverlapping++ - } else { - stats.nonBcOverlapping++ - } - } else { - stats.nonOverlapping++ - } - }) - - setOverlapMap(overlaps) - setOverlapStats(stats) - console.log('Period overlaps:', overlaps) - console.log('Overlap statistics:', stats) - } - }, [locations, geofencingResults, geofencingStatus]) - - // Generate the popup content for a location group - const generatePopupContent = (coordKey, locGroup) => { - const firstLoc = locGroup[0] - const isInBC = geofencingResults[firstLoc.id] || false - - // Get unique supply units at this location - const uniqueSupplyUnits = {} - locGroup.forEach((loc) => { - if (!uniqueSupplyUnits[loc.id]) { - uniqueSupplyUnits[loc.id] = { - regNum: loc.registrationNbr, - serialNum: loc.serialNbr, - hasOverlap: false, - records: [] - } - } - - // Check if this specific record has overlaps - const hasOverlap = - overlapMap[loc.uniqueId] && overlapMap[loc.uniqueId].length > 0 - if (hasOverlap) { - uniqueSupplyUnits[loc.id].hasOverlap = true - } - - uniqueSupplyUnits[loc.id].records.push({ - ...loc, - hasOverlap - }) - }) - - return ( -
    - - {firstLoc.name} - - - - Total Supply Units:{' '} - {Object.keys(uniqueSupplyUnits).length} - - - {!isInBC && ( - - 🚨 Outside BC! - - )} - - - Supply Units at this location: - - - - - Coordinates: {firstLoc.lat.toFixed(4)}, {firstLoc.lng.toFixed(4)} - -
    - ) - } - - // Component for Geofencing Status - const GeofencingStatus = () => { - if (geofencingStatus === 'loading') { - return ( - } - > - - Geofencing in progress... - - - Checking each location to determine if it's inside BC's - boundaries. - - - ) - } - - if (geofencingStatus === 'error') { - return ( - - - Geofencing error - - - There was an error checking location boundaries. Using fallback - method. - - - ) - } - - return null - } - - // Summary of overlapping periods - const OverlapSummary = () => { - if (geofencingStatus !== 'completed') return null - console.log('Overlap stats:', overlapStats) - return ( - 0 ? 'warning' : 'success'} - sx={{ mb: 2 }} - > - - {overlapStats.overlapping > 0 - ? 'Period Overlaps Detected' - : 'No Period Overlaps'} - - -
    - - Total Supply Units: {overlapStats.total} - - - Units with Overlaps: {overlapStats.overlapping} - - - Units without Overlaps:{' '} - {overlapStats.nonOverlapping} - - - BC Units with Overlaps:{' '} - {overlapStats.bcOverlapping} - - - Outside BC with Overlaps:{' '} - {overlapStats.nonBcOverlapping} - -
    -
    - ) - } - - if (isLoading) - return ( - } - sx={{ mb: 2 }} - > - Loading map data... - - ) - - if (isError || error) - return ( -
    - - - Error: {error?.message || 'Failed to load data'} - - - Please ensure the API provides location data with latitude, - longitude, and date fields. - - - { - refetch() - setGeofencingStatus('idle') - }} - sx={{ mb: 2 }} - > - Refresh Map Data - -
    - ) - - if ( - !supplyEquipmentData || - !supplyEquipmentData.finalSupplyEquipments || - supplyEquipmentData.finalSupplyEquipments.length === 0 - ) - return ( -
    - - - No location data found - - - API should return data with the following fields: - -
      -
    • registrationNbr and serialNbr (for ID creation)
    • -
    • streetAddress, city, postalCode (for location name)
    • -
    • latitude and longitude
    • -
    • supplyFromDate and supplyToDate
    • -
    -
    - { - refetch() - setGeofencingStatus('idle') - }} - sx={{ mb: 2 }} - > - Refresh Map Data - -
    - ) - - return ( -
    - { - refetch() - setGeofencingStatus('idle') - }} - sx={{ mb: 2 }} - > - Refresh Map Data - - - - - {/* {geofencingStatus === 'completed' && } */} - - - - - - - - - {Object.entries(groupedLocations).map(([coordKey, locGroup]) => { - const firstLoc = locGroup[0] - const position = [firstLoc.lat, firstLoc.lng] - - // Determine marker icon based on geofencing results and overlap status - let icon = markerIcons.grey // Default to loading icon - - if (geofencingStatus === 'completed') { - const isInBC = geofencingResults[firstLoc.id] - - const hasAnyOverlaps = locGroup.some( - (loc) => - overlapMap[loc.uniqueId] && - overlapMap[loc.uniqueId].length > 0 - ) - - if (!isInBC) { - icon = markerIcons.red - } else if (hasAnyOverlaps) { - icon = markerIcons.orange - } else { - icon = markerIcons.default - } - } - - return ( - - - {geofencingStatus === 'completed' ? ( - generatePopupContent(coordKey, locGroup) - ) : ( -
    - - {firstLoc.name} - - - - Checking location... - -
    - )} -
    -
    - ) - })} -
    -
    -
    - ) -} - -export default MapComponent diff --git a/frontend/src/views/FinalSupplyEquipments/_schema.jsx b/frontend/src/views/FinalSupplyEquipments/_schema.jsx index c5dbf359e..d7e530279 100644 --- a/frontend/src/views/FinalSupplyEquipments/_schema.jsx +++ b/frontend/src/views/FinalSupplyEquipments/_schema.jsx @@ -19,7 +19,7 @@ import { import { StandardCellWarningAndErrors } from '@/utils/grid/errorRenderers' import { apiRoutes } from '@/constants/routes' import { numberFormatter } from '@/utils/formatters.js' -import { useMemo } from 'react' +import { ADDRESS_SEARCH_URL } from '@/constants/common' export const finalSupplyEquipmentColDefs = ( optionsData, @@ -284,9 +284,7 @@ export const finalSupplyEquipmentColDefs = ( queryKey: 'fuel-code-search', queryFn: async ({ queryKey, client }) => { const response = await fetch( - `https://geocoder.api.gov.bc.ca/addresses.json?minScore=50&maxResults=5&echo=true&brief=true&autoComplete=true&exactSpelling=false&fuzzyMatch=false&matchPrecisionNot=&locationDescriptor=frontDoorPoint&addressString=${encodeURIComponent( - queryKey[1] - )}` + ADDRESS_SEARCH_URL + encodeURIComponent(queryKey[1]) ) if (!response.ok) throw new Error('Network response was not ok') const data = await response.json() diff --git a/frontend/src/views/FinalSupplyEquipments/components/MapComponents.jsx b/frontend/src/views/FinalSupplyEquipments/components/MapComponents.jsx new file mode 100644 index 000000000..0e0b1cbb7 --- /dev/null +++ b/frontend/src/views/FinalSupplyEquipments/components/MapComponents.jsx @@ -0,0 +1,148 @@ +import { useEffect } from 'react' +import { useMap, Marker, Popup, TileLayer } from 'react-leaflet' +import Control from 'react-leaflet-custom-control' +import { Paper, CircularProgress } from '@mui/material' +import BCTypography from '@/components/BCTypography' +import { markerIcons } from './utils' + +// Component to fit the map bounds when locations change +export const MapBoundsHandler = ({ groupedLocations }) => { + const map = useMap() + + useEffect(() => { + if (Object.keys(groupedLocations).length > 0) { + const bounds = Object.values(groupedLocations).map((group) => [ + group[0].lat, + group[0].lng + ]) + + if (bounds.length > 0) { + map.fitBounds(bounds) + } + } + }, [map, groupedLocations]) + + return null +} + +// Legend component for react-leaflet +export const MapLegend = ({ geofencingStatus }) => { + const legendItems = [ + // Conditionally include the loading state item + ...(geofencingStatus === 'loading' + ? [ + { + src: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-grey.png', + alt: 'Grey marker', + text: 'Checking location...' + } + ] + : []), + // Always include these items + { + src: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png', + alt: 'Blue marker', + text: 'Inside BC, no overlaps' + }, + { + src: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png', + alt: 'Orange marker', + text: 'Period overlap (same Reg# & Serial#)' + }, + { + src: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png', + alt: 'Red marker', + text: 'Outside BC' + } + ] + + return ( + + + + Legend + + + {legendItems.map((item, index) => ( +
    + {item.alt} + + {item.text} + +
    + ))} +
    +
    + ) +} + +// Map markers component to separate marker rendering logic +export const MapMarkers = ({ + groupedLocations, + geofencingStatus, + geofencingResults, + overlapMap, + generatePopupContent +}) => { + return ( + <> + {Object.entries(groupedLocations).map(([coordKey, locGroup]) => { + const firstLoc = locGroup[0] + const position = [firstLoc.lat, firstLoc.lng] + + // Determine marker icon based on geofencing results and overlap status + let icon = markerIcons.grey // Default to loading icon + + if (geofencingStatus === 'completed') { + const isInBC = geofencingResults[firstLoc.id] + + const hasAnyOverlaps = locGroup.some( + (loc) => + overlapMap[loc.uniqueId] && overlapMap[loc.uniqueId].length > 0 + ) + + if (!isInBC) { + icon = markerIcons.red + } else if (hasAnyOverlaps) { + icon = markerIcons.orange + } else { + icon = markerIcons.default + } + } + + return ( + + + {geofencingStatus === 'completed' ? ( + generatePopupContent(coordKey, locGroup) + ) : ( +
    + + {firstLoc.name} + + + + Checking location... + +
    + )} +
    +
    + ) + })} + + ) +} + +// Base Map component with the tiles +export const BaseMap = () => { + return ( + + ) +} diff --git a/frontend/src/views/FinalSupplyEquipments/components/StatusComponent.jsx b/frontend/src/views/FinalSupplyEquipments/components/StatusComponent.jsx new file mode 100644 index 000000000..070fb76eb --- /dev/null +++ b/frontend/src/views/FinalSupplyEquipments/components/StatusComponent.jsx @@ -0,0 +1,143 @@ +import { Alert, CircularProgress } from '@mui/material' +import BCTypography from '@/components/BCTypography' +import BCButton from '@/components/BCButton' + +// Geofencing Status component +export const GeofencingStatus = ({ status }) => { + if (status === 'loading') { + return ( + } + > + + Geofencing in progress... + + + Checking each location to determine if it's inside BC's + boundaries. + + + ) + } + + if (status === 'error') { + return ( + + + Geofencing error + + + There was an error checking location boundaries. Using fallback + method. + + + ) + } + + return null +} + +// Summary of overlapping periods +export const OverlapSummary = ({ overlapStats }) => { + return ( + 0 ? 'warning' : 'success'} + sx={{ mb: 2 }} + > + + {overlapStats.overlapping > 0 + ? 'Period Overlaps Detected' + : 'No Period Overlaps'} + + +
    + + Total Supply Units: {overlapStats.total} + + + Units with Overlaps: {overlapStats.overlapping} + + + Units without Overlaps: {overlapStats.nonOverlapping} + + + BC Units with Overlaps: {overlapStats.bcOverlapping} + + + Outside BC with Overlaps:{' '} + {overlapStats.nonBcOverlapping} + +
    +
    + ) +} + +// Loading and error states +export const LoadingState = () => ( + } sx={{ mb: 2 }}> + Loading map data... + +) + +export const ErrorState = ({ error, refetch, resetGeofencing }) => ( +
    + + + Error: {error?.message || 'Failed to load data'} + + + Please ensure the API provides location data with latitude, longitude, + and date fields. + + + { + refetch() + resetGeofencing() + }} + sx={{ mb: 2 }} + > + Refresh Map Data + +
    +) + +export const NoDataState = ({ refetch, resetGeofencing }) => ( +
    + + + No location data found + + + API should return data with the following fields: + +
      +
    • registrationNbr and serialNbr (for ID creation)
    • +
    • streetAddress, city, postalCode (for location name)
    • +
    • latitude and longitude
    • +
    • supplyFromDate and supplyToDate
    • +
    +
    + { + refetch() + resetGeofencing() + }} + sx={{ mb: 2 }} + > + Refresh Map Data + +
    +) diff --git a/frontend/src/views/FinalSupplyEquipments/components/TableComponent.jsx b/frontend/src/views/FinalSupplyEquipments/components/TableComponent.jsx new file mode 100644 index 000000000..bf3e44e73 --- /dev/null +++ b/frontend/src/views/FinalSupplyEquipments/components/TableComponent.jsx @@ -0,0 +1,157 @@ +import React from 'react' +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper +} from '@mui/material' + +export const ExcelStyledTable = ({ uniqueSupplyUnits, overlapMap }) => { + return ( + + + + + + Reg # + + + Serial # + + + Periods + + + Status + + + + + {Object.values(uniqueSupplyUnits).map((unit, index) => { + const sortedRecords = [...unit.records].sort( + (a, b) => new Date(a.supplyFromDate) - new Date(b.supplyFromDate) + ) + + return ( + + + + {unit.regNum} + + + {unit.serialNum} + + + {sortedRecords.map((record, idx) => ( +
    + {record.supplyFromDate} → {record.supplyToDate} +
    + ))} +
    + + {unit.hasOverlap ? '⚠️ Period overlap' : '✓ No overlap'} + +
    + + {/* Overlapping Period Details */} + {unit.hasOverlap && + sortedRecords + .filter((record) => record.hasOverlap) + .map((record, idx) => ( + + + Details for period:{' '} + {record.supplyFromDate} → {record.supplyToDate} +
    + + ⚠️ Overlaps with: + +
      + {overlapMap[record.uniqueId].map((overlap, i) => ( +
    • + Period: {overlap.supplyFromDate} →{' '} + {overlap.supplyToDate} +
    • + ))} +
    +
    +
    + ))} +
    + ) + })} +
    +
    +
    + ) +} diff --git a/frontend/src/views/FinalSupplyEquipments/components/utils.js b/frontend/src/views/FinalSupplyEquipments/components/utils.js new file mode 100644 index 000000000..b731e8fbd --- /dev/null +++ b/frontend/src/views/FinalSupplyEquipments/components/utils.js @@ -0,0 +1,183 @@ +import L from 'leaflet' + +// Leaflet's default icon +export const fixLeafletIcons = () => { + delete L.Icon.Default.prototype._getIconUrl + L.Icon.Default.mergeOptions({ + iconRetinaUrl: + 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png' + }) +} + +// Create a marker icons map to avoid URL imports +export const createMarkerIcon = (color) => { + return new L.Icon({ + iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-${color}.png`, + shadowUrl: + 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34] + }) +} + +// Prepare marker icons +export const markerIcons = { + default: new L.Icon.Default(), + red: createMarkerIcon('red'), + orange: createMarkerIcon('orange'), + grey: createMarkerIcon('grey') +} + +// Check if date ranges overlap between two locations +export const datesOverlap = (start1, end1, start2, end2) => { + const s1 = new Date(start1) + const e1 = new Date(end1) + const s2 = new Date(start2) + const e2 = new Date(end2) + + return s1 <= e2 && s2 <= e1 +} + +// Transform API data to match the expected format +export const transformApiData = (data) => { + if (!data || !data.finalSupplyEquipments) return [] + + return data.finalSupplyEquipments.map((row, index) => { + // Create a combined ID from registrationNbr and serialNbr + const registrationNbr = row.registrationNbr || 'unknown' + const serialNbr = row.serialNbr || 'unknown' + const combinedId = `${registrationNbr}_${serialNbr}` + + return { + id: combinedId, + uniqueId: `${combinedId}_${index}`, + registrationNbr, + serialNbr, + name: + `${row.streetAddress || ''}, ${row.city || ''}, ${ + row.postalCode || '' + }`.trim() || `Location ${index}`, + lat: parseFloat(row.latitude) || 0, + lng: parseFloat(row.longitude) || 0, + supplyFromDate: + row.supplyFromDate || new Date().toISOString().split('T')[0], + supplyToDate: row.supplyToDate || new Date().toISOString().split('T')[0] + } + }) +} + +// Group locations by their coordinates +export const groupLocationsByCoordinates = (locations) => { + const grouped = {} + + locations.forEach((location) => { + const key = `${location.lat.toFixed(6)},${location.lng.toFixed(6)}` + + if (!grouped[key]) { + grouped[key] = [] + } + + grouped[key].push(location) + }) + + return grouped +} + +// Find all overlapping periods for a given location, only considering same ID +export const findOverlappingPeriods = (currentLoc, allLocations) => { + const currentRegNum = currentLoc.id.split('_')[0] + const currentSerialNum = currentLoc.id.split('_')[1] + + return allLocations + .filter((loc) => { + const locRegNum = loc.id.split('_')[0] + const locSerialNum = loc.id.split('_')[1] + + // Only check for overlap if this is the same ID but different record + return ( + currentLoc.uniqueId !== loc.uniqueId && // Different record + currentRegNum === locRegNum && // Same registration number + currentSerialNum === locSerialNum && // Same serial number + datesOverlap( + currentLoc.supplyFromDate, + currentLoc.supplyToDate, + loc.supplyFromDate, + loc.supplyToDate + ) + ) + }) + .map((loc) => ({ + id: loc.id, + uniqueId: loc.uniqueId, + name: loc.name, + regNum: loc.id.split('_')[0], + serialNum: loc.id.split('_')[1], + supplyFromDate: loc.supplyFromDate, + supplyToDate: loc.supplyToDate + })) +} + +// Geofencing approach using Nominatim for reverse geocoding +export const checkLocationInBC = async (lat, lng) => { + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=10&addressdetails=1`, + { + headers: { + 'User-Agent': 'BC-Travel-Planner/1.0' + } + } + ) + + if (!response.ok) { + throw new Error('Reverse geocoding request failed') + } + + const data = await response.json() + + // Check if the location is in British Columbia + const state = data.address?.state || data.address?.province || '' + const country = data.address?.country || '' + const stateDistrict = data.address?.state_district || '' + + // Return true if it's explicitly BC, or likely in BC based on surrounding data + return ( + (state.toLowerCase().includes('british columbia') || + stateDistrict.toLowerCase().includes('british columbia')) && + country.toLowerCase() === 'canada' + ) + } catch (error) { + console.error('Error checking location with geofencing:', error) + // Fallback to simple boundary check if the API fails + return lat > 48.0 && lat < 60.0 && lng > -139.0 && lng < -114.03 + } +} + +// Batch process location geofencing checks +export const batchProcessGeofencing = async (locations) => { + const results = {} + const batchSize = 3 // Process 3 locations at a time + + for (let i = 0; i < locations.length; i += batchSize) { + const batch = locations.slice(i, i + batchSize) + const batchPromises = batch.map(async (loc) => { + const isInBC = await checkLocationInBC(loc.lat, loc.lng) + return { id: loc.id, isInBC } + }) + + const batchResults = await Promise.all(batchPromises) + + // Add batch results to the overall results + batchResults.forEach(({ id, isInBC }) => { + results[id] = isInBC + }) + if (i + batchSize < locations.length) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + return results +}