zoho crm data mapped and need to integrate zoho projects
This commit is contained in:
parent
0df78919f2
commit
438654be98
2
App.tsx
2
App.tsx
@ -18,6 +18,7 @@ import AuthNavigator from '@/modules/auth/navigation/AuthNavigator';
|
||||
import type { RootState } from '@/store/store';
|
||||
import IntegrationsNavigator from '@/modules/integrations/navigation/IntegrationsNavigator';
|
||||
import { StatusBar } from 'react-native';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
function AppContent(): React.JSX.Element {
|
||||
const isAuthenticated = useSelector((s: RootState) => Boolean(s.auth.isAuthenticated));
|
||||
@ -63,6 +64,7 @@ function AppContent(): React.JSX.Element {
|
||||
</NavigationContainer>
|
||||
)
|
||||
)}
|
||||
<Toast bottomOffset={20} position='bottom'/>
|
||||
</PersistGate>
|
||||
</ThemeProvider>
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
9
android/app/src/main/res/xml/network_security_config.xml
Normal file
9
android/app/src/main/res/xml/network_security_config.xml
Normal 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
15
src/modules/auth/index.ts
Normal 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';
|
||||
@ -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 }]}>Don’t 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>
|
||||
|
||||
35
src/modules/auth/store/selectors.ts
Normal file
35
src/modules/auth/store/selectors.ts
Normal 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
|
||||
);
|
||||
353
src/modules/crm/components/CrmDataCards.tsx
Normal file
353
src/modules/crm/components/CrmDataCards.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
316
src/modules/crm/screens/ZohoCrmDataScreen.tsx
Normal file
316
src/modules/crm/screens/ZohoCrmDataScreen.tsx
Normal 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;
|
||||
49
src/modules/crm/services/crmAPI.ts
Normal file
49
src/modules/crm/services/crmAPI.ts
Normal 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),
|
||||
};
|
||||
|
||||
21
src/modules/crm/services/index.ts
Normal file
21
src/modules/crm/services/index.ts
Normal 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';
|
||||
|
||||
309
src/modules/crm/store/crmSlice.ts
Normal file
309
src/modules/crm/store/crmSlice.ts
Normal 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;
|
||||
289
src/modules/crm/store/selectors.ts
Normal file
289
src/modules/crm/store/selectors.ts
Normal 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)
|
||||
);
|
||||
|
||||
321
src/modules/crm/types/CrmTypes.ts
Normal file
321
src/modules/crm/types/CrmTypes.ts
Normal 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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
49
src/modules/profile/store/selectors.ts
Normal file
49
src/modules/profile/store/selectors.ts
Normal 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')
|
||||
);
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
181
src/shared/components/charts/CompactPipeline.tsx
Normal file
181
src/shared/components/charts/CompactPipeline.tsx
Normal 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;
|
||||
133
src/shared/components/charts/DonutChart.tsx
Normal file
133
src/shared/components/charts/DonutChart.tsx
Normal 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;
|
||||
121
src/shared/components/charts/FunnelChart.tsx
Normal file
121
src/shared/components/charts/FunnelChart.tsx
Normal 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;
|
||||
131
src/shared/components/charts/PieChart.tsx
Normal file
131
src/shared/components/charts/PieChart.tsx
Normal 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;
|
||||
189
src/shared/components/charts/PipelineCards.tsx
Normal file
189
src/shared/components/charts/PipelineCards.tsx
Normal 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;
|
||||
159
src/shared/components/charts/PipelineFlow.tsx
Normal file
159
src/shared/components/charts/PipelineFlow.tsx
Normal 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;
|
||||
104
src/shared/components/charts/StackedBarChart.tsx
Normal file
104
src/shared/components/charts/StackedBarChart.tsx
Normal 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;
|
||||
7
src/shared/components/charts/index.ts
Normal file
7
src/shared/components/charts/index.ts
Normal 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';
|
||||
@ -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;
|
||||
|
||||
@ -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'],
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user