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.
|
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
||||||
*/
|
*/
|
||||||
def enableProguardInReleaseBuilds = false
|
def enableProguardInReleaseBuilds = false
|
||||||
|
def enableSeparateBuildPerCPUArchitecture = true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preferred build flavor of JavaScriptCore (JSC)
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
@ -78,6 +79,13 @@ android {
|
|||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
namespace "com.centralizedreportingsystem"
|
namespace "com.centralizedreportingsystem"
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
enable true
|
||||||
|
include 'armeabi-v7a', 'arm64-v8a', 'x86'
|
||||||
|
universalApk false
|
||||||
|
}
|
||||||
|
}
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.centralizedreportingsystem"
|
applicationId "com.centralizedreportingsystem"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
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
|
# your application. You should enable this flag either if you want
|
||||||
# to write custom TurboModules/Fabric components OR use libraries that
|
# to write custom TurboModules/Fabric components OR use libraries that
|
||||||
# are providing them.
|
# are providing them.
|
||||||
newArchEnabled=true
|
newArchEnabled=false
|
||||||
|
|
||||||
# Use this property to enable or disable the Hermes JS engine.
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
# If set to false, you will be using JSC instead.
|
# If set to false, you will be using JSC instead.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Export auth slice and actions
|
// Export auth slice and actions
|
||||||
export { default as authSlice } from './store/authSlice';
|
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 selectors
|
||||||
export * from './store/selectors';
|
export * from './store/selectors';
|
||||||
@ -13,3 +13,4 @@ export { default as AuthNavigator } from './navigation/AuthNavigator';
|
|||||||
|
|
||||||
// Export screens
|
// Export screens
|
||||||
export { default as LoginScreen } from './screens/LoginScreen';
|
export { default as LoginScreen } from './screens/LoginScreen';
|
||||||
|
export { default as SignupScreen } from './screens/SignupScreen';
|
||||||
|
|||||||
@ -1,14 +1,26 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createStackNavigator } from '@react-navigation/stack';
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
import LoginScreen from '@/modules/auth/screens/LoginScreen';
|
import LoginScreen from '@/modules/auth/screens/LoginScreen';
|
||||||
|
import SignupScreen from '@/modules/auth/screens/SignupScreen';
|
||||||
|
|
||||||
const Stack = createStackNavigator();
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
const AuthNavigator = () => (
|
const AuthNavigator = () => (
|
||||||
<Stack.Navigator>
|
<Stack.Navigator>
|
||||||
<Stack.Screen name="Login" component={LoginScreen} options={{
|
<Stack.Screen
|
||||||
headerShown: false, // Hide header for login 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>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import GradientBackground from '@/shared/components/layout/GradientBackground';
|
import GradientBackground from '@/shared/components/layout/GradientBackground';
|
||||||
import LinearGradient from 'react-native-linear-gradient';
|
import LinearGradient from 'react-native-linear-gradient';
|
||||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
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';
|
import { showError, showSuccess, showWarning, showInfo } from '@/shared/utils/Toast';
|
||||||
|
|
||||||
const LoginScreen: React.FC = () => {
|
const LoginScreen: React.FC = () => {
|
||||||
|
const navigation = useNavigation();
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const { colors, fonts } = useTheme();
|
const { colors, fonts } = useTheme();
|
||||||
const { loading, error, isAuthenticated } = useSelector((s: RootState) => s.auth);
|
const { loading, error, isAuthenticated } = useSelector((s: RootState) => s.auth);
|
||||||
@ -303,7 +305,7 @@ const LoginScreen: React.FC = () => {
|
|||||||
{/* Sign up */}
|
{/* Sign up */}
|
||||||
<View style={styles.signupRow}>
|
<View style={styles.signupRow}>
|
||||||
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Don't have an account? </Text>
|
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Don't have an account? </Text>
|
||||||
<TouchableOpacity 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>
|
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Sign up</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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 = {
|
export const authAPI = {
|
||||||
login: (email: string, password: string) => http.post(API_ENDPOINTS.AUTH_LOGIN, { email, password }),
|
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;
|
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 LoginResponse = LoginSuccessResponse | LoginErrorResponse;
|
||||||
|
type RefreshTokenResponse = RefreshTokenSuccessResponse | RefreshTokenErrorResponse;
|
||||||
|
type RegisterResponse = RegisterSuccessResponse | RegisterErrorResponse;
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number;
|
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({
|
const authSlice = createSlice({
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
initialState,
|
initialState,
|
||||||
@ -90,6 +179,9 @@ const authSlice = createSlice({
|
|||||||
clearError: state => {
|
clearError: state => {
|
||||||
state.error = null;
|
state.error = null;
|
||||||
},
|
},
|
||||||
|
updateAccessToken: (state, action: PayloadAction<string>) => {
|
||||||
|
state.accessToken = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
@ -109,11 +201,41 @@ const authSlice = createSlice({
|
|||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.error = action.payload as string || 'Login failed';
|
state.error = action.payload as string || 'Login failed';
|
||||||
state.isAuthenticated = false;
|
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;
|
export default authSlice;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
import { useTheme } from '@/shared/styles/useTheme';
|
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 {
|
interface BaseCardProps {
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@ -24,6 +24,18 @@ interface DealCardProps extends BaseCardProps {
|
|||||||
deal: CrmDeal;
|
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) => {
|
const getStatusColor = (status: string, colors: any) => {
|
||||||
// return '#3AA0FF';
|
// return '#3AA0FF';
|
||||||
switch (status.toLowerCase()) {
|
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({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
|||||||
@ -131,6 +131,134 @@ const CrmDashboardScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</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 */}
|
{/* Lead Status Distribution - Pie Chart */}
|
||||||
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Lead Status Distribution</Text>
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Lead Status Distribution</Text>
|
||||||
@ -236,6 +364,143 @@ const CrmDashboardScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</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 */}
|
{/* Lists */}
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
@ -355,6 +620,31 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
fontStyle: 'italic'
|
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
|
// 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 { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui';
|
||||||
import { useTheme } from '@/shared/styles/useTheme';
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
import { showError, showSuccess, showInfo } from '@/shared/utils/Toast';
|
import { showError, showSuccess, showInfo } from '@/shared/utils/Toast';
|
||||||
import type { CrmData, CrmLead, CrmTask, CrmContact, CrmDeal } from '../types/CrmTypes';
|
import type { CrmData, CrmLead, CrmTask, CrmContact, CrmDeal, CrmSalesOrder, CrmPurchaseOrder, CrmInvoice } from '../types/CrmTypes';
|
||||||
import { LeadCard, TaskCard, ContactCard, DealCard } from '../components/CrmDataCards';
|
import { LeadCard, TaskCard, ContactCard, DealCard, SalesOrderCard, PurchaseOrderCard, InvoiceCard } from '../components/CrmDataCards';
|
||||||
import {
|
import {
|
||||||
selectLeads,
|
selectLeads,
|
||||||
selectTasks,
|
selectTasks,
|
||||||
selectContacts,
|
selectContacts,
|
||||||
selectDeals,
|
selectDeals,
|
||||||
|
selectSalesOrders,
|
||||||
|
selectPurchaseOrders,
|
||||||
|
selectInvoices,
|
||||||
selectCrmLoading,
|
selectCrmLoading,
|
||||||
selectCrmErrors
|
selectCrmErrors
|
||||||
} from '../store/selectors';
|
} from '../store/selectors';
|
||||||
@ -31,7 +34,7 @@ import type { RootState } from '@/store/store';
|
|||||||
const ZohoCrmDataScreen: React.FC = () => {
|
const ZohoCrmDataScreen: React.FC = () => {
|
||||||
const { colors, fonts, spacing, shadows } = useTheme();
|
const { colors, fonts, spacing, shadows } = useTheme();
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
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);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
// Redux selectors
|
// Redux selectors
|
||||||
@ -39,6 +42,9 @@ const ZohoCrmDataScreen: React.FC = () => {
|
|||||||
const tasks = useSelector(selectTasks);
|
const tasks = useSelector(selectTasks);
|
||||||
const contacts = useSelector(selectContacts);
|
const contacts = useSelector(selectContacts);
|
||||||
const deals = useSelector(selectDeals);
|
const deals = useSelector(selectDeals);
|
||||||
|
const salesOrders = useSelector(selectSalesOrders);
|
||||||
|
const purchaseOrders = useSelector(selectPurchaseOrders);
|
||||||
|
const invoices = useSelector(selectInvoices);
|
||||||
const loading = useSelector(selectCrmLoading);
|
const loading = useSelector(selectCrmLoading);
|
||||||
const errors = useSelector(selectCrmErrors);
|
const errors = useSelector(selectCrmErrors);
|
||||||
|
|
||||||
@ -48,7 +54,10 @@ const ZohoCrmDataScreen: React.FC = () => {
|
|||||||
tasks: tasks || [],
|
tasks: tasks || [],
|
||||||
contacts: contacts || [],
|
contacts: contacts || [],
|
||||||
deals: deals || [],
|
deals: deals || [],
|
||||||
}), [leads, tasks, contacts, deals]);
|
salesOrders: salesOrders || [],
|
||||||
|
purchaseOrders: purchaseOrders || [],
|
||||||
|
invoices: invoices || [],
|
||||||
|
}), [leads, tasks, contacts, deals, salesOrders, purchaseOrders, invoices]);
|
||||||
|
|
||||||
// Fetch CRM data using Redux
|
// Fetch CRM data using Redux
|
||||||
const fetchCrmData = async (showRefresh = false) => {
|
const fetchCrmData = async (showRefresh = false) => {
|
||||||
@ -88,8 +97,10 @@ const ZohoCrmDataScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get current loading state and error
|
// Get current loading state and error
|
||||||
const isLoading = loading.leads || loading.tasks || loading.contacts || loading.deals;
|
const isLoading = loading.leads || loading.tasks || loading.contacts || loading.deals ||
|
||||||
const hasError = errors.leads || errors.tasks || errors.contacts || errors.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
|
// Tab configuration
|
||||||
@ -98,6 +109,9 @@ const ZohoCrmDataScreen: React.FC = () => {
|
|||||||
{ key: 'tasks', label: 'Tasks', icon: 'check-circle', count: crmData.tasks.length },
|
{ key: 'tasks', label: 'Tasks', icon: 'check-circle', count: crmData.tasks.length },
|
||||||
{ key: 'contacts', label: 'Contacts', icon: 'account-group', count: crmData.contacts.length },
|
{ key: 'contacts', label: 'Contacts', icon: 'account-group', count: crmData.contacts.length },
|
||||||
{ key: 'deals', label: 'Deals', icon: 'handshake', count: crmData.deals.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;
|
] as const;
|
||||||
|
|
||||||
if (isLoading && !crmData.leads.length) {
|
if (isLoading && !crmData.leads.length) {
|
||||||
@ -175,6 +189,54 @@ const ZohoCrmDataScreen: React.FC = () => {
|
|||||||
contentContainerStyle={styles.listContainer}
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type {
|
|||||||
} from '../types/CrmTypes';
|
} from '../types/CrmTypes';
|
||||||
|
|
||||||
// Available CRM resource types
|
// 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
|
// Base API endpoint
|
||||||
const CRM_BASE_URL = '/api/v1/integrations/data';
|
const CRM_BASE_URL = '/api/v1/integrations/data';
|
||||||
@ -45,5 +45,15 @@ export const crmAPI = {
|
|||||||
|
|
||||||
getDeals: (params?: CrmSearchParams) =>
|
getDeals: (params?: CrmSearchParams) =>
|
||||||
crmAPI.getCrmData<CrmDeal>('deals', params),
|
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,
|
CrmTask,
|
||||||
CrmContact,
|
CrmContact,
|
||||||
CrmDeal,
|
CrmDeal,
|
||||||
|
CrmSalesOrder,
|
||||||
|
CrmPurchaseOrder,
|
||||||
|
CrmInvoice,
|
||||||
CrmStats,
|
CrmStats,
|
||||||
CrmSearchParams,
|
CrmSearchParams,
|
||||||
CrmApiResponse,
|
CrmApiResponse,
|
||||||
@ -18,6 +21,9 @@ export interface CrmState {
|
|||||||
tasks: CrmTask[];
|
tasks: CrmTask[];
|
||||||
contacts: CrmContact[];
|
contacts: CrmContact[];
|
||||||
deals: CrmDeal[];
|
deals: CrmDeal[];
|
||||||
|
salesOrders: CrmSalesOrder[];
|
||||||
|
purchaseOrders: CrmPurchaseOrder[];
|
||||||
|
invoices: CrmInvoice[];
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
loading: {
|
loading: {
|
||||||
@ -25,6 +31,9 @@ export interface CrmState {
|
|||||||
tasks: boolean;
|
tasks: boolean;
|
||||||
contacts: boolean;
|
contacts: boolean;
|
||||||
deals: boolean;
|
deals: boolean;
|
||||||
|
salesOrders: boolean;
|
||||||
|
purchaseOrders: boolean;
|
||||||
|
invoices: boolean;
|
||||||
stats: boolean;
|
stats: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -34,6 +43,9 @@ export interface CrmState {
|
|||||||
tasks: string | null;
|
tasks: string | null;
|
||||||
contacts: string | null;
|
contacts: string | null;
|
||||||
deals: string | null;
|
deals: string | null;
|
||||||
|
salesOrders: string | null;
|
||||||
|
purchaseOrders: string | null;
|
||||||
|
invoices: string | null;
|
||||||
stats: string | null;
|
stats: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -43,6 +55,9 @@ export interface CrmState {
|
|||||||
tasks: { page: number; count: number; moreRecords: boolean };
|
tasks: { page: number; count: number; moreRecords: boolean };
|
||||||
contacts: { page: number; count: number; moreRecords: boolean };
|
contacts: { page: number; count: number; moreRecords: boolean };
|
||||||
deals: { 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
|
// Statistics
|
||||||
@ -54,6 +69,9 @@ export interface CrmState {
|
|||||||
tasks: string | null;
|
tasks: string | null;
|
||||||
contacts: string | null;
|
contacts: string | null;
|
||||||
deals: string | null;
|
deals: string | null;
|
||||||
|
salesOrders: string | null;
|
||||||
|
purchaseOrders: string | null;
|
||||||
|
invoices: string | null;
|
||||||
stats: string | null;
|
stats: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -64,11 +82,17 @@ const initialState: CrmState = {
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
contacts: [],
|
contacts: [],
|
||||||
deals: [],
|
deals: [],
|
||||||
|
salesOrders: [],
|
||||||
|
purchaseOrders: [],
|
||||||
|
invoices: [],
|
||||||
loading: {
|
loading: {
|
||||||
leads: false,
|
leads: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
contacts: false,
|
contacts: false,
|
||||||
deals: false,
|
deals: false,
|
||||||
|
salesOrders: false,
|
||||||
|
purchaseOrders: false,
|
||||||
|
invoices: false,
|
||||||
stats: false,
|
stats: false,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
@ -76,6 +100,9 @@ const initialState: CrmState = {
|
|||||||
tasks: null,
|
tasks: null,
|
||||||
contacts: null,
|
contacts: null,
|
||||||
deals: null,
|
deals: null,
|
||||||
|
salesOrders: null,
|
||||||
|
purchaseOrders: null,
|
||||||
|
invoices: null,
|
||||||
stats: null,
|
stats: null,
|
||||||
},
|
},
|
||||||
pagination: {
|
pagination: {
|
||||||
@ -83,6 +110,9 @@ const initialState: CrmState = {
|
|||||||
tasks: { page: 1, count: 0, moreRecords: false },
|
tasks: { page: 1, count: 0, moreRecords: false },
|
||||||
contacts: { page: 1, count: 0, moreRecords: false },
|
contacts: { page: 1, count: 0, moreRecords: false },
|
||||||
deals: { 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,
|
stats: null,
|
||||||
lastUpdated: {
|
lastUpdated: {
|
||||||
@ -90,6 +120,9 @@ const initialState: CrmState = {
|
|||||||
tasks: null,
|
tasks: null,
|
||||||
contacts: null,
|
contacts: null,
|
||||||
deals: null,
|
deals: null,
|
||||||
|
salesOrders: null,
|
||||||
|
purchaseOrders: null,
|
||||||
|
invoices: null,
|
||||||
stats: 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
|
// Fetch all CRM data
|
||||||
export const fetchAllCrmData = createAsyncThunk(
|
export const fetchAllCrmData = createAsyncThunk(
|
||||||
'crm/fetchAllData',
|
'crm/fetchAllData',
|
||||||
async (params?: CrmSearchParams) => {
|
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.getLeads(params),
|
||||||
crmAPI.getTasks(params),
|
crmAPI.getTasks(params),
|
||||||
crmAPI.getContacts(params),
|
crmAPI.getContacts(params),
|
||||||
crmAPI.getDeals(params),
|
crmAPI.getDeals(params),
|
||||||
|
crmAPI.getSalesOrders(params),
|
||||||
|
crmAPI.getPurchaseOrders(params),
|
||||||
|
crmAPI.getInvoices(params),
|
||||||
]);
|
]);
|
||||||
console.log('leads response data',leadsResponse)
|
console.log('leads response data',leadsResponse)
|
||||||
return {
|
return {
|
||||||
@ -143,6 +211,9 @@ export const fetchAllCrmData = createAsyncThunk(
|
|||||||
tasks: tasksResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } },
|
tasks: tasksResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } },
|
||||||
contacts: contactsResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } },
|
contacts: contactsResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } },
|
||||||
deals: dealsResponse.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,
|
tasks: null,
|
||||||
contacts: null,
|
contacts: null,
|
||||||
deals: null,
|
deals: null,
|
||||||
|
salesOrders: null,
|
||||||
|
purchaseOrders: null,
|
||||||
|
invoices: null,
|
||||||
stats: null,
|
stats: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -166,6 +240,9 @@ const crmSlice = createSlice({
|
|||||||
state.tasks = [];
|
state.tasks = [];
|
||||||
state.contacts = [];
|
state.contacts = [];
|
||||||
state.deals = [];
|
state.deals = [];
|
||||||
|
state.salesOrders = [];
|
||||||
|
state.purchaseOrders = [];
|
||||||
|
state.invoices = [];
|
||||||
state.stats = null;
|
state.stats = null;
|
||||||
},
|
},
|
||||||
setLeadsPage: (state, action: PayloadAction<number>) => {
|
setLeadsPage: (state, action: PayloadAction<number>) => {
|
||||||
@ -180,6 +257,15 @@ const crmSlice = createSlice({
|
|||||||
setDealsPage: (state, action: PayloadAction<number>) => {
|
setDealsPage: (state, action: PayloadAction<number>) => {
|
||||||
state.pagination.deals.page = action.payload;
|
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) => {
|
extraReducers: (builder) => {
|
||||||
// Fetch leads
|
// Fetch leads
|
||||||
@ -247,52 +333,124 @@ const crmSlice = createSlice({
|
|||||||
state.errors.deals = action.error.message || 'Failed to fetch deals';
|
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
|
// Fetch all CRM data
|
||||||
.addCase(fetchAllCrmData.pending, (state) => {
|
.addCase(fetchAllCrmData.pending, (state) => {
|
||||||
state.loading.leads = true;
|
state.loading.leads = true;
|
||||||
state.loading.tasks = true;
|
state.loading.tasks = true;
|
||||||
state.loading.contacts = true;
|
state.loading.contacts = true;
|
||||||
state.loading.deals = true;
|
state.loading.deals = true;
|
||||||
|
state.loading.salesOrders = true;
|
||||||
|
state.loading.purchaseOrders = true;
|
||||||
|
state.loading.invoices = true;
|
||||||
state.errors.leads = null;
|
state.errors.leads = null;
|
||||||
state.errors.tasks = null;
|
state.errors.tasks = null;
|
||||||
state.errors.contacts = null;
|
state.errors.contacts = null;
|
||||||
state.errors.deals = null;
|
state.errors.deals = null;
|
||||||
|
state.errors.salesOrders = null;
|
||||||
|
state.errors.purchaseOrders = null;
|
||||||
|
state.errors.invoices = null;
|
||||||
})
|
})
|
||||||
.addCase(fetchAllCrmData.fulfilled, (state, action) => {
|
.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.leads = false;
|
||||||
state.loading.tasks = false;
|
state.loading.tasks = false;
|
||||||
state.loading.contacts = false;
|
state.loading.contacts = false;
|
||||||
state.loading.deals = false;
|
state.loading.deals = false;
|
||||||
|
state.loading.salesOrders = false;
|
||||||
|
state.loading.purchaseOrders = false;
|
||||||
|
state.loading.invoices = false;
|
||||||
|
|
||||||
state.leads = leads.data || [];
|
state.leads = leads.data || [];
|
||||||
state.tasks = tasks.data || [];
|
state.tasks = tasks.data || [];
|
||||||
state.contacts = contacts.data || [];
|
state.contacts = contacts.data || [];
|
||||||
state.deals = deals.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.leads = leads.info || { page: 1, count: 0, moreRecords: false };
|
||||||
state.pagination.tasks = tasks.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.contacts = contacts.info || { page: 1, count: 0, moreRecords: false };
|
||||||
state.pagination.deals = deals.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();
|
const now = new Date().toISOString();
|
||||||
state.lastUpdated.leads = now;
|
state.lastUpdated.leads = now;
|
||||||
state.lastUpdated.tasks = now;
|
state.lastUpdated.tasks = now;
|
||||||
state.lastUpdated.contacts = now;
|
state.lastUpdated.contacts = now;
|
||||||
state.lastUpdated.deals = now;
|
state.lastUpdated.deals = now;
|
||||||
|
state.lastUpdated.salesOrders = now;
|
||||||
|
state.lastUpdated.purchaseOrders = now;
|
||||||
|
state.lastUpdated.invoices = now;
|
||||||
})
|
})
|
||||||
.addCase(fetchAllCrmData.rejected, (state, action) => {
|
.addCase(fetchAllCrmData.rejected, (state, action) => {
|
||||||
state.loading.leads = false;
|
state.loading.leads = false;
|
||||||
state.loading.tasks = false;
|
state.loading.tasks = false;
|
||||||
state.loading.contacts = false;
|
state.loading.contacts = false;
|
||||||
state.loading.deals = 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';
|
const errorMessage = action.error.message || 'Failed to fetch CRM data';
|
||||||
state.errors.leads = errorMessage;
|
state.errors.leads = errorMessage;
|
||||||
state.errors.tasks = errorMessage;
|
state.errors.tasks = errorMessage;
|
||||||
state.errors.contacts = errorMessage;
|
state.errors.contacts = errorMessage;
|
||||||
state.errors.deals = errorMessage;
|
state.errors.deals = errorMessage;
|
||||||
|
state.errors.salesOrders = errorMessage;
|
||||||
|
state.errors.purchaseOrders = errorMessage;
|
||||||
|
state.errors.invoices = errorMessage;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -304,6 +462,9 @@ export const {
|
|||||||
setTasksPage,
|
setTasksPage,
|
||||||
setContactsPage,
|
setContactsPage,
|
||||||
setDealsPage,
|
setDealsPage,
|
||||||
|
setSalesOrdersPage,
|
||||||
|
setPurchaseOrdersPage,
|
||||||
|
setInvoicesPage,
|
||||||
} = crmSlice.actions;
|
} = crmSlice.actions;
|
||||||
|
|
||||||
export default crmSlice.reducer;
|
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 selectTasks = (state: RootState) => state.crm.tasks;
|
||||||
export const selectContacts = (state: RootState) => state.crm.contacts;
|
export const selectContacts = (state: RootState) => state.crm.contacts;
|
||||||
export const selectDeals = (state: RootState) => state.crm.deals;
|
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 selectCrmLoading = (state: RootState) => state.crm.loading;
|
||||||
export const selectCrmErrors = (state: RootState) => state.crm.errors;
|
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 selectTasksLoading = (state: RootState) => state.crm.loading.tasks;
|
||||||
export const selectContactsLoading = (state: RootState) => state.crm.loading.contacts;
|
export const selectContactsLoading = (state: RootState) => state.crm.loading.contacts;
|
||||||
export const selectDealsLoading = (state: RootState) => state.crm.loading.deals;
|
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
|
// Error selectors
|
||||||
export const selectLeadsError = (state: RootState) => state.crm.errors.leads;
|
export const selectLeadsError = (state: RootState) => state.crm.errors.leads;
|
||||||
export const selectTasksError = (state: RootState) => state.crm.errors.tasks;
|
export const selectTasksError = (state: RootState) => state.crm.errors.tasks;
|
||||||
export const selectContactsError = (state: RootState) => state.crm.errors.contacts;
|
export const selectContactsError = (state: RootState) => state.crm.errors.contacts;
|
||||||
export const selectDealsError = (state: RootState) => state.crm.errors.deals;
|
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
|
// Computed selectors for dashboard
|
||||||
export const selectCrmStats = createSelector(
|
export const selectCrmStats = createSelector(
|
||||||
[selectLeads, selectTasks, selectContacts, selectDeals],
|
[selectLeads, selectTasks, selectContacts, selectDeals, selectSalesOrders, selectPurchaseOrders, selectInvoices],
|
||||||
(leads, tasks, contacts, deals): CrmStats => {
|
(leads, tasks, contacts, deals, salesOrders, purchaseOrders, invoices): CrmStats => {
|
||||||
// Ensure arrays are defined and are actually arrays, fallback to empty arrays
|
// Ensure arrays are defined and are actually arrays, fallback to empty arrays
|
||||||
const safeLeads = Array.isArray(leads) ? leads : [];
|
const safeLeads = Array.isArray(leads) ? leads : [];
|
||||||
const safeTasks = Array.isArray(tasks) ? tasks : [];
|
const safeTasks = Array.isArray(tasks) ? tasks : [];
|
||||||
const safeContacts = Array.isArray(contacts) ? contacts : [];
|
const safeContacts = Array.isArray(contacts) ? contacts : [];
|
||||||
const safeDeals = Array.isArray(deals) ? deals : [];
|
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
|
// Calculate leads stats with safe property access and better fallbacks
|
||||||
const leadsByStatus = safeLeads.reduce((acc, lead) => {
|
const leadsByStatus = safeLeads.reduce((acc, lead) => {
|
||||||
@ -84,6 +96,143 @@ export const selectCrmStats = createSelector(
|
|||||||
const wonDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Won');
|
const wonDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Won');
|
||||||
const lostDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Lost');
|
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 {
|
return {
|
||||||
leads: {
|
leads: {
|
||||||
total: safeLeads.length,
|
total: safeLeads.length,
|
||||||
@ -119,6 +268,29 @@ export const selectCrmStats = createSelector(
|
|||||||
.filter(deal => !['Closed Won', 'Closed Lost'].includes(deal.Stage))
|
.filter(deal => !['Closed Won', 'Closed Lost'].includes(deal.Stage))
|
||||||
.reduce((sum, deal) => sum + (deal.Amount || 0), 0),
|
.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[];
|
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
|
// Enums and Union Types
|
||||||
export type LeadStatus = 'New' | 'Contacted' | 'Qualified' | 'Unqualified' | 'Converted' | 'Lost Lead' | 'Not Contacted' | 'Junk Lead';
|
export type LeadStatus = 'New' | 'Contacted' | 'Qualified' | 'Unqualified' | 'Converted' | 'Lost Lead' | 'Not Contacted' | 'Junk Lead';
|
||||||
export type TaskType = 'Call' | 'Email' | 'Meeting' | 'Follow-up' | 'Demo' | 'Proposal' | 'Other';
|
export type TaskType = 'Call' | 'Email' | 'Meeting' | 'Follow-up' | 'Demo' | 'Proposal' | 'Other';
|
||||||
@ -162,6 +495,9 @@ export interface CrmData {
|
|||||||
tasks: CrmTask[];
|
tasks: CrmTask[];
|
||||||
contacts: CrmContact[];
|
contacts: CrmContact[];
|
||||||
deals: CrmDeal[];
|
deals: CrmDeal[];
|
||||||
|
salesOrders: CrmSalesOrder[];
|
||||||
|
purchaseOrders: CrmPurchaseOrder[];
|
||||||
|
invoices: CrmInvoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Response Types
|
// API Response Types
|
||||||
@ -264,6 +600,40 @@ export interface CrmStats {
|
|||||||
byStage: Record<DealStage, number>;
|
byStage: Record<DealStage, number>;
|
||||||
pipelineValue: 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
|
// 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 { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import type { IntegrationsStackParamList } from '@/modules/integrations/navigation/IntegrationsNavigator';
|
import type { IntegrationsStackParamList } from '@/modules/integrations/navigation/IntegrationsNavigator';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
import GradientBackground from '@/shared/components/layout/GradientBackground';
|
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'>;
|
type Nav = StackNavigationProp<IntegrationsStackParamList, 'IntegrationsHome'>;
|
||||||
|
|
||||||
@ -30,12 +34,32 @@ interface Props {
|
|||||||
const IntegrationsHomeScreen: React.FC<Props> = () => {
|
const IntegrationsHomeScreen: React.FC<Props> = () => {
|
||||||
const { colors, shadows, fonts } = useTheme();
|
const { colors, shadows, fonts } = useTheme();
|
||||||
const navigation = useNavigation();
|
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 (
|
return (
|
||||||
<GradientBackground colors={['#FFE9CC', '#F6E6FF']} style={{flex:1}}>
|
<GradientBackground colors={['#FFE9CC', '#F6E6FF']} style={{flex:1}}>
|
||||||
|
|
||||||
<View style={[styles.container]}>
|
<View style={[styles.container]}>
|
||||||
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Choose a Service</Text>
|
{/* Header with title and logout button */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Choose a Service</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.logoutButton, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
||||||
|
onPress={handleLogout}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Icon name="logout" size={20} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={categories}
|
data={categories}
|
||||||
keyExtractor={item => item.key}
|
keyExtractor={item => item.key}
|
||||||
@ -62,6 +86,17 @@ const IntegrationsHomeScreen: React.FC<Props> = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -70,12 +105,27 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
title: {
|
header: {
|
||||||
fontSize: 20,
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
marginHorizontal: GUTTER,
|
marginHorizontal: GUTTER,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
logoutButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
card: {
|
card: {
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|||||||
@ -71,6 +71,8 @@ const getScopeForService = (_serviceKey?: ServiceKey): string => {
|
|||||||
// Zoho CRM (adjust modules per your needs)
|
// Zoho CRM (adjust modules per your needs)
|
||||||
'ZohoCRM.users.READ',
|
'ZohoCRM.users.READ',
|
||||||
'ZohoCRM.modules.READ',
|
'ZohoCRM.modules.READ',
|
||||||
|
//settings
|
||||||
|
'ZohoCRM.settings.READ',
|
||||||
// Zoho Books (use granular scopes if preferred instead of FullAccess)
|
// Zoho Books (use granular scopes if preferred instead of FullAccess)
|
||||||
'ZohoBooks.FullAccess.READ',
|
'ZohoBooks.FullAccess.READ',
|
||||||
// Zoho People
|
// 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 React from 'react';
|
||||||
import { createStackNavigator } from '@react-navigation/stack';
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
import ZohoProjectsDashboardScreen from '@/modules/zohoProjects/screens/ZohoProjectsDashboardScreen';
|
import ZohoProjectsDashboardScreen from '@/modules/zohoProjects/screens/ZohoProjectsDashboardScreen';
|
||||||
|
import ZohoProjectsDataScreen from '@/modules/zohoProjects/screens/ZohoProjectsDataScreen';
|
||||||
|
|
||||||
const Stack = createStackNavigator();
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
const ZohoProjectsNavigator = () => (
|
const ZohoProjectsNavigator = () => (
|
||||||
<Stack.Navigator>
|
<Stack.Navigator>
|
||||||
<Stack.Screen name="ZohoProjectsDashboard" component={ZohoProjectsDashboardScreen} options={{headerShown:false}}/>
|
<Stack.Screen name="ZohoProjectsDashboard" component={ZohoProjectsDashboardScreen} options={{headerShown:false}}/>
|
||||||
|
<Stack.Screen name="ZohoProjectsData" component={ZohoProjectsDataScreen} options={{headerShown:false}}/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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 Icon from 'react-native-vector-icons/MaterialIcons';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
|
||||||
import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui';
|
import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui';
|
||||||
import { useTheme } from '@/shared/styles/useTheme';
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
@ -25,6 +26,7 @@ import type { RootState } from '@/store/store';
|
|||||||
const ZohoProjectsDashboardScreen: React.FC = () => {
|
const ZohoProjectsDashboardScreen: React.FC = () => {
|
||||||
const { colors, fonts } = useTheme();
|
const { colors, fonts } = useTheme();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const navigation = useNavigation();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
// Redux state
|
// Redux state
|
||||||
@ -44,6 +46,10 @@ const ZohoProjectsDashboardScreen: React.FC = () => {
|
|||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigateToData = () => {
|
||||||
|
navigation.navigate('ZohoProjectsData' as never);
|
||||||
|
};
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading && stats.totalProjects === 0) {
|
if (isLoading && stats.totalProjects === 0) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
@ -114,7 +120,16 @@ const ZohoProjectsDashboardScreen: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Zoho Projects</Text>
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Zoho Projects</Text>
|
||||||
<Icon name="insights" size={24} color={colors.primary} />
|
<View style={styles.headerActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleNavigateToData}
|
||||||
|
style={styles.dataButton}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Icon name="list" size={24} color={colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Icon name="insights" size={24} color={colors.primary} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
@ -399,6 +414,16 @@ const styles = StyleSheet.create({
|
|||||||
padding: 16,
|
padding: 16,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
},
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
dataButton: {
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 24,
|
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 }) => {
|
async (params: { refresh?: boolean } = {}, { dispatch }) => {
|
||||||
// First fetch projects
|
// First fetch projects
|
||||||
const projectsResponse = await dispatch(fetchZohoProjects(params));
|
const projectsResponse = await dispatch(fetchZohoProjects(params));
|
||||||
|
console.log('projectsResponse',projectsResponse)
|
||||||
|
|
||||||
if (fetchZohoProjects.fulfilled.match(projectsResponse) && projectsResponse.payload) {
|
if (fetchZohoProjects.fulfilled.match(projectsResponse) && projectsResponse.payload) {
|
||||||
const projects = projectsResponse.payload.data.data;
|
const projects = projectsResponse.payload.data.data;
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import { create } from 'apisauce';
|
import { create } from 'apisauce';
|
||||||
import { store } from '@/store/store';
|
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({
|
const http = create({
|
||||||
baseURL: 'http://192.168.1.12:4000',
|
// baseURL: 'http://192.168.1.24:4000',
|
||||||
// baseURL: 'http://160.187.167.216',
|
baseURL: 'http://160.187.167.216',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -28,6 +33,7 @@ http.addRequestTransform((request) => {
|
|||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
const token = selectAccessToken(state);
|
const token = selectAccessToken(state);
|
||||||
|
const refreshToken=selectRefreshToken(state)
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
request.headers = {
|
request.headers = {
|
||||||
@ -40,11 +46,130 @@ http.addRequestTransform((request) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add response interceptor for error handling
|
// Add response interceptor for error handling
|
||||||
http.addResponseTransform((response) => {
|
http.addResponseTransform(async (response) => {
|
||||||
|
console.log('unauthorized response',response)
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
console.warn('Unauthorized request - token may be expired');
|
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)
|
// Log successful requests for debugging (optional)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
AUTH_LOGIN: '/api/v1/auth/login',
|
AUTH_LOGIN: '/api/v1/auth/login',
|
||||||
|
REFRESH_TOKEN: '/api/v1/auth/refresh',
|
||||||
USERSIGNUP:'/api/v1/users/register',
|
USERSIGNUP:'/api/v1/users/register',
|
||||||
MANAGE_TOKEN:'/api/v1/users/zoho/token ',
|
MANAGE_TOKEN:'/api/v1/users/zoho/token ',
|
||||||
HR_METRICS: '/hr/metrics',
|
HR_METRICS: '/hr/metrics',
|
||||||
|
|||||||
@ -23,8 +23,61 @@ export const validatePassword = (password: string): ValidationResult => {
|
|||||||
return { isValid: false, error: 'Password is required' };
|
return { isValid: false, error: 'Password is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
return { isValid: false, error: 'Password must be at least 6 characters long' };
|
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 };
|
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