zoho projects implemented on front end

This commit is contained in:
yashwin-foxy 2025-09-15 19:59:08 +05:30
parent a68523567a
commit e0be9146dd
26 changed files with 3422 additions and 32 deletions

142
__tests__/auth.test.ts Normal file
View File

@ -0,0 +1,142 @@
/**
* @format
*/
import { configureStore } from '@reduxjs/toolkit';
import authSlice, { login, refreshToken, logout, updateAccessToken } from '../src/modules/auth/store/authSlice';
import { authAPI } from '../src/modules/auth/services/authAPI';
// Mock the authAPI
jest.mock('../src/modules/auth/services/authAPI', () => ({
authAPI: {
login: jest.fn(),
refreshToken: jest.fn(),
},
}));
const mockAuthAPI = authAPI as jest.Mocked<typeof authAPI>;
describe('Auth Slice', () => {
let store: ReturnType<typeof configureStore>;
beforeEach(() => {
store = configureStore({
reducer: {
auth: authSlice.reducer,
},
});
jest.clearAllMocks();
});
describe('refreshToken', () => {
it('should handle successful token refresh', async () => {
const mockRefreshResponse = {
data: {
status: 'success',
message: 'Token refreshed successfully',
data: {
accessToken: 'new-access-token',
},
timestamp: '2024-01-01T00:00:00Z',
},
};
mockAuthAPI.refreshToken.mockResolvedValue(mockRefreshResponse);
const result = await store.dispatch(refreshToken('valid-refresh-token'));
expect(refreshToken.fulfilled.match(result)).toBe(true);
expect(store.getState().auth.accessToken).toBe('new-access-token');
expect(store.getState().auth.error).toBeNull();
});
it('should handle refresh token failure and logout user', async () => {
const mockErrorResponse = {
response: {
data: {
status: 'error',
message: 'Invalid refresh token',
errorCode: 'INVALID_REFRESH',
timestamp: '2024-01-01T00:00:00Z',
},
},
};
mockAuthAPI.refreshToken.mockRejectedValue(mockErrorResponse);
// Set initial state with user logged in
store.dispatch(updateAccessToken('old-access-token'));
store.dispatch({
type: 'auth/login/fulfilled',
payload: {
accessToken: 'old-access-token',
refreshToken: 'valid-refresh-token',
user: {
id: 1,
uuid: 'test-uuid',
email: 'test@example.com',
displayName: 'Test User',
role: 'user',
},
},
});
const result = await store.dispatch(refreshToken('invalid-refresh-token'));
expect(refreshToken.rejected.match(result)).toBe(true);
expect(store.getState().auth.isAuthenticated).toBe(false);
expect(store.getState().auth.user).toBeNull();
expect(store.getState().auth.accessToken).toBeNull();
expect(store.getState().auth.refreshToken).toBeNull();
});
it('should handle network error during refresh', async () => {
mockAuthAPI.refreshToken.mockRejectedValue(new Error('Network error'));
const result = await store.dispatch(refreshToken('valid-refresh-token'));
expect(refreshToken.rejected.match(result)).toBe(true);
expect(store.getState().auth.error).toBe('Network error');
});
});
describe('updateAccessToken', () => {
it('should update access token', () => {
const newToken = 'new-access-token';
store.dispatch(updateAccessToken(newToken));
expect(store.getState().auth.accessToken).toBe(newToken);
});
});
describe('logout', () => {
it('should clear all auth state', () => {
// Set initial state
store.dispatch(updateAccessToken('access-token'));
store.dispatch({
type: 'auth/login/fulfilled',
payload: {
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: {
id: 1,
uuid: 'test-uuid',
email: 'test@example.com',
displayName: 'Test User',
role: 'user',
},
},
});
// Dispatch logout
store.dispatch(logout());
const state = store.getState().auth;
expect(state.user).toBeNull();
expect(state.accessToken).toBeNull();
expect(state.refreshToken).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.error).toBeNull();
});
});
});

View File

@ -58,6 +58,7 @@ react {
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = false
def enableSeparateBuildPerCPUArchitecture = true
/**
* The preferred build flavor of JavaScriptCore (JSC)
@ -78,6 +79,13 @@ android {
compileSdk rootProject.ext.compileSdkVersion
namespace "com.centralizedreportingsystem"
splits {
abi {
enable true
include 'armeabi-v7a', 'arm64-v8a', 'x86'
universalApk false
}
}
defaultConfig {
applicationId "com.centralizedreportingsystem"
minSdkVersion rootProject.ext.minSdkVersion

View File

@ -32,7 +32,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.

View File

@ -1,6 +1,6 @@
// Export auth slice and actions
export { default as authSlice } from './store/authSlice';
export { login, logout, clearError } from './store/authSlice';
export { login, logout, clearError, register } from './store/authSlice';
// Export selectors
export * from './store/selectors';
@ -13,3 +13,4 @@ export { default as AuthNavigator } from './navigation/AuthNavigator';
// Export screens
export { default as LoginScreen } from './screens/LoginScreen';
export { default as SignupScreen } from './screens/SignupScreen';

View File

@ -1,14 +1,26 @@
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import LoginScreen from '@/modules/auth/screens/LoginScreen';
import SignupScreen from '@/modules/auth/screens/SignupScreen';
const Stack = createStackNavigator();
const AuthNavigator = () => (
<Stack.Navigator>
<Stack.Screen name="Login" component={LoginScreen} options={{
headerShown: false, // Hide header for login screen
}}/>
<Stack.Screen
name="Login"
component={LoginScreen}
options={{
headerShown: false, // Hide header for login screen
}}
/>
<Stack.Screen
name="Signup"
component={SignupScreen}
options={{
headerShown: false, // Hide header for signup screen
}}
/>
</Stack.Navigator>
);

View File

@ -8,6 +8,7 @@ import {
Pressable,
Alert,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import GradientBackground from '@/shared/components/layout/GradientBackground';
import LinearGradient from 'react-native-linear-gradient';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
@ -19,6 +20,7 @@ import { validateLoginForm } from '@/shared/utils/validation';
import { showError, showSuccess, showWarning, showInfo } from '@/shared/utils/Toast';
const LoginScreen: React.FC = () => {
const navigation = useNavigation();
const dispatch = useDispatch<AppDispatch>();
const { colors, fonts } = useTheme();
const { loading, error, isAuthenticated } = useSelector((s: RootState) => s.auth);
@ -303,7 +305,7 @@ const LoginScreen: React.FC = () => {
{/* Sign up */}
<View style={styles.signupRow}>
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Don't have an account? </Text>
<TouchableOpacity onPress={() => showInfo('Sign up feature coming soon!')}>
<TouchableOpacity onPress={() => navigation.navigate('Signup' as never)}>
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Sign up</Text>
</TouchableOpacity>
</View>

View File

@ -0,0 +1,634 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
Pressable,
ScrollView,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import GradientBackground from '@/shared/components/layout/GradientBackground';
import LinearGradient from 'react-native-linear-gradient';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { useDispatch, useSelector } from 'react-redux';
import { register, clearError } from '@/modules/auth/store/authSlice';
import type { RootState, AppDispatch } from '@/store/store';
import { useTheme } from '@/shared/styles/useTheme';
import { validateSignupForm } from '@/shared/utils/validation';
import { showError, showSuccess, showWarning, showInfo } from '@/shared/utils/Toast';
const SignupScreen: React.FC = () => {
const navigation = useNavigation();
const dispatch = useDispatch<AppDispatch>();
const { colors, fonts } = useTheme();
const { loading, error } = useSelector((s: RootState) => s.auth);
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [confirmPassword, setConfirmPassword] = React.useState('');
const [firstName, setFirstName] = React.useState('');
const [lastName, setLastName] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [showConfirmPassword, setShowConfirmPassword] = React.useState(false);
const [focused, setFocused] = React.useState<null | 'email' | 'password' | 'confirmPassword' | 'firstName' | 'lastName'>(null);
const [validationErrors, setValidationErrors] = React.useState<{
email?: string;
password?: string;
confirmPassword?: string;
firstName?: string;
lastName?: string;
}>({});
const emailRef = React.useRef<TextInput>(null);
const passwordRef = React.useRef<TextInput>(null);
const confirmPasswordRef = React.useRef<TextInput>(null);
const firstNameRef = React.useRef<TextInput>(null);
const lastNameRef = React.useRef<TextInput>(null);
// Clear validation errors when user starts typing
const handleEmailChange = (text: string) => {
setEmail(text);
if (validationErrors.email) {
setValidationErrors(prev => ({ ...prev, email: undefined }));
}
if (error) {
dispatch(clearError());
}
};
const handlePasswordChange = (text: string) => {
setPassword(text);
if (validationErrors.password) {
setValidationErrors(prev => ({ ...prev, password: undefined }));
}
if (error) {
dispatch(clearError());
}
};
const handleConfirmPasswordChange = (text: string) => {
setConfirmPassword(text);
if (validationErrors.confirmPassword) {
setValidationErrors(prev => ({ ...prev, confirmPassword: undefined }));
}
if (error) {
dispatch(clearError());
}
};
const handleFirstNameChange = (text: string) => {
setFirstName(text);
if (validationErrors.firstName) {
setValidationErrors(prev => ({ ...prev, firstName: undefined }));
}
if (error) {
dispatch(clearError());
}
};
const handleLastNameChange = (text: string) => {
setLastName(text);
if (validationErrors.lastName) {
setValidationErrors(prev => ({ ...prev, lastName: undefined }));
}
if (error) {
dispatch(clearError());
}
};
const handleSignup = async () => {
// Clear previous validation errors
setValidationErrors({});
// Validate form inputs
const validation = validateSignupForm(email, password, confirmPassword, firstName, lastName);
if (!validation.isValid) {
setValidationErrors(validation.errors);
// Show toast messages for validation errors
Object.values(validation.errors).forEach(error => {
if (error) {
showError(error);
}
});
return;
}
try {
// Dispatch register action
const result = await dispatch(register({ email, password, firstName, lastName }));
// Check if registration was successful
if (register.fulfilled.match(result)) {
// Registration successful - show success toast
showSuccess('Registration successful! Please login to continue.');
// Clear form
setEmail('');
setPassword('');
setConfirmPassword('');
setFirstName('');
setLastName('');
// Navigate to login screen after 1 second delay
setTimeout(() => {
navigation.navigate('Login' as never);
}, 1000);
} else if (register.rejected.match(result)) {
// Registration failed - show error toast
showError(result.payload as string || 'Registration failed. Please try again.');
}
} catch (err) {
// Error handling is done in the slice
console.error('Registration error:', err);
showError('An unexpected error occurred. Please try again.');
}
};
// Clear error when component mounts or when user starts typing
React.useEffect(() => {
if (error) {
dispatch(clearError());
}
}, [dispatch]);
// Auto-focus first name input when component mounts
React.useEffect(() => {
const timer = setTimeout(() => {
firstNameRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, []);
const isFormValid = email.trim() && password.trim() && confirmPassword.trim() && firstName.trim() && lastName.trim();
return (
<GradientBackground preset="warm" style={styles.gradient}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
// keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
<ScrollView
contentContainerStyle={styles.scrollContainer}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{/* Logo placeholder */}
<View style={[styles.logoCircle, { backgroundColor: '#F1F5F9' }]}>
<Icon name="account-plus" size={28} color={colors.primary} />
</View>
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Create Account</Text>
<Text style={[styles.subtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>
Fill in your details to get started
</Text>
{/* First Name input */}
<View>
<Pressable
style={[
styles.inputWrapper,
{
borderColor: validationErrors.firstName
? colors.error
: focused === 'firstName'
? colors.primary
: colors.border,
backgroundColor: colors.surface,
},
]}
onPress={() => firstNameRef.current?.focus()}
>
<Icon
name="account-outline"
size={20}
color={validationErrors.firstName
? colors.error
: focused === 'firstName'
? colors.primary
: colors.textLight
}
/>
<TextInput
ref={firstNameRef}
placeholder="First Name"
placeholderTextColor={colors.textLight}
autoCapitalize="words"
autoCorrect={false}
autoComplete="given-name"
returnKeyType="next"
value={firstName}
onFocus={() => setFocused('firstName')}
onBlur={() => setFocused(null)}
onChangeText={handleFirstNameChange}
onSubmitEditing={() => lastNameRef.current?.focus()}
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
/>
</Pressable>
{validationErrors.firstName && (
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{validationErrors.firstName}
</Text>
)}
</View>
{/* Last Name input */}
<View>
<Pressable
style={[
styles.inputWrapper,
{
borderColor: validationErrors.lastName
? colors.error
: focused === 'lastName'
? colors.primary
: colors.border,
backgroundColor: colors.surface,
},
]}
onPress={() => lastNameRef.current?.focus()}
>
<Icon
name="account-outline"
size={20}
color={validationErrors.lastName
? colors.error
: focused === 'lastName'
? colors.primary
: colors.textLight
}
/>
<TextInput
ref={lastNameRef}
placeholder="Last Name"
placeholderTextColor={colors.textLight}
autoCapitalize="words"
autoCorrect={false}
autoComplete="family-name"
returnKeyType="next"
value={lastName}
onFocus={() => setFocused('lastName')}
onBlur={() => setFocused(null)}
onChangeText={handleLastNameChange}
onSubmitEditing={() => emailRef.current?.focus()}
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
/>
</Pressable>
{validationErrors.lastName && (
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{validationErrors.lastName}
</Text>
)}
</View>
{/* Email input */}
<View>
<Pressable
style={[
styles.inputWrapper,
{
borderColor: validationErrors.email
? colors.error
: focused === 'email'
? colors.primary
: colors.border,
backgroundColor: colors.surface,
},
]}
onPress={() => emailRef.current?.focus()}
>
<Icon
name="email-outline"
size={20}
color={validationErrors.email
? colors.error
: focused === 'email'
? colors.primary
: colors.textLight
}
/>
<TextInput
ref={emailRef}
placeholder="Email"
placeholderTextColor={colors.textLight}
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
keyboardType="email-address"
returnKeyType="next"
value={email}
onFocus={() => setFocused('email')}
onBlur={() => setFocused(null)}
onChangeText={handleEmailChange}
onSubmitEditing={() => passwordRef.current?.focus()}
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
/>
</Pressable>
{validationErrors.email && (
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{validationErrors.email}
</Text>
)}
</View>
{/* Password input */}
<View>
<Pressable
style={[
styles.inputWrapper,
{
borderColor: validationErrors.password
? colors.error
: focused === 'password'
? colors.primary
: colors.border,
backgroundColor: colors.surface,
},
]}
onPress={() => passwordRef.current?.focus()}
>
<Icon
name="lock-outline"
size={20}
color={validationErrors.password
? colors.error
: focused === 'password'
? colors.primary
: colors.textLight
}
/>
<TextInput
ref={passwordRef}
placeholder="Password"
placeholderTextColor={colors.textLight}
secureTextEntry={!showPassword}
textContentType="newPassword"
autoComplete="new-password"
autoCorrect={false}
autoCapitalize="none"
value={password}
onFocus={() => setFocused('password')}
onBlur={() => setFocused(null)}
onChangeText={handlePasswordChange}
onSubmitEditing={() => confirmPasswordRef.current?.focus()}
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
/>
<TouchableOpacity
style={styles.iconButton}
onPress={() => setShowPassword(v => !v)}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
accessibilityRole="button"
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
>
<Icon name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={22} color={colors.textLight} />
</TouchableOpacity>
</Pressable>
{validationErrors.password && (
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{validationErrors.password}
</Text>
)}
</View>
{/* Confirm Password input */}
<View>
<Pressable
style={[
styles.inputWrapper,
{
borderColor: validationErrors.confirmPassword
? colors.error
: focused === 'confirmPassword'
? colors.primary
: colors.border,
backgroundColor: colors.surface,
},
]}
onPress={() => confirmPasswordRef.current?.focus()}
>
<Icon
name="lock-check-outline"
size={20}
color={validationErrors.confirmPassword
? colors.error
: focused === 'confirmPassword'
? colors.primary
: colors.textLight
}
/>
<TextInput
ref={confirmPasswordRef}
placeholder="Confirm Password"
placeholderTextColor={colors.textLight}
secureTextEntry={!showConfirmPassword}
textContentType="newPassword"
autoComplete="new-password"
autoCorrect={false}
autoCapitalize="none"
value={confirmPassword}
onFocus={() => setFocused('confirmPassword')}
onBlur={() => setFocused(null)}
onChangeText={handleConfirmPasswordChange}
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
/>
<TouchableOpacity
style={styles.iconButton}
onPress={() => setShowConfirmPassword(v => !v)}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
accessibilityRole="button"
accessibilityLabel={showConfirmPassword ? 'Hide confirm password' : 'Show confirm password'}
>
<Icon name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'} size={22} color={colors.textLight} />
</TouchableOpacity>
</Pressable>
{validationErrors.confirmPassword && (
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{validationErrors.confirmPassword}
</Text>
)}
</View>
{/* Password requirements */}
<View style={styles.passwordRequirements}>
<Text style={[styles.requirementsTitle, { color: colors.textLight, fontFamily: fonts.medium }]}>
Password must contain:
</Text>
<Text style={[styles.requirement, { color: colors.textLight, fontFamily: fonts.regular }]}>
At least 8 characters
</Text>
<Text style={[styles.requirement, { color: colors.textLight, fontFamily: fonts.regular }]}>
One uppercase letter
</Text>
<Text style={[styles.requirement, { color: colors.textLight, fontFamily: fonts.regular }]}>
One lowercase letter
</Text>
<Text style={[styles.requirement, { color: colors.textLight, fontFamily: fonts.regular }]}>
One number
</Text>
<Text style={[styles.requirement, { color: colors.textLight, fontFamily: fonts.regular }]}>
One special character
</Text>
</View>
{/* Signup button */}
<TouchableOpacity
activeOpacity={0.9}
onPress={handleSignup}
disabled={loading || !isFormValid}
style={{ marginTop: 12 }}
>
<LinearGradient
colors={loading || !isFormValid
? [colors.border, colors.border]
: ["#3AA0FF", "#2D6BFF"]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.signupButton}
>
<Text style={[styles.signupButtonText, { fontFamily: fonts.bold }]}>
{loading ? 'Creating Account...' : 'Create Account'}
</Text>
</LinearGradient>
</TouchableOpacity>
{/* Sign in */}
<View style={styles.signinRow}>
<Text style={[styles.signinText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Already have an account?
</Text>
<TouchableOpacity onPress={() => navigation.navigate('Login' as never)}>
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Sign in</Text>
</TouchableOpacity>
</View>
{/* API Error */}
{!!error && (
<View style={styles.errorContainer}>
<Icon name="alert-circle-outline" size={16} color={colors.error} />
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
{error}
</Text>
</View>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</GradientBackground>
);
};
const styles = StyleSheet.create({
gradient: {
flex: 1,
},
container: {
flex: 1,
},
scrollContainer: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
card: {
width: '100%',
maxWidth: 380,
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
logoCircle: {
alignSelf: 'center',
width: 48,
height: 48,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
title: {
fontSize: 24,
textAlign: 'center',
},
subtitle: {
textAlign: 'center',
marginTop: 4,
marginBottom: 12,
},
inputWrapper: {
marginTop: 12,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
height: 52,
flexDirection: 'row',
alignItems: 'center',
},
input: {
paddingVertical: 0,
marginLeft: 8,
flex: 1,
},
iconButton: {
position: 'absolute',
right: 8,
top: 0,
bottom: 0,
width: 36,
alignItems: 'center',
justifyContent: 'center',
},
passwordRequirements: {
marginTop: 8,
paddingHorizontal: 8,
},
requirementsTitle: {
fontSize: 12,
marginBottom: 4,
},
requirement: {
fontSize: 11,
marginBottom: 2,
},
signupButton: {
height: 48,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
signupButtonText: {
color: '#FFFFFF',
fontSize: 16,
},
signinRow: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 16,
},
signinText: {
fontSize: 12,
},
link: {},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
paddingHorizontal: 8,
},
errorText: {
marginLeft: 6,
fontSize: 14,
textAlign: 'left',
flex: 1,
},
});
export default SignupScreen;

View File

@ -3,6 +3,13 @@ import { API_ENDPOINTS } from '@/shared/constants/API_ENDPOINTS';
export const authAPI = {
login: (email: string, password: string) => http.post(API_ENDPOINTS.AUTH_LOGIN, { email, password }),
refreshToken: (refreshToken:string) => http.post(API_ENDPOINTS.REFRESH_TOKEN,{refreshToken}),
register: (userData: {
email: string;
password: string;
firstName: string;
lastName: string;
}) => http.post(API_ENDPOINTS.USERSIGNUP, userData),
};

View File

@ -20,7 +20,42 @@ interface LoginErrorResponse {
timestamp: string;
}
interface RefreshTokenSuccessResponse {
status: 'success';
message: string;
data: {
accessToken: string;
};
timestamp: string;
}
interface RefreshTokenErrorResponse {
status: 'error';
message: string;
errorCode: 'INVALID_REFRESH';
timestamp: string;
}
interface RegisterSuccessResponse {
status: 'success';
message: string;
data: {
uuid: string;
email: string;
};
timestamp: string;
}
interface RegisterErrorResponse {
status: 'error';
message: string;
errorCode: string;
timestamp: string;
}
type LoginResponse = LoginSuccessResponse | LoginErrorResponse;
type RefreshTokenResponse = RefreshTokenSuccessResponse | RefreshTokenErrorResponse;
type RegisterResponse = RegisterSuccessResponse | RegisterErrorResponse;
export interface AuthUser {
id: number;
@ -76,6 +111,60 @@ export const login = createAsyncThunk(
},
);
export const refreshToken = createAsyncThunk(
'auth/refreshToken',
async (refreshTokenValue: string, { rejectWithValue }) => {
try {
const response = await authAPI.refreshToken(refreshTokenValue);
const data = response.data as RefreshTokenResponse;
console.log('refresh token response got',data)
if (data.status === 'success') {
return {
accessToken: data.data.accessToken,
};
} else {
return rejectWithValue(data.message || 'Token refresh failed');
}
} catch (error: any) {
// Handle network errors or API errors
if (error.response?.data?.status === 'error') {
const errorData = error.response.data as RefreshTokenErrorResponse;
return rejectWithValue(errorData.message || 'Token refresh failed');
}
return rejectWithValue(error.message || 'Network error occurred');
}
},
);
export const register = createAsyncThunk(
'auth/register',
async (payload: { email: string; password: string; firstName: string; lastName: string }, { rejectWithValue }) => {
try {
const response = await authAPI.register(payload);
const data = response.data as RegisterResponse;
console.log('register response got', data);
if (data.status === 'success') {
return {
uuid: data.data.uuid,
email: data.data.email,
message: data.message,
};
} else {
return rejectWithValue(data.message || 'Registration failed');
}
} catch (error: any) {
// Handle network errors or API errors
if (error.response?.data?.status === 'error') {
const errorData = error.response.data as RegisterErrorResponse;
return rejectWithValue(errorData.message || 'Registration failed');
}
return rejectWithValue(error.message || 'Network error occurred');
}
},
);
const authSlice = createSlice({
name: 'auth',
initialState,
@ -90,6 +179,9 @@ const authSlice = createSlice({
clearError: state => {
state.error = null;
},
updateAccessToken: (state, action: PayloadAction<string>) => {
state.accessToken = action.payload;
},
},
extraReducers: builder => {
builder
@ -109,11 +201,41 @@ const authSlice = createSlice({
state.loading = false;
state.error = action.payload as string || 'Login failed';
state.isAuthenticated = false;
})
.addCase(refreshToken.pending, state => {
// Don't set loading to true for refresh token to avoid UI flicker
state.error = null;
})
.addCase(refreshToken.fulfilled, (state, action) => {
state.accessToken = action.payload.accessToken;
state.error = null;
})
.addCase(refreshToken.rejected, (state, action) => {
// If refresh token fails, logout the user
state.user = null;
state.accessToken = null;
state.refreshToken = null;
state.error = action.payload as string || 'Token refresh failed';
state.isAuthenticated = false;
})
.addCase(register.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(register.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
// Note: Registration doesn't automatically log in the user
// They need to login separately after successful registration
})
.addCase(register.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string || 'Registration failed';
});
},
});
export const { logout, clearError } = authSlice.actions;
export const { logout, clearError, updateAccessToken } = authSlice.actions;
export default authSlice;

View File

@ -2,7 +2,7 @@ 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';
import type { CrmLead, CrmTask, CrmContact, CrmDeal, CrmSalesOrder, CrmPurchaseOrder, CrmInvoice } from '../types/CrmTypes';
interface BaseCardProps {
onPress: () => void;
@ -24,6 +24,18 @@ interface DealCardProps extends BaseCardProps {
deal: CrmDeal;
}
interface SalesOrderCardProps extends BaseCardProps {
salesOrder: CrmSalesOrder;
}
interface PurchaseOrderCardProps extends BaseCardProps {
purchaseOrder: CrmPurchaseOrder;
}
interface InvoiceCardProps extends BaseCardProps {
invoice: CrmInvoice;
}
const getStatusColor = (status: string, colors: any) => {
// return '#3AA0FF';
switch (status.toLowerCase()) {
@ -289,6 +301,189 @@ export const DealCard: React.FC<DealCardProps> = ({ deal, onPress }) => {
);
};
export const SalesOrderCard: React.FC<SalesOrderCardProps> = ({ salesOrder, 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}>
{salesOrder.Subject}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(salesOrder.Status, colors) }]}>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
{salesOrder.Status}
</Text>
</View>
</View>
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
SO: {salesOrder.SO_Number}
</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 }]}>
{salesOrder.$currency_symbol}{salesOrder.Grand_Total?.toLocaleString()}
</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 }]}>
Account: {salesOrder.Account_Name?.name || 'N/A'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="map-marker-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
{salesOrder.Billing_City}, {salesOrder.Billing_Country}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="truck-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Carrier: {salesOrder.Carrier}
</Text>
</View>
</View>
<View style={styles.cardFooter}>
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Created: {new Date(salesOrder.Created_Time)?.toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
);
};
export const PurchaseOrderCard: React.FC<PurchaseOrderCardProps> = ({ purchaseOrder, 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}>
{purchaseOrder.Subject}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(purchaseOrder.Status, colors) }]}>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
{purchaseOrder.Status}
</Text>
</View>
</View>
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
PO: {purchaseOrder.PO_Number || '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 }]}>
{purchaseOrder.$currency_symbol}{purchaseOrder.Grand_Total?.toLocaleString()}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="store-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Vendor: {purchaseOrder.Vendor_Name?.name || 'N/A'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="map-marker-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
{purchaseOrder.Billing_City}, {purchaseOrder.Billing_Country}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="truck-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Carrier: {purchaseOrder.Carrier}
</Text>
</View>
</View>
<View style={styles.cardFooter}>
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
PO Date: {new Date(purchaseOrder.PO_Date)?.toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
);
};
export const InvoiceCard: React.FC<InvoiceCardProps> = ({ invoice, 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}>
{invoice.Subject}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(invoice.Status, colors) }]}>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
{invoice.Status}
</Text>
</View>
</View>
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
Invoice: {invoice.Invoice_Number}
</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 }]}>
{invoice.$currency_symbol}{invoice.Grand_Total?.toLocaleString()}
</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 }]}>
Account: {invoice.Account_Name?.name || 'N/A'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="map-marker-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
{invoice.Billing_City}, {invoice.Billing_Country}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="calendar-outline" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
Due: {new Date(invoice.Due_Date)?.toLocaleDateString()}
</Text>
</View>
</View>
<View style={styles.cardFooter}>
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Invoice Date: {new Date(invoice.Invoice_Date)?.toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
card: {
borderRadius: 12,

View File

@ -131,6 +131,134 @@ const CrmDashboardScreen: React.FC = () => {
/>
</View>
{/* Sales Orders & Purchase Orders Row */}
<View style={styles.kpiGrid}>
<Kpi
label="Sales Orders"
value={String(crmStats.salesOrders.total)}
color={colors.text}
fonts={fonts}
accent="#3B82F6"
/>
<Kpi
label="Sales Value"
value={formatCurrency(crmStats.salesOrders.totalValue)}
color={colors.text}
fonts={fonts}
accent="#10B981"
/>
<Kpi
label="Purchase Orders"
value={String(crmStats.purchaseOrders.total)}
color={colors.text}
fonts={fonts}
accent="#F59E0B"
/>
<Kpi
label="Purchase Value"
value={formatCurrency(crmStats.purchaseOrders.totalValue)}
color={colors.text}
fonts={fonts}
accent="#EF4444"
/>
</View>
{/* Invoices Row */}
<View style={styles.kpiGrid}>
<Kpi
label="Total Invoices"
value={String(crmStats.invoices.total)}
color={colors.text}
fonts={fonts}
accent="#8B5CF6"
/>
<Kpi
label="Invoice Value"
value={formatCurrency(crmStats.invoices.totalValue)}
color={colors.text}
fonts={fonts}
accent="#06B6D4"
/>
<Kpi
label="Overdue Invoices"
value={String(crmStats.invoices.overdue)}
color={colors.text}
fonts={fonts}
accent="#EF4444"
/>
<Kpi
label="Paid Invoices"
value={String(crmStats.invoices.paid)}
color={colors.text}
fonts={fonts}
accent="#22C55E"
/>
</View>
{/* Customer & Sales KPIs Row 1 */}
<View style={styles.kpiGrid}>
<Kpi
label="Sales Cycle (Days)"
value={String(crmStats.customerKPIs.salesCycleLength)}
color={colors.text}
fonts={fonts}
accent="#3B82F6"
/>
<Kpi
label="Avg Revenue/Account"
value={formatCurrency(crmStats.customerKPIs.averageRevenuePerAccount)}
color={colors.text}
fonts={fonts}
accent="#10B981"
/>
<Kpi
label="Churn Rate"
value={`${crmStats.customerKPIs.churnRate}%`}
color={colors.text}
fonts={fonts}
accent="#EF4444"
/>
<Kpi
label="Customer LTV"
value={formatCurrency(crmStats.customerKPIs.customerLifetimeValue)}
color={colors.text}
fonts={fonts}
accent="#8B5CF6"
/>
</View>
{/* Customer & Sales KPIs Row 2 */}
<View style={styles.kpiGrid}>
<Kpi
label="LTV/CAC Ratio"
value={String(crmStats.customerKPIs.ltvToCacRatio)}
color={colors.text}
fonts={fonts}
accent="#F59E0B"
/>
<Kpi
label="Retention Rate"
value={`${crmStats.customerKPIs.customerRetentionRate}%`}
color={colors.text}
fonts={fonts}
accent="#22C55E"
/>
<Kpi
label="Conversion Rate"
value={`${crmStats.customerKPIs.conversionRate}%`}
color={colors.text}
fonts={fonts}
accent="#06B6D4"
/>
<Kpi
label="Win Rate"
value={`${crmStats.customerKPIs.winRate}%`}
color={colors.text}
fonts={fonts}
accent="#10B981"
/>
</View>
{/* Lead Status Distribution - Pie Chart */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Lead Status Distribution</Text>
@ -236,6 +364,143 @@ const CrmDashboardScreen: React.FC = () => {
</View>
</View>
{/* Sales Orders by Status - Donut Chart */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Sales Orders by Status</Text>
<View style={styles.chartContainer}>
<DonutChart
data={Object.entries(crmStats.salesOrders.byStatus).map(([status, count]) => ({
label: status,
value: count,
color: getStatusColor(status)
}))}
colors={colors}
fonts={fonts}
size={140}
/>
{/* Legend */}
<View style={styles.pieLegend}>
{Object.entries(crmStats.salesOrders.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>
{/* Purchase Orders by Vendor - Pie Chart */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Purchase Orders by Vendor</Text>
<View style={styles.chartContainer}>
<PieChart
data={Object.entries(crmStats.purchaseOrders.byVendor).map(([vendor, count]) => ({
label: vendor,
value: count,
color: getStatusColor(vendor)
}))}
colors={colors}
fonts={fonts}
size={140}
/>
{/* Legend */}
<View style={styles.pieLegend}>
{Object.entries(crmStats.purchaseOrders.byVendor).map(([vendor, count]) => (
<View key={vendor} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getStatusColor(vendor) }]} />
<Text style={[styles.legendText, { color: colors.textLight, fontFamily: fonts.regular }]}>
{vendor} ({count})
</Text>
</View>
))}
</View>
</View>
</View>
{/* Invoices by Status - Stacked Bar Chart */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Invoices by Status</Text>
<View style={styles.chartContainer}>
<StackedBarChart
data={Object.entries(crmStats.invoices.byStatus).map(([status, count]) => ({
label: status,
value: count,
color: getStatusColor(status)
}))}
colors={colors}
fonts={fonts}
height={120}
/>
{/* Legend */}
<View style={styles.barLegend}>
{Object.entries(crmStats.invoices.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>
{/* Customer & Sales KPIs Summary */}
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Customer & Sales KPIs Summary</Text>
<View style={styles.kpiSummaryGrid}>
<View style={styles.kpiSummaryItem}>
<Text style={[styles.kpiSummaryLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Sales Cycle Length</Text>
<Text style={[styles.kpiSummaryValue, { color: colors.text, fontFamily: fonts.bold }]}>
{crmStats.customerKPIs.salesCycleLength} days
</Text>
<Text style={[styles.kpiSummaryDesc, { color: colors.textLight, fontFamily: fonts.regular }]}>
Avg. time from first touch to close
</Text>
</View>
<View style={styles.kpiSummaryItem}>
<Text style={[styles.kpiSummaryLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Customer LTV</Text>
<Text style={[styles.kpiSummaryValue, { color: colors.primary, fontFamily: fonts.bold }]}>
{formatCurrency(crmStats.customerKPIs.customerLifetimeValue)}
</Text>
<Text style={[styles.kpiSummaryDesc, { color: colors.textLight, fontFamily: fonts.regular }]}>
Average lifetime value per customer
</Text>
</View>
<View style={styles.kpiSummaryItem}>
<Text style={[styles.kpiSummaryLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>LTV/CAC Ratio</Text>
<Text style={[styles.kpiSummaryValue, { color: crmStats.customerKPIs.ltvToCacRatio >= 3 ? '#22C55E' : '#EF4444', fontFamily: fonts.bold }]}>
{crmStats.customerKPIs.ltvToCacRatio}x
</Text>
<Text style={[styles.kpiSummaryDesc, { color: colors.textLight, fontFamily: fonts.regular }]}>
{crmStats.customerKPIs.ltvToCacRatio >= 3 ? 'Healthy ratio' : 'Needs improvement'}
</Text>
</View>
<View style={styles.kpiSummaryItem}>
<Text style={[styles.kpiSummaryLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Churn Rate</Text>
<Text style={[styles.kpiSummaryValue, { color: crmStats.customerKPIs.churnRate <= 5 ? '#22C55E' : '#EF4444', fontFamily: fonts.bold }]}>
{crmStats.customerKPIs.churnRate}%
</Text>
<Text style={[styles.kpiSummaryDesc, { color: colors.textLight, fontFamily: fonts.regular }]}>
{crmStats.customerKPIs.churnRate <= 5 ? 'Low churn' : 'High churn risk'}
</Text>
</View>
</View>
</View>
{/* Lists */}
<View style={styles.row}>
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
@ -355,6 +620,31 @@ const styles = StyleSheet.create({
paddingVertical: 20,
fontStyle: 'italic'
},
kpiSummaryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
marginTop: 8,
},
kpiSummaryItem: {
width: '48%',
marginBottom: 16,
padding: 12,
backgroundColor: '#F8F9FA',
borderRadius: 8,
},
kpiSummaryLabel: {
fontSize: 12,
marginBottom: 4,
},
kpiSummaryValue: {
fontSize: 18,
marginBottom: 4,
},
kpiSummaryDesc: {
fontSize: 11,
lineHeight: 14,
},
});
// Helper functions for color coding

View File

@ -15,13 +15,16 @@ 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 type { CrmData, CrmLead, CrmTask, CrmContact, CrmDeal, CrmSalesOrder, CrmPurchaseOrder, CrmInvoice } from '../types/CrmTypes';
import { LeadCard, TaskCard, ContactCard, DealCard, SalesOrderCard, PurchaseOrderCard, InvoiceCard } from '../components/CrmDataCards';
import {
selectLeads,
selectTasks,
selectContacts,
selectDeals,
selectSalesOrders,
selectPurchaseOrders,
selectInvoices,
selectCrmLoading,
selectCrmErrors
} from '../store/selectors';
@ -31,7 +34,7 @@ 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 [selectedTab, setSelectedTab] = useState<'leads' | 'tasks' | 'contacts' | 'deals' | 'salesOrders' | 'purchaseOrders' | 'invoices'>('leads');
const [refreshing, setRefreshing] = useState(false);
// Redux selectors
@ -39,6 +42,9 @@ const ZohoCrmDataScreen: React.FC = () => {
const tasks = useSelector(selectTasks);
const contacts = useSelector(selectContacts);
const deals = useSelector(selectDeals);
const salesOrders = useSelector(selectSalesOrders);
const purchaseOrders = useSelector(selectPurchaseOrders);
const invoices = useSelector(selectInvoices);
const loading = useSelector(selectCrmLoading);
const errors = useSelector(selectCrmErrors);
@ -48,7 +54,10 @@ const ZohoCrmDataScreen: React.FC = () => {
tasks: tasks || [],
contacts: contacts || [],
deals: deals || [],
}), [leads, tasks, contacts, deals]);
salesOrders: salesOrders || [],
purchaseOrders: purchaseOrders || [],
invoices: invoices || [],
}), [leads, tasks, contacts, deals, salesOrders, purchaseOrders, invoices]);
// Fetch CRM data using Redux
const fetchCrmData = async (showRefresh = false) => {
@ -88,8 +97,10 @@ const ZohoCrmDataScreen: React.FC = () => {
};
// 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;
const isLoading = loading.leads || loading.tasks || loading.contacts || loading.deals ||
loading.salesOrders || loading.purchaseOrders || loading.invoices;
const hasError = errors.leads || errors.tasks || errors.contacts || errors.deals ||
errors.salesOrders || errors.purchaseOrders || errors.invoices;
// Tab configuration
@ -98,6 +109,9 @@ const ZohoCrmDataScreen: React.FC = () => {
{ 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 },
{ key: 'salesOrders', label: 'Sales Orders', icon: 'shopping', count: crmData.salesOrders.length },
{ key: 'purchaseOrders', label: 'Purchase Orders', icon: 'cart', count: crmData.purchaseOrders.length },
{ key: 'invoices', label: 'Invoices', icon: 'receipt', count: crmData.invoices.length },
] as const;
if (isLoading && !crmData.leads.length) {
@ -175,6 +189,54 @@ const ZohoCrmDataScreen: React.FC = () => {
contentContainerStyle={styles.listContainer}
/>
);
case 'salesOrders':
return (
<FlatList
data={crmData.salesOrders}
renderItem={({ item }) => (
<SalesOrderCard
salesOrder={item}
onPress={() => handleCardPress(item, 'Sales Order')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
case 'purchaseOrders':
return (
<FlatList
data={crmData.purchaseOrders}
renderItem={({ item }) => (
<PurchaseOrderCard
purchaseOrder={item}
onPress={() => handleCardPress(item, 'Purchase Order')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
case 'invoices':
return (
<FlatList
data={crmData.invoices}
renderItem={({ item }) => (
<InvoiceCard
invoice={item}
onPress={() => handleCardPress(item, 'Invoice')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
default:
return null;
}

View File

@ -10,7 +10,7 @@ import type {
} from '../types/CrmTypes';
// Available CRM resource types
export type CrmResourceType = 'leads' | 'tasks' | 'deals' | 'contacts';
export type CrmResourceType = 'leads' | 'tasks' | 'deals' | 'contacts' | 'sales-orders' | 'purchase-orders' | 'invoices';
// Base API endpoint
const CRM_BASE_URL = '/api/v1/integrations/data';
@ -45,5 +45,15 @@ export const crmAPI = {
getDeals: (params?: CrmSearchParams) =>
crmAPI.getCrmData<CrmDeal>('deals', params),
// New API endpoints for sales orders, purchase orders, and invoices
getSalesOrders: (params?: CrmSearchParams) =>
http.get<CrmApiResponse<CrmPaginatedResponse<any>>>(`/api/v1/integrations/sales-orders?provider=zoho`, params),
getPurchaseOrders: (params?: CrmSearchParams) =>
http.get<CrmApiResponse<CrmPaginatedResponse<any>>>(`/api/v1/integrations/purchase-orders?provider=zoho`, params),
getInvoices: (params?: CrmSearchParams) =>
http.get<CrmApiResponse<CrmPaginatedResponse<any>>>(`/api/v1/integrations/invoices?provider=zoho`, params),
};

View File

@ -5,6 +5,9 @@ import type {
CrmTask,
CrmContact,
CrmDeal,
CrmSalesOrder,
CrmPurchaseOrder,
CrmInvoice,
CrmStats,
CrmSearchParams,
CrmApiResponse,
@ -18,6 +21,9 @@ export interface CrmState {
tasks: CrmTask[];
contacts: CrmContact[];
deals: CrmDeal[];
salesOrders: CrmSalesOrder[];
purchaseOrders: CrmPurchaseOrder[];
invoices: CrmInvoice[];
// Loading states
loading: {
@ -25,6 +31,9 @@ export interface CrmState {
tasks: boolean;
contacts: boolean;
deals: boolean;
salesOrders: boolean;
purchaseOrders: boolean;
invoices: boolean;
stats: boolean;
};
@ -34,6 +43,9 @@ export interface CrmState {
tasks: string | null;
contacts: string | null;
deals: string | null;
salesOrders: string | null;
purchaseOrders: string | null;
invoices: string | null;
stats: string | null;
};
@ -43,6 +55,9 @@ export interface CrmState {
tasks: { page: number; count: number; moreRecords: boolean };
contacts: { page: number; count: number; moreRecords: boolean };
deals: { page: number; count: number; moreRecords: boolean };
salesOrders: { page: number; count: number; moreRecords: boolean };
purchaseOrders: { page: number; count: number; moreRecords: boolean };
invoices: { page: number; count: number; moreRecords: boolean };
};
// Statistics
@ -54,6 +69,9 @@ export interface CrmState {
tasks: string | null;
contacts: string | null;
deals: string | null;
salesOrders: string | null;
purchaseOrders: string | null;
invoices: string | null;
stats: string | null;
};
}
@ -64,11 +82,17 @@ const initialState: CrmState = {
tasks: [],
contacts: [],
deals: [],
salesOrders: [],
purchaseOrders: [],
invoices: [],
loading: {
leads: false,
tasks: false,
contacts: false,
deals: false,
salesOrders: false,
purchaseOrders: false,
invoices: false,
stats: false,
},
errors: {
@ -76,6 +100,9 @@ const initialState: CrmState = {
tasks: null,
contacts: null,
deals: null,
salesOrders: null,
purchaseOrders: null,
invoices: null,
stats: null,
},
pagination: {
@ -83,6 +110,9 @@ const initialState: CrmState = {
tasks: { page: 1, count: 0, moreRecords: false },
contacts: { page: 1, count: 0, moreRecords: false },
deals: { page: 1, count: 0, moreRecords: false },
salesOrders: { page: 1, count: 0, moreRecords: false },
purchaseOrders: { page: 1, count: 0, moreRecords: false },
invoices: { page: 1, count: 0, moreRecords: false },
},
stats: null,
lastUpdated: {
@ -90,6 +120,9 @@ const initialState: CrmState = {
tasks: null,
contacts: null,
deals: null,
salesOrders: null,
purchaseOrders: null,
invoices: null,
stats: null,
},
};
@ -127,15 +160,50 @@ export const fetchDeals = createAsyncThunk(
}
);
export const fetchSalesOrders = createAsyncThunk(
'crm/fetchSalesOrders',
async (params?: CrmSearchParams) => {
const response = await crmAPI.getSalesOrders(params);
return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } };
}
);
export const fetchPurchaseOrders = createAsyncThunk(
'crm/fetchPurchaseOrders',
async (params?: CrmSearchParams) => {
const response = await crmAPI.getPurchaseOrders(params);
return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } };
}
);
export const fetchInvoices = createAsyncThunk(
'crm/fetchInvoices',
async (params?: CrmSearchParams) => {
const response = await crmAPI.getInvoices(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([
const [
leadsResponse,
tasksResponse,
contactsResponse,
dealsResponse,
salesOrdersResponse,
purchaseOrdersResponse,
invoicesResponse
] = await Promise.all([
crmAPI.getLeads(params),
crmAPI.getTasks(params),
crmAPI.getContacts(params),
crmAPI.getDeals(params),
crmAPI.getSalesOrders(params),
crmAPI.getPurchaseOrders(params),
crmAPI.getInvoices(params),
]);
console.log('leads response data',leadsResponse)
return {
@ -143,6 +211,9 @@ export const fetchAllCrmData = createAsyncThunk(
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 } },
salesOrders: salesOrdersResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } },
purchaseOrders: purchaseOrdersResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } },
invoices: invoicesResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } },
};
}
);
@ -158,6 +229,9 @@ const crmSlice = createSlice({
tasks: null,
contacts: null,
deals: null,
salesOrders: null,
purchaseOrders: null,
invoices: null,
stats: null,
};
},
@ -166,6 +240,9 @@ const crmSlice = createSlice({
state.tasks = [];
state.contacts = [];
state.deals = [];
state.salesOrders = [];
state.purchaseOrders = [];
state.invoices = [];
state.stats = null;
},
setLeadsPage: (state, action: PayloadAction<number>) => {
@ -180,6 +257,15 @@ const crmSlice = createSlice({
setDealsPage: (state, action: PayloadAction<number>) => {
state.pagination.deals.page = action.payload;
},
setSalesOrdersPage: (state, action: PayloadAction<number>) => {
state.pagination.salesOrders.page = action.payload;
},
setPurchaseOrdersPage: (state, action: PayloadAction<number>) => {
state.pagination.purchaseOrders.page = action.payload;
},
setInvoicesPage: (state, action: PayloadAction<number>) => {
state.pagination.invoices.page = action.payload;
},
},
extraReducers: (builder) => {
// Fetch leads
@ -247,52 +333,124 @@ const crmSlice = createSlice({
state.errors.deals = action.error.message || 'Failed to fetch deals';
})
// Fetch sales orders
.addCase(fetchSalesOrders.pending, (state) => {
state.loading.salesOrders = true;
state.errors.salesOrders = null;
})
.addCase(fetchSalesOrders.fulfilled, (state, action) => {
state.loading.salesOrders = false;
state.salesOrders = action.payload.data || [];
state.pagination.salesOrders = action.payload.info || { page: 1, count: 0, moreRecords: false };
state.lastUpdated.salesOrders = new Date().toISOString();
})
.addCase(fetchSalesOrders.rejected, (state, action) => {
state.loading.salesOrders = false;
state.errors.salesOrders = action.error.message || 'Failed to fetch sales orders';
})
// Fetch purchase orders
.addCase(fetchPurchaseOrders.pending, (state) => {
state.loading.purchaseOrders = true;
state.errors.purchaseOrders = null;
})
.addCase(fetchPurchaseOrders.fulfilled, (state, action) => {
state.loading.purchaseOrders = false;
state.purchaseOrders = action.payload.data || [];
state.pagination.purchaseOrders = action.payload.info || { page: 1, count: 0, moreRecords: false };
state.lastUpdated.purchaseOrders = new Date().toISOString();
})
.addCase(fetchPurchaseOrders.rejected, (state, action) => {
state.loading.purchaseOrders = false;
state.errors.purchaseOrders = action.error.message || 'Failed to fetch purchase orders';
})
// Fetch invoices
.addCase(fetchInvoices.pending, (state) => {
state.loading.invoices = true;
state.errors.invoices = null;
})
.addCase(fetchInvoices.fulfilled, (state, action) => {
state.loading.invoices = false;
state.invoices = action.payload.data || [];
state.pagination.invoices = action.payload.info || { page: 1, count: 0, moreRecords: false };
state.lastUpdated.invoices = new Date().toISOString();
})
.addCase(fetchInvoices.rejected, (state, action) => {
state.loading.invoices = false;
state.errors.invoices = action.error.message || 'Failed to fetch invoices';
})
// 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.loading.salesOrders = true;
state.loading.purchaseOrders = true;
state.loading.invoices = true;
state.errors.leads = null;
state.errors.tasks = null;
state.errors.contacts = null;
state.errors.deals = null;
state.errors.salesOrders = null;
state.errors.purchaseOrders = null;
state.errors.invoices = null;
})
.addCase(fetchAllCrmData.fulfilled, (state, action) => {
const { leads, tasks, contacts, deals } = action.payload;
const { leads, tasks, contacts, deals, salesOrders, purchaseOrders, invoices } = action.payload;
state.loading.leads = false;
state.loading.tasks = false;
state.loading.contacts = false;
state.loading.deals = false;
state.loading.salesOrders = false;
state.loading.purchaseOrders = false;
state.loading.invoices = false;
state.leads = leads.data || [];
state.tasks = tasks.data || [];
state.contacts = contacts.data || [];
state.deals = deals.data || [];
state.salesOrders = salesOrders.data || [];
state.purchaseOrders = purchaseOrders.data || [];
state.invoices = invoices.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 };
state.pagination.salesOrders = salesOrders.info || { page: 1, count: 0, moreRecords: false };
state.pagination.purchaseOrders = purchaseOrders.info || { page: 1, count: 0, moreRecords: false };
state.pagination.invoices = invoices.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;
state.lastUpdated.salesOrders = now;
state.lastUpdated.purchaseOrders = now;
state.lastUpdated.invoices = now;
})
.addCase(fetchAllCrmData.rejected, (state, action) => {
state.loading.leads = false;
state.loading.tasks = false;
state.loading.contacts = false;
state.loading.deals = false;
state.loading.salesOrders = false;
state.loading.purchaseOrders = false;
state.loading.invoices = 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;
state.errors.salesOrders = errorMessage;
state.errors.purchaseOrders = errorMessage;
state.errors.invoices = errorMessage;
});
},
});
@ -304,6 +462,9 @@ export const {
setTasksPage,
setContactsPage,
setDealsPage,
setSalesOrdersPage,
setPurchaseOrdersPage,
setInvoicesPage,
} = crmSlice.actions;
export default crmSlice.reducer;

View File

@ -9,6 +9,9 @@ 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 selectSalesOrders = (state: RootState) => state.crm.salesOrders;
export const selectPurchaseOrders = (state: RootState) => state.crm.purchaseOrders;
export const selectInvoices = (state: RootState) => state.crm.invoices;
export const selectCrmLoading = (state: RootState) => state.crm.loading;
export const selectCrmErrors = (state: RootState) => state.crm.errors;
@ -19,22 +22,31 @@ 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;
export const selectSalesOrdersLoading = (state: RootState) => state.crm.loading.salesOrders;
export const selectPurchaseOrdersLoading = (state: RootState) => state.crm.loading.purchaseOrders;
export const selectInvoicesLoading = (state: RootState) => state.crm.loading.invoices;
// 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;
export const selectSalesOrdersError = (state: RootState) => state.crm.errors.salesOrders;
export const selectPurchaseOrdersError = (state: RootState) => state.crm.errors.purchaseOrders;
export const selectInvoicesError = (state: RootState) => state.crm.errors.invoices;
// Computed selectors for dashboard
export const selectCrmStats = createSelector(
[selectLeads, selectTasks, selectContacts, selectDeals],
(leads, tasks, contacts, deals): CrmStats => {
[selectLeads, selectTasks, selectContacts, selectDeals, selectSalesOrders, selectPurchaseOrders, selectInvoices],
(leads, tasks, contacts, deals, salesOrders, purchaseOrders, invoices): 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 : [];
const safeSalesOrders = Array.isArray(salesOrders) ? salesOrders : [];
const safePurchaseOrders = Array.isArray(purchaseOrders) ? purchaseOrders : [];
const safeInvoices = Array.isArray(invoices) ? invoices : [];
// Calculate leads stats with safe property access and better fallbacks
const leadsByStatus = safeLeads.reduce((acc, lead) => {
@ -84,6 +96,143 @@ export const selectCrmStats = createSelector(
const wonDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Won');
const lostDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Lost');
// Calculate sales orders stats
const salesOrdersByStatus = safeSalesOrders.reduce((acc, order) => {
const status = order?.Status && order.Status.trim() !== '' ? order.Status : 'Not Set';
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const salesOrdersByCountry = safeSalesOrders.reduce((acc, order) => {
const country = order?.Billing_Country && order.Billing_Country.trim() !== '' ? order.Billing_Country : 'Not Set';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const totalSalesOrderValue = safeSalesOrders.reduce((sum, order) => {
const amount = order?.Grand_Total || 0;
return sum + (typeof amount === 'number' && !isNaN(amount) ? amount : 0);
}, 0);
// Calculate purchase orders stats
const purchaseOrdersByStatus = safePurchaseOrders.reduce((acc, order) => {
const status = order?.Status && order.Status.trim() !== '' ? order.Status : 'Not Set';
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const purchaseOrdersByVendor = safePurchaseOrders.reduce((acc, order) => {
const vendor = order?.Vendor_Name?.name || 'Unknown';
acc[vendor] = (acc[vendor] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const totalPurchaseOrderValue = safePurchaseOrders.reduce((sum, order) => {
const amount = order?.Grand_Total || 0;
return sum + (typeof amount === 'number' && !isNaN(amount) ? amount : 0);
}, 0);
// Calculate invoices stats
const invoicesByStatus = safeInvoices.reduce((acc, invoice) => {
const status = invoice?.Status && invoice.Status.trim() !== '' ? invoice.Status : 'Not Set';
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const totalInvoiceValue = safeInvoices.reduce((sum, invoice) => {
const amount = invoice?.Grand_Total || 0;
return sum + (typeof amount === 'number' && !isNaN(amount) ? amount : 0);
}, 0);
const overdueInvoices = safeInvoices.filter(invoice => {
if (!invoice?.Due_Date) return false;
return new Date(invoice.Due_Date) < new Date() && invoice.Status !== 'Paid';
});
const paidInvoices = safeInvoices.filter(invoice => invoice?.Status === 'Paid');
// Calculate Customer & Sales KPIs
const calculateCustomerKPIs = () => {
// 1. Sales Cycle Length - Average days from deal creation to close
const closedDeals = safeDeals.filter(deal =>
deal?.Stage === 'Closed Won' && deal?.Created_Time && deal?.Modified_Time
);
const salesCycleLength = closedDeals.length > 0
? closedDeals.reduce((total, deal) => {
const created = new Date(deal.Created_Time);
const closed = new Date(deal.Modified_Time);
const daysDiff = Math.ceil((closed.getTime() - created.getTime()) / (1000 * 60 * 60 * 24));
return total + daysDiff;
}, 0) / closedDeals.length
: 0;
// 2. Average Revenue Per Account - Total deal value / unique accounts
const uniqueAccounts = new Set(
safeDeals
.filter(deal => deal?.Account_Name?.name)
.map(deal => deal.Account_Name.name)
);
const totalDealValue = safeDeals.reduce((sum, deal) => sum + (deal?.Amount || 0), 0);
const averageRevenuePerAccount = uniqueAccounts.size > 0
? totalDealValue / uniqueAccounts.size
: 0;
// 3. Churn Rate - Lost leads / total leads (using lead status)
const lostLeads = safeLeads.filter(lead =>
lead?.Lead_Status === 'Lost Lead' || lead?.Lead_Status === 'Unqualified'
);
const churnRate = safeLeads.length > 0
? (lostLeads.length / safeLeads.length) * 100
: 0;
// 4. Customer Lifetime Value - Average deal value per customer
const wonDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Won');
const totalWonValue = wonDeals.reduce((sum, deal) => sum + (deal?.Amount || 0), 0);
const customerLifetimeValue = uniqueAccounts.size > 0
? totalWonValue / uniqueAccounts.size
: 0;
// 5. LTV-to-CAC Ratio (estimated - using average deal value as proxy for LTV)
// Assuming CAC is 20% of average deal value (this would need real marketing spend data)
const estimatedCAC = customerLifetimeValue * 0.2;
const ltvToCacRatio = estimatedCAC > 0
? customerLifetimeValue / estimatedCAC
: 0;
// 6. Customer Retention Rate
const customerRetentionRate = 100 - churnRate;
// 7. Average Deal Size
const averageDealSize = safeDeals.length > 0
? totalDealValue / safeDeals.length
: 0;
// 8. Conversion Rate - Converted leads / total leads
const convertedLeads = safeLeads.filter(lead => lead?.Lead_Status === 'Converted');
const conversionRate = safeLeads.length > 0
? (convertedLeads.length / safeLeads.length) * 100
: 0;
// 9. Win Rate - Won deals / total deals
const winRate = safeDeals.length > 0
? (wonDeals.length / safeDeals.length) * 100
: 0;
return {
salesCycleLength: Math.round(salesCycleLength),
averageRevenuePerAccount: Math.round(averageRevenuePerAccount),
churnRate: Math.round(churnRate * 100) / 100,
customerLifetimeValue: Math.round(customerLifetimeValue),
ltvToCacRatio: Math.round(ltvToCacRatio * 100) / 100,
customerRetentionRate: Math.round(customerRetentionRate * 100) / 100,
averageDealSize: Math.round(averageDealSize),
conversionRate: Math.round(conversionRate * 100) / 100,
winRate: Math.round(winRate * 100) / 100,
};
};
const customerKPIs = calculateCustomerKPIs();
return {
leads: {
total: safeLeads.length,
@ -119,6 +268,29 @@ export const selectCrmStats = createSelector(
.filter(deal => !['Closed Won', 'Closed Lost'].includes(deal.Stage))
.reduce((sum, deal) => sum + (deal.Amount || 0), 0),
},
salesOrders: {
total: safeSalesOrders.length,
totalValue: totalSalesOrderValue,
averageOrderValue: safeSalesOrders.length > 0 ? totalSalesOrderValue / safeSalesOrders.length : 0,
byStatus: salesOrdersByStatus,
byCountry: salesOrdersByCountry,
},
purchaseOrders: {
total: safePurchaseOrders.length,
totalValue: totalPurchaseOrderValue,
averageOrderValue: safePurchaseOrders.length > 0 ? totalPurchaseOrderValue / safePurchaseOrders.length : 0,
byStatus: purchaseOrdersByStatus,
byVendor: purchaseOrdersByVendor,
},
invoices: {
total: safeInvoices.length,
totalValue: totalInvoiceValue,
averageInvoiceValue: safeInvoices.length > 0 ? totalInvoiceValue / safeInvoices.length : 0,
byStatus: invoicesByStatus,
overdue: overdueInvoices.length,
paid: paidInvoices.length,
},
customerKPIs,
};
}
);

View File

@ -147,6 +147,339 @@ export interface CrmDeal {
Tag: string[];
}
export interface CrmSalesOrder {
id: string;
Owner: {
name: string;
id: string;
email: string;
};
$currency_symbol: string;
Customer_No?: string;
Tax: number;
Last_Activity_Time?: string;
$state: string;
$converted: boolean;
$process_flow: boolean;
Deal_Name: {
name: string;
id: string;
};
Billing_Country: string;
$locked_for_me: boolean;
Carrier: string;
$approved: boolean;
Quote_Name?: string;
Status: string;
Grand_Total: number;
$approval: {
delegate: boolean;
takeover: boolean;
approve: boolean;
reject: boolean;
resubmit: boolean;
};
Adjustment: number;
Billing_Street: string;
Created_Time: string;
$editable: boolean;
Billing_Code: string;
Product_Details: Array<{
product: {
Product_Code: string;
name: string;
id: string;
};
quantity: number;
Discount: number;
total_after_discount: number;
net_total: number;
book?: string;
Tax: number;
list_price: number;
unit_price?: number;
quantity_in_stock: number;
total: number;
id: string;
product_description?: string;
line_tax: any[];
}>;
Excise_Duty?: number;
Shipping_City?: string;
Shipping_Country?: string;
Shipping_Code?: string;
Billing_City: string;
Purchase_Order?: string;
Created_By: {
name: string;
id: string;
email: string;
};
Shipping_Street?: string;
Description?: string;
Discount: number;
Shipping_State?: string;
$review_process: {
approve: boolean;
reject: boolean;
resubmit: boolean;
};
$layout_id: {
display_label: string;
name: string;
id: string;
};
Modified_By: {
name: string;
id: string;
email: string;
};
$review?: any;
Account_Name: {
name: string;
id: string;
};
Sales_Commission?: number;
Modified_Time: string;
Due_Date?: string;
Terms_and_Conditions?: string;
Sub_Total: number;
Subject: string;
$orchestration: boolean;
Contact_Name: {
name: string;
id: string;
};
$in_merge: boolean;
SO_Number: string;
Locked__s: boolean;
Billing_State: string;
$line_tax: any[];
Tag: string[];
$approval_state: string;
Pending?: any;
}
export interface CrmPurchaseOrder {
id: string;
Owner: {
name: string;
id: string;
email: string;
};
$currency_symbol: string;
Tax: number;
Last_Activity_Time?: string;
PO_Date: string;
$state: string;
$process_flow: boolean;
Billing_Country: string;
$locked_for_me: boolean;
Carrier: string;
$approved: boolean;
Status: string;
Grand_Total: number;
$approval: {
delegate: boolean;
takeover: boolean;
approve: boolean;
reject: boolean;
resubmit: boolean;
};
PO_Number?: string;
Adjustment: number;
Billing_Street: string;
Created_Time: string;
$editable: boolean;
Billing_Code: string;
Product_Details: Array<{
product: {
Product_Code: string;
name: string;
id: string;
};
quantity: number;
Discount: number;
total_after_discount: number;
net_total: number;
book?: string;
Tax: number;
list_price: number;
unit_price: number;
quantity_in_stock: number;
total: number;
id: string;
product_description?: string;
line_tax: any[];
}>;
Tracking_Number?: string;
Excise_Duty?: number;
Shipping_City?: string;
Shipping_Country?: string;
Shipping_Code?: string;
Billing_City: string;
Requisition_No?: string;
Created_By: {
name: string;
id: string;
email: string;
};
Shipping_Street?: string;
Description?: string;
Discount: number;
Vendor_Name: {
name: string;
id: string;
};
Shipping_State?: string;
$review_process: {
approve: boolean;
reject: boolean;
resubmit: boolean;
};
$layout_id: {
display_label: string;
name: string;
id: string;
};
Modified_By: {
name: string;
id: string;
email: string;
};
$review?: any;
Sales_Commission?: number;
Modified_Time: string;
Due_Date?: string;
Terms_and_Conditions?: string;
Sub_Total: number;
Subject: string;
$orchestration: boolean;
Contact_Name?: {
name: string;
id: string;
};
$in_merge: boolean;
Locked__s: boolean;
Billing_State: string;
$line_tax: any[];
Tag: string[];
$approval_state: string;
}
export interface CrmInvoice {
id: string;
Owner: {
name: string;
id: string;
email: string;
};
$currency_symbol: string;
Tax: number;
Last_Activity_Time?: string;
$state: string;
$process_flow: boolean;
Billing_Country: string;
$locked_for_me: boolean;
$approved: boolean;
Status: string;
Grand_Total: number;
$approval: {
delegate: boolean;
takeover: boolean;
approve: boolean;
reject: boolean;
resubmit: boolean;
};
Adjustment: number;
Billing_Street: string;
Created_Time: string;
$editable: boolean;
Billing_Code: string;
Product_Details: Array<{
product: {
Product_Code: string;
name: string;
id: string;
};
quantity: number;
Discount: number;
total_after_discount: number;
net_total: number;
book?: string;
Tax: number;
list_price: number;
unit_price?: number;
quantity_in_stock: number;
total: number;
id: string;
product_description?: string;
line_tax: any[];
}>;
Excise_Duty?: number;
Shipping_City?: string;
Shipping_Country?: string;
Shipping_Code?: string;
Billing_City: string;
Purchase_Order?: string;
Created_By: {
name: string;
id: string;
email: string;
};
Shipping_Street?: string;
Description?: string;
Discount: number;
Shipping_State?: string;
$review_process: {
approve: boolean;
reject: boolean;
resubmit: boolean;
};
$layout_id: {
display_label: string;
name: string;
id: string;
};
Invoice_Date: string;
Modified_By: {
name: string;
id: string;
email: string;
};
$review?: any;
Account_Name: {
name: string;
id: string;
};
Sales_Order: {
name: string;
id: string;
};
Deal_Name__s: {
name: string;
id: string;
};
Sales_Commission?: number;
Modified_Time: string;
Due_Date: string;
Terms_and_Conditions?: string;
Sub_Total: number;
Invoice_Number: string;
Subject: string;
$orchestration: boolean;
Contact_Name: {
name: string;
id: string;
};
$in_merge: boolean;
Locked__s: boolean;
Billing_State: string;
$line_tax: any[];
Tag: string[];
$approval_state: 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';
@ -162,6 +495,9 @@ export interface CrmData {
tasks: CrmTask[];
contacts: CrmContact[];
deals: CrmDeal[];
salesOrders: CrmSalesOrder[];
purchaseOrders: CrmPurchaseOrder[];
invoices: CrmInvoice[];
}
// API Response Types
@ -264,6 +600,40 @@ export interface CrmStats {
byStage: Record<DealStage, number>;
pipelineValue: number;
};
salesOrders: {
total: number;
totalValue: number;
averageOrderValue: number;
byStatus: Record<string, number>;
byCountry: Record<string, number>;
};
purchaseOrders: {
total: number;
totalValue: number;
averageOrderValue: number;
byStatus: Record<string, number>;
byVendor: Record<string, number>;
};
invoices: {
total: number;
totalValue: number;
averageInvoiceValue: number;
byStatus: Record<string, number>;
overdue: number;
paid: number;
};
// Customer & Sales KPIs
customerKPIs: {
salesCycleLength: number; // Average days from first touch to close
averageRevenuePerAccount: number; // Total recurring revenue / total customers
churnRate: number; // Lost customers / start-of-period customers
customerLifetimeValue: number; // Average gross profit per customer
ltvToCacRatio: number; // LTV / CAC (estimated)
customerRetentionRate: number; // (1 - churn rate) * 100
averageDealSize: number; // Average deal amount
conversionRate: number; // Converted leads / total leads
winRate: number; // Won deals / total deals
};
}
// Form Types for Creating/Editing CRM Records

View File

@ -5,7 +5,11 @@ import { useTheme } from '@/shared/styles/useTheme';
import type { StackNavigationProp } from '@react-navigation/stack';
import type { IntegrationsStackParamList } from '@/modules/integrations/navigation/IntegrationsNavigator';
import { useNavigation } from '@react-navigation/native';
import { useDispatch } from 'react-redux';
import GradientBackground from '@/shared/components/layout/GradientBackground';
import { Container, ConfirmModal } from '@/shared/components/ui';
import { logout } from '@/modules/auth/store/authSlice';
import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice';
type Nav = StackNavigationProp<IntegrationsStackParamList, 'IntegrationsHome'>;
@ -30,12 +34,32 @@ interface Props {
const IntegrationsHomeScreen: React.FC<Props> = () => {
const { colors, shadows, fonts } = useTheme();
const navigation = useNavigation();
const dispatch = useDispatch();
const [showLogout, setShowLogout] = React.useState(false);
const handleLogout = () => setShowLogout(true);
const handleConfirmLogout = () => {
setShowLogout(false);
dispatch(clearSelectedService());
dispatch(logout());
};
const handleCancelLogout = () => setShowLogout(false);
return (
<GradientBackground colors={['#FFE9CC', '#F6E6FF']} style={{flex:1}}>
<View style={[styles.container]}>
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Choose a Service</Text>
{/* Header with title and logout button */}
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Choose a Service</Text>
<TouchableOpacity
style={[styles.logoutButton, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={handleLogout}
activeOpacity={0.8}
>
<Icon name="logout" size={20} color={colors.text} />
</TouchableOpacity>
</View>
<FlatList
data={categories}
keyExtractor={item => item.key}
@ -62,6 +86,17 @@ const IntegrationsHomeScreen: React.FC<Props> = () => {
)}
/>
</View>
{/* Logout Confirmation Modal */}
<ConfirmModal
visible={showLogout}
title="Logout"
message="Are you sure you want to logout?"
confirmText="Logout"
cancelText="Cancel"
onConfirm={handleConfirmLogout}
onCancel={handleCancelLogout}
/>
</GradientBackground>
);
};
@ -70,12 +105,27 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
title: {
fontSize: 20,
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 12,
marginHorizontal: GUTTER,
marginBottom: 4,
},
title: {
fontSize: 20,
flex: 1,
},
logoutButton: {
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 12,
},
card: {
borderRadius: 12,
borderWidth: 1,

View File

@ -71,6 +71,8 @@ const getScopeForService = (_serviceKey?: ServiceKey): string => {
// Zoho CRM (adjust modules per your needs)
'ZohoCRM.users.READ',
'ZohoCRM.modules.READ',
//settings
'ZohoCRM.settings.READ',
// Zoho Books (use granular scopes if preferred instead of FullAccess)
'ZohoBooks.FullAccess.READ',
// Zoho People

View File

@ -0,0 +1,559 @@
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 {
ZohoProject,
ZohoTask,
ZohoIssue,
ZohoPhase
} from '../types/ZohoProjectsTypes';
// Project Card Component
interface ProjectCardProps {
project: ZohoProject;
onPress: () => void;
}
export const ProjectCard: React.FC<ProjectCardProps> = ({ project, onPress }) => {
const { colors, fonts, spacing, shadows } = useTheme();
const getStatusColor = (status: string) => {
const statusColors: Record<string, string> = {
'Active': '#10B981',
'Completed': '#3B82F6',
'On Hold': '#F59E0B',
'Cancelled': '#EF4444',
'Planning': '#8B5CF6',
};
return statusColors[status] || '#6B7280';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface, ...shadows.light }]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<View style={styles.titleContainer}>
<Icon name="folder" size={20} color={colors.primary} />
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.medium }]}>
{project.name}
</Text>
</View>
<View
style={[
styles.statusBadge,
{ backgroundColor: getStatusColor(project.status?.name || 'Unknown') },
]}
>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
{project.status?.name || 'Unknown'}
</Text>
</View>
</View>
<View style={styles.cardContent}>
<View style={styles.infoRow}>
<Icon name="account" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Owner: {project.owner?.full_name || project.owner?.name || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="tag" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Type: {project.project_type || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="calendar" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Start: {formatDate(project.start_date)}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="calendar-end" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
End: {formatDate(project.end_date)}
</Text>
</View>
<View style={styles.progressContainer}>
<View style={styles.progressInfo}>
<Text style={[styles.progressLabel, { color: colors.text, fontFamily: fonts.medium }]}>
Progress
</Text>
<Text style={[styles.progressValue, { color: colors.primary, fontFamily: fonts.bold }]}>
{project.percent_complete || 0}%
</Text>
</View>
<View style={[styles.progressBar, { backgroundColor: colors.background }]}>
<View
style={[
styles.progressFill,
{
width: `${project.percent_complete || 0}%`,
backgroundColor: colors.primary,
},
]}
/>
</View>
</View>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Icon name="check-circle" size={16} color="#10B981" />
<Text style={[styles.statText, { color: colors.text, fontFamily: fonts.regular }]}>
{project.tasks?.closed_count || 0} Tasks
</Text>
</View>
<View style={styles.statItem}>
<Icon name="alert-circle" size={16} color="#F59E0B" />
<Text style={[styles.statText, { color: colors.text, fontFamily: fonts.regular }]}>
{project.issues?.open_count || 0} Issues
</Text>
</View>
<View style={styles.statItem}>
<Icon name="flag" size={16} color="#3B82F6" />
<Text style={[styles.statText, { color: colors.text, fontFamily: fonts.regular }]}>
{project.milestones?.open_count || 0} Milestones
</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
};
// Task Card Component
interface TaskCardProps {
task: ZohoTask;
onPress: () => void;
}
export const TaskCard: React.FC<TaskCardProps> = ({ task, onPress }) => {
const { colors, fonts, spacing, shadows } = useTheme();
const getPriorityColor = (priority: string) => {
const priorityColors: Record<string, string> = {
'high': '#EF4444',
'urgent': '#DC2626',
'medium': '#F59E0B',
'low': '#10B981',
'normal': '#3B82F6',
};
return priorityColors[priority?.toLowerCase()] || '#6B7280';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
};
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface, ...shadows.light }]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<View style={styles.titleContainer}>
<Icon name="check-circle" size={20} color={colors.primary} />
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.medium }]}>
{task.name}
</Text>
</View>
<View
style={[
styles.priorityBadge,
{ backgroundColor: getPriorityColor(task.priority || 'normal') },
]}
>
<Text style={[styles.priorityText, { color: colors.surface, fontFamily: fonts.medium }]}>
{task.priority?.toUpperCase() || 'NORMAL'}
</Text>
</View>
</View>
<View style={styles.cardContent}>
<View style={styles.infoRow}>
<Icon name="folder" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Project: {task.project?.name || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="account" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Owner: {task.owners_and_work?.owners?.[0]?.name || 'Unassigned'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="calendar" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Due: {formatDate(task.end_date)}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="flag" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Status: {task.status?.name || 'Unknown'}
</Text>
</View>
<View style={styles.progressContainer}>
<View style={styles.progressInfo}>
<Text style={[styles.progressLabel, { color: colors.text, fontFamily: fonts.medium }]}>
Completion
</Text>
<Text style={[styles.progressValue, { color: colors.primary, fontFamily: fonts.bold }]}>
{task.completion_percentage || 0}%
</Text>
</View>
<View style={[styles.progressBar, { backgroundColor: colors.background }]}>
<View
style={[
styles.progressFill,
{
width: `${task.completion_percentage || 0}%`,
backgroundColor: colors.primary,
},
]}
/>
</View>
</View>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Icon name="clock" size={16} color="#3B82F6" />
<Text style={[styles.statText, { color: colors.text, fontFamily: fonts.regular }]}>
{task.duration?.value || '0h'}
</Text>
</View>
<View style={styles.statItem}>
<Icon name="currency-usd" size={16} color="#10B981" />
<Text style={[styles.statText, { color: colors.text, fontFamily: fonts.regular }]}>
{task.billing_type || 'Non-billable'}
</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
};
// Issue Card Component
interface IssueCardProps {
issue: ZohoIssue;
onPress: () => void;
}
export const IssueCard: React.FC<IssueCardProps> = ({ issue, onPress }) => {
const { colors, fonts, spacing, shadows } = useTheme();
const getSeverityColor = (severity: string) => {
const severityColors: Record<string, string> = {
'Critical': '#DC2626',
'High': '#EF4444',
'Medium': '#F59E0B',
'Low': '#10B981',
'None': '#6B7280',
};
return severityColors[severity] || '#6B7280';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
};
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface, ...shadows.light }]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<View style={styles.titleContainer}>
<Icon name="alert-circle" size={20} color={colors.primary} />
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.medium }]}>
{issue.name}
</Text>
</View>
<View
style={[
styles.severityBadge,
{ backgroundColor: getSeverityColor(issue.severity?.value || 'None') },
]}
>
<Text style={[styles.severityText, { color: colors.surface, fontFamily: fonts.medium }]}>
{issue.severity?.value?.toUpperCase() || 'NONE'}
</Text>
</View>
</View>
<View style={styles.cardContent}>
<View style={styles.infoRow}>
<Icon name="folder" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Project: {issue.project?.name || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="account" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Assignee: {issue.assignee?.name || 'Unassigned'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="calendar" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Created: {formatDate(issue.created_time)}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="flag" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Status: {issue.status?.name || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="tag" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Classification: {issue.classification?.value || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="cog" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Module: {issue.module?.value || 'Unknown'}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
// Phase Card Component
interface PhaseCardProps {
phase: ZohoPhase;
onPress: () => void;
}
export const PhaseCard: React.FC<PhaseCardProps> = ({ phase, onPress }) => {
const { colors, fonts, spacing, shadows } = useTheme();
const getStatusColor = (status: string) => {
const statusColors: Record<string, string> = {
'Active': '#10B981',
'Completed': '#3B82F6',
'On Hold': '#F59E0B',
'Cancelled': '#EF4444',
'Planning': '#8B5CF6',
};
return statusColors[status] || '#6B7280';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.surface, ...shadows.light }]}
onPress={onPress}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<View style={styles.titleContainer}>
<Icon name="timeline" size={20} color={colors.primary} />
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.medium }]}>
{phase.name}
</Text>
</View>
<View
style={[
styles.statusBadge,
{ backgroundColor: getStatusColor(phase.status?.name || 'Unknown') },
]}
>
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
{phase.status?.name || 'Unknown'}
</Text>
</View>
</View>
<View style={styles.cardContent}>
<View style={styles.infoRow}>
<Icon name="folder" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Project: {phase.project?.name || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="account" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Owner: {`${phase.owner?.first_name} ${phase.owner?.last_name}` || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="calendar" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Start: {formatDate(phase.start_date)}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="calendar-end" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
End: {formatDate(phase.end_date)}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="tag" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Type: {phase.status_type || 'Unknown'}
</Text>
</View>
<View style={styles.infoRow}>
<Icon name="flag" size={16} color={colors.textLight} />
<Text style={[styles.infoText, { color: colors.textLight, fontFamily: fonts.regular }]}>
Flag: {phase.flag || 'None'}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
card: {
borderRadius: 12,
padding: 16,
marginBottom: 12,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
title: {
fontSize: 16,
marginLeft: 8,
flex: 1,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 12,
},
priorityBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
priorityText: {
fontSize: 12,
},
severityBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
severityText: {
fontSize: 12,
},
cardContent: {
gap: 8,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
},
infoText: {
fontSize: 14,
marginLeft: 8,
flex: 1,
},
progressContainer: {
marginTop: 8,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 4,
},
progressLabel: {
fontSize: 14,
},
progressValue: {
fontSize: 14,
},
progressBar: {
height: 6,
borderRadius: 3,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 3,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginTop: 8,
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
},
statItem: {
flexDirection: 'row',
alignItems: 'center',
},
statText: {
fontSize: 12,
marginLeft: 4,
},
});

View File

@ -1,12 +1,14 @@
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import ZohoProjectsDashboardScreen from '@/modules/zohoProjects/screens/ZohoProjectsDashboardScreen';
import ZohoProjectsDataScreen from '@/modules/zohoProjects/screens/ZohoProjectsDataScreen';
const Stack = createStackNavigator();
const ZohoProjectsNavigator = () => (
<Stack.Navigator>
<Stack.Screen name="ZohoProjectsDashboard" component={ZohoProjectsDashboardScreen} options={{headerShown:false}}/>
<Stack.Screen name="ZohoProjectsData" component={ZohoProjectsDataScreen} options={{headerShown:false}}/>
</Stack.Navigator>
);

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, RefreshControl, StyleSheet } from 'react-native';
import { View, Text, ScrollView, RefreshControl, StyleSheet, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui';
import { useTheme } from '@/shared/styles/useTheme';
@ -25,6 +26,7 @@ import type { RootState } from '@/store/store';
const ZohoProjectsDashboardScreen: React.FC = () => {
const { colors, fonts } = useTheme();
const dispatch = useDispatch();
const navigation = useNavigation();
const [refreshing, setRefreshing] = useState(false);
// Redux state
@ -44,6 +46,10 @@ const ZohoProjectsDashboardScreen: React.FC = () => {
setRefreshing(false);
};
const handleNavigateToData = () => {
navigation.navigate('ZohoProjectsData' as never);
};
// Loading state
if (isLoading && stats.totalProjects === 0) {
return <LoadingSpinner />;
@ -114,7 +120,16 @@ const ZohoProjectsDashboardScreen: React.FC = () => {
>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Zoho Projects</Text>
<Icon name="insights" size={24} color={colors.primary} />
<View style={styles.headerActions}>
<TouchableOpacity
onPress={handleNavigateToData}
style={styles.dataButton}
activeOpacity={0.8}
>
<Icon name="list" size={24} color={colors.primary} />
</TouchableOpacity>
<Icon name="insights" size={24} color={colors.primary} />
</View>
</View>
<View style={styles.content}>
@ -399,6 +414,16 @@ const styles = StyleSheet.create({
padding: 16,
backgroundColor: '#FFFFFF',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
dataButton: {
padding: 8,
borderRadius: 8,
backgroundColor: '#F3F4F6',
},
title: {
fontSize: 24,
},

View File

@ -0,0 +1,346 @@
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 {
ZohoProject,
ZohoTask,
ZohoIssue,
ZohoPhase
} from '../types/ZohoProjectsTypes';
import {
ProjectCard,
TaskCard,
IssueCard,
PhaseCard
} from '../components/ZohoProjectsDataCards';
import {
selectZohoProjects,
selectZohoTasks,
selectZohoIssues,
selectZohoPhases,
selectZohoProjectsLoading,
selectZohoProjectsErrors
} from '../store/selectors';
import { fetchAllZohoProjectsData } from '../store/zohoProjectsSlice';
import type { RootState } from '@/store/store';
const ZohoProjectsDataScreen: React.FC = () => {
const { colors, fonts, spacing, shadows } = useTheme();
const dispatch = useDispatch<AppDispatch>();
const [selectedTab, setSelectedTab] = useState<'projects' | 'tasks' | 'issues' | 'phases'>('projects');
const [refreshing, setRefreshing] = useState(false);
// Redux selectors
const projects = useSelector(selectZohoProjects);
const tasks = useSelector(selectZohoTasks);
const issues = useSelector(selectZohoIssues);
const phases = useSelector(selectZohoPhases);
const loading = useSelector(selectZohoProjectsLoading);
const errors = useSelector(selectZohoProjectsErrors);
// Create Zoho Projects data object from Redux state
const zohoProjectsData = useMemo(() => ({
projects: projects || [],
tasks: tasks || [],
issues: issues || [],
phases: phases || [],
}), [projects, tasks, issues, phases]);
// Fetch Zoho Projects data using Redux
const fetchZohoProjectsData = async (showRefresh = false) => {
try {
if (showRefresh) {
setRefreshing(true);
}
// Dispatch Redux action to fetch all Zoho Projects data
await dispatch(fetchAllZohoProjectsData({ refresh: showRefresh })).unwrap();
if (showRefresh) {
showSuccess('Zoho Projects data refreshed successfully');
}
} catch (err) {
const errorMessage = 'Failed to fetch Zoho Projects data';
showError(errorMessage);
} finally {
setRefreshing(false);
}
};
useEffect(() => {
fetchZohoProjectsData();
}, []);
const handleRefresh = () => {
fetchZohoProjectsData(true);
};
const handleRetry = () => {
fetchZohoProjectsData();
};
const handleCardPress = (item: any, type: string) => {
showInfo(`Viewing ${type}: ${item.name || item.prefix + item.name || 'Unknown'}`);
};
// Get current loading state and error
const isLoading = loading.projects || loading.tasks || loading.issues || loading.phases;
const hasError = errors.projects || errors.tasks || errors.issues || errors.phases;
// Tab configuration
const tabs = [
{
key: 'projects',
label: 'Projects',
icon: 'folder-multiple',
count: zohoProjectsData.projects.length
},
{
key: 'tasks',
label: 'Tasks',
icon: 'check-circle',
count: zohoProjectsData.tasks.length
},
{
key: 'issues',
label: 'Issues',
icon: 'alert-circle',
count: zohoProjectsData.issues.length
},
{
key: 'phases',
label: 'Phases',
icon: 'timeline',
count: zohoProjectsData.phases.length
},
] as const;
if (isLoading && !zohoProjectsData.projects.length) {
return <LoadingSpinner />;
}
if (hasError && !zohoProjectsData.projects.length) {
return <ErrorState onRetry={handleRetry} />;
}
const renderTabContent = () => {
switch (selectedTab) {
case 'projects':
return (
<FlatList
data={zohoProjectsData.projects}
renderItem={({ item }) => (
<ProjectCard
project={item}
onPress={() => handleCardPress(item, 'Project')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
case 'tasks':
return (
<FlatList
data={zohoProjectsData.tasks}
renderItem={({ item }) => (
<TaskCard
task={item}
onPress={() => handleCardPress(item, 'Task')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
case 'issues':
return (
<FlatList
data={zohoProjectsData.issues}
renderItem={({ item }) => (
<IssueCard
issue={item}
onPress={() => handleCardPress(item, 'Issue')}
/>
)}
keyExtractor={(item) => item.id}
numColumns={1}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
/>
);
case 'phases':
return (
<FlatList
data={zohoProjectsData.phases}
renderItem={({ item }) => (
<PhaseCard
phase={item}
onPress={() => handleCardPress(item, 'Phase')}
/>
)}
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 Projects 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,
{ backgroundColor: colors.surface, ...shadows.light },
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,
},
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 ZohoProjectsDataScreen;

View File

@ -79,6 +79,7 @@ export const fetchAllZohoProjectsData = createAsyncThunk(
async (params: { refresh?: boolean } = {}, { dispatch }) => {
// First fetch projects
const projectsResponse = await dispatch(fetchZohoProjects(params));
console.log('projectsResponse',projectsResponse)
if (fetchZohoProjects.fulfilled.match(projectsResponse) && projectsResponse.payload) {
const projects = projectsResponse.payload.data.data;

View File

@ -1,10 +1,15 @@
import { create } from 'apisauce';
import { store } from '@/store/store';
import { selectAccessToken } from '@/modules/auth/store/selectors';
import { selectAccessToken ,selectRefreshToken } from '@/modules/auth/store/selectors';
import { refreshToken, logout } from '@/modules/auth/store/authSlice';
import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice';
// Store for pending requests that need to be retried
let pendingRequest: any = null;
const http = create({
baseURL: 'http://192.168.1.12:4000',
// baseURL: 'http://160.187.167.216',
// baseURL: 'http://192.168.1.24:4000',
baseURL: 'http://160.187.167.216',
timeout: 10000,
});
@ -28,6 +33,7 @@ http.addRequestTransform((request) => {
const state = store.getState();
const token = selectAccessToken(state);
const refreshToken=selectRefreshToken(state)
if (token) {
request.headers = {
@ -40,11 +46,130 @@ http.addRequestTransform((request) => {
});
// Add response interceptor for error handling
http.addResponseTransform((response) => {
http.addResponseTransform(async (response) => {
console.log('unauthorized response',response)
if (response.status === 401) {
console.warn('Unauthorized request - token may be expired');
// You could dispatch a logout action here if needed
// dispatch(logout());
// Skip refresh token logic for auth endpoints to avoid infinite loops
const authEndpoints = [
'/api/v1/auth/login',
'/api/v1/auth/refresh',
'/api/v1/users/register',
'/api/v1/users/signup',
];
const isAuthEndpoint = authEndpoints.some(endpoint =>
response.config?.url?.startsWith(endpoint)
);
if (isAuthEndpoint) {
return; // Skip refresh token logic for auth endpoints
}
// Store the original request for retry
pendingRequest = {
method: response.config?.method?.toUpperCase(),
url: response.config?.url,
data: response.config?.data,
params: response.config?.params,
headers: response.config?.headers,
};
// Get refresh token from store
const state = store.getState();
const refreshTokenValue = selectRefreshToken(state);
if (!refreshTokenValue) {
console.warn('No refresh token available - logging out');
store.dispatch(logout());
store.dispatch(clearSelectedService());
return;
}
try {
// Attempt to refresh the token
console.log('Attempting to refresh token...');
const refreshResult = await store.dispatch(refreshToken(refreshTokenValue));
if (refreshToken.fulfilled.match(refreshResult)) {
console.log('Token refreshed successfully - retrying original request');
// Retry the original request with new token
if (pendingRequest) {
try {
const newState = store.getState();
const newToken = selectAccessToken(newState);
if (newToken) {
// Prepare headers with new token
const retryHeaders = {
...pendingRequest.headers,
Authorization: `Bearer ${newToken}`,
};
// Retry the request
let retryResponse;
switch (pendingRequest.method) {
case 'GET':
retryResponse = await http.get(pendingRequest.url, pendingRequest.params, {
headers: retryHeaders,
});
break;
case 'POST':
retryResponse = await http.post(pendingRequest.url, pendingRequest.data, {
headers: retryHeaders,
});
break;
case 'PUT':
retryResponse = await http.put(pendingRequest.url, pendingRequest.data, {
headers: retryHeaders,
});
break;
case 'DELETE':
retryResponse = await http.delete(pendingRequest.url, pendingRequest.params, {
headers: retryHeaders,
});
break;
case 'PATCH':
retryResponse = await http.patch(pendingRequest.url, pendingRequest.data, {
headers: retryHeaders,
});
break;
default:
console.warn('Unsupported HTTP method for retry:', pendingRequest.method);
return;
}
console.log('Original request retried successfully:', retryResponse);
// Update the original response with retry response data
if (retryResponse) {
response.data = retryResponse.data;
response.status = retryResponse.status;
response.ok = retryResponse.ok;
response.problem = retryResponse.problem;
}
}
} catch (retryError) {
console.error('Error retrying original request:', retryError);
} finally {
// Clear pending request
pendingRequest = null;
}
}
} else {
console.warn('Token refresh failed - logging out');
store.dispatch(logout());
store.dispatch(clearSelectedService());
pendingRequest = null;
}
} catch (error) {
console.error('Error during token refresh:', error);
store.dispatch(logout());
store.dispatch(clearSelectedService());
pendingRequest = null;
}
}
// Log successful requests for debugging (optional)

View File

@ -1,5 +1,6 @@
export const API_ENDPOINTS = {
AUTH_LOGIN: '/api/v1/auth/login',
REFRESH_TOKEN: '/api/v1/auth/refresh',
USERSIGNUP:'/api/v1/users/register',
MANAGE_TOKEN:'/api/v1/users/zoho/token ',
HR_METRICS: '/hr/metrics',

View File

@ -23,8 +23,61 @@ export const validatePassword = (password: string): ValidationResult => {
return { isValid: false, error: 'Password is required' };
}
if (password.length < 6) {
return { isValid: false, error: 'Password must be at least 6 characters long' };
if (password.length < 8) {
return { isValid: false, error: 'Password must be at least 8 characters long' };
}
// Check for at least one uppercase letter
if (!/[A-Z]/.test(password)) {
return { isValid: false, error: 'Password must contain at least one uppercase letter' };
}
// Check for at least one lowercase letter
if (!/[a-z]/.test(password)) {
return { isValid: false, error: 'Password must contain at least one lowercase letter' };
}
// Check for at least one number
if (!/\d/.test(password)) {
return { isValid: false, error: 'Password must contain at least one number' };
}
// Check for at least one special character
if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
return { isValid: false, error: 'Password must contain at least one special character' };
}
return { isValid: true };
};
export const validateName = (name: string, fieldName: string): ValidationResult => {
if (!name.trim()) {
return { isValid: false, error: `${fieldName} is required` };
}
if (name.trim().length < 1) {
return { isValid: false, error: `${fieldName} must be at least 1 characters long` };
}
if (name.trim().length > 50) {
return { isValid: false, error: `${fieldName} must be less than 50 characters` };
}
// Check for valid name characters (letters, spaces, hyphens, apostrophes)
if (!/^[a-zA-Z\s\-']+$/.test(name.trim())) {
return { isValid: false, error: `${fieldName} can only contain letters, spaces, hyphens, and apostrophes` };
}
return { isValid: true };
};
export const validateConfirmPassword = (password: string, confirmPassword: string): ValidationResult => {
if (!confirmPassword) {
return { isValid: false, error: 'Please confirm your password' };
}
if (password !== confirmPassword) {
return { isValid: false, error: 'Passwords do not match' };
}
return { isValid: true };
@ -42,3 +95,41 @@ export const validateLoginForm = (email: string, password: string): { isValid: b
},
};
};
export const validateSignupForm = (
email: string,
password: string,
confirmPassword: string,
firstName: string,
lastName: string
): {
isValid: boolean;
errors: {
email?: string;
password?: string;
confirmPassword?: string;
firstName?: string;
lastName?: string;
}
} => {
const emailValidation = validateEmail(email);
const passwordValidation = validatePassword(password);
const confirmPasswordValidation = validateConfirmPassword(password, confirmPassword);
const firstNameValidation = validateName(firstName, 'First name');
const lastNameValidation = validateName(lastName, 'Last name');
return {
isValid: emailValidation.isValid &&
passwordValidation.isValid &&
confirmPasswordValidation.isValid &&
firstNameValidation.isValid &&
lastNameValidation.isValid,
errors: {
email: emailValidation.error,
password: passwordValidation.error,
confirmPassword: confirmPasswordValidation.error,
firstName: firstNameValidation.error,
lastName: lastNameValidation.error,
},
};
};