diff --git a/src/action/agentPerformanceAction.ts b/src/action/agentPerformanceAction.ts new file mode 100644 index 00000000..752749f3 --- /dev/null +++ b/src/action/agentPerformanceAction.ts @@ -0,0 +1,52 @@ +import axiosInstance, { ApiKeys, API_STATUS_CODE, getApiUrl } from '../components/utlis/apiHelper'; +import { logError } from '../components/utlis/errorUtils'; + +export const getAgentDetail = () => { + const url = getApiUrl(ApiKeys.GET_AGENT_DETAIL); + return axiosInstance + .get(url) + .then((response) => { + if (response.status === API_STATUS_CODE.OK) { + return response.data; + } + throw response; + }) + .catch((err) => { + logError(err); + throw new Error(err); + }); +}; + +export const getPerformanceMetrics = () => { + const url = getApiUrl(ApiKeys.GET_PERFORMANCE_METRICS); + return axiosInstance + .get(url) + .then((response) => { + if (response.status === API_STATUS_CODE.OK) { + return response.data; + } + throw response; + }) + .catch((err) => { + logError(err); + throw new Error(err); + }); +}; + +export const getCashCollectedSplit = (payload: any) => { + const url = getApiUrl(ApiKeys.GET_CASH_COLLECTED); + return axiosInstance + .get(url, { + params: payload, + }) + .then((response) => { + if (response.status === API_STATUS_CODE.OK) { + return response.data; + } + throw response; + }) + .catch((err) => { + logError(err); + throw new Error(err); + }); +}; diff --git a/src/assets/icons/CashCollectedIcon.tsx b/src/assets/icons/CashCollectedIcon.tsx new file mode 100644 index 00000000..c53dc945 --- /dev/null +++ b/src/assets/icons/CashCollectedIcon.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Svg, { Rect, G, Path } from 'react-native-svg'; + +const CashCollectedIcon = () => { + return ( + + + + + + + ); +}; + +export default CashCollectedIcon; diff --git a/src/assets/icons/DashboardIcon.tsx b/src/assets/icons/DashboardIcon.tsx new file mode 100644 index 00000000..0a3e5f70 --- /dev/null +++ b/src/assets/icons/DashboardIcon.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import Svg, { Path } from 'react-native-svg'; +import { ITabIconProps } from '../../../RN-UI-LIB/src/components/bottomNavigator'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; + +const DashboardIcon: React.FC = ({ + size = 20, + color = COLORS.TEXT.LIGHT, + focused, +}) => { + return ( + + + + + ); +}; +export default DashboardIcon; diff --git a/src/assets/icons/EmiCollectedIcon.tsx b/src/assets/icons/EmiCollectedIcon.tsx new file mode 100644 index 00000000..c73f6736 --- /dev/null +++ b/src/assets/icons/EmiCollectedIcon.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import Svg, { Rect, G, Path, Mask } from 'react-native-svg'; + +const EmiCollectedIcon = () => { + return ( + + + + + + + + + + + + + + ); +}; + +export default EmiCollectedIcon; diff --git a/src/assets/icons/FilledStarIcon.tsx b/src/assets/icons/FilledStarIcon.tsx new file mode 100644 index 00000000..f9a8c1a8 --- /dev/null +++ b/src/assets/icons/FilledStarIcon.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import Svg, { Rect, G, Path, Mask } from 'react-native-svg'; + +const FilledStarIcon = () => { + return ( + + + + + + + + + + + + + + ); +}; + +export default FilledStarIcon; diff --git a/src/common/Constants.ts b/src/common/Constants.ts index 8b02119b..909cda4f 100644 --- a/src/common/Constants.ts +++ b/src/common/Constants.ts @@ -521,6 +521,12 @@ export const CLICKSTREAM_EVENT_NAMES = { name: 'FA_SUBMIT_ANYWAYS_FAILED', description: 'Feedback submit anyway failed', }, + + // Agent Dashboard + AD_FIREBASE_SYNC_ISSUE: { + name: 'AD_FIREBASE_SYNC_ISSUE', + description: 'Agent dashboard firebase sync issues', + }, } as const; export enum MimeType { diff --git a/src/components/screens/allCases/allCasesFilters/FilterUtils.ts b/src/components/screens/allCases/allCasesFilters/FilterUtils.ts index c354c72d..212ec55e 100644 --- a/src/components/screens/allCases/allCasesFilters/FilterUtils.ts +++ b/src/components/screens/allCases/allCasesFilters/FilterUtils.ts @@ -26,12 +26,12 @@ export const evaluateFilterForCases = ( }); Object.keys(selectedFilters).forEach((key) => { const fieldToCompareIdx = - filters[key].fieldsToCompare.length > 1 + filters[key]?.fieldsToCompare.length > 1 ? filters[key].fieldsToCompare.findIndex((field) => { return field.caseType === caseRecord.caseType; }) : 0; - switch (filters[key].filterType) { + switch (filters[key]?.filterType) { case FILTER_TYPES.DATE: switch (filters[key].operator) { case CONDITIONAL_OPERATORS.EQUALS: @@ -63,7 +63,7 @@ export const evaluateFilterForCases = ( break; } case FILTER_TYPES.STRING: - switch (filters[key].operator) { + switch (filters[key]?.operator) { case CONDITIONAL_OPERATORS.EQUALS: if (selectedFilters[key]) { if (typeof selectedFilters[key] === 'string') { diff --git a/src/components/utlis/apiHelper.ts b/src/components/utlis/apiHelper.ts index 19422a6c..64c6b240 100644 --- a/src/components/utlis/apiHelper.ts +++ b/src/components/utlis/apiHelper.ts @@ -54,6 +54,9 @@ export enum ApiKeys { GLOBAL_CONFIG = 'GLOBAL_CONFIG', UPLOAD_IMAGE_ID = 'UPLOAD_IMAGE_ID', GET_DOCUMENTS = 'GET_DOCUMENTS', + GET_AGENT_DETAIL = 'GET_AGENT_DETAIL', + GET_PERFORMANCE_METRICS = 'GET_PERFORMANCE_METRICS', + GET_CASH_COLLECTED = 'GET_CASH_COLLECTED', } export const API_URLS: Record = {} as Record; @@ -93,6 +96,9 @@ API_URLS[ApiKeys.UPLOAD_FEEDBACK_IMAGES] = '/feedback/persist-original-images'; API_URLS[ApiKeys.GLOBAL_CONFIG] = '/global-config'; API_URLS[ApiKeys.UPLOAD_IMAGE_ID] = '/user/documents/selfie'; API_URLS[ApiKeys.GET_DOCUMENTS] = '/user/documents'; +API_URLS[ApiKeys.GET_AGENT_DETAIL] = '/agent-info'; +API_URLS[ApiKeys.GET_PERFORMANCE_METRICS] = '/agent-performance'; +API_URLS[ApiKeys.GET_CASH_COLLECTED] = '/cash-collected-split'; export const API_STATUS_CODE = { OK: 200, diff --git a/src/reducer/agentPerformanceSlice.ts b/src/reducer/agentPerformanceSlice.ts new file mode 100644 index 00000000..c01a1ac1 --- /dev/null +++ b/src/reducer/agentPerformanceSlice.ts @@ -0,0 +1,52 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { CashCollectedDataType, PerformanceDataType } from '../screens/Dashboard/interface'; + +interface AgentPerformanceInterface { + performanceData: PerformanceDataType; + cashCollectedData: Array; +} +const initialState: AgentPerformanceInterface = { + performanceData: { + cases: { + visitedCases: 0, + unvisitedCaseIds: [], + unvisitedCases: 0, + contactableCases: 0, + nonContactableCaseIds: [], + nonContactableCases: 0, + totalPtp: 0, + nonPtpCaseIds: [], + nonPtp: 0, + convertedPtp: 0, + brokenPtpCaseIds: [], + brokenPtp: 0, + totalEmi: 0, + atleastOneEmiCollected: 0, + }, + totalCashCollected: 0, + totalCashCollectedCaseIds: [], + performanceLevel: { + currentLevel: 0, + totalLevel: 0, + }, + lastUpdatedAt: '', + }, + cashCollectedData: [], +}; + +const agentPerformanceSlice = createSlice({ + name: 'agentPerformance', + initialState, + reducers: { + setPerformanceData: (state, action) => { + state.performanceData = action.payload; + }, + setCashCollectedData: (state, action) => { + state.cashCollectedData = action.payload; + }, + }, +}); + +export const { setPerformanceData, setCashCollectedData } = agentPerformanceSlice.actions; + +export default agentPerformanceSlice.reducer; diff --git a/src/reducer/allCasesSlice.ts b/src/reducer/allCasesSlice.ts index 867694a7..7c681a35 100644 --- a/src/reducer/allCasesSlice.ts +++ b/src/reducer/allCasesSlice.ts @@ -21,6 +21,12 @@ import { CollectionCaseWidgetId, CommonCaseWidgetId } from '../types/template.ty import { IAvatarUri } from '../action/caseListAction'; export type ICasesMap = { [key: string]: ICaseItem }; + +interface FilteredListToast { + showToast: boolean; + caseType: string; +} + interface IAllCasesSlice { casesList: ICaseItem[]; casesListMap: ICasesMap; @@ -42,6 +48,7 @@ interface IAllCasesSlice { completedList: ICaseItem[]; pinnedList: ICaseItem[]; newVisitedCases: string[]; + filteredListToast: FilteredListToast; } const initialState: IAllCasesSlice = { @@ -65,6 +72,10 @@ const initialState: IAllCasesSlice = { completedList: [], pinnedList: [], newVisitedCases: [], + filteredListToast: { + showToast: false, + caseType: '', + }, }; const getCaseListComponents = (casesList: ICaseItem[], caseDetails: Record) => { @@ -573,6 +584,9 @@ const allCasesSlice = createSlice({ } }); }, + setFilteredListToast: (state, action) => { + state.filteredListToast = action.payload; + }, }, }); @@ -594,6 +608,7 @@ export const { resetNewVisitedCases, syncCasesByFallback, setCasesImageUri, + setFilteredListToast, } = allCasesSlice.actions; export default allCasesSlice.reducer; diff --git a/src/reducer/userSlice.ts b/src/reducer/userSlice.ts index b7fe08af..9a64fb09 100644 --- a/src/reducer/userSlice.ts +++ b/src/reducer/userSlice.ts @@ -47,6 +47,7 @@ export interface IUserSlice extends IUser { clickstreamEvents: IClickstreamEvents[]; isImpersonated: boolean; lock: ILockData; + isExternalAgent: boolean; } const initialState: IUserSlice = { @@ -59,6 +60,7 @@ const initialState: IUserSlice = { lock: { visitPlanStatus: VisitPlanStatus.UNLOCKED, }, + isExternalAgent: false, }; export const userSlice = createSlice({ @@ -86,9 +88,12 @@ export const userSlice = createSlice({ state.lock = action.payload; } }, + setIsExternalAgent: (state, action) => { + state.isExternalAgent = action.payload; + }, }, }); -export const { setAuthData, setDeviceId, setLockData } = userSlice.actions; +export const { setAuthData, setDeviceId, setLockData, setIsExternalAgent } = userSlice.actions; export default userSlice.reducer; diff --git a/src/screens/Dashboard/DashboardHeader.tsx b/src/screens/Dashboard/DashboardHeader.tsx new file mode 100644 index 00000000..8fed0bfc --- /dev/null +++ b/src/screens/Dashboard/DashboardHeader.tsx @@ -0,0 +1,60 @@ +import { Animated, StyleSheet, View } from 'react-native'; +import React from 'react'; +import Heading from '../../../RN-UI-LIB/src/components/Heading'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; +import NotificationMenu from '../../components/notificationMenu'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import { PerfomanceHeaderTitle } from './constants'; +import { useAppSelector } from '../../hooks'; +import { sanitizeString } from '../../components/utlis/commonFunctions'; +import { BUSINESS_TIME_FORMAT, dateFormat } from '../../../RN-UI-LIB/src/utlis/dates'; + +const DashboardHeader = () => { + const performanceData = useAppSelector((state) => state.agentPerformance.performanceData); + const { lastUpdatedAt } = performanceData; + return ( + + + + + {PerfomanceHeaderTitle} + + + Last updated at{' '} + {sanitizeString(`${dateFormat(new Date(lastUpdatedAt), BUSINESS_TIME_FORMAT)}`)} + + + + + + ); +}; + +const styles = StyleSheet.create({ + dashboardHeaderContainer: { + justifyContent: 'flex-end', + position: 'absolute', + top: 0, + zIndex: 10, + width: '100%', + height: 'auto', + backgroundColor: COLORS.BACKGROUND.INDIGO, + }, + headerLabel: { + color: COLORS.BACKGROUND.PRIMARY, + }, + subtitle: { + color: COLORS.TEXT.GREY_1, + }, +}); + +export default DashboardHeader; diff --git a/src/screens/Dashboard/PerformanceCard.tsx b/src/screens/Dashboard/PerformanceCard.tsx new file mode 100644 index 00000000..a67a9588 --- /dev/null +++ b/src/screens/Dashboard/PerformanceCard.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import Heading from '../../../RN-UI-LIB/src/components/Heading'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import Chevron from '../../../RN-UI-LIB/src/Icons/Chevron'; +import { GenericStyles, getShadowStyle } from '../../../RN-UI-LIB/src/styles'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import { navigateToScreen } from '../../components/utlis/navigationUtlis'; +import { useAppDispatch, useAppSelector } from '../../hooks'; +import { setFilteredListToast } from '../../reducer/allCasesSlice'; +import { setSelectedFilters } from '../../reducer/filtersSlice'; +import { BOTTOM_TAB_ROUTES } from '../allCases/constants'; +import { CurrentAllocationStatsFilterMap, CurrentAllocationStatsMap } from './interface'; +import { getPerformanceDetailFilter, getPerformanceDetails } from './utils'; + +const PerformanceCard = () => { + const { performanceData, filters } = useAppSelector((state) => ({ + performanceData: state.agentPerformance.performanceData, + filters: state.filters.filters, + })); + const { cases } = performanceData || {}; + const performanceDetails = getPerformanceDetails(cases); + + const dispatch = useAppDispatch(); + + return ( + + {performanceDetails.map((item) => ( + { + dispatch( + setSelectedFilters( + getPerformanceDetailFilter( + item.redirectionType, + Object.keys(filters['COMMON']?.filters)?.includes( + CurrentAllocationStatsFilterMap[item.redirectionType] + ) + ) + ) + ); + dispatch( + setFilteredListToast({ + showToast: true, + caseType: CurrentAllocationStatsMap[item.redirectionType], + }) + ); + navigateToScreen(BOTTOM_TAB_ROUTES.Cases); + }} + style={[ + getShadowStyle(2), + GenericStyles.br6, + GenericStyles.mt16, + GenericStyles.p12, + styles.pressableCard, + ]} + > + {item.totalConverted} + {item.convertedText} + + + + {item.totalNotConverted} + {item.notConvertedText} + + + + + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexWrap: 'wrap', + justifyContent: 'space-between', + paddingTop: 10, + }, + title: { + fontSize: 13, + marginTop: 8, + marginBottom: 8, + }, + totalCount: { + color: COLORS.TEXT.DARK, + fontSize: 36, + fontWeight: '600', + paddingTop: 16, + }, + rightIcon: { + marginTop: 2, + }, + subTitle: { + color: COLORS.BASE.BLUE, + }, + fw700: { + fontWeight: '700', + }, + pressableCard: { + backgroundColor: COLORS.BACKGROUND.PRIMARY, + width: '47%', + }, +}); + +export default PerformanceCard; diff --git a/src/screens/Dashboard/PerformanceItem.tsx b/src/screens/Dashboard/PerformanceItem.tsx new file mode 100644 index 00000000..0a20dabc --- /dev/null +++ b/src/screens/Dashboard/PerformanceItem.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; +import { navigateToScreen } from '../../components/utlis/navigationUtlis'; +import { PerformanceItemProps } from './interface'; + +const PerformanceItem = (props: PerformanceItemProps) => { + const { title, icon, navigateTo, rightSideView } = props; + return ( + navigateTo && navigateToScreen(navigateTo)} + > + + {icon} + {title} + + {rightSideView} + + ); +}; + +const styles = StyleSheet.create({ + performanceContainer: { + marginTop: 14, + marginBottom: 14, + }, +}); + +export default PerformanceItem; diff --git a/src/screens/Dashboard/PerformanceOverview.tsx b/src/screens/Dashboard/PerformanceOverview.tsx new file mode 100644 index 00000000..5df2997a --- /dev/null +++ b/src/screens/Dashboard/PerformanceOverview.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import Chevron from '../../../RN-UI-LIB/src/Icons/Chevron'; +import { GenericStyles, getShadowStyle } from '../../../RN-UI-LIB/src/styles'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import CashCollectedIcon from '../../assets/icons/CashCollectedIcon'; +import EmiCollectedIcon from '../../assets/icons/EmiCollectedIcon'; +import PerformanceItem from './PerformanceItem'; +import FilledStarIcon from '../../assets/icons/FilledStarIcon'; +import { PerformanceDetails } from './constants'; +import { PageRouteEnum } from '../auth/ProtectedRouter'; +import { useAppSelector } from '../../hooks'; +import { formatAmount } from '../../../RN-UI-LIB/src/utlis/amount'; + +const PerformanceOverview = () => { + const performanceData = useAppSelector((state) => state.agentPerformance.performanceData); + const { totalCashCollected = 0, performanceLevel, cases } = performanceData || {}; + + return ( + + } + navigateTo={PageRouteEnum.CASH_COLLECTED} + rightSideView={ + + + {formatAmount(totalCashCollected, false)} + + + + + + } + /> + + } + rightSideView={ + + + {cases?.atleastOneEmiCollected}{' '} + + / {cases?.totalEmi} + + } + /> + + } + rightSideView={ + + + {performanceLevel?.currentLevel}{' '} + + + / {performanceLevel?.totalLevel} + + + } + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginTop: 100, + backgroundColor: COLORS.BACKGROUND.PRIMARY, + }, + leftContent: { + color: COLORS.TEXT.DARK, + }, + rightContent: { + color: COLORS.TEXT.BLACK, + }, + rightIcon: { + marginLeft: 10, + marginTop: 2, + }, + fw700: { + fontWeight: '700', + }, +}); + +export default PerformanceOverview; diff --git a/src/screens/Dashboard/constants.ts b/src/screens/Dashboard/constants.ts new file mode 100644 index 00000000..e3e320f1 --- /dev/null +++ b/src/screens/Dashboard/constants.ts @@ -0,0 +1,20 @@ +export const PerformanceDetails = { + totalCashCollected: 'Total cash collected', + caseWithAtleast1Emi: 'Cases with atleast 1 EMI collected', + performanceLevel: 'Performance level', +}; + +export const PerformanceCardDetails = { + visitedCases: 'Visited Cases', + unVisitedCases: 'unvisited', + contactableCases: 'Contactable cases', + nonContactableCases: 'non contactable', + totalPtp: 'Total PTPs', + nonPtp: 'non PTPs', + ptpConverted: 'PTPs converted', + brokenPtp: 'broken PTPs', +}; + +export const PerfomanceHeaderTitle = 'Performance summary'; + +export const NO_CASH_COLLECTED_FOUND = 'No Cash Collected'; diff --git a/src/screens/Dashboard/index.tsx b/src/screens/Dashboard/index.tsx new file mode 100644 index 00000000..52b59fb1 --- /dev/null +++ b/src/screens/Dashboard/index.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + RefreshControl, + SafeAreaView, + ScrollView, + StyleSheet, + View, +} from 'react-native'; +import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import { getPerformanceMetrics } from '../../action/agentPerformanceAction'; +import { logError } from '../../components/utlis/errorUtils'; +import { useAppDispatch, useAppSelector } from '../../hooks'; +import { setPerformanceData } from '../../reducer/agentPerformanceSlice'; +import DashboardHeader from './DashboardHeader'; +import PerformanceCard from './PerformanceCard'; +import PerformanceOverview from './PerformanceOverview'; +import { getFiltersCount } from './utils'; + +const Dashboard = () => { + const [refreshing, setRefreshing] = React.useState(false); + const [isLoading, setIsLoading] = useState(false); + const caseDetailsIds = useAppSelector((state) => Object.keys(state.allCases.caseDetails)); + + const dispatch = useAppDispatch(); + + const fetchAgentPerformanceMetrics = (callbackFn?: () => void) => { + setIsLoading(true); + getPerformanceMetrics() + .then((res) => { + const { + visitedCases, + contactableCases, + ptpCases, + convertedPtpCases, + atLeastOneEmiCollectedCases, + } = res?.casesSplit; + const { totalLevel, currentLevel } = res?.performanceLevel; + const { unVisitedCount, nonContactableCount, nonPtpCount, brokenPtpCount } = + getFiltersCount(caseDetailsIds, res?.casesSplit); + + dispatch( + setPerformanceData({ + cases: { + visitedCases, + unvisitedCases: unVisitedCount, + contactableCases, + nonContactableCases: nonContactableCount, + totalPtp: ptpCases, + nonPtp: nonPtpCount, + convertedPtp: convertedPtpCases, + brokenPtp: brokenPtpCount, + totalEmi: res?.data?.allCases, + atleastOneEmiCollected: atLeastOneEmiCollectedCases, + }, + performanceLevel: { + totalLevel, + currentLevel, + }, + totalCashCollected: res?.data?.totalCashCollected, + lastUpdatedAt: res?.data?.lastUpdatedAt, + }) + ); + }) + .catch((err) => { + logError(err); + }) + .finally(() => { + callbackFn && callbackFn(); + setIsLoading(false); + }); + }; + + useEffect(() => { + fetchAgentPerformanceMetrics(); + }, []); + + const onRefresh = React.useCallback(() => { + setRefreshing(true); + fetchAgentPerformanceMetrics(() => setRefreshing(false)); + }, []); + + if (isLoading && !refreshing) + return ( + + + + ); + + return ( + + + + } + > + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: COLORS.BACKGROUND.GREY_LIGHT, + position: 'relative', + }, +}); + +export default Dashboard; diff --git a/src/screens/Dashboard/interface.ts b/src/screens/Dashboard/interface.ts new file mode 100644 index 00000000..797df5d6 --- /dev/null +++ b/src/screens/Dashboard/interface.ts @@ -0,0 +1,76 @@ +import React from 'react'; + +export interface CasesType { + visitedCases: number; + unvisitedCases: number; + contactableCases: number; + nonContactableCases: number; + totalPtp: number; + nonPtp: number; + convertedPtp: number; + brokenPtp: number; + totalEmi: number; + atleastOneEmiCollected: number; + unvisitedCaseIds: Array; + nonContactableCaseIds: Array; + nonPtpCaseIds: Array; + brokenPtpCaseIds: Array; +} + +interface PerformanceLevelType { + totalLevel: number; + currentLevel: number; +} + +export interface PerformanceItemProps { + title: string; + icon: React.ReactNode; + navigateTo?: string; + rightSideView: React.ReactNode; +} + +export interface PerformanceDataType { + cases: CasesType; + performanceLevel: PerformanceLevelType; + totalCashCollected: number; + totalCashCollectedCaseIds: Array; + lastUpdatedAt: string; +} + +export interface PerformanceProps { + performanceData: PerformanceDataType | undefined; +} + +export enum CurrentAllocationStats { + UNVSIITED = 'UNVSIITED', + NON_CONTACTABLE = 'NON_CONTACTABLE', + NON_PTP = 'NON_PTP', + BROKEN_PTP = 'BROKEN_PTP', +} + +export enum CurrentAllocationStatsFilter { + VISIT_STATUS = 'VISIT_STATUS', + CONTACTABLE_STATUS = 'CONTACTABLE_STATUS', + PTP_STATUS = 'PTP_STATUS', + PTP_CONVERSION_STATUS = 'PTP_CONVERSION_STATUS', +} + +export interface CashCollectedDataType { + amountCollected: number; + isClosed: boolean; + caseId: string; +} + +export const CurrentAllocationStatsMap = { + [CurrentAllocationStats.UNVSIITED]: 'unvsited', + [CurrentAllocationStats.NON_CONTACTABLE]: 'non contactable', + [CurrentAllocationStats.NON_PTP]: 'non ptp', + [CurrentAllocationStats.BROKEN_PTP]: 'broken ptp', +}; + +export const CurrentAllocationStatsFilterMap = { + [CurrentAllocationStats.UNVSIITED]: CurrentAllocationStatsFilter.VISIT_STATUS, + [CurrentAllocationStats.NON_CONTACTABLE]: CurrentAllocationStatsFilter.CONTACTABLE_STATUS, + [CurrentAllocationStats.NON_PTP]: CurrentAllocationStatsFilter.PTP_STATUS, + [CurrentAllocationStats.BROKEN_PTP]: CurrentAllocationStatsFilter.PTP_CONVERSION_STATUS, +}; diff --git a/src/screens/Dashboard/utils.ts b/src/screens/Dashboard/utils.ts new file mode 100644 index 00000000..04b74807 --- /dev/null +++ b/src/screens/Dashboard/utils.ts @@ -0,0 +1,112 @@ +import { CLICKSTREAM_EVENT_NAMES } from '../../common/Constants'; +import { addClickstreamEvent } from '../../services/clickstreamEventService'; +import { CashCollectedItemType } from '../cashCollected/interface'; +import { PerformanceCardDetails } from './constants'; +import { CasesType, CurrentAllocationStats } from './interface'; + +export const getPerformanceDetailFilter = (item: CurrentAllocationStats, applyFilter: boolean) => { + if (!applyFilter) return []; + + switch (item) { + case CurrentAllocationStats.UNVSIITED: + return { VISIT_STATUS: { FALSE: true } }; + + case CurrentAllocationStats.NON_CONTACTABLE: + return { CONTACTABLE_STATUS: { FALSE: true } }; + + case CurrentAllocationStats.NON_PTP: + return { PTP_STATUS: { FALSE: true } }; + + case CurrentAllocationStats.BROKEN_PTP: + return { PTP_CONVERSION_STATUS: { TRUE: true } }; + + default: + break; + } +}; + +export const getPerformanceDetails = (cases: CasesType) => { + const { + visitedCases, + unvisitedCases, + contactableCases, + nonContactableCases, + totalPtp, + nonPtp, + convertedPtp, + brokenPtp, + } = cases || {}; + return [ + { + totalConverted: visitedCases, + convertedText: PerformanceCardDetails.visitedCases, + totalNotConverted: unvisitedCases, + notConvertedText: PerformanceCardDetails.unVisitedCases, + redirectionType: CurrentAllocationStats.UNVSIITED, + }, + { + totalConverted: contactableCases, + convertedText: PerformanceCardDetails.contactableCases, + totalNotConverted: nonContactableCases, + notConvertedText: PerformanceCardDetails.nonContactableCases, + redirectionType: CurrentAllocationStats.NON_CONTACTABLE, + }, + { + totalConverted: totalPtp, + convertedText: PerformanceCardDetails.totalPtp, + totalNotConverted: nonPtp, + notConvertedText: PerformanceCardDetails.nonPtp, + redirectionType: CurrentAllocationStats.NON_PTP, + }, + { + totalConverted: convertedPtp, + convertedText: PerformanceCardDetails.ptpConverted, + totalNotConverted: brokenPtp, + notConvertedText: PerformanceCardDetails.brokenPtp, + redirectionType: CurrentAllocationStats.BROKEN_PTP, + }, + ]; +}; + +export const getFiltersCount = (caseDetailsIds: Array, cases: CasesType) => { + const { unvisitedCaseIds, nonContactableCaseIds, nonPtpCaseIds, brokenPtpCaseIds } = cases; + + const unVisitedCount = caseDetailsIds.filter((caseId: string) => + unvisitedCaseIds.includes(caseId) + ).length; + const nonContactableCount = caseDetailsIds.filter((caseId: string) => + nonContactableCaseIds.includes(caseId) + ).length; + const nonPtpCount = caseDetailsIds.filter((caseId: string) => + nonPtpCaseIds.includes(caseId) + ).length; + const brokenPtpCount = caseDetailsIds.filter((caseId: string) => + brokenPtpCaseIds.includes(caseId) + ).length; + + if ( + unvisitedCaseIds.length !== unVisitedCount || + nonContactableCaseIds.length !== nonContactableCount || + nonPtpCaseIds.length !== nonPtpCount || + brokenPtpCaseIds.length !== brokenPtpCount + ) { + const caseIds = [ + ...unvisitedCaseIds, + ...nonContactableCaseIds, + ...nonPtpCaseIds, + ...brokenPtpCaseIds, + ]; + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.AD_FIREBASE_SYNC_ISSUE, { + caseIds: caseIds.filter((caseId) => !caseDetailsIds.includes(caseId)), + }); + } + + return { unVisitedCount, nonContactableCount, nonPtpCount, brokenPtpCount }; +}; + +export const getCashCollectedData = ( + cashCollectedRes: Array, + caseDetailsIds: Array +) => { + return cashCollectedRes?.filter((cashData) => caseDetailsIds.includes(cashData.caseId)); +}; diff --git a/src/screens/allCases/CasesList.tsx b/src/screens/allCases/CasesList.tsx index 0145aae2..4922c12c 100644 --- a/src/screens/allCases/CasesList.tsx +++ b/src/screens/allCases/CasesList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, ListRenderItem, @@ -19,7 +19,7 @@ import CaseItem from './CaseItem'; import { Search } from '../../../RN-UI-LIB/src/utlis/search'; import FiltersContainer from '../../components/screens/allCases/allCasesFilters/FiltersContainer'; import EmptyList from './EmptyList'; -import { useAppSelector } from '../../hooks'; +import { useAppDispatch, useAppSelector } from '../../hooks'; import { addClickstreamEvent } from '../../services/clickstreamEventService'; import { CLICKSTREAM_EVENT_NAMES, @@ -44,6 +44,8 @@ import { FlashList } from '@shopify/flash-list'; import { VisitPlanStatus } from '../../reducer/userSlice'; import { dateFormat, DAY_MONTH_DATE_FORMAT } from '../../../RN-UI-LIB/src/utlis/dates'; import { getAttemptedList, getNonAttemptedList } from './utils'; +import { toast } from '../../../RN-UI-LIB/src/components/toast'; +import { setFilteredListToast } from '../../reducer/allCasesSlice'; export const getItem = (item: Array, index: number) => item[index]; export const ESTIMATED_ITEM_SIZE = 250; // Average height of List item @@ -73,11 +75,15 @@ const CasesList: React.FC = ({ casesList = [], isVisitPlan }) => { quickFiltersPresent: state?.filters?.quickFilters?.length > 0, isLockedVisitPlanStatus: state.user?.lock?.visitPlanStatus === VisitPlanStatus.LOCKED, })); + const { showToast = false, caseType = '' } = + useAppSelector((state: RootState) => state.allCases.filteredListToast) || {}; const [showFilterModal, setShowFilterModal] = React.useState(false); const scrollAnimation = useRef(new Animated.Value(0)).current; + const dispatch = useAppDispatch(); + const firePageLoadEvent = () => { if (getCurrentScreen()?.name === 'Cases') { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.AV_CASE_LIST_LOAD); @@ -217,6 +223,25 @@ const CasesList: React.FC = ({ casesList = [], isVisitPlan }) => { intermediateTodoListMap, ]); + useEffect(() => { + if (showToast) { + const filteredCasesCount = filteredCasesList.length; + toast({ + type: 'info', + text1: `${filteredCasesCount} ${caseType} case${ + filteredCasesCount > 1 ? 's' : '' + } have been filtered`, + visibilityTime: 1, + }); + dispatch( + setFilteredListToast({ + showToast: false, + caseType: '', + }) + ); + } + }, [filteredCasesList]); + const handleSearchChange = useCallback( debounce((query: string) => { setSearchQuery(query); diff --git a/src/screens/allCases/constants.ts b/src/screens/allCases/constants.ts index 5fad1303..a1f0ae60 100644 --- a/src/screens/allCases/constants.ts +++ b/src/screens/allCases/constants.ts @@ -77,4 +77,5 @@ export enum BOTTOM_TAB_ROUTES { Cases = 'Cases', VisitPlan = 'Visit plan', Profile = 'Profile', + Dashboard = 'Dashboard', } diff --git a/src/screens/allCases/index.tsx b/src/screens/allCases/index.tsx index a0770892..75a52d3d 100644 --- a/src/screens/allCases/index.tsx +++ b/src/screens/allCases/index.tsx @@ -22,14 +22,18 @@ import { addClickstreamEvent } from '../../services/clickstreamEventService'; import { CLICKSTREAM_EVENT_NAMES } from '../../common/Constants'; import { BOTTOM_TAB_ROUTES } from './constants'; import { getSelfieDocument } from '../../action/profileActions'; +import Dashboard from '../Dashboard'; +import DashboardIcon from '../../assets/icons/DashboardIcon'; const AllCasesMain = () => { const { pendingList, pinnedList, loading } = useAppSelector((state) => state.allCases); const userState = useAppSelector((state: RootState) => state.user); + const isExternalAgent = useAppSelector((state: RootState) => state.user.isExternalAgent); + const dispatch = useAppDispatch(); - const HOME_SCREENS: ITabScreen[] = useMemo( - () => [ + const HOME_SCREENS: ITabScreen[] = useMemo(() => { + const screens = [ { name: BOTTOM_TAB_ROUTES.Cases, component: () => , @@ -40,14 +44,24 @@ const AllCasesMain = () => { component: () => , icon: VisitPlanIcon, }, - { - name: BOTTOM_TAB_ROUTES.Profile, - component: () => , - icon: ProfileIcon, - }, - ], - [pendingList, pinnedList] - ); + ]; + + if (!isExternalAgent) { + screens.push({ + name: BOTTOM_TAB_ROUTES.Dashboard, + component: () => , + icon: DashboardIcon, + }); + } + + screens.push({ + name: BOTTOM_TAB_ROUTES.Profile, + component: () => , + icon: ProfileIcon, + }); + + return screens; + }, [pendingList, pinnedList]); const onTabPressHandler = (e: any) => { addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_TAB_SWITCH, { diff --git a/src/screens/auth/ProtectedRouter.tsx b/src/screens/auth/ProtectedRouter.tsx index e7d4f679..c5f820de 100644 --- a/src/screens/auth/ProtectedRouter.tsx +++ b/src/screens/auth/ProtectedRouter.tsx @@ -1,5 +1,5 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { _map } from '../../../RN-UI-LIB/src/utlis/common'; import { UnifiedCaseDetailsTypes, getCaseUnifiedData } from '../../action/caseApiActions'; @@ -32,6 +32,10 @@ import Notifications from '../notifications'; import RegisterPayments from '../registerPayements/RegisterPayments'; import TodoList from '../todoList/TodoList'; import UngroupedAddressContainer from '../addressGeolocation/UngroupedAddressContainer'; +import CashCollected from '../cashCollected'; +import { getAgentDetail } from '../../action/agentPerformanceAction'; +import { setIsExternalAgent } from '../../reducer/userSlice'; +import FullScreenLoader from '../../../RN-UI-LIB/src/components/FullScreenLoader'; const Stack = createNativeStackNavigator(); @@ -43,6 +47,7 @@ export enum PageRouteEnum { EMI_SCHEDULE = 'EmiSchedule', PAST_FEEDBACK_DETAIL = 'pastFeedbackDetail', ADDITIONAL_ADDRESSES = 'additionalAddresses', + CASH_COLLECTED = 'cashCollected', } const ProtectedRouter = () => { @@ -50,6 +55,7 @@ const ProtectedRouter = () => { const { notificationsWithActions } = useAppSelector((state) => state.notifications); const isOnline = useIsOnline(); const dispatch = useAppDispatch(); + const [isLoading, setIsLoading] = useState(false); // Gets unified data for new visit plan cases // TODO: Move this to another place @@ -106,6 +112,22 @@ const ProtectedRouter = () => { // Firestore listener hook useFirestoreUpdates(); + useEffect(() => { + if (isOnline) { + setIsLoading(true); + getAgentDetail() + .then((res) => { + dispatch(setIsExternalAgent(res?.isExternalAgent)); + }) + .catch(() => { + dispatch(setIsExternalAgent(false)); + }) + .finally(() => setIsLoading(false)); + } + }, [isOnline]); + + if (isLoading) return ; + return ( { }} listeners={getScreenFocusListenerObj} /> + null, + animation: 'none', + animationDuration: SCREEN_ANIMATION_DURATION, + }} + listeners={getScreenFocusListenerObj} + /> ); }; diff --git a/src/screens/cashCollected/CashCollectedItem.tsx b/src/screens/cashCollected/CashCollectedItem.tsx new file mode 100644 index 00000000..a85774d7 --- /dev/null +++ b/src/screens/cashCollected/CashCollectedItem.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import Avatar from '../../../RN-UI-LIB/src/components/Avatar'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import Chevron from '../../../RN-UI-LIB/src/Icons/Chevron'; +import { GenericStyles, getShadowStyle } from '../../../RN-UI-LIB/src/styles'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import { formatAmount } from '../../../RN-UI-LIB/src/utlis/amount'; +import { sanitizeString } from '../../components/utlis/commonFunctions'; +import { navigateToScreen } from '../../components/utlis/navigationUtlis'; +import { PageRouteEnum } from '../auth/ProtectedRouter'; +import { CashCollectedItemProps } from './interface'; + +const CashCollectedItem = (props: CashCollectedItemProps) => { + const { cashCollectedItem, isLast, caseDetails } = props; + const { amountCollected = 0, isClosed, caseId } = cashCollectedItem || {}; + const { totalOverdueAmount = 0, imageUri = '', customerName = '' } = caseDetails || {}; + + return ( + navigateToScreen(PageRouteEnum.COLLECTION_CASE_DETAIL, { caseId })} + style={[ + getShadowStyle(2), + GenericStyles.row, + GenericStyles.alignCenter, + GenericStyles.br6, + GenericStyles.p16, + GenericStyles.mt16, + GenericStyles.ml16, + GenericStyles.mr16, + isLast && GenericStyles.mb24, + styles.cashCollectedContainer, + ]} + > + + + + {sanitizeString(customerName)} + + Collected :{' '} + = totalOverdueAmount ? styles.green : styles.red}> + {formatAmount(amountCollected, false)} + + + + Current outstanding :{' '} + {formatAmount(totalOverdueAmount, false)} + + + + + {isClosed && ( + + Foreclosed + + )} + + ); +}; + +const styles = StyleSheet.create({ + cashCollectedContainer: { + backgroundColor: COLORS.BACKGROUND.PRIMARY, + }, + textHeading: { + fontSize: 14, + fontWeight: '700', + color: COLORS.TEXT.DARK, + }, + subText: { + fontSize: 12, + color: COLORS.TEXT.BLACK, + }, + overdueAmountColor: { + color: COLORS.TEXT.LIGHT, + }, + green: { + color: COLORS.TEXT.GREEN, + }, + red: { + color: COLORS.TEXT.RED, + }, + foreclosedContainer: { + right: 0, + top: 0, + paddingLeft: 6, + paddingRight: 6, + backgroundColor: COLORS.BACKGROUND.TEAL, + borderBottomLeftRadius: 4, + borderTopRightRadius: 4, + }, + foreclosedText: { + color: COLORS.TEXT.TEAL, + }, + flexBasis96: { + flexBasis: '96%', + }, +}); + +export default CashCollectedItem; diff --git a/src/screens/cashCollected/index.tsx b/src/screens/cashCollected/index.tsx new file mode 100644 index 00000000..6b02a7f6 --- /dev/null +++ b/src/screens/cashCollected/index.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useState } from 'react'; +import { FlatList, RefreshControl, SafeAreaView, StyleSheet, View } from 'react-native'; +import NavigationHeader from '../../../RN-UI-LIB/src/components/NavigationHeader'; +import LineLoader from '../../../RN-UI-LIB/src/components/suspense_loader/LineLoader'; +import SuspenseLoader from '../../../RN-UI-LIB/src/components/suspense_loader/SuspenseLoader'; +import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import { _map } from '../../../RN-UI-LIB/src/utlis/common'; +import { getCashCollectedSplit } from '../../action/agentPerformanceAction'; +import NotificationMenu from '../../components/notificationMenu'; +import { logError } from '../../components/utlis/errorUtils'; +import { goBack } from '../../components/utlis/navigationUtlis'; +import { useAppDispatch, useAppSelector } from '../../hooks'; +import { setCashCollectedData } from '../../reducer/agentPerformanceSlice'; +import { RootState } from '../../store/store'; +import EmptyList from '../allCases/EmptyList'; +import { NO_CASH_COLLECTED_FOUND } from '../Dashboard/constants'; +import { getCashCollectedData } from '../Dashboard/utils'; +import Layout from '../layout/Layout'; +import CashCollectedItem from './CashCollectedItem'; + +const CashCollected = () => { + const cashCollectedData = useAppSelector((state) => state.agentPerformance.cashCollectedData); + + const [isLoading, setIsLoading] = useState(false); + const [paginationDetails, setPaginationDetails] = useState({ + pageNo: 0, + totalPages: 0, + pageSize: 0, + }); + + const { + allCases: { caseDetails }, + } = useAppSelector((state: RootState) => ({ + allCases: state.allCases, + })); + + const caseDetailsIds = Object.keys(caseDetails); + + const dispatch = useAppDispatch(); + + const fetchCashCollectedData = (pageNo: number, callbackFn?: () => void) => { + setIsLoading(true); + getCashCollectedSplit({ pageNo, pageSize: 10 }) + .then((res) => { + const cashData = res?.data; + setPaginationDetails(res?.pages); + const cashCollected = getCashCollectedData(cashData, caseDetailsIds); + dispatch(setCashCollectedData(cashCollected)); + }) + .catch((err) => { + logError(err); + }) + .finally(() => { + setIsLoading(false); + callbackFn && callbackFn(); + }); + }; + + const fetchMoreData = () => { + const hasMoreData = paginationDetails.pageNo < paginationDetails.totalPages; + if (hasMoreData) { + setPaginationDetails({ ...paginationDetails, pageNo: paginationDetails.pageNo + 1 }); + } + }; + + useEffect(() => { + if (paginationDetails.pageNo !== 0) fetchCashCollectedData(paginationDetails.pageNo); + }, [paginationDetails.pageNo]); + + const [refreshing, setRefreshing] = React.useState(false); + + const onRefresh = React.useCallback(() => { + setRefreshing(true); + fetchCashCollectedData(0, () => setRefreshing(false)); + }, []); + + useEffect(() => { + fetchCashCollectedData(0); + }, []); + + return ( + + + + + + } + /> + + {[...Array(9).keys()].map(() => ( + + ))} + + } + > + {cashCollectedData?.length > 0 ? ( + } + renderItem={({ item, index }) => ( + + )} + onEndReached={fetchMoreData} + onEndReachedThreshold={0} + /> + ) : ( + + + + )} + + + + ); +}; +const styles = StyleSheet.create({ + container: { + backgroundColor: COLORS.BACKGROUND.GREY_LIGHT, + position: 'relative', + }, + centerAbsolute: { + height: '80%', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 4, + }, +}); + +export default CashCollected; diff --git a/src/screens/cashCollected/interface.ts b/src/screens/cashCollected/interface.ts new file mode 100644 index 00000000..410fc215 --- /dev/null +++ b/src/screens/cashCollected/interface.ts @@ -0,0 +1,13 @@ +import { CaseDetail } from '../caseDetails/interface'; + +export interface CashCollectedItemType { + amountCollected: number; + isClosed: boolean; + caseId: string; +} + +export interface CashCollectedItemProps { + cashCollectedItem: CashCollectedItemType; + isLast: boolean; + caseDetails: CaseDetail; +} diff --git a/src/store/store.ts b/src/store/store.ts index dc515532..b4a7bcb9 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -30,6 +30,7 @@ import foregroundServiceSlice from '../reducer/foregroundServiceSlice'; import feedbackImagesSlice from '../reducer/feedbackImagesSlice'; import configSlice from '../reducer/configSlice'; import profileSlice from '../reducer/profileSlice'; +import agentPerformanceSlice from '../reducer/agentPerformanceSlice'; const rootReducer = combineReducers({ case: caseReducer, @@ -50,6 +51,7 @@ const rootReducer = combineReducers({ feedbackImages: feedbackImagesSlice, config: configSlice, profile: profileSlice, + agentPerformance: agentPerformanceSlice, }); const persistConfig = {