From 438654be98a1b5cd7cb7bcaa76e71d7d19b7b556 Mon Sep 17 00:00:00 2001 From: yashwin-foxy Date: Fri, 12 Sep 2025 18:53:17 +0530 Subject: [PATCH] zoho crm data mapped and need to integrate zoho projects --- App.tsx | 2 + android/app/src/main/AndroidManifest.xml | 1 + .../main/res/xml/network_security_config.xml | 9 + src/modules/auth/index.ts | 15 + src/modules/auth/screens/LoginScreen.tsx | 50 +- src/modules/auth/store/selectors.ts | 35 ++ src/modules/crm/components/CrmDataCards.tsx | 353 +++++++++++++ src/modules/crm/navigation/CrmNavigator.tsx | 2 + .../crm/screens/CrmDashboardScreen.tsx | 486 +++++++++++++++--- src/modules/crm/screens/ZohoCrmDataScreen.tsx | 316 ++++++++++++ src/modules/crm/services/crmAPI.ts | 49 ++ src/modules/crm/services/index.ts | 21 + src/modules/crm/store/crmSlice.ts | 309 +++++++++++ src/modules/crm/store/selectors.ts | 289 +++++++++++ src/modules/crm/types/CrmTypes.ts | 321 ++++++++++++ src/modules/integrations/screens/ZohoAuth.tsx | 4 +- src/modules/profile/screens/ProfileScreen.tsx | 42 +- src/modules/profile/store/profileSlice.ts | 21 +- src/modules/profile/store/selectors.ts | 49 ++ src/services/http.ts | 50 +- .../components/charts/CompactPipeline.tsx | 181 +++++++ src/shared/components/charts/DonutChart.tsx | 133 +++++ src/shared/components/charts/FunnelChart.tsx | 121 +++++ src/shared/components/charts/PieChart.tsx | 131 +++++ .../components/charts/PipelineCards.tsx | 189 +++++++ src/shared/components/charts/PipelineFlow.tsx | 159 ++++++ .../components/charts/StackedBarChart.tsx | 104 ++++ src/shared/components/charts/index.ts | 7 + src/shared/constants/API_ENDPOINTS.ts | 3 + src/store/store.ts | 4 +- 30 files changed, 3358 insertions(+), 98 deletions(-) create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 src/modules/auth/index.ts create mode 100644 src/modules/auth/store/selectors.ts create mode 100644 src/modules/crm/components/CrmDataCards.tsx create mode 100644 src/modules/crm/screens/ZohoCrmDataScreen.tsx create mode 100644 src/modules/crm/services/crmAPI.ts create mode 100644 src/modules/crm/services/index.ts create mode 100644 src/modules/crm/store/crmSlice.ts create mode 100644 src/modules/crm/store/selectors.ts create mode 100644 src/modules/crm/types/CrmTypes.ts create mode 100644 src/modules/profile/store/selectors.ts create mode 100644 src/shared/components/charts/CompactPipeline.tsx create mode 100644 src/shared/components/charts/DonutChart.tsx create mode 100644 src/shared/components/charts/FunnelChart.tsx create mode 100644 src/shared/components/charts/PieChart.tsx create mode 100644 src/shared/components/charts/PipelineCards.tsx create mode 100644 src/shared/components/charts/PipelineFlow.tsx create mode 100644 src/shared/components/charts/StackedBarChart.tsx create mode 100644 src/shared/components/charts/index.ts diff --git a/App.tsx b/App.tsx index 3ba1d32..450bf64 100644 --- a/App.tsx +++ b/App.tsx @@ -18,6 +18,7 @@ import AuthNavigator from '@/modules/auth/navigation/AuthNavigator'; import type { RootState } from '@/store/store'; import IntegrationsNavigator from '@/modules/integrations/navigation/IntegrationsNavigator'; import { StatusBar } from 'react-native'; +import Toast from 'react-native-toast-message'; function AppContent(): React.JSX.Element { const isAuthenticated = useSelector((s: RootState) => Boolean(s.auth.isAuthenticated)); @@ -63,6 +64,7 @@ function AppContent(): React.JSX.Element { ) )} + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5a82dba..613f59b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" + android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true"> + + + + + + + + \ No newline at end of file diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts new file mode 100644 index 0000000..b872034 --- /dev/null +++ b/src/modules/auth/index.ts @@ -0,0 +1,15 @@ +// Export auth slice and actions +export { default as authSlice } from './store/authSlice'; +export { login, logout, clearError } from './store/authSlice'; + +// Export selectors +export * from './store/selectors'; + +// Export services +export { authAPI } from './services/authAPI'; + +// Export navigation +export { default as AuthNavigator } from './navigation/AuthNavigator'; + +// Export screens +export { default as LoginScreen } from './screens/LoginScreen'; diff --git a/src/modules/auth/screens/LoginScreen.tsx b/src/modules/auth/screens/LoginScreen.tsx index 92e9309..0c298da 100644 --- a/src/modules/auth/screens/LoginScreen.tsx +++ b/src/modules/auth/screens/LoginScreen.tsx @@ -16,6 +16,7 @@ import { login, clearError } from '@/modules/auth/store/authSlice'; import type { RootState, AppDispatch } from '@/store/store'; import { useTheme } from '@/shared/styles/useTheme'; import { validateLoginForm } from '@/shared/utils/validation'; +import { showError, showSuccess, showWarning, showInfo } from '@/shared/utils/Toast'; const LoginScreen: React.FC = () => { const dispatch = useDispatch(); @@ -64,6 +65,14 @@ const LoginScreen: React.FC = () => { if (!validation.isValid) { setValidationErrors(validation.errors); + + // Show toast messages for validation errors + if (validation.errors.email) { + showError(validation.errors.email); + } + if (validation.errors.password) { + showError(validation.errors.password); + } return; } @@ -73,15 +82,17 @@ const LoginScreen: React.FC = () => { // Check if login was successful if (login.fulfilled.match(result)) { - // Login successful - navigation will be handled by the app navigator - // based on isAuthenticated state - Alert.alert('Success', 'Login successful!', [ - { text: 'OK', style: 'default' } - ]); + // Login successful - show success toast + showSuccess('Login successful! Welcome back!'); + // Navigation will be handled by the app navigator based on isAuthenticated state + } else if (login.rejected.match(result)) { + // Login failed - show error toast + showError(result.payload as string || 'Login failed. Please try again.'); } } catch (err) { // Error handling is done in the slice console.error('Login error:', err); + showError('An unexpected error occurred. Please try again.'); } }; @@ -222,12 +233,18 @@ const LoginScreen: React.FC = () => { {/* Row: Remember me + Forgot password */} - setRememberMe(v => !v)}> + { + setRememberMe(v => !v); + showInfo(rememberMe ? 'Will not remember login' : 'Will remember login'); + }} + > Remember me - + showInfo('Forgot password feature coming soon!')}> Forgot Password ? @@ -263,21 +280,30 @@ const LoginScreen: React.FC = () => { {/* Social buttons */} - + showInfo('Google login coming soon!')} + > - + showInfo('Facebook login coming soon!')} + > - + showInfo('Apple login coming soon!')} + > {/* Sign up */} - Don’t have an account? - + Don't have an account? + showInfo('Sign up feature coming soon!')}> Sign up diff --git a/src/modules/auth/store/selectors.ts b/src/modules/auth/store/selectors.ts new file mode 100644 index 0000000..78670ec --- /dev/null +++ b/src/modules/auth/store/selectors.ts @@ -0,0 +1,35 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '@/store/store'; + +// Base selectors +export const selectAuthState = (state: RootState) => state.auth; + +export const selectUser = createSelector( + [selectAuthState], + (auth) => auth.user +); + +export const selectAccessToken = createSelector( + [selectAuthState], + (auth) => auth.accessToken +); + +export const selectRefreshToken = createSelector( + [selectAuthState], + (auth) => auth.refreshToken +); + +export const selectIsAuthenticated = createSelector( + [selectAuthState], + (auth) => auth.isAuthenticated +); + +export const selectAuthLoading = createSelector( + [selectAuthState], + (auth) => auth.loading +); + +export const selectAuthError = createSelector( + [selectAuthState], + (auth) => auth.error +); diff --git a/src/modules/crm/components/CrmDataCards.tsx b/src/modules/crm/components/CrmDataCards.tsx new file mode 100644 index 0000000..7619b09 --- /dev/null +++ b/src/modules/crm/components/CrmDataCards.tsx @@ -0,0 +1,353 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { useTheme } from '@/shared/styles/useTheme'; +import type { CrmLead, CrmTask, CrmContact, CrmDeal } from '../types/CrmTypes'; + +interface BaseCardProps { + onPress: () => void; +} + +interface LeadCardProps extends BaseCardProps { + lead: CrmLead; +} + +interface TaskCardProps extends BaseCardProps { + task: CrmTask; +} + +interface ContactCardProps extends BaseCardProps { + contact: CrmContact; +} + +interface DealCardProps extends BaseCardProps { + deal: CrmDeal; +} + +const getStatusColor = (status: string, colors: any) => { + // return '#3AA0FF'; + switch (status.toLowerCase()) { + case 'new': + case 'not started': + case 'attempted to contact': + return '#3AA0FF'; + case 'contacted': + case 'in progress': + case 'qualification': + return '#F59E0B'; + case 'qualified': + case 'proposal': + return '#10B981'; + case 'completed': + case 'closed won': + return '#22C55E'; + case 'unqualified': + case 'cancelled': + case 'lost lead': + return '#EF4444'; + default: + return colors.textLight; + } +}; + +const getPriorityColor = (priority: string) => { + return '#EF4444'; + switch (priority.toLowerCase()) { + case 'high': + return '#EF4444'; + case 'medium': + return '#F59E0B'; + case 'low': + return '#10B981'; + default: + return '#6B7280'; + } +}; + +export const LeadCard: React.FC = ({ lead, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {lead.Full_Name} + + + + {lead.Lead_Status} + + + + + {lead.Company} + + + + + + + + {lead.Email} + + + + + + {lead.Phone} + + + + + + Source: {lead.Lead_Source} + + + {lead.Annual_Revenue && ( + + + + ${lead.Annual_Revenue.toLocaleString()} + + + )} + + + + + Created: {new Date(lead.Created_Time)?.toLocaleDateString()} + + + + ); +}; + +export const TaskCard: React.FC = ({ task, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {task.Subject} + + + + {task.Status} + + + + + {task.Priority} + + + + + + {task.Description} + + + + + Priority: {task.Priority} + + + + + + Assigned: {task.Owner.name} + + + + + + + Due: {new Date(task.Due_Date)?.toLocaleDateString()} + + + + ); +}; + +export const ContactCard: React.FC = ({ contact, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {contact.Full_Name} + + + + Active + + + + + {contact.Title} at {contact.Account_Name?.name || 'N/A'} + + + + + + + + {contact.Email} + + + + + + {contact.Phone} + + + + + + Source: {contact.Lead_Source} + + + + + + + Last contact: {new Date(contact.Last_Activity_Time)?.toLocaleDateString()} + + + + ); +}; + +export const DealCard: React.FC = ({ deal, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {deal.Deal_Name} + + + + {deal.Stage} + + + + + {deal.Account_Name?.name || 'N/A'} + + + + + + + + ${deal.Amount?.toLocaleString()} + + + + + + Probability: {deal.Probability}% + + + + + + Contact: {deal.Contact_Name?.name || 'N/A'} + + + + + + + Close date: {new Date(deal.Closing_Date)?.toLocaleDateString()} + + + + ); +}; + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + borderWidth: 1, + marginBottom: 16, + overflow: 'hidden', + }, + cardHeader: { + padding: 16, + paddingBottom: 12, + }, + cardTitleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 4, + }, + cardTitle: { + fontSize: 16, + flex: 1, + marginRight: 8, + }, + cardSubtitle: { + fontSize: 14, + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + }, + statusText: { + fontSize: 12, + }, + cardContent: { + paddingHorizontal: 16, + paddingBottom: 12, + }, + infoRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + }, + infoText: { + marginLeft: 8, + fontSize: 14, + flex: 1, + }, + descriptionText: { + fontSize: 14, + marginBottom: 8, + lineHeight: 20, + }, + cardFooter: { + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#F8F9FA', + }, + dateText: { + fontSize: 12, + }, +}); + diff --git a/src/modules/crm/navigation/CrmNavigator.tsx b/src/modules/crm/navigation/CrmNavigator.tsx index a413358..04c5959 100644 --- a/src/modules/crm/navigation/CrmNavigator.tsx +++ b/src/modules/crm/navigation/CrmNavigator.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import CrmDashboardScreen from '@/modules/crm/screens/CrmDashboardScreen'; +import ZohoCrmDataScreen from '@/modules/crm/screens/ZohoCrmDataScreen'; const Stack = createStackNavigator(); const CrmNavigator = () => ( + ); diff --git a/src/modules/crm/screens/CrmDashboardScreen.tsx b/src/modules/crm/screens/CrmDashboardScreen.tsx index a2ad302..c8c6924 100644 --- a/src/modules/crm/screens/CrmDashboardScreen.tsx +++ b/src/modules/crm/screens/CrmDashboardScreen.tsx @@ -1,87 +1,238 @@ -import React, { useMemo } from 'react'; -import { View, Text, StyleSheet } from 'react-native'; +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, RefreshControl, ScrollView } from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { useSelector, useDispatch } from 'react-redux'; import { Container } from '@/shared/components/ui'; +import { PieChart, DonutChart, FunnelChart, StackedBarChart, PipelineFlow, PipelineCards, CompactPipeline } from '@/shared/components/charts'; import { useTheme } from '@/shared/styles/useTheme'; +import { useNavigation } from '@react-navigation/native'; +import { fetchAllCrmData } from '../store/crmSlice'; +import { + selectDashboardData, + selectIsAnyLoading, + selectHasAnyError, + selectCrmStats +} from '../store/selectors'; +import type { RootState } from '@/store/store'; +import type { AppDispatch } from '@/store/store'; const CrmDashboardScreen: React.FC = () => { const { colors, fonts } = useTheme(); + const navigation = useNavigation(); + const dispatch = useDispatch(); - const mock = useMemo(() => { - const leads = 420; - const opportunities = 76; - const wonDeals = 28; - const conversionPct = 37; - const leadsTrend = [60, 62, 68, 70, 76, 84]; - const pipeline = [ - { label: 'Prospecting', value: 28, color: '#3AA0FF' }, - { label: 'Qualified', value: 18, color: '#10B981' }, - { label: 'Proposal', value: 12, color: '#F59E0B' }, - { label: 'Negotiation', value: 9, color: '#6366F1' }, - { label: 'Closed Won', value: 6, color: '#22C55E' }, - { label: 'Closed Lost', value: 7, color: '#EF4444' }, - ]; - const topOpps = [ - { name: 'Acme Upgrade', value: 48000 }, - { name: 'Globex Renewal', value: 36000 }, - { name: 'Initech Expansion', value: 29000 }, - ]; - const recent = [ - { who: 'Jane D.', what: 'Follow-up call completed', when: '2h' }, - { who: 'Sam R.', what: 'Demo scheduled', when: '5h' }, - { who: 'Priya K.', what: 'Proposal sent', when: '1d' }, - ]; - const sourceDist = [ - { label: 'Website', value: 180, color: '#3AA0FF' }, - { label: 'Referral', value: 120, color: '#10B981' }, - { label: 'Events', value: 64, color: '#F59E0B' }, - { label: 'Ads', value: 56, color: '#EF4444' }, - ]; - return { leads, opportunities, wonDeals, conversionPct, leadsTrend, pipeline, topOpps, recent, sourceDist }; - }, []); + // Redux selectors + const dashboardData = useSelector(selectDashboardData); + const crmStats = useSelector(selectCrmStats); + const isLoading = useSelector(selectIsAnyLoading); + const hasError = useSelector(selectHasAnyError); + + // Fetch data on component mount + useEffect(() => { + dispatch(fetchAllCrmData()); + }, [dispatch]); + + // Handle refresh + const handleRefresh = () => { + dispatch(fetchAllCrmData()); + }; return ( - - CRM & Sales + + } + > + + CRM & Sales + navigation.navigate('ZohoCrmData' as never)} + > + + + View Data + + + + + {/* Error State */} + {hasError && ( + + + + Failed to load CRM data. Pull to refresh. + + + )} {/* KPIs */} - - - - + + + + - {/* Leads Trend */} - - Leads Trend - + {/* Additional Stats Row */} + + + + + - {/* Pipeline distribution */} + {/* Lead Status Distribution - Pie Chart */} - Pipeline Stages - a + b.value, 0)} /> - - {mock.pipeline.map(s => ( - - - {s.label} - - ))} + Lead Status Distribution + + + ({ + label: status, + value: count, + color: getStatusColor(status) + }))} + colors={colors} + fonts={fonts} + size={140} + /> + + {/* Legend */} + + {Object.entries(crmStats.leads.byStatus).map(([status, count]) => ( + + + + {status} ({count}) + + + ))} + - {/* Lead Sources */} + {/* Deal Pipeline Stages - Compact View */} + + Deal Pipeline Stages + + ({ + label: stage.label, + value: stage.value, + color: stage.color + }))} + colors={colors} + fonts={fonts} + /> + + + {/* Leads by Source - Donut Chart */} Leads by Source - a + b.value, 0)} /> - - {mock.sourceDist.map(s => ( - - - {s.label} - - ))} + + + ({ + label: source.label, + value: source.value, + color: source.color + }))} + colors={colors} + fonts={fonts} + size={140} + /> + + {/* Legend */} + + {dashboardData.sourceDist.map(source => ( + + + + {source.label} ({source.value}) + + + ))} + + + + + {/* Tasks by Priority - Stacked Bar Chart */} + + Tasks by Priority + + + ({ + label: priority, + value: count, + color: getPriorityColor(priority) + }))} + colors={colors} + fonts={fonts} + height={120} + /> + + {/* Legend */} + + {Object.entries(crmStats.tasks.byPriority).map(([priority, count]) => ( + + + + {priority} ({count}) + + + ))} + @@ -89,31 +240,77 @@ const CrmDashboardScreen: React.FC = () => { Top Opportunities - {mock.topOpps.map(o => ( + {dashboardData.topOpps.length > 0 ? dashboardData.topOpps.map(o => ( - {o.name} - ${o.value.toLocaleString()} + + {o.name} + + + {formatCurrency(o.value)} + - ))} + )) : ( + + No opportunities found + + )} Recent Activity - {mock.recent.map(r => ( + {dashboardData.recent.length > 0 ? dashboardData.recent.map(r => ( - {r.who} - {r.what} · {r.when} + + {r.who} + + + {r.what} · {r.when} + - ))} + )) : ( + + No recent activity + + )} - + ); }; const styles = StyleSheet.create({ wrap: { flex: 1, padding: 16 }, - title: { fontSize: 18, marginBottom: 8 }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + title: { fontSize: 18 }, + dataButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + dataButtonText: { + marginLeft: 6, + fontSize: 14, + }, + errorCard: { + borderRadius: 12, + borderWidth: 1, + padding: 12, + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + }, + errorText: { + marginLeft: 8, + fontSize: 14, + flex: 1, + }, kpiGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' }, kpiCard: { width: '48%', borderRadius: 12, borderWidth: 1, borderColor: '#E2E8F0', backgroundColor: '#FFFFFF', padding: 12, marginBottom: 12 }, kpiLabel: { fontSize: 12, opacity: 0.8 }, @@ -125,13 +322,156 @@ const styles = StyleSheet.create({ legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 }, legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 12, marginTop: 6 }, legendDot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 }, + chartContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + }, + pieLegend: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + marginTop: 12, + gap: 8, + }, + barLegend: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + marginTop: 8, + gap: 6, + }, + legendText: { + fontSize: 12, + }, row: { marginTop: 12 }, col: { flex: 1, marginRight: 8 }, listRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB' }, listPrimary: { fontSize: 14, flex: 1, paddingRight: 8 }, listSecondary: { fontSize: 12 }, + emptyText: { + fontSize: 14, + textAlign: 'center', + paddingVertical: 20, + fontStyle: 'italic' + }, }); +// Helper functions for color coding +const getStatusColor = (status: string): string => { + // Define a comprehensive color palette with distinct colors + const colorPalette = [ + '#3B82F6', // Bright Blue + '#8B5CF6', // Purple + '#06B6D4', // Cyan + '#F59E0B', // Amber + '#10B981', // Emerald + '#F97316', // Orange + '#22C55E', // Green + '#84CC16', // Lime + '#14B8A6', // Teal + '#059669', // Dark Green + '#EF4444', // Red + '#DC2626', // Dark Red + '#991B1B', // Darker Red + '#9CA3AF', // Gray + '#EC4899', // Pink + '#8B5A2B', // Brown + '#B91C1C', // Lost Red + '#16A34A', // Success Green + '#6366F1', // Indigo + '#7C3AED', // Violet + '#0891B2', // Sky Blue + '#CA8A04', // Gold + '#1F2937', // Dark Gray + '#BE185D', // Rose + '#0D9488', // Emerald Dark + '#7C2D12', // Brown Dark + '#1E40AF', // Blue Dark + '#C2410C', // Orange Dark + '#9333EA', // Purple Dark + '#059669', // Green Dark + ]; + + // Create a consistent hash from the status string + let hash = 0; + const normalizedStatus = status.toLowerCase().trim(); + for (let i = 0; i < normalizedStatus.length; i++) { + const char = normalizedStatus.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + + // Ensure positive index and get color from palette + const colorIndex = Math.abs(hash) % colorPalette.length; + return colorPalette[colorIndex]; +}; + +const getPriorityColor = (priority: string): string => { + // Define a comprehensive color palette with distinct colors for priorities + const colorPalette = [ + '#DC2626', // Dark Red + '#B91C1C', // Urgent Red + '#991B1B', // Critical Dark Red + '#F59E0B', // Amber + '#10B981', // Emerald + '#059669', // Dark Green + '#16A34A', // Success Green + '#9CA3AF', // Gray + '#6B7280', // Light Gray + '#6366F1', // Indigo + '#EF4444', // Red + '#8B5CF6', // Purple + '#06B6D4', // Cyan + '#F97316', // Orange + '#22C55E', // Green + '#84CC16', // Lime + '#14B8A6', // Teal + '#EC4899', // Pink + '#8B5A2B', // Brown + '#7C3AED', // Violet + '#0891B2', // Sky Blue + '#CA8A04', // Gold + '#1F2937', // Dark Gray + '#BE185D', // Rose + '#0D9488', // Emerald Dark + '#7C2D12', // Brown Dark + '#1E40AF', // Blue Dark + '#C2410C', // Orange Dark + '#9333EA', // Purple Dark + '#3B82F6', // Info Blue + '#0077B5', // LinkedIn Blue + ]; + + // Create a consistent hash from the priority string + let hash = 0; + const normalizedPriority = priority.toLowerCase().trim(); + for (let i = 0; i < normalizedPriority.length; i++) { + const char = normalizedPriority.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + + // Ensure positive index and get color from palette + const colorIndex = Math.abs(hash) % colorPalette.length; + return colorPalette[colorIndex]; +}; + +// Helper function to format currency values safely +const formatCurrency = (value: number | undefined | null): string => { + if (value === undefined || value === null || isNaN(value)) { + return '$0'; + } + + if (value >= 1000000) { + return `$${(value / 1000000).toFixed(1)}M`; + } else if (value >= 1000) { + return `$${Math.round(value / 1000)}K`; + } else { + return `$${Math.round(value)}`; + } +}; + export default CrmDashboardScreen; // UI helpers diff --git a/src/modules/crm/screens/ZohoCrmDataScreen.tsx b/src/modules/crm/screens/ZohoCrmDataScreen.tsx new file mode 100644 index 0000000..1c907f8 --- /dev/null +++ b/src/modules/crm/screens/ZohoCrmDataScreen.tsx @@ -0,0 +1,316 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + RefreshControl, + FlatList, + Alert, +} from 'react-native'; +import { useSelector, useDispatch } from 'react-redux'; +import type { AppDispatch } from '@/store/store'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui'; +import { useTheme } from '@/shared/styles/useTheme'; +import { showError, showSuccess, showInfo } from '@/shared/utils/Toast'; +import type { CrmData, CrmLead, CrmTask, CrmContact, CrmDeal } from '../types/CrmTypes'; +import { LeadCard, TaskCard, ContactCard, DealCard } from '../components/CrmDataCards'; +import { + selectLeads, + selectTasks, + selectContacts, + selectDeals, + selectCrmLoading, + selectCrmErrors +} from '../store/selectors'; +import { fetchAllCrmData } from '../store/crmSlice'; +import type { RootState } from '@/store/store'; + +const ZohoCrmDataScreen: React.FC = () => { + const { colors, fonts, spacing, shadows } = useTheme(); + const dispatch = useDispatch(); + const [selectedTab, setSelectedTab] = useState<'leads' | 'tasks' | 'contacts' | 'deals'>('leads'); + const [refreshing, setRefreshing] = useState(false); + + // Redux selectors + const leads = useSelector(selectLeads); + const tasks = useSelector(selectTasks); + const contacts = useSelector(selectContacts); + const deals = useSelector(selectDeals); + const loading = useSelector(selectCrmLoading); + const errors = useSelector(selectCrmErrors); + + // Create CRM data object from Redux state + const crmData: CrmData = useMemo(() => ({ + leads: leads || [], + tasks: tasks || [], + contacts: contacts || [], + deals: deals || [], + }), [leads, tasks, contacts, deals]); + + // Fetch CRM data using Redux + const fetchCrmData = async (showRefresh = false) => { + try { + if (showRefresh) { + setRefreshing(true); + } + + // Dispatch Redux action to fetch all CRM data + await dispatch(fetchAllCrmData()).unwrap(); + + if (showRefresh) { + showSuccess('CRM data refreshed successfully'); + } + } catch (err) { + const errorMessage = 'Failed to fetch CRM data'; + showError(errorMessage); + } finally { + setRefreshing(false); + } + }; + + useEffect(() => { + fetchCrmData(); + }, []); + + const handleRefresh = () => { + fetchCrmData(true); + }; + + const handleRetry = () => { + fetchCrmData(); + }; + + const handleCardPress = (item: any, type: string) => { + showInfo(`Viewing ${type}: ${item.name || item.subject || `${item.firstName} ${item.lastName}`}`); + }; + + // Get current loading state and error + const isLoading = loading.leads || loading.tasks || loading.contacts || loading.deals; + const hasError = errors.leads || errors.tasks || errors.contacts || errors.deals; + + + // Tab configuration + const tabs = [ + { key: 'leads', label: 'Leads', icon: 'account-heart', count: crmData.leads.length }, + { key: 'tasks', label: 'Tasks', icon: 'check-circle', count: crmData.tasks.length }, + { key: 'contacts', label: 'Contacts', icon: 'account-group', count: crmData.contacts.length }, + { key: 'deals', label: 'Deals', icon: 'handshake', count: crmData.deals.length }, + ] as const; + + if (isLoading && !crmData.leads.length) { + return ; + } + + if (hasError && !crmData.leads.length) { + return ; + } + + + const renderTabContent = () => { + switch (selectedTab) { + case 'leads': + return ( + ( + handleCardPress(item, 'Lead')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + case 'tasks': + return ( + ( + handleCardPress(item, 'Task')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + case 'contacts': + return ( + ( + handleCardPress(item, 'Contact')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + case 'deals': + return ( + ( + handleCardPress(item, 'Deal')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + default: + return null; + } + }; + + return ( + + + } + > + {/* Header */} + + + Zoho CRM Data + + + + + + + {/* Tabs */} + + + {tabs.map((tab) => ( + setSelectedTab(tab.key)} + activeOpacity={0.8} + > + + + {tab.label} + + + + {tab.count} + + + + ))} + + + + {/* Content */} + + {renderTabContent()} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + }, + title: { + fontSize: 24, + }, + tabsContainer: { + marginBottom: 16, + }, + tabs: { + flexDirection: 'row', + paddingHorizontal: 16, + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + marginRight: 8, + borderRadius: 20, + backgroundColor: '#F1F5F9', + }, + tabText: { + marginLeft: 6, + fontSize: 14, + }, + countBadge: { + marginLeft: 6, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + minWidth: 20, + alignItems: 'center', + }, + countText: { + fontSize: 12, + }, + content: { + flex: 1, + paddingHorizontal: 16, + }, + listContainer: { + paddingBottom: 20, + }, +}); + +export default ZohoCrmDataScreen; diff --git a/src/modules/crm/services/crmAPI.ts b/src/modules/crm/services/crmAPI.ts new file mode 100644 index 0000000..dc76e4d --- /dev/null +++ b/src/modules/crm/services/crmAPI.ts @@ -0,0 +1,49 @@ +import http from '@/services/http'; +import type { + CrmLead, + CrmTask, + CrmContact, + CrmDeal, + CrmSearchParams, + CrmApiResponse, + CrmPaginatedResponse +} from '../types/CrmTypes'; + +// Available CRM resource types +export type CrmResourceType = 'leads' | 'tasks' | 'deals' | 'contacts'; + +// Base API endpoint +const CRM_BASE_URL = '/api/v1/integrations/data'; + +export const crmAPI = { + // Generic method to get CRM data by resource type + getCrmData: ( + resource: CrmResourceType, + params?: CrmSearchParams + ) => { + const queryParams = { + provider: 'zoho', + service: 'crm', + resource, + page: 1, + limit: 20, + ...params + }; + + return http.get>>(CRM_BASE_URL, queryParams); + }, + + // Specific resource methods for type safety + getLeads: (params?: CrmSearchParams) => + crmAPI.getCrmData('leads', params), + + getTasks: (params?: CrmSearchParams) => + crmAPI.getCrmData('tasks', params), + + getContacts: (params?: CrmSearchParams) => + crmAPI.getCrmData('contacts', params), + + getDeals: (params?: CrmSearchParams) => + crmAPI.getCrmData('deals', params), +}; + diff --git a/src/modules/crm/services/index.ts b/src/modules/crm/services/index.ts new file mode 100644 index 0000000..ca9507a --- /dev/null +++ b/src/modules/crm/services/index.ts @@ -0,0 +1,21 @@ +// CRM Services Exports +export { crmAPI } from './crmAPI'; + +// Re-export types for convenience +export type { + CrmData, + CrmLead, + CrmTask, + CrmContact, + CrmDeal, + CreateLeadForm, + CreateTaskForm, + CreateContactForm, + CreateDealForm, + CrmSearchParams, + CrmApiResponse, + CrmPaginatedResponse, + CrmStats, + CrmFilters, +} from '../types/CrmTypes'; + diff --git a/src/modules/crm/store/crmSlice.ts b/src/modules/crm/store/crmSlice.ts new file mode 100644 index 0000000..94e68da --- /dev/null +++ b/src/modules/crm/store/crmSlice.ts @@ -0,0 +1,309 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import { crmAPI } from '../services/crmAPI'; +import type { + CrmLead, + CrmTask, + CrmContact, + CrmDeal, + CrmStats, + CrmSearchParams, + CrmApiResponse, + CrmPaginatedResponse +} from '../types/CrmTypes'; + +// State interface +export interface CrmState { + // Data + leads: CrmLead[]; + tasks: CrmTask[]; + contacts: CrmContact[]; + deals: CrmDeal[]; + + // Loading states + loading: { + leads: boolean; + tasks: boolean; + contacts: boolean; + deals: boolean; + stats: boolean; + }; + + // Error states + errors: { + leads: string | null; + tasks: string | null; + contacts: string | null; + deals: string | null; + stats: string | null; + }; + + // Pagination + pagination: { + leads: { page: number; count: number; moreRecords: boolean }; + tasks: { page: number; count: number; moreRecords: boolean }; + contacts: { page: number; count: number; moreRecords: boolean }; + deals: { page: number; count: number; moreRecords: boolean }; + }; + + // Statistics + stats: CrmStats | null; + + // Last updated timestamps + lastUpdated: { + leads: string | null; + tasks: string | null; + contacts: string | null; + deals: string | null; + stats: string | null; + }; +} + +// Initial state +const initialState: CrmState = { + leads: [], + tasks: [], + contacts: [], + deals: [], + loading: { + leads: false, + tasks: false, + contacts: false, + deals: false, + stats: false, + }, + errors: { + leads: null, + tasks: null, + contacts: null, + deals: null, + stats: null, + }, + pagination: { + leads: { page: 1, count: 0, moreRecords: false }, + tasks: { page: 1, count: 0, moreRecords: false }, + contacts: { page: 1, count: 0, moreRecords: false }, + deals: { page: 1, count: 0, moreRecords: false }, + }, + stats: null, + lastUpdated: { + leads: null, + tasks: null, + contacts: null, + deals: null, + stats: null, + }, +}; + +// Async thunks +export const fetchLeads = createAsyncThunk( + 'crm/fetchLeads', + async (params?: CrmSearchParams) => { + const response = await crmAPI.getLeads(params); + return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }; + } +); + +export const fetchTasks = createAsyncThunk( + 'crm/fetchTasks', + async (params?: CrmSearchParams) => { + const response = await crmAPI.getTasks(params); + return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }; + } +); + +export const fetchContacts = createAsyncThunk( + 'crm/fetchContacts', + async (params?: CrmSearchParams) => { + const response = await crmAPI.getContacts(params); + return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }; + } +); + +export const fetchDeals = createAsyncThunk( + 'crm/fetchDeals', + async (params?: CrmSearchParams) => { + const response = await crmAPI.getDeals(params); + return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }; + } +); + +// Fetch all CRM data +export const fetchAllCrmData = createAsyncThunk( + 'crm/fetchAllData', + async (params?: CrmSearchParams) => { + const [leadsResponse, tasksResponse, contactsResponse, dealsResponse] = await Promise.all([ + crmAPI.getLeads(params), + crmAPI.getTasks(params), + crmAPI.getContacts(params), + crmAPI.getDeals(params), + ]); + console.log('leads response data',leadsResponse) + return { + leads: leadsResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, + tasks: tasksResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, + contacts: contactsResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, + deals: dealsResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, + }; + } +); + +// Slice +const crmSlice = createSlice({ + name: 'crm', + initialState, + reducers: { + clearErrors: (state) => { + state.errors = { + leads: null, + tasks: null, + contacts: null, + deals: null, + stats: null, + }; + }, + clearData: (state) => { + state.leads = []; + state.tasks = []; + state.contacts = []; + state.deals = []; + state.stats = null; + }, + setLeadsPage: (state, action: PayloadAction) => { + state.pagination.leads.page = action.payload; + }, + setTasksPage: (state, action: PayloadAction) => { + state.pagination.tasks.page = action.payload; + }, + setContactsPage: (state, action: PayloadAction) => { + state.pagination.contacts.page = action.payload; + }, + setDealsPage: (state, action: PayloadAction) => { + state.pagination.deals.page = action.payload; + }, + }, + extraReducers: (builder) => { + // Fetch leads + builder + .addCase(fetchLeads.pending, (state) => { + state.loading.leads = true; + state.errors.leads = null; + }) + .addCase(fetchLeads.fulfilled, (state, action) => { + state.loading.leads = false; + state.leads = action.payload.data || []; + state.pagination.leads = action.payload.info || { page: 1, count: 0, moreRecords: false }; + state.lastUpdated.leads = new Date().toISOString(); + }) + .addCase(fetchLeads.rejected, (state, action) => { + state.loading.leads = false; + state.errors.leads = action.error.message || 'Failed to fetch leads'; + }) + + // Fetch tasks + .addCase(fetchTasks.pending, (state) => { + state.loading.tasks = true; + state.errors.tasks = null; + }) + .addCase(fetchTasks.fulfilled, (state, action) => { + state.loading.tasks = false; + state.tasks = action.payload.data || []; + state.pagination.tasks = action.payload.info || { page: 1, count: 0, moreRecords: false }; + state.lastUpdated.tasks = new Date().toISOString(); + }) + .addCase(fetchTasks.rejected, (state, action) => { + state.loading.tasks = false; + state.errors.tasks = action.error.message || 'Failed to fetch tasks'; + }) + + // Fetch contacts + .addCase(fetchContacts.pending, (state) => { + state.loading.contacts = true; + state.errors.contacts = null; + }) + .addCase(fetchContacts.fulfilled, (state, action) => { + state.loading.contacts = false; + state.contacts = action.payload.data || []; + state.pagination.contacts = action.payload.info || { page: 1, count: 0, moreRecords: false }; + state.lastUpdated.contacts = new Date().toISOString(); + }) + .addCase(fetchContacts.rejected, (state, action) => { + state.loading.contacts = false; + state.errors.contacts = action.error.message || 'Failed to fetch contacts'; + }) + + // Fetch deals + .addCase(fetchDeals.pending, (state) => { + state.loading.deals = true; + state.errors.deals = null; + }) + .addCase(fetchDeals.fulfilled, (state, action) => { + state.loading.deals = false; + state.deals = action.payload.data || []; + state.pagination.deals = action.payload.info || { page: 1, count: 0, moreRecords: false }; + state.lastUpdated.deals = new Date().toISOString(); + }) + .addCase(fetchDeals.rejected, (state, action) => { + state.loading.deals = false; + state.errors.deals = action.error.message || 'Failed to fetch deals'; + }) + + // Fetch all CRM data + .addCase(fetchAllCrmData.pending, (state) => { + state.loading.leads = true; + state.loading.tasks = true; + state.loading.contacts = true; + state.loading.deals = true; + state.errors.leads = null; + state.errors.tasks = null; + state.errors.contacts = null; + state.errors.deals = null; + }) + .addCase(fetchAllCrmData.fulfilled, (state, action) => { + const { leads, tasks, contacts, deals } = action.payload; + + state.loading.leads = false; + state.loading.tasks = false; + state.loading.contacts = false; + state.loading.deals = false; + + state.leads = leads.data || []; + state.tasks = tasks.data || []; + state.contacts = contacts.data || []; + state.deals = deals.data || []; + + state.pagination.leads = leads.info || { page: 1, count: 0, moreRecords: false }; + state.pagination.tasks = tasks.info || { page: 1, count: 0, moreRecords: false }; + state.pagination.contacts = contacts.info || { page: 1, count: 0, moreRecords: false }; + state.pagination.deals = deals.info || { page: 1, count: 0, moreRecords: false }; + + const now = new Date().toISOString(); + state.lastUpdated.leads = now; + state.lastUpdated.tasks = now; + state.lastUpdated.contacts = now; + state.lastUpdated.deals = now; + }) + .addCase(fetchAllCrmData.rejected, (state, action) => { + state.loading.leads = false; + state.loading.tasks = false; + state.loading.contacts = false; + state.loading.deals = false; + + const errorMessage = action.error.message || 'Failed to fetch CRM data'; + state.errors.leads = errorMessage; + state.errors.tasks = errorMessage; + state.errors.contacts = errorMessage; + state.errors.deals = errorMessage; + }); + }, +}); + +export const { + clearErrors, + clearData, + setLeadsPage, + setTasksPage, + setContactsPage, + setDealsPage, +} = crmSlice.actions; + +export default crmSlice.reducer; diff --git a/src/modules/crm/store/selectors.ts b/src/modules/crm/store/selectors.ts new file mode 100644 index 0000000..5e85c38 --- /dev/null +++ b/src/modules/crm/store/selectors.ts @@ -0,0 +1,289 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '@/store/store'; +import type { CrmStats, LeadSource, DealStage, TaskStatus } from '../types/CrmTypes'; + +// Base selectors +export const selectCrmState = (state: RootState) => state.crm; + +export const selectLeads = (state: RootState) => state.crm.leads; +export const selectTasks = (state: RootState) => state.crm.tasks; +export const selectContacts = (state: RootState) => state.crm.contacts; +export const selectDeals = (state: RootState) => state.crm.deals; + +export const selectCrmLoading = (state: RootState) => state.crm.loading; +export const selectCrmErrors = (state: RootState) => state.crm.errors; +export const selectCrmPagination = (state: RootState) => state.crm.pagination; + +// Loading selectors +export const selectLeadsLoading = (state: RootState) => state.crm.loading.leads; +export const selectTasksLoading = (state: RootState) => state.crm.loading.tasks; +export const selectContactsLoading = (state: RootState) => state.crm.loading.contacts; +export const selectDealsLoading = (state: RootState) => state.crm.loading.deals; + +// Error selectors +export const selectLeadsError = (state: RootState) => state.crm.errors.leads; +export const selectTasksError = (state: RootState) => state.crm.errors.tasks; +export const selectContactsError = (state: RootState) => state.crm.errors.contacts; +export const selectDealsError = (state: RootState) => state.crm.errors.deals; + +// Computed selectors for dashboard +export const selectCrmStats = createSelector( + [selectLeads, selectTasks, selectContacts, selectDeals], + (leads, tasks, contacts, deals): CrmStats => { + // Ensure arrays are defined and are actually arrays, fallback to empty arrays + const safeLeads = Array.isArray(leads) ? leads : []; + const safeTasks = Array.isArray(tasks) ? tasks : []; + const safeContacts = Array.isArray(contacts) ? contacts : []; + const safeDeals = Array.isArray(deals) ? deals : []; + + // Calculate leads stats with safe property access and better fallbacks + const leadsByStatus = safeLeads.reduce((acc, lead) => { + const status = lead?.Lead_Status && lead.Lead_Status.trim() !== '' ? lead.Lead_Status : 'Not Set'; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {} as Record); + + const leadsBySource = safeLeads.reduce((acc, lead) => { + const source = lead?.Lead_Source && lead.Lead_Source.trim() !== '' ? lead.Lead_Source : 'Not Set'; + acc[source] = (acc[source] || 0) + 1; + return acc; + }, {} as Record); + + // Calculate tasks stats with safe property access and better fallbacks + const tasksByStatus = safeTasks.reduce((acc, task) => { + const status = task?.Status && task.Status.trim() !== '' ? task.Status : 'Not Set'; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {} as Record); + + const tasksByPriority = safeTasks.reduce((acc, task) => { + const priority = task?.Priority && task.Priority.trim() !== '' ? task.Priority : 'Not Set'; + acc[priority] = (acc[priority] || 0) + 1; + return acc; + }, {} as Record); + + // Calculate contacts stats with safe property access + const contactsByCompany = safeContacts.reduce((acc, contact) => { + const company = contact?.Account_Name?.name || 'Unknown'; + acc[company] = (acc[company] || 0) + 1; + return acc; + }, {} as Record); + + // Calculate deals stats with safe property access and better fallbacks + const dealsByStage = safeDeals.reduce((acc, deal) => { + const stage = deal?.Stage && deal.Stage.trim() !== '' ? deal.Stage : 'Not Set'; + acc[stage] = (acc[stage] || 0) + 1; + return acc; + }, {} as Record); + + const totalDealValue = safeDeals.reduce((sum, deal) => { + const amount = deal?.Amount || 0; + return sum + (typeof amount === 'number' && !isNaN(amount) ? amount : 0); + }, 0); + + const wonDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Won'); + const lostDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Lost'); + + return { + leads: { + total: safeLeads.length, + new: leadsByStatus['New'] || 0, + qualified: leadsByStatus['Qualified'] || 0, + converted: leadsByStatus['Converted'] || 0, + bySource: leadsBySource as Record, + byStatus: leadsByStatus as Record, + }, + tasks: { + total: safeTasks.length, + completed: tasksByStatus['Completed'] || 0, + overdue: safeTasks.filter(task => + new Date(task.Due_Date) < new Date() && task.Status !== 'Completed' + ).length, + byStatus: tasksByStatus as Record, + byPriority: tasksByPriority as Record, + }, + contacts: { + total: safeContacts.length, + active: safeContacts.length, // All contacts are considered active for now + inactive: 0, // No inactive contacts for now + byCompany: contactsByCompany, + }, + deals: { + total: safeDeals.length, + won: wonDeals.length, + lost: lostDeals.length, + totalValue: totalDealValue, + averageDealSize: safeDeals.length > 0 ? totalDealValue / safeDeals.length : 0, + byStage: dealsByStage as Record, + pipelineValue: safeDeals + .filter(deal => !['Closed Won', 'Closed Lost'].includes(deal.Stage)) + .reduce((sum, deal) => sum + (deal.Amount || 0), 0), + }, + }; + } +); + +// Dashboard specific selectors +export const selectDashboardData = createSelector( + [selectCrmStats, selectLeads, selectDeals, selectTasks], + (stats, leads, deals, tasks) => { + // Ensure arrays are defined, fallback to empty arrays + const safeLeads = leads || []; + const safeDeals = deals || []; + const safeTasks = tasks || []; + + // Top opportunities (deals by value) with safe property access + const topOpportunities = [...safeDeals] + .filter(deal => { + const stage = deal?.Stage || ''; + return !['Closed Won', 'Closed Lost'].includes(stage); + }) + .sort((a, b) => { + const amountA = a?.Amount || 0; + const amountB = b?.Amount || 0; + return (typeof amountB === 'number' && !isNaN(amountB) ? amountB : 0) - + (typeof amountA === 'number' && !isNaN(amountA) ? amountA : 0); + }) + .slice(0, 3) + .map(deal => ({ + name: deal?.Deal_Name || 'Unnamed Deal', + value: deal?.Amount || 0, + })); + + // Recent activity (recent tasks) with safe property access + const recentActivity = [...safeTasks] + .sort((a, b) => { + const dateA = a?.Created_Time ? new Date(a.Created_Time).getTime() : 0; + const dateB = b?.Created_Time ? new Date(b.Created_Time).getTime() : 0; + return dateB - dateA; + }) + .slice(0, 3) + .map(task => ({ + who: task?.Owner?.name || 'Unknown', + what: task?.Subject || 'No subject', + when: task?.Created_Time ? getTimeAgo(task.Created_Time) : 'Unknown', + })); + + // Pipeline distribution + const pipelineStages = [ + { label: 'Prospecting', value: stats.deals.byStage['Prospecting'] || 0, color: '#3AA0FF' }, + { label: 'Qualified', value: stats.deals.byStage['Qualification'] || 0, color: '#10B981' }, + { label: 'Proposal', value: stats.deals.byStage['Proposal'] || 0, color: '#F59E0B' }, + { label: 'Negotiation', value: stats.deals.byStage['Negotiation'] || 0, color: '#6366F1' }, + { label: 'Closed Won', value: stats.deals.won, color: '#22C55E' }, + { label: 'Closed Lost', value: stats.deals.lost, color: '#EF4444' }, + ]; + + // Lead sources distribution + const leadSources = Object.entries(stats.leads.bySource) + .map(([source, count]) => ({ + label: source, + value: count, + color: getSourceColor(source), + })) + .sort((a, b) => b.value - a.value); + + // Leads trend (mock data for now - could be calculated from historical data) + const leadsTrend = [60, 62, 68, 70, 76, 84]; // This would come from historical data + + return { + // KPIs with safe calculations + leads: stats.leads.total || 0, + opportunities: stats.deals.total || 0, + wonDeals: stats.deals.won || 0, + conversionPct: stats.leads.total > 0 ? Math.round(((stats.leads.converted || 0) / stats.leads.total) * 100) : 0, + + // Charts and visualizations + leadsTrend, + pipeline: pipelineStages, + sourceDist: leadSources, + topOpps: topOpportunities, + recent: recentActivity, + }; + } +); + +// Helper functions +function getTimeAgo(dateString: string): string { + const now = new Date(); + const date = new Date(dateString); + const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60)); + + if (diffInHours < 1) return 'now'; + if (diffInHours < 24) return `${diffInHours}h`; + const diffInDays = Math.floor(diffInHours / 24); + return `${diffInDays}d`; +} + +function getSourceColor(source: string): string { + // Define a comprehensive color palette with distinct colors for sources + const colorPalette = [ + '#3B82F6', // Bright Blue + '#8B5CF6', // Purple + '#06B6D4', // Cyan + '#F59E0B', // Amber + '#10B981', // Emerald + '#F97316', // Orange + '#22C55E', // Green + '#84CC16', // Lime + '#14B8A6', // Teal + '#059669', // Dark Green + '#EF4444', // Red + '#DC2626', // Dark Red + '#991B1B', // Darker Red + '#9CA3AF', // Gray + '#EC4899', // Pink + '#8B5A2B', // Brown + '#B91C1C', // Lost Red + '#16A34A', // Success Green + '#6366F1', // Indigo + '#7C3AED', // Violet + '#0891B2', // Sky Blue + '#CA8A04', // Gold + '#1F2937', // Dark Gray + '#BE185D', // Rose + '#0D9488', // Emerald Dark + '#7C2D12', // Brown Dark + '#1E40AF', // Blue Dark + '#C2410C', // Orange Dark + '#9333EA', // Purple Dark + '#059669', // Green Dark + '#DC2626', // Hot Red + '#F97316', // Warm Orange + '#6B7280', // Cold Gray + '#EF4444', // Error Red + '#10B981', // Success Green + '#F59E0B', // Warning Orange + '#3B82F6', // Info Blue + '#0077B5', // LinkedIn Blue + '#1877F2', // Facebook Blue + '#1DA1F2', // Twitter Blue + '#E4405F', // Instagram Pink + '#FF0000', // YouTube Red + ]; + + // Create a consistent hash from the source string + let hash = 0; + const normalizedSource = source.toLowerCase().trim(); + for (let i = 0; i < normalizedSource.length; i++) { + const char = normalizedSource.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + + // Ensure positive index and get color from palette + const colorIndex = Math.abs(hash) % colorPalette.length; + return colorPalette[colorIndex]; +} + +// Loading state selectors +export const selectIsAnyLoading = createSelector( + [selectCrmLoading], + (loading) => Object.values(loading).some(Boolean) +); + +export const selectHasAnyError = createSelector( + [selectCrmErrors], + (errors) => Object.values(errors).some(error => error !== null) +); + diff --git a/src/modules/crm/types/CrmTypes.ts b/src/modules/crm/types/CrmTypes.ts new file mode 100644 index 0000000..b99bc07 --- /dev/null +++ b/src/modules/crm/types/CrmTypes.ts @@ -0,0 +1,321 @@ +// CRM Data Types for Zoho CRM Integration + +export interface CrmLead { + id: string; + Owner: { + name: string; + id: string; + email: string; + }; + Company: string; + Email: string; + Phone: string; + Mobile: string; + Lead_Status: LeadStatus; + Lead_Source: LeadSource; + Created_Time: string; + Last_Activity_Time: string; + Annual_Revenue?: number; + Description?: string; + Industry?: string; + Street?: string; + City?: string; + State?: string; + Country?: string; + Zip_Code?: string; + Website?: string; + First_Name: string; + Last_Name: string; + Full_Name: string; + Salutation?: string; + Designation?: string; + Twitter?: string; + Skype_ID?: string; + Secondary_Email?: string; + Fax?: string; + No_of_Employees?: number; + Tag: string[]; +} + +export interface CrmTask { + id: string; + Owner: { + name: string; + id: string; + email: string; + }; + Subject: string; + Description: string; + Status: TaskStatus; + Priority: TaskPriority; + Due_Date: string; + Created_Time: string; + Closed_Time?: string; + Who_Id?: { + name: string; + id: string; + }; + What_Id?: { + name: string; + id: string; + }; + Remind_At?: string; + Send_Notification_Email: boolean; + Recurring_Activity?: string; + Tag: string[]; +} + +export interface CrmContact { + id: string; + Owner: { + name: string; + id: string; + email: string; + }; + First_Name: string; + Last_Name: string; + Full_Name: string; + Email: string; + Phone: string; + Mobile: string; + Home_Phone?: string; + Other_Phone?: string; + Account_Name?: { + name: string; + id: string; + }; + Title: string; + Department?: string; + Lead_Source: LeadSource; + Last_Activity_Time: string; + Created_Time: string; + Mailing_Street?: string; + Mailing_City?: string; + Mailing_State?: string; + Mailing_Country?: string; + Mailing_Zip?: string; + Other_Street?: string; + Other_City?: string; + Other_State?: string; + Other_Country?: string; + Other_Zip?: string; + Salutation?: string; + Assistant?: string; + Asst_Phone?: string; + Reporting_To?: string; + Date_of_Birth?: string; + Secondary_Email?: string; + Twitter?: string; + Skype_ID?: string; + Fax?: string; + Description?: string; + Tag: string[]; +} + +export interface CrmDeal { + id: string; + Owner: { + name: string; + id: string; + email: string; + }; + Deal_Name: string; + Amount: number; + Stage: DealStage; + Probability: number; + Closing_Date: string; + Contact_Name?: { + name: string; + id: string; + }; + Account_Name?: { + name: string; + id: string; + }; + Description?: string; + Lead_Source?: LeadSource; + Type?: string; + Next_Step?: string; + Created_Time: string; + Modified_Time: string; + Expected_Revenue?: number; + Overall_Sales_Duration?: number; + Sales_Cycle_Duration?: number; + Lead_Conversion_Time?: string; + Reason_For_Loss__s?: string; + Campaign_Source?: string; + Tag: string[]; +} + +// Enums and Union Types +export type LeadStatus = 'New' | 'Contacted' | 'Qualified' | 'Unqualified' | 'Converted' | 'Lost Lead' | 'Not Contacted' | 'Junk Lead'; +export type TaskType = 'Call' | 'Email' | 'Meeting' | 'Follow-up' | 'Demo' | 'Proposal' | 'Other'; +export type TaskPriority = 'Highest' | 'High' | 'Medium' | 'Low' | 'Lowest'; +export type TaskStatus = 'Not Started' | 'In Progress' | 'Completed' | 'Cancelled' | 'Deferred' | 'Waiting on someone else'; +export type ContactStatus = 'Active' | 'Inactive' | 'Unsubscribed'; +export type DealStage = 'Prospecting' | 'Qualification' | 'Identify Decision Makers' | 'Proposal' | 'Negotiation' | 'Closed Won' | 'Closed Lost'; +export type LeadSource = 'Website' | 'Referral' | 'Event' | 'Cold Call' | 'Email Campaign' | 'Social Media' | 'Partner' | 'Trade Show' | 'Other'; + +// Combined CRM Data Interface +export interface CrmData { + leads: CrmLead[]; + tasks: CrmTask[]; + contacts: CrmContact[]; + deals: CrmDeal[]; +} + +// API Response Types +export interface CrmApiResponse { + status: 'success' | 'error'; + message: string; + data?: T; + error?: string; + timestamp: string; +} + +export interface CrmPaginatedResponse { + data: T[]; + info: { + count: number; + moreRecords: boolean; + page: number; + }; +} + +// Filter and Search Types +export interface CrmFilters { + leads?: { + status?: LeadStatus[]; + source?: LeadSource[]; + assignedTo?: string[]; + dateRange?: { + start: string; + end: string; + }; + }; + tasks?: { + status?: TaskStatus[]; + priority?: TaskPriority[]; + type?: TaskType[]; + assignedTo?: string[]; + dueDateRange?: { + start: string; + end: string; + }; + }; + contacts?: { + status?: ContactStatus[]; + company?: string[]; + assignedTo?: string[]; + }; + deals?: { + stage?: DealStage[]; + owner?: string[]; + amountRange?: { + min: number; + max: number; + }; + closeDateRange?: { + start: string; + end: string; + }; + }; +} + +export interface CrmSearchParams { + query?: string; + type?: 'leads' | 'tasks' | 'contacts' | 'deals' | 'all'; + filters?: CrmFilters; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page?: number; + limit?: number; +} + +// Statistics and Analytics Types +export interface CrmStats { + leads: { + total: number; + new: number; + qualified: number; + converted: number; + bySource: Record; + byStatus: Record; + }; + tasks: { + total: number; + completed: number; + overdue: number; + byStatus: Record; + byPriority: Record; + }; + contacts: { + total: number; + active: number; + inactive: number; + byCompany: Record; + }; + deals: { + total: number; + won: number; + lost: number; + totalValue: number; + averageDealSize: number; + byStage: Record; + pipelineValue: number; + }; +} + +// Form Types for Creating/Editing CRM Records +export interface CreateLeadForm { + name: string; + company: string; + email: string; + phone: string; + status: LeadStatus; + source: LeadSource; + value?: number; + description?: string; + industry?: string; + assignedTo?: string; +} + +export interface CreateTaskForm { + subject: string; + description: string; + type: TaskType; + priority: TaskPriority; + dueDate: string; + assignedTo: string; + relatedTo: string; + relatedId?: string; +} + +export interface CreateContactForm { + firstName: string; + lastName: string; + email: string; + phone: string; + company: string; + title: string; + status: ContactStatus; + leadSource: LeadSource; + address?: string; + website?: string; + notes?: string; + assignedTo?: string; +} + +export interface CreateDealForm { + name: string; + amount: number; + stage: DealStage; + probability: number; + closeDate: string; + contactName: string; + accountName: string; + owner: string; + description?: string; + source?: LeadSource; +} + diff --git a/src/modules/integrations/screens/ZohoAuth.tsx b/src/modules/integrations/screens/ZohoAuth.tsx index 446a5a5..5d7e107 100644 --- a/src/modules/integrations/screens/ZohoAuth.tsx +++ b/src/modules/integrations/screens/ZohoAuth.tsx @@ -55,9 +55,11 @@ const ZOHO_CONFIG = { // across Projects, CRM, Books, and People. Tailor scopes to your app's needs and compliance. const getScopeForService = (_serviceKey?: ServiceKey): string => { return [ + //zoho portals + 'ZohoProjects.portals.READ', // Zoho Projects 'ZohoProjects.projects.READ', - 'ZohoProjects.tasks.READ', + 'ZohoProjects.tasklists.READ', 'ZohoProjects.timesheets.READ', // Zoho CRM (adjust modules per your needs) 'ZohoCRM.users.READ', diff --git a/src/modules/profile/screens/ProfileScreen.tsx b/src/modules/profile/screens/ProfileScreen.tsx index 1e4c9ec..e44eb65 100644 --- a/src/modules/profile/screens/ProfileScreen.tsx +++ b/src/modules/profile/screens/ProfileScreen.tsx @@ -5,21 +5,28 @@ import { Container, ConfirmModal } from '@/shared/components/ui'; import type { RootState } from '@/store/store'; import { useTheme } from '@/shared/styles/useTheme'; import { logout } from '@/modules/auth/store/authSlice'; -import { setProfile } from '@/modules/profile/store/profileSlice'; +import { setProfile, setProfileFromAuth } from '@/modules/profile/store/profileSlice'; +import { selectUserDisplayName, selectUserEmail, selectUserRole, selectAuthUser } from '@/modules/profile/store/selectors'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice'; const ProfileScreen: React.FC = () => { const dispatch = useDispatch(); const { colors, fonts } = useTheme(); - const { name, email } = useSelector((s: RootState) => s.profile); + + // Get user data using selectors + const authUser = useSelector(selectAuthUser); + const displayName = useSelector(selectUserDisplayName); + const email = useSelector(selectUserEmail); + const role = useSelector(selectUserRole); + const { name } = useSelector((s: RootState) => s.profile); useEffect(() => { - // Seed dummy data if empty - if (!name && !email) { - dispatch(setProfile({ name: 'Jane Doe', email: 'jane.doe@example.com' })); + // Sync profile data with auth user data when available + if (authUser && (!name || !email)) { + dispatch(setProfileFromAuth(authUser)); } - }, [dispatch, name, email]); + }, [dispatch, authUser, name, email]); const [showLogout, setShowLogout] = React.useState(false); const handleLogout = () => setShowLogout(true); @@ -45,12 +52,25 @@ const ProfileScreen: React.FC = () => { {/* Name */} - {name || 'Sana Afzal'} + + {displayName} + {/* Email pill */} - {email || 'sanaaafzal291@gmail.com'} + + {email} + + + {/* Role badge */} + {role && ( + + + {role.toUpperCase()} + + + )} {/* Settings card */} @@ -119,6 +139,12 @@ const styles = StyleSheet.create({ borderRadius: 14, marginTop: 10, }, + roleBadge: { + paddingHorizontal: 16, + paddingVertical: 6, + borderRadius: 12, + marginTop: 8, + }, card: { marginTop: 20, marginHorizontal: 16, diff --git a/src/modules/profile/store/profileSlice.ts b/src/modules/profile/store/profileSlice.ts index d10d66c..2feaab7 100644 --- a/src/modules/profile/store/profileSlice.ts +++ b/src/modules/profile/store/profileSlice.ts @@ -1,8 +1,12 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { AuthUser } from '@/modules/auth/store/authSlice'; export interface ProfileState { name: string; email: string; + role: string; + userId: number | null; + uuid: string; loading: boolean; error: string | null; } @@ -10,6 +14,9 @@ export interface ProfileState { const initialState: ProfileState = { name: '', email: '', + role: '', + userId: null, + uuid: '', loading: false, error: null, }; @@ -18,9 +25,19 @@ const profileSlice = createSlice({ name: 'profile', initialState, reducers: { - setProfile: (state, action: PayloadAction<{ name: string; email: string }>) => { + setProfile: (state, action: PayloadAction<{ name: string; email: string; role?: string; userId?: number; uuid?: string }>) => { state.name = action.payload.name; state.email = action.payload.email; + if (action.payload.role) state.role = action.payload.role; + if (action.payload.userId) state.userId = action.payload.userId; + if (action.payload.uuid) state.uuid = action.payload.uuid; + }, + setProfileFromAuth: (state, action: PayloadAction) => { + state.name = action.payload.displayName; + state.email = action.payload.email; + state.role = action.payload.role; + state.userId = action.payload.id; + state.uuid = action.payload.uuid; }, setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -32,7 +49,7 @@ const profileSlice = createSlice({ }, }); -export const { setProfile, setLoading, setError, resetState } = profileSlice.actions; +export const { setProfile, setProfileFromAuth, setLoading, setError, resetState } = profileSlice.actions; export default profileSlice; diff --git a/src/modules/profile/store/selectors.ts b/src/modules/profile/store/selectors.ts new file mode 100644 index 0000000..2bf5fdc --- /dev/null +++ b/src/modules/profile/store/selectors.ts @@ -0,0 +1,49 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '@/store/store'; + +// Profile selectors +export const selectProfile = (state: RootState) => state.profile; +export const selectProfileName = (state: RootState) => state.profile.name; +export const selectProfileEmail = (state: RootState) => state.profile.email; +export const selectProfileRole = (state: RootState) => state.profile.role; +export const selectProfileUserId = (state: RootState) => state.profile.userId; +export const selectProfileUuid = (state: RootState) => state.profile.uuid; +export const selectProfileLoading = (state: RootState) => state.profile.loading; +export const selectProfileError = (state: RootState) => state.profile.error; + +// Auth selectors +export const selectAuthUser = (state: RootState) => state.auth.user; +export const selectIsAuthenticated = (state: RootState) => state.auth.isAuthenticated; + +// Combined selectors for user data +export const selectUserDisplayName = createSelector( + [selectProfileName, selectAuthUser], + (profileName, authUser) => profileName || authUser?.displayName || 'User' +); + +export const selectUserEmail = createSelector( + [selectProfileEmail, selectAuthUser], + (profileEmail, authUser) => profileEmail || authUser?.email || 'user@example.com' +); + +export const selectUserRole = createSelector( + [selectProfileRole, selectAuthUser], + (profileRole, authUser) => profileRole || authUser?.role || '' +); + +export const selectUserData = createSelector( + [selectAuthUser, selectProfile], + (authUser, profile) => ({ + name: profile.name || authUser?.displayName || 'User', + email: profile.email || authUser?.email || 'user@example.com', + role: profile.role || authUser?.role || '', + userId: profile.userId || authUser?.id || null, + uuid: profile.uuid || authUser?.uuid || '', + }) +); + +// Check if profile data is available +export const selectHasProfileData = createSelector( + [selectUserDisplayName, selectUserEmail], + (name, email) => !!(name && email && name !== 'User' && email !== 'user@example.com') +); diff --git a/src/services/http.ts b/src/services/http.ts index 939e1bd..790d686 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -1,10 +1,58 @@ import { create } from 'apisauce'; +import { store } from '@/store/store'; +import { selectAccessToken } from '@/modules/auth/store/selectors'; const http = create({ - baseURL: 'http://192.168.1.16:4000', + baseURL: 'http://192.168.1.12:4000', + // baseURL: 'http://160.187.167.216', timeout: 10000, }); +// Add request interceptor to include auth token +http.addRequestTransform((request) => { + // Skip adding token for authentication and public endpoints + const publicEndpoints = [ + '/api/v1/auth/login', // All auth endpoints + '/api/v1/users/register', // User registration + '/api/v1/users/signup', // User signup + '/api/v1/public/', // Any public endpoints + ]; + + const isPublicEndpoint = publicEndpoints.some(endpoint => + request.url?.startsWith(endpoint) + ); + + if (isPublicEndpoint) { + return; // Skip adding token for public endpoints + } + + const state = store.getState(); + const token = selectAccessToken(state); + + if (token) { + request.headers = { + ...request.headers, + Authorization: `Bearer ${token}`, + }; + } else { + console.warn('No access token found for API request to:', request.url); + } +}); + +// Add response interceptor for error handling +http.addResponseTransform((response) => { + if (response.status === 401) { + console.warn('Unauthorized request - token may be expired'); + // You could dispatch a logout action here if needed + // dispatch(logout()); + } + + // Log successful requests for debugging (optional) + if (response.ok && __DEV__) { + console.log(`✅ API Success: ${response.config?.method?.toUpperCase()} ${response.config?.url}`); + } +}); + export default http; diff --git a/src/shared/components/charts/CompactPipeline.tsx b/src/shared/components/charts/CompactPipeline.tsx new file mode 100644 index 0000000..ec12fae --- /dev/null +++ b/src/shared/components/charts/CompactPipeline.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import Svg, { Rect, Text as SvgText } from 'react-native-svg'; + +interface CompactPipelineData { + label: string; + value: number; + color: string; +} + +interface CompactPipelineProps { + data: CompactPipelineData[]; + colors: any; + fonts: any; +} + +const CompactPipeline: React.FC = ({ + data, + colors, + fonts +}) => { + const total = data.reduce((sum, item) => sum + item.value, 0); + const maxValue = Math.max(...data.map(item => item.value)); + + if (total === 0) { + return ( + + + No Pipeline Data + + + ); + } + + const containerWidth = 320; + const barHeight = 24; + const labelWidth = 80; + const valueWidth = 40; + const barWidth = containerWidth - labelWidth - valueWidth - 60; // 60 for padding + + return ( + + + {data.map((stage, index) => { + const y = index * 35 + 5; + const percentage = total > 0 ? (stage.value / total) * 100 : 0; + const barFillWidth = maxValue > 0 ? (stage.value / maxValue) * barWidth : 0; + + return ( + + {/* Stage Label */} + + {stage.label.length > 10 ? stage.label.substring(0, 10) + '...' : stage.label} + + + {/* Progress Bar Background */} + + + {/* Progress Bar Fill */} + + + {/* Value */} + + {stage.value} + + + {/* Percentage */} + + {percentage.toFixed(0)}% + + + ); + })} + + + {/* Summary Row */} + + + + Total Stages + + + {data.length} + + + + + Total Deals + + + {total} + + + + + Avg/Stage + + + {data.length > 0 ? Math.round(total / data.length) : 0} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 8, + paddingHorizontal: 5, + }, + noDataText: { + fontSize: 14, + textAlign: 'center', + fontStyle: 'italic', + paddingVertical: 20, + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 12, + paddingTop: 8, + }, + summaryItem: { + flex: 1, + alignItems: 'center', + paddingVertical: 6, + borderTopWidth: 1, + marginHorizontal: 4, + }, + summaryLabel: { + fontSize: 10, + marginBottom: 2, + }, + summaryValue: { + fontSize: 14, + fontWeight: 'bold', + }, +}); + +export default CompactPipeline; diff --git a/src/shared/components/charts/DonutChart.tsx b/src/shared/components/charts/DonutChart.tsx new file mode 100644 index 0000000..eec0c09 --- /dev/null +++ b/src/shared/components/charts/DonutChart.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import Svg, { Circle, G } from 'react-native-svg'; + +interface DonutChartData { + label: string; + value: number; + color: string; +} + +interface DonutChartProps { + data: DonutChartData[]; + size?: number; + colors: any; + fonts: any; +} + +const DonutChart: React.FC = ({ + data, + size = 140, + colors, + fonts +}) => { + const total = data.reduce((sum, item) => sum + item.value, 0); + + if (total === 0) { + return ( + + + + No Data + + + + ); + } + + let currentAngle = 0; + const radius = (size - 40) / 2; + const innerRadius = radius * 0.6; + const centerX = size / 2; + const centerY = size / 2; + + return ( + + + + {/* Background circle */} + + + {/* Inner circle (donut hole) */} + + + {data.map((item, index) => { + const percentage = (item.value / total) * 100; + const angle = (percentage / 100) * 360; + const startAngle = currentAngle; + + currentAngle += angle; + + if (percentage === 0) return null; + + return ( + + + + ); + })} + + + + {total} + + + Sources + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + chartContainer: { + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + }, + centerText: { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + }, + totalText: { + fontSize: 16, + fontWeight: 'bold', + }, + totalLabel: { + fontSize: 9, + marginTop: 2, + }, + noDataText: { + fontSize: 14, + textAlign: 'center', + }, +}); + +export default DonutChart; diff --git a/src/shared/components/charts/FunnelChart.tsx b/src/shared/components/charts/FunnelChart.tsx new file mode 100644 index 0000000..5f05f67 --- /dev/null +++ b/src/shared/components/charts/FunnelChart.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import Svg, { Path, Text as SvgText } from 'react-native-svg'; + +interface FunnelChartData { + label: string; + value: number; + color: string; +} + +interface FunnelChartProps { + data: FunnelChartData[]; + colors: any; + fonts: any; +} + +const FunnelChart: React.FC = ({ + data, + colors, + fonts +}) => { + const total = data.reduce((sum, item) => sum + item.value, 0); + const maxValue = Math.max(...data.map(item => item.value)); + + if (total === 0) { + return ( + + + No Pipeline Data + + + ); + } + + // Increased dimensions for better spacing + const funnelWidth = 280; + const funnelHeight = 220; + const segmentHeight = funnelHeight / data.length; + const centerX = funnelWidth / 2; + const textPadding = 20; // Space for text on sides + + const createFunnelPath = (index: number, value: number) => { + const availableWidth = funnelWidth - (textPadding * 2); + const width = (value / maxValue) * (availableWidth * 0.7) + (availableWidth * 0.15); + const y = index * segmentHeight; + const nextY = (index + 1) * segmentHeight; + const nextWidth = index < data.length - 1 ? + (data[index + 1].value / maxValue) * (availableWidth * 0.7) + (availableWidth * 0.15) : + (availableWidth * 0.15); + + const leftX = centerX - width / 2; + const rightX = centerX + width / 2; + const nextLeftX = centerX - nextWidth / 2; + const nextRightX = centerX + nextWidth / 2; + + return `M ${leftX} ${y} L ${rightX} ${y} L ${nextRightX} ${nextY} L ${nextLeftX} ${nextY} Z`; + }; + + return ( + + + {data.map((item, index) => { + const percentage = (item.value / total) * 100; + const path = createFunnelPath(index, item.value); + const textY = index * segmentHeight + segmentHeight / 2; + + return ( + + + {/* Label text */} + + {item.label.length > 12 ? item.label.substring(0, 12) + '...' : item.label} + + {/* Value and percentage text */} + + {item.value} ({percentage.toFixed(0)}%) + + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 15, + paddingHorizontal: 10, + width: '100%', + }, + noDataText: { + fontSize: 14, + textAlign: 'center', + fontStyle: 'italic', + }, +}); + +export default FunnelChart; diff --git a/src/shared/components/charts/PieChart.tsx b/src/shared/components/charts/PieChart.tsx new file mode 100644 index 0000000..e48a914 --- /dev/null +++ b/src/shared/components/charts/PieChart.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import Svg, { Circle, G } from 'react-native-svg'; + +interface PieChartData { + label: string; + value: number; + color: string; +} + +interface PieChartProps { + data: PieChartData[]; + size?: number; + colors: any; + fonts: any; +} + +const PieChart: React.FC = ({ + data, + size = 120, + colors, + fonts +}) => { + const total = data.reduce((sum, item) => sum + item.value, 0); + + if (total === 0) { + return ( + + + + No Data + + + + ); + } + + let currentAngle = 0; + const radius = (size - 40) / 2; + const centerX = size / 2; + const centerY = size / 2; + + const createArcPath = (startAngle: number, endAngle: number, radius: number) => { + const start = polarToCartesian(centerX, centerY, radius, endAngle); + const end = polarToCartesian(centerX, centerY, radius, startAngle); + const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1"; + + return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y} L ${centerX} ${centerY} Z`; + }; + + const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => { + const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0; + return { + x: centerX + (radius * Math.cos(angleInRadians)), + y: centerY + (radius * Math.sin(angleInRadians)) + }; + }; + + return ( + + + + {data.map((item, index) => { + const percentage = (item.value / total) * 100; + const angle = (percentage / 100) * 360; + const startAngle = currentAngle; + const endAngle = currentAngle + angle; + + currentAngle += angle; + + if (percentage === 0) return null; + + return ( + + + + ); + })} + + + + {total} + + + Total + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + chartContainer: { + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + }, + centerText: { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + }, + totalText: { + fontSize: 18, + fontWeight: 'bold', + }, + totalLabel: { + fontSize: 11, + marginTop: 2, + }, + noDataText: { + fontSize: 14, + textAlign: 'center', + }, +}); + +export default PieChart; diff --git a/src/shared/components/charts/PipelineCards.tsx b/src/shared/components/charts/PipelineCards.tsx new file mode 100644 index 0000000..9ce80a7 --- /dev/null +++ b/src/shared/components/charts/PipelineCards.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +interface PipelineCardsData { + label: string; + value: number; + color: string; +} + +interface PipelineCardsProps { + data: PipelineCardsData[]; + colors: any; + fonts: any; +} + +const PipelineCards: React.FC = ({ + data, + colors, + fonts +}) => { + const total = data.reduce((sum, item) => sum + item.value, 0); + const maxValue = Math.max(...data.map(item => item.value)); + + if (total === 0) { + return ( + + + No Pipeline Data + + + ); + } + + return ( + + {data.map((stage, index) => { + const percentage = total > 0 ? (stage.value / total) * 100 : 0; + const progressWidth = maxValue > 0 ? (stage.value / maxValue) * 100 : 0; + + return ( + + {/* Stage Header */} + + + + + {stage.label} + + + + + {stage.value} + + + {percentage.toFixed(1)}% + + + + + {/* Progress Bar */} + + + + + {/* Stage Details */} + + + + Deals + + + {stage.value} + + + + + Pipeline % + + + {percentage.toFixed(1)}% + + + + + Rank + + + #{index + 1} + + + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 8, + }, + noDataText: { + fontSize: 14, + textAlign: 'center', + fontStyle: 'italic', + paddingVertical: 20, + }, + stageCard: { + borderRadius: 12, + borderWidth: 1, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + stageHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + stageInfo: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + stageIndicator: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 10, + }, + stageLabel: { + fontSize: 16, + fontWeight: '600', + flex: 1, + }, + stageStats: { + alignItems: 'flex-end', + }, + stageValue: { + fontSize: 20, + fontWeight: 'bold', + }, + stagePercentage: { + fontSize: 12, + marginTop: 2, + }, + progressContainer: { + height: 8, + borderRadius: 4, + marginBottom: 12, + overflow: 'hidden', + }, + progressBar: { + height: '100%', + borderRadius: 4, + }, + stageDetails: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + detailItem: { + alignItems: 'center', + flex: 1, + }, + detailLabel: { + fontSize: 11, + marginBottom: 4, + }, + detailValue: { + fontSize: 14, + fontWeight: 'bold', + }, +}); + +export default PipelineCards; diff --git a/src/shared/components/charts/PipelineFlow.tsx b/src/shared/components/charts/PipelineFlow.tsx new file mode 100644 index 0000000..b87a3f7 --- /dev/null +++ b/src/shared/components/charts/PipelineFlow.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import Svg, { Rect, Circle, Line, Text as SvgText } from 'react-native-svg'; + +interface PipelineFlowData { + label: string; + value: number; + color: string; +} + +interface PipelineFlowProps { + data: PipelineFlowData[]; + colors: any; + fonts: any; +} + +const PipelineFlow: React.FC = ({ + data, + colors, + fonts +}) => { + const total = data.reduce((sum, item) => sum + item.value, 0); + const maxValue = Math.max(...data.map(item => item.value)); + + if (total === 0) { + return ( + + + No Pipeline Data + + + ); + } + + const containerWidth = 300; + const stageHeight = 50; + const circleRadius = 12; + const lineWidth = 40; + const startX = 30; + const textOffset = 60; + + return ( + + + {data.map((stage, index) => { + const y = index * stageHeight + stageHeight / 2; + const x = startX + (index * (circleRadius * 2 + lineWidth)); + const percentage = (stage.value / maxValue) * 100; + const barWidth = (percentage / 100) * 80 + 20; // Min width 20, max 100 + + return ( + + {/* Stage Circle */} + + + {/* Stage Value inside circle */} + + {stage.value} + + + {/* Progress Bar */} + + + {/* Progress Fill */} + + + {/* Stage Label */} + + {stage.label} + + + {/* Percentage */} + + {((stage.value / total) * 100).toFixed(0)}% + + + {/* Connecting Line (except for last item) */} + {index < data.length - 1 && ( + + )} + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'flex-start', + justifyContent: 'center', + paddingVertical: 10, + paddingHorizontal: 5, + }, + noDataText: { + fontSize: 14, + textAlign: 'center', + fontStyle: 'italic', + }, +}); + +export default PipelineFlow; diff --git a/src/shared/components/charts/StackedBarChart.tsx b/src/shared/components/charts/StackedBarChart.tsx new file mode 100644 index 0000000..d3103ec --- /dev/null +++ b/src/shared/components/charts/StackedBarChart.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import Svg, { Rect, Text as SvgText } from 'react-native-svg'; + +interface StackedBarChartData { + label: string; + value: number; + color: string; +} + +interface StackedBarChartProps { + data: StackedBarChartData[]; + colors: any; + fonts: any; + height?: number; +} + +const StackedBarChart: React.FC = ({ + data, + colors, + fonts, + height = 120 +}) => { + const total = data.reduce((sum, item) => sum + item.value, 0); + const maxValue = Math.max(...data.map(item => item.value)); + + if (total === 0) { + return ( + + + No Task Data + + + ); + } + + const chartWidth = 250; + const barWidth = chartWidth / data.length - 10; + const maxBarHeight = height - 40; + + return ( + + + {data.map((item, index) => { + const barHeight = maxValue > 0 ? (item.value / maxValue) * maxBarHeight : 0; + const x = index * (barWidth + 10) + 5; + const y = height - 20 - barHeight; + const percentage = total > 0 ? (item.value / total) * 100 : 0; + + return ( + + + + {item.label} + + + {item.value} + + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + }, + noDataText: { + fontSize: 14, + textAlign: 'center', + fontStyle: 'italic', + }, +}); + +export default StackedBarChart; diff --git a/src/shared/components/charts/index.ts b/src/shared/components/charts/index.ts new file mode 100644 index 0000000..1f5bd9b --- /dev/null +++ b/src/shared/components/charts/index.ts @@ -0,0 +1,7 @@ +export { default as PieChart } from './PieChart'; +export { default as DonutChart } from './DonutChart'; +export { default as FunnelChart } from './FunnelChart'; +export { default as StackedBarChart } from './StackedBarChart'; +export { default as PipelineFlow } from './PipelineFlow'; +export { default as PipelineCards } from './PipelineCards'; +export { default as CompactPipeline } from './CompactPipeline'; diff --git a/src/shared/constants/API_ENDPOINTS.ts b/src/shared/constants/API_ENDPOINTS.ts index 0824046..c86ea89 100644 --- a/src/shared/constants/API_ENDPOINTS.ts +++ b/src/shared/constants/API_ENDPOINTS.ts @@ -5,6 +5,9 @@ export const API_ENDPOINTS = { HR_METRICS: '/hr/metrics', ZOHO_PROJECTS: '/zoho/projects', PROFILE: '/profile', + + // CRM API Endpoints + CRM_DATA: '/api/v1/crm/data', } as const; export type ApiEndpointKey = keyof typeof API_ENDPOINTS; diff --git a/src/store/store.ts b/src/store/store.ts index 5b32204..a4653f3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,6 +9,7 @@ import hrSlice from '@/modules/hr/store/hrSlice'; import zohoProjectsSlice from '@/modules/zohoProjects/store/zohoProjectsSlice'; import profileSlice from '@/modules/profile/store/profileSlice'; import integrationsSlice from '@/modules/integrations/store/integrationsSlice'; +import crmSlice from '@/modules/crm/store/crmSlice'; const rootReducer = combineReducers({ auth: authSlice.reducer, @@ -16,13 +17,14 @@ const rootReducer = combineReducers({ zohoProjects: zohoProjectsSlice.reducer, profile: profileSlice.reducer, integrations: integrationsSlice.reducer, + crm: crmSlice, ui: uiSlice.reducer, }); const persistConfig = { key: 'root', storage: AsyncStorage, - whitelist: ['auth', 'hr', 'zohoProjects', 'profile', 'integrations'], + whitelist: ['auth', 'hr', 'zohoProjects', 'profile', 'integrations', 'crm'], blacklist: ['ui'], };