diff --git a/.github/workflows/hardReleaseParent.yml b/.github/workflows/hardReleaseParent.yml new file mode 100644 index 00000000..1f947496 --- /dev/null +++ b/.github/workflows/hardReleaseParent.yml @@ -0,0 +1,96 @@ +name: Hard release + +on: + workflow_dispatch: + inputs: + environment: + description: Choose build environment + required: true + type: choice + options: + - QA + - Prod + runnerType: + description: Choose runner type + required: true + type: choice + options: + - macos + - default + releaseType: + description: Choose release type + required: true + type: choice + options: + - TEST_BUILD + - SOFT_RELEASE + - HARD_RELEASE + type: + description: Choose build type + required: true + type: choice + options: + - release + version_code_tele: + description: Tele app version code (example, 292) + required: true + type: string + default: '292' + version_name_tele: + description: Tele app version name (example, 3.2.1) + required: true + type: string + default: '3.2.1' + version_code_field: + description: Field app version code (example, 292) + required: true + type: string + default: '292' + version_name_field: + description: Field app version name (example, 3.2.1) + required: true + type: string + default: '3.2.1' + +jobs: + field-app: + uses: ./.github/workflows/newBuild.yml + with: + environment: ${{ github.event.inputs.environment }} + runnerType: ${{ github.event.inputs.runnerType }} + releaseType: ${{ github.event.inputs.releaseType }} + flavor: fieldAgents + type: ${{ github.event.inputs.type }} + version_code: ${{ github.event.inputs.version_code_field }} + version_name: ${{ github.event.inputs.version_name_field }} + secrets: + MY_REPO_PAT: ${{ secrets.MY_REPO_PAT }} + CODEPUSH_QA_KEY: ${{ secrets.CODEPUSH_QA_KEY }} + CODEPUSH_PROD_KEY: ${{ secrets.CODEPUSH_PROD_KEY }} + PASSPHARASE: ${{ secrets.PASSPHARASE }} + KEY_STORE: ${{ secrets.KEY_STORE }} + LONGHORN_QA_BASE_URL: ${{ secrets.LONGHORN_QA_BASE_URL }} + LONGHORN_PROD_BASE_URL: ${{ secrets.LONGHORN_PROD_BASE_URL }} + LONGHORN_HEADER: ${{ secrets.LONGHORN_HEADER }} + CYBERTRON_BASE_URL: ${{ secrets.CYBERTRON_BASE_URL }} + CYBERTRON_PROJECT_ID: ${{ secrets.CYBERTRON_PROJECT_ID }} + + tele-app: + uses: ./.github/workflows/hardReleaseTele.yml + with: + environment: ${{ github.event.inputs.environment }} + releaseType: ${{ github.event.inputs.releaseType }} + runnerType: ${{ github.event.inputs.runnerType }} + flavor: callingAgents + type: ${{ github.event.inputs.type }} + version_code: ${{ github.event.inputs.version_code_tele }} + version_name: ${{ github.event.inputs.version_name_tele }} + secrets: + MY_REPO_PAT: ${{ secrets.MY_REPO_PAT }} + CODEPUSH_QA_KEY: ${{ secrets.CODEPUSH_QA_KEY }} + TELE_CODE_PUSH_PROD_KEY: ${{ secrets.TELE_CODE_PUSH_PROD_KEY }} + PASSPHARASE: ${{ secrets.PASSPHARASE }} + KEY_STORE: ${{ secrets.KEY_STORE }} + LONGHORN_QA_BASE_URL: ${{ secrets.LONGHORN_QA_BASE_URL }} + LONGHORN_PROD_BASE_URL: ${{ secrets.LONGHORN_PROD_BASE_URL }} + LONGHORN_HEADER: ${{ secrets.LONGHORN_HEADER }} diff --git a/.github/workflows/hardReleaseTele.yml b/.github/workflows/hardReleaseTele.yml index 8b93f054..80bd26fd 100644 --- a/.github/workflows/hardReleaseTele.yml +++ b/.github/workflows/hardReleaseTele.yml @@ -1,42 +1,50 @@ -name: generate-apk-tele +name: hard-release-tele on: - workflow_dispatch: + workflow_call: + secrets: + MY_REPO_PAT: + required: true + CODEPUSH_QA_KEY: + required: true + TELE_CODE_PUSH_PROD_KEY: + required: true + PASSPHARASE: + required: true + KEY_STORE: + required: true + LONGHORN_QA_BASE_URL: + required: true + LONGHORN_PROD_BASE_URL: + required: true + LONGHORN_HEADER: + required: true inputs: - environment: - description: Choose build environment - required: true - type: choice - options: - - QA - - Prod - releaseType: - description: Choose release type - required: true - type: choice - options: - - TEST_BUILD - - SOFT_RELEASE - - HARD_RELEASE - type: - description: Choose build type - required: true - type: choice - options: - - release - version_code: - description: Enter app version code (example, 292) - required: true - type: string - default: "292" - version_name: - description: Enter app version name (example, 3.2.1) - required: true - type: string - default: "3.2.1" + environment: + required: true + type: string + releaseType: + required: true + type: string + runnerType: + required: true + type: string + flavor: + required: true + type: string + type: + required: true + type: string + version_code: + required: true + type: string + version_name: + required: true + type: string + jobs: - generate: - runs-on: [ default ] + generate_build: + runs-on: ${{ inputs.runnerType }} steps: - name: Checkout uses: actions/checkout@v2 @@ -44,13 +52,25 @@ jobs: token: ${{ secrets.MY_REPO_PAT }} submodules: recursive - name: update codepush key QA - if: (github.event.inputs.environment == 'QA' || inputs.environment == 'QA') - run: sed -i "s/pastekeyhere/${{ secrets.CODEPUSH_QA_KEY }}/" android/app/src/main/res/values/strings.xml && cat android/app/src/main/res/values/strings.xml + if: inputs.environment == 'QA' + run: | + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + sed -i "" "s/pastekeyhere/${{ secrets.CODEPUSH_QA_KEY }}/" android/app/src/main/res/values/strings.xml + else + sed -i "s/pastekeyhere/${{ secrets.CODEPUSH_QA_KEY }}/" android/app/src/main/res/values/strings.xml + fi + cat android/app/src/main/res/values/strings.xml - name: update codepush key PROD - if: (github.event.inputs.environment == 'Prod' || inputs.environment == 'Prod') - run: sed -i "s/pastekeyhere/${{ secrets.TELE_CODE_PUSH_PROD_KEY }}/" android/app/src/main/res/values/strings.xml && cat android/app/src/main/res/values/strings.xml + if: inputs.environment == 'Prod' + run: | + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + sed -i "" "s/pastekeyhere/${{ secrets.TELE_CODE_PUSH_PROD_KEY }}/" android/app/src/main/res/values/strings.xml + else + sed -i "s/pastekeyhere/${{ secrets.TELE_CODE_PUSH_PROD_KEY }}/" android/app/src/main/res/values/strings.xml + fi + cat android/app/src/main/res/values/strings.xml - name: Generate keystore - if: (github.event.inputs.type == 'release' || inputs.type == 'release') + if: inputs.type == 'release' run: echo "${{ secrets.KEY_STORE }}" > keystore.asc && gpg -d --passphrase "${{ secrets.PASSPHARASE }}" --batch keystore.asc > android/app/my-upload-key.keystore - name: Set Node.js 16.x uses: actions/setup-node@v3 @@ -61,16 +81,26 @@ jobs: - name: Install dependency run: yarn - name: Override App Version Code - if: github.event_name == 'workflow_dispatch' && github.event.inputs.version_code != '' - run: sed -i 's/def VERSION_CODE = [0-9].*/def VERSION_CODE = ${{ github.event.inputs.version_code }}/g' android/app/build.gradle + if: github.event_name == 'workflow_dispatch' && inputs.version_code != '' + run: | + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + sed -i "" 's/def VERSION_CODE = [0-9].*/def VERSION_CODE = '${{ inputs.version_code }}'/g' android/app/build.gradle + else + sed -i 's/def VERSION_CODE = [0-9].*/def VERSION_CODE = '${{ inputs.version_code }}'/g' android/app/build.gradle + fi - name: Override App Version Name - if: github.event_name == 'workflow_dispatch' && github.event.inputs.version_name != '' - run: sed -i 's/def VERSION_NAME = "[0-9].*"/def VERSION_NAME = "${{ github.event.inputs.version_name }}"/g' android/app/build.gradle + if: github.event_name == 'workflow_dispatch' && inputs.version_name != '' + run: | + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + sed -i "" 's/def VERSION_NAME = "[0-9].*"/def VERSION_NAME = "'${{ inputs.version_name }}'"/g' android/app/build.gradle + else + sed -i 's/def VERSION_NAME = "[0-9].*"/def VERSION_NAME = "'${{ inputs.version_name }}'"/g' android/app/build.gradle + fi - name: Log Build Metadata run: | echo "Commit SHA: ${{ github.sha }}" - echo "Build Environment: ${{ github.event.inputs.environment || inputs.environment }}" - echo "Build Type: ${{ github.event.inputs.type || inputs.type }}" + echo "Build Environment: ${{ inputs.environment }}" + echo "Build Type: ${{ inputs.type }}" echo "App Version Code: $(awk '/VERSION_CODE/ {print $4}' app/build.gradle)" echo "App Version Name: $(awk '/VERSION_NAME/ {print $4}' app/build.gradle | tr -d '"')" - name: Set up JDK 18 @@ -85,24 +115,24 @@ jobs: - name: Create local.properties run: cd android && touch local.properties && echo "sdk.dir = /home/USERNAME/Android/Sdk" > local.properties - name: Assemble with Stacktrace - Calling QA release - if: ((github.event.inputs.environment == 'QA' || inputs.environment == 'QA')) + if: inputs.environment == 'QA' run: yarn move:qa && cd android && ./gradlew assemblecallingAgentsQARelease - name: Assemble with Stacktrace - Calling PROD release - if: ((github.event.inputs.environment == 'Prod' || inputs.environment == 'Prod')) + if: inputs.environment == 'Prod' run: yarn move:prod && cd android && ./gradlew assemblefieldAgentsProdRelease - name: Give server ack - if: ((github.event.inputs.releaseType != 'TEST_BUILD' || inputs.releaseType != 'TEST_BUILD')) + if: inputs.releaseType != 'TEST_BUILD' run: | ls ls -asl pwd baseUrl=${{secrets.LONGHORN_QA_BASE_URL}} - if [ "${{ github.event.inputs.environment }}" == "Prod" ] || [ "${{ inputs.environment }}" == "Prod" ]; then + if [ "${{ inputs.environment }}" == "Prod" ]; then echo "Prod" baseUrl=${{secrets.LONGHORN_PROD_BASE_URL}} fi echo "$baseUrl" - getPreSignedURL="$baseUrl/app/upload-url?appType=callingAgents&buildNumber=${{github.event.inputs.version_code || inputs.version_code}}&appVersion=${{github.event.inputs.version_name || inputs.version_name}}&releaseType=${{github.event.inputs.releaseType || inputs.releaseType}}" + getPreSignedURL="$baseUrl/app/upload-url?appType=callingAgents&buildNumber=${{inputs.version_code}}&appVersion=${{inputs.version_name}}&releaseType=${{inputs.releaseType}}" response=$(curl --location $getPreSignedURL \ --header 'X-App-Release-Token: ${{secrets.LONGHORN_HEADER}}' ) @@ -117,7 +147,7 @@ jobs: ls - apk_path="./android/app/build/outputs/apk/callingAgentsProd/${{github.event.inputs.type || inputs.type}}/app-callingAgentsProd-release" + apk_path="./android/app/build/outputs/apk/callingAgentsProd/${{inputs.type}}/app-callingAgentsProd-release" echo "$apk_path" @@ -137,7 +167,7 @@ jobs: echo "ack url" - ack_url=("$baseUrl/app/upload-ack?referenceId=${id}&releaseType=${{github.event.inputs.releaseType || inputs.releaseType}}") + ack_url=("$baseUrl/app/upload-ack?referenceId=${id}&releaseType=${{inputs.releaseType}}") echo "$ack_url" @@ -146,14 +176,14 @@ jobs: - name: Upload APK as Artifact uses: actions/upload-artifact@v3 with: - name: app-${{ github.event.inputs.type || inputs.type }}-v${{ github.event.inputs.version_code || inputs.version_code }}-name-${{github.event.inputs.version_name || inputs.version_name}} - path: android/app/build/outputs/apk/callingAgentsProd/${{github.event.inputs.type || inputs.type}} + name: app-${{ inputs.type }}-v${{ inputs.version_code }}-name-${{inputs.version_name}} + path: android/app/build/outputs/apk/callingAgentsProd/${{inputs.type}} retention-days: 30 generate_source_map: - needs: generate + needs: generate_build runs-on: [default] - if: success() && (github.event.inputs.environment == 'Prod') && (github.event.inputs.releaseType == 'HARD_RELEASE' || inputs.releaseType == 'HARD_RELEASE') # Only create source map for Prod releases and not for test builds + if: success() && inputs.environment == 'Prod' && inputs.releaseType == 'HARD_RELEASE' # Only create source map for Prod releases and not for test builds steps: - name: Checkout uses: actions/checkout@v2 @@ -183,39 +213,44 @@ jobs: - name: Compile Hermes Bytecode and Generate Source Maps run: | - node_modules/react-native/sdks/hermesc/linux64-bin/hermesc \ - -O -emit-binary \ - -output-source-map \ - -out=index.android.bundle.hbc \ - index.android.bundle + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + HERMESC_PATH="node_modules/react-native/sdks/hermesc/osx-bin/hermesc" + else + HERMESC_PATH="node_modules/react-native/sdks/hermesc/linux64-bin/hermesc" + fi + $HERMESC_PATH \ + -O -emit-binary \ + -output-source-map \ + -out=index.android.bundle.hbc \ + index.android.bundle - # Remove the original bundle to prevent duplication - rm -f index.android.bundle + # Remove the original bundle to prevent duplication + rm -f index.android.bundle - # Rename the Hermes bundle and source map - mv index.android.bundle.hbc index.android.bundle - mv index.android.bundle.map index.android.bundle.packager.map + # Rename the Hermes bundle and source map + mv index.android.bundle.hbc index.android.bundle + mv index.android.bundle.map index.android.bundle.packager.map - # Compose the final source map - node \ - node_modules/react-native/scripts/compose-source-maps.js \ - index.android.bundle.packager.map \ - index.android.bundle.hbc.map \ - -o index.android.bundle.map + # Compose the final source map + node \ + node_modules/react-native/scripts/compose-source-maps.js \ + index.android.bundle.packager.map \ + index.android.bundle.hbc.map \ + -o index.android.bundle.map - # Clean up the temporary files - rm -f index.android.bundle.packager.map + # Clean up the temporary files + rm -f index.android.bundle.packager.map - name: Upload Source Map uses: actions/upload-artifact@v3 with: - name: source-map + name: source-map-${{inputs.version_name}} path: index.android.bundle.map create_release_tag: needs: generate_source_map runs-on: [default] - if: success() && (github.event.inputs.environment == 'Prod') && (github.event.inputs.releaseType == 'HARD_RELEASE' || inputs.releaseType == 'HARD_RELEASE') # Only create source map for Prod releases and not for test builds + if: success() && inputs.environment == 'Prod' && inputs.releaseType == 'HARD_RELEASE' # Only create source map for Prod releases and not for test builds steps: - name: Checkout uses: actions/checkout@v2 @@ -226,7 +261,7 @@ jobs: - name: Check if tag exists id: check_tag run: | - TAG_NAME="${{github.event.inputs.version_name || inputs.version_name}}" + TAG_NAME="${{inputs.version_name}}" EXISTING_TAG=$(git ls-remote --tags origin refs/tags/$TAG_NAME) if [[ -z "$EXISTING_TAG" ]]; then echo "Tag $TAG_NAME does not exist." @@ -239,7 +274,7 @@ jobs: - name: Create and push tag if: env.tag_exists == 'false' run: | - TAG_NAME="${{github.event.inputs.version_name || inputs.version_name}}" + TAG_NAME="${{inputs.version_name}}" # git config --local user.email "${{ github.actor }}@github.com" git config --local user.name "${{ github.actor }}" git tag $TAG_NAME @@ -248,10 +283,10 @@ jobs: GITHUB_TOKEN: ${{ secrets.MY_REPO_PAT }} - name: Create release tag run: | - TAG_NAME="${{github.event.inputs.version_name || inputs.version_name}}" + TAG_NAME="${{inputs.version_name}}" BUILD_NUMBER="${{ needs.generate.outputs.build_number }}" RELEASE_NAME="$TAG_NAME (build $BUILD_NUMBER) code push" - DESCRIPTION="${{ github.event.inputs.description }}" + DESCRIPTION="${{ inputs.description }}" REPO="navi-medici/address-verification-app" BRANCH_NAME="${GITHUB_REF#refs/heads/}" diff --git a/.github/workflows/newBuild.yml b/.github/workflows/newBuild.yml index 8d211e9c..9116e366 100644 --- a/.github/workflows/newBuild.yml +++ b/.github/workflows/newBuild.yml @@ -1,59 +1,57 @@ -name: generate-apk +name: hard-release-field on: - workflow_dispatch: + workflow_call: + secrets: + MY_REPO_PAT: + required: true + CODEPUSH_QA_KEY: + required: true + CODEPUSH_PROD_KEY: + required: true + PASSPHARASE: + required: true + KEY_STORE: + required: true + LONGHORN_QA_BASE_URL: + required: true + LONGHORN_PROD_BASE_URL: + required: true + LONGHORN_HEADER: + required: true + CYBERTRON_BASE_URL: + required: true + CYBERTRON_PROJECT_ID: + required: true inputs: - environment: - description: Choose build environment - required: true - type: choice - options: - - QA - - Prod - runnerType: - description: Choose runner type - required: true - type: choice - options: - - default - - macos - flavor: - description: Choose build flavour - required: true - type: choice - options: - - fieldAgents - - callingAgents - releaseType: - description: Choose release type - required: true - type: choice - options: - - TEST_BUILD - - SOFT_RELEASE - - HARD_RELEASE - type: - description: Choose build type - required: true - type: choice - options: - - release - version_code: - description: Enter app version code (example, 292) - required: true - type: string - default: "292" - version_name: - description: Enter app version name (example, 3.2.1) - required: true - type: string - default: "3.2.1" + environment: + required: true + type: string + releaseType: + required: true + type: string + runnerType: + required: true + type: string + flavor: + required: true + type: string + type: + required: true + type: string + version_code: + required: true + type: string + version_name: + required: true + type: string + jobs: - generate: - runs-on: ${{ github.event.inputs.runnerType }} + generate_build: + runs-on: ${{ inputs.runnerType }} outputs: - package_version: ${{ github.event.inputs.version_name }} - build_number: ${{ github.event.inputs.version_code }} + package_version: ${{ inputs.version_name }} + build_number: ${{ inputs.version_code }} steps: - name: Checkout uses: actions/checkout@v2 @@ -61,25 +59,25 @@ jobs: token: ${{ secrets.MY_REPO_PAT }} submodules: recursive - name: Update CodePush key for QA - if: (github.event.inputs.environment == 'QA' || inputs.environment == 'QA') + if: inputs.environment == 'QA' run: | - if [[ "${{github.event.inputs.runnerType}}" == "macos" ]]; then + if [[ "${{inputs.runnerType}}" == "macos" ]]; then sed -i "" "s/pastekeyhere/${{ secrets.CODEPUSH_QA_KEY }}/" android/app/src/main/res/values/strings.xml else sed -i "s/pastekeyhere/${{ secrets.CODEPUSH_QA_KEY }}/" android/app/src/main/res/values/strings.xml fi cat android/app/src/main/res/values/strings.xml - name: Update CodePush key for PROD - if: (github.event.inputs.environment == 'Prod' || inputs.environment == 'Prod') + if: inputs.environment == 'Prod' run: | - if [[ "${{github.event.inputs.runnerType}}" == "macos" ]]; then + if [[ "${{inputs.runnerType}}" == "macos" ]]; then sed -i "" "s/pastekeyhere/${{ secrets.CODEPUSH_PROD_KEY }}/" android/app/src/main/res/values/strings.xml else sed -i "s/pastekeyhere/${{ secrets.CODEPUSH_PROD_KEY }}/" android/app/src/main/res/values/strings.xml fi cat android/app/src/main/res/values/strings.xml - name: Generate keystore - if: (github.event.inputs.type == 'release' || inputs.type == 'release') + if: inputs.type == 'release' run: echo "${{ secrets.KEY_STORE }}" > keystore.asc && gpg -d --passphrase "${{ secrets.PASSPHARASE }}" --batch keystore.asc > android/app/my-upload-key.keystore - name: Set Node.js 16.x uses: actions/setup-node@v3 @@ -90,26 +88,26 @@ jobs: - name: Install dependency run: yarn - name: Override App Version Code - if: github.event_name == 'workflow_dispatch' && github.event.inputs.version_code != '' + if: github.event_name == 'workflow_dispatch' && inputs.version_code != '' run: | - if [[ "${{github.event.inputs.runnerType}}" == "macos" ]]; then - sed -i "" 's/def VERSION_CODE = [0-9].*/def VERSION_CODE = '${{ github.event.inputs.version_code }}'/g' android/app/build.gradle + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + sed -i "" 's/def VERSION_CODE = [0-9].*/def VERSION_CODE = '${{ inputs.version_code }}'/g' android/app/build.gradle else - sed -i 's/def VERSION_CODE = [0-9].*/def VERSION_CODE = '${{ github.event.inputs.version_code }}'/g' android/app/build.gradle + sed -i 's/def VERSION_CODE = [0-9].*/def VERSION_CODE = '${{ inputs.version_code }}'/g' android/app/build.gradle fi - name: Override App Version Name - if: github.event_name == 'workflow_dispatch' && github.event.inputs.version_name != '' + if: github.event_name == 'workflow_dispatch' && inputs.version_name != '' run: | - if [[ "${{github.event.inputs.runnerType}}" == "macos" ]]; then - sed -i "" 's/def VERSION_NAME = "[0-9].*"/def VERSION_NAME = "'${{ github.event.inputs.version_name }}'"/g' android/app/build.gradle + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + sed -i "" 's/def VERSION_NAME = "[0-9].*"/def VERSION_NAME = "'${{ inputs.version_name }}'"/g' android/app/build.gradle else - sed -i 's/def VERSION_NAME = "[0-9].*"/def VERSION_NAME = "'${{ github.event.inputs.version_name }}'"/g' android/app/build.gradle + sed -i 's/def VERSION_NAME = "[0-9].*"/def VERSION_NAME = "'${{ inputs.version_name }}'"/g' android/app/build.gradle fi - name: Log Build Metadata run: | echo "Commit SHA: ${{ github.sha }}" - echo "Build Environment: ${{ github.event.inputs.environment || inputs.environment }}" - echo "Build Type: ${{ github.event.inputs.type || inputs.type }}" + echo "Build Environment: ${{ inputs.environment }}" + echo "Build Type: ${{ inputs.type }}" echo "App Version Code: $(awk '/VERSION_CODE/ {print $4}' app/build.gradle)" echo "App Version Name: $(awk '/VERSION_NAME/ {print $4}' app/build.gradle | tr -d '"')" - name: Set up JDK 18 @@ -124,158 +122,158 @@ jobs: - name: Create local.properties run: cd android && touch local.properties && echo "sdk.dir = /home/USERNAME/Android/Sdk" > local.properties - name: Assemble with Stacktrace - Field QA release - if: ((github.event.inputs.environment == 'QA' || inputs.environment == 'QA') && (github.event.flavor.type == 'fieldAgents' || inputs.flavor == 'fieldAgents')) + if: (inputs.environment == 'QA' && inputs.flavor == 'fieldAgents') run: yarn move:qa && cd android && ./gradlew assemblefieldAgentsQARelease - name: Assemble with Stacktrace - Field PROD release - if: ((github.event.inputs.environment == 'Prod' || inputs.environment == 'Prod') && (github.event.flavor.type == 'fieldAgents' || inputs.flavor == 'fieldAgents')) + if: (inputs.environment == 'Prod' && inputs.flavor == 'fieldAgents') run: yarn move:prod && cd android && ./gradlew assemblefieldAgentsProdRelease - name: Assemble with Stacktrace - Calling QA release - if: ((github.event.inputs.environment == 'QA' || inputs.environment == 'QA') && (github.event.flavor.type == 'callingAgents' || inputs.flavor == 'callingAgents')) + if: (inputs.environment == 'QA' && inputs.flavor == 'callingAgents') run: yarn move:qa && cd android && ./gradlew assemblecallingAgentsQARelease - name: Assemble with Stacktrace - Calling PROD release - if: ((github.event.inputs.environment == 'Prod' || inputs.environment == 'Prod') && (github.event.flavor.type == 'callingAgents' || inputs.flavor == 'callingAgents')) - run: yarn move:prod && cd android && ./gradlew assemblecallingAgentsProdRelease + if: (inputs.environment == 'Prod' && inputs.flavor == 'callingAgents') + run: yarn move:prod && cd android && ./gradlew assemblefieldAgentsProdRelease - name: Give server ack - if: ((github.event.inputs.releaseType != 'TEST_BUILD' || inputs.releaseType != 'TEST_BUILD')) + if: (inputs.releaseType != 'TEST_BUILD') run: | - ls - ls -asl - pwd - baseUrl=${{secrets.LONGHORN_QA_BASE_URL}} - if [ "${{ github.event.inputs.environment }}" == "Prod" ] || [ "${{ inputs.environment }}" == "Prod" ]; then - echo "Prod" - baseUrl=${{secrets.LONGHORN_PROD_BASE_URL}} - fi - echo "$baseUrl" - getPreSignedURL="$baseUrl/app/upload-url?appType=${{github.event.inputs.flavor || inputs.flavor}}&buildNumber=${{github.event.inputs.version_code || inputs.version_code}}&appVersion=${{github.event.inputs.version_name || inputs.version_name}}&releaseType=${{github.event.inputs.releaseType || inputs.releaseType}}" - response=$(curl --location $getPreSignedURL \ + ls + ls -asl + pwd + baseUrl=${{secrets.LONGHORN_QA_BASE_URL}} + if [ "${{ inputs.environment }}" == "Prod" ]; then + echo "Prod" + baseUrl=${{secrets.LONGHORN_PROD_BASE_URL}} + fi + echo "$baseUrl" + getPreSignedURL="$baseUrl/app/upload-url?appType=${{inputs.flavor}}&buildNumber=${{inputs.version_code}}&appVersion=${{inputs.version_name}}&releaseType=${{inputs.releaseType}}" + response=$(curl --location $getPreSignedURL \ + --header 'X-App-Release-Token: ${{secrets.LONGHORN_HEADER}}' + ) + + echo "$response" + + upload_url=$(echo "$response" | awk -F'"' '/uploadPreSignedUrl/{print $4}') + id=$(echo "$response" | awk -F'"referenceId":' '{print $2}' | awk -F',' '{print $1}' | tr -d '[:space:]' | tr -d '"}') + + + echo "$id" + + ls + + apk_path="./android/app/build/outputs/apk/${{ inputs.flavor }}${{inputs.environment}}/${{inputs.type}}/app-${{ inputs.flavor }}${{inputs.environment}}-release.apk" + + echo "$apk_path" + + # Check if APK exists, exit if not + if [ ! -f "$apk_path" ]; then + echo "Error: APK file not found at $apk_path" + exit 1 + fi + + chmod +r "$apk_path" + + curl --location --request PUT "$upload_url" \ + --data-binary "@$apk_path" + + + echo "upload compleate" + + echo "ack url" + + ack_url=("$baseUrl/app/upload-ack?referenceId=${id}&releaseType=${{inputs.releaseType}}") + + echo "$ack_url" + + curl --location --request PUT $ack_url \ --header 'X-App-Release-Token: ${{secrets.LONGHORN_HEADER}}' - ) - - echo "$response" - - upload_url=$(echo "$response" | awk -F'"' '/uploadPreSignedUrl/{print $4}') - id=$(echo "$response" | awk -F'"referenceId":' '{print $2}' | awk -F',' '{print $1}' | tr -d '[:space:]' | tr -d '"}') - - - echo "$id" - - ls - - apk_path="./android/app/build/outputs/apk/${{ github.event.inputs.flavor || inputs.flavor }}${{github.event.inputs.environment || inputs.environment}}/${{github.event.inputs.type || inputs.type}}/app-${{ github.event.inputs.flavor || inputs.flavor }}${{github.event.inputs.environment || inputs.environment}}-release.apk" - - echo "$apk_path" - - # Check if APK exists, exit if not - if [ ! -f "$apk_path" ]; then - echo "Error: APK file not found at $apk_path" - exit 1 - fi - - chmod +r "$apk_path" - - curl --location --request PUT "$upload_url" \ - --data-binary "@$apk_path" - - - echo "upload compleate" - - echo "ack url" - - ack_url=("$baseUrl/app/upload-ack?referenceId=${id}&releaseType=${{github.event.inputs.releaseType || inputs.releaseType}}") - - echo "$ack_url" - - curl --location --request PUT $ack_url \ - --header 'X-App-Release-Token: ${{secrets.LONGHORN_HEADER}}' - name: Upload APK as Artifact uses: actions/upload-artifact@v4 with: - name: app-${{ github.event.inputs.type || inputs.type }}-v${{ github.event.inputs.version_code || inputs.version_code }}-name-${{github.event.inputs.version_name || inputs.version_name}} - path: android/app/build/outputs/apk/${{ github.event.inputs.flavor || inputs.flavor }}${{github.event.inputs.environment || inputs.environment}}/${{github.event.inputs.type || inputs.type}} + name: app-${{ inputs.type }}-v${{ inputs.version_code }}-name-${{inputs.version_name}} + path: android/app/build/outputs/apk/${{ inputs.flavor }}${{inputs.environment}}/${{inputs.type}} retention-days: 30 generate_source_map: - needs: generate - runs-on: ${{ github.event.inputs.runnerType }} + needs: generate_build + runs-on: ${{ inputs.runnerType }} outputs: package_version: ${{ needs.generate.outputs.package_version }} build_number: ${{ needs.generate.outputs.build_number }} - if: success() && (github.event.inputs.environment == 'Prod') && (github.event.inputs.releaseType == 'HARD_RELEASE' || inputs.releaseType == 'HARD_RELEASE') # Only create source map for Prod releases and not for test builds + if: success() && inputs.environment == 'Prod' && inputs.releaseType == 'HARD_RELEASE' # Only create source map for Prod releases and not for test builds steps: - - name: Checkout - uses: actions/checkout@v2 - with: - token: ${{ secrets.MY_REPO_PAT }} - submodules: recursive + - name: Checkout + uses: actions/checkout@v2 + with: + token: ${{ secrets.MY_REPO_PAT }} + submodules: recursive - - name: Set Node.js 16.x - uses: actions/setup-node@v3 - with: - node-version: 16.x - - name: Install yarn - run: npm install --global yarn - - name: Install dependency - run: yarn + - name: Set Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16.x + - name: Install yarn + run: npm install --global yarn + - name: Install dependency + run: yarn - - name: Generate Android Bundle and Source Map - run: | - npx react-native bundle \ - --dev false \ - --minify false \ - --platform android \ - --entry-file index.js \ - --reset-cache \ - --bundle-output index.android.bundle \ - --sourcemap-output index.android.bundle.map + - name: Generate Android Bundle and Source Map + run: | + npx react-native bundle \ + --dev false \ + --minify false \ + --platform android \ + --entry-file index.js \ + --reset-cache \ + --bundle-output index.android.bundle \ + --sourcemap-output index.android.bundle.map - - name: Compile Hermes Bytecode and Generate Source Maps - run: | - if [[ "${{github.event.inputs.runnerType}}" == "macos" ]]; then - HERMESC_PATH="node_modules/react-native/sdks/hermesc/osx-bin/hermesc" - else - HERMESC_PATH="node_modules/react-native/sdks/hermesc/linux64-bin/hermesc" - fi - $HERMESC_PATH \ - -O -emit-binary \ - -output-source-map \ - -out=index.android.bundle.hbc \ - index.android.bundle + - name: Compile Hermes Bytecode and Generate Source Maps + run: | + if [[ "${{inputs.runnerType}}" == "macos" ]]; then + HERMESC_PATH="node_modules/react-native/sdks/hermesc/osx-bin/hermesc" + else + HERMESC_PATH="node_modules/react-native/sdks/hermesc/linux64-bin/hermesc" + fi + $HERMESC_PATH \ + -O -emit-binary \ + -output-source-map \ + -out=index.android.bundle.hbc \ + index.android.bundle - # Remove the original bundle to prevent duplication - rm -f index.android.bundle + # Remove the original bundle to prevent duplication + rm -f index.android.bundle - # Rename the Hermes bundle and source map - mv index.android.bundle.hbc index.android.bundle - mv index.android.bundle.map index.android.bundle.packager.map + # Rename the Hermes bundle and source map + mv index.android.bundle.hbc index.android.bundle + mv index.android.bundle.map index.android.bundle.packager.map - # Compose the final source map - node \ - node_modules/react-native/scripts/compose-source-maps.js \ - index.android.bundle.packager.map \ - index.android.bundle.hbc.map \ - -o index.android.bundle.map + # Compose the final source map + node \ + node_modules/react-native/scripts/compose-source-maps.js \ + index.android.bundle.packager.map \ + index.android.bundle.hbc.map \ + -o index.android.bundle.map - # Clean up the temporary files - rm -f index.android.bundle.packager.map + # Clean up the temporary files + rm -f index.android.bundle.packager.map - - name: Upload Source Map - uses: actions/upload-artifact@v4 - with: - name: source-map-${{github.event.inputs.target_versions}} - path: index.android.bundle.map + - name: Upload Source Map + uses: actions/upload-artifact@v4 + with: + name: source-map-${{inputs.version_name}} + path: index.android.bundle.map upload_sourcemap_cybertron: needs: generate_source_map - runs-on: ${{ github.event.inputs.runnerType }} - if: success() && (github.event.inputs.environment == 'Prod') + runs-on: ${{ inputs.runnerType }} + if: success() && inputs.environment == 'Prod' steps: - name: Download Source Map uses: actions/download-artifact@v4 with: - name: source-map-${{github.event.inputs.target_versions}} - path: ./artifacts # Specify the folder to store the downloaded artifact - + name: source-map-${{inputs.version_name}} + path: ./artifacts # Specify the folder to store the downloaded artifact + - name: 'create release' run: | cd artifacts @@ -288,21 +286,20 @@ jobs: "projectReferenceId": "${{ secrets.CYBERTRON_PROJECT_ID }}" }') echo $response - + - name: 'create presigned url' - run: | + run: | presigned_url_source_map='${{secrets.CYBERTRON_BASE_URL}}/api/v1/get-sourcemap-upload-url?project_id=${{secrets.CYBERTRON_PROJECT_ID}}&release_id=${{ needs.generate_source_map.outputs.package_version }}&file_name=index.android.bundle.map' response=$(curl --location $presigned_url_source_map) echo "$response" upload_url=$(echo "$response" | jq -r .url) echo $upload_url curl --location --request PUT --progress-bar --header "Content-Type: application/octet-stream" $upload_url --upload-file artifacts/index.android.bundle.map - create_release_tag: needs: generate_source_map - runs-on: ${{ github.event.inputs.runnerType }} - if: success() && (github.event.inputs.environment == 'Prod') && (github.event.inputs.releaseType == 'HARD_RELEASE' || inputs.releaseType == 'HARD_RELEASE') # Only create source map for Prod releases and not for test builds + runs-on: ${{ inputs.runnerType }} + if: success() && inputs.environment == 'Prod' && inputs.releaseType == 'HARD_RELEASE' # Only create source map for Prod releases and not for test builds steps: - name: Checkout uses: actions/checkout@v2 @@ -313,7 +310,7 @@ jobs: - name: Check if tag exists id: check_tag run: | - TAG_NAME="${{github.event.inputs.version_name || inputs.version_name}}" + TAG_NAME="${{inputs.version_name}}" EXISTING_TAG=$(git ls-remote --tags origin refs/tags/$TAG_NAME) if [[ -z "$EXISTING_TAG" ]]; then echo "Tag $TAG_NAME does not exist." @@ -326,7 +323,7 @@ jobs: - name: Create and push tag if: env.tag_exists == 'false' run: | - TAG_NAME="${{github.event.inputs.version_name || inputs.version_name}}" + TAG_NAME="${{inputs.version_name}}" # git config --local user.email "${{ github.actor }}@github.com" git config --local user.name "${{ github.actor }}" git tag $TAG_NAME @@ -335,10 +332,10 @@ jobs: GITHUB_TOKEN: ${{ secrets.MY_REPO_PAT }} - name: Create release tag run: | - TAG_NAME="${{github.event.inputs.version_name || inputs.version_name}}" + TAG_NAME="${{inputs.version_name}}" BUILD_NUMBER="${{ needs.generate.outputs.build_number }}" RELEASE_NAME="$TAG_NAME (build $BUILD_NUMBER) code push" - DESCRIPTION="${{ github.event.inputs.description }}" + DESCRIPTION="${{ inputs.description }}" REPO="navi-medici/address-verification-app" BRANCH_NAME="${GITHUB_REF#refs/heads/}" @@ -355,4 +352,4 @@ jobs: \"generate_release_notes\": true }" \ "https://api.github.com/repos/$REPO/releases" - shell: bash \ No newline at end of file + shell: bash diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index f183ee12..d3547903 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -6,6 +6,8 @@ on: branches: - master - main + - develop + - portal # Schedule this job to run at a certain time, using cron syntax # Note that * is a special character in YAML so you have to quote this string @@ -20,11 +22,13 @@ jobs: github-event-number: ${{github.event.number}} github-event-name: ${{github.event_name}} github-repository: ${{github.repository}} + github-pr_owner_name: ${{github.event.pull_request.user.login}} secrets: READ_SEMGREP_RULES_TOKEN: ${{secrets.READ_SEMGREP_RULES_TOKEN}} + EMAIL_FETCH_TOKEN: ${{secrets.EMAIL_FETCH_TOKEN}} run-if-failed: - runs-on: [ self-hosted ] + runs-on: [ self-hosted, Linux ] needs: [central-semgrep] if: always() && (needs.semgrep.result == 'failure') steps: diff --git a/App.tsx b/App.tsx index d3d93e59..a5e86018 100644 --- a/App.tsx +++ b/App.tsx @@ -39,7 +39,6 @@ import { getPermissionsToRequest } from '@utils/PermissionUtils'; import ScreenshotBlocker from './src/components/utlis/ScreenshotBlocker'; import { setItem } from './src/components/utlis/storageHelper'; import { ENV } from './src/constants/config'; -import usePolling from './src/hooks/usePolling'; import AuthRouter from './src/screens/auth/AuthRouter'; import { type TDocumentObj } from '@screens/caseDetails/interface'; import Permissions from './src/screens/permissions/Permissions'; @@ -49,6 +48,7 @@ import fetchUpdatedRemoteConfig from './src/services/firebaseFetchAndUpdate.serv import { StorageKeys } from './src/types/storageKeys'; import CodePushLoadingModal, { CodePushLoadingModalRef } from './CodePushModal'; import { initSentry } from '@components/utlis/sentry'; +import { AppStates } from '@interfaces/appStates'; if (!__DEV__) { initSentry(); @@ -159,8 +159,6 @@ function App() { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.AV_APP_FOREGROUND, { now }, true); } - usePolling(askForPermissions, PERMISSION_CHECK_POLL_INTERVAL); - useEffect(() => { ScreenshotBlocker.unblockScreenshots(); getBuildFlavour().then((flavour) => { @@ -174,6 +172,9 @@ function App() { fetchUpdatedRemoteConfig(); askForPermissions(); const appStateChange = AppState.addEventListener('change', async (change) => { + if(change === AppStates.ACTIVE) { + askForPermissions(); + } handleAppStateChange(change, onSyncStatusChange, onDownloadProgress); hydrateGlobalImageMap(); }); diff --git a/RN-UI-LIB b/RN-UI-LIB index 348345f1..7aa1a0d2 160000 --- a/RN-UI-LIB +++ b/RN-UI-LIB @@ -1 +1 @@ -Subproject commit 348345f1bb3af8b78a8246231dbc440ddc02de32 +Subproject commit 7aa1a0d2aec25744b0486fe30f9cd8ea571b9453 diff --git a/android/app/build.gradle b/android/app/build.gradle index 05d1c25f..11c563c7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -113,8 +113,8 @@ def jscFlavor = 'org.webkit:android-jsc:+' def enableHermes = project.ext.react.get("enableHermes", false); -def VERSION_CODE = 303 -def VERSION_NAME = "100.1.2" +def VERSION_CODE = 232 +def VERSION_NAME = "2.16.9" android { namespace "com.avapp" @@ -217,7 +217,7 @@ dependencies { implementation 'com.navi.android:alfred:1.15.0-20240905.065147-1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' - implementation(platform("com.google.firebase:firebase-bom:32.2.3")) + implementation(platform("com.google.firebase:firebase-bom:32.7.0")) implementation("com.google.firebase:firebase-config-ktx") implementation("com.google.firebase:firebase-analytics-ktx") implementation 'androidx.core:core-splashscreen:1.0.1' diff --git a/android/app/src/main/java/com/avapp/MainApplication.java b/android/app/src/main/java/com/avapp/MainApplication.java index a5c3cf1e..a9cd04c8 100644 --- a/android/app/src/main/java/com/avapp/MainApplication.java +++ b/android/app/src/main/java/com/avapp/MainApplication.java @@ -15,6 +15,7 @@ import com.avapp.photoModule.PhotoModulePackage; import com.avapp.phoneStateBroadcastReceiver.PhoneStateModulePackage; import com.avapp.utils.FirebaseRemoteConfigHelper; import com.avapp.wifiDetailsModule.WifiDetailsModulePackage; +import com.avapp.restartApp.RestartPackage; import com.facebook.react.PackageList; import com.facebook.react.ReactApplication; import com.facebook.react.ReactInstanceManager; @@ -64,6 +65,7 @@ public class MainApplication extends Application implements ReactApplication { packages.add(new PhotoModulePackage()); packages.add(new WifiDetailsModulePackage()); packages.add(new ApkInstallerPackage()); + packages.add(new RestartPackage()); return packages; } @@ -120,17 +122,6 @@ public class MainApplication extends Application implements ReactApplication { PulseManager.INSTANCE.init(pulseConfig, this, null, false); setupAlfredANRWatchDog(alfredConfig); setupAlfredCrashReporting(alfredConfig); - - // https://github.com/rt2zz/redux-persist/issues/284#issuecomment-1011214066 - try { - Field field = CursorWindow.class.getDeclaredField("sCursorWindowSize"); - field.setAccessible(true); - field.set(null, 10 * 1024 * 1024); // 10MB - } catch (Exception e) { - if (BuildConfig.DEBUG) { - e.printStackTrace(); - } - } } /** diff --git a/android/app/src/main/java/com/avapp/deviceDataSync/FileZipper.java b/android/app/src/main/java/com/avapp/deviceDataSync/FileZipper.java index e4c59373..6e59d4a4 100644 --- a/android/app/src/main/java/com/avapp/deviceDataSync/FileZipper.java +++ b/android/app/src/main/java/com/avapp/deviceDataSync/FileZipper.java @@ -20,6 +20,7 @@ import java.util.zip.ZipOutputStream; public class FileZipper { private static String TAG = "TestTag"; + public static void compressAndZipFiles(Context context, FileDetails[] fileDetailsArray, Promise promise) { byte[] buffer = new byte[1024]; Log.d(TAG, "compressAndZipFiles: "); @@ -50,15 +51,15 @@ public class FileZipper { ZipOutputStream zos = new ZipOutputStream(fos); zos.setLevel(Deflater.BEST_COMPRESSION); - if(fileDetailsArray != null) { + if (fileDetailsArray != null) { for (FileDetails fileDetails : fileDetailsArray) { - if(fileDetails != null) { + if (fileDetails != null) { String filePath = fileDetails.getPath(); String fileName = fileDetails.getName(); - if(filePath != null && fileName != null) { + if (filePath != null && fileName != null) { File file = new File(filePath); - if(file != null) { + if (file != null) { FileInputStream fis = new FileInputStream(file); zos.putNextEntry(new ZipEntry(fileName)); @@ -79,7 +80,7 @@ public class FileZipper { File zipFileForData = new File(cacheDir, zipFileName); Date date = new Date(); - Double createdAt = (double)date.getTime(); + Double createdAt = (double) date.getTime(); WritableMap imageMetadata = Arguments.createMap(); imageMetadata.putString("path", zipFileForData.getPath()); imageMetadata.putString("name", zipFileForData.getName()); @@ -145,7 +146,7 @@ public class FileZipper { File zipFileForData = new File(cacheDir, zipFileName); Date date = new Date(); - Double createdAt = (double)date.getTime(); + Double createdAt = (double) date.getTime(); WritableMap audioMetaData = Arguments.createMap(); audioMetaData.putString("path", zipFileForData.getPath()); audioMetaData.putString("name", zipFileForData.getName()); @@ -194,18 +195,20 @@ public class FileZipper { zos.setLevel(Deflater.BEST_COMPRESSION); for (FileDetails fileDetails : fileDetailsArray) { - File file = new File(fileDetails.getPath()); - FileInputStream fis =new FileInputStream(file); + if (fileDetails != null) { + File file = new File(fileDetails.getPath()); + FileInputStream fis = new FileInputStream(file); - zos.putNextEntry(new ZipEntry(fileDetails.getName())); + zos.putNextEntry(new ZipEntry(fileDetails.getName())); - int length; - while ((length = fis.read(buffer)) > 0) { - zos.write(buffer, 0, length); + int length; + while ((length = fis.read(buffer)) > 0) { + zos.write(buffer, 0, length); + } + + zos.closeEntry(); + fis.close(); } - - zos.closeEntry(); - fis.close(); } zos.close(); @@ -229,7 +232,6 @@ public class FileZipper { } } - } class FileDetails { diff --git a/android/app/src/main/java/com/avapp/restartApp/ReactInstanceHolder.java b/android/app/src/main/java/com/avapp/restartApp/ReactInstanceHolder.java new file mode 100644 index 00000000..a9bc1afb --- /dev/null +++ b/android/app/src/main/java/com/avapp/restartApp/ReactInstanceHolder.java @@ -0,0 +1,7 @@ +package com.avapp.restartApp; + +import com.facebook.react.ReactInstanceManager; + +public interface ReactInstanceHolder { + ReactInstanceManager getReactInstanceManager(); +} \ No newline at end of file diff --git a/android/app/src/main/java/com/avapp/restartApp/RestartModule.java b/android/app/src/main/java/com/avapp/restartApp/RestartModule.java new file mode 100644 index 00000000..ebb389bb --- /dev/null +++ b/android/app/src/main/java/com/avapp/restartApp/RestartModule.java @@ -0,0 +1,116 @@ +package com.avapp.restartApp; + +import android.app.Activity; +import android.os.Handler; +import android.os.Looper; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.Promise; + +public class RestartModule extends ReactContextBaseJavaModule { + + private static final String REACT_APPLICATION_CLASS_NAME = "com.facebook.react.ReactApplication"; + private static final String REACT_NATIVE_HOST_CLASS_NAME = "com.facebook.react.ReactNativeHost"; + + // Listener to track lifecycle events + private LifecycleEventListener mLifecycleEventListener = null; + + public RestartModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + // Legacy method to reload the app by recreating the current activity + private void loadBundleLegacy() { + final Activity currentActivity = getCurrentActivity(); + if (currentActivity == null) { + return; // Exit if no current activity is available + } + + // Reload the activity on the UI thread + currentActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + currentActivity.recreate(); + } + }); + } + + // Modern method to reload the app using ReactInstanceManager + private void loadBundle() { + clearLifecycleEventListener(); // Remove any existing lifecycle listeners + try { + final ReactInstanceManager instanceManager = resolveInstanceManager(); // Get ReactInstanceManager + if (instanceManager == null) { + return; // Exit if not resolved + } + + // Reload React Native context in the background + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + try { + instanceManager.recreateReactContextInBackground(); + } catch (Throwable t) { + loadBundleLegacy(); // Fallback to legacy method on error + } + } + }); + + } catch (Throwable t) { + loadBundleLegacy(); // Fallback to legacy method on any exception + } + } + + // Static reference to hold ReactInstanceHolder + private static ReactInstanceHolder mReactInstanceHolder; + + // Static method to get ReactInstanceManager if available + static ReactInstanceManager getReactInstanceManager() { + if (mReactInstanceHolder == null) { + return null; + } + return mReactInstanceHolder.getReactInstanceManager(); + } + + // Resolves the ReactInstanceManager from the current React Native setup + private ReactInstanceManager resolveInstanceManager() throws NoSuchFieldException, IllegalAccessException { + ReactInstanceManager instanceManager = getReactInstanceManager(); + if (instanceManager != null) { + return instanceManager; + } + + // Get ReactInstanceManager from the current activity + final Activity currentActivity = getCurrentActivity(); + if (currentActivity == null) { + return null; + } + + // Resolve from the ReactApplication + ReactApplication reactApplication = (ReactApplication) currentActivity.getApplication(); + instanceManager = reactApplication.getReactNativeHost().getReactInstanceManager(); + + return instanceManager; + } + + // Clears any lifecycle event listeners + private void clearLifecycleEventListener() { + if (mLifecycleEventListener != null) { + getReactApplicationContext().removeLifecycleEventListener(mLifecycleEventListener); + mLifecycleEventListener = null; + } + } + + @ReactMethod + public void restart() { + loadBundle(); + } + + @Override + public String getName() { + return "RNRestart"; + } +} diff --git a/android/app/src/main/java/com/avapp/restartApp/RestartPackage.java b/android/app/src/main/java/com/avapp/restartApp/RestartPackage.java new file mode 100644 index 00000000..e3e3019d --- /dev/null +++ b/android/app/src/main/java/com/avapp/restartApp/RestartPackage.java @@ -0,0 +1,26 @@ +package com.avapp.restartApp; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RestartPackage implements ReactPackage { + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createNativeModules( + ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new RestartModule(reactContext)); + return modules; + } +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index ed80567b..c0c1d90a 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -39,6 +39,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 # to write custom TurboModules/Fabric components OR use libraries that # are providing them. newArchEnabled=false +hermesEnabled=true MYAPP_UPLOAD_STORE_FILE=my-upload-key.keystore MYAPP_UPLOAD_KEY_ALIAS=my-key-alias diff --git a/babel.config.js b/babel.config.js index dda01a14..4dc5cd0a 100644 --- a/babel.config.js +++ b/babel.config.js @@ -30,6 +30,7 @@ module.exports = { }, ], 'jest-hoist', + 'react-native-reanimated/plugin', ], env: { production: { diff --git a/package.json b/package.json index 5e25d078..fb3a53be 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "AV_APP", - "version": "100.1.2", - "buildNumber": "303", + "version": "2.16.9", + "buildNumber": "232", "private": true, "scripts": { "android:dev": "yarn move:dev && react-native run-android", @@ -40,7 +40,6 @@ "@bam.tech/react-native-image-resizer": "3.0.5", "@notifee/react-native": "7.8.2", "@nozbe/with-observables": "1.4.1", - "@react-native/metro-config": "0.72.11", "@react-native-async-storage/async-storage": "1.17.11", "@react-native-clipboard/clipboard": "^1.11.2", "@react-native-community/netinfo": "9.3.7", @@ -54,6 +53,7 @@ "@react-native-firebase/perf": "16.7.0", "@react-native-firebase/remote-config": "16.7.0", "@react-native-google-signin/google-signin": "13.1.0", + "@react-native/metro-config": "0.72.11", "@react-navigation/bottom-tabs": "6.6.1", "@react-navigation/native": "6.1.18", "@react-navigation/native-stack": "6.11.0", @@ -70,7 +70,7 @@ "dayjs": "1.11.9", "fuzzysort": "2.0.4", "lodash.chunk": "^4.2.0", - "lottie-react-native": "6.4.0", + "lottie-react-native": "5.1.6", "patch-package": "8.0.0", "postinstall-postinstall": "2.1.0", "react": "18.2.0", @@ -92,6 +92,7 @@ "react-native-pdf-renderer": "1.1.1", "react-native-permissions": "3.6.1", "react-native-qrcode-svg": "^6.2.0", + "react-native-quick-base64": "2.1.2", "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.18.2", "react-native-svg": "^13.9.0", @@ -101,11 +102,17 @@ "react-native-webview": "13.12.1", "react-redux": "8.0.5", "redux": "4.2.0", - "redux-persist": "6.0.0" + "redux-persist": "6.0.0", + "react-native-reanimated": "3.6.3" }, "devDependencies": { "@babel/core": "7.25.2", "@babel/plugin-proposal-decorators": "7.20.7", + "@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6", + "@babel/plugin-proposal-optional-chaining": "7.21.0", + "@babel/plugin-transform-arrow-functions": "7.24.7", + "@babel/plugin-transform-shorthand-properties": "7.24.7", + "@babel/plugin-transform-template-literals": "7.24.7", "@babel/runtime": "7.12.5", "@tsconfig/react-native": "2.0.2", "@types/d3-scale": "^4.0.5", diff --git a/patches/react-native+0.72.6.patch b/patches/react-native+0.72.6.patch index 1f879672..59bb762c 100644 --- a/patches/react-native+0.72.6.patch +++ b/patches/react-native+0.72.6.patch @@ -1,3 +1,16 @@ +diff --git a/node_modules/react-native/ReactAndroid/build.gradle b/node_modules/react-native/ReactAndroid/build.gradle +index f44b6e4..c2b76ed 100644 +--- a/node_modules/react-native/ReactAndroid/build.gradle ++++ b/node_modules/react-native/ReactAndroid/build.gradle +@@ -243,7 +243,7 @@ task createNativeDepsDirectories { + } + + task downloadBoost(dependsOn: createNativeDepsDirectories, type: Download) { +- src("https://boostorg.jfrog.io/artifactory/main/release/${BOOST_VERSION.replace("_", ".")}/source/boost_${BOOST_VERSION}.tar.gz") ++ src("https://archives.boost.io/release/${BOOST_VERSION.replace("_", ".")}/source/boost_${BOOST_VERSION}.tar.gz") + onlyIfModified(true) + overwrite(false) + retries(5) diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java index 0b6294b..d0a01d8 100644 --- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java diff --git a/src/action/TrainingMaterialAction.ts b/src/action/TrainingMaterialAction.ts new file mode 100644 index 00000000..031132be --- /dev/null +++ b/src/action/TrainingMaterialAction.ts @@ -0,0 +1,34 @@ +import axiosInstance, { ApiKeys, getApiUrl } from '@components/utlis/apiHelper'; +import { + setTrainingMaterialData, + setTrainingMaterialLoading, +} from '@reducers/trainingMaterialSlice'; +import { AppDispatch } from '@store'; + +export const getTrainingMaterialList = () => (dispatch: AppDispatch) => { + dispatch(setTrainingMaterialLoading(true)); + const url = getApiUrl(ApiKeys.GET_TRAINING_MATERIAL_LIST); + axiosInstance + .get(url) + .then((res) => { + if (res.data) { + dispatch(setTrainingMaterialLoading(false)); + if (res?.data) { + dispatch(setTrainingMaterialData(res.data)); + } + } + }) + .finally(() => { + dispatch(setTrainingMaterialLoading(false)); + }); +}; + +export const getTrainingMaterialDetails = async (docRefId: string) => { + try { + const url = getApiUrl(ApiKeys.GET_TRAINING_MATERIAL_DETAILS, { docRefId }); + const response = await axiosInstance.get(url); + return response.data; + } catch (error) { + throw error; + } +}; diff --git a/src/action/addressGeolocationAction.ts b/src/action/addressGeolocationAction.ts index b8f3b028..d0ad59bc 100644 --- a/src/action/addressGeolocationAction.ts +++ b/src/action/addressGeolocationAction.ts @@ -13,6 +13,8 @@ import { setUngroupedAddresses, setUngroupedAddressesLoading, } from '@reducers/ungroupedAddressesSlice'; +import { setSkipTracingAddresses, setSkipTracingAddressesLoading } from '@reducers/skipTracingAddressesSlice'; +import { VisitType } from '@screens/caseDetails/interface'; export const getAddressesGeolocation = (payload: IAddressGeolocationPayload) => (dispatch: AppDispatch) => { @@ -41,9 +43,21 @@ export const getAddressesGeolocation = }; export const addAddress = - (payload: IAddAddressPayload, commonPayload: IAddressGeolocationPayload) => + ( + payload: IAddAddressPayload, + commonPayload: IAddressGeolocationPayload, + caseReferenceId: string, + caseBusinessVertical: string + ) => (dispatch: AppDispatch) => { - const url = getApiUrl(ApiKeys.NEW_ADDRESS); + const url = getApiUrl( + ApiKeys.NEW_ADDRESS, + {}, + { + caseReferenceId, + caseBusinessVertical, + } + ); dispatch(setLoading(true)); return axiosInstance .post(url, { ...payload, ...commonPayload }) @@ -63,13 +77,22 @@ export const addAddress = }; export const getUngroupedAddress = - (loanAccountNumber: string, includeFeedbacks = false) => + ( + loanAccountNumber: string, + caseReferenceId: string, + caseBusinessVertical: string, + includeFeedbacks = false + ) => (dispatch: AppDispatch) => { dispatch(setUngroupedAddressesLoading({ loanAccountNumber, isLoading: true })); const url = getApiUrl( ApiKeys.GET_UNGROUPED_ADDRESSES, - { loanAccountNumber }, - { includeFeedbacks } + {}, + { + includeFeedbacks, + caseReferenceId: caseReferenceId, + caseBusinessVertical: caseBusinessVertical, + } ); axiosInstance .get(url) @@ -117,3 +140,45 @@ export const getPinCodeDetails = async (pinCode: string) => { }, }); }; + +export const getSkipTracingAddress = + ( + loanAccountNumber: string, + caseReferenceId: string, + caseBusinessVertical: string, + includeFeedbacks = false + ) => + (dispatch: AppDispatch) => { + dispatch(setSkipTracingAddressesLoading({ loanAccountNumber, isLoading: true })); + const url = getApiUrl( + ApiKeys.GET_UNGROUPED_ADDRESSES, + {}, + { + includeFeedbacks, + caseReferenceId: caseReferenceId, + caseBusinessVertical: caseBusinessVertical, + requestSourceTypeFilter: VisitType.SKIP_TRACING + } + ); + axiosInstance + .get(url) + .then((response) => { + if (response?.status === API_STATUS_CODE.OK) { + const data = { + loanAccountNumber, + skipTracingAddresses: response?.data?.ungroupedAddresses, + skipTracingAddressFeedbacks: response?.data?.addressFeedbacks || [], + }; + if (!includeFeedbacks) { + delete response?.data?.skipTracingAddressFeedbacks; + } + dispatch(setSkipTracingAddresses(data)); + } + }) + .catch((err) => { + logError(err); + }) + .finally(() => { + dispatch(setSkipTracingAddressesLoading({ loanAccountNumber, isLoading: false })); + }); + }; \ No newline at end of file diff --git a/src/action/authActions.ts b/src/action/authActions.ts index 2a300d57..932c0c29 100644 --- a/src/action/authActions.ts +++ b/src/action/authActions.ts @@ -27,7 +27,7 @@ import { clearAllAsyncStorage } from '../components/utlis/commonFunctions'; import { logError } from '../components/utlis/errorUtils'; import auth from '@react-native-firebase/auth'; import foregroundService from '../services/foregroundServices/foreground.service'; -import { loggedOutCurrentUser } from '../hooks/useFirestoreUpdates'; +import { loggedOutCurrentUser } from '@hooks/useFirestoreUpdates'; import { GenericFunctionArgs, GenericType } from '../common/GenericTypes'; import { GoogleSignin } from '@react-native-google-signin/google-signin'; import { resetConfig } from '../reducer/configSlice'; @@ -38,6 +38,7 @@ import { resetPerformanceData } from '@reducers/agentPerformanceSlice'; import { clearStorageEngine } from '../PersistStorageEngine'; import { resetNearbyCasesData } from '@reducers/nearbyCasesSlice'; import { resetActiveCallData } from '@reducers/activeCallSlice'; +import { firestoreService } from '@services/firestoreService'; export interface GenerateOTPPayload { phoneNumber: string; @@ -198,6 +199,7 @@ export const handleGoogleLogout = async () => { const firebaseSignout = async () => { try { + await firestoreService.unsubscribeAll(); await auth().signOut(); } catch (error) { logError(error as Error, 'Firebase signout error'); @@ -206,6 +208,12 @@ const firebaseSignout = async () => { export const handleLogout = () => async (dispatch: AppDispatch) => { try { + CosmosForegroundService.isRunning().then((isRunning) => { + if (isRunning) { + CosmosForegroundService.clearTasks(); + CosmosForegroundService.stopAll(); + } + }); await firebaseSignout(); await handleGoogleLogout(); await clearAllAsyncStorage(); diff --git a/src/action/callRecordingActions.tsx b/src/action/callRecordingActions.tsx index dcd7c602..bdadcce3 100644 --- a/src/action/callRecordingActions.tsx +++ b/src/action/callRecordingActions.tsx @@ -1,4 +1,4 @@ -import axiosInstance, { ApiKeys, getApiUrl } from '@components/utlis/apiHelper'; +import axiosInstance, { API_STATUS_CODE, ApiKeys, getApiUrl } from '@components/utlis/apiHelper'; import { logError } from '@components/utlis/errorUtils'; import { getCurrentScreen, navigateToScreen } from '@components/utlis/navigationUtlis'; import { @@ -29,15 +29,21 @@ const redirectionHandlerOnCallFailed = (caseId: string) => { export const makeACallToCustomer = (payload: IMakeACallToCustomerPayload, details: IMakeACallToCustomerDetails) => (dispatch: AppDispatch) => { - const url = getApiUrl(ApiKeys.CALL_CUSTOMER, { - telephoneReferenceId: payload?.referenceId, - loanAccountNumber: payload?.loanAccountNumber, - }); + const url = getApiUrl( + ApiKeys.CALL_CUSTOMER, + {}, + { + caseReferenceId: details?.caseId, + caseBusinessVertical: details?.caseBusinessVertical, + telephoneReferenceId: payload?.referenceId, + } + ); dispatch(setIsCallCreationLoading(true)); dispatch(setConnectingToCustomerBottomSheet(true)); axiosInstance - .post(url, {}, { headers: { donotHandleError: true } }) - .catch((err: Error) => { + .post(url, {}, { headers: { donotHandleError: true, autoLogoutOnUnauthorized: true } }) + .catch((err) => { + const { response } = err; if (details?.isCallHistory) { dispatch( fetchCallHistory({ @@ -48,12 +54,22 @@ export const makeACallToCustomer = } else { dispatch( fetchTelephoneNumber({ - loanAccountNumber: payload?.loanAccountNumber, caseId: details?.caseId, + caseBusinessVertical: details?.caseBusinessVertical, + loanAccountNumber: payload?.loanAccountNumber }) ); } dispatch(setConnectingToCustomerBottomSheet(false)); + logError(err); + if (response?.data?.status_code === API_STATUS_CODE.TOO_MANY_REQUESTS) { + toast({ + type: 'error', + text1: response?.data?.message, + visibilityTime: 5000 + }); + return; + } toast({ type: 'error', onPress: () => redirectionHandlerOnCallFailed(details?.caseId), @@ -63,7 +79,6 @@ export const makeACallToCustomer = }, visibilityTime: 10000, }); - logError(err); }) .finally(() => { dispatch(setIsCallCreationLoading(false)); diff --git a/src/action/caseApiActions.ts b/src/action/caseApiActions.ts index b622a906..8fb50d07 100644 --- a/src/action/caseApiActions.ts +++ b/src/action/caseApiActions.ts @@ -1,4 +1,4 @@ -import axiosInstance, { ApiKeys, getApiUrl } from '../components/utlis/apiHelper'; +import axiosInstance, { API_STATUS_CODE, ApiKeys, getApiUrl } from '../components/utlis/apiHelper'; import { type AppDispatch } from '../store/store'; import { setEmiSchedule, setEmiScheduleLoading } from '../reducer/emiScheduleSlice'; import { setFeedbackHistory, setFeedbackHistoryLoading } from '../reducer/feedbackHistorySlice'; @@ -7,10 +7,17 @@ import { setAddresses, setAddressLoading } from '../reducer/addressSlice'; import { MILLISECONDS_IN_A_MINUTE, _map } from '../../RN-UI-LIB/src/utlis/common'; import { logError } from '../components/utlis/errorUtils'; import { type IDocument, removeDocumentByQuestionKey } from '../reducer/feedbackImagesSlice'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { PAST_FEEDBACK_PAGE_SIZE } from '@screens/caseDetails/feedback/pastFeedbackCommon'; -export const getRepaymentsData = (loanAccountNumber: string) => (dispatch: AppDispatch) => { +export const getRepaymentsData = (loanAccountNumber: string, caseId: string, caseBusinessVertical: string) => (dispatch: AppDispatch) => { dispatch(setRepaymentsLoading({ loanAccountNumber, isLoading: true })); - const url = getApiUrl(ApiKeys.GET_REPAYMENTS, { loanAccountNumber }); + const url = getApiUrl(ApiKeys.GET_REPAYMENTS, {}, + { + caseReferenceId: caseId, + caseBusinessVertical: caseBusinessVertical + }); axiosInstance .get(url) .then((res) => { @@ -24,9 +31,13 @@ export const getRepaymentsData = (loanAccountNumber: string) => (dispatch: AppDi }); }; -export const getEmiScheduleData = (loanAccountNumber: string) => (dispatch: AppDispatch) => { +export const getEmiScheduleData = (loanAccountNumber: string, caseId: string, caseBusinessVertical: string) => (dispatch: AppDispatch) => { dispatch(setEmiScheduleLoading({ loanAccountNumber, isLoading: true })); - const url = getApiUrl(ApiKeys.GET_EMI_SCHEDULE, { loanAccountNumber }); + const url = getApiUrl(ApiKeys.GET_EMI_SCHEDULE, {}, + { + caseReferenceId: caseId, + caseBusinessVertical: caseBusinessVertical + }); axiosInstance .get(url) .then((res) => { @@ -41,13 +52,16 @@ export const getEmiScheduleData = (loanAccountNumber: string) => (dispatch: AppD }; export const getAddressesAndGeolocations = - (loanAccountNumber: string, includeFeedbacks: boolean = false) => + (loanAccountNumber: string, caseReferenceId: string, caseBusinessVertical: string, includeFeedbacks: boolean = false) => (dispatch: AppDispatch) => { dispatch(setAddressLoading({ loanAccountNumber, isLoading: true })); const url = getApiUrl( ApiKeys.GET_GROUPED_ADDRESSES_AND_GEOLOCATIONS, - { loanAccountNumber }, - { includeFeedbacks } + { }, + { includeFeedbacks, + caseReferenceId: caseReferenceId, + caseBusinessVertical: caseBusinessVertical, + } ); axiosInstance .get(url) @@ -79,7 +93,7 @@ export const getFeedbackHistory = (loanAccountNumber: string) => (dispatch: AppD { loan_account_number: loanAccountNumber, page_no: 0, - page_size: 5, + page_size: PAST_FEEDBACK_PAGE_SIZE, } ); axiosInstance @@ -141,6 +155,17 @@ export const uploadImages = dispatch(removeDocumentByQuestionKey({ caseKey, questionKey })); }) .catch((err) => { + if (err?.response?.status === API_STATUS_CODE.NOT_FOUND) { + dispatch(removeDocumentByQuestionKey({ caseKey, questionKey })); + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_WRONG_QUESTION_KEY, { + error: err, + questionKey, + interactionReferenceId, + }); + } + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_PERSIST_ORIGINAL_IMAGE_FAILURE, { + payload: formData, + }); logError(err as Error, 'Error uploading image to document service'); }); }); diff --git a/src/action/dataActions.ts b/src/action/dataActions.ts index ad044591..ea864184 100644 --- a/src/action/dataActions.ts +++ b/src/action/dataActions.ts @@ -94,6 +94,9 @@ export const syncCaseDetail = .then((res) => { const caseType = payload.caseType; const interactionId = res.data?.referenceId; + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FEEDBACK_SUCCESS, { + data: res?.data, + }); dispatch( updateSingleCase({ data: res.data, diff --git a/src/action/feedbackActions.ts b/src/action/feedbackActions.ts index 5d486026..e4cdf8a9 100644 --- a/src/action/feedbackActions.ts +++ b/src/action/feedbackActions.ts @@ -77,27 +77,23 @@ export const getPastFeedbacksOnAddresses = (pastFeedbackPayload: IPastFeedbacksP }); }; -export const getTopFeedbacks = (loanAccountNumber: string) => (dispatch: AppDispatch) => { - // TODO: Change API Endpoint - const url = getApiUrl(ApiKeys.PAST_FEEDBACK_ON_ADDRESSES); - dispatch(setTopFeedbacksLoading({ loanAccountNumber, isLoading: true })); +export const getTopFeedbacks = (caseId: string) => (dispatch: AppDispatch) => { + const url = getApiUrl(ApiKeys.GET_PRIORTIY_FEEDBACK); + dispatch(setTopFeedbacksLoading({ caseId, isLoading: true })); return axiosInstance .get(url, { - params: { loanAccountNumber }, + params: { caseReferenceId: caseId }, }) .then((response) => { dispatch( setTopFeedbacks({ - loanAccountNumber, - feedbacks: [ - response?.data?.data?.currentMonthFeedbackStatus, - response?.data?.data?.lastMonthFeedbackStatus, - ], + caseId, + feedbacks: response?.data || [], }) ); }) .catch((err) => { - dispatch(setTopFeedbacksLoading({ loanAccountNumber, isLoading: false })); logError(err); - }); + }) + .finally(() => dispatch(setTopFeedbacksLoading({ caseId, isLoading: false }))); }; diff --git a/src/action/fetchTelephoneNumber.ts b/src/action/fetchTelephoneNumber.ts index 39e8edc3..782d23fc 100644 --- a/src/action/fetchTelephoneNumber.ts +++ b/src/action/fetchTelephoneNumber.ts @@ -1,5 +1,5 @@ import axiosInstance, { ApiKeys, getApiUrl } from '../components/utlis/apiHelper'; -import { AppDispatch } from '../store/store'; +import store, { AppDispatch } from '../store/store'; import { logError } from '../components/utlis/errorUtils'; import { ITelephoneNumbers, @@ -9,15 +9,26 @@ import { import { isFunction } from '@components/utlis/commonFunctions'; type FetchTelephoneNumbersParams = { + caseId: string; + caseBusinessVertical: string; + loanAccountNumber: string; + setLoading?: (value: boolean) => void; +}; + +type FetchCallHistoryParams = { caseId: string; loanAccountNumber: string; setLoading?: (value: boolean) => void; }; export const fetchTelephoneNumber = - ({ caseId, loanAccountNumber, setLoading }: FetchTelephoneNumbersParams) => + ({ caseId, caseBusinessVertical, loanAccountNumber, setLoading }: FetchTelephoneNumbersParams) => (dispatch: AppDispatch) => { - const url = getApiUrl(ApiKeys.GET_TELEPHONE_NUMBERS_V2, { loanAccountNumber }); + const url = getApiUrl(ApiKeys.GET_TELEPHONE_NUMBERS_V2, { }, + { + caseReferenceId: caseId, + caseBusinessVertical: caseBusinessVertical + }); const isSetLoadingFunction = isFunction(setLoading); if (isSetLoadingFunction) setLoading(true); axiosInstance @@ -34,9 +45,17 @@ export const fetchTelephoneNumber = }; export const fetchCallHistory = - ({ caseId, loanAccountNumber, setLoading }: FetchTelephoneNumbersParams) => + ({ caseId, loanAccountNumber, setLoading }: FetchCallHistoryParams) => (dispatch: AppDispatch) => { - const url = getApiUrl(ApiKeys.GET_CALL_HISTORY, { loanAccountNumber }); + const caseDetail = store?.getState()?.allCases?.caseDetails?.[caseId] || {}; + const url = getApiUrl( + ApiKeys.GET_CALL_HISTORY, + {}, + { + caseReferenceId: caseDetail?.caseReferenceId, + caseBusinessVertical: caseDetail?.businessVertical, + } + ); const isSetLoadingFunction = isFunction(setLoading); if (isSetLoadingFunction) setLoading(true); axiosInstance diff --git a/src/action/filterActions.ts b/src/action/filterActions.ts new file mode 100644 index 00000000..cf76c004 --- /dev/null +++ b/src/action/filterActions.ts @@ -0,0 +1,69 @@ +import firestore from '@react-native-firebase/firestore'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import { TIMESTAMP_IST, TIMEZONE_ASIA } from '@rn-ui-lib/utils/dates'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { GLOBAL } from '@constants/Global'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export const CoachMarkFeatures = { + CASE_STATUS_FILTERS: 'caseStatusFilters', +}; + +export const showCoachMark = async ( + featureName: string, + agentId: string, + serverTimestamp: string, + callback: () => Promise +) => { + if (GLOBAL.IS_IMPERSONATED) { + return; + } + const coachMarkDoc = firestore().collection('coachMarks').doc(agentId); + const userSnapshot = await coachMarkDoc.get(); + let coachMarkData = userSnapshot.data()?.[featureName]; + const dayJsTime = dayjs().tz(TIMEZONE_ASIA).format(TIMESTAMP_IST); + const timestamp = serverTimestamp || dayJsTime; + + if (!coachMarkData) { + // Create user document if it doesn't exist + coachMarkData = { + lastSeenDate: timestamp, + viewsCount: 1, + }; + callback().then( + async () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FILTER_COACHMARKS_LOADED); + await coachMarkDoc.set({ [featureName]: coachMarkData }, { merge: true }); + }, + (error) => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FILTER_COACHMARKS_FAILED); + } + ); + return; + } + + const lastSeenDate = dayjs(coachMarkData.lastSeenDate); + const currentDay = dayjs(timestamp); + const oldDay = dayjs(lastSeenDate); + + const isDifferentDay = currentDay.startOf('day').diff(oldDay.startOf('day'), 'day') > 0; + + if (isDifferentDay && coachMarkData.viewsCount < 5) { + coachMarkData.viewsCount += 1; + coachMarkData.lastSeenDate = timestamp; + callback().then( + async () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FILTER_COACHMARKS_LOADED); + await coachMarkDoc.set({ [featureName]: coachMarkData }, { merge: true }); + }, + (error) => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FILTER_COACHMARKS_FAILED); + } + ); + } +}; diff --git a/src/action/firebaseFallbackActions.ts b/src/action/firebaseFallbackActions.ts deleted file mode 100644 index 49d44747..00000000 --- a/src/action/firebaseFallbackActions.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { AxiosResponse } from 'axios'; -import axiosInstance, { ApiKeys, getApiUrl } from '../components/utlis/apiHelper'; -import { logError } from '../components/utlis/errorUtils'; -import { VisitPlanStatus } from '../reducer/userSlice'; -import { FilterResponse } from '../screens/allCases/interface'; -import { CaseDetail } from '../screens/caseDetails/interface'; - -export enum SyncStatus { - SEND_CASES = 'SEND_CASES', - FETCH_CASES = 'FETCH_CASES', - SKIP = 'SKIP', -} - -interface IFilterSync { - filterComponentList: FilterResponse[]; -} - -export interface ISyncedCases { - cases: CaseDetail[]; - filters: IFilterSync; - deletedCaseIds: string[]; - payloadCreatedAt: number; -} - -interface ICases { - caseId: string; - caseViewCreatedAt?: number; -} - -export interface ISyncCaseIdPayload { - agentId: string; - cases: ICases[]; -} - -interface ICasesSyncStatus { - syncStatus: SyncStatus; - visitPlanStatus: VisitPlanStatus; -} - -export const getCasesSyncStatus = async (userReferenceId: string) => { - try { - const url = getApiUrl(ApiKeys.CASES_SYNC_STATUS, {}, { userReferenceId }); - const response: AxiosResponse = await axiosInstance.get(url, { - headers: { donotHandleError: true }, - }); - return response?.data; - } catch (err) { - logError(err as Error, 'Error getting sync status'); - } -}; - -export const sendSyncCaseIds = async (payload: ISyncCaseIdPayload) => { - try { - const url = getApiUrl(ApiKeys.CASES_SEND_ID); - const response = await axiosInstance.post(url, payload, { - headers: { donotHandleError: true }, - }); - return response?.data; - } catch (err) { - logError(err as Error, 'Error sending case ids sync'); - } -}; - -export const fetchCasesToSync = async (agentReferenceId: string) => { - //disabling this function since its conflicting with the new sync logic - return null; - try { - const url = getApiUrl(ApiKeys.FETCH_CASES, { agentReferenceId }); - const response = await axiosInstance.get(url, { headers: { donotHandleError: true } }); - return response?.data; - } catch (err) { - logError(err as Error, 'Error fetching cases to be synced'); - } -}; diff --git a/src/assets/icons/AnomalyNotificationIcon.tsx b/src/assets/icons/AnomalyNotificationIcon.tsx new file mode 100644 index 00000000..385f10dd --- /dev/null +++ b/src/assets/icons/AnomalyNotificationIcon.tsx @@ -0,0 +1,44 @@ +import { COLORS } from '@rn-ui-lib/colors'; +import React from 'react'; +import { Path, Rect, Svg } from 'react-native-svg'; + +interface IAnomalyIcon { + fillColor?: string; + strokeColor?: string; +} +const AnomalyNotificationIcon = ({ + fillColor = COLORS.BACKGROUND.LIGHT_YELLOW, + strokeColor = COLORS.BORDER.DARK_YELLOW, +}: IAnomalyIcon) => { + return ( + + + + + + + + ); +}; + +export default AnomalyNotificationIcon; diff --git a/src/assets/icons/BookIcon.tsx b/src/assets/icons/BookIcon.tsx new file mode 100644 index 00000000..a87b7655 --- /dev/null +++ b/src/assets/icons/BookIcon.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +const BookIcon = () => ( + + + +); + +export default BookIcon; diff --git a/src/assets/icons/LocationDistanceIcon.tsx b/src/assets/icons/LocationDistanceIcon.tsx index ae5e0b6c..b8931f64 100644 --- a/src/assets/icons/LocationDistanceIcon.tsx +++ b/src/assets/icons/LocationDistanceIcon.tsx @@ -12,10 +12,10 @@ const LocationDistanceIcon = (props: ILocationDistanceIcon) => { const backgroundColor = props?.backgroundColor || COLORS.BACKGROUND.BLUE; return ( - + - - + + diff --git a/src/assets/icons/RightChevronIcon.tsx b/src/assets/icons/RightChevronIcon.tsx index 69e11441..002a2652 100644 --- a/src/assets/icons/RightChevronIcon.tsx +++ b/src/assets/icons/RightChevronIcon.tsx @@ -1,7 +1,9 @@ +import { COLORS } from '@rn-ui-lib/colors'; +import { IconProps } from '@rn-ui-lib/icons/types'; import React from 'react'; import { G, Mask, Path, Rect, Svg } from 'react-native-svg'; -const RightChevronIcon = () => { +const RightChevronIcon: React.FC = ({ fillColor = COLORS.TEXT.BLUE }) => { return ( @@ -10,7 +12,7 @@ const RightChevronIcon = () => { diff --git a/src/assets/icons/TagPlaceholderIcon.tsx b/src/assets/icons/TagPlaceholderIcon.tsx new file mode 100644 index 00000000..c2633f54 --- /dev/null +++ b/src/assets/icons/TagPlaceholderIcon.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Path, Svg } from 'react-native-svg'; + +const TagPlaceholderIcon = () => { + return ( + + + + + ); +}; + +export default TagPlaceholderIcon; diff --git a/src/assets/icons/TextMaterialIcon.tsx b/src/assets/icons/TextMaterialIcon.tsx new file mode 100644 index 00000000..36b8e98a --- /dev/null +++ b/src/assets/icons/TextMaterialIcon.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +const TextMaterialIcon = () => ( + + + +); + +export default TextMaterialIcon; diff --git a/src/assets/icons/VideoIcon.tsx b/src/assets/icons/VideoIcon.tsx new file mode 100644 index 00000000..a96db6a9 --- /dev/null +++ b/src/assets/icons/VideoIcon.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import Svg, { G, Path, Defs, ClipPath, Rect } from 'react-native-svg'; + +const VideoIcon = () => ( + + + + + + + + + + +); + +export default VideoIcon; diff --git a/src/common/AgentActivityConfigurableConstants.ts b/src/common/AgentActivityConfigurableConstants.ts index c2d1daf5..582bf6cf 100644 --- a/src/common/AgentActivityConfigurableConstants.ts +++ b/src/common/AgentActivityConfigurableConstants.ts @@ -1,7 +1,6 @@ let ACTIVITY_TIME_ON_APP: number = 5; //5 seconds let ACTIVITY_TIME_WINDOW_HIGH: number = 10; //10 minutes let ACTIVITY_TIME_WINDOW_MEDIUM: number = 30; //30 minutes -let ENABLE_FIRESTORE_RESYNC = false; let FIRESTORE_RESYNC_INTERVAL_IN_MINUTES = 15; let DATA_SYNC_JOB_INTERVAL_IN_MINUTES = 10; // 10 minutes let IMAGE_UPLOAD_JOB_INTERVAL_IN_MINUTES = 10; // 10 minutes @@ -14,7 +13,6 @@ export const getActivityTimeOnApp = () => ACTIVITY_TIME_ON_APP; export const getActivityTimeWindowHigh = () => ACTIVITY_TIME_WINDOW_HIGH; export const getActivityTimeWindowMedium = () => ACTIVITY_TIME_WINDOW_MEDIUM; -export const getEnableFirestoreResync = () => ENABLE_FIRESTORE_RESYNC; export const getFirestoreResyncIntervalInMinutes = () => FIRESTORE_RESYNC_INTERVAL_IN_MINUTES; export const getDataSyncJobIntervalInMinutes = () => DATA_SYNC_JOB_INTERVAL_IN_MINUTES; export const getImageUploadJobIntervalInMinutes = () => IMAGE_UPLOAD_JOB_INTERVAL_IN_MINUTES; @@ -37,10 +35,6 @@ export const setActivityTimeWindowMedium = (activityTimeWindowMedium: number) => ACTIVITY_TIME_WINDOW_MEDIUM = activityTimeWindowMedium; }; -export const setEnableFirestoreResync = (enableFirestoreResync: boolean) => { - ENABLE_FIRESTORE_RESYNC = enableFirestoreResync; -}; - export const setFirestoreResyncIntervalInMinutes = (firestoreResyncIntervalInMinutes: number) => { FIRESTORE_RESYNC_INTERVAL_IN_MINUTES = firestoreResyncIntervalInMinutes; }; diff --git a/src/common/BlockerInstructions.tsx b/src/common/BlockerInstructions.tsx index 3e0c0912..3a6c8c62 100644 --- a/src/common/BlockerInstructions.tsx +++ b/src/common/BlockerInstructions.tsx @@ -1,8 +1,13 @@ import { StyleSheet, View } from 'react-native'; -import React from 'react'; +import React, { useEffect } from 'react'; import Text from '../../RN-UI-LIB/src/components/Text'; import Heading from '../../RN-UI-LIB/src/components/Heading'; import Button from '../../RN-UI-LIB/src/components/Button'; +import { MILLISECONDS_IN_A_SECOND } from '@rn-ui-lib/utils/common'; +import usePolling from '@hooks/usePolling'; +import { useAppDispatch, useAppSelector } from '@hooks'; +import { BLOCKER_SCREEN_DATA } from './Constants'; +import { checkLocationEnabled } from '@hooks/useIsLocationEnabled'; interface IActionButton { title: string; @@ -16,11 +21,28 @@ interface IBlockerInstructions { actionBtn?: IActionButton; } +const CHECK_DEVICE_LOCATION_INTERVAL = 2 * MILLISECONDS_IN_A_SECOND; + const BlockerInstructions: React.FC = ({ heading, instructions, actionBtn, }) => { + const { isDeviceLocationEnabled } = useAppSelector((state) => state.foregroundService); + const dispatch = useAppDispatch(); + + const stopLocationPolling = usePolling(() => { + if (heading === BLOCKER_SCREEN_DATA.DEVICE_LOCATION_OFF.heading && !isDeviceLocationEnabled) { + dispatch(checkLocationEnabled(isDeviceLocationEnabled)); + } + }, CHECK_DEVICE_LOCATION_INTERVAL); + + useEffect(() => { + return () => { + stopLocationPolling(); + }; + }, []); + return ( diff --git a/src/common/BlockerScreen.tsx b/src/common/BlockerScreen.tsx index e3148085..8fedc902 100644 --- a/src/common/BlockerScreen.tsx +++ b/src/common/BlockerScreen.tsx @@ -174,12 +174,10 @@ const BlockerScreen = (props: IBlockerScreen) => { }, []); const handleLocationAccess = async () => { - setShowActionBtnLoader(true); const isLocationEnabled = await locationEnabled(); if (isLocationEnabled) { dispatch(setIsDeviceLocationEnabled(isLocationEnabled)); } else { - setShowActionBtnLoader(false); toast({ type: 'info', text1: RETRY_GEOLOCATION_STEPS }); } }; diff --git a/src/common/Constants.ts b/src/common/Constants.ts index f9b4b46f..03a379f4 100644 --- a/src/common/Constants.ts +++ b/src/common/Constants.ts @@ -28,6 +28,14 @@ export interface Option { meta?: Record; } +export interface IFilterOption { + label: string; + value: any; + meta?: Record; + subOptions?: IFilterOption[]; + parentOption?: IFilterOption; +} + export interface SubLabelOption extends Option { subLabel?: string; } @@ -456,9 +464,9 @@ export const CLICKSTREAM_EVENT_NAMES = { name: 'FA_VIEW_PAST_FEEDBACK_PREV_PAGE_FAILED', description: 'FA_VIEW_PAST_FEEDBACK_PREV_PAGE_FAILED', }, - FA_VIEW_PHOTO_CLICKED: { - name: 'FA_VIEW_PHOTO_CLICKED', - description: 'FA_VIEW_PHOTO_CLICKED' + FA_VIEW_PHOTO_CLICKED: { + name: 'FA_VIEW_PHOTO_CLICKED', + description: 'FA_VIEW_PHOTO_CLICKED', }, FA_CUSTOMER_DOCUMENT_CLICKED: { name: 'FA_CUSTOMER_DOCUMENT_CLICKED', @@ -505,7 +513,6 @@ export const CLICKSTREAM_EVENT_NAMES = { name: 'FA_VIEW_ALL_ESCALATIONS_SCREEN_LANDED', description: 'FA_VIEW_ALL_ESCALATIONS_SCREEN_LANDED', }, - // Notifications FA_NOTIFICATION_ICON_CLICK: { @@ -802,7 +809,7 @@ export const CLICKSTREAM_EVENT_NAMES = { name: 'FA_FETCHED_CUSTOMER_DOCUMENTS', description: 'FA_FETCHED_CUSTOMER_DOCUMENTS', }, - FA_FETCH_CUSTOMER_DOCUMENTS_FAILED: { + FA_FETCH_CUSTOMER_DOCUMENTS_FAILED: { name: 'FA_FETCHED_CUSTOMER_DOCUMENTS_FAILED', description: 'FA_FETCHED_CUSTOMER_DOCUMENTS_FAILED', }, @@ -829,7 +836,7 @@ export const CLICKSTREAM_EVENT_NAMES = { FA_CHANNEL_CLICKED_SHARE_JOURNEY: { name: 'FA_CHANNEL_CLICKED_SHARE_JOURNEY', description: 'FA_CHANNEL_CLICKED_SHARE_JOURNEY', - }, + }, FA_PHONE_NUMBER_CLICKED_SHARE_JOURNEY: { name: 'FA_PHONE_NUMBER_CLICKED_SHARE_JOURNEY', description: 'FA_PHONE_NUMBER_CLICKED_SHARE_JOURNEY', @@ -929,6 +936,10 @@ export const CLICKSTREAM_EVENT_NAMES = { name: 'FA_ADDRESS_TAB_CLICKED', description: 'FA_ADDRESS_TAB_CLICKED', }, + FA_SKIP_TRACING_TAB_CLICKED: { + name: 'FA_SKIP_TRACING_TAB_CLICKED', + description: 'FA_SKIP_TRACING_TAB_CLICKED', + }, FA_GEOLOCATION_LOADED: { name: 'FA_GEOLOCATION_LOADED', description: 'FA_GEOLOCATION_LOADED', @@ -1137,7 +1148,7 @@ export const CLICKSTREAM_EVENT_NAMES = { }, LITMUS_EXPERIMENT: { name: 'LITMUS_EXPERIMENT', - description: 'LITMUS_EXPERIMENT' + description: 'LITMUS_EXPERIMENT', }, //FEE WAIVE CLICKSTREAM EVENTS @@ -1298,92 +1309,153 @@ export const CLICKSTREAM_EVENT_NAMES = { description: 'Call banner clicked', }, FA_READ_PERMISSION_ERROR: { - name: 'ERROR_IN_FETCHING_READ_PERMISSION', - description: 'Error in fetching read permission' + name: 'ERROR_IN_FETCHING_READ_PERMISSION', + description: 'Error in fetching read permission', }, FA_READ_PERMISSION_NOT_PROVIDED: { - name: 'FA_READ_PERMISSION_NOT_PROVIDED', - description: 'Read permission not provided' + name: 'FA_READ_PERMISSION_NOT_PROVIDED', + description: 'Read permission not provided', }, FA_CALLING_FEEDBACK_NUDGE_LOADED: { name: 'FA_CALLING_FEEDBACK_NUDGE_LOADED', - description: 'Calling feedback nudge loaded' + description: 'Calling feedback nudge loaded', }, FA_CALLING_FEEDBACK_NUDGE_FEEDBACK_BUTTON_CLICKED: { name: 'FA_CALLING_FEEDBACK_NUDGE_FEEDBACK_BUTTON_CLICKED', - description: 'Fill feedback button clicked' + description: 'Fill feedback button clicked', }, FA_CALLING_FEEDBACK_NUDGE_CLOSED: { name: 'FA_CALLING_FEEDBACK_NUDGE_CLOSED', - description: 'Feedback nudge closed' + description: 'Feedback nudge closed', }, FA_INSTALLING_CODEPUSH: { - name : 'FA_INSTALLING_CODEPUSH', - description: 'Codepush installation started' + name: 'FA_INSTALLING_CODEPUSH', + description: 'Codepush installation started', }, FA_CODEPUSH_DEFAULT_STATUS: { - name : 'FA_CODEPUSH_DEFAULT_STATUS', - description: 'Codepush default fallback case' + name: 'FA_CODEPUSH_DEFAULT_STATUS', + description: 'Codepush default fallback case', + }, + FA_CODEPUSH_UNKNOWN_ERROR: { + name: 'FA_CODEPUSH_UNKNOWN_ERROR', + description: 'Codepush unknown error', }, FA_FEEDBACK_IMAGE_NOT_FOUND: { name: 'FA_FEEDBACK_IMAGE_NOT_FOUND', - description: 'Feedback image not found' + description: 'Feedback image not found', }, FA_UNSYNC_FEEDBACK_CAPTURED: { name: 'FA_UNSYNC_FEEDBACK_CAPTURED', description: 'Unsync feedback captured' - }, - FA_CODEPUSH_UNKNOWN_ERROR: { - name : 'FA_CODEPUSH_UNKNOWN_ERROR', - description: 'Codepush unknown error' + }, + FA_UNSYNC_FEEDBACK_CAPTURING: { + name: 'FA_UNSYNC_FEEDBACK_CAPTURING', + description: 'Feedback capturing' + }, + FA_UNSYNC_FEEDBACK_CAPTURE_SUCCESS: { + name: 'FA_UNSYNC_FEEDBACK_CAPTURE_SUCCESS', + description: 'Feedback capture success' + }, + FA_FEEDBACK_SUCCESS: { + name: 'FA_FEEDBACK_SUCCESS', + description: 'Feedback capture success' + }, + FA_ORIGINAL_IMAGE_UPLOADING: { + name: 'FA_ORIGINAL_IMAGE_UPLOADING', + description: 'Original image uploading' + }, + FA_WRONG_QUESTION_KEY: { + name: 'FA_WRONG_QUESTION_KEY', + description: 'Wrong question key' }, FA_API_FAILED: { name: 'FA_API_FAILED', - description: 'API failed' + description: 'API failed', }, // Apk Update FA_APK_UPDATE_DOWNLOAD_STARTED: { name: 'FA_APK_UPDATE_DOWNLOAD_STARTED', - description: 'APK update download started' + description: 'APK update download started', }, FA_APK_UPDATE_DOWNLOAD_SUCCESS: { name: 'FA_APK_UPDATE_DOWNLOAD_SUCCESS', - description: 'APK update download completed' + description: 'APK update download completed', }, FA_APK_UPDATE_DOWNLOAD_FAILED: { name: 'FA_APK_UPDATE_DOWNLOAD_FAILED', - description: 'APK update download failed' + description: 'APK update download failed', }, FA_APK_UPDATE_BUTTON_CLICKED: { name: 'FA_APK_UPDATE_BUTTON_CLICKED', - description: 'APK update button clicked' + description: 'APK update button clicked', }, FA_APK_UPDATE_INSTALL_STARTED: { name: 'FA_APK_UPDATE_INSTALL_STARTED', - description: 'APK update installation started' + description: 'APK update installation started', }, FA_APK_UPDATE_INSTALL_FAILED: { name: 'FA_APK_UPDATE_INSTALL_FAILED', - description: 'APK update installation failed' + description: 'APK update installation failed', }, FA_APK_UPDATE_FALLBACK_TRIGGERED: { name: 'FA_APK_UPDATE_FALLBACK_TRIGGERED', - description: 'APK update fallback triggered' + description: 'APK update fallback triggered', }, FA_APK_UPDATE_CORRUPTED_FILE_DOWNLOADED: { name: 'FA_APK_UPDATE_CORRUPTED_FILE_DOWNLOADED', - description: 'APK update corrupted file downloaded' + description: 'APK update corrupted file downloaded', }, FA_APK_UPDATE_INSTALL_SUCCESS: { name: 'FA_APK_UPDATE_INSTALL_SUCCESS', - description: 'APK update installation success' + description: 'APK update installation success', }, FA_POST_OPERATIVE_HOURS_SCREEN_LOADED: { name: 'FA_POST_OPERATIVE_HOURS_SCREEN_LOADED', - description: 'Post operative hours screen loaded' - } + description: 'Post operative hours screen loaded', + }, + + FA_PERSIST_ORIGINAL_IMAGE_FAILURE: { + name: 'FA_PERSIST_ORIGINAL_IMAGE_FAILURE', + description: 'Failed to persist original image', + }, + + // Filter coachmarks + FA_FILTER_COACHMARKS_LOADED: { + name: 'FA_FILTER_COACHMARKS_LOADED', + description: 'Filter coachmarks loaded', + }, + FA_FILTER_COACHMARKS_FAILED: { + name: 'FA_FILTER_COACHMARKS_FAILED', + description: 'Filter coachmarks failed', + }, + + // Training module + FA_PROFILE_PAGE_TRAINING_MATERIAL_CLICKED: { + name: 'FA_PROFILE_PAGE_TRAINING_MATERIAL_CLICKED', + description: 'Training material clicked', + }, + FA_TRAINING_MATERIAL_LIST_SCREEN_LOADED: { + name: 'FA_TRAINING_MATERIAL_LIST_SCREEN_LOADED', + description: 'Training material screen loaded', + }, + FA_TRAINING_MATERIAL_ITEM_CLICKED: { + name: 'FA_TRAINING_MATERIAL_ITEM_CLICKED', + description: 'Training material item clicked', + }, + FA_TRAINING_MATERIAL_ITEM_LOADED: { + name: 'FA_TRAINING_MATERIAL_ITEM_LOADED', + description: 'Training material item loaded', + }, + FA_TRAINING_MATERIAL_ITEM_CLOSED: { + name: 'FA_TRAINING_MATERIAL_ITEM_CLOSED', + description: 'Training material item closed', + }, + FA_TRAINING_MATERIAL_PDF_PAGE_CHANGED: { + name: 'FA_TRAINING_MATERIAL_PDF_PAGE_CHANGED', + description: 'Training material PDF page changed', + }, } as const; export enum MimeType { @@ -1530,4 +1602,4 @@ export const API_ERROR_MESSAGE = 'Oops! something went wrong'; export enum BuildFlavours { FIELD_AGENTS = 'fieldAgents', CALLING_AGENTS = 'callingAgents', -} \ No newline at end of file +} diff --git a/src/common/ErrorBoundary.tsx b/src/common/ErrorBoundary.tsx index 1db7717c..f2878803 100644 --- a/src/common/ErrorBoundary.tsx +++ b/src/common/ErrorBoundary.tsx @@ -1,6 +1,6 @@ import React, { Component, ReactNode } from 'react'; import { sentryCaptureException } from '../components/utlis/errorUtils'; -import { StyleSheet, View } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; import { addClickstreamEvent } from '../services/clickstreamEventService'; import { CLICKSTREAM_EVENT_NAMES } from './Constants'; import ErrorImage from '../assets/images/Error'; @@ -9,9 +9,12 @@ import VersionNumber from 'react-native-version-number'; import Text from '../../RN-UI-LIB/src/components/Text'; import { getAppVersion } from '../components/utlis/commonFunctions'; import { COLORS } from '../../RN-UI-LIB/src/styles/colors'; -import { handleCrash } from '../components/utlis/DeviceUtils'; +import { handleCrash, restartJSBundle } from '../components/utlis/DeviceUtils'; import crashlytics from '@react-native-firebase/crashlytics'; import { getCurrentScreen } from '@components/utlis/navigationUtlis'; +import Button from '@rn-ui-lib/components/Button'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import LoadingIcon from '@rn-ui-lib/icons/LoadingIcon'; interface IErrorBoundary { children?: ReactNode; @@ -55,6 +58,15 @@ class ErrorBoundary extends Component { App Version: {getAppVersion()} Gradle Version: {VersionNumber.appVersion} Gradle Build No: {VersionNumber.buildVersion} + + + + + Reload + ); } @@ -81,6 +93,15 @@ const styles = StyleSheet.create({ display: 'flex', flexDirection: 'row', }, + textContainer: { + fontSize: 14, + fontWeight: '500', + color: COLORS.TEXT.BLACK_24, + }, + retryBtn: { + fontSize: 16, + color: COLORS.BASE.BLUE, + }, }); export default ErrorBoundary; diff --git a/src/common/ModalWrapperForAlfredV2.tsx b/src/common/ModalWrapperForAlfredV2.tsx index 4577809f..6e85cc3c 100644 --- a/src/common/ModalWrapperForAlfredV2.tsx +++ b/src/common/ModalWrapperForAlfredV2.tsx @@ -34,7 +34,7 @@ const ModalWrapperForAlfredV2: React.FC = ({ children, ...props } {children} diff --git a/src/common/NewTag.tsx b/src/common/NewTag.tsx new file mode 100644 index 00000000..5b5ad2f7 --- /dev/null +++ b/src/common/NewTag.tsx @@ -0,0 +1,8 @@ +import Tag, { TagVariant } from '@rn-ui-lib/components/Tag'; +import { GenericStyles } from '@rn-ui-lib/styles'; + +const NewTag = () => { + return ; +}; + +export default NewTag; diff --git a/src/common/TrackingComponent.tsx b/src/common/TrackingComponent.tsx index 6e82cf1f..8e526b67 100644 --- a/src/common/TrackingComponent.tsx +++ b/src/common/TrackingComponent.tsx @@ -1,5 +1,5 @@ -import React,{ type ReactNode, useEffect, useRef, useState } from 'react'; -import { type NativeEventSubscription, AppState, type AppStateStatus } from 'react-native'; +import React,{ type ReactNode, useEffect, useRef } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; import dayJs from 'dayjs'; import RNFS from 'react-native-fs'; import fetchUpdatedRemoteConfig, { @@ -13,30 +13,16 @@ import useIsOnline from '../hooks/useIsOnline'; import { getSyncTime, sendCurrentGeolocationAndBuffer } from '../hooks/capturingApi'; import { getAppVersion, - isTimeDifferenceWithinRange, - setAsyncStorageItem, } from '../components/utlis/commonFunctions'; -import { setIsTimeSynced } from '../reducer/foregroundServiceSlice'; +import { setServerTimestamp } from '../reducer/foregroundServiceSlice'; import { logError } from '../components/utlis/errorUtils'; import { useAppDispatch, useAppSelector } from '../hooks'; import { dataSyncService } from '../services/dataSync.service'; import { DATA_SYNC_TIME_INTERVAL, IS_DATA_SYNC_REQUIRED } from '../constants/config'; import useIsLocationEnabled from '../hooks/useIsLocationEnabled'; -import { - type ISyncCaseIdPayload, - type ISyncedCases, - SyncStatus, - fetchCasesToSync, - getCasesSyncStatus, - sendSyncCaseIds, -} from '../action/firebaseFallbackActions'; -import { getSyncCaseIds } from '../components/utlis/firebaseFallbackUtils'; -import { syncCasesByFallback } from '../reducer/allCasesSlice'; -import { MILLISECONDS_IN_A_MINUTE, MILLISECONDS_IN_A_SECOND, noop } from '../../RN-UI-LIB/src/utlis/common'; -import { setCaseSyncLock, setLockData } from '../reducer/userSlice'; +import { MILLISECONDS_IN_A_MINUTE, MILLISECONDS_IN_A_SECOND } from '../../RN-UI-LIB/src/utlis/common'; import { getConfigData } from '../action/configActions'; -import { getBuildFlavour } from '../components/utlis/DeviceUtils'; import { AppStates } from '../types/appStates'; import { StorageKeys } from '../types/storageKeys'; import { AgentActivity } from '../types/agentActivity'; @@ -44,7 +30,6 @@ import { getActivityTimeOnApp, getActivityTimeWindowMedium, getActivityTimeWindowHigh, - getEnableFirestoreResync, getDataSyncJobIntervalInMinutes, getImageUploadJobIntervalInMinutes, getVideoUploadJobIntervalInMinutes, @@ -54,7 +39,7 @@ import { } from './AgentActivityConfigurableConstants'; import { GlobalImageMap } from './CachedImage'; import { addClickstreamEvent } from '../services/clickstreamEventService'; -import { BuildFlavours, CLICKSTREAM_EVENT_NAMES, LocalStorageKeys } from './Constants'; +import { BuildFlavours, CLICKSTREAM_EVENT_NAMES } from './Constants'; import useResyncFirebase from '@hooks/useResyncFirebase'; import { imageSyncService, sendImagesToServer } from '@services/imageSyncService'; import { sendAudiosToServer } from '@services/audioSyncService'; @@ -68,6 +53,8 @@ import store from '@store'; import useFirestoreUpdates from '@hooks/useFirestoreUpdates'; import { GLOBAL } from '@constants/Global'; +import { handlePostOperativeHourActivity } from '@screens/caseDetails/utils/postOperationalHourActions'; +import { setPostOperationalHourRestrictions } from '@reducers/postOperationalHourRestrictionsSlice'; export enum FOREGROUND_TASKS { GEOLOCATION = 'GEOLOCATION', @@ -93,7 +80,6 @@ interface ITrackingComponent { children?: ReactNode; } -let LAST_SYNC_STATUS = 'SKIP'; const ACTIVITY_TIME_WINDOW = 10; // 10 minutes const TrackingComponent: React.FC = ({ children }) => { @@ -101,11 +87,7 @@ const TrackingComponent: React.FC = ({ children }) => { const dispatch = useAppDispatch(); const appState = useRef(AppState.currentState); - const isTeamLead = useAppSelector((state) => state.user.isTeamLead); - const caseSyncLock = useAppSelector((state) => state?.user?.caseSyncLock); const referenceId = useAppSelector((state) => state.user.user?.referenceId!); - const pendingList = useAppSelector((state) => state.allCases.pendingList); - const pinnedList = useAppSelector((state) => state.allCases.pinnedList); const handleTimeSync = async () => { try { @@ -114,8 +96,8 @@ const TrackingComponent: React.FC = ({ children }) => { } const timestamp = await getSyncTime(); if (timestamp) { - const isTimeDifferenceLess = isTimeDifferenceWithinRange(timestamp, 5); - dispatch(setIsTimeSynced(isTimeDifferenceLess)); + dispatch(setServerTimestamp(timestamp)); + dispatch(setPostOperationalHourRestrictions(handlePostOperativeHourActivity(timestamp))); } } catch (e: any) { logError(e, 'Error during fetching timestamp from server.'); @@ -124,46 +106,6 @@ const TrackingComponent: React.FC = ({ children }) => { const resyncFirebase = useResyncFirebase(); - const handleGetCaseSyncStatus = async () => { - if (caseSyncLock || getEnableFirestoreResync()) { - return; - } - dispatch(setCaseSyncLock(true)); - try { - if (!isOnline) { - return; - } - const { syncStatus, visitPlanStatus } = (await getCasesSyncStatus(referenceId)) ?? {}; - if (syncStatus) { - // Keep track of the last status received - LAST_SYNC_STATUS = syncStatus; - } - if (syncStatus === SyncStatus.SEND_CASES) { - const cases = getSyncCaseIds([...pendingList, ...pinnedList]); - const payload: ISyncCaseIdPayload = { - agentId: referenceId, - cases, - }; - sendSyncCaseIds(payload); - } else if (syncStatus === SyncStatus.FETCH_CASES) { - const updatedDetails: ISyncedCases = await fetchCasesToSync(referenceId); - if (updatedDetails?.cases?.length) { - dispatch(syncCasesByFallback(updatedDetails)); - } - } - if (visitPlanStatus) { - dispatch( - setLockData({ - visitPlanStatus, - }) - ); - } - dispatch(setCaseSyncLock(false)); - } catch (e) { - logError(e as Error, 'Error during fetching case sync status'); - } - }; - const handleUpdateActivity = async () => { const foregroundTimestamp = await getItem(StorageKeys.APP_FOREGROUND_TIMESTAMP); const backgroundTimestamp = await getItem(StorageKeys.APP_BACKGROUND_TIMESTAMP); @@ -174,7 +116,6 @@ const TrackingComponent: React.FC = ({ children }) => { } const foregroundTime = dayJs(foregroundTimestamp); - const backgroundTime = dayJs(backgroundTimestamp); const stateSetTime = dayJs(stateSetTimestamp); const diffBetweenCurrentTimeAndForegroundTime = @@ -354,15 +295,6 @@ const TrackingComponent: React.FC = ({ children }) => { }, ]; - if (!isTeamLead) { - tasks.push({ - taskId: FOREGROUND_TASKS.FIRESTORE_FALLBACK, - task: handleGetCaseSyncStatus, - delay: 5 * MILLISECONDS_IN_A_MINUTE, // 5 minutes - onLoop: true, - }); - } - if(GLOBAL?.BUILD_FLAVOUR === BuildFlavours.CALLING_AGENTS) { tasks.push({ taskId: FOREGROUND_TASKS.COSMOS_SYNC_WITH_LONGHORN, @@ -417,7 +349,6 @@ const TrackingComponent: React.FC = ({ children }) => { if (nextAppState === AppStates.ACTIVE) { await setItem(StorageKeys.APP_FOREGROUND_TIMESTAMP, now); addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.AV_APP_FOREGROUND, { now }); - handleGetCaseSyncStatus(); handleTimeSync(); dispatch(getConfigData()); CosmosForegroundService.start(tasks); @@ -431,22 +362,11 @@ const TrackingComponent: React.FC = ({ children }) => { appState.current = nextAppState; }; - // Fetch cases on login initially and set data useEffect(() => { - (async () => { - if (!referenceId) { - return; - } - await handleGetCaseSyncStatus(); - dispatch(getConfigData()); - const isFirestoreResyncEnabled = getEnableFirestoreResync(); - if (!isTeamLead && LAST_SYNC_STATUS !== SyncStatus.FETCH_CASES && !isFirestoreResyncEnabled) { - const updatedDetails: ISyncedCases = await fetchCasesToSync(referenceId); - if (updatedDetails?.cases?.length) { - dispatch(syncCasesByFallback(updatedDetails)); - } - } - })(); + if (!referenceId) { + return; + } + dispatch(getConfigData()); }, []); useEffect(() => { diff --git a/src/components/Tour/components/AnimatedRenderMask.tsx b/src/components/Tour/components/AnimatedRenderMask.tsx new file mode 100644 index 00000000..f7569adf --- /dev/null +++ b/src/components/Tour/components/AnimatedRenderMask.tsx @@ -0,0 +1,114 @@ +import React, { useEffect } from 'react'; +import { View } from 'react-native'; +import Svg, { Circle, Defs, Mask, Rect, G } from 'react-native-svg'; +import Animated, { + Easing, + interpolate, + useAnimatedProps, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; +import { SCREEN_WIDTH } from '@rn-ui-lib/styles'; +import { IAnimatedRenderMask } from '../types'; + +const HIGHLIGHT_PADDING = 2; + +const AnimatedCircle = Animated.createAnimatedComponent(Circle); +const AnimatedRect = Animated.createAnimatedComponent(Rect); + +const AnimatedRenderMask: React.FC = ({ maskRect }) => { + const circleCenterX = useSharedValue(0); + const circleCenterY = useSharedValue(0); + const rippleAnimation = useSharedValue(0); + + const highlightAreaX = maskRect.x - 2 * HIGHLIGHT_PADDING; + const highlightAreaY = maskRect.y - HIGHLIGHT_PADDING; + const highlightAreaWidth = maskRect.width + 4 * HIGHLIGHT_PADDING; + const highlightAreaHeight = maskRect.height + 2 * HIGHLIGHT_PADDING; + const highlightAreaRadius = (maskRect.height + HIGHLIGHT_PADDING) / 2; + + useEffect(() => { + circleCenterX.value = maskRect.x + maskRect.width / 2; + circleCenterY.value = maskRect.y + maskRect.height / 2; + }, [maskRect]); + + useEffect(() => { + rippleAnimation.value = withRepeat( + withTiming(1, { duration: 500, easing: Easing.inOut(Easing.ease) }), + -1, + true + ); + }, []); + + const circleAnimatedProps = useAnimatedProps(() => ({ + transform: [ + { + translateX: withTiming(circleCenterX.value, { + duration: 200, + }), + }, + { + translateY: withTiming(circleCenterY.value, { + duration: 200, + }), + }, + ], + })); + + // TODO: Can use for ripple animation in future + // const animatedRectProps = useAnimatedProps(() => { + // const animatedExpansion = interpolate(rippleAnimation.value, [0, 1], [0, 10]); + // return { + // // x: highlightAreaX - animatedExpansion, + // // y: highlightAreaY - animatedExpansion, + // // width: highlightAreaWidth + animatedExpansion * 2, + // // height: highlightAreaHeight + animatedExpansion * 2, + // // rx: highlightAreaRadius + animatedExpansion, + // strokeWidth: animatedExpansion, + // // strokeOpacity: interpolate(rippleAnimation.value, [0, 1], [0.7, 0.2]), + // }; + // }); + + return ( + + + + + + + + + + + + {/* Can use this for ripple animation in future */} + {/* */} + + + ); +}; + +export default React.memo(AnimatedRenderMask); diff --git a/src/components/Tour/components/CopilotModal.tsx b/src/components/Tour/components/CopilotModal.tsx new file mode 100644 index 00000000..c176d1a6 --- /dev/null +++ b/src/components/Tour/components/CopilotModal.tsx @@ -0,0 +1,165 @@ +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'; +import { View, LayoutRectangle, ViewStyle } from 'react-native'; +import { useCopilot } from '../contexts/CopilotProvider'; +import { CopilotModalHandle, CopilotOptions } from '../types'; +import { MARGIN, styles } from './style'; +import AnimatedRenderMask from './AnimatedRenderMask'; +import { Tooltip } from './Tooltip'; +import { SCREEN_HEIGHT, SCREEN_WIDTH } from '@rn-ui-lib/styles'; +import ModalWrapperForAlfredV2 from '@common/ModalWrapperForAlfredV2'; + +export const CopilotModal = forwardRef(function CopilotModal( + { + tooltipComponent: TooltipComponent = Tooltip, + tooltipStyle = {}, + labels = { + finish: 'Got it', + next: 'Next', + previous: 'Previous', + skip: 'Skip', + }, + margin = MARGIN, + }, + ref +) { + const { currentStep, visible } = useCopilot(); + const [tooltipStyles, setTooltipStyles] = useState({}); + const [maskRect, setMaskRect] = useState(); + const { noHighlightArea } = currentStep || {}; + + const [containerVisible, setContainerVisible] = useState(false); + + useEffect(() => { + if (visible) { + setContainerVisible(true); + } else { + reset(); + } + }, [visible]); + + const setStylesForNoHighlightArea = (rect: LayoutRectangle) => { + setMaskRect({ + width: 0, + height: 0, + x: SCREEN_WIDTH / 2, + y: SCREEN_HEIGHT + SCREEN_WIDTH / 2, + }); + + setTooltipStyles({ + bottom: SCREEN_WIDTH / 8, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + width: SCREEN_WIDTH, + }); + }; + + const _animateMove = useCallback( + async (rect: LayoutRectangle) => { + if (noHighlightArea) { + setStylesForNoHighlightArea(rect); + return; + } + // center of the coachmark element + const center = { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; + + const relativeToLeft = center.x; + const relativeToTop = center.y; + const relativeToBottom = Math.abs(center.y - SCREEN_HEIGHT); + const relativeToRight = Math.abs(center.x - SCREEN_WIDTH); + + // position of tooltip + const verticalPosition = relativeToBottom > relativeToTop ? 'bottom' : 'top'; + const horizontalPosition = relativeToLeft > relativeToRight ? 'left' : 'right'; + + const tooltip: ViewStyle = {}; + + if (verticalPosition === 'bottom') { + // if the tooltip is at the bottom, we need to add some margin to the bottom + tooltip.top = rect.y + rect.height + margin; + } else { + // if the tooltip is at the top, we need to add some margin to the top + tooltip.bottom = noHighlightArea ? 40 : SCREEN_HEIGHT - rect.y + margin + margin; + } + + if (horizontalPosition === 'left') { + // if the tooltip is at the left, we need to add some margin to the left + tooltip.right = Math.min(SCREEN_WIDTH - (rect.x + rect.width), 24); + } else { + // if the tooltip is at the right, we need to add some margin to the right + tooltip.left = noHighlightArea ? 50 : Math.min(rect.x, 24); + } + + setTooltipStyles(tooltip); + + // highlight area dimensions and position + setMaskRect({ + width: rect.width, + height: rect.height, + x: Math.floor(Math.max(rect.x, 0)), + y: Math.floor(Math.max(rect.y, 0)), + }); + }, + [margin, currentStep] + ); + + const animateMove = useCallback( + async (rect) => { + await new Promise((resolve) => { + const frame = async () => { + await _animateMove(rect); + resolve(); + }; + + setContainerVisible(true); + requestAnimationFrame(() => { + frame(); + }); + }); + }, + [_animateMove] + ); + + const reset = () => { + setContainerVisible(false); + setMaskRect(undefined); + }; + + useImperativeHandle( + ref, + () => { + return { + animateMove, + }; + }, + [animateMove] + ); + + const modalVisible = containerVisible || visible; + + if (!modalVisible) { + return null; + } + + const showMask = containerVisible && maskRect; + + return ( + + {showMask ? ( + + + + + + + ) : null} + + ); +}); diff --git a/src/components/Tour/components/CopilotStep.tsx b/src/components/Tour/components/CopilotStep.tsx new file mode 100644 index 00000000..c9e8276a --- /dev/null +++ b/src/components/Tour/components/CopilotStep.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { StyleSheet, View, type NativeMethods } from 'react-native'; +import { useCopilot } from '../contexts/CopilotProvider'; +import { GenericStyles, SCREEN_HEIGHT, SCREEN_WIDTH } from '@rn-ui-lib/styles'; +import { CopilotStepProps } from '../types'; + +export const CopilotStep = ({ + name, + order, + text, + children, + active = true, + width, + height, + isCircularHighlight, + noHighlightArea, +}: CopilotStepProps) => { + const registeredName = useRef(null); + const { registerStep, unregisterStep, currentStepNumber, visible } = useCopilot(); + const wrapperRef = React.useRef(null); + + const measure = async () => { + return await new Promise<{ + x: number; + y: number; + width: number; + height: number; + }>((resolve) => { + const measure = () => { + if (noHighlightArea) { + resolve({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + return; + } + // Wait until the wrapper element appears + if (wrapperRef.current != null && 'measure' in wrapperRef.current) { + wrapperRef.current.measure((_ox, _oy, elementWidth, elementHeight, x, y) => { + resolve({ + x, + y, + width: width || elementWidth, + height: height || elementHeight, + }); + }); + } else { + requestAnimationFrame(measure); + } + }; + + measure(); + }); + }; + + useEffect(() => { + if (active) { + if (registeredName.current && registeredName.current !== name) { + unregisterStep(registeredName.current); + } + registerStep({ + name, + text, + order, + measure, + wrapperRef, + visible: true, + isCircularHighlight, + noHighlightArea, + }); + registeredName.current = name; + } + }, [active]); + + useEffect(() => { + if (active) { + return () => { + if (registeredName.current) { + unregisterStep(registeredName.current); + } + }; + } + }, [name, unregisterStep, active]); + + const copilotProps = useMemo( + () => ({ + onLayout: () => {}, + }), + [] + ); + + const isNoHighlightAreaStepActive = visible && noHighlightArea && currentStepNumber === order; + + if (noHighlightArea) { + if (!isNoHighlightAreaStepActive) { + return null; + } + return {children}; + } + + return React.cloneElement(children, { ...copilotProps, ref: wrapperRef }); +}; + +const styles = StyleSheet.create({ + wrapper: { + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + position: 'absolute', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, +}); diff --git a/src/components/Tour/components/Tooltip.tsx b/src/components/Tour/components/Tooltip.tsx new file mode 100644 index 00000000..41d77c42 --- /dev/null +++ b/src/components/Tour/components/Tooltip.tsx @@ -0,0 +1,104 @@ +import React, { useEffect } from 'react'; +import { View } from 'react-native'; +import Button from '@rn-ui-lib/components/Button'; +import Text from '@rn-ui-lib/components/Text'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import { useCopilot } from '../contexts/CopilotProvider'; +import { styles } from './style'; +import { ITooltip } from '../types'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; + +export const Tooltip: React.FC = ({ labels }) => { + const { + goToNext, + goToPrev, + stop, + currentStep, + isLastStep, + isFirstStep, + currentStepNumber, + totalStepsNumber, + } = useCopilot(); + const tooltipOpacity = useSharedValue(0); + + useEffect(() => { + if (currentStepNumber) { + tooltipOpacity.value = 0; + tooltipOpacity.value = withTiming(1, { duration: 700 }); + } + }, [currentStepNumber]); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: tooltipOpacity.value, + }; + }); + + const handleStop = () => { + stop(); + }; + const handleNext = () => { + if (isLastStep) { + handleStop(); + return; + } + goToNext(); + }; + + const handlePrevious = () => { + goToPrev(); + }; + + return ( + + + {currentStep?.text} + + + {totalStepsNumber > 1 ? ( + + {currentStepNumber}/{totalStepsNumber} + + ) : null} + + {!isFirstStep ? ( + + ) : null} + {isLastStep ? ( + + ) : null} + {!isLastStep ? ( + + ) : null} + + + + ); +}; diff --git a/src/components/Tour/components/style.ts b/src/components/Tour/components/style.ts new file mode 100644 index 00000000..99aeb5fb --- /dev/null +++ b/src/components/Tour/components/style.ts @@ -0,0 +1,39 @@ +import { COLORS } from '@rn-ui-lib/colors'; +import { StyleSheet } from 'react-native'; + +export const ZINDEX: number = 99999; +export const MARGIN: number = 16; +export const OFFSET_WIDTH: number = 4; + +export const styles = StyleSheet.create({ + container: { + position: 'absolute', + left: 0, + top: 0, + right: 0, + bottom: 0, + zIndex: ZINDEX, + }, + stepNoText: { + color: COLORS.TEXT.GREY_1, + }, + tooltip: { + position: 'absolute', + paddingTop: 15, + borderRadius: 3, + overflow: 'hidden', + }, + tooltipText: { + color: COLORS.TEXT.WHITE, + fontSize: 16, + fontWeight: '700', + }, + nextBtn: { + paddingHorizontal: 21, + paddingVertical: 4, + }, + prevBtn: { + paddingVertical: 4, + paddingHorizontal: 10, + }, +}); diff --git a/src/components/Tour/contexts/CopilotProvider.tsx b/src/components/Tour/contexts/CopilotProvider.tsx new file mode 100644 index 00000000..a7e23ad3 --- /dev/null +++ b/src/components/Tour/contexts/CopilotProvider.tsx @@ -0,0 +1,197 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type PropsWithChildren, +} from 'react'; +import { findNodeHandle, type ScrollView } from 'react-native'; +import { CopilotModal } from '../components/CopilotModal'; +import { OFFSET_WIDTH } from '../components/style'; +import { useStateWithAwait } from '../hooks/useStateWithAwait'; +import { useStepsMap } from '../hooks/useStepsMap'; +import { CopilotContextType, CopilotModalHandle, type CopilotOptions, type Step } from '../types'; +import { noop } from '@rn-ui-lib/utils/common'; + +/* +This is the maximum wait time for the steps to be registered before starting the tutorial +At 60fps means 2 seconds +*/ +const MAX_START_TRIES = 120; + +const CopilotContext = createContext(undefined); + +export const CopilotProvider = ({ + verticalOffset = 0, + children, + ...rest +}: PropsWithChildren) => { + const startTries = useRef(0); + const modal = useRef(null); + + const [visible, setVisibility] = useStateWithAwait(false); + const [scrollView, setScrollView] = useState(null); + + const { + currentStep, + currentStepNumber, + totalStepsNumber, + getFirstStep, + getPrevStep, + getNextStep, + getNthStep, + isFirstStep, + isLastStep, + setCurrentStepState, + steps, + registerStep, + unregisterStep, + } = useStepsMap(); + + const moveModalToStep = useCallback( + async (step: Step) => { + const size = await step?.measure(); + if (!size) { + return; + } + + await modal.current?.animateMove({ + width: size.width + OFFSET_WIDTH, + height: size.height + OFFSET_WIDTH, + x: size.x - OFFSET_WIDTH / 2, + y: size.y - OFFSET_WIDTH / 2 + verticalOffset, + }); + }, + [verticalOffset] + ); + + const setCurrentStep = useCallback( + async (step?: Step, move: boolean = true) => { + setCurrentStepState(step); + + if (scrollView != null) { + const nodeHandle = findNodeHandle(scrollView); + if (nodeHandle) { + step?.wrapperRef.current?.measureLayout( + nodeHandle, + (_x, y, _w, h) => { + const yOffset = y > 0 ? y - h / 2 : 0; + scrollView.scrollTo({ y: yOffset, animated: false }); + }, + noop + ); + } + } + + setTimeout( + () => { + if (move && step) { + void moveModalToStep(step); + } + }, + scrollView != null ? 100 : 0 + ); + }, + [moveModalToStep, scrollView, setCurrentStepState] + ); + + const start = useCallback( + async (fromStep?: string, suppliedScrollView: ScrollView | null = null) => { + if (scrollView == null) { + setScrollView(suppliedScrollView); + } + + const currentStep = fromStep ? steps[fromStep] : getFirstStep(); + + if (startTries.current > MAX_START_TRIES) { + startTries.current = 0; + return; + } + + if (currentStep == null) { + startTries.current += 1; + requestAnimationFrame(() => { + void start(fromStep); + }); + } else { + await setCurrentStep(currentStep); + await moveModalToStep(currentStep); + await setVisibility(true); + startTries.current = 0; + } + }, + [getFirstStep, moveModalToStep, scrollView, setCurrentStep, setVisibility, steps] + ); + + const stop = useCallback(async () => { + await setVisibility(false); + }, [setVisibility]); + + const next = useCallback(async () => { + await setCurrentStep(getNextStep()); + }, [getNextStep, setCurrentStep]); + + const nth = useCallback( + async (n: number) => { + await setCurrentStep(getNthStep(n)); + }, + [getNthStep, setCurrentStep] + ); + + const prev = useCallback(async () => { + await setCurrentStep(getPrevStep()); + }, [getPrevStep, setCurrentStep]); + + const value = useMemo( + () => ({ + registerStep, + unregisterStep, + currentStep, + start, + stop, + visible, + goToNext: next, + goToNth: nth, + goToPrev: prev, + isFirstStep, + isLastStep, + currentStepNumber, + totalStepsNumber, + }), + [ + registerStep, + unregisterStep, + currentStep, + start, + stop, + visible, + next, + nth, + prev, + isFirstStep, + isLastStep, + currentStepNumber, + totalStepsNumber, + ] + ); + + return ( + + <> + + {children} + + + ); +}; + +export const useCopilot = () => { + const value = useContext(CopilotContext); + if (value == null) { + throw new Error('You must wrap your app inside CopilotProvider'); + } + + return value; +}; diff --git a/src/components/Tour/hooks/useStateWithAwait.ts b/src/components/Tour/hooks/useStateWithAwait.ts new file mode 100644 index 00000000..2c0b12a7 --- /dev/null +++ b/src/components/Tour/hooks/useStateWithAwait.ts @@ -0,0 +1,31 @@ +import { noop } from "@rn-ui-lib/utils/common"; +import { useEffect, useRef, useState } from "react"; + +/** + * A hook like useState that allows you to use await the setter + */ +export const useStateWithAwait = ( + initialState: T +): [T, (newValue: T) => Promise] => { + const endPending = useRef(noop); + const newDesiredValue = useRef(initialState); + + const [state, setState] = useState(initialState); + + const setStateWithAwait = async (newState: T) => { + const pending = new Promise((resolve) => { + endPending.current = resolve; + }); + newDesiredValue.current = newState; + setState(newState); + await pending; + }; + + useEffect(() => { + if (state === newDesiredValue.current) { + endPending.current(); + } + }, [state]); + + return [state, setStateWithAwait]; +}; diff --git a/src/components/Tour/hooks/useStepsMap.ts b/src/components/Tour/hooks/useStepsMap.ts new file mode 100644 index 00000000..fbc33b3f --- /dev/null +++ b/src/components/Tour/hooks/useStepsMap.ts @@ -0,0 +1,94 @@ +import { useCallback, useMemo, useReducer, useState } from 'react'; +import { type Step, type StepsMap } from '../types'; + +type Action = + | { + type: 'register'; + step: Step; + } + | { + type: 'unregister'; + stepName: string; + }; + +export const useStepsMap = () => { + const [currentStep, setCurrentStepState] = useState(undefined); + const [steps, dispatch] = useReducer((state: StepsMap, action: Action) => { + switch (action.type) { + case 'register': + return { + ...state, + [action.step.name]: action.step, + }; + case 'unregister': { + const { [action.stepName]: _, ...rest } = state; + return rest; + } + default: + return state; + } + }, {}); + + const orderedSteps = useMemo( + () => Object.values(steps).sort((a, b) => a.order - b.order), + [steps] + ); + + const stepIndex = useCallback( + (step = currentStep) => + step ? orderedSteps.findIndex((stepCandidate) => stepCandidate.order === step.order) : -1, + [currentStep, orderedSteps] + ); + + const currentStepNumber = useMemo( + (step = currentStep) => stepIndex(step) + 1, + [currentStep, stepIndex] + ); + + const totalStepsNumber = useMemo(() => orderedSteps.length, [orderedSteps]); + + const getPrevStep = useCallback( + (step = currentStep) => step && orderedSteps[stepIndex(step) - 1], + [currentStep, stepIndex, orderedSteps] + ); + + const getNextStep = useCallback( + (step = currentStep) => step && orderedSteps[stepIndex(step) + 1], + [currentStep, stepIndex, orderedSteps] + ); + + const getNthStep = useCallback((n: number) => orderedSteps[n - 1], [orderedSteps]); + + const getFirstStep = useCallback(() => getNthStep(1), [orderedSteps]); + + const getLastStep = useCallback(() => getNthStep(orderedSteps.length), [orderedSteps]); + + const isFirstStep = useMemo(() => currentStep === getFirstStep(), [currentStep, getFirstStep]); + + const isLastStep = useMemo(() => currentStep === getLastStep(), [currentStep, getLastStep]); + + const registerStep = useCallback((step: Step) => { + dispatch({ type: 'register', step }); + }, []); + + const unregisterStep = useCallback((stepName: string) => { + dispatch({ type: 'unregister', stepName }); + }, []); + + return { + currentStepNumber, + totalStepsNumber, + getFirstStep, + getLastStep, + getPrevStep, + getNextStep, + getNthStep, + isFirstStep, + isLastStep, + currentStep, + setCurrentStepState, + steps, + registerStep, + unregisterStep, + }; +}; diff --git a/src/components/Tour/types.ts b/src/components/Tour/types.ts new file mode 100644 index 00000000..1a212a92 --- /dev/null +++ b/src/components/Tour/types.ts @@ -0,0 +1,114 @@ +import { ReactNode } from 'react'; +import type { Animated, LayoutRectangle, NativeMethods, ScrollView, ViewStyle } from 'react-native'; + +export type WalktroughedComponent = NativeMethods & React.ComponentType; + +export interface Step { + name: string; + order: number; + visible: boolean; + wrapperRef: React.RefObject; + measure: () => Promise; + text: ReactNode; + isCircularHighlight?: boolean; + noHighlightArea?: boolean; +} + +interface ValueXY { + x: number; + y: number; +} + +type SvgMaskPathFunction = (args: { + size: Animated.ValueXY; + position: Animated.ValueXY; + canvasSize: ValueXY; + step: Step; +}) => string; + +export type StepsMap = Record; + +export type EasingFunction = (value: number) => number; + +export type Labels = Partial>; + +export interface TooltipProps { + labels: Labels; +} + +export interface MaskProps { + size: ValueXY; + position: ValueXY; + style: ViewStyle; + easing?: EasingFunction; + animationDuration: number; + animated: boolean; + backdropColor: string; + svgMaskPath?: SvgMaskPathFunction; + layout: { + width: number; + height: number; + }; + onClick?: () => any; + currentStep: Step; +} + +export interface CopilotOptions { + easing?: ((value: number) => number) | undefined; + overlay?: 'svg' | 'view'; + animationDuration?: number; + tooltipComponent?: React.ComponentType; + tooltipStyle?: ViewStyle; + stepNumberComponent?: React.ComponentType; + animated?: boolean; + labels?: Labels; + androidStatusBarVisible?: boolean; + svgMaskPath?: SvgMaskPathFunction; + verticalOffset?: number; + arrowColor?: string; + arrowSize?: number; + margin?: number; + stopOnOutsideClick?: boolean; + backdropColor?: string; +} + +export interface CopilotContextType { + registerStep: (step: Step) => void; + unregisterStep: (stepName: string) => void; + currentStep: Step | undefined; + start: (fromStep?: string, suppliedScrollView?: ScrollView | null) => Promise; + stop: () => Promise; + goToNext: () => Promise; + goToNth: (n: number) => Promise; + goToPrev: () => Promise; + visible: boolean; + isFirstStep: boolean; + isLastStep: boolean; + currentStepNumber: number; + totalStepsNumber: number; +} + +export interface ITooltip { + maskRect: LayoutRectangle; + labels: Labels; +} + +export interface IAnimatedRenderMask { + maskRect: LayoutRectangle; +} + +export interface CopilotModalHandle { + animateMove: (obj: LayoutRectangle) => Promise; +} + +export interface CopilotStepProps { + name: string; + order: number; + text: ReactNode; + children: React.ReactElement; + active?: boolean; + width?: number; + height?: number; + isCircularHighlight?: boolean; + noHighlightArea?: boolean; +} diff --git a/src/components/form/Submit.tsx b/src/components/form/Submit.tsx index 6f5458e0..10df3b0f 100644 --- a/src/components/form/Submit.tsx +++ b/src/components/form/Submit.tsx @@ -16,7 +16,7 @@ import { GLOBAL } from '@constants/Global'; import { getItem } from '@components/utlis/storageHelper'; import { StorageKeys } from '@interfaces/storageKeys'; import { AgentActivity } from '@interfaces/agentActivity'; -import { setDeviceGeolocation } from '@reducers/foregroundServiceSlice'; +import { setDeviceGeolocation, setIsDeviceLocationEnabled } from '@reducers/foregroundServiceSlice'; import { GeoCoordinates } from 'react-native-geolocation-service'; interface ISubmit { @@ -50,73 +50,77 @@ const Submit: React.FC = (props) => { useEffect(() => { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.AV_FORM_SUMMARY_PAGE_LOADED, { journeyId: journey, - caseId - }); - CaptureGeolocation.fetchLocation(caseId).then(async (location: GeoCoordinates | undefined) => { - if (location) { - const isActiveOnApp: string | boolean = - (await getItem(StorageKeys.IS_USER_ACTIVE)) || false; - const userActivityonApp: string = - (await getItem(StorageKeys.USER_ACTIVITY_ON_APP)) || AgentActivity.LOW; - const geolocation: IGeolocationPayload = { - latitude: location?.latitude, - longitude: location?.longitude, - accuracy: location?.accuracy, - timestamp: Date.now(), - isActiveOnApp: Boolean(isActiveOnApp), - userActivityOnApp: String(userActivityonApp), - deviceId: GLOBAL.DEVICE_ID || (await getItem('deviceId')), - }; - dispatch(setDeviceGeolocation(geolocation)); - } - toast({ text1: 'Location captured successfully.', visibilityTime: 3000 }); + caseId, }); + CaptureGeolocation.fetchLocation(caseId) + .then(async (location: GeoCoordinates | undefined) => { + if (location) { + const isActiveOnApp: string | boolean = + (await getItem(StorageKeys.IS_USER_ACTIVE)) || false; + const userActivityonApp: string = + (await getItem(StorageKeys.USER_ACTIVITY_ON_APP)) || AgentActivity.LOW; + const geolocation: IGeolocationPayload = { + latitude: location?.latitude, + longitude: location?.longitude, + accuracy: location?.accuracy, + timestamp: Date.now(), + isActiveOnApp: Boolean(isActiveOnApp), + userActivityOnApp: String(userActivityonApp), + deviceId: GLOBAL.DEVICE_ID || (await getItem('deviceId')), + }; + dispatch(setDeviceGeolocation(geolocation)); + } + toast({ text1: 'Location captured successfully.', visibilityTime: 3000 }); + }) + .catch((err) => { + if (err === CaptureGeolocation.LOCATION_SERVICE_DISABLED) { + dispatch(setIsDeviceLocationEnabled(false)); + } + }); }, []); return ( - - - - {verifiedVisitedWidgets?.map((visited) => { - const sectionsArray = templateData?.widget?.[visited]?.sections; + + + {verifiedVisitedWidgets?.map((visited) => { + const sectionsArray = templateData?.widget?.[visited]?.sections; - return ( - - {sectionsArray?.map((section: string) => ( - - {sections?.[section]?.questions?.map((question: string) => { - const answer = - data?.widgetContext?.[visited]?.sectionContext?.[section]?.questionContext?.[ - question - ]; - return answer ? ( - - {questions[question].text} - - - - ) : ( - <> - ); - })} - - ))} - - ); - })} - - - + return ( + + {sectionsArray?.map((section: string) => ( + + {sections?.[section]?.questions?.map((question: string) => { + const answer = + data?.widgetContext?.[visited]?.sectionContext?.[section]?.questionContext?.[ + question + ]; + return answer ? ( + + {questions[question].text} + + + + ) : ( + <> + ); + })} + + ))} + + ); + })} + + ); }; diff --git a/src/components/form/components/AddressSelection.tsx b/src/components/form/components/AddressSelection.tsx index b4debdb4..8b4f8e36 100644 --- a/src/components/form/components/AddressSelection.tsx +++ b/src/components/form/components/AddressSelection.tsx @@ -1,5 +1,5 @@ import { TouchableOpacity, View } from 'react-native'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useAppDispatch, useAppSelector } from '../../../hooks'; import { CaseAllocationType } from '../../../screens/allCases/interface'; import { validateInput } from '../services/validation.service'; @@ -21,6 +21,7 @@ import LineLoader from '@rn-ui-lib/components/suspense_loader/LineLoader'; import { getAddressesAndGeolocations } from '@actions/caseApiActions'; import LoadingIcon from '@rn-ui-lib/icons/LoadingIcon'; import { COLORS } from '@rn-ui-lib/colors'; +import { IAddress } from '@interfaces/addressGeolocation.types'; interface IAddressSelection { questionType: string; @@ -36,6 +37,7 @@ interface IAddressSelection { export const VisitTypeSelection = { GEOLOCATION_SELECTION: 'GEOLOCATION_SELECTION', ADDRESS_SELECTION: 'ADDRESS_SELECTION', + SKIP_TRACING_ADDRESS_SELECTION: 'SKIP_TRACING_ADDRESS_SELECTION', }; export const AddressSourceMap: Record = { @@ -59,7 +61,12 @@ const AddressSelection: React.FC = (props) => { const ungroupedAddresses = useAppSelector( (state: RootState) => state.ungroupedAddresses[loanAccountNumber]?.ungroupedAddresses || [] ); - + const caseBusinessVertical = useAppSelector( + (state) => state?.allCases?.caseDetails?.[caseId]?.businessVertical + ); + const skipTracingAddresses = useAppSelector( + (state) => state?.skipTracingAddress?.[loanAccountNumber]?.skipTracingAddresses || [] + ); const groupedAndUngroupedAddresses = useMemo(() => { let addressesList = []; groupedAddresses.forEach((groupedAddress) => { @@ -97,13 +104,29 @@ const AddressSelection: React.FC = (props) => { }; const isGeolocation = question?.tag === VisitTypeSelection.GEOLOCATION_SELECTION; + const isSkipTracing = question?.tag === VisitTypeSelection.SKIP_TRACING_ADDRESS_SELECTION; - const addresses = (isGeolocation ? geoLocations : groupedAndUngroupedAddresses) || []; + const getAddresses = useCallback(() => { + if (isSkipTracing) { + return skipTracingAddresses || []; + } + if (isGeolocation) { + return geoLocations || []; + } + return groupedAndUngroupedAddresses || []; + }, [ + isSkipTracing, + isGeolocation, + skipTracingAddresses, + geoLocations, + groupedAndUngroupedAddresses, + ]); + const addresses = getAddresses(); const controllerName = `widgetContext.${widgetId}.sectionContext.${sectionId}.questionContext.${questionId}`; const reloadGeolocations = () => { - dispatch(getAddressesAndGeolocations(loanAccountNumber)); + dispatch(getAddressesAndGeolocations(loanAccountNumber, caseId, caseBusinessVertical)); }; if (isGeolocation) { diff --git a/src/components/form/components/GeolocationAddress.tsx b/src/components/form/components/GeolocationAddress.tsx index 5437a3de..07bcb8b3 100644 --- a/src/components/form/components/GeolocationAddress.tsx +++ b/src/components/form/components/GeolocationAddress.tsx @@ -1,5 +1,5 @@ import { Linking, StyleSheet, TouchableOpacity, View } from 'react-native'; -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { GeolocationSource, GeolocationSourceMap, @@ -31,6 +31,8 @@ import { IAddressFeedback } from '../../../reducer/addressSlice'; import Tag, { TagVariant } from '@rn-ui-lib/components/Tag'; import ArrowSolidIcon from '@rn-ui-lib/icons/ArrowSolidIcon'; import { CaseDetailStackEnum } from '@screens/caseDetails/CaseDetailStack'; +import { handlePostOperativeHourActivities } from '@screens/addressGeolocation/utils/operativeHourUtils'; +import { ToastMessages } from '@screens/allCases/constants'; interface IGeolocationAddress { address: IGeolocation; @@ -104,7 +106,9 @@ const GeolocationAddress: React.FC = ({ ); return { addressDate, addressTime, lastFeedbackTimestampDate }; }, [lastFeedbackForGeolocation, timestamp]); - + const addingNewFeedbackDisabled = useAppSelector( + (state) => state?.postOperationalHourRestrictionsSlice?.postOperationalHourRestrictions + ); const handleCloseRouting = () => handlePageRouting?.(CaseDetailStackEnum.ADDRESS_GEO); const handleAddFeedback = () => { @@ -181,7 +185,11 @@ const GeolocationAddress: React.FC = ({ }; const Container: React.ElementType = !isFeedbackView ? TouchableOpacity : View; - + const handleDisableAddFeedback = () => { + handlePostOperativeHourActivities( + ToastMessages.DISABLE_ADD_FEEDBACK_AFTER_POST_OPERATIVE_HOURS + ); + }; return ( @@ -297,10 +305,10 @@ const GeolocationAddress: React.FC = ({ {showAddFeedback ? ( - + Add feedback @@ -364,6 +372,13 @@ const styles = StyleSheet.create({ tagText: { lineHeight: 18, }, + disabledButton: { + fontSize: 13, + lineHeight: 20, + color: COLORS.TEXT.BLUE, + paddingVertical: 4, + opacity: 0.5 + }, }); export default GeolocationAddress; diff --git a/src/components/form/components/GeolocationAddressAnswer.tsx b/src/components/form/components/GeolocationAddressAnswer.tsx index d8f349d0..416d6f23 100644 --- a/src/components/form/components/GeolocationAddressAnswer.tsx +++ b/src/components/form/components/GeolocationAddressAnswer.tsx @@ -27,6 +27,9 @@ const GeolocationAddressAnswer: React.FC = ({ const ungroupedAddresses = useAppSelector( (state: RootState) => state.ungroupedAddresses[loanAccountNumber]?.ungroupedAddresses || [] ); + const skipTracingAddresses = useAppSelector( + (state: RootState) => state.skipTracingAddress[loanAccountNumber]?.skipTracingAddresses || [] + ); const groupedAndUngroupedAddresses = useMemo(() => { let addressesList = []; @@ -42,8 +45,12 @@ const GeolocationAddressAnswer: React.FC = ({ }, [groupedAddresses, ungroupedAddresses]); const isGeolocation = metadata?.type === VisitTypeSelection.GEOLOCATION_SELECTION; + const isSkipTracing = metadata?.type === VisitTypeSelection.SKIP_TRACING_ADDRESS_SELECTION; const getAddressFromId = memoize((id: string) => { + if (isSkipTracing) { + return getAddressString(skipTracingAddresses?.find((address) => address.id === id)); + } return getAddressString(groupedAndUngroupedAddresses?.find((address) => address.id === id)); }); diff --git a/src/components/form/components/TextInput.tsx b/src/components/form/components/TextInput.tsx index 032d5767..4dd1a0dc 100644 --- a/src/components/form/components/TextInput.tsx +++ b/src/components/form/components/TextInput.tsx @@ -1,5 +1,5 @@ -import { StyleSheet, View } from 'react-native'; -import React from 'react'; +import { Keyboard, StyleSheet, View } from 'react-native'; +import React, { useEffect } from 'react'; import RNTextInput from '../../../../RN-UI-LIB/src/components/TextInput'; import { GenericStyles } from '../../../../RN-UI-LIB/src/styles'; import { Control, Controller } from 'react-hook-form'; @@ -32,6 +32,15 @@ const TextInput: React.FC = (props) => { return null; } + useEffect(() => { + const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { + Keyboard.dismiss(); + }); + return () => { + hideSubscription.remove(); + }; + }, []); + const handleChange = (text: string, onChange: (...event: any[]) => void) => { let cleanedText = text; if (question.metadata.keyboardType === 'decimal-pad') { @@ -61,13 +70,14 @@ const TextInput: React.FC = (props) => { validateInput(data, question.metadata.validators) }} - render={({ field: { onChange, value } }) => ( + render={({ field: { onChange, onBlur, value } }) => ( handleChange(text, onChange)} value={value?.answer || ''} containerStyle={[GenericStyles.mt12]} + onBlur={onBlur} placeholder={'Enter here'} inputContainerStyle={styles.inputContainerStyle} maxLength={question.metadata.validators?.phoneNumber?.value as number} diff --git a/src/components/form/index.tsx b/src/components/form/index.tsx index a8e46e27..00b78ae5 100644 --- a/src/components/form/index.tsx +++ b/src/components/form/index.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { ScrollView, StyleSheet, View } from 'react-native'; import Geolocation from 'react-native-geolocation-service'; @@ -41,9 +41,11 @@ import NavigationHeader, { Icon } from '../../../RN-UI-LIB/src/components/Naviga import { CaseDetailStackEnum } from '@screens/caseDetails/CaseDetailStack'; import { useNavigation, useRoute } from '@react-navigation/native'; import { NUDGE_BOTTOM_SHEET_DEFAULT_STATE } from './constants'; -import {useBackHandler} from "@hooks/useBackHandler"; +import { useBackHandler } from '@hooks/useBackHandler'; import { CALLING_NUDGE } from '@screens/caseDetails/CallingFlow/constants'; import { isFunction } from '@components/utlis/commonFunctions'; +import { getTopFeedbacks } from '@actions/feedbackActions'; +import { setIsDeviceLocationEnabled } from '@reducers/foregroundServiceSlice'; interface IWidget { route: { @@ -197,7 +199,7 @@ const Widget: React.FC = (props) => { journey: journey, caseId, handleCloseRouting, - from + from, }); }; @@ -217,7 +219,7 @@ const Widget: React.FC = (props) => { const onSuccessfulSubmit = (data: any, interactionId: string, nextActions?: any) => { setIsSubmitting(false); setNudgeBottomSheetDetails(NUDGE_BOTTOM_SHEET_DEFAULT_STATE); - + dispatch(setDocumentsToUpload({ caseId, caseKey: caseKey.current })); if (from === CALLING_NUDGE && isFunction(handleCloseRouting)) { handleCloseRouting(); } else { @@ -226,6 +228,7 @@ const Widget: React.FC = (props) => { caseId, }); } + dispatch(getTopFeedbacks(caseId)); dispatch( deleteJourney({ caseId, @@ -257,12 +260,17 @@ const Widget: React.FC = (props) => { widgetId: name, } ); - fetchLocation().then((location) => { + fetchLocation() + .then((location) => { if (location) { return handleSubmitJourney(data, location); } - }).catch((err) => { + }) + .catch((err) => { setIsSubmitting(false); + if (err === CaptureGeolocation.LOCATION_SERVICE_DISABLED) { + dispatch(setIsDeviceLocationEnabled(false)); + } }); }; @@ -279,6 +287,13 @@ const Widget: React.FC = (props) => { suspiciousFeedbackMessage: errObj?.errorCode, }); } + if (errObj?.statusCode === API_STATUS_CODE.POST_OPERATIVE_HOURS_ACTIVITY) { + toast({ + type: 'error', + text1: ToastMessages.POST_OPERATIVE_HOURS_ACTIVITY, + }); + navigateToScreen(CaseDetailStackEnum.COLLECTION_CASE_DETAIL, { caseId }); + } }; const handleSubmitJourney = async (data: any, coords: Geolocation.GeoCoordinates) => { @@ -293,7 +308,6 @@ const Widget: React.FC = (props) => { ); caseKey.current = `${caseId}_${Date.now()}`; - dispatch(setDocumentsToUpload({ caseId, caseKey: caseKey.current })); const updatedCase = getUpdatedCollectionCaseDetail({ caseData, answer: data, @@ -307,12 +321,12 @@ const Widget: React.FC = (props) => { unSyncedCase, nudgeBottomSheetDetails?.showNudgeBottomSheet ); - if(!transformedPayload?.data?.answers) { + if (!transformedPayload?.data?.answers) { toast({ type: 'error', text1: ToastMessages.FEEDBACK_IMAGE_NOT_FOUND, }); - onErrorSubmit({}, transformedPayload) + onErrorSubmit({}, transformedPayload); return; } dispatch( @@ -411,39 +425,36 @@ const Widget: React.FC = (props) => { icon={Icon.close} onBack={handleCloseIconPress} /> - - {name === CommonCaseWidgetId.END ? ( - - ) : ( - <> - {sections?.map((section: any, index: number) => { - return ( - - - - ); - })} - - )} - + {name === CommonCaseWidgetId.END ? ( + + ) : ( + + {sections?.map((section: any, index: number) => { + return ( + + + + ); + })} + + )} { return new Promise(async (resolve, reject) => { + const isLocationServiceEnabled = await locationEnabled(); + if (!isLocationServiceEnabled) { + reject(this.LOCATION_SERVICE_DISABLED); + return; + } const cachedLocation = CaptureGeolocation.capturedLocation?.[resourceId]; if (cachedLocation && Date.now() - (cachedLocation?.location?.timestamp || 0) < cacheTTL) { return resolve(cachedLocation?.location?.coords); @@ -139,21 +146,28 @@ export class CaptureGeolocation { } export const captureLatestDeviceLocation = (caseId: string) => (dispatch: AppDispatch) => { - CaptureGeolocation.fetchLocation(caseId).then(async (location: GeoCoordinates | undefined) => { - if (location) { - const isActiveOnApp: string | boolean = (await getItem(StorageKeys.IS_USER_ACTIVE)) || false; - const userActivityonApp: string = - (await getItem(StorageKeys.USER_ACTIVITY_ON_APP)) || AgentActivity.LOW; - const geolocation: IGeolocationPayload = { - latitude: location?.latitude, - longitude: location?.longitude, - accuracy: location?.accuracy, - timestamp: Date.now(), - isActiveOnApp: Boolean(isActiveOnApp), - userActivityOnApp: String(userActivityonApp), - deviceId: GLOBAL.DEVICE_ID || (await getItem('deviceId')), - }; - dispatch(setDeviceGeolocation(geolocation)); - } - }); + CaptureGeolocation.fetchLocation(caseId) + .then(async (location: GeoCoordinates | undefined) => { + if (location) { + const isActiveOnApp: string | boolean = + (await getItem(StorageKeys.IS_USER_ACTIVE)) || false; + const userActivityonApp: string = + (await getItem(StorageKeys.USER_ACTIVITY_ON_APP)) || AgentActivity.LOW; + const geolocation: IGeolocationPayload = { + latitude: location?.latitude, + longitude: location?.longitude, + accuracy: location?.accuracy, + timestamp: Date.now(), + isActiveOnApp: Boolean(isActiveOnApp), + userActivityOnApp: String(userActivityonApp), + deviceId: GLOBAL.DEVICE_ID || (await getItem('deviceId')), + }; + dispatch(setDeviceGeolocation(geolocation)); + } + }) + .catch((err) => { + if (err === CaptureGeolocation.LOCATION_SERVICE_DISABLED) { + dispatch(setIsDeviceLocationEnabled(false)); + } + }); }; diff --git a/src/components/pdfRenderer/PdfRenderer.tsx b/src/components/pdfRenderer/PdfRenderer.tsx new file mode 100644 index 00000000..551b755e --- /dev/null +++ b/src/components/pdfRenderer/PdfRenderer.tsx @@ -0,0 +1,92 @@ +import { COLORS } from '@rn-ui-lib/colors'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, SafeAreaView, StyleSheet } from 'react-native'; +import PdfRendererView from 'react-native-pdf-renderer'; +import { IPdfRenderer } from './interfaces'; +import RNFetchBlob from 'react-native-blob-util'; +import SuspenseLoader from '@rn-ui-lib/components/suspense_loader/SuspenseLoader'; +import Text from '@rn-ui-lib/components/Text'; +import { isFunction } from '@components/utlis/commonFunctions'; +import { getFileType } from '@screens/caseDetails/PDFUtil'; +import { DocumentContentType } from '@screens/caseDetails/interface'; + +const ERROR_STATE = 'ERROR'; + +const PdfRenderer: React.FC = ({ docId, onPageChange, getFileUri }) => { + const [pdfFilePath, setPdfFilePath] = useState(''); + const isLoading = !pdfFilePath; + const error = pdfFilePath === ERROR_STATE; + + const checkIfFileExists = async () => { + const cacheDirectory = RNFetchBlob.fs.dirs.CacheDir; + const cacheFilePath = `${cacheDirectory}/${docId}.pdf`; + const doesFileExist = await RNFetchBlob.fs.exists(cacheFilePath); + return { doesFileExist, cacheFilePath }; + }; + + const saveFileToCache = async () => { + const { doesFileExist, cacheFilePath } = await checkIfFileExists(); + if (doesFileExist) { + setPdfFilePath(cacheFilePath); + return; + } + const url = await getFileUri(); + if (!url) { + setPdfFilePath(ERROR_STATE); + return; + } + const highQualityResponse = await RNFetchBlob.fetch('GET', url); + if (highQualityResponse.respInfo.status !== 200) { + setPdfFilePath(ERROR_STATE); + } else if (highQualityResponse.respInfo.status === 200) { + const highQualityImageBase64 = await highQualityResponse.base64(); + const documentType = await getFileType(highQualityImageBase64); + if (documentType !== DocumentContentType.PDF) { + setPdfFilePath(ERROR_STATE); + return; + } + await RNFetchBlob.fs.writeFile(cacheFilePath, highQualityImageBase64, 'base64'); + const exists = await RNFetchBlob.fs.exists(cacheFilePath); + if (exists) { + setPdfFilePath(cacheFilePath); + } + } + }; + + useEffect(() => { + saveFileToCache(); + }, []); + + const handlePageChange = (pageNumber: number) => { + if (isNaN(pageNumber) || !isFunction(onPageChange)) return; + onPageChange(pageNumber + 1); + }; + + return ( + + }> + {error ? ( + Failed to load PDF + ) : ( + + )} + + + ); +}; + +export const styles = StyleSheet.create({ + pdf: { + flex: 1, + backgroundColor: COLORS.BACKGROUND.GREY_LIGHT_2, + }, +}); + +export default PdfRenderer; diff --git a/src/components/pdfRenderer/interfaces.ts b/src/components/pdfRenderer/interfaces.ts new file mode 100644 index 00000000..abe34bf0 --- /dev/null +++ b/src/components/pdfRenderer/interfaces.ts @@ -0,0 +1,5 @@ +export interface IPdfRenderer { + docId: string; + getFileUri: () => Promise; + onPageChange?: (pageNumber: number) => void; +} diff --git a/src/components/screens/allCases/allCasesFilters/FilterOptions.tsx b/src/components/screens/allCases/allCasesFilters/FilterOptions.tsx index 0e3dd6b8..b69f60ee 100644 --- a/src/components/screens/allCases/allCasesFilters/FilterOptions.tsx +++ b/src/components/screens/allCases/allCasesFilters/FilterOptions.tsx @@ -2,14 +2,14 @@ import * as React from 'react'; import { Search } from '../../../../../RN-UI-LIB/src/utlis/search'; import { SELECTION_TYPES } from '../../../../common/Constants'; import { ScrollView, View } from 'react-native'; -import CheckboxGroup from '../../../../../RN-UI-LIB/src/components/chechbox/CheckboxGroup'; import RadioGroup from '../../../../../RN-UI-LIB/src/components/radio_button/RadioGroup'; import RadioButton from '../../../../../RN-UI-LIB/src/components/radio_button/RadioButton'; import { IFilterOptionsProps } from './Interface'; import { useSelector } from 'react-redux'; import { RootState } from '../../../../store/store'; import styles from './styles'; -import {getKeys, getOptions} from "@components/screens/allCases/allCasesFilters/FilterUtils"; +import { getKeys, getOptions } from '@components/screens/allCases/allCasesFilters/FilterUtils'; +import MultiSelectFilter from './MultiSelectFilter'; const FilterOptions: React.FC = ({ selectedFilterKey, @@ -29,8 +29,11 @@ const FilterOptions: React.FC = ({ filterSearchString.length > 0 ? Search( filterSearchString, - getOptions(filters[selectedFilterKey.filterGroup]?.filters[selectedFilterKey.filterKey].options, selectedFilterKey.filterKey) || - [], + getOptions( + filters[selectedFilterKey.filterGroup]?.filters[selectedFilterKey.filterKey].options, + selectedFilterKey.filterKey, + !!filterSearchString.length + ) || [], { keys: getKeys(selectedFilterKey.filterKey) } ).map((option) => option.obj) : getOptions(filters[selectedFilterKey.filterGroup]?.filters[selectedFilterKey.filterKey].options, selectedFilterKey.filterKey); @@ -40,15 +43,12 @@ const FilterOptions: React.FC = ({ ) { case SELECTION_TYPES.MULTIPLE: return ( - - - - - + ); case SELECTION_TYPES.SINGLE: return ( @@ -60,7 +60,7 @@ const FilterOptions: React.FC = ({ onValueChange={handleFilterSelection} > {options?.map((option) => ( - + ))} diff --git a/src/components/screens/allCases/allCasesFilters/FilterUtils.ts b/src/components/screens/allCases/allCasesFilters/FilterUtils.ts index 276ecc60..835693b8 100644 --- a/src/components/screens/allCases/allCasesFilters/FilterUtils.ts +++ b/src/components/screens/allCases/allCasesFilters/FilterUtils.ts @@ -7,14 +7,15 @@ import { } from '../../../../screens/allCases/interface'; import { CONDITIONAL_OPERATORS, - FILTER_TYPES, Option, + FILTER_TYPES, + IFilterOption, + Option, RANGE_FILTER_SEPARATOR, - SELECTION_TYPES, SubLabelOption, + SELECTION_TYPES, } from '../../../../common/Constants'; import { getObjectValueFromKeys } from '../../../utlis/parsers'; import { _map } from '../../../../../RN-UI-LIB/src/utlis/common'; -import { pluralise } from "@utils/commonFunctions"; -import { FilterKeys, OptionTypes } from "@components/screens/allCases/allCasesFilters/types"; +import { FilterKeys, OptionTypes } from '@components/screens/allCases/allCasesFilters/types'; export const evaluateFilterForCases = ( caseRecord: CaseDetail, @@ -193,11 +194,25 @@ const populateSubLabelsAndSort = (options: Option[], subLabelKey: string) => opt } }); -export const getOptions = (options: Option[], filterKey: string) => { +const flattenOptions = (options: IFilterOption[], parentOption = {} as IFilterOption) => { + return options.reduce((acc: IFilterOption[], option) => { + if (option.subOptions && option.subOptions.length > 0) { + acc = acc.concat(flattenOptions(option.subOptions, option)); // Sending option as parentOption + } else { + acc.push({ ...option, parentOption }); + } + return acc; + }, []); +}; + +export const getOptions = (options: Option[], filterKey: string, isSearchString = false) => { if (filterKey === FilterKeys.PIN_CODE) { return populateSubLabelsAndSort(options, OptionTypes.LOCALITY); } + if (isSearchString) { + return flattenOptions(options); + } return options; } diff --git a/src/components/screens/allCases/allCasesFilters/FiltersContainer.tsx b/src/components/screens/allCases/allCasesFilters/FiltersContainer.tsx index d4322f01..12d74368 100644 --- a/src/components/screens/allCases/allCasesFilters/FiltersContainer.tsx +++ b/src/components/screens/allCases/allCasesFilters/FiltersContainer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { ScrollView, TouchableOpacity, View } from 'react-native'; import { GenericStyles } from '../../../../../RN-UI-LIB/src/styles'; import Heading from '../../../../../RN-UI-LIB/src/components/Heading'; @@ -20,13 +20,23 @@ import FilterOptions from './FilterOptions'; import { toast } from '../../../../../RN-UI-LIB/src/components/toast'; import { ToastMessages } from '../../../../screens/allCases/constants'; import { getSelectedFilters } from '../../../../screens/Dashboard/utils'; +import { CopilotStep } from '@components/Tour/components/CopilotStep'; +import { useCopilot } from '@components/Tour/contexts/CopilotProvider'; +import { ENABLE_COACHMARK } from './constants'; +import { CoachMarkFeatures, showCoachMark } from '@actions/filterActions'; const FiltersContainer: React.FC = (props) => { const { closeFilterModal, isVisitPlan, isAgentDashboard } = props; const filters = useAppSelector((state: RootState) => state.filters.filters); + const userId = useAppSelector((state: RootState) => state.user?.user?.referenceId); + const serverTimestamp = useAppSelector( + (state: RootState) => state.foregroundService.serverTimestamp + ); + const copilot = useCopilot(); const selectedFilters = useAppSelector((state: RootState) => getSelectedFilters(state, isAgentDashboard, isVisitPlan) ); + const onLayoutHandled = useRef(false); const [selectedFiltersMap, setSelectedFiltersMap] = React.useState>(selectedFilters); const filterGroupKeys = Object.keys(filters); @@ -98,6 +108,14 @@ const FiltersContainer: React.FC = (props) => { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.AV_FILTERS_CLEAR_CLICKED); }; + const startCoachMark = async () => { + if (onLayoutHandled.current) return; + if (userId && copilot.totalStepsNumber > 0) { + onLayoutHandled.current = true; + showCoachMark(CoachMarkFeatures.CASE_STATUS_FILTERS, userId, serverTimestamp, copilot.start); + } + }; + useEffect(() => { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.AV_FILTERS_PAGE_LOAD, { filters: filterKeys, @@ -117,7 +135,7 @@ const FiltersContainer: React.FC = (props) => { }; return ( - + = (props) => { {filterGroupKeys.map((filterGroupKey) => ( - <> + {filterGroupKeys.length > 1 && ( = (props) => { )} - {filterKeys[filterGroupKey].map((filterKey) => + {filterKeys[filterGroupKey].map((filterKey: string) => filters[filterGroupKey].filters[filterKey]?.visible !== false ? ( = (props) => { }); }} > - - {filters[filterGroupKey].filters[filterKey].displayText} - - {selectedFiltersMap[filterKey] && ( - - {typeof selectedFiltersMap[filterKey] === 'object' - ? Object.keys(selectedFiltersMap[filterKey]).length - : 1} + {filters[filterGroupKey].filters[filterKey].displayText} + + ) : ( + + {filters[filterGroupKey].filters[filterKey].displayText} + + )} + {selectedFiltersMap[filterKey] && ( + + + + {typeof selectedFiltersMap[filterKey] === 'object' + ? Object.keys(selectedFiltersMap[filterKey]).length + : 1} + + )} ) : null )} - + ))} @@ -227,6 +269,7 @@ const FiltersContainer: React.FC = (props) => { } placeholder="Search..." + value={filterSearchString} defaultValue={filterSearchString} onChangeText={handleOptionFilterChange} testID="test_search" diff --git a/src/components/screens/allCases/allCasesFilters/Interface.ts b/src/components/screens/allCases/allCasesFilters/Interface.ts index 57010d0b..144a7c6d 100644 --- a/src/components/screens/allCases/allCasesFilters/Interface.ts +++ b/src/components/screens/allCases/allCasesFilters/Interface.ts @@ -1,3 +1,5 @@ +import { IFilterOption } from "@common/Constants"; + export interface FilterContainerProps { closeFilterModal: () => void; isVisitPlan?: boolean; @@ -16,3 +18,16 @@ export interface IFilterOptionsProps { handleFilterSelection: (filterValues: any) => void; selectedFiltersMap: Record; } + +export interface IMultiSelectFilter { + options: IFilterOption[]; + selectedFilterKey: ISelectedFilterKey; + handleFilterSelection: (filterValues: any) => void; + selectedFiltersMap: Record; +} + +export interface ICoachMark { + key: string; + order: number; + description: string; +} \ No newline at end of file diff --git a/src/components/screens/allCases/allCasesFilters/MultiSelectFilter.tsx b/src/components/screens/allCases/allCasesFilters/MultiSelectFilter.tsx new file mode 100644 index 00000000..2167f4f5 --- /dev/null +++ b/src/components/screens/allCases/allCasesFilters/MultiSelectFilter.tsx @@ -0,0 +1,32 @@ +import CheckboxGroup from '@rn-ui-lib/components/chechbox/CheckboxGroup'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { IMultiSelectFilter } from './Interface'; +import { ScrollView } from 'react-native'; +import { COLORS } from '@rn-ui-lib/colors'; +import { GenericStyles } from '@rn-ui-lib/styles'; + +const MultiSelectFilter = (props: IMultiSelectFilter) => { + const { options, handleFilterSelection, selectedFiltersMap, selectedFilterKey } = props; + return ( + + {options?.map((option) => ( + + + + ))} + + ); +}; +export default MultiSelectFilter; diff --git a/src/components/screens/allCases/allCasesFilters/constants.tsx b/src/components/screens/allCases/allCasesFilters/constants.tsx new file mode 100644 index 00000000..f24b3be6 --- /dev/null +++ b/src/components/screens/allCases/allCasesFilters/constants.tsx @@ -0,0 +1,14 @@ +import { ICoachMark } from './Interface'; + +export const ENABLE_COACHMARK: Record = { + CURRENT_MONTH_INTERACTION_STATUS: { + key: 'CURRENT_MONTH_INTERACTION_STATUS', + order: 1, + description: '👆 You can find the priority feedback of current month in Current Month Status', + }, + LAST_MONTH_INTERACTION_STATUS: { + key: 'LAST_MONTH_INTERACTION_STATUS', + order: 2, + description: "👆 You can find the priority feedback of last month in Last Month Status", + }, +}; diff --git a/src/components/screens/allCases/allCasesFilters/styles.ts b/src/components/screens/allCases/allCasesFilters/styles.ts index bc491b24..64fe845e 100644 --- a/src/components/screens/allCases/allCasesFilters/styles.ts +++ b/src/components/screens/allCases/allCasesFilters/styles.ts @@ -53,15 +53,17 @@ const styles = StyleSheet.create({ backgroundColor: COLORS.BACKGROUND.SILVER, borderColor: COLORS.BORDER.PRIMARY, borderWidth: 1, - height: PixelRatio.roundToNearestPixel(25), - width: PixelRatio.roundToNearestPixel(25), + height: 20, + width: 20, borderRadius: 20, alignItems: 'center', }, filterCountSelected: { backgroundColor: COLORS.TEXT.BLUE, - height: PixelRatio.roundToNearestPixel(25), - width: PixelRatio.roundToNearestPixel(25), + borderColor: COLORS.BORDER.LIGHT_BLUE, + borderWidth: 1, + height: 20, + width: 20, borderRadius: 20, alignItems: 'center', }, @@ -74,6 +76,10 @@ const styles = StyleSheet.create({ paddingVertical: 8, flexShrink: 1, }, + selectedCount: { + color: COLORS.TEXT.WHITE, + lineHeight: 20, + }, }); export default styles; diff --git a/src/components/screens/allCases/allCasesFilters/types.ts b/src/components/screens/allCases/allCasesFilters/types.ts index 31bda42d..82dd9b46 100644 --- a/src/components/screens/allCases/allCasesFilters/types.ts +++ b/src/components/screens/allCases/allCasesFilters/types.ts @@ -4,5 +4,6 @@ export enum OptionTypes { } export enum FilterKeys { - PIN_CODE = 'PINCODE' + PIN_CODE = 'PINCODE', + FEEDBACK = 'FEEDBACK' } diff --git a/src/components/utlis/DeviceUtils.ts b/src/components/utlis/DeviceUtils.ts index 850c353b..ef2149ba 100644 --- a/src/components/utlis/DeviceUtils.ts +++ b/src/components/utlis/DeviceUtils.ts @@ -5,7 +5,9 @@ interface CrashError { screenName: string; } -const { DeviceUtilsModule } = NativeModules; // this is the same name we returned in getName function. +const { DeviceUtilsModule, RNRestart } = NativeModules; // this is the same name we returned in getName function. + +export const restartJSBundle = () => RNRestart.restart(); // returns true if enabled, and false if disabled. export const locationEnabled = (): Promise => DeviceUtilsModule.isLocationEnabled(); diff --git a/src/components/utlis/addressGeolocationUtils.ts b/src/components/utlis/addressGeolocationUtils.ts index b78b4e2b..a71adbcf 100644 --- a/src/components/utlis/addressGeolocationUtils.ts +++ b/src/components/utlis/addressGeolocationUtils.ts @@ -17,3 +17,17 @@ export const getCollectionFeedbackOnAddressPreDefinedJourney = ( logError(err as Error); } }; + +export const getCollectionFeedbackOnSkipTracingPreDefinedJourney = ( + templateJson: IPredefinedAddressScreenTemplate, + key: string, + value: string +) => { + try { + const templateStr = JSON.stringify(templateJson.skipTracingAddressTemplate); + const updatedStr = templateStr.replace(key, value); + return JSON.parse(updatedStr); + } catch (err) { + logError(err as Error); + } +}; diff --git a/src/components/utlis/apiHelper.ts b/src/components/utlis/apiHelper.ts index cf07b27a..eccca5e9 100644 --- a/src/components/utlis/apiHelper.ts +++ b/src/components/utlis/apiHelper.ts @@ -112,6 +112,9 @@ export enum ApiKeys { GET_EMI_SCHEDULE = 'GET_EMI_SCHEDULE', GET_REPAYMENTS = 'GET_REPAYMENTS', GET_FEEDBACK_HISTORY = 'GET_FEEDBACK_HISTORY', + GET_PRIORTIY_FEEDBACK = 'GET_PRIORTIY_FEEDBACK', + GET_TRAINING_MATERIAL_LIST = 'GET_TRAINING_MATERIAL_LIST', + GET_TRAINING_MATERIAL_DETAILS = 'GET_TRAINING_MATERIAL_DETAILS', } export const API_URLS: Record = {} as Record; @@ -164,8 +167,7 @@ API_URLS[ApiKeys.GET_PERFORMANCE_METRICS] = '/allocation-cycle/agent-performance API_URLS[ApiKeys.GET_CASH_COLLECTED] = '/allocation-cycle/cash-collected-split'; API_URLS[ApiKeys.GET_TELEPHONE_NUMBERS] = '/v2/collection-cases/telephones-view/{loanAccountNumber}'; -API_URLS[ApiKeys.GET_TELEPHONE_NUMBERS_V2] = - '/collections/{loanAccountNumber}/telephones-agent-call-activity-view'; +API_URLS[ApiKeys.GET_TELEPHONE_NUMBERS_V2] = '/collections/telephones-agent-call-activity-view'; API_URLS[ApiKeys.FIRESTORE_INCONSISTENCY_INFO] = '/cases/sync-status'; API_URLS[ApiKeys.FIRESTORE_INCONSISTENCY_INFO_V2] = '/cases/v2/sync-status'; API_URLS[ApiKeys.GET_CASE_DETAILS_FROM_API] = @@ -197,10 +199,9 @@ API_URLS[ApiKeys.FEE_WAIVER_HISTORY] = '/collection-cases/{loanAccountNumber}/wa API_URLS[ApiKeys.FEE_WAIVER_V2] = '/loan/request/{loanAccountNumber}/adjust-component/v2'; API_URLS[ApiKeys.GET_PIN_CODES_DETAILS] = '/api/v1/pincodes/{pinCode}'; API_URLS[ApiKeys.SYNC_COSMOS_TO_LONGHORN] = '/sync/tele-cosmos-sync'; -API_URLS[ApiKeys.CALL_CUSTOMER] = - '/call-recording/call-request/{loanAccountNumber}/{telephoneReferenceId}'; +API_URLS[ApiKeys.CALL_CUSTOMER] = '/call-recording/v2/call-request'; API_URLS[ApiKeys.SYNC_ACTIVE_CALL_DETAILS] = '/call-recording/call-status'; -API_URLS[ApiKeys.GET_CALL_HISTORY] = '/call-recording/call-history/{loanAccountNumber}'; +API_URLS[ApiKeys.GET_CALL_HISTORY] = '/call-recording/v2/call-history'; API_URLS[ApiKeys.SYNC_CALL_FEEDBACK_NUDGE_DETAILS] = '/call-recording/acknowledge-feedback-nudge/{callId}'; @@ -211,13 +212,15 @@ API_URLS[ApiKeys.SEND_COMMUNICATION_NAVI_ACCOUNT] = '/navi-communications/{loanA API_URLS[ApiKeys.GENERATE_DYNAMIC_DOCUMENT] = '/documents/generate/{loanAccountNumber}'; API_URLS[ApiKeys.DOWNLOAD_LATEST_APP] = 'https://longhorn.navi.com/api/app/download'; API_URLS[ApiKeys.ALL_ESCALATIONS] = '/customer-escalation'; -API_URLS[ApiKeys.GET_UNGROUPED_ADDRESSES] = - '/collection-cases/{loanAccountNumber}/ungrouped/addresses'; +API_URLS[ApiKeys.GET_UNGROUPED_ADDRESSES] = '/collection-cases/ungrouped/addresses'; API_URLS[ApiKeys.GET_GROUPED_ADDRESSES_AND_GEOLOCATIONS] = - '/collection-cases/{loanAccountNumber}/grouped/addresses-geo-locations'; -API_URLS[ApiKeys.GET_EMI_SCHEDULE] = '/collection-cases/{loanAccountNumber}/emiSchedule'; -API_URLS[ApiKeys.GET_REPAYMENTS] = '/collection-cases/{loanAccountNumber}/repayments'; + '/collection-cases/grouped/addresses-geo-locations'; +API_URLS[ApiKeys.GET_EMI_SCHEDULE] = '/collection-cases/emiSchedule'; +API_URLS[ApiKeys.GET_REPAYMENTS] = '/collection-cases/repayments'; API_URLS[ApiKeys.GET_FEEDBACK_HISTORY] = '/feedback/filters'; +API_URLS[ApiKeys.GET_PRIORTIY_FEEDBACK] = '/feedback/case-status'; +API_URLS[ApiKeys.GET_TRAINING_MATERIAL_LIST] = '/training-page/content-list'; +API_URLS[ApiKeys.GET_TRAINING_MATERIAL_DETAILS] = '/training-page/{docRefId}'; export const API_STATUS_CODE = { OK: 200, @@ -230,8 +233,11 @@ export const API_STATUS_CODE = { INTERNAL_SERVER_ERROR: 500, TOO_MANY_REQUESTS: 429, GONE: 410, + POST_OPERATIVE_HOURS_ACTIVITY: 451, }; +export const UNAUTHORIZED_VALUES = [API_STATUS_CODE.UNAUTHORIZED, API_STATUS_CODE.FORBIDDEN]; + const API_TIMEOUT_INTERVAL = 2e4; // 20s let dispatch: Dispatch; @@ -351,8 +357,10 @@ axiosInstance.interceptors.response.use( Number ); if ( - config?.headers?.donotHandleError || - donotHandleErrorOnStatusCode.includes(error?.response?.status) + (config?.headers?.donotHandleError || + donotHandleErrorOnStatusCode.includes(error?.response?.status)) && + // Logout even donotHandleError is true when status code is 401, 403 + !config?.headers?.autoLogoutOnUnauthorized ) { return Promise.reject(error); } diff --git a/src/components/webViewVideoPlayer/WebViewVideoPlayer.tsx b/src/components/webViewVideoPlayer/WebViewVideoPlayer.tsx new file mode 100644 index 00000000..8eec3121 --- /dev/null +++ b/src/components/webViewVideoPlayer/WebViewVideoPlayer.tsx @@ -0,0 +1,49 @@ +import React, { useEffect } from 'react'; +import WebView from 'react-native-webview'; +import { IWebViewVideoPlayer } from './interfaces'; +import { videoPlayerHTML } from './constants'; +import SuspenseLoader from '@rn-ui-lib/components/suspense_loader/SuspenseLoader'; +import { ActivityIndicator } from 'react-native'; +import { COLORS } from '@rn-ui-lib/colors'; +import Text from '@rn-ui-lib/components/Text'; + +const ERROR_STATE = 'ERROR'; + +const WebViewVideoPlayer: React.FC = ({ getVideoUrl }) => { + const [url, setUrl] = React.useState(''); + const isLoading = !url; + const error = url === ERROR_STATE; + + const fetchVideoUrl = async () => { + const url = await getVideoUrl(); + if (!url) { + setUrl(ERROR_STATE); + return; + } + setUrl(url); + }; + + useEffect(() => { + fetchVideoUrl(); + }, []); + + return ( + } + loading={isLoading} + > + {error ? ( + Not able to load the video + ) : ( + + )} + + ); +}; + +export default WebViewVideoPlayer; diff --git a/src/components/webViewVideoPlayer/constants.ts b/src/components/webViewVideoPlayer/constants.ts new file mode 100644 index 00000000..b9cfe5f1 --- /dev/null +++ b/src/components/webViewVideoPlayer/constants.ts @@ -0,0 +1,11 @@ +export const videoPlayerHTML = (url: string) => ` + + + + + + +`; diff --git a/src/components/webViewVideoPlayer/interfaces.ts b/src/components/webViewVideoPlayer/interfaces.ts new file mode 100644 index 00000000..c77a4f17 --- /dev/null +++ b/src/components/webViewVideoPlayer/interfaces.ts @@ -0,0 +1,3 @@ +export interface IWebViewVideoPlayer { + getVideoUrl: () => Promise; +} \ No newline at end of file diff --git a/src/hooks/capturingApi.ts b/src/hooks/capturingApi.ts index 15369213..7243f3a9 100644 --- a/src/hooks/capturingApi.ts +++ b/src/hooks/capturingApi.ts @@ -1,8 +1,8 @@ import axiosInstance, { ApiKeys, getApiUrl } from '../components/utlis/apiHelper'; import { AppDispatch, AppGetState } from '../store/store'; import { - clearDeviceGeolocationsBuffer, setDeviceGeolocationsBuffer, + setIsDeviceLocationEnabled, } from '../reducer/foregroundServiceSlice'; import { logError } from '../components/utlis/errorUtils'; import { CaptureGeolocation } from '@components/form/services/geoLocation.service'; @@ -28,6 +28,7 @@ export const sendLocationAndActivenessToServerV2 = .post(getApiUrl(ApiKeys.SEND_LOCATION), geolocationBuffer, { headers: { donotHandleError: 'true', + autoLogoutOnUnauthorized: true, }, }) .then(() => { @@ -78,13 +79,18 @@ export const sendCurrentGeolocationAndBuffer = dispatch(sendLocationAndActivenessToServerV2(allGeolocations)); } catch (e) { logError(e as Error, 'Error during background location sending.'); + if (e === CaptureGeolocation.LOCATION_SERVICE_DISABLED) { + dispatch(setIsDeviceLocationEnabled(false)); + } } }; export const getSyncTime = async () => { try { const url = getApiUrl(ApiKeys.SYNC_TIME); - const response = await axiosInstance.get(url, { headers: { donotHandleError: true } }); + const response = await axiosInstance.get(url, { + headers: { donotHandleError: true, autoLogoutOnUnauthorized: true }, + }); return response?.data?.currentTimestamp; } catch (error) { console.log(error); diff --git a/src/hooks/useFCM/notificationHelperFunctions.ts b/src/hooks/useFCM/notificationHelperFunctions.ts index bbe0c246..d31c2489 100644 --- a/src/hooks/useFCM/notificationHelperFunctions.ts +++ b/src/hooks/useFCM/notificationHelperFunctions.ts @@ -54,7 +54,9 @@ export enum PushNotificationTypes { AGENT_REVIVAL_PERFORMANCE_LEVEL_MAY_DROP_REMINDER_NOTIFICATION = 'AGENT_REVIVAL_PERFORMANCE_LEVEL_MAY_DROP_REMINDER_NOTIFICATION', AGENT_REVIVAL_PERFORMANCE_LEVEL_INCREASED_REMINDER_NOTIFICATION = 'AGENT_REVIVAL_PERFORMANCE_LEVEL_INCREASED_REMINDER_NOTIFICATION', AGENT_REVIVAL_PERFORMANCE_LEVEL_MAY_INCREASED_REMINDER_NOTIFICATION = 'AGENT_REVIVAL_PERFORMANCE_LEVEL_MAY_INCREASED_REMINDER_NOTIFICATION', - CUSTOMER_TRIED_CALLING_NOTIFICATION = 'CUSTOMER_TRIED_CALLING_NOTIFICATION' + CUSTOMER_TRIED_CALLING_NOTIFICATION = 'CUSTOMER_TRIED_CALLING_NOTIFICATION', + ANOMALY_TRACKER_DETECTION='ANOMALY_TRACKER_DETECTION_V2', + ANOMALY_TRACKER_RESOLUTION='ANOMALY_TRACKER_RESOLUTION', } type NotificationContent = (notification: INotification) => { @@ -421,6 +423,39 @@ const getCustomerTriedCallingNotificationContent = (notification: INotification) return { title, body, actions, data, defaultPressAction }; } + +// Anomaly detected notification content +const getAnomalyDetectedNotificationContent = (notification: INotification) => { + const { params } = notification || {}; + const { anomalySubType } = params || {}; + const title = 'Issue raised on you'; + const body = `${anomalySubType?.replace(/\[.*?\]/g, '')}`; + const defaultPressAction = actionContentMap[NotificationAction.DEFAULT].pressAction; + const actions = [] as AndroidAction[]; + const data = { + templateId: notification?.template?.id, + notificationId: notification?.id, + deepLinks: {}, + }; + return { title, body, actions, data, defaultPressAction }; +}; + +// Anomaly resolved notification content +const getAnomalyResolvedNotificationContent = (notification: INotification) => { + const { params } = notification || {}; + const { anomalySubType } = params || {}; + const title = 'Your`s issue resolved'; + const body = `${anomalySubType?.replace(/\[.*?\]/g, '')}`; + const defaultPressAction = actionContentMap[NotificationAction.DEFAULT].pressAction; + const actions = [] as AndroidAction[]; + const data = { + templateId: notification?.template?.id, + notificationId: notification?.id, + deepLinks: {}, + }; + return { title, body, actions, data, defaultPressAction }; +}; + // Map of notification templates to notification content export const notificationContentMap: Record = { [PushNotificationTypes.PAYMENT_MADE_TEMPLATE_V2]: getPaymentMadeNotificationContent, @@ -439,6 +474,8 @@ export const notificationContentMap: Record = { [PushNotificationTypes.AGENT_REVIVAL_PERFORMANCE_LEVEL_INCREASED_REMINDER_NOTIFICATION]: getAgentRevivalPerformanceLevelIncreasedReminderNotificationContent, [PushNotificationTypes.AGENT_REVIVAL_PERFORMANCE_LEVEL_MAY_INCREASED_REMINDER_NOTIFICATION]: getAgentRevivalPerformanceLevelMayIncreasedReminderNotificationContent, [PushNotificationTypes.CUSTOMER_TRIED_CALLING_NOTIFICATION]: getCustomerTriedCallingNotificationContent, + [PushNotificationTypes.ANOMALY_TRACKER_DETECTION]: getAnomalyDetectedNotificationContent, + [PushNotificationTypes.ANOMALY_TRACKER_RESOLUTION]: getAnomalyResolvedNotificationContent, }; const notificationDismissed = (notificationEvent: Event, isBgHandler = false) => { diff --git a/src/hooks/useFirestoreUpdates.ts b/src/hooks/useFirestoreUpdates.ts index 2ea49c32..d2fd2a41 100644 --- a/src/hooks/useFirestoreUpdates.ts +++ b/src/hooks/useFirestoreUpdates.ts @@ -1,24 +1,22 @@ import { useEffect, useRef } from 'react'; -import firestore, { type FirebaseFirestoreTypes } from '@react-native-firebase/firestore'; +import { type FirebaseFirestoreTypes } from '@react-native-firebase/firestore'; import auth from '@react-native-firebase/auth'; import { setFeedbackFilterTemplate } from '@reducers/feedbackFiltersSlice'; import { InteractionManager } from 'react-native'; import { useAppDispatch, useAppSelector } from '.'; -import { setLoading, updateCaseDetailsFirestore } from '../reducer/allCasesSlice'; +import { setLoading } from '../reducer/allCasesSlice'; import { type CaseDetail } from '../screens/caseDetails/interface'; -import { CLICKSTREAM_EVENT_NAMES, FirestoreUpdateTypes } from '../common/Constants'; import { toast } from '../../RN-UI-LIB/src/components/toast'; import { updateCollectionTemplateData } from '../reducer/caseReducer'; import { type ILockData, MY_CASE_ITEM, setLockData, VisitPlanStatus } from '../reducer/userSlice'; import { setFilters } from '../reducer/filtersSlice'; import { ToastMessages } from '../screens/allCases/constants'; import { setCurrentProdAPK } from '../reducer/metadataSlice'; -import { logError } from '../components/utlis/errorUtils'; -import { type GenericFunctionArgs } from '../common/GenericTypes'; -import { addClickstreamEvent } from '@services/clickstreamEventService'; import { setCsaFilters } from '@reducers/cosmosSupportSlice'; import { setActiveCallData, setCallingFeedbackNudgeBottomSheet } from '@reducers/activeCallSlice'; import { FEEDBACK_NUDGE_STATUS } from '@screens/caseDetails/CallingFlow/interfaces'; +import { updateCases } from '@screens/caseDetails/utils/caseDetailsUtils'; +import { firestoreService } from '@services/firestoreService'; export interface CaseUpdates { updateType: string; @@ -31,13 +29,12 @@ export const loggedOutCurrentUser = async () => { } }; +const { subscribeToDoc, subscribeToCollection, unsubscribeAll } = firestoreService; + const isUserSignedIn = () => !!auth().currentUser; const useFirestoreUpdates = () => { const isTeamLead = useAppSelector((state) => state.user.isTeamLead); - const caseDetails = useAppSelector((state) => state.allCases.caseDetails); - const casesList = useAppSelector((state) => state.allCases.casesList); - const user = useAppSelector((state) => state.user.user); const isLoggedIn = useAppSelector((state) => state.user.isLoggedIn); const sessionDetails = useAppSelector((state) => state.user.sessionDetails); @@ -50,82 +47,17 @@ const useFirestoreUpdates = () => { useEffect(() => { lockRef.current = lock; }, [lock]); - - let casesUnsubscribe: GenericFunctionArgs; - let collectionTemplateUnsubscribe: GenericFunctionArgs; - let filterUnsubscribe: GenericFunctionArgs; - let lockUnsubscribe: GenericFunctionArgs; - let appUpdateUnsubscribe: GenericFunctionArgs; - let feedbackFiltersUnsubscribe: GenericFunctionArgs; - let csaFiltersUnsubscribe: GenericFunctionArgs; - let activeCallDetailsUnsubscribe: GenericFunctionArgs; const dispatch = useAppDispatch(); - const showCaseUpdationToast = (newlyAddedCases: number, deletedCases: number) => { - let toastConfig: any = null; - const addedCasesText = newlyAddedCases - ? `${newlyAddedCases} new case${newlyAddedCases > 1 ? 's' : ''} allocated` - : ''; - const deletedCasesText = deletedCases - ? `${deletedCases} case${deletedCases > 1 ? 's' : ''} de-allocated` - : ''; - if (newlyAddedCases && deletedCases) { - toastConfig = { - type: 'info', - text1: `${addedCasesText} & ${deletedCasesText}`, - }; - } else if (newlyAddedCases) { - toastConfig = { type: 'success', text1: addedCasesText }; - } else if (deletedCases) { - toastConfig = { type: 'error', text1: deletedCasesText }; - } - if (toastConfig) { - toast(toastConfig); - } - }; - const handleCasesUpdate = async (querySnapshot: FirebaseFirestoreTypes.QuerySnapshot) => { - let newlyAddedCases = 0; - let deletedCases = 0; - const caseUpdates: CaseUpdates[] = []; - querySnapshot - .docChanges() - .forEach((documentSnapshot: FirebaseFirestoreTypes.DocumentChange) => { - InteractionManager.runAfterInteractions(() => { - const updateType = documentSnapshot.type; - const updatedCaseDetail = documentSnapshot.doc.data() as CaseDetail; - if (updateType === FirestoreUpdateTypes.ADDED) { - if (!caseDetails[updatedCaseDetail.id]) { - newlyAddedCases++; - caseUpdates.push({ updateType, updatedCaseDetail }); - } - } else { - if (updateType === FirestoreUpdateTypes.REMOVED) { - deletedCases++; - } - caseUpdates.push({ updateType, updatedCaseDetail }); - } - }); - }); - const isInitialLoad = casesList.length === 0; - InteractionManager.runAfterInteractions(() => { - requestAnimationFrame(() => { - InteractionManager.runAfterInteractions(() => { - dispatch( - updateCaseDetailsFirestore({ - caseUpdates, - isInitialLoad, - isVisitPlanLocked: lockRef?.current?.visitPlanStatus === VisitPlanStatus.LOCKED, - selectedAgent, - }) - ); - }); - }); - }); - !isInitialLoad && showCaseUpdationToast(newlyAddedCases, deletedCases); - addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_SNAPSHOT_LISTENER, { - snapshot_path: 'handleCasesUpdate', - }); + const isVisitPlanLocked = lockRef?.current?.visitPlanStatus === VisitPlanStatus.LOCKED; + dispatch( + updateCases({ + selectedAgent, + isVisitPlanLocked, + querySnapshot, + }) + ); }; const handleCollectionTemplateUpdate = ( @@ -166,17 +98,12 @@ const useFirestoreUpdates = () => { snapshot: FirebaseFirestoreTypes.DocumentSnapshot ) => { const activeCallDetails = snapshot.data(); - if(activeCallDetails?.feedbackNudgeStatus === FEEDBACK_NUDGE_STATUS.PENDING) { + if (activeCallDetails?.feedbackNudgeStatus === FEEDBACK_NUDGE_STATUS.PENDING) { dispatch(setCallingFeedbackNudgeBottomSheet(true)); } dispatch(setActiveCallData(activeCallDetails)); }; - const handleError = (err: any, collectionPath?: string) => { - const errMsg = `Error while fetching fireStore snapshot: referenceId: ${user?.referenceId} collectionPath: ${collectionPath}`; - logError(err as Error, errMsg); - }; - const handleAppUpdate = ( snapshot: FirebaseFirestoreTypes.DocumentSnapshot ) => { @@ -184,107 +111,51 @@ const useFirestoreUpdates = () => { dispatch(setCurrentProdAPK(configData)); }; - const subscribeToAppUpdate = () => { - const collectionPath = 'app-state/app-update'; - return subscribeToDoc(handleAppUpdate, collectionPath); - }; - const signInUserToFirebase = () => { if (!sessionDetails) { + dispatch(setLoading(false)); return; } - auth() - .signInWithCustomToken(sessionDetails?.firebaseToken) - .then((userCredential) => { - addFirestoreListeners(); - }) - .catch((error) => { + firestoreService.signInUserToFirebase( + sessionDetails.firebaseToken, + subscribeToFirestore, + () => { dispatch(setLoading(false)); - logError(error as Error, 'Error in signInUserToFirebase'); toast({ type: 'error', text1: ToastMessages.FIRESTORE_SIGNIN_FAILED, }); - }); - }; - - const subscribeToDoc = (successCb: GenericFunctionArgs, collectionPath: string) => - firestore() - .doc(collectionPath) - .onSnapshot( - async (data) => { - successCb(data); - }, - (err) => { - handleError(err, collectionPath); - } - ); - - const subscribeToCases = () => { - let refId = user?.referenceId; - if (isTeamLead && selectedAgent?.referenceId !== MY_CASE_ITEM.referenceId) { - refId = selectedAgent?.referenceId; - } - const collectionPath = `allocations/${refId}/cases`; - return firestore() - .collection(collectionPath) - .orderBy('totalOverdueAmount', 'asc') // It is descending order only, but acting weirdly. Need to check. - .onSnapshot(handleCasesUpdate, (err) => { - handleError(err, collectionPath); - }); - }; - - const subscribeToCollectionTemplate = () => { - const collectionPath = `template/${isExternalAgent ? 'external' : 'inhouse'}_template`; - return subscribeToDoc(handleCollectionTemplateUpdate, collectionPath); - }; - - const subscribeToFilters = () => { - let refId = user?.referenceId; - if (isTeamLead && selectedAgent?.referenceId !== MY_CASE_ITEM.referenceId) { - refId = selectedAgent?.referenceId; - } - const collectionPath = `filters/${refId}`; - - return subscribeToDoc(handleFilterUpdate, collectionPath); - }; - - const subscribeToFeedbackFilters = () => { - const feedbackFiltersPath = `feedback-filters/v2`; - return subscribeToDoc(handleFeedbackFilters, feedbackFiltersPath); - }; - - const subscribeToLocks = () => { - const lockPath = `locks/${user?.referenceId}`; - return subscribeToDoc(handleLockUpdate, lockPath); - }; - - const subscribeToCsaFilters = () => { - const collectionPath = 'global-filters/v1'; - return subscribeToDoc(handleCsaFilters, collectionPath); - }; - - const subscribeToActiveCallDetailsUnsubscribe = () => { - const refId = user?.referenceId; - const collectionPath = `allocations/${refId}/agentActivity/callDetails`; - return subscribeToDoc(handleActiveCallDetails, collectionPath); + } + ); }; const subscribeToFirestore = () => { - addFirestoreListeners(); + let refId = user?.referenceId; + if (isTeamLead && selectedAgent?.referenceId !== MY_CASE_ITEM.referenceId) { + refId = selectedAgent?.referenceId; + } + InteractionManager.runAfterInteractions(() => { + subscribeToCollection( + `allocations/${refId}/cases`, + (ref) => ref.orderBy('totalOverdueAmount', 'asc'), + handleCasesUpdate + ); + subscribeToDoc(`filters/${refId}`, handleFilterUpdate); + subscribeToDoc( + `template/${isExternalAgent ? 'external' : 'inhouse'}_template`, + handleCollectionTemplateUpdate + ); + subscribeToDoc(`locks/${user?.referenceId}`, handleLockUpdate); + subscribeToDoc(`feedback-filters/v2`, handleFeedbackFilters); + subscribeToDoc('global-filters/v1', handleCsaFilters); + subscribeToDoc( + `allocations/${user?.referenceId}/agentActivity/callDetails`, + handleActiveCallDetails + ); + subscribeToDoc('app-state/app-update', handleAppUpdate); + }); }; - function addFirestoreListeners() { - casesUnsubscribe = subscribeToCases(); - filterUnsubscribe = subscribeToFilters(); - collectionTemplateUnsubscribe = subscribeToCollectionTemplate(); - lockUnsubscribe = subscribeToLocks(); - appUpdateUnsubscribe = subscribeToAppUpdate(); - feedbackFiltersUnsubscribe = subscribeToFeedbackFilters(); - csaFiltersUnsubscribe = subscribeToCsaFilters(); - activeCallDetailsUnsubscribe = subscribeToActiveCallDetailsUnsubscribe(); - } - useEffect(() => { if (!user?.referenceId) { return; @@ -294,48 +165,12 @@ const useFirestoreUpdates = () => { return; } if (isUserSignedIn()) { - InteractionManager.runAfterInteractions(() => { - subscribeToFirestore(); - }); + subscribeToFirestore(); } else { dispatch(setLoading(true)); signInUserToFirebase(); } - return () => { - casesUnsubscribe && casesUnsubscribe(); - filterUnsubscribe && filterUnsubscribe(); - collectionTemplateUnsubscribe && collectionTemplateUnsubscribe(); - lockUnsubscribe && lockUnsubscribe(); - feedbackFiltersUnsubscribe && feedbackFiltersUnsubscribe(); - csaFiltersUnsubscribe && csaFiltersUnsubscribe(); - activeCallDetailsUnsubscribe && activeCallDetailsUnsubscribe(); - appUpdateUnsubscribe && appUpdateUnsubscribe(); - }; - }, [isLoggedIn, user?.referenceId, isExternalAgent]); - - useEffect(() => { - if (!isTeamLead) { - return; - } - if (!selectedAgent?.referenceId) { - return; - } - if (!isLoggedIn || !sessionDetails?.firebaseToken) { - loggedOutCurrentUser(); - return; - } - // unsubscribe from previous agent's cases - casesUnsubscribe && casesUnsubscribe(); - filterUnsubscribe && filterUnsubscribe(); - dispatch(setLoading(true)); - // subscribe to new agent's cases - subscribeToCases(); - subscribeToFilters(); - return () => { - casesUnsubscribe && casesUnsubscribe(); - filterUnsubscribe && filterUnsubscribe(); - }; - }, [selectedAgent, isTeamLead]); + }, [isLoggedIn, user?.referenceId, isExternalAgent, selectedAgent]); }; export default useFirestoreUpdates; diff --git a/src/hooks/useIsLocationEnabled.ts b/src/hooks/useIsLocationEnabled.ts index 20fcd01a..b9222564 100644 --- a/src/hooks/useIsLocationEnabled.ts +++ b/src/hooks/useIsLocationEnabled.ts @@ -1,17 +1,14 @@ import { useAppDispatch, useAppSelector } from '.'; import { setIsDeviceLocationEnabled } from '../reducer/foregroundServiceSlice'; -import usePolling from './usePolling'; import { locationEnabled } from '../components/utlis/DeviceUtils'; import { logError } from '../components/utlis/errorUtils'; -import { MILLISECONDS_IN_A_SECOND } from '../../RN-UI-LIB/src/utlis/common'; +import { useEffect } from 'react'; +import { AppState } from 'react-native'; +import { AppStates } from '@interfaces/appStates'; +import { AppDispatch } from '@store'; -const CHECK_DEVICE_LOCATION_INTERVAL = 2 * MILLISECONDS_IN_A_SECOND ; - -const useIsLocationEnabled = () => { - const { isDeviceLocationEnabled } = useAppSelector((state) => state.foregroundService); - const dispatch = useAppDispatch(); - - const checkLocationEnabled = async () => { +export const checkLocationEnabled = + (isDeviceLocationEnabled: boolean) => async (dispatch: AppDispatch) => { try { const isLocationEnabled = await locationEnabled(); if (!isDeviceLocationEnabled && isLocationEnabled) { @@ -27,7 +24,20 @@ const useIsLocationEnabled = () => { } }; - usePolling(checkLocationEnabled, CHECK_DEVICE_LOCATION_INTERVAL); +const useIsLocationEnabled = () => { + const { isDeviceLocationEnabled } = useAppSelector((state) => state.foregroundService); + const dispatch = useAppDispatch(); + + useEffect(() => { + const appStateChange = AppState.addEventListener('change', async (change) => { + if (change === AppStates.ACTIVE) { + dispatch(checkLocationEnabled(isDeviceLocationEnabled)); + } + }); + return () => { + appStateChange.remove(); + }; + }, [isDeviceLocationEnabled]); return isDeviceLocationEnabled; }; diff --git a/src/hooks/useMobileNumbers.ts b/src/hooks/useMobileNumbers.ts index 3554f8d5..6793df5b 100644 --- a/src/hooks/useMobileNumbers.ts +++ b/src/hooks/useMobileNumbers.ts @@ -8,16 +8,17 @@ const useMobileNumbers = (caseId: string) => { ); const mobileNumbers = useAppSelector((state) => state?.telephoneNumbers?.telephoneNumbers?.[caseId]) || []; + const caseBusinessVertical = useAppSelector((state) => state?.allCases?.caseDetails?.[caseId]?.businessVertical); const [loading, setLoading] = useState(false); const dispatch = useAppDispatch(); useEffect(() => { if (!caseId || !loanAccountNumber) return; - dispatch(fetchTelephoneNumber({ loanAccountNumber, caseId, setLoading })); + dispatch(fetchTelephoneNumber({ caseId, caseBusinessVertical, loanAccountNumber, setLoading})); }, []); const refetchMobileNumbers = (setRefreshing?: (val: boolean) => void) => { - dispatch(fetchTelephoneNumber({ loanAccountNumber, caseId, setLoading: setRefreshing })); + dispatch(fetchTelephoneNumber({ caseId, caseBusinessVertical, loanAccountNumber, setLoading: setRefreshing })); }; return { loading, mobileNumbers, refetchMobileNumbers }; diff --git a/src/hooks/useResyncFirebase.ts b/src/hooks/useResyncFirebase.ts index 8b71e999..b68c5c63 100644 --- a/src/hooks/useResyncFirebase.ts +++ b/src/hooks/useResyncFirebase.ts @@ -1,20 +1,20 @@ import firestore from '@react-native-firebase/firestore'; -import { useAppDispatch, useAppSelector } from '@hooks'; -import store, { type RootState } from '@store'; -import { updateCaseDetailsFirestore } from '@reducers/allCasesSlice'; +import { useAppDispatch } from '@hooks'; +import store from '@store'; import { CLICKSTREAM_EVENT_NAMES, FirestoreUpdateTypes, SyncedSource } from '@common/Constants'; import axiosInstance, { ApiKeys, getApiUrl } from '@utils/apiHelper'; import { getSyncCaseIds } from '@utils/firebaseFallbackUtils'; import { logError } from '@utils/errorUtils'; import { addClickstreamEvent } from '@services/clickstreamEventService'; -import { GenericObject } from '@common/GenericTypes'; import { setLastFirebaseResyncTimestamp } from '@reducers/metadataSlice'; import dayJs from 'dayjs'; -import { - getEnableFirestoreResync, - getFirestoreResyncIntervalInMinutes, -} from '@common/AgentActivityConfigurableConstants'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { updateCases } from '@screens/caseDetails/utils/caseDetailsUtils'; +import { CaseAllocationType } from '@screens/allCases/interface'; +import { CaseDetail } from '@screens/caseDetails/interface'; +import { CaseUpdates } from './useFirestoreUpdates'; +import { getFirestoreResyncIntervalInMinutes } from '@common/AgentActivityConfigurableConstants'; +import chunk from 'lodash/chunk'; const selectedAgentReferenceIDForMyCases = 'MY_CASES'; @@ -25,9 +25,9 @@ type CasesToFetchPayload = { updatedCaseIds: string[]; }; }; + const useResyncFirebase = () => { const dispatch = useAppDispatch(); - const refId = store?.getState()?.user?.user?.referenceId || ''; const selectedAgent = store?.getState()?.user?.selectedAgent; const selectedAgentRefId = store?.getState()?.user?.selectedAgent?.referenceId || ''; @@ -46,36 +46,141 @@ const useResyncFirebase = () => { }); }; - const updateCaseInRedux = ( - updateType: FirestoreUpdateTypes, - caseDetails: GenericObject, - selectedAgent: GenericObject + const fetchFirestoreCases = async ( + caseIds: string[], + casesPath: string, + updateType = FirestoreUpdateTypes.ADDED ) => { + const CHUNK_SIZE = 10; // Firestore "in" filter supports up to 10 elements + const firebaseAllocatedCases: CaseUpdates[] = []; + + // Split caseIds into chunks + const caseIdChunks = chunk(caseIds, CHUNK_SIZE); + + // Query Firestore for each chunk + for (const caseIdChunk of caseIdChunks) { + try { + const caseDocs = await firestore() + .collection(casesPath) + .where('caseReferenceId', 'in', caseIdChunk) + .get(); + + caseDocs?.forEach((doc) => { + const firebaseCase = doc.data(); + if (!firebaseCase?.caseReferenceId) { + return; + } + + firebaseAllocatedCases.push({ + updateType, + updatedCaseDetail: firebaseCase, + }); + + void addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FIREBASE_RESYNC, { + [updateType === FirestoreUpdateTypes.ADDED + ? FirestoreUpdateTypes.ADDED + : FirestoreUpdateTypes.MODIFIED]: firebaseCase.caseReferenceId, + syncedSource: SyncedSource.FIREBASE, + }); + }); + } catch (err) { + logError(err as Error, 'Error fetching cases from Firestore chunk'); + } + } + + // If firebase case is not found, fetch from API + const firebaseCaseIdsSet = new Set( + firebaseAllocatedCases.map((firebaseCase) => firebaseCase.updatedCaseDetail.caseReferenceId) + ); + + const caseIdsToFetch = caseIds.filter((caseId) => !firebaseCaseIdsSet.has(caseId)); + + // Fetch remaining cases from API + for (const caseId of caseIdsToFetch) { + try { + const res = await _getCaseDetailsFromApi(caseId); + const caseDetails = res?.data; + firebaseAllocatedCases.push({ + updateType, + updatedCaseDetail: caseDetails, + }); + void addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FIREBASE_RESYNC, { + updated: caseId, + syncedSource: SyncedSource.API, + }); + } catch (err) { + logError(err as Error, 'Error fetching cases from API'); + } + } + + return firebaseAllocatedCases; + }; + + const addCasesToRedux = async (allocatedCases: string[], casesPath: string) => { + if (!allocatedCases.length) { + return; + } + const firebaseAllocatedCases = await fetchFirestoreCases(allocatedCases, casesPath); dispatch( - updateCaseDetailsFirestore({ - caseUpdates: [ - { - updateType: updateType, - updatedCaseDetail: caseDetails, - }, - ], - isInitialLoad: false, - isVisitPlanLocked: false, + updateCases({ selectedAgent, + caseUpdateList: firebaseAllocatedCases, + }) + ); + }; + + const removeCasesFromRedux = (unallocatedCases: string[]) => { + if (!unallocatedCases.length) { + return; + } + const unallocatedCasesUpdates: CaseUpdates[] = []; + unallocatedCases.forEach((caseId: string) => { + if (!caseId) { + return null; + } + unallocatedCasesUpdates.push({ + updateType: FirestoreUpdateTypes.REMOVED, + updatedCaseDetail: { + caseType: CaseAllocationType.COLLECTION_CASE, + caseReferenceId: caseId, + id: '', + pinRank: null, + caseViewCreatedAt: 0, + } as CaseDetail, + }); + void addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FIREBASE_RESYNC, { deleted: caseId }); + }); + dispatch( + updateCases({ + selectedAgent, + caseUpdateList: unallocatedCasesUpdates, + }) + ); + }; + + const modifyCasesInRedux = async (updatedCases: string[], casesPath: string) => { + if (!updatedCases.length) { + return; + } + const firebaseUpdatedCases = await fetchFirestoreCases( + updatedCases, + casesPath, + FirestoreUpdateTypes.MODIFIED + ); + dispatch( + updateCases({ + selectedAgent, + caseUpdateList: firebaseUpdatedCases, }) ); }; return async (): Promise => { - console.log('firebase resync called'); const now = dayJs().toString(); const FIRST_DATE = new Date(1970, 1, 1); const lastFirebaseResyncTimestamp = (await AsyncStorage.getItem('lastFirebaseResyncTimestamp')) || dayJs(FIRST_DATE).toString(); const minutesSinceLastResync = dayJs(now).diff(dayJs(lastFirebaseResyncTimestamp), 'minutes'); - if (!getEnableFirestoreResync()) { - return; - } if (minutesSinceLastResync < getFirestoreResyncIntervalInMinutes()) { return; } @@ -93,117 +198,16 @@ const useResyncFirebase = () => { } ); - const allocatedCases = casesToFetch?.data?.allocatedCaseIds; - const unallocatedCases = casesToFetch?.data?.deallocatedCaseIds; - const updatedCases = casesToFetch?.data?.updatedCaseIds; + const allocatedCases = casesToFetch?.data?.allocatedCaseIds || []; + const unallocatedCases = casesToFetch?.data?.deallocatedCaseIds || []; + const updatedCases = casesToFetch?.data?.updatedCaseIds || []; - allocatedCases.forEach((caseId: string) => { - if (!caseId) { - return null; - } - firestore() - .collection(casesPath) - .doc(caseId.toString()) - .get({ source: 'server' }) - .then((res) => { - const firebaseCase = res?.data() || {}; - if (!firebaseCase?.caseReferenceId) { - throw new Error('could not find case in firebase'); - } - dispatch( - updateCaseDetailsFirestore({ - caseUpdates: [ - { - updateType: FirestoreUpdateTypes.ADDED, - updatedCaseDetail: firebaseCase, - }, - ], - isInitialLoad: false, - isVisitPlanLocked: false, - selectedAgent, - }) - ); - void addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FIREBASE_RESYNC, { - added: caseId, - syncedSource: SyncedSource.FIREBASE, - }); - }) - .catch((err) => { - logError(err as Error, 'Error fetching cases from firestore'); - void _getCaseDetailsFromApi(caseId).then((res: { data: GenericObject }) => { - const caseDetails = res?.data; - updateCaseInRedux(FirestoreUpdateTypes.ADDED, caseDetails, selectedAgent); - void addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FIREBASE_RESYNC, { - updated: caseId, - syncedSource: SyncedSource.API, - }); - }); - }); - }); - - unallocatedCases.forEach((caseId: string) => { - if (!caseId) { - return null; - } - dispatch( - updateCaseDetailsFirestore({ - caseUpdates: [ - { - updateType: FirestoreUpdateTypes.REMOVED, - updatedCaseDetail: { - caseType: '', - caseReferenceId: caseId, - id: '', - pinRank: '', - caseViewCreatedAt: '', - }, - }, - ], - isInitialLoad: false, - isVisitPlanLocked: false, - selectedAgent, - }) - ); - void addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FIREBASE_RESYNC, { deleted: caseId }); - }); - - updatedCases.forEach((caseId: string) => { - if (!caseId) { - return null; - } - firestore() - .collection(casesPath) - .doc(caseId.toString()) - .get({ source: 'server' }) - .then((res) => { - const firebaseCase = res?.data() || {}; - - if (!firebaseCase?.caseReferenceId) { - throw new Error('could not find case in firebase'); - } - updateCaseInRedux(FirestoreUpdateTypes.MODIFIED, firebaseCase, selectedAgent); - void addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FIREBASE_RESYNC, { - updated: caseId, - syncedSource: SyncedSource.FIREBASE, - }); - }) - .catch((err) => { - void _getCaseDetailsFromApi(caseId).then((res: { data: GenericObject }) => { - const caseDetails = res?.data || {}; - updateCaseInRedux(FirestoreUpdateTypes.MODIFIED, caseDetails, selectedAgent); - void addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_FIREBASE_RESYNC, { - updated: caseId, - syncedSource: SyncedSource.API, - }); - }); - logError(err as Error, 'Error fetching cases from firestore'); - console.log('cases err:', err); - }); - }); + addCasesToRedux(allocatedCases, casesPath); + removeCasesFromRedux(unallocatedCases); + modifyCasesInRedux(updatedCases, casesPath); dispatch(setLastFirebaseResyncTimestamp(dayJs().toString())); await AsyncStorage.setItem('lastFirebaseResyncTimestamp', dayJs().toString()); - await Promise.resolve(); }; }; diff --git a/src/reducer/allCasesSlice.ts b/src/reducer/allCasesSlice.ts index dd4e68f7..5675949f 100644 --- a/src/reducer/allCasesSlice.ts +++ b/src/reducer/allCasesSlice.ts @@ -1,49 +1,32 @@ import { createSlice } from '@reduxjs/toolkit'; -import { toast } from '../../RN-UI-LIB/src/components/toast'; import { _map } from '../../RN-UI-LIB/src/utlis/common'; -import { - findDocumentByDocumentType, - getLoanAccountNumber, -} from '../components/utlis/commonFunctions'; -import { CLICKSTREAM_EVENT_NAMES, FirestoreUpdateTypes } from '../common/Constants'; -import { getCurrentScreen, navigateToScreen } from '../components/utlis/navigationUtlis'; -import { type CaseUpdates } from '../hooks/useFirestoreUpdates'; +import { CLICKSTREAM_EVENT_NAMES } from '../common/Constants'; +import { navigateToScreen } from '../components/utlis/navigationUtlis'; import { COMPLETED_STATUSES } from '../screens/allCases/constants'; - -import { CaseAllocationType, type ICaseItem, type IReportee } from '../screens/allCases/interface'; -import { - type CaseDetail, - DOCUMENT_TYPE, - type IGeolocation, -} from '../screens/caseDetails/interface'; +import { CaseAllocationType, type ICaseItem } from '../screens/allCases/interface'; +import { type CaseDetail } from '../screens/caseDetails/interface'; import { addClickstreamEvent } from '../services/clickstreamEventService'; import { getVisitedWidgetsNodeList } from '../components/form/services/forms.service'; import { CollectionCaseWidgetId, CommonCaseWidgetId } from '../types/template.types'; import { type IAvatarUri } from '../action/caseListAction'; -import { MY_CASE_ITEM } from './userSlice'; export type ICasesMap = Record; interface IAllCasesSlice { casesList: ICaseItem[]; - casesListMap: ICasesMap; intermediateTodoList: ICaseItem[]; intermediateTodoListMap: ICasesMap; selectedTodoListMap: ICasesMap; selectedTodoListCount: number; - initialPinnedRankCount: number; pinnedRankCount: number; loading: boolean; newlyPinnedCases: number; - completedCases: number; caseDetails: Record; searchQuery: string; visitPlansUpdating: boolean; pendingList: ICaseItem[]; completedList: ICaseItem[]; pinnedList: ICaseItem[]; - newVisitedCases: string[]; - geolocations?: IGeolocation[]; selectedCaseId: string; allCasesViewSearchQuery: string; visitPlanSearchQuery: string; @@ -51,45 +34,24 @@ interface IAllCasesSlice { const initialState: IAllCasesSlice = { casesList: [], - casesListMap: {}, intermediateTodoList: [], intermediateTodoListMap: {}, selectedTodoListCount: 0, selectedTodoListMap: {}, - initialPinnedRankCount: 0, pinnedRankCount: 0, loading: false, newlyPinnedCases: 0, - completedCases: 0, caseDetails: {}, searchQuery: '', visitPlansUpdating: false, pendingList: [], completedList: [], pinnedList: [], - newVisitedCases: [], selectedCaseId: '', allCasesViewSearchQuery: '', visitPlanSearchQuery: '', }; -const getCaseListComponents = (casesList: ICaseItem[], caseDetails: Record) => { - const pendingList: ICaseItem[] = []; - const completedList: ICaseItem[] = []; - const pinnedList: ICaseItem[] = []; - casesList.forEach((item) => { - const { caseReferenceId, pinRank } = item; - const { caseStatus } = caseDetails[caseReferenceId] || {}; - const isCaseCompleted = COMPLETED_STATUSES.includes(caseStatus); - isCaseCompleted - ? completedList.push(item) - : pinRank - ? pinnedList.push(item) - : pendingList.push(item); - }); - return { pendingList, completedList, pinnedList }; -}; - export const getUpdatedCollectionCaseDetail = ({ caseData, answer, @@ -101,8 +63,6 @@ export const getUpdatedCollectionCaseDetail = ({ updatedValue.isSynced = false; updatedValue.isApiCalled = false; updatedValue.taskStatus = 'completed'; - // @deprecating - const { visitedWidgets } = answer; const allWidget = answer.widgetContext; const widgetContext = {}; @@ -127,16 +87,6 @@ export const getUpdatedCollectionCaseDetail = ({ return updatedValue; }; -const getCaseListItem = ( - caseReferenceId: string, - pinRank?: number | null, - caseViewCreatedAt?: number -) => ({ - caseReferenceId, - pinRank: pinRank || null, - caseViewCreatedAt, -}); - const allCasesSlice = createSlice({ name: 'cases', initialState, @@ -144,166 +94,21 @@ const allCasesSlice = createSlice({ setLoading: (state, action) => { state.loading = action.payload; }, - updateCaseDetailsFirestore: (state, action) => { - const { caseUpdates, isInitialLoad, isVisitPlanLocked, selectedAgent } = action.payload as { - caseUpdates: CaseUpdates[]; - isInitialLoad: boolean; - isVisitPlanLocked: boolean; - selectedAgent: IReportee; - }; - const newVisitCaseLoanIds: string[] = []; - const newVisitCollectionCases: string[] = []; - const removedVisitedCasesLoanIds: string[] = []; - caseUpdates.forEach(({ updateType, updatedCaseDetail }) => { - const { caseType, caseReferenceId, id, pinRank, caseViewCreatedAt } = updatedCaseDetail; - - const caseId = caseReferenceId || id; - switch (updateType) { - case FirestoreUpdateTypes.MODIFIED: { - const index = state.casesList?.findIndex( - (caseItem) => caseItem.caseReferenceId?.toString() === caseId?.toString() - ); - if (index !== -1) { - if (pinRank && !state.casesList[index].pinRank) { - // this is a new visit case - newVisitCaseLoanIds.push( - state.caseDetails[caseId]?.loanAccountNumber ?? - state.caseDetails[caseId]?.loanDetails?.loanAccountNumber ?? - 0 - ); - if (caseType === CaseAllocationType.COLLECTION_CASE) { - newVisitCollectionCases.push(caseId); - } - } - if (!pinRank && state.casesList[index].pinRank) { - // this is a removed visit case - removedVisitedCasesLoanIds.push( - state.caseDetails[caseId]?.loanAccountNumber ?? - state.caseDetails[caseId]?.loanDetails?.loanAccountNumber ?? - 0 - ); - } - state.casesList[index] = { - ...state.casesList[index], - caseReferenceId: caseId, - pinRank: pinRank || null, - caseViewCreatedAt: caseViewCreatedAt, - }; - } - let currentTask = null; - if (caseType !== CaseAllocationType.COLLECTION_CASE) { - const { tasks, currentTask: updatedCurrentTask } = updatedCaseDetail; - currentTask = tasks?.find((task) => task.taskType === (updatedCurrentTask as string)); - } - state.caseDetails[caseId] = { - ...updatedCaseDetail, - currentTask, - isSynced: true, - imageUri: state.caseDetails[caseId]?.imageUri, - }; - break; - } - case FirestoreUpdateTypes.ADDED: { - if (state.caseDetails[caseId]) { - return; - } - if (pinRank && caseType === CaseAllocationType.COLLECTION_CASE) { - newVisitCollectionCases.push(caseId); - } - const caseListItem = getCaseListItem(caseId, pinRank, caseViewCreatedAt); - state.casesList.unshift(caseListItem); - let currentTask = null; - if (caseType !== CaseAllocationType.COLLECTION_CASE) { - const { tasks, currentTask: updatedCurrentTask } = updatedCaseDetail; - currentTask = tasks?.find( - (task) => task?.taskType === (updatedCurrentTask as string) - ); - } - const imageUri = - findDocumentByDocumentType( - updatedCaseDetail.documents, - DOCUMENT_TYPE.OPTIMIZED_SELFIE - )?.uri || ''; - state.caseDetails[caseId] = { - ...updatedCaseDetail, - currentTask, - isSynced: true, - isNewlyAdded: !isInitialLoad, - imageUri, - }; - break; - } - case FirestoreUpdateTypes.REMOVED: { - const index = state.casesList.findIndex( - (caseItem) => caseItem.caseReferenceId?.toString() === caseId?.toString() - ); - const currentScreen = getCurrentScreen(); - // Redirect to home screen if the case deletes which the agent is seeing - if (currentScreen?.name === 'caseDetail') { - const { caseId: id } = currentScreen.params; - if (id === caseId) { - navigateToScreen('Home'); - } - } - if (index !== -1) { - state.casesList.splice(index, 1); - } - delete state.caseDetails[caseId]; - break; - } - default: - break; - } - }); - const { pendingList, completedList, pinnedList } = getCaseListComponents( - state.casesList, - state.caseDetails - ); + updateCaseDetailsFromFirestore: (state, action) => { + const { + updatedCasesList, + updatedCaseDetails, + updatedLoading, + pendingList, + completedList, + pinnedList, + } = action.payload; + state.casesList = updatedCasesList; + state.caseDetails = updatedCaseDetails; + state.loading = updatedLoading; state.pendingList = pendingList; state.completedList = completedList; state.pinnedList = pinnedList; - state.newVisitedCases = newVisitCollectionCases; - if (state.loading) { - if (selectedAgent && selectedAgent.referenceId !== MY_CASE_ITEM.referenceId) { - addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_AGENT_CASE_LOAD_SUCCESS, { - selectedAgent: selectedAgent.referenceId, - }); - } - state.loading = false; - } - - if (newVisitCaseLoanIds?.length > 0) { - addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_VISIT_PLAN_UPDATED, { - newPinCases: [...newVisitCaseLoanIds], - currentPinCases: pinnedList.map((item) => - getLoanAccountNumber(state.caseDetails[item?.caseReferenceId]) - ), - }); - if (!isVisitPlanLocked) { - toast({ - type: 'info', - text1: `${newVisitCaseLoanIds.length} case${ - newVisitCaseLoanIds.length > 1 ? 's' : '' - } added to the visit plan`, - }); - } - } - if (removedVisitedCasesLoanIds.length > 0) { - addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_VISIT_PLAN_UPDATED, { - newUnpinCases: [...removedVisitedCasesLoanIds], - currentPinCases: pinnedList.map((item) => - getLoanAccountNumber(state.caseDetails[item?.caseReferenceId]) - ), - }); - if (!isVisitPlanLocked) { - toast({ - type: 'info', - text1: `${removedVisitedCasesLoanIds.length} case${ - removedVisitedCasesLoanIds.length > 1 ? 's' : '' - } removed from the visit plan`, - }); - } - } }, updateCaseDetail: (state, action) => { const { caseKey, updatedCaseDetail } = action.payload; @@ -348,7 +153,7 @@ const allCasesSlice = createSlice({ resetTodoList: (state) => { state.intermediateTodoListMap = {}; state.newlyPinnedCases = 0; - state.pinnedRankCount = state.initialPinnedRankCount; + state.pinnedRankCount = 0; }, setSelectedTodoListMap: (state, action: { payload: ICaseItem }) => { const caseId = action.payload.caseReferenceId; @@ -405,10 +210,6 @@ const allCasesSlice = createSlice({ state.caseDetails[id].isSynced = false; } }, - updateCaseDetailBeforeApiCall: (state, action) => { - const { caseId } = action.payload; - state.caseDetails[caseId].isApiCalled = false; - }, toggleNewlyAddedCase: (state, action) => { if (state.caseDetails[action.payload]) { state.caseDetails[action.payload].isNewlyAdded = false; @@ -418,44 +219,6 @@ const allCasesSlice = createSlice({ setVisitPlansUpdating: (state, action) => { state.visitPlansUpdating = action.payload; }, - resetNewVisitedCases: (state) => { - state.newVisitedCases = []; - }, - syncCasesByFallback: (state, action) => { - const { cases = [], deletedCaseIds = [], payloadCreatedAt } = action.payload; - cases.forEach((caseItem: CaseDetail | null) => { - if (!caseItem) { - return; - } - const { caseViewCreatedAt, caseReferenceId, isSynced, pinRank } = caseItem; - const isCaseAlreadyPresent = state.caseDetails[caseReferenceId]; - if ( - !isCaseAlreadyPresent || - (isSynced && caseViewCreatedAt && caseViewCreatedAt < payloadCreatedAt) - ) { - const caseListItem = getCaseListItem(caseReferenceId, pinRank, caseViewCreatedAt); - state.casesList.unshift(caseListItem); - const imageUri = isCaseAlreadyPresent - ? state.caseDetails[caseReferenceId]?.imageUri - : findDocumentByDocumentType(caseItem.documents, DOCUMENT_TYPE.OPTIMIZED_SELFIE)?.uri || - ''; - state.caseDetails[caseReferenceId] = { ...caseItem, isSynced: true, imageUri }; - } - }); - const { pendingList, completedList, pinnedList } = getCaseListComponents( - state.casesList, - state.caseDetails - ); - state.pendingList = pendingList; - state.completedList = completedList; - state.pinnedList = pinnedList; - deletedCaseIds.forEach((caseItem: CaseDetail) => { - const { caseViewCreatedAt, caseReferenceId } = caseItem; - if (caseViewCreatedAt && caseViewCreatedAt < payloadCreatedAt) { - delete state.caseDetails[caseReferenceId]; - } - }); - }, setCasesImageUri: (state, action) => { const imageUris: IAvatarUri[] = action.payload; imageUris.forEach(({ caseId, imageUri }) => { @@ -486,17 +249,14 @@ export const { resetSelectedTodoList, updateCaseDetail, updateSingleCase, - updateCaseDetailsFirestore, toggleNewlyAddedCase, resetCasesData, - updateCaseDetailBeforeApiCall, setVisitPlansUpdating, - resetNewVisitedCases, - syncCasesByFallback, setCasesImageUri, setSelectedCaseId, setAllCasesViewSearchQuery, setVisitPlanSearchQuery, + updateCaseDetailsFromFirestore, } = allCasesSlice.actions; export default allCasesSlice.reducer; diff --git a/src/reducer/feedbackImagesSlice.ts b/src/reducer/feedbackImagesSlice.ts index f9f613af..4e4273c4 100644 --- a/src/reducer/feedbackImagesSlice.ts +++ b/src/reducer/feedbackImagesSlice.ts @@ -47,6 +47,11 @@ const feedbackImagesSlice = createSlice({ }; } }, + updateIntermediateDocument: (state, action) => { + const { caseId, docs } = action.payload; + if (!caseId || !state?.intermediateDocsToBeUploaded?.[caseId]) return; + state.intermediateDocsToBeUploaded[caseId].documents = docs; + }, deleteIntermediateDocument: (state, action) => { const { caseId, questionKey } = action.payload; // delete respective document @@ -90,6 +95,7 @@ const feedbackImagesSlice = createSlice({ export const { addIntermediateDocument, + updateIntermediateDocument, deleteIntermediateDocument, setDocumentsToUpload, setDocumentInteractionId, diff --git a/src/reducer/filtersSlice.ts b/src/reducer/filtersSlice.ts index a8f455af..02ca3fc5 100644 --- a/src/reducer/filtersSlice.ts +++ b/src/reducer/filtersSlice.ts @@ -1,8 +1,7 @@ -import { FilterGroup, FilterResponse, IQuickFilter } from '../screens/allCases/interface'; +import { FilterGroup, IQuickFilter } from '../screens/allCases/interface'; import { createSlice } from '@reduxjs/toolkit'; import { filterTransformer } from '../components/screens/allCases/allCasesFilters/FilterUtils'; import { _map } from '../../RN-UI-LIB/src/utlis/common'; -import { CONDITIONAL_OPERATORS, FILTER_TYPES, SELECTION_TYPES } from '../common/Constants'; interface IFiltersSlice { filters: Record; diff --git a/src/reducer/foregroundServiceSlice.ts b/src/reducer/foregroundServiceSlice.ts index 93b534f7..976f2014 100644 --- a/src/reducer/foregroundServiceSlice.ts +++ b/src/reducer/foregroundServiceSlice.ts @@ -1,6 +1,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { IGeoLocation } from '../types/addressGeolocation.types'; import { IGeolocationPayload } from '../hooks/capturingApi'; +import { isTimeDifferenceWithinRange } from '@components/utlis/commonFunctions'; const initialDeviceGeolocationCoordinate: IGeoLocation = {} as IGeoLocation; @@ -9,14 +10,18 @@ const initialState = { isDeviceLocationEnabled: true, deviceGeolocationCoordinate: initialDeviceGeolocationCoordinate, deviceGeolocationsBuffer: [] as IGeolocationPayload[], + serverTimestamp: '' }; const ForegroundServiceSlice = createSlice({ name: 'foregroundService', initialState, reducers: { - setIsTimeSynced: (state, action) => { - state.isTimeSynced = action.payload; + setServerTimestamp: (state, action) => { + const timestamp = action.payload; + const isTimeDifferenceLess = isTimeDifferenceWithinRange(timestamp, 5); + state.serverTimestamp = action.payload; + state.isTimeSynced = isTimeDifferenceLess; }, setIsDeviceLocationEnabled: (state, action) => { state.isDeviceLocationEnabled = action.payload; @@ -38,11 +43,11 @@ const ForegroundServiceSlice = createSlice({ }); export const { - setIsTimeSynced, setIsDeviceLocationEnabled, setDeviceGeolocation, setDeviceGeolocationsBuffer, clearDeviceGeolocationsBuffer, + setServerTimestamp } = ForegroundServiceSlice.actions; export default ForegroundServiceSlice.reducer; diff --git a/src/reducer/postOperationalHourRestrictionsSlice.ts b/src/reducer/postOperationalHourRestrictionsSlice.ts new file mode 100644 index 00000000..33b3f1ec --- /dev/null +++ b/src/reducer/postOperationalHourRestrictionsSlice.ts @@ -0,0 +1,24 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +const initialState = { + postOperationalHourRestrictions: false +}; + +const postOperationalHourRestrictionsSlice = createSlice({ + name: 'postOperationalHourRestrictions', + initialState, + reducers: { + setPostOperationalHourRestrictions: (state, action) => { + state.postOperationalHourRestrictions = action.payload; + } + }, +}); + +export const { + setPostOperationalHourRestrictions +} = postOperationalHourRestrictionsSlice.actions; + +export default postOperationalHourRestrictionsSlice.reducer; + + + diff --git a/src/reducer/skipTracingAddressesSlice.ts b/src/reducer/skipTracingAddressesSlice.ts new file mode 100644 index 00000000..31b2d254 --- /dev/null +++ b/src/reducer/skipTracingAddressesSlice.ts @@ -0,0 +1,46 @@ + +import { createSlice } from '@reduxjs/toolkit'; +import { IAddress } from '../types/addressGeolocation.types'; +import { IAddressFeedback } from './addressSlice'; + +type ISkipTracingAddressesSlice = Record< + string, + { + skipTracingAddresses: IAddress[]; + skipTracingAddressFeedbacks: IAddressFeedback[]; + isLoading: boolean; + } +>; + +const initialState: ISkipTracingAddressesSlice = {}; + +const SkipTracingAddressesSlice = createSlice({ + name: 'skipTracingAddressesSlice', + initialState, + reducers: { + setSkipTracingAddresses: (state, action) => { + const { + loanAccountNumber, + skipTracingAddresses = [], + skipTracingAddressFeedbacks = [], + } = action.payload; + state[loanAccountNumber] = { + skipTracingAddresses, + skipTracingAddressFeedbacks, + isLoading: false, + }; + }, + setSkipTracingAddressesLoading: (state, action) => { + const { loanAccountNumber, isLoading } = action.payload; + state[loanAccountNumber] = { + ...(state?.[loanAccountNumber] || {}), + isLoading, + }; + }, + }, +}); + +export const { setSkipTracingAddresses, setSkipTracingAddressesLoading } = +SkipTracingAddressesSlice.actions; + +export default SkipTracingAddressesSlice.reducer; diff --git a/src/reducer/topFeedbacksSlice.ts b/src/reducer/topFeedbacksSlice.ts index 774ca90d..ccf8d149 100644 --- a/src/reducer/topFeedbacksSlice.ts +++ b/src/reducer/topFeedbacksSlice.ts @@ -4,7 +4,7 @@ interface ITopFeedback { status: string; color: string; referenceId: string; - offset: number; + orderOffset: number; createdAt: string; type: string; } @@ -23,16 +23,16 @@ const TopFeedbacksSlice = createSlice({ initialState, reducers: { setTopFeedbacks: (state, action) => { - const { loanAccountNumber, feedbacks } = action.payload || {}; - state[loanAccountNumber] = { - ...(state[loanAccountNumber] || {}), + const { caseId, feedbacks } = action.payload || {}; + state[caseId] = { + ...(state[caseId] || {}), feedbacks, }; }, setTopFeedbacksLoading: (state, action) => { - const { loanAccountNumber, isLoading } = action.payload || {}; - state[loanAccountNumber] = { - ...(state?.[loanAccountNumber] || {}), + const { caseId, isLoading } = action.payload || {}; + state[caseId] = { + ...(state?.[caseId] || {}), isLoading: isLoading, }; }, diff --git a/src/reducer/trainingMaterialSlice.ts b/src/reducer/trainingMaterialSlice.ts new file mode 100644 index 00000000..a6840ed9 --- /dev/null +++ b/src/reducer/trainingMaterialSlice.ts @@ -0,0 +1,30 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { ITrainingMaterial } from '@screens/trainingMaterial/interfaces'; + +interface ITrainingMaterialSlice { + loading: boolean; + data: ITrainingMaterial[]; +} + +const initialState: ITrainingMaterialSlice = { + loading: false, + data: [], +}; + +const TrainingMaterialSlice = createSlice({ + name: 'trainingMaterial', + initialState, + reducers: { + setTrainingMaterialLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + }, + setTrainingMaterialData: (state, action: PayloadAction) => { + state.data = action.payload; + }, + }, +}); + +export const { setTrainingMaterialLoading, setTrainingMaterialData } = + TrainingMaterialSlice.actions; + +export default TrainingMaterialSlice.reducer; diff --git a/src/screens/Profile/Navigation/constants.ts b/src/screens/Profile/Navigation/constants.ts index 039769ea..45b3789b 100644 --- a/src/screens/Profile/Navigation/constants.ts +++ b/src/screens/Profile/Navigation/constants.ts @@ -10,6 +10,8 @@ import store from '@store'; import { Alert } from 'react-native'; import { ProfileScreenStackEnum } from '../ProfileStack'; import CountComponent from '../CountComponent'; +import BookIcon from '@assets/icons/BookIcon'; +import NewTag from '@common/NewTag'; export const getNavigationLinks = () => { const { isTeamLead, selectedAgent, featureFlags } = store?.getState().user; @@ -37,6 +39,16 @@ export const getNavigationLinks = () => { isNew: true, NewComponent: CountComponent, }, + { + name: 'Training material', + icon: BookIcon, + isVisible: true, + onPress: () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_PROFILE_PAGE_TRAINING_MATERIAL_CLICKED); + navigateToScreen(ProfileScreenStackEnum.TRAINING_MATERIAL); + }, + NewComponent: NewTag + }, { name: 'Logout', icon: LogoutIcon, diff --git a/src/screens/Profile/ProfileStack.tsx b/src/screens/Profile/ProfileStack.tsx index 0e42b135..c58a70ce 100644 --- a/src/screens/Profile/ProfileStack.tsx +++ b/src/screens/Profile/ProfileStack.tsx @@ -9,6 +9,8 @@ import Profile from '.'; import AgentIdCard from './AgentIdCard'; import MyDocuments from './MyDocuments'; import PDFFullScreen from '@screens/caseDetails/PDFFullScreen'; +import TrainingMaterial from '@screens/trainingMaterial/TrainingMaterial'; +import TrainingMaterialDetail from '@screens/trainingMaterial/TrainingMaterialDetail'; const Stack = createNativeStackNavigator(); @@ -20,6 +22,8 @@ export enum ProfileScreenStackEnum { AGENT_ID_CARD = 'agentIdCard', MY_DOCUMENTS = 'myDocuments', PDF_FULL = 'pdfFull', + TRAINING_MATERIAL = 'trainingMaterial', + TRAINING_MATERIAL_DETAIL = 'trainingMaterialDetail', } const ProfileStack = () => { @@ -47,6 +51,12 @@ const ProfileStack = () => { component={PDFFullScreen} options={{ ...DEFAULT_SCREEN_OPTIONS, orientation: 'all' }} /> + + ); }; diff --git a/src/screens/addNewNumber/apiHelper.ts b/src/screens/addNewNumber/apiHelper.ts index b9cc83d1..b8adcb94 100644 --- a/src/screens/addNewNumber/apiHelper.ts +++ b/src/screens/addNewNumber/apiHelper.ts @@ -16,11 +16,20 @@ interface IAddNewNumberApi { } export const addNewNumberApi = ( data: IAddNewNumberApi, + caseReferenceId: string, + caseBusinessVertical: string, afterApiCallback?: GenericFunctionArgs, successCallbackFn?: GenericFunctionArgs ) => { const { tag, source, number, customerReferenceId, caseId } = data; - const url = getApiUrl(ApiKeys.TELEPHONES); + const url = getApiUrl( + ApiKeys.TELEPHONES, + {}, + { + caseReferenceId, + caseBusinessVertical, + } + ); const payload = [ { number, diff --git a/src/screens/addNewNumber/index.tsx b/src/screens/addNewNumber/index.tsx index 3c7f1ddf..32e973d9 100644 --- a/src/screens/addNewNumber/index.tsx +++ b/src/screens/addNewNumber/index.tsx @@ -38,6 +38,9 @@ const AddNewNumber: React.FC = (props) => { const { customerReferenceId, loanAccountNumber } = useAppSelector( (state) => state.allCases.caseDetails[caseId] || {} ); + const caseBusinessVertical = useAppSelector( + (state) => state?.allCases?.caseDetails?.[caseId]?.businessVertical + ); const dispatch = useAppDispatch(); const { control, @@ -74,10 +77,12 @@ const AddNewNumber: React.FC = (props) => { ...getValues(), caseId, }, + caseId, + caseBusinessVertical, () => { setLoading(false); }, - () => dispatch(fetchTelephoneNumber({ loanAccountNumber, caseId })) + () => dispatch(fetchTelephoneNumber({ caseId, caseBusinessVertical, loanAccountNumber })) ); }; diff --git a/src/screens/addressGeolocation/AddressItem.tsx b/src/screens/addressGeolocation/AddressItem.tsx index 06f055d3..0b5150dd 100644 --- a/src/screens/addressGeolocation/AddressItem.tsx +++ b/src/screens/addressGeolocation/AddressItem.tsx @@ -1,16 +1,20 @@ -import React from 'react'; -import { View, StyleSheet, type ViewStyle, TouchableOpacity } from 'react-native'; +import React, { useEffect } from 'react'; +import { View, StyleSheet, type ViewStyle, TouchableOpacity, Linking } from 'react-native'; import dayjs from 'dayjs'; import Text from '../../../RN-UI-LIB/src/components/Text'; import { type IAddress, type IGeolocationCoordinate } from '../../types/addressGeolocation.types'; import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; -import { getDistanceFromLatLonInKm, sanitizeString } from '../../components/utlis/commonFunctions'; +import { + getDistanceFromLatLonInKm, + getGoogleMapUrl, + sanitizeString, +} from '../../components/utlis/commonFunctions'; import { useAppDispatch, useAppSelector } from '../../hooks'; import Tag, { TagVariant } from '../../../RN-UI-LIB/src/components/Tag'; import { CaseAllocationType, TaskTitleUIMapping } from '../allCases/interface'; import { updatePreDefinedCaseFormJourney } from '../../reducer/caseReducer'; -import { getCollectionFeedbackOnAddressPreDefinedJourney } from '../../components/utlis/addressGeolocationUtils'; +import { getCollectionFeedbackOnAddressPreDefinedJourney, getCollectionFeedbackOnSkipTracingPreDefinedJourney } from '../../components/utlis/addressGeolocationUtils'; import { _map } from '../../../RN-UI-LIB/src/utlis/common'; import { getTemplateRoute, navigateToScreen } from '../../components/utlis/navigationUtlis'; import { type GenericFunctionArgs } from '../../common/GenericTypes'; @@ -19,6 +23,8 @@ import { addClickstreamEvent } from '@services/clickstreamEventService'; import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; import CopyIcon from '@rn-ui-lib/icons/CopyIcon'; import { copyAddressToClipboard } from './utils/copyAddressText'; +import { handlePostOperativeHourActivities } from './utils/operativeHourUtils'; +import { ToastMessages } from '@screens/allCases/constants'; interface IAddressItem { addressItem: IAddress; @@ -33,6 +39,9 @@ interface IAddressItem { handleOldFeedbackRouting?: GenericFunctionArgs; showSource?: boolean; lastFeedbackForAddress?: any; + showOpenMap?: boolean; + showCopy?: boolean; + isSkipTracing?: boolean; } function AddressItem({ @@ -48,6 +57,9 @@ function AddressItem({ handleOldFeedbackRouting, showSource = false, lastFeedbackForAddress, + showOpenMap = false, + showCopy = true, + isSkipTracing = false, }: IAddressItem) { const { currentGeolocationCoordinates, prefilledAddressScreenTemplate } = useAppSelector( (state) => ({ @@ -58,7 +70,9 @@ function AddressItem({ ); const dispatch = useAppDispatch(); - + const addingNewFeedbackDisabled = useAppSelector( + (state) => state?.postOperationalHourRestrictionsSlice?.postOperationalHourRestrictions + ); let relativeDistanceBwLatLong = 0; const addressGeolocationCoordinated: IGeolocationCoordinate = { @@ -76,9 +90,12 @@ function AddressItem({ addressId: addressItem.id, caseId, }); + const getCollectionFeedbackFn = isSkipTracing + ? getCollectionFeedbackOnSkipTracingPreDefinedJourney + : getCollectionFeedbackOnAddressPreDefinedJourney; const addressKey = '{{addressReferenceId}}'; - const { visitedWidgets, widgetContext } = getCollectionFeedbackOnAddressPreDefinedJourney( + const { visitedWidgets, widgetContext } = getCollectionFeedbackFn( prefilledAddressScreenTemplate, addressKey, addressItem.id @@ -106,7 +123,16 @@ function AddressItem({ const copyAddress = () => { copyAddressToClipboard(addressItem?.addressText, caseId); }; - + const handleDisableAddFeedback = () => { + handlePostOperativeHourActivities( + ToastMessages.DISABLE_ADD_FEEDBACK_AFTER_POST_OPERATIVE_HOURS + ); + }; + const openGeolocation = (address: any) => { + const geolocationUrl = getGoogleMapUrl(address?.latitude, address?.longitude); + if (!geolocationUrl) return; + return Linking.openURL(geolocationUrl); + }; return ( {isGroupedAddress ? ( @@ -220,23 +246,41 @@ function AddressItem({ {showActionButtons ? ( + {showOpenMap ? ( + openGeolocation(addressItem)} + hitSlop={{ top: 25, bottom: 25, left: 15, right: 15 }} + style={GenericStyles.mr16} + > + + Open map + + + ) : null + } + {showCopy ? ( + + + + Copy + + + ): null} + - - - Copy - - - - Add Feedback + + Add Feedback + {lastFeedbackForAddress?.feedbackPresent ? ( = ({ route: routeParam const [isPinCodeFieldInteracted, setIsPinCodeFieldInteracted] = React.useState(false); const [validFields, setValidFields] = React.useState(initialValidState); const isValid = validFields.lineOne && validFields.lineTwo && validFields.pinCode; + const caseBusinessVertical = useAppSelector( + (state) => state?.allCases?.caseDetails?.[caseId]?.businessVertical + ); const handlePinCodeChange = (pinCode: string, onChange: (...event: any[]) => void) => { @@ -143,7 +146,7 @@ const NewAddressContainer: React.FC = ({ route: routeParam pinCode: getValues('pinCode'), }; - dispatch(addAddress(payload, commonPayload)) + dispatch(addAddress(payload, commonPayload, caseId, caseBusinessVertical)) .then((_) => { toast({ type: 'info', diff --git a/src/screens/addressGeolocation/SimilarAddressItem.tsx b/src/screens/addressGeolocation/SimilarAddressItem.tsx index cc2c8b2e..fc021653 100644 --- a/src/screens/addressGeolocation/SimilarAddressItem.tsx +++ b/src/screens/addressGeolocation/SimilarAddressItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { View, StyleSheet, type ViewStyle, TouchableOpacity, Linking } from 'react-native'; import Text from '../../../RN-UI-LIB/src/components/Text'; import { type IAddress, type IGeolocationCoordinate } from '../../types/addressGeolocation.types'; @@ -24,6 +24,7 @@ import AddressSource from './AddressSource'; import relativeDistanceFormatter from './utils/relativeDistanceFormatter'; import CopyIcon from '@rn-ui-lib/icons/CopyIcon'; import { copyAddressToClipboard } from './utils/copyAddressText'; +import { handlePostOperativeHourActivities } from './utils/operativeHourUtils'; interface IAddressItem { addressItem: IAddress; @@ -118,11 +119,16 @@ function SimilarAddressItem({ } } }; - + const addingNewFeedbackDisabled = useAppSelector((state) => state?.postOperationalHourRestrictionsSlice?.postOperationalHourRestrictions); + const copyAddress = () => { copyAddressToClipboard(addressItem?.addressText, caseId); }; - + const handleDisableAddFeedback = () => { + handlePostOperativeHourActivities( + ToastMessages.DISABLE_ADD_FEEDBACK_AFTER_POST_OPERATIVE_HOURS + ); + }; return ( {isGroupedAddress ? ( @@ -193,11 +199,11 @@ function SimilarAddressItem({ {showAddFeedbackBtn ? ( - Add Feedback + Add Feedback ) : null} {showOldFeedbackBtn ? ( @@ -251,6 +257,13 @@ const styles = StyleSheet.create({ fontWeight: '500', color: COLORS.TEXT.BLUE, }, + disabledButton: { + fontSize: 13, + lineHeight: 20, + fontWeight: '500', + color: COLORS.TEXT.BLUE, + opacity: 0.5 + }, dotStyle: { fontSize: 11, color: COLORS.TEXT.LIGHT, diff --git a/src/screens/addressGeolocation/SkipTracingAddressItem.tsx b/src/screens/addressGeolocation/SkipTracingAddressItem.tsx new file mode 100644 index 00000000..2891434b --- /dev/null +++ b/src/screens/addressGeolocation/SkipTracingAddressItem.tsx @@ -0,0 +1,92 @@ +import React, { useMemo } from 'react'; +import AddressItem from './AddressItem'; +import Accordion from '@rn-ui-lib/components/accordian/Accordian'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import { addClickstreamEvent } from '@services/clickstreamEventService'; +import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import { IAddress } from '@interfaces/addressGeolocation.types'; +import { CaseDetailStackEnum } from '@screens/caseDetails/CaseDetailStack'; +import { GenericFunctionArgs } from '@common/GenericTypes'; +import { View } from 'react-native'; +import { useAppSelector } from '@hooks'; + +interface ISkipTracingAddressItem { + skipTracingAddress: IAddress; + caseId: string; + handlePageRouting: GenericFunctionArgs | undefined; +} +const SkipTracingAddressItem = (props: ISkipTracingAddressItem) => { + const { skipTracingAddress, caseId, handlePageRouting } = props; + const loanAccountNumber = useAppSelector( + (state) => state?.allCases?.caseDetails[caseId]?.loanAccountNumber + ); + const skipTracingAddressFeedbacks = useAppSelector( + (state) => state?.skipTracingAddress?.[loanAccountNumber]?.skipTracingAddressFeedbacks || [] + ); + const getAllAddressIds = (skipTracingAddress: IAddress) => { + const addressIds = new Set(); + if (skipTracingAddress?.id) { + addressIds.add(skipTracingAddress?.id); + } + return Array.from(addressIds); + }; + const handleOpenOldFeedbacks = (skipTracingAddress: IAddress) => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_ADDRESS_OLD_FEEDBACK_CLICKED, { + addressId: skipTracingAddress?.id, + caseId, + }); + const addressIds = getAllAddressIds(skipTracingAddress); + const commonParams = { + addressText: skipTracingAddress?.addressText, + addressReferenceIds: addressIds.join(','), + }; + handlePageRouting?.(CaseDetailStackEnum.PAST_FEEDBACK_DETAIL, commonParams); + }; + const lastFeedbackForAddress = useMemo(() => { + return skipTracingAddressFeedbacks?.find((skipTracingAddressFeedback) => { + return skipTracingAddressFeedback?.addressReferenceId === skipTracingAddress?.id; + }); + }, [skipTracingAddressFeedbacks, skipTracingAddress]); + + const handleAccordionExpand = (isExpanded: boolean, addressId: string) => { + if (isExpanded) { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_VIEW_MORE_ADDRESSES_CLICKED, { + addressId, + }); + } + }; + return ( + + { + handleOpenOldFeedbacks(skipTracingAddress); + }} + handleCloseRouting={() => handlePageRouting?.(CaseDetailStackEnum.ADDRESS_GEO)} + lastFeedbackForAddress={lastFeedbackForAddress} + showOpenMap={true} + showCopy={false} + isSkipTracing={true} + /> + } + onExpanded={(isExpanded) => { + handleAccordionExpand(isExpanded, skipTracingAddress?.id); + }} + > + + ); +}; + +export default SkipTracingAddressItem; diff --git a/src/screens/addressGeolocation/SkipTracingContainer.tsx b/src/screens/addressGeolocation/SkipTracingContainer.tsx new file mode 100644 index 00000000..8f6f3ac5 --- /dev/null +++ b/src/screens/addressGeolocation/SkipTracingContainer.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import { type IAddress } from '../../types/addressGeolocation.types'; +import { type GenericFunctionArgs } from '../../common/GenericTypes'; +import { useAppSelector } from '../../hooks'; +import SkipTracingAddressItem from './SkipTracingAddressItem'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import CustomLocationIcon from '@assets/icons/CustomLocationIcon'; +import Text from '@rn-ui-lib/components/Text'; + +interface IAddressContainer { + caseId: string; + handlePageRouting?: GenericFunctionArgs; +} + +const SkipTracingAddressContainer: React.FC = ({ + caseId, + handlePageRouting, +}) => { + const loanAccountNumber = useAppSelector( + (state) => state?.allCases?.caseDetails[caseId]?.loanAccountNumber + ); + const skipTracingAddresses = useAppSelector( + (state) => state?.skipTracingAddress?.[loanAccountNumber]?.skipTracingAddresses || [] + ); + + if (!skipTracingAddresses?.length) { + return ( + + + No skip tracing found + + ); + } + return ( + + {skipTracingAddresses?.map((skipTracingAddress: IAddress) => { + return ( + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + textContainer: { + fontSize: 14, + lineHeight: 20, + }, + borderLine: { + borderColor: COLORS.BORDER.PRIMARY, + borderWidth: 0.5, + }, + noAddressContainer: { + backgroundColor: COLORS.BACKGROUND.PRIMARY, + height: 150, + }, + noAddressText: { + color: COLORS.TEXT.BLUE_BC, + fontWeight: '600', + }, + accordionDetailHeading: { + color: COLORS.TEXT.BLACK_24, + }, + card: { + backgroundColor: COLORS.BACKGROUND.SILVER, + borderRadius: 8, + marginBottom: 16, + padding: 16, + }, + noSkipTracingAddressContainer: { + height: 150, + backgroundColor: COLORS.BACKGROUND.PRIMARY, + }, + noSkipTracingAddressText: { + color: COLORS.TEXT.BLUE_BC, + fontWeight: '600', + marginTop: 6 + }, +}); + +export default SkipTracingAddressContainer; diff --git a/src/screens/addressGeolocation/UngroupedAddressContainer.tsx b/src/screens/addressGeolocation/UngroupedAddressContainer.tsx index 51ba5f23..1862c826 100644 --- a/src/screens/addressGeolocation/UngroupedAddressContainer.tsx +++ b/src/screens/addressGeolocation/UngroupedAddressContainer.tsx @@ -53,6 +53,9 @@ const UngroupedAddressContainer: React.FC = ({ route: routePa const { isLoading = false } = useAppSelector((state) => state.ungroupedAddresses?.[loanAccountNumber]) || {}; + const caseBusinessVertical = useAppSelector( + (state) => state?.allCases?.caseDetails?.[caseId]?.businessVertical + ); const { additionalAddresses = [], additionalAddressFeedbackList = [] } = useAddresses(loanAccountNumber); const dispatch = useAppDispatch(); @@ -61,7 +64,7 @@ const UngroupedAddressContainer: React.FC = ({ route: routePa const fetchUngroupedAddress = () => { // fetch ungrouped address - dispatch(getUngroupedAddress(loanAccountNumber, true)); + dispatch(getUngroupedAddress(loanAccountNumber, caseId, caseBusinessVertical, true)); }; if (!isOnline) { diff --git a/src/screens/addressGeolocation/constant.ts b/src/screens/addressGeolocation/constant.ts index e12e2c9a..6aa9fef1 100644 --- a/src/screens/addressGeolocation/constant.ts +++ b/src/screens/addressGeolocation/constant.ts @@ -28,11 +28,16 @@ export const ADDRESSES_TABS = [ key: 'geolocation', label: 'Geolocations', }, + { + key: 'skipTracing', + label: 'Skip Tracing', + }, ]; export enum AddressGeolocationTabEnum { ADDRESS = 'address', GEOLOCATION = 'geolocation', + SKIP_TRACING = 'skipTracing' } export enum AddressTabType { diff --git a/src/screens/addressGeolocation/index.tsx b/src/screens/addressGeolocation/index.tsx index d885335b..89900384 100644 --- a/src/screens/addressGeolocation/index.tsx +++ b/src/screens/addressGeolocation/index.tsx @@ -26,13 +26,14 @@ import HomeIconSmall from '../../assets/icons/HomeIconSmall'; import SuspenseLoader from '../../../RN-UI-LIB/src/components/suspense_loader/SuspenseLoader'; import LineLoader from '../../../RN-UI-LIB/src/components/suspense_loader/LineLoader'; import Layout from '../layout/Layout'; -import { getUngroupedAddress } from '../../action/addressGeolocationAction'; +import { getSkipTracingAddress, getUngroupedAddress } from '../../action/addressGeolocationAction'; import CustomTabs from '@rn-ui-lib/components/customTabs/CustomTabs'; import { CaseDetailStackEnum } from '@screens/caseDetails/CaseDetailStack'; import { ADDRESSES_TABS, AddressGeolocationTabEnum, IAddressGeolocation } from './constant'; import useAddresses from '@hooks/useAddresses'; import { sendCurrentGeolocationAndBuffer } from '@hooks/capturingApi'; import Text from '@rn-ui-lib/components/Text'; +import SkipTracingContainer from './SkipTracingContainer'; const PAGE_TITLE = 'All addresses'; @@ -45,6 +46,9 @@ const AddressGeolocation: React.FC = ({ route: routeParams addressGeolocation: state.address?.[loanAccountNumber]?.addressesAndGeoLocations || {}, isLoading: state.address?.[loanAccountNumber]?.isLoading || false, })); + const caseBusinessVertical = useAppSelector( + (state) => state?.allCases?.caseDetails?.[caseId]?.businessVertical + ); const [selectedTab, setSelectedTab] = useState(AddressGeolocationTabEnum.ADDRESS); const [retryBtnToggle, setRetryBtnToggle] = useState(false); const dispatch = useAppDispatch(); @@ -71,8 +75,16 @@ const AddressGeolocation: React.FC = ({ route: routeParams }; const getGroupedAddresses = () => { - dispatch(getAddressesAndGeolocations(loanAccountNumber, true)); dispatch(sendCurrentGeolocationAndBuffer(AppState.currentState)); + if(selectedTab === AddressGeolocationTabEnum.GEOLOCATION) { + dispatch(getAddressesAndGeolocations(loanAccountNumber, caseId, caseBusinessVertical, true)); + return; + } + if(selectedTab === AddressGeolocationTabEnum.SKIP_TRACING) { + dispatch(getSkipTracingAddress(loanAccountNumber, caseId, caseBusinessVertical, true)); + return; + } + dispatch(getUngroupedAddress(loanAccountNumber, caseId, caseBusinessVertical, true)); }; useEffect(() => { @@ -83,7 +95,8 @@ const AddressGeolocation: React.FC = ({ route: routeParams useEffect(() => { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_ALL_ADDRESSES_LANDED, commonParams); - dispatch(getUngroupedAddress(loanAccountNumber, true)); + dispatch(getUngroupedAddress(loanAccountNumber, caseId, caseBusinessVertical, true)); + dispatch(getSkipTracingAddress(loanAccountNumber, caseId, caseBusinessVertical, true)); }, []); useEffect(() => { @@ -98,6 +111,9 @@ const AddressGeolocation: React.FC = ({ route: routeParams if (tab === AddressGeolocationTabEnum.ADDRESS) { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_ADDRESS_TAB_CLICKED, commonParams); } + if (tab === AddressGeolocationTabEnum.SKIP_TRACING) { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_SKIP_TRACING_TAB_CLICKED, commonParams); + } setSelectedTab(tab); } }; @@ -168,7 +184,7 @@ const AddressGeolocation: React.FC = ({ route: routeParams ) : null} - + = ({ route: routeParams loanAccountNumber={loanAccountNumber} /> + + + diff --git a/src/screens/addressGeolocation/utils/operativeHourUtils.tsx b/src/screens/addressGeolocation/utils/operativeHourUtils.tsx new file mode 100644 index 00000000..78abeaf4 --- /dev/null +++ b/src/screens/addressGeolocation/utils/operativeHourUtils.tsx @@ -0,0 +1,15 @@ +import { COLORS } from '@rn-ui-lib/colors'; +import { toast } from '@rn-ui-lib/components/toast'; +import InfoIcon from '@rn-ui-lib/icons/InfoIcon'; + +export const handlePostOperativeHourActivities = (textMessage: string) => { + toast({ + type: 'custom', + text1: textMessage, + props: { + customBackgroundColor: COLORS.BACKGROUND.ORANGE_LIGHT, + customIcon: , + }, + }); + return; +}; diff --git a/src/screens/allCases/AgentListItem.tsx b/src/screens/allCases/AgentListItem.tsx index 19bc42a9..dce897d5 100644 --- a/src/screens/allCases/AgentListItem.tsx +++ b/src/screens/allCases/AgentListItem.tsx @@ -7,7 +7,7 @@ import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; import { useAppDispatch, useAppSelector } from '../../hooks'; import { setShowAgentSelectionBottomSheet } from '../../reducer/reporteesSlice'; import { IReportee } from './interface'; -import { resetCasesData } from '../../reducer/allCasesSlice'; +import { resetCasesData, setLoading } from '@reducers/allCasesSlice'; import fuzzySort from '../../../RN-UI-LIB/src/utlis/fuzzySort'; import fuzzysort from 'fuzzysort'; import { MY_CASE_ITEM, setSelectedAgent } from '../../reducer/userSlice'; @@ -15,6 +15,7 @@ import { resetFilters } from '../../reducer/filtersSlice'; import { setGlobalUserData } from '../../constants/Global'; import { addClickstreamEvent } from '../../services/clickstreamEventService'; import { CLICKSTREAM_EVENT_NAMES } from '../../common/Constants'; +import { firestoreService } from '@services/firestoreService'; interface IAgentListItem { agent: IReportee; @@ -22,8 +23,17 @@ interface IAgentListItem { searchQuery: string; } +const unsubscribeFromPreviousDoc = (agentId?: string) => { + if (!agentId) { + return; + } + firestoreService.unsubscribeFromPath(`allocations/${agentId}/cases`); + firestoreService.unsubscribeFromPath(`filters/${agentId}`); +}; + const AgentListItem: React.FC = ({ agent, leftAdornment, searchQuery }) => { const selectedAgent = useAppSelector((state) => state.user.selectedAgent) || MY_CASE_ITEM; + const userReferenceId = useAppSelector((state) => state.user.user?.referenceId); const dispatch = useAppDispatch(); const handleAgentSelection = () => { @@ -31,10 +41,16 @@ const AgentListItem: React.FC = ({ agent, leftAdornment, searchQ addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_AGENT_SELECT_BUTTON_CLICKED, { selectedAgentId, }); + if (selectedAgentId !== MY_CASE_ITEM.referenceId) { + unsubscribeFromPreviousDoc(selectedAgent.referenceId); + } else { + unsubscribeFromPreviousDoc(userReferenceId); + } dispatch(setSelectedAgent(agent)); setGlobalUserData({ selectedAgentId }); dispatch(resetFilters()); dispatch(resetCasesData()); + dispatch(setLoading(true)); dispatch(setShowAgentSelectionBottomSheet(false)); }; diff --git a/src/screens/allCases/CaseItem.tsx b/src/screens/allCases/CaseItem.tsx index fad5c6cb..32fbbaa5 100644 --- a/src/screens/allCases/CaseItem.tsx +++ b/src/screens/allCases/CaseItem.tsx @@ -2,7 +2,6 @@ import React, { useMemo } from 'react'; import { Text, View, ViewProps, StyleSheet, Pressable } from 'react-native'; import { GenericStyles, getShadowStyle } from '../../../RN-UI-LIB/src/styles'; import { CaseTypes, ICaseItemCaseDetailObj } from './interface'; -import ListItem from './ListItem'; import Button from '../../../RN-UI-LIB/src/components/Button'; import { navigateToScreen } from '../../components/utlis/navigationUtlis'; import { useAppSelector } from '../../hooks'; @@ -13,6 +12,7 @@ import LocationIcon from '@assets/icons/LocationIcon'; import ArrowRightOutlineIcon from '@rn-ui-lib/icons/ArrowRightOutlineIcon'; import { addClickstreamEvent } from '@services/clickstreamEventService'; import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; +import CaseListItem from './CaseItem/CaseListItem'; interface ICaseItemProps extends ViewProps { caseDetailObj: ICaseItemCaseDetailObj; @@ -125,7 +125,7 @@ const CaseItem: React.FC = ({ default: return ( - { + const { title, value, showDot = false } = props; + return ( + + + + {title} + {' '} + {value ?? '--'} + + {showDot ? : null} + + ); +}; + +const styles = StyleSheet.create({ + caseStatusText: { + color: COLORS.TEXT.GREY_32465B, + fontWeight: '500', + }, + caseStatusTextTitle: { + color: COLORS.TEXT.LIGHT, + fontWeight: '500', + }, + dot: { + height: 4, + width: 4, + borderRadius: 2, + marginHorizontal: 8, + backgroundColor: COLORS.TEXT.GREY_1, + }, +}); + +export default CaseDetailKeyValue; diff --git a/src/screens/allCases/CaseItem/CaseListItem.tsx b/src/screens/allCases/CaseItem/CaseListItem.tsx new file mode 100644 index 00000000..e75df1d3 --- /dev/null +++ b/src/screens/allCases/CaseItem/CaseListItem.tsx @@ -0,0 +1,254 @@ +import React, { memo, useMemo } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import Heading from '../../../../RN-UI-LIB/src/components/Heading'; +import Text from '../../../../RN-UI-LIB/src/components/Text'; +import { GenericStyles, getShadowStyle } from '../../../../RN-UI-LIB/src/styles'; +import { getCurrentScreen, navigateToScreen } from '../../../components/utlis/navigationUtlis'; +import { useAppDispatch, useAppSelector } from '../../../hooks'; +import { setPinnedRank, setSelectedTodoListMap } from '../../../reducer/allCasesSlice'; +import CaseItemAvatar from '../CaseItemAvatar'; +import { CaseStatuses, ICaseItemAvatarCaseDetailObj, ICaseListItem } from '../interface'; +import { COLORS } from '../../../../RN-UI-LIB/src/styles/colors'; +import { CLICKSTREAM_EVENT_NAMES } from '../../../common/Constants'; +import { addClickstreamEvent } from '../../../services/clickstreamEventService'; +import { formatAmount } from '../../../../RN-UI-LIB/src/utlis/amount'; +import RoundCheckIcon from '../../../../RN-UI-LIB/src/Icons/RoundCheckIcon'; +import { getDocumentList, pluralise } from '../../../components/utlis/commonFunctions'; +import { toast } from '../../../../RN-UI-LIB/src/components/toast'; +import { COMPLETED_STATUSES, ToastMessages } from '../constants'; +import { VisitPlanStatus } from '../../../reducer/userSlice'; +import { CaseDetailStackEnum } from '@screens/caseDetails/CaseDetailStack'; +import { PageRouteEnum } from '@screens/auth/ProtectedRouter'; +import VisitPlanTag from './VisitPlanTag'; +import CaseStatus from './CaseStatus'; +import FeedbackStatus from './FeedbackStatus'; +import CaseDetailKeyValue from './CaseDetailKeyValue'; +import Escalation from '../Escalation/Escalation'; + +const CaseListItem: React.FC = (props) => { + const { + caseListItemDetailObj, + isCompleted, + isTodoItem, + shouldBatchAvatar, + allCasesView, + nearbyCaseView, + isVisitPlan, + } = props; + const { + caseReferenceId: caseId, + isIntermediateOrSelectedTodoCaseItem, + caseStatus, + caseType, + dpdBucket, + dpdCycle, + daysTillDeallocation, + pinRank, + isSynced, + totalOverdueAmount, + escalationData, + } = caseListItemDetailObj; + + const isVisitPlanStatusLocked = useAppSelector( + (state) => state.user?.lock?.visitPlanStatus === VisitPlanStatus.LOCKED + ); + const is1To30FieldAgent = useAppSelector((state) => state.user?.is1To30FieldAgent); + const isTeamLead = useAppSelector((state) => state.user.isTeamLead); + const activeEscalationCount = Number(escalationData?.activeEscalationCount ?? 0); + const pastEscalationCount = Number(escalationData?.pastEscalationCount ?? 0); + const totalEscalationsCount = activeEscalationCount + pastEscalationCount; + + const dispatch = useAppDispatch(); + + const handleAvatarClick = () => { + if (isTodoItem || caseStatus === CaseStatuses.CLOSED) { + return; + } + if (isVisitPlanStatusLocked) { + toast({ + type: 'info', + text1: ToastMessages.CASES_SELECTION_DISABLED, + }); + return; + } + if (pinRank) { + dispatch( + setSelectedTodoListMap({ + pinRank, + caseReferenceId: caseId, + }) + ); + } else { + dispatch( + setPinnedRank({ + caseReferenceId: caseId, + }) + ); + } + }; + + const handleCaseClick = async () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.AV_CASE_LIST_CASE_CLICKED, { + caseId, + screen: getCurrentScreen().name === 'Profile' ? 'Completed Cases' : getCurrentScreen().name, // todo: need to update use router + caseType, + }); + if (nearbyCaseView) { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_NEARBY_CASE_CLICKED, { + caseId, + }); + } + navigateToScreen(PageRouteEnum.CASE_DETAIL_STACK, { + screen: CaseDetailStackEnum.COLLECTION_CASE_DETAIL, + params: { caseId }, + }); + }; + + const isCaseSelected = !isTodoItem && !!isIntermediateOrSelectedTodoCaseItem; + const address = + caseListItemDetailObj?.currentTask?.metadata?.addressLine || + caseListItemDetailObj?.addressString; + const customerName = + caseListItemDetailObj.customerInfo?.name || + caseListItemDetailObj.customerInfo?.customerName || + caseListItemDetailObj.customerName; + + const getCaseItemAvatarCaseDetailObj = useMemo( + (): ICaseItemAvatarCaseDetailObj => ({ + isPinned: pinRank ? true : false, + isCaseSynced: isSynced as boolean, + customerName: customerName, + caseId, + documentList: getDocumentList(caseListItemDetailObj) || [], + caseType: caseType, + imageUri: caseListItemDetailObj?.imageUri || '', + }), + [ + caseType, + isSynced, + pinRank, + caseListItemDetailObj?.customerInfo?.documents, + caseListItemDetailObj?.documents, + caseListItemDetailObj?.imageUri, + ] + ); + + const isCaseItemPinnedMainView = getCaseItemAvatarCaseDetailObj.isPinned && allCasesView; + const caseCompleted = COMPLETED_STATUSES.includes(caseStatus); + + const showVisitPlanBtn = + !(caseCompleted || isCaseItemPinnedMainView) && + !isTodoItem && + !isCompleted && + !isTeamLead && + !nearbyCaseView; + + const showInVisitPlanTag = isCaseItemPinnedMainView && !caseCompleted; + + return ( + + + + + + + + {customerName} + + + + + + + {showVisitPlanBtn ? ( + + + + ) : null} + + {showInVisitPlanTag ? : null} + + + + {address ? address : 'Address not available'} + + {is1To30FieldAgent ? ( + + + + + + + + ) : ( + + + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + listItem: { + backgroundColor: COLORS.BACKGROUND.PRIMARY, + borderRadius: 8, + marginVertical: 10, + position: 'relative', + }, + selectBtn: { + position: 'absolute', + paddingTop: 12, + right: 0, + paddingRight: 12, + width: 80, + height: 80, + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'flex-end', + }, + backgroundBlue: { + backgroundColor: COLORS.BACKGROUND.BLUE, + }, + backgroundSilverLight: { + backgroundColor: COLORS.BACKGROUND.SILVER_LIGHT_3, + }, + backgroundBlueLight: { + backgroundColor: COLORS.BACKGROUND.BLUE_LIGHT_3, + }, +}); + +export default memo(CaseListItem); diff --git a/src/screens/allCases/CaseItem/CaseStatus.tsx b/src/screens/allCases/CaseItem/CaseStatus.tsx new file mode 100644 index 00000000..fff0c925 --- /dev/null +++ b/src/screens/allCases/CaseItem/CaseStatus.tsx @@ -0,0 +1,57 @@ +import { GenericStyles } from '@rn-ui-lib/styles'; +import React from 'react'; +import { View } from 'react-native'; +import { paymentStatusMapping } from '../utils'; +import Tag, { TagVariant } from '@rn-ui-lib/components/Tag'; +import LocationDistanceIcon from '@assets/icons/LocationDistanceIcon'; +import { COLORS } from '@rn-ui-lib/colors'; +import { useAppSelector } from '@hooks'; +import { TABS_KEYS } from '../constants'; +import { ICaseStatus } from '../interface'; + +const CaseStatus = (props: ICaseStatus) => { + const { caseListItemDetailObj, isVisitPlan } = props; + const { collectionTag, paymentStatus, caseReferenceId } = caseListItemDetailObj || {}; + const distanceMapOfNearbyCases = + useAppSelector((state) => state.nearbyCasesSlice.caseReferenceIdToDistanceMap) || {}; + const selectedTab = useAppSelector((state) => state?.nearbyCasesSlice?.sortTabSelected); + + const distanceOfCaseItem = distanceMapOfNearbyCases.get(caseReferenceId); + const isNearestCaseView = selectedTab === TABS_KEYS.NEAREST_CASE; + + return ( + + {paymentStatus ? ( + + + + ) : null} + {collectionTag ? ( + + + + ) : null} + + {!isVisitPlan && distanceOfCaseItem ? ( + + } + style={GenericStyles.pl2} + text={Number(distanceOfCaseItem?.toFixed(1)) + ' KM'} + variant={isNearestCaseView ? TagVariant.darkGray1 : TagVariant.white} + /> + ) : null} + + ); +}; + +export default CaseStatus; diff --git a/src/screens/allCases/CaseItem/FeedbackStatus.tsx b/src/screens/allCases/CaseItem/FeedbackStatus.tsx new file mode 100644 index 00000000..a152e90b --- /dev/null +++ b/src/screens/allCases/CaseItem/FeedbackStatus.tsx @@ -0,0 +1,84 @@ +import { COLORS } from '@rn-ui-lib/colors'; +import Text from '@rn-ui-lib/components/Text'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { View } from 'react-native'; +import { IFeedbackStatus } from '../interface'; +import { feedbackStatusColorMapping } from '../utils'; + +const FeedbackStatus = (props: IFeedbackStatus) => { + const { caseListItemDetailObj } = props; + const { currentMonthCaseInteractionStatus, lastMonthCaseInteractionStatus } = + caseListItemDetailObj || {}; + + return ( + + + + + + Current month + + + {currentMonthCaseInteractionStatus?.status + ? currentMonthCaseInteractionStatus?.status + : 'Not attempted'} + + + + + Last month + + + {lastMonthCaseInteractionStatus?.status + ? lastMonthCaseInteractionStatus?.status + : 'Not attempted'} + + + + + ); +}; + +const styles = StyleSheet.create({ + dashedBorder: { + borderColor: COLORS.BORDER.PRIMARY, + borderStyle: 'dashed', + borderTopWidth: 1, + }, + feedbackStatus: { + color: COLORS.TEXT.BLACK, + fontWeight: '500', + lineHeight: 18, + marginTop: 4, + }, + rightAlign: { + textAlign: 'right', + }, +}); + +export default FeedbackStatus; diff --git a/src/screens/allCases/CaseItem/VisitPlanTag.tsx b/src/screens/allCases/CaseItem/VisitPlanTag.tsx new file mode 100644 index 00000000..c0ea3bdb --- /dev/null +++ b/src/screens/allCases/CaseItem/VisitPlanTag.tsx @@ -0,0 +1,46 @@ +import TagPlaceholderIcon from '@assets/icons/TagPlaceholderIcon'; +import { COLORS } from '@rn-ui-lib/colors'; +import Text from '@rn-ui-lib/components/Text'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { View } from 'react-native'; + +interface IVisitPlanTag { + totalEscalationsCount: number; +} + +const VisitPlanTag = (props: IVisitPlanTag) => { + const { totalEscalationsCount } = props; + return ( + + + In visit plan + + ); +}; + +const styles = StyleSheet.create({ + visitPlanContainer: { + right: -4, + top: 12, + }, + top42: { + top: 42, + }, + visitPlanText: { + position: 'absolute', + left: 9, + color: COLORS.TEXT.DARK, + fontWeight: '500', + lineHeight: 18 + }, +}); + +export default VisitPlanTag; diff --git a/src/screens/allCases/CasesList.tsx b/src/screens/allCases/CasesList.tsx index f0b0b189..b2f6d520 100644 --- a/src/screens/allCases/CasesList.tsx +++ b/src/screens/allCases/CasesList.tsx @@ -67,6 +67,7 @@ import { setAllCasesViewSearchQuery, setVisitPlanSearchQuery } from '@reducers/a import { setDashboardSearchQuery } from '@reducers/agentPerformanceSlice'; import { setSortTabSelected } from '@reducers/nearbyCasesSlice'; import { clearBottomSheet } from '@components/utlis/DeviceUtils'; +import { CopilotProvider } from '@components/Tour/contexts/CopilotProvider'; export const getItem = (item: ICaseItem[], index: number) => item[index]; export const ESTIMATED_ITEM_SIZE = 250; // Average height of List item @@ -132,7 +133,8 @@ const CasesList: React.FC = ({ ); const selectedTab = useAppSelector((state) => state?.nearbyCasesSlice?.sortTabSelected); const isNearestCaseTabSelected = selectedTab === TABS_KEYS.NEAREST_CASE; - const isNearestCaseView = isNearestCaseTabSelected && (getCurrentScreen()?.name === BOTTOM_TAB_ROUTES.Cases); + const isNearestCaseView = + isNearestCaseTabSelected && getCurrentScreen()?.name === BOTTOM_TAB_ROUTES.Cases; const isPullToRefreshBannerVisible = isNearestCaseView && isPullToRefreshNearbyCasesVisible; const dispatch = useAppDispatch(); @@ -223,7 +225,7 @@ const CasesList: React.FC = ({ 'addressString', 'customerInfo.primaryPhoneNumber', 'primaryPhoneNumber', - 'loanAccountNumber' + 'loanAccountNumber', ], }).map((filteredListItem: { obj: CaseDetail }) => { const { caseReferenceId, id, pinRank } = filteredListItem.obj; @@ -254,7 +256,7 @@ const CasesList: React.FC = ({ searchQuery, isLockedVisitPlanStatus, selectedTab, - nearbyCases + nearbyCases, ]); useEffect(() => { @@ -410,7 +412,7 @@ const CasesList: React.FC = ({ {visitPlansUpdating ? ( ) : null} - + {filteredCasesListWithCTA.length > 0 ? ( = ({ }} visible={showFilterModal} > - { - setShowFilterModal((prev) => !prev); - firePageLoadEvent(); - clearBottomSheet(); - }} - isVisitPlan={isVisitPlan} - isAgentDashboard={isAgentDashboard} - /> + + { + setShowFilterModal((prev) => !prev); + firePageLoadEvent(); + clearBottomSheet(); + }} + isVisitPlan={isVisitPlan} + isAgentDashboard={isAgentDashboard} + /> + ( @@ -516,22 +520,22 @@ const styles = StyleSheet.create({ opacity: 0.6, }, list: { - paddingHorizontal: 12, + paddingHorizontal: 16, paddingTop: HEADER_HEIGHT_MAX, - paddingBottom: 5, + paddingBottom: 10, }, visitPlanList: { - paddingHorizontal: 12, + paddingHorizontal: 16, paddingTop: VISIT_PLAN_HEADER_HEIGHT_MAX, paddingBottom: 10, }, listWithQuickFilters: { - paddingHorizontal: 12, + paddingHorizontal: 16, paddingTop: HEADER_HEIGHT_MAX_WITH_QUICK_FILTERS, - paddingBottom: 5, + paddingBottom: 10, }, visitPlanListWithQuickFilters: { - paddingHorizontal: 12, + paddingHorizontal: 16, paddingTop: VISIT_PLAN_HEADER_HEIGHT_MAX_WITH_QUICK_FILTERS, paddingBottom: 10, }, diff --git a/src/screens/allCases/Escalation/Escalation.tsx b/src/screens/allCases/Escalation/Escalation.tsx new file mode 100644 index 00000000..3fdaf67e --- /dev/null +++ b/src/screens/allCases/Escalation/Escalation.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { View } from 'react-native'; +import EscalationItem from './EscalationItem'; +import { EscalationData } from '@screens/caseDetails/interface'; + +interface IEscalation { + escalationData?: EscalationData; +} + +const Escalation = (props: IEscalation) => { + const { escalationData } = props; + const isActiveEscalationCase = Number(escalationData?.activeEscalationCount) > 0; + const activeEscalationCount = Number(escalationData?.activeEscalationCount); + const pastEscalationCount = Number(escalationData?.pastEscalationCount); + const totalEscalationsCount = activeEscalationCount + pastEscalationCount; + + if (!escalationData || totalEscalationsCount === 0) { + return null; + } + + return ( + + {isActiveEscalationCase ? ( + + ) : ( + + )} + + ); +}; + +export default Escalation; diff --git a/src/screens/allCases/Escalation/EscalationItem.tsx b/src/screens/allCases/Escalation/EscalationItem.tsx new file mode 100644 index 00000000..264348e4 --- /dev/null +++ b/src/screens/allCases/Escalation/EscalationItem.tsx @@ -0,0 +1,62 @@ +import FlagIcon from '@assets/icons/FlagIcon'; +import { COLORS } from '@rn-ui-lib/colors'; +import Text from '@rn-ui-lib/components/Text'; +import { GenericStyles } from '@rn-ui-lib/styles'; +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { View } from 'react-native'; + +interface IEscalationItem { + escalationText: string; + isActiveEscalationCase?: boolean; +} + +const EscalationItem = (props: IEscalationItem) => { + const { escalationText, isActiveEscalationCase = false } = props; + return ( + + + + {escalationText} + + + ); +}; + +const styles = StyleSheet.create({ + escalationContainer: { + height: 30, + paddingLeft: 12, + paddingRight: 8, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + borderWidth: 1, + }, + escalationText: { + marginLeft: 4, + }, + redContainer: { + borderColor: COLORS.BORDER.RED, + backgroundColor: COLORS.BACKGROUND.RED, + }, + yellowContainer: { + borderColor: COLORS.BORDER.YELLOW, + backgroundColor: COLORS.BACKGROUND.YELLOW_LIGHT, + }, +}); + +export default EscalationItem; diff --git a/src/screens/allCases/Filters.tsx b/src/screens/allCases/Filters.tsx index adaf65db..f40395a2 100644 --- a/src/screens/allCases/Filters.tsx +++ b/src/screens/allCases/Filters.tsx @@ -100,7 +100,6 @@ const Filters: React.FC = ({ diff --git a/src/screens/allCases/ListItem.tsx b/src/screens/allCases/ListItem.tsx index 070bb404..6337588d 100644 --- a/src/screens/allCases/ListItem.tsx +++ b/src/screens/allCases/ListItem.tsx @@ -24,7 +24,7 @@ import { addClickstreamEvent } from '../../services/clickstreamEventService'; import Tag, { TagVariant } from '../../../RN-UI-LIB/src/components/Tag'; import { formatAmount } from '../../../RN-UI-LIB/src/utlis/amount'; import RoundCheckIcon from '../../../RN-UI-LIB/src/Icons/RoundCheckIcon'; -import { getDocumentList } from '../../components/utlis/commonFunctions'; +import { getDocumentList, pluralise } from '../../components/utlis/commonFunctions'; import { toast } from '../../../RN-UI-LIB/src/components/toast'; import { COMPLETED_STATUSES, TABS_KEYS, TAG_CONTAINER_WIDTH, ToastMessages } from './constants'; import { VisitPlanStatus } from '../../reducer/userSlice'; @@ -85,6 +85,7 @@ const ListItem: React.FC = (props) => { totalOverdueAmount, distanceInKm, escalationData, + daysTillDeallocation } = caseListItemDetailObj; const isVisitPlanStatusLocked = useAppSelector( @@ -335,6 +336,12 @@ const ListItem: React.FC = (props) => { {' '}Bucket {dpdBucket} )} + {daysTillDeallocation && is1To30FieldAgent ? ( + + Deallocation in {daysTillDeallocation} day + {pluralise(daysTillDeallocation, '', 's')} + + ) : null} {caseInteractionStatus ? ( {caseInteractionStatus} diff --git a/src/screens/allCases/constants.ts b/src/screens/allCases/constants.ts index be787421..fd95ee71 100644 --- a/src/screens/allCases/constants.ts +++ b/src/screens/allCases/constants.ts @@ -95,6 +95,11 @@ export const ToastMessages = { WHATSAPP_SHARE_SUCCESS: 'Document shared successfully via WhatsApp', WHATSAPP_SHARE_FAILURE: 'Document sharing failed via WhatsApp', ERROR_FETCHING_MULTILINGUAL_DOC : 'Error fetching multilingual document', + POST_OPERATIVE_HOURS_ACTIVITY: 'Submission failed! You can add feedback only during work hours (8 AM to 6:55 PM)', + DISABLE_ADD_FEEDBACK_AFTER_POST_OPERATIVE_HOURS: 'You will be able to add feedback only during work hours (8 AM to 6:55 PM)', + DISABLE_PAYMENT_LINK_SHARING_POST_OPERATIVE_HOURS: 'You can share payment link only during work hours (8 AM to 6:55 PM)', + DISABLE_CALLING_POST_OPERATIVE_HOURS: 'You can contact customer only during work hours (8 AM to 6:55 PM)', + DISABLE_SHARING_DOCUMENTS_POST_OPERATIVE_HOURS: 'You will be able to share documents only during work hours (8 AM to 6:55 PM)' }; export enum BOTTOM_TAB_ROUTES { diff --git a/src/screens/allCases/index.tsx b/src/screens/allCases/index.tsx index 6959e0eb..47036bd4 100644 --- a/src/screens/allCases/index.tsx +++ b/src/screens/allCases/index.tsx @@ -125,7 +125,6 @@ const AllCasesMain = () => { initCrashlytics(userState); } dispatch(setVisitPlansUpdating(false)); - dispatch(setLoading(false)); dispatch(resetTodoList()); dispatch(resetSelectedTodoList()); }, []); diff --git a/src/screens/allCases/interface.ts b/src/screens/allCases/interface.ts index 848aebbf..ed99540e 100644 --- a/src/screens/allCases/interface.ts +++ b/src/screens/allCases/interface.ts @@ -355,3 +355,29 @@ export interface IEscalationSummary { activeEscalationCount: number; recentEscalationDetails: IRecentEscalationDetails; } + +export interface ICaseListItem { + caseListItemDetailObj: ICaseItemCaseDetailObj; + isTodoItem?: boolean; + isCompleted?: boolean; + shouldBatchAvatar?: boolean; + allCasesView?: boolean; + isAgentDashboard?: boolean; + nearbyCaseView?: boolean; + isVisitPlan?: boolean; +} + +export interface ICaseDetailKeyValue { + title: string; + value: string; + showDot?: boolean; +} + +export interface ICaseStatus { + caseListItemDetailObj: ICaseItemCaseDetailObj; + isVisitPlan?: boolean; +} + +export interface IFeedbackStatus{ + caseListItemDetailObj: ICaseItemCaseDetailObj; +} \ No newline at end of file diff --git a/src/screens/allCases/utils.ts b/src/screens/allCases/utils.ts index fc1070e6..42e18ce2 100644 --- a/src/screens/allCases/utils.ts +++ b/src/screens/allCases/utils.ts @@ -8,6 +8,7 @@ import { FeedbackStatus, IDocumentItem, INearbyCaseItemObj, + PaymentStatus, } from '../caseDetails/interface'; import { BOTTOM_TAB_ROUTES, @@ -28,6 +29,8 @@ import { logError } from '@components/utlis/errorUtils'; import { setAgentsDocumentsData, setDocumentsData } from '@reducers/documentsSlice'; import { useWindowDimensions } from 'react-native'; import { toast } from '@rn-ui-lib/components/toast'; +import { TagVariant } from '@rn-ui-lib/components/Tag'; +import { COLORS } from '@rn-ui-lib/colors'; export const getAttemptedList = ( filteredCasesList: ICaseItem[], @@ -322,3 +325,22 @@ export const calculateBottomSheetHeight = (rowLength = 0) => { const dynamicHeight = ((rowLength * rowHeight + headerOffset) / SCREEN_HEIGHT) * 100; return Math.min(dynamicHeight, maxHeightPercentage); }; + +export const paymentStatusMapping: Record< + PaymentStatus, + { label: PaymentStatus | string; variant: TagVariant } +> = { + [PaymentStatus.Paid]: { label: PaymentStatus.Paid, variant: TagVariant.success }, + [PaymentStatus['Partially Paid']]: { + label: PaymentStatus['Partially Paid'], + variant: TagVariant.yellow, + }, + [PaymentStatus.Unpaid]: { label: PaymentStatus.Unpaid, variant: TagVariant.alert }, + [PaymentStatus.Closed]: { label: PaymentStatus.Closed, variant: TagVariant.error }, +}; + +export const feedbackStatusColorMapping = { + green: COLORS.TEXT.GREEN, + red: COLORS.TEXT.RED, + gray: COLORS.TEXT.BLACK, +}; diff --git a/src/screens/auth/AuthRouter.tsx b/src/screens/auth/AuthRouter.tsx index 8e8dd9af..41fa4ec1 100644 --- a/src/screens/auth/AuthRouter.tsx +++ b/src/screens/auth/AuthRouter.tsx @@ -26,6 +26,9 @@ import { getSyncTime } from '@hooks/capturingApi'; import ModalWrapperForAlfredV2 from '@common/ModalWrapperForAlfredV2'; import IdCardApproved from '@screens/AgentIdCard/IdCardStatus/IdCardApproved'; import CallingFeedbackNudgeBottomSheet from '@screens/caseDetails/CallingFlow/BottomSheets/CallingFeedbackNudgeBottomSheet'; +import { handlePostOperativeHourActivity } from '@screens/caseDetails/utils/postOperationalHourActions'; +import { setPostOperationalHourRestrictions } from '@reducers/postOperationalHourRestrictionsSlice'; +import { logError } from '@components/utlis/errorUtils'; function AuthRouter() { const dispatch = useAppDispatch(); @@ -67,7 +70,15 @@ function AuthRouter() { } }; const CHECK_ATTENDANCE_TIME = 10000; - + const syncTimeToCheckPostOperativeHours = async () => { + try { + const timestamp = await getSyncTime(); + const getActivityStatus = handlePostOperativeHourActivity(timestamp); + dispatch(setPostOperationalHourRestrictions(getActivityStatus)); + } catch (error) { + logError(error as Error); + } + }; useEffect(() => { const appStateChange = AppState.addEventListener('change', async (change) => { if (change !== 'active') return; @@ -82,6 +93,7 @@ function AuthRouter() { } alfredSetCodePushVersion(getAppVersion()); }); + syncTimeToCheckPostOperativeHours(); return () => { appStateChange.remove(); }; diff --git a/src/screens/auth/ProtectedRouter.tsx b/src/screens/auth/ProtectedRouter.tsx index aab806ef..3accc2c0 100644 --- a/src/screens/auth/ProtectedRouter.tsx +++ b/src/screens/auth/ProtectedRouter.tsx @@ -5,11 +5,8 @@ import { import React, { useEffect } from 'react'; import { _map, MILLISECONDS_IN_A_MINUTE } from '../../../RN-UI-LIB/src/utlis/common'; import { getNotifications, notificationAction } from '../../action/notificationActions'; -import { LocalStorageKeys, SCREEN_ANIMATION_DURATION } from '../../common/Constants'; -import { - getScreenFocusListenerObj, - setAsyncStorageItem, -} from '../../components/utlis/commonFunctions'; +import { SCREEN_ANIMATION_DURATION } from '../../common/Constants'; +import { getScreenFocusListenerObj } from '../../components/utlis/commonFunctions'; import { useAppDispatch, useAppSelector } from '../../hooks'; import useIsOnline from '../../hooks/useIsOnline'; import AllCasesMain from '../allCases'; @@ -18,19 +15,13 @@ import ImpersonatedUser from '../impersonatedUser'; import Notifications from '../notifications'; import TodoList from '../todoList/TodoList'; import { getAgentDetail } from '../../action/authActions'; -import { CaptureGeolocation, DeviceLocation } from '@components/form/services/geoLocation.service'; -import { setDeviceGeolocation } from '@reducers/foregroundServiceSlice'; -import CallingAgentRoutes from '../../miniModules/callingAgents/routes'; import NearbyCases from '@screens/allCases/NearbyCases'; +import CallingAgentRoutes from '../../miniModules/callingAgents/routes'; import usePolling from '@hooks/usePolling'; import useResyncFirebase from '@hooks/useResyncFirebase'; import CaseDetailStack from '@screens/caseDetails/CaseDetailStack'; import { getFirestoreResyncIntervalInMinutes } from '@common/AgentActivityConfigurableConstants'; import { getSelfieDocument } from '@actions/profileActions'; -import getLitmusExperimentResult, { - LitmusExperimentName, - LitmusExperimentNameMap, -} from '@services/litmusExperiments.service'; import { GLOBAL } from '@constants/Global'; const Stack = createNativeStackNavigator(); @@ -84,19 +75,12 @@ const ProtectedRouter = () => { const resyncFirebase = useResyncFirebase(); - const stopLocationPolling = usePolling(() => { - CaptureGeolocation.watchLocation((location: DeviceLocation) => { - return dispatch(setDeviceGeolocation(location)); - }); - }, 3 * MILLISECONDS_IN_A_MINUTE); - const stopFirebaseResyncPolling = usePolling(() => { void resyncFirebase(); }, getFirestoreResyncIntervalInMinutes() * MILLISECONDS_IN_A_MINUTE); useEffect(() => { return () => { - stopLocationPolling(); stopFirebaseResyncPolling(); }; }, []); diff --git a/src/screens/caseDetails/CallingFlow/CallHistory/CallHistoryItem.tsx b/src/screens/caseDetails/CallingFlow/CallHistory/CallHistoryItem.tsx index 811e0fbe..b2fc3b33 100644 --- a/src/screens/caseDetails/CallingFlow/CallHistory/CallHistoryItem.tsx +++ b/src/screens/caseDetails/CallingFlow/CallHistory/CallHistoryItem.tsx @@ -17,6 +17,8 @@ import { CLICKSTREAM_EVENT_NAMES } from '@common/Constants'; import { setCallCannotInitiateBottomSheet } from '@reducers/activeCallSlice'; import PhoneStateModule from '@components/utlis/PhoneState'; import { logError } from '@components/utlis/errorUtils'; +import { handlePostOperativeHourActivities } from '@screens/addressGeolocation/utils/operativeHourUtils'; +import { ToastMessages } from '@screens/allCases/constants'; const CallHistoryItem = (props: ICallHistoryItem) => { const { callHistory, isLastElement, caseId } = props; @@ -32,9 +34,15 @@ const CallHistoryItem = (props: ICallHistoryItem) => { const customerName = useAppSelector( (state) => state?.allCases?.caseDetails?.[caseId]?.customerName ); + const caseBusinessVertical = useAppSelector( + (state) => state?.allCases?.caseDetails?.[caseId]?.businessVertical + ); const isCallCreationLoading = useAppSelector((state) => state?.activeCall?.isCallCreationLoading); const isCallActive = useAppSelector((state) => state?.activeCall?.activeCallDetails?.callActive); + const isCallingDisabledPostOperativeHour = useAppSelector( + (state) => state?.postOperationalHourRestrictionsSlice?.postOperationalHourRestrictions + ); const dispatch = useAppDispatch(); @@ -42,6 +50,10 @@ const CallHistoryItem = (props: ICallHistoryItem) => { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_CALL_HISTORY_CALL_BUTTON_CLICKED, { callVia: !isCallRecordingCosmosExotelEnabled ? CallVia.ANDROID : CallVia.EXOTEL, }); + if (isCallingDisabledPostOperativeHour) { + handlePostOperativeHourActivities(ToastMessages.DISABLE_CALLING_POST_OPERATIVE_HOURS); + return; + } PhoneStateModule.getCurrentCallState() .then((callState: string) => { @@ -53,7 +65,13 @@ const CallHistoryItem = (props: ICallHistoryItem) => { dispatch( makeACallToCustomer( { referenceId: telephoneReferenceId, loanAccountNumber }, - { caseId, customerName, isCallHistory: true } + { + caseId, + customerName, + isCallHistory: true, + caseBusinessVertical, + telephoneReferenceId, + } ) ); } else { @@ -83,6 +101,7 @@ const CallHistoryItem = (props: ICallHistoryItem) => { GenericStyles.flex30, GenericStyles.alignItemsFlexEnd, GenericStyles.justifyContentSpaceBetween, + isCallingDisabledPostOperativeHour && styles.disabledIconButton, ]} > = (props) => { const isCallActive = useAppSelector( (state: RootState) => state?.activeCall?.activeCallDetails?.callActive ); + const caseBusinessVertical = useAppSelector( + (state) => state?.allCases?.caseDetails?.[caseId]?.businessVertical + ); const { loanAccountNumber } = caseDetail; const opacityAnimation = useRef(new Animated.Value(0.8)).current; @@ -59,7 +63,6 @@ const CollectionCaseDetails: React.FC = (props) => { useEffect(() => { if (caseId) dispatch(setSelectedCaseId(caseId)); - dispatch(getFeedbackHistory(loanAccountNumber)); return () => { dispatch(setSelectedCaseId('')); }; @@ -68,8 +71,9 @@ const CollectionCaseDetails: React.FC = (props) => { useFocusEffect( React.useCallback(() => { if (loanAccountNumber) { - dispatch(getAddressesAndGeolocations(loanAccountNumber)); - dispatch(getUngroupedAddress(loanAccountNumber)); + dispatch(getAddressesAndGeolocations(loanAccountNumber, caseId, caseBusinessVertical)); + dispatch(getUngroupedAddress(loanAccountNumber, caseId, caseBusinessVertical)); + dispatch(getSkipTracingAddress(loanAccountNumber, caseId, caseBusinessVertical)); dispatch(getFeedbackHistory(loanAccountNumber)); } }, [loanAccountNumber]) @@ -130,6 +134,7 @@ const CollectionCaseDetails: React.FC = (props) => { ) : null} + diff --git a/src/screens/caseDetails/CollectionCaseDetailFooter.tsx b/src/screens/caseDetails/CollectionCaseDetailFooter.tsx index d9c9e310..c1893500 100644 --- a/src/screens/caseDetails/CollectionCaseDetailFooter.tsx +++ b/src/screens/caseDetails/CollectionCaseDetailFooter.tsx @@ -14,6 +14,8 @@ import React, { useEffect } from 'react'; import { StyleSheet, View } from 'react-native'; import { CaseDetailStackEnum } from './CaseDetailStack'; import { captureLatestDeviceLocation } from '@components/form/services/geoLocation.service'; +import { handlePostOperativeHourActivities } from '@screens/addressGeolocation/utils/operativeHourUtils'; +import { ToastMessages } from '@screens/allCases/constants'; interface ICollectionCaseDetailFooter { caseId: string; @@ -29,7 +31,9 @@ const CollectionCaseDetailFooter = ({ caseId, notificationId }: ICollectionCaseD (state: RootState) => state.case.caseForm?.[caseId]?.[TaskTitleUIMapping.COLLECTION_FEEDBACK] ); const dispatch = useAppDispatch(); - + const addingNewFeedbackDisabled = useAppSelector( + (state) => state?.postOperationalHourRestrictionsSlice?.postOperationalHourRestrictions + ); const handleCustomerCall = () => { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_CALL_CUSTOMER_CLICKED, { caseId: caseId, @@ -88,7 +92,10 @@ const CollectionCaseDetailFooter = ({ caseId, notificationId }: ICollectionCaseD }); } }, [notificationId]); - + const handleDisableAddFeedback = () => + handlePostOperativeHourActivities( + ToastMessages.DISABLE_ADD_FEEDBACK_AFTER_POST_OPERATIVE_HOURS + ); return (