zoho projects implemented on front end
This commit is contained in:
parent
a68523567a
commit
e0be9146dd
142
__tests__/auth.test.ts
Normal file
142
__tests__/auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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={{
|
||||
<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>
|
||||
);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
634
src/modules/auth/screens/SignupScreen.tsx
Normal file
634
src/modules/auth/screens/SignupScreen.tsx
Normal 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;
|
||||
@ -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),
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]}>
|
||||
{/* 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,
|
||||
|
||||
@ -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
|
||||
|
||||
559
src/modules/zohoProjects/components/ZohoProjectsDataCards.tsx
Normal file
559
src/modules/zohoProjects/components/ZohoProjectsDataCards.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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,8 +120,17 @@ const ZohoProjectsDashboardScreen: React.FC = () => {
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Zoho Projects</Text>
|
||||
<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}>
|
||||
{/* Projects Overview Header */}
|
||||
@ -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,
|
||||
},
|
||||
|
||||
346
src/modules/zohoProjects/screens/ZohoProjectsDataScreen.tsx
Normal file
346
src/modules/zohoProjects/screens/ZohoProjectsDataScreen.tsx
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user