zoho crm data mapped and need to integrate zoho projects

This commit is contained in:
yashwin-foxy 2025-09-12 18:53:17 +05:30
parent 0df78919f2
commit 438654be98
30 changed files with 3358 additions and 98 deletions

View File

@ -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 {
</NavigationContainer>
)
)}
<Toast bottomOffset={20} position='bottom'/>
</PersistGate>
</ThemeProvider>

View File

@ -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">
<activity
android:name=".MainActivity"

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

15
src/modules/auth/index.ts Normal file
View File

@ -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';

View File

@ -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<AppDispatch>();
@ -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 */}
<View style={styles.rowBetween}>
<Pressable style={styles.row} onPress={() => setRememberMe(v => !v)}>
<Pressable
style={styles.row}
onPress={() => {
setRememberMe(v => !v);
showInfo(rememberMe ? 'Will not remember login' : 'Will remember login');
}}
>
<Icon name={rememberMe ? 'checkbox-marked' : 'checkbox-blank-outline'} size={20} color={colors.primary} />
<Text style={[styles.rememberText, { color: colors.text, fontFamily: fonts.regular }]}>Remember me</Text>
</Pressable>
<TouchableOpacity>
<TouchableOpacity onPress={() => showInfo('Forgot password feature coming soon!')}>
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Forgot Password ?</Text>
</TouchableOpacity>
</View>
@ -263,21 +280,30 @@ const LoginScreen: React.FC = () => {
{/* Social buttons */}
<View style={styles.socialRow}>
<TouchableOpacity style={[styles.socialButton, { borderColor: colors.border }]}>
<TouchableOpacity
style={[styles.socialButton, { borderColor: colors.border }]}
onPress={() => showInfo('Google login coming soon!')}
>
<Icon name="google" size={20} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity style={[styles.socialButton, { borderColor: colors.border }]}>
<TouchableOpacity
style={[styles.socialButton, { borderColor: colors.border }]}
onPress={() => showInfo('Facebook login coming soon!')}
>
<Icon name="facebook" size={20} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity style={[styles.socialButton, { borderColor: colors.border }]}>
<TouchableOpacity
style={[styles.socialButton, { borderColor: colors.border }]}
onPress={() => showInfo('Apple login coming soon!')}
>
<Icon name="apple" size={20} color={colors.text} />
</TouchableOpacity>
</View>
{/* Sign up */}
<View style={styles.signupRow}>
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Dont have an account? </Text>
<TouchableOpacity>
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Don't have an account? </Text>
<TouchableOpacity onPress={() => showInfo('Sign up feature coming soon!')}>
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Sign up</Text>
</TouchableOpacity>
</View>

View File

@ -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
);

View File

@ -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<LeadCardProps> = ({ lead, onPress }) => {
const { colors, fonts, shadows } = useTheme();
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<View style={styles.cardTitleRow}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
{lead.Full_Name}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(lead.Lead_Status, colors) }]}>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
{lead.Lead_Status}
</Text>
</View>
</View>
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
{lead.Company}
</Text>
</View>
<View style={styles.cardContent}>
<View style={styles.infoRow}>
<Icon name="email-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
{lead.Email}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="phone-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
{lead.Phone}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="source-branch" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Source: {lead.Lead_Source}
</Text>
</View>
{lead.Annual_Revenue && (
<View style={styles.infoRow}>
<Icon name="currency-usd" size={16} color={colors.primary} />
<Text style={[styles.infoText, { color: colors.primary, fontFamily: fonts.bold }]}>
${lead.Annual_Revenue.toLocaleString()}
</Text>
</View>
)}
</View>
<View style={styles.cardFooter}>
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Created: {new Date(lead.Created_Time)?.toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
);
};
export const TaskCard: React.FC<TaskCardProps> = ({ task, onPress }) => {
const { colors, fonts, shadows } = useTheme();
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<View style={styles.cardTitleRow}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={2}>
{task.Subject}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(task.Status, colors) }]}>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
{task.Status}
</Text>
</View>
</View>
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
{task.Priority}
</Text>
</View>
<View style={styles.cardContent}>
<Text style={[styles.descriptionText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={2}>
{task.Description}
</Text>
<View style={styles.infoRow}>
<Icon name="flag-outline" size={16} color={getPriorityColor(task.Priority)} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Priority: {task.Priority}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="account-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Assigned: {task.Owner.name}
</Text>
</View>
</View>
<View style={styles.cardFooter}>
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Due: {new Date(task.Due_Date)?.toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
);
};
export const ContactCard: React.FC<ContactCardProps> = ({ contact, onPress }) => {
const { colors, fonts, shadows } = useTheme();
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<View style={styles.cardTitleRow}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
{contact.Full_Name}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor('Active', colors) }]}>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
Active
</Text>
</View>
</View>
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
{contact.Title} at {contact.Account_Name?.name || 'N/A'}
</Text>
</View>
<View style={styles.cardContent}>
<View style={styles.infoRow}>
<Icon name="email-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
{contact.Email}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="phone-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
{contact.Phone}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="source-branch" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Source: {contact.Lead_Source}
</Text>
</View>
</View>
<View style={styles.cardFooter}>
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Last contact: {new Date(contact.Last_Activity_Time)?.toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
);
};
export const DealCard: React.FC<DealCardProps> = ({ deal, onPress }) => {
const { colors, fonts, shadows } = useTheme();
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<View style={styles.cardTitleRow}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
{deal.Deal_Name}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(deal.Stage, colors) }]}>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
{deal.Stage}
</Text>
</View>
</View>
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
{deal.Account_Name?.name || 'N/A'}
</Text>
</View>
<View style={styles.cardContent}>
<View style={styles.infoRow}>
<Icon name="currency-usd" size={16} color={colors.primary} />
<Text style={[styles.infoText, { color: colors.primary, fontFamily: fonts.bold }]}>
${deal.Amount?.toLocaleString()}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="trending-up" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Probability: {deal.Probability}%
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="account-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Contact: {deal.Contact_Name?.name || 'N/A'}
</Text>
</View>
</View>
<View style={styles.cardFooter}>
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Close date: {new Date(deal.Closing_Date)?.toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
);
};
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,
},
});

View File

@ -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 = () => (
<Stack.Navigator>
<Stack.Screen name="CrmDashboard" component={CrmDashboardScreen} options={{headerShown:false}} />
<Stack.Screen name="ZohoCrmData" component={ZohoCrmDataScreen} options={{headerShown:false}} />
</Stack.Navigator>
);

View File

@ -1,119 +1,316 @@
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<AppDispatch>();
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 (
<Container>
<View style={[styles.wrap, { backgroundColor: colors.background }]}>
<ScrollView
style={[styles.wrap, { backgroundColor: colors.background }]}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={handleRefresh} />
}
>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>CRM & Sales</Text>
<TouchableOpacity
style={[styles.dataButton, { backgroundColor: colors.primary }]}
onPress={() => navigation.navigate('ZohoCrmData' as never)}
>
<Icon name="database" size={20} color={colors.surface} />
<Text style={[styles.dataButtonText, { color: colors.surface, fontFamily: fonts.medium }]}>
View Data
</Text>
</TouchableOpacity>
</View>
{/* Error State */}
{hasError && (
<View style={[styles.errorCard, { borderColor: colors.error, backgroundColor: colors.surface }]}>
<Icon name="alert-circle" size={20} color={colors.error} />
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.medium }]}>
Failed to load CRM data. Pull to refresh.
</Text>
</View>
)}
{/* KPIs */}
<View style={styles.kpiGrid}>
<Kpi label="Leads (M)" value={String(mock.leads)} color={colors.text} fonts={fonts} accent="#3AA0FF" />
<Kpi label="Opportunities" value={String(mock.opportunities)} color={colors.text} fonts={fonts} accent="#6366F1" />
<Kpi label="Won Deals" value={String(mock.wonDeals)} color={colors.text} fonts={fonts} accent="#10B981" />
<Kpi label="Conversion" value={`${mock.conversionPct}%`} color={colors.text} fonts={fonts} accent="#F59E0B" />
<Kpi
label="Total Leads"
value={String(crmStats.leads.total)}
color={colors.text}
fonts={fonts}
accent="#3AA0FF"
/>
<Kpi
label="Active Tasks"
value={String(crmStats.tasks.total - crmStats.tasks.completed)}
color={colors.text}
fonts={fonts}
accent="#6366F1"
/>
<Kpi
label="Won Deals"
value={String(crmStats.deals.won)}
color={colors.text}
fonts={fonts}
accent="#10B981"
/>
<Kpi
label="Pipeline Value"
value={formatCurrency(crmStats.deals.pipelineValue)}
color={colors.text}
fonts={fonts}
accent="#F59E0B"
/>
</View>
{/* Leads Trend */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Leads Trend</Text>
<Bars data={mock.leadsTrend} max={Math.max(...mock.leadsTrend)} color="#3AA0FF" />
{/* Additional Stats Row */}
<View style={styles.kpiGrid}>
<Kpi
label="New Leads"
value={String(crmStats.leads.new)}
color={colors.text}
fonts={fonts}
accent="#22C55E"
/>
<Kpi
label="Overdue Tasks"
value={String(crmStats.tasks.overdue)}
color={colors.text}
fonts={fonts}
accent="#EF4444"
/>
<Kpi
label="Active Contacts"
value={String(crmStats.contacts.active)}
color={colors.text}
fonts={fonts}
accent="#8B5CF6"
/>
<Kpi
label="Avg Deal Size"
value={formatCurrency(crmStats.deals.averageDealSize)}
color={colors.text}
fonts={fonts}
accent="#06B6D4"
/>
</View>
{/* Pipeline distribution */}
{/* Lead Status Distribution - Pie Chart */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Pipeline Stages</Text>
<Stacked segments={mock.pipeline} total={mock.pipeline.reduce((a, b) => a + b.value, 0)} />
<View style={styles.legendRow}>
{mock.pipeline.map(s => (
<View key={s.label} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Lead Status Distribution</Text>
<View style={styles.chartContainer}>
<PieChart
data={Object.entries(crmStats.leads.byStatus).map(([status, count]) => ({
label: status,
value: count,
color: getStatusColor(status)
}))}
colors={colors}
fonts={fonts}
size={140}
/>
{/* Legend */}
<View style={styles.pieLegend}>
{Object.entries(crmStats.leads.byStatus).map(([status, count]) => (
<View key={status} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getStatusColor(status) }]} />
<Text style={[styles.legendText, { color: colors.textLight, fontFamily: fonts.regular }]}>
{status} ({count})
</Text>
</View>
))}
</View>
</View>
</View>
{/* Lead Sources */}
{/* Deal Pipeline Stages - Compact View */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Deal Pipeline Stages</Text>
<CompactPipeline
data={dashboardData.pipeline.map(stage => ({
label: stage.label,
value: stage.value,
color: stage.color
}))}
colors={colors}
fonts={fonts}
/>
</View>
{/* Leads by Source - Donut Chart */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Leads by Source</Text>
<Stacked segments={mock.sourceDist} total={mock.sourceDist.reduce((a, b) => a + b.value, 0)} />
<View style={styles.legendRow}>
{mock.sourceDist.map(s => (
<View key={s.label} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
<View style={styles.chartContainer}>
<DonutChart
data={dashboardData.sourceDist.map(source => ({
label: source.label,
value: source.value,
color: source.color
}))}
colors={colors}
fonts={fonts}
size={140}
/>
{/* Legend */}
<View style={styles.pieLegend}>
{dashboardData.sourceDist.map(source => (
<View key={source.label} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: source.color }]} />
<Text style={[styles.legendText, { color: colors.textLight, fontFamily: fonts.regular }]}>
{source.label} ({source.value})
</Text>
</View>
))}
</View>
</View>
</View>
{/* Tasks by Priority - Stacked Bar Chart */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Tasks by Priority</Text>
<View style={styles.chartContainer}>
<StackedBarChart
data={Object.entries(crmStats.tasks.byPriority).map(([priority, count]) => ({
label: priority,
value: count,
color: getPriorityColor(priority)
}))}
colors={colors}
fonts={fonts}
height={120}
/>
{/* Legend */}
<View style={styles.barLegend}>
{Object.entries(crmStats.tasks.byPriority).map(([priority, count]) => (
<View key={priority} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getPriorityColor(priority) }]} />
<Text style={[styles.legendText, { color: colors.textLight, fontFamily: fonts.regular }]}>
{priority} ({count})
</Text>
</View>
))}
</View>
</View>
</View>
{/* Lists */}
<View style={styles.row}>
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Top Opportunities</Text>
{mock.topOpps.map(o => (
{dashboardData.topOpps.length > 0 ? dashboardData.topOpps.map(o => (
<View key={o.name} style={styles.listRow}>
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{o.name}</Text>
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>${o.value.toLocaleString()}</Text>
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
{o.name}
</Text>
<Text style={[styles.listSecondary, { color: colors.primary, fontFamily: fonts.bold }]}>
{formatCurrency(o.value)}
</Text>
</View>
))}
)) : (
<Text style={[styles.emptyText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No opportunities found
</Text>
)}
</View>
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Recent Activity</Text>
{mock.recent.map(r => (
{dashboardData.recent.length > 0 ? dashboardData.recent.map(r => (
<View key={`${r.who}-${r.when}`} style={styles.listRow}>
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{r.who}</Text>
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.regular }]}>{r.what} · {r.when}</Text>
</View>
))}
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
{r.who}
</Text>
<Text style={[styles.listSecondary, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
{r.what} · {r.when}
</Text>
</View>
)) : (
<Text style={[styles.emptyText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No recent activity
</Text>
)}
</View>
</View>
</ScrollView>
</Container>
);
};
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

View File

@ -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<AppDispatch>();
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 <LoadingSpinner />;
}
if (hasError && !crmData.leads.length) {
return <ErrorState onRetry={handleRetry} />;
}
const renderTabContent = () => {
switch (selectedTab) {
case 'leads':
return (
<FlatList
data={crmData.leads}
renderItem={({ item }) => (
<LeadCard
lead={item}
onPress={() => handleCardPress(item, 'Lead')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
case 'tasks':
return (
<FlatList
data={crmData.tasks}
renderItem={({ item }) => (
<TaskCard
task={item}
onPress={() => handleCardPress(item, 'Task')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
case 'contacts':
return (
<FlatList
data={crmData.contacts}
renderItem={({ item }) => (
<ContactCard
contact={item}
onPress={() => handleCardPress(item, 'Contact')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
case 'deals':
return (
<FlatList
data={crmData.deals}
renderItem={({ item }) => (
<DealCard
deal={item}
onPress={() => handleCardPress(item, 'Deal')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
default:
return null;
}
};
return (
<Container>
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
>
{/* Header */}
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>
Zoho CRM Data
</Text>
<TouchableOpacity onPress={handleRefresh} disabled={refreshing}>
<Icon name="refresh" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
{/* Tabs */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.tabsContainer}>
<View style={styles.tabs}>
{tabs.map((tab) => (
<TouchableOpacity
key={tab.key}
style={[
styles.tab,
selectedTab === tab.key && { backgroundColor: colors.primary },
]}
onPress={() => setSelectedTab(tab.key)}
activeOpacity={0.8}
>
<Icon
name={tab.icon}
size={20}
color={selectedTab === tab.key ? colors.surface : colors.textLight}
/>
<Text
style={[
styles.tabText,
{
color: selectedTab === tab.key ? colors.surface : colors.textLight,
fontFamily: fonts.medium,
},
]}
>
{tab.label}
</Text>
<View
style={[
styles.countBadge,
{ backgroundColor: selectedTab === tab.key ? colors.surface : colors.primary },
]}
>
<Text
style={[
styles.countText,
{
color: selectedTab === tab.key ? colors.primary : colors.surface,
fontFamily: fonts.bold,
},
]}
>
{tab.count}
</Text>
</View>
</TouchableOpacity>
))}
</View>
</ScrollView>
{/* Content */}
<View style={styles.content}>
{renderTabContent()}
</View>
</ScrollView>
</Container>
);
};
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;

View File

@ -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: <T = any>(
resource: CrmResourceType,
params?: CrmSearchParams
) => {
const queryParams = {
provider: 'zoho',
service: 'crm',
resource,
page: 1,
limit: 20,
...params
};
return http.get<CrmApiResponse<CrmPaginatedResponse<T>>>(CRM_BASE_URL, queryParams);
},
// Specific resource methods for type safety
getLeads: (params?: CrmSearchParams) =>
crmAPI.getCrmData<CrmLead>('leads', params),
getTasks: (params?: CrmSearchParams) =>
crmAPI.getCrmData<CrmTask>('tasks', params),
getContacts: (params?: CrmSearchParams) =>
crmAPI.getCrmData<CrmContact>('contacts', params),
getDeals: (params?: CrmSearchParams) =>
crmAPI.getCrmData<CrmDeal>('deals', params),
};

View File

@ -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';

View File

@ -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<number>) => {
state.pagination.leads.page = action.payload;
},
setTasksPage: (state, action: PayloadAction<number>) => {
state.pagination.tasks.page = action.payload;
},
setContactsPage: (state, action: PayloadAction<number>) => {
state.pagination.contacts.page = action.payload;
},
setDealsPage: (state, action: PayloadAction<number>) => {
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;

View File

@ -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<string, number>);
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<string, number>);
// 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<string, number>);
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<string, number>);
// 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<string, number>);
// 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<string, number>);
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<LeadSource, number>,
byStatus: leadsByStatus as Record<string, number>,
},
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<TaskStatus, number>,
byPriority: tasksByPriority as Record<string, number>,
},
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<DealStage, number>,
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)
);

View File

@ -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<T> {
status: 'success' | 'error';
message: string;
data?: T;
error?: string;
timestamp: string;
}
export interface CrmPaginatedResponse<T> {
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<LeadSource, number>;
byStatus: Record<LeadStatus, number>;
};
tasks: {
total: number;
completed: number;
overdue: number;
byStatus: Record<TaskStatus, number>;
byPriority: Record<TaskPriority, number>;
};
contacts: {
total: number;
active: number;
inactive: number;
byCompany: Record<string, number>;
};
deals: {
total: number;
won: number;
lost: number;
totalValue: number;
averageDealSize: number;
byStage: Record<DealStage, number>;
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;
}

View File

@ -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',

View File

@ -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 = () => {
</View>
{/* Name */}
<Text style={[styles.displayName, { color: colors.text, fontFamily: fonts.bold }]}>{name || 'Sana Afzal'}</Text>
<Text style={[styles.displayName, { color: colors.text, fontFamily: fonts.bold }]}>
{displayName}
</Text>
{/* Email pill */}
<View style={[styles.emailPill, { backgroundColor: '#DFE9FF' }]}>
<Text style={{ color: colors.text, fontFamily: fonts.regular, fontSize: 12 }}>{email || 'sanaaafzal291@gmail.com'}</Text>
<Text style={{ color: colors.text, fontFamily: fonts.regular, fontSize: 12 }}>
{email}
</Text>
</View>
{/* Role badge */}
{role && (
<View style={[styles.roleBadge, { backgroundColor: colors.primary }]}>
<Text style={{ color: colors.surface, fontFamily: fonts.medium, fontSize: 12 }}>
{role.toUpperCase()}
</Text>
</View>
)}
</View>
{/* 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,

View File

@ -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<AuthUser>) => {
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<boolean>) => {
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;

View File

@ -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')
);

View File

@ -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;

View File

@ -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<CompactPipelineProps> = ({
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 (
<View style={styles.container}>
<Text style={[styles.noDataText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No Pipeline Data
</Text>
</View>
);
}
const containerWidth = 320;
const barHeight = 24;
const labelWidth = 80;
const valueWidth = 40;
const barWidth = containerWidth - labelWidth - valueWidth - 60; // 60 for padding
return (
<View style={styles.container}>
<Svg width={containerWidth} height={data.length * 35}>
{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 (
<React.Fragment key={index}>
{/* Stage Label */}
<SvgText
x={10}
y={y + 16}
fontSize="12"
fontFamily={fonts.medium}
fill={colors.text}
textAnchor="start"
alignmentBaseline="middle"
>
{stage.label.length > 10 ? stage.label.substring(0, 10) + '...' : stage.label}
</SvgText>
{/* Progress Bar Background */}
<Rect
x={labelWidth + 10}
y={y}
width={barWidth}
height={barHeight}
fill={colors.border}
rx={4}
ry={4}
/>
{/* Progress Bar Fill */}
<Rect
x={labelWidth + 10}
y={y}
width={barFillWidth}
height={barHeight}
fill={stage.color}
rx={4}
ry={4}
/>
{/* Value */}
<SvgText
x={labelWidth + barWidth + 20}
y={y + 16}
fontSize="12"
fontFamily={fonts.bold}
fill={colors.text}
textAnchor="middle"
alignmentBaseline="middle"
>
{stage.value}
</SvgText>
{/* Percentage */}
<SvgText
x={labelWidth + barWidth + 60}
y={y + 16}
fontSize="10"
fontFamily={fonts.regular}
fill={colors.textLight}
textAnchor="middle"
alignmentBaseline="middle"
>
{percentage.toFixed(0)}%
</SvgText>
</React.Fragment>
);
})}
</Svg>
{/* Summary Row */}
<View style={styles.summaryRow}>
<View style={[styles.summaryItem, { borderColor: colors.border }]}>
<Text style={[styles.summaryLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>
Total Stages
</Text>
<Text style={[styles.summaryValue, { color: colors.text, fontFamily: fonts.bold }]}>
{data.length}
</Text>
</View>
<View style={[styles.summaryItem, { borderColor: colors.border }]}>
<Text style={[styles.summaryLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>
Total Deals
</Text>
<Text style={[styles.summaryValue, { color: colors.text, fontFamily: fonts.bold }]}>
{total}
</Text>
</View>
<View style={[styles.summaryItem, { borderColor: colors.border }]}>
<Text style={[styles.summaryLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>
Avg/Stage
</Text>
<Text style={[styles.summaryValue, { color: colors.text, fontFamily: fonts.bold }]}>
{data.length > 0 ? Math.round(total / data.length) : 0}
</Text>
</View>
</View>
</View>
);
};
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;

View File

@ -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<DonutChartProps> = ({
data,
size = 140,
colors,
fonts
}) => {
const total = data.reduce((sum, item) => sum + item.value, 0);
if (total === 0) {
return (
<View style={styles.container}>
<View style={[styles.chartContainer, { width: size, height: size }]}>
<Text style={[styles.noDataText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No Data
</Text>
</View>
</View>
);
}
let currentAngle = 0;
const radius = (size - 40) / 2;
const innerRadius = radius * 0.6;
const centerX = size / 2;
const centerY = size / 2;
return (
<View style={styles.container}>
<View style={styles.chartContainer}>
<Svg width={size} height={size}>
{/* Background circle */}
<Circle
cx={centerX}
cy={centerY}
r={radius}
fill="transparent"
stroke={colors.border}
strokeWidth={16}
/>
{/* Inner circle (donut hole) */}
<Circle
cx={centerX}
cy={centerY}
r={innerRadius}
fill={colors.surface}
/>
{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 (
<G key={index}>
<Circle
cx={centerX}
cy={centerY}
r={radius}
fill="transparent"
stroke={item.color}
strokeWidth={16}
strokeDasharray={`${(percentage / 100) * (2 * Math.PI * radius)} ${2 * Math.PI * radius}`}
strokeDashoffset={-(startAngle / 360) * (2 * Math.PI * radius)}
transform={`rotate(-90 ${centerX} ${centerY})`}
/>
</G>
);
})}
</Svg>
<View style={styles.centerText}>
<Text style={[styles.totalText, { color: colors.text, fontFamily: fonts.bold }]}>
{total}
</Text>
<Text style={[styles.totalLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>
Sources
</Text>
</View>
</View>
</View>
);
};
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;

View File

@ -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<FunnelChartProps> = ({
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 (
<View style={styles.container}>
<Text style={[styles.noDataText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No Pipeline Data
</Text>
</View>
);
}
// 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 (
<View style={styles.container}>
<Svg width={funnelWidth} height={funnelHeight}>
{data.map((item, index) => {
const percentage = (item.value / total) * 100;
const path = createFunnelPath(index, item.value);
const textY = index * segmentHeight + segmentHeight / 2;
return (
<React.Fragment key={index}>
<Path
d={path}
fill={item.color}
fillOpacity={0.8}
/>
{/* Label text */}
<SvgText
x={centerX}
y={textY - 8}
fontSize="11"
fontFamily={fonts.medium}
fill={colors.textLight}
textAnchor="middle"
alignmentBaseline="middle"
>
{item.label.length > 12 ? item.label.substring(0, 12) + '...' : item.label}
</SvgText>
{/* Value and percentage text */}
<SvgText
x={centerX}
y={textY + 8}
fontSize="9"
fontFamily={fonts.regular}
fill={colors.textLight}
textAnchor="middle"
alignmentBaseline="middle"
>
{item.value} ({percentage.toFixed(0)}%)
</SvgText>
</React.Fragment>
);
})}
</Svg>
</View>
);
};
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;

View File

@ -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<PieChartProps> = ({
data,
size = 120,
colors,
fonts
}) => {
const total = data.reduce((sum, item) => sum + item.value, 0);
if (total === 0) {
return (
<View style={styles.container}>
<View style={[styles.chartContainer, { width: size, height: size }]}>
<Text style={[styles.noDataText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No Data
</Text>
</View>
</View>
);
}
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 (
<View style={styles.container}>
<View style={styles.chartContainer}>
<Svg width={size} height={size}>
{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 (
<G key={index}>
<Circle
cx={centerX}
cy={centerY}
r={radius}
fill="transparent"
stroke={item.color}
strokeWidth={20}
strokeDasharray={`${(percentage / 100) * (2 * Math.PI * radius)} ${2 * Math.PI * radius}`}
strokeDashoffset={-(startAngle / 360) * (2 * Math.PI * radius)}
transform={`rotate(-90 ${centerX} ${centerY})`}
/>
</G>
);
})}
</Svg>
<View style={styles.centerText}>
<Text style={[styles.totalText, { color: colors.text, fontFamily: fonts.bold }]}>
{total}
</Text>
<Text style={[styles.totalLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>
Total
</Text>
</View>
</View>
</View>
);
};
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;

View File

@ -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<PipelineCardsProps> = ({
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 (
<View style={styles.container}>
<Text style={[styles.noDataText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No Pipeline Data
</Text>
</View>
);
}
return (
<View style={styles.container}>
{data.map((stage, index) => {
const percentage = total > 0 ? (stage.value / total) * 100 : 0;
const progressWidth = maxValue > 0 ? (stage.value / maxValue) * 100 : 0;
return (
<View key={index} style={[styles.stageCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{/* Stage Header */}
<View style={styles.stageHeader}>
<View style={styles.stageInfo}>
<View style={[styles.stageIndicator, { backgroundColor: stage.color }]} />
<Text style={[styles.stageLabel, { color: colors.text, fontFamily: fonts.medium }]}>
{stage.label}
</Text>
</View>
<View style={styles.stageStats}>
<Text style={[styles.stageValue, { color: colors.text, fontFamily: fonts.bold }]}>
{stage.value}
</Text>
<Text style={[styles.stagePercentage, { color: colors.textLight, fontFamily: fonts.regular }]}>
{percentage.toFixed(1)}%
</Text>
</View>
</View>
{/* Progress Bar */}
<View style={[styles.progressContainer, { backgroundColor: colors.border }]}>
<View
style={[
styles.progressBar,
{
width: `${progressWidth}%`,
backgroundColor: stage.color
}
]}
/>
</View>
{/* Stage Details */}
<View style={styles.stageDetails}>
<View style={styles.detailItem}>
<Text style={[styles.detailLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>
Deals
</Text>
<Text style={[styles.detailValue, { color: colors.text, fontFamily: fonts.bold }]}>
{stage.value}
</Text>
</View>
<View style={styles.detailItem}>
<Text style={[styles.detailLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>
Pipeline %
</Text>
<Text style={[styles.detailValue, { color: colors.text, fontFamily: fonts.bold }]}>
{percentage.toFixed(1)}%
</Text>
</View>
<View style={styles.detailItem}>
<Text style={[styles.detailLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>
Rank
</Text>
<Text style={[styles.detailValue, { color: stage.color, fontFamily: fonts.bold }]}>
#{index + 1}
</Text>
</View>
</View>
</View>
);
})}
</View>
);
};
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;

View File

@ -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<PipelineFlowProps> = ({
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 (
<View style={styles.container}>
<Text style={[styles.noDataText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No Pipeline Data
</Text>
</View>
);
}
const containerWidth = 300;
const stageHeight = 50;
const circleRadius = 12;
const lineWidth = 40;
const startX = 30;
const textOffset = 60;
return (
<View style={styles.container}>
<Svg width={containerWidth} height={data.length * stageHeight}>
{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 (
<React.Fragment key={index}>
{/* Stage Circle */}
<Circle
cx={x}
cy={y}
r={circleRadius}
fill={stage.color}
stroke={colors.surface}
strokeWidth={2}
/>
{/* Stage Value inside circle */}
<SvgText
x={x}
y={y + 4}
fontSize="10"
fontFamily={fonts.bold}
fill={colors.surface}
textAnchor="middle"
alignmentBaseline="middle"
>
{stage.value}
</SvgText>
{/* Progress Bar */}
<Rect
x={textOffset}
y={y - 8}
width={barWidth}
height={16}
fill={stage.color}
fillOpacity={0.3}
rx={8}
ry={8}
/>
{/* Progress Fill */}
<Rect
x={textOffset}
y={y - 8}
width={barWidth}
height={16}
fill={stage.color}
rx={8}
ry={8}
/>
{/* Stage Label */}
<SvgText
x={textOffset + 10}
y={y + 4}
fontSize="11"
fontFamily={fonts.medium}
fill={colors.text}
textAnchor="start"
alignmentBaseline="middle"
>
{stage.label}
</SvgText>
{/* Percentage */}
<SvgText
x={textOffset + barWidth - 10}
y={y + 4}
fontSize="9"
fontFamily={fonts.regular}
fill={colors.textLight}
textAnchor="end"
alignmentBaseline="middle"
>
{((stage.value / total) * 100).toFixed(0)}%
</SvgText>
{/* Connecting Line (except for last item) */}
{index < data.length - 1 && (
<Line
x1={x + circleRadius}
y1={y}
x2={x + circleRadius + lineWidth}
y2={y}
stroke={colors.border}
strokeWidth={2}
strokeDasharray="4,4"
/>
)}
</React.Fragment>
);
})}
</Svg>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'flex-start',
justifyContent: 'center',
paddingVertical: 10,
paddingHorizontal: 5,
},
noDataText: {
fontSize: 14,
textAlign: 'center',
fontStyle: 'italic',
},
});
export default PipelineFlow;

View File

@ -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<StackedBarChartProps> = ({
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 (
<View style={styles.container}>
<Text style={[styles.noDataText, { color: colors.textLight, fontFamily: fonts.regular }]}>
No Task Data
</Text>
</View>
);
}
const chartWidth = 250;
const barWidth = chartWidth / data.length - 10;
const maxBarHeight = height - 40;
return (
<View style={styles.container}>
<Svg width={chartWidth} height={height}>
{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 (
<View key={index}>
<Rect
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={item.color}
rx={4}
ry={4}
/>
<SvgText
x={x + barWidth / 2}
y={height - 5}
fontSize="9"
fontFamily={fonts.medium}
fill={colors.textLight}
textAnchor="middle"
alignmentBaseline="baseline"
>
{item.label}
</SvgText>
<SvgText
x={x + barWidth / 2}
y={y - 8}
fontSize="8"
fontFamily={fonts.bold}
fill={colors.textLight}
textAnchor="middle"
alignmentBaseline="baseline"
>
{item.value}
</SvgText>
</View>
);
})}
</Svg>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
},
noDataText: {
fontSize: 14,
textAlign: 'center',
fontStyle: 'italic',
},
});
export default StackedBarChart;

View File

@ -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';

View File

@ -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;

View File

@ -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'],
};