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 type { RootState } from '@/store/store';
|
||||||
import IntegrationsNavigator from '@/modules/integrations/navigation/IntegrationsNavigator';
|
import IntegrationsNavigator from '@/modules/integrations/navigation/IntegrationsNavigator';
|
||||||
import { StatusBar } from 'react-native';
|
import { StatusBar } from 'react-native';
|
||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
function AppContent(): React.JSX.Element {
|
function AppContent(): React.JSX.Element {
|
||||||
const isAuthenticated = useSelector((s: RootState) => Boolean(s.auth.isAuthenticated));
|
const isAuthenticated = useSelector((s: RootState) => Boolean(s.auth.isAuthenticated));
|
||||||
@ -63,6 +64,7 @@ function AppContent(): React.JSX.Element {
|
|||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
<Toast bottomOffset={20} position='bottom'/>
|
||||||
</PersistGate>
|
</PersistGate>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
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 type { RootState, AppDispatch } from '@/store/store';
|
||||||
import { useTheme } from '@/shared/styles/useTheme';
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
import { validateLoginForm } from '@/shared/utils/validation';
|
import { validateLoginForm } from '@/shared/utils/validation';
|
||||||
|
import { showError, showSuccess, showWarning, showInfo } from '@/shared/utils/Toast';
|
||||||
|
|
||||||
const LoginScreen: React.FC = () => {
|
const LoginScreen: React.FC = () => {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
@ -64,6 +65,14 @@ const LoginScreen: React.FC = () => {
|
|||||||
|
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
setValidationErrors(validation.errors);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,15 +82,17 @@ const LoginScreen: React.FC = () => {
|
|||||||
|
|
||||||
// Check if login was successful
|
// Check if login was successful
|
||||||
if (login.fulfilled.match(result)) {
|
if (login.fulfilled.match(result)) {
|
||||||
// Login successful - navigation will be handled by the app navigator
|
// Login successful - show success toast
|
||||||
// based on isAuthenticated state
|
showSuccess('Login successful! Welcome back!');
|
||||||
Alert.alert('Success', 'Login successful!', [
|
// Navigation will be handled by the app navigator based on isAuthenticated state
|
||||||
{ text: 'OK', style: 'default' }
|
} else if (login.rejected.match(result)) {
|
||||||
]);
|
// Login failed - show error toast
|
||||||
|
showError(result.payload as string || 'Login failed. Please try again.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error handling is done in the slice
|
// Error handling is done in the slice
|
||||||
console.error('Login error:', err);
|
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 */}
|
{/* Row: Remember me + Forgot password */}
|
||||||
<View style={styles.rowBetween}>
|
<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} />
|
<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>
|
<Text style={[styles.rememberText, { color: colors.text, fontFamily: fonts.regular }]}>Remember me</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<TouchableOpacity>
|
<TouchableOpacity onPress={() => showInfo('Forgot password feature coming soon!')}>
|
||||||
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Forgot Password ?</Text>
|
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Forgot Password ?</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@ -263,21 +280,30 @@ const LoginScreen: React.FC = () => {
|
|||||||
|
|
||||||
{/* Social buttons */}
|
{/* Social buttons */}
|
||||||
<View style={styles.socialRow}>
|
<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} />
|
<Icon name="google" size={20} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</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} />
|
<Icon name="facebook" size={20} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</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} />
|
<Icon name="apple" size={20} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Sign up */}
|
{/* Sign up */}
|
||||||
<View style={styles.signupRow}>
|
<View style={styles.signupRow}>
|
||||||
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Don’t have an account? </Text>
|
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Don't have an account? </Text>
|
||||||
<TouchableOpacity>
|
<TouchableOpacity onPress={() => showInfo('Sign up feature coming soon!')}>
|
||||||
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Sign up</Text>
|
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Sign up</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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 React from 'react';
|
||||||
import { createStackNavigator } from '@react-navigation/stack';
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
import CrmDashboardScreen from '@/modules/crm/screens/CrmDashboardScreen';
|
import CrmDashboardScreen from '@/modules/crm/screens/CrmDashboardScreen';
|
||||||
|
import ZohoCrmDataScreen from '@/modules/crm/screens/ZohoCrmDataScreen';
|
||||||
|
|
||||||
const Stack = createStackNavigator();
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
const CrmNavigator = () => (
|
const CrmNavigator = () => (
|
||||||
<Stack.Navigator>
|
<Stack.Navigator>
|
||||||
<Stack.Screen name="CrmDashboard" component={CrmDashboardScreen} options={{headerShown:false}} />
|
<Stack.Screen name="CrmDashboard" component={CrmDashboardScreen} options={{headerShown:false}} />
|
||||||
|
<Stack.Screen name="ZohoCrmData" component={ZohoCrmDataScreen} options={{headerShown:false}} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,87 +1,238 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
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 { Container } from '@/shared/components/ui';
|
||||||
|
import { PieChart, DonutChart, FunnelChart, StackedBarChart, PipelineFlow, PipelineCards, CompactPipeline } from '@/shared/components/charts';
|
||||||
import { useTheme } from '@/shared/styles/useTheme';
|
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 CrmDashboardScreen: React.FC = () => {
|
||||||
const { colors, fonts } = useTheme();
|
const { colors, fonts } = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
|
||||||
const mock = useMemo(() => {
|
// Redux selectors
|
||||||
const leads = 420;
|
const dashboardData = useSelector(selectDashboardData);
|
||||||
const opportunities = 76;
|
const crmStats = useSelector(selectCrmStats);
|
||||||
const wonDeals = 28;
|
const isLoading = useSelector(selectIsAnyLoading);
|
||||||
const conversionPct = 37;
|
const hasError = useSelector(selectHasAnyError);
|
||||||
const leadsTrend = [60, 62, 68, 70, 76, 84];
|
|
||||||
const pipeline = [
|
// Fetch data on component mount
|
||||||
{ label: 'Prospecting', value: 28, color: '#3AA0FF' },
|
useEffect(() => {
|
||||||
{ label: 'Qualified', value: 18, color: '#10B981' },
|
dispatch(fetchAllCrmData());
|
||||||
{ label: 'Proposal', value: 12, color: '#F59E0B' },
|
}, [dispatch]);
|
||||||
{ label: 'Negotiation', value: 9, color: '#6366F1' },
|
|
||||||
{ label: 'Closed Won', value: 6, color: '#22C55E' },
|
// Handle refresh
|
||||||
{ label: 'Closed Lost', value: 7, color: '#EF4444' },
|
const handleRefresh = () => {
|
||||||
];
|
dispatch(fetchAllCrmData());
|
||||||
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 };
|
|
||||||
}, []);
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<View style={[styles.wrap, { backgroundColor: colors.background }]}>
|
<ScrollView
|
||||||
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>CRM & Sales</Text>
|
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 */}
|
{/* KPIs */}
|
||||||
<View style={styles.kpiGrid}>
|
<View style={styles.kpiGrid}>
|
||||||
<Kpi label="Leads (M)" value={String(mock.leads)} color={colors.text} fonts={fonts} accent="#3AA0FF" />
|
<Kpi
|
||||||
<Kpi label="Opportunities" value={String(mock.opportunities)} color={colors.text} fonts={fonts} accent="#6366F1" />
|
label="Total Leads"
|
||||||
<Kpi label="Won Deals" value={String(mock.wonDeals)} color={colors.text} fonts={fonts} accent="#10B981" />
|
value={String(crmStats.leads.total)}
|
||||||
<Kpi label="Conversion" value={`${mock.conversionPct}%`} color={colors.text} fonts={fonts} accent="#F59E0B" />
|
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>
|
</View>
|
||||||
|
|
||||||
{/* Leads Trend */}
|
{/* Additional Stats Row */}
|
||||||
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
<View style={styles.kpiGrid}>
|
||||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Leads Trend</Text>
|
<Kpi
|
||||||
<Bars data={mock.leadsTrend} max={Math.max(...mock.leadsTrend)} color="#3AA0FF" />
|
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>
|
</View>
|
||||||
|
|
||||||
{/* Pipeline distribution */}
|
{/* Lead Status Distribution - Pie Chart */}
|
||||||
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Pipeline Stages</Text>
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Lead Status Distribution</Text>
|
||||||
<Stacked segments={mock.pipeline} total={mock.pipeline.reduce((a, b) => a + b.value, 0)} />
|
|
||||||
<View style={styles.legendRow}>
|
<View style={styles.chartContainer}>
|
||||||
{mock.pipeline.map(s => (
|
<PieChart
|
||||||
<View key={s.label} style={styles.legendItem}>
|
data={Object.entries(crmStats.leads.byStatus).map(([status, count]) => ({
|
||||||
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
label: status,
|
||||||
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
value: count,
|
||||||
</View>
|
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>
|
||||||
</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 }]}>
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Leads by Source</Text>
|
<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}>
|
<View style={styles.chartContainer}>
|
||||||
{mock.sourceDist.map(s => (
|
<DonutChart
|
||||||
<View key={s.label} style={styles.legendItem}>
|
data={dashboardData.sourceDist.map(source => ({
|
||||||
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
label: source.label,
|
||||||
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
value: source.value,
|
||||||
</View>
|
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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -89,31 +240,77 @@ const CrmDashboardScreen: React.FC = () => {
|
|||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
<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>
|
<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}>
|
<View key={o.name} style={styles.listRow}>
|
||||||
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{o.name}</Text>
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>${o.value.toLocaleString()}</Text>
|
{o.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.primary, fontFamily: fonts.bold }]}>
|
||||||
|
{formatCurrency(o.value)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
)) : (
|
||||||
|
<Text style={[styles.emptyText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
No opportunities found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
<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>
|
<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}>
|
<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.listPrimary, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.regular }]}>{r.what} · {r.when}</Text>
|
{r.who}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||||
|
{r.what} · {r.when}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
)) : (
|
||||||
|
<Text style={[styles.emptyText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||||
|
No recent activity
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: { flex: 1, padding: 16 },
|
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' },
|
kpiGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' },
|
||||||
kpiCard: { width: '48%', borderRadius: 12, borderWidth: 1, borderColor: '#E2E8F0', backgroundColor: '#FFFFFF', padding: 12, marginBottom: 12 },
|
kpiCard: { width: '48%', borderRadius: 12, borderWidth: 1, borderColor: '#E2E8F0', backgroundColor: '#FFFFFF', padding: 12, marginBottom: 12 },
|
||||||
kpiLabel: { fontSize: 12, opacity: 0.8 },
|
kpiLabel: { fontSize: 12, opacity: 0.8 },
|
||||||
@ -125,13 +322,156 @@ const styles = StyleSheet.create({
|
|||||||
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
|
||||||
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 12, marginTop: 6 },
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 12, marginTop: 6 },
|
||||||
legendDot: { width: 8, height: 8, borderRadius: 4, marginRight: 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 },
|
row: { marginTop: 12 },
|
||||||
col: { flex: 1, marginRight: 8 },
|
col: { flex: 1, marginRight: 8 },
|
||||||
listRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB' },
|
listRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB' },
|
||||||
listPrimary: { fontSize: 14, flex: 1, paddingRight: 8 },
|
listPrimary: { fontSize: 14, flex: 1, paddingRight: 8 },
|
||||||
listSecondary: { fontSize: 12 },
|
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;
|
export default CrmDashboardScreen;
|
||||||
|
|
||||||
// UI helpers
|
// 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.
|
// across Projects, CRM, Books, and People. Tailor scopes to your app's needs and compliance.
|
||||||
const getScopeForService = (_serviceKey?: ServiceKey): string => {
|
const getScopeForService = (_serviceKey?: ServiceKey): string => {
|
||||||
return [
|
return [
|
||||||
|
//zoho portals
|
||||||
|
'ZohoProjects.portals.READ',
|
||||||
// Zoho Projects
|
// Zoho Projects
|
||||||
'ZohoProjects.projects.READ',
|
'ZohoProjects.projects.READ',
|
||||||
'ZohoProjects.tasks.READ',
|
'ZohoProjects.tasklists.READ',
|
||||||
'ZohoProjects.timesheets.READ',
|
'ZohoProjects.timesheets.READ',
|
||||||
// Zoho CRM (adjust modules per your needs)
|
// Zoho CRM (adjust modules per your needs)
|
||||||
'ZohoCRM.users.READ',
|
'ZohoCRM.users.READ',
|
||||||
|
|||||||
@ -5,21 +5,28 @@ import { Container, ConfirmModal } from '@/shared/components/ui';
|
|||||||
import type { RootState } from '@/store/store';
|
import type { RootState } from '@/store/store';
|
||||||
import { useTheme } from '@/shared/styles/useTheme';
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
import { logout } from '@/modules/auth/store/authSlice';
|
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 Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice';
|
import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice';
|
||||||
|
|
||||||
const ProfileScreen: React.FC = () => {
|
const ProfileScreen: React.FC = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { colors, fonts } = useTheme();
|
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(() => {
|
useEffect(() => {
|
||||||
// Seed dummy data if empty
|
// Sync profile data with auth user data when available
|
||||||
if (!name && !email) {
|
if (authUser && (!name || !email)) {
|
||||||
dispatch(setProfile({ name: 'Jane Doe', email: 'jane.doe@example.com' }));
|
dispatch(setProfileFromAuth(authUser));
|
||||||
}
|
}
|
||||||
}, [dispatch, name, email]);
|
}, [dispatch, authUser, name, email]);
|
||||||
|
|
||||||
const [showLogout, setShowLogout] = React.useState(false);
|
const [showLogout, setShowLogout] = React.useState(false);
|
||||||
const handleLogout = () => setShowLogout(true);
|
const handleLogout = () => setShowLogout(true);
|
||||||
@ -45,12 +52,25 @@ const ProfileScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Name */}
|
{/* 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 */}
|
{/* Email pill */}
|
||||||
<View style={[styles.emailPill, { backgroundColor: '#DFE9FF' }]}>
|
<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>
|
</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>
|
</View>
|
||||||
|
|
||||||
{/* Settings card */}
|
{/* Settings card */}
|
||||||
@ -119,6 +139,12 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
},
|
},
|
||||||
|
roleBadge: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
card: {
|
card: {
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
marginHorizontal: 16,
|
marginHorizontal: 16,
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import type { AuthUser } from '@/modules/auth/store/authSlice';
|
||||||
|
|
||||||
export interface ProfileState {
|
export interface ProfileState {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
role: string;
|
||||||
|
userId: number | null;
|
||||||
|
uuid: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@ -10,6 +14,9 @@ export interface ProfileState {
|
|||||||
const initialState: ProfileState = {
|
const initialState: ProfileState = {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
role: '',
|
||||||
|
userId: null,
|
||||||
|
uuid: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@ -18,9 +25,19 @@ const profileSlice = createSlice({
|
|||||||
name: 'profile',
|
name: 'profile',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
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.name = action.payload.name;
|
||||||
state.email = action.payload.email;
|
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>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.loading = action.payload;
|
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;
|
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 { create } from 'apisauce';
|
||||||
|
import { store } from '@/store/store';
|
||||||
|
import { selectAccessToken } from '@/modules/auth/store/selectors';
|
||||||
|
|
||||||
const http = create({
|
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,
|
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;
|
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',
|
HR_METRICS: '/hr/metrics',
|
||||||
ZOHO_PROJECTS: '/zoho/projects',
|
ZOHO_PROJECTS: '/zoho/projects',
|
||||||
PROFILE: '/profile',
|
PROFILE: '/profile',
|
||||||
|
|
||||||
|
// CRM API Endpoints
|
||||||
|
CRM_DATA: '/api/v1/crm/data',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ApiEndpointKey = keyof typeof API_ENDPOINTS;
|
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 zohoProjectsSlice from '@/modules/zohoProjects/store/zohoProjectsSlice';
|
||||||
import profileSlice from '@/modules/profile/store/profileSlice';
|
import profileSlice from '@/modules/profile/store/profileSlice';
|
||||||
import integrationsSlice from '@/modules/integrations/store/integrationsSlice';
|
import integrationsSlice from '@/modules/integrations/store/integrationsSlice';
|
||||||
|
import crmSlice from '@/modules/crm/store/crmSlice';
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
auth: authSlice.reducer,
|
auth: authSlice.reducer,
|
||||||
@ -16,13 +17,14 @@ const rootReducer = combineReducers({
|
|||||||
zohoProjects: zohoProjectsSlice.reducer,
|
zohoProjects: zohoProjectsSlice.reducer,
|
||||||
profile: profileSlice.reducer,
|
profile: profileSlice.reducer,
|
||||||
integrations: integrationsSlice.reducer,
|
integrations: integrationsSlice.reducer,
|
||||||
|
crm: crmSlice,
|
||||||
ui: uiSlice.reducer,
|
ui: uiSlice.reducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistConfig = {
|
const persistConfig = {
|
||||||
key: 'root',
|
key: 'root',
|
||||||
storage: AsyncStorage,
|
storage: AsyncStorage,
|
||||||
whitelist: ['auth', 'hr', 'zohoProjects', 'profile', 'integrations'],
|
whitelist: ['auth', 'hr', 'zohoProjects', 'profile', 'integrations', 'crm'],
|
||||||
blacklist: ['ui'],
|
blacklist: ['ui'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user