From e0be9146dde1e7661f8e4b07f92c945ad56576c1 Mon Sep 17 00:00:00 2001 From: yashwin-foxy Date: Mon, 15 Sep 2025 19:59:08 +0530 Subject: [PATCH] zoho projects implemented on front end --- __tests__/auth.test.ts | 142 ++++ android/app/build.gradle | 8 + android/gradle.properties | 2 +- src/modules/auth/index.ts | 3 +- src/modules/auth/navigation/AuthNavigator.tsx | 18 +- src/modules/auth/screens/LoginScreen.tsx | 4 +- src/modules/auth/screens/SignupScreen.tsx | 634 ++++++++++++++++++ src/modules/auth/services/authAPI.ts | 7 + src/modules/auth/store/authSlice.ts | 124 +++- src/modules/crm/components/CrmDataCards.tsx | 197 +++++- .../crm/screens/CrmDashboardScreen.tsx | 290 ++++++++ src/modules/crm/screens/ZohoCrmDataScreen.tsx | 74 +- src/modules/crm/services/crmAPI.ts | 12 +- src/modules/crm/store/crmSlice.ts | 165 ++++- src/modules/crm/store/selectors.ts | 176 ++++- src/modules/crm/types/CrmTypes.ts | 370 ++++++++++ .../screens/IntegrationsHomeScreen.tsx | 56 +- src/modules/integrations/screens/ZohoAuth.tsx | 2 + .../components/ZohoProjectsDataCards.tsx | 559 +++++++++++++++ .../navigation/ZohoProjectsNavigator.tsx | 2 + .../screens/ZohoProjectsDashboardScreen.tsx | 29 +- .../screens/ZohoProjectsDataScreen.tsx | 346 ++++++++++ .../zohoProjects/store/zohoProjectsSlice.ts | 1 + src/services/http.ts | 137 +++- src/shared/constants/API_ENDPOINTS.ts | 1 + src/shared/utils/validation.ts | 95 ++- 26 files changed, 3422 insertions(+), 32 deletions(-) create mode 100644 __tests__/auth.test.ts create mode 100644 src/modules/auth/screens/SignupScreen.tsx create mode 100644 src/modules/zohoProjects/components/ZohoProjectsDataCards.tsx create mode 100644 src/modules/zohoProjects/screens/ZohoProjectsDataScreen.tsx diff --git a/__tests__/auth.test.ts b/__tests__/auth.test.ts new file mode 100644 index 0000000..50d466f --- /dev/null +++ b/__tests__/auth.test.ts @@ -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; + +describe('Auth Slice', () => { + let store: ReturnType; + + 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(); + }); + }); +}); diff --git a/android/app/build.gradle b/android/app/build.gradle index 9ce89e2..ca48cc1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -58,6 +58,7 @@ react { * Set this to true to Run Proguard on Release builds to minify the Java bytecode. */ def enableProguardInReleaseBuilds = false +def enableSeparateBuildPerCPUArchitecture = true /** * The preferred build flavor of JavaScriptCore (JSC) @@ -78,6 +79,13 @@ android { compileSdk rootProject.ext.compileSdkVersion namespace "com.centralizedreportingsystem" + splits { + abi { + enable true + include 'armeabi-v7a', 'arm64-v8a', 'x86' + universalApk false + } + } defaultConfig { applicationId "com.centralizedreportingsystem" minSdkVersion rootProject.ext.minSdkVersion diff --git a/android/gradle.properties b/android/gradle.properties index 5e24e3a..9fb1566 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -32,7 +32,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 # your application. You should enable this flag either if you want # to write custom TurboModules/Fabric components OR use libraries that # are providing them. -newArchEnabled=true +newArchEnabled=false # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index b872034..e02ee8a 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -1,6 +1,6 @@ // Export auth slice and actions export { default as authSlice } from './store/authSlice'; -export { login, logout, clearError } from './store/authSlice'; +export { login, logout, clearError, register } from './store/authSlice'; // Export selectors export * from './store/selectors'; @@ -13,3 +13,4 @@ export { default as AuthNavigator } from './navigation/AuthNavigator'; // Export screens export { default as LoginScreen } from './screens/LoginScreen'; +export { default as SignupScreen } from './screens/SignupScreen'; diff --git a/src/modules/auth/navigation/AuthNavigator.tsx b/src/modules/auth/navigation/AuthNavigator.tsx index 9342551..e8e9fd9 100644 --- a/src/modules/auth/navigation/AuthNavigator.tsx +++ b/src/modules/auth/navigation/AuthNavigator.tsx @@ -1,14 +1,26 @@ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import LoginScreen from '@/modules/auth/screens/LoginScreen'; +import SignupScreen from '@/modules/auth/screens/SignupScreen'; const Stack = createStackNavigator(); const AuthNavigator = () => ( - + + ); diff --git a/src/modules/auth/screens/LoginScreen.tsx b/src/modules/auth/screens/LoginScreen.tsx index 0c298da..c5f82f1 100644 --- a/src/modules/auth/screens/LoginScreen.tsx +++ b/src/modules/auth/screens/LoginScreen.tsx @@ -8,6 +8,7 @@ import { Pressable, Alert, } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; import GradientBackground from '@/shared/components/layout/GradientBackground'; import LinearGradient from 'react-native-linear-gradient'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; @@ -19,6 +20,7 @@ import { validateLoginForm } from '@/shared/utils/validation'; import { showError, showSuccess, showWarning, showInfo } from '@/shared/utils/Toast'; const LoginScreen: React.FC = () => { + const navigation = useNavigation(); const dispatch = useDispatch(); const { colors, fonts } = useTheme(); const { loading, error, isAuthenticated } = useSelector((s: RootState) => s.auth); @@ -303,7 +305,7 @@ const LoginScreen: React.FC = () => { {/* Sign up */} Don't have an account? - showInfo('Sign up feature coming soon!')}> + navigation.navigate('Signup' as never)}> Sign up diff --git a/src/modules/auth/screens/SignupScreen.tsx b/src/modules/auth/screens/SignupScreen.tsx new file mode 100644 index 0000000..4567e8b --- /dev/null +++ b/src/modules/auth/screens/SignupScreen.tsx @@ -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(); + 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); + const [validationErrors, setValidationErrors] = React.useState<{ + email?: string; + password?: string; + confirmPassword?: string; + firstName?: string; + lastName?: string; + }>({}); + + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + const confirmPasswordRef = React.useRef(null); + const firstNameRef = React.useRef(null); + const lastNameRef = React.useRef(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 ( + + + + {/* Card */} + + {/* Logo placeholder */} + + + + + Create Account + + Fill in your details to get started + + + {/* First Name input */} + + firstNameRef.current?.focus()} + > + + setFocused('firstName')} + onBlur={() => setFocused(null)} + onChangeText={handleFirstNameChange} + onSubmitEditing={() => lastNameRef.current?.focus()} + style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]} + /> + + {validationErrors.firstName && ( + + {validationErrors.firstName} + + )} + + + {/* Last Name input */} + + lastNameRef.current?.focus()} + > + + setFocused('lastName')} + onBlur={() => setFocused(null)} + onChangeText={handleLastNameChange} + onSubmitEditing={() => emailRef.current?.focus()} + style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]} + /> + + {validationErrors.lastName && ( + + {validationErrors.lastName} + + )} + + + {/* Email input */} + + emailRef.current?.focus()} + > + + setFocused('email')} + onBlur={() => setFocused(null)} + onChangeText={handleEmailChange} + onSubmitEditing={() => passwordRef.current?.focus()} + style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]} + /> + + {validationErrors.email && ( + + {validationErrors.email} + + )} + + + {/* Password input */} + + passwordRef.current?.focus()} + > + + setFocused('password')} + onBlur={() => setFocused(null)} + onChangeText={handlePasswordChange} + onSubmitEditing={() => confirmPasswordRef.current?.focus()} + style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]} + /> + setShowPassword(v => !v)} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + accessibilityRole="button" + accessibilityLabel={showPassword ? 'Hide password' : 'Show password'} + > + + + + {validationErrors.password && ( + + {validationErrors.password} + + )} + + + {/* Confirm Password input */} + + confirmPasswordRef.current?.focus()} + > + + setFocused('confirmPassword')} + onBlur={() => setFocused(null)} + onChangeText={handleConfirmPasswordChange} + style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]} + /> + setShowConfirmPassword(v => !v)} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + accessibilityRole="button" + accessibilityLabel={showConfirmPassword ? 'Hide confirm password' : 'Show confirm password'} + > + + + + {validationErrors.confirmPassword && ( + + {validationErrors.confirmPassword} + + )} + + + {/* Password requirements */} + + + Password must contain: + + + • At least 8 characters + + + • One uppercase letter + + + • One lowercase letter + + + • One number + + + • One special character + + + + {/* Signup button */} + + + + {loading ? 'Creating Account...' : 'Create Account'} + + + + + {/* Sign in */} + + + Already have an account? + + navigation.navigate('Login' as never)}> + Sign in + + + + {/* API Error */} + {!!error && ( + + + + {error} + + + )} + + + + + ); +}; + +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; diff --git a/src/modules/auth/services/authAPI.ts b/src/modules/auth/services/authAPI.ts index 209e489..7a07e30 100644 --- a/src/modules/auth/services/authAPI.ts +++ b/src/modules/auth/services/authAPI.ts @@ -3,6 +3,13 @@ import { API_ENDPOINTS } from '@/shared/constants/API_ENDPOINTS'; export const authAPI = { login: (email: string, password: string) => http.post(API_ENDPOINTS.AUTH_LOGIN, { email, password }), + refreshToken: (refreshToken:string) => http.post(API_ENDPOINTS.REFRESH_TOKEN,{refreshToken}), + register: (userData: { + email: string; + password: string; + firstName: string; + lastName: string; + }) => http.post(API_ENDPOINTS.USERSIGNUP, userData), }; diff --git a/src/modules/auth/store/authSlice.ts b/src/modules/auth/store/authSlice.ts index 9a1b56e..9158779 100644 --- a/src/modules/auth/store/authSlice.ts +++ b/src/modules/auth/store/authSlice.ts @@ -20,7 +20,42 @@ interface LoginErrorResponse { timestamp: string; } +interface RefreshTokenSuccessResponse { + status: 'success'; + message: string; + data: { + accessToken: string; + }; + timestamp: string; +} + +interface RefreshTokenErrorResponse { + status: 'error'; + message: string; + errorCode: 'INVALID_REFRESH'; + timestamp: string; +} + +interface RegisterSuccessResponse { + status: 'success'; + message: string; + data: { + uuid: string; + email: string; + }; + timestamp: string; +} + +interface RegisterErrorResponse { + status: 'error'; + message: string; + errorCode: string; + timestamp: string; +} + type LoginResponse = LoginSuccessResponse | LoginErrorResponse; +type RefreshTokenResponse = RefreshTokenSuccessResponse | RefreshTokenErrorResponse; +type RegisterResponse = RegisterSuccessResponse | RegisterErrorResponse; export interface AuthUser { id: number; @@ -76,6 +111,60 @@ export const login = createAsyncThunk( }, ); +export const refreshToken = createAsyncThunk( + 'auth/refreshToken', + async (refreshTokenValue: string, { rejectWithValue }) => { + try { + const response = await authAPI.refreshToken(refreshTokenValue); + const data = response.data as RefreshTokenResponse; + console.log('refresh token response got',data) + + if (data.status === 'success') { + return { + accessToken: data.data.accessToken, + }; + } else { + return rejectWithValue(data.message || 'Token refresh failed'); + } + } catch (error: any) { + // Handle network errors or API errors + if (error.response?.data?.status === 'error') { + const errorData = error.response.data as RefreshTokenErrorResponse; + return rejectWithValue(errorData.message || 'Token refresh failed'); + } + return rejectWithValue(error.message || 'Network error occurred'); + } + }, +); + +export const register = createAsyncThunk( + 'auth/register', + async (payload: { email: string; password: string; firstName: string; lastName: string }, { rejectWithValue }) => { + try { + const response = await authAPI.register(payload); + const data = response.data as RegisterResponse; + console.log('register response got', data); + + if (data.status === 'success') { + return { + uuid: data.data.uuid, + email: data.data.email, + message: data.message, + }; + } else { + return rejectWithValue(data.message || 'Registration failed'); + } + } catch (error: any) { + // Handle network errors or API errors + if (error.response?.data?.status === 'error') { + const errorData = error.response.data as RegisterErrorResponse; + return rejectWithValue(errorData.message || 'Registration failed'); + } + return rejectWithValue(error.message || 'Network error occurred'); + } + }, +); + const authSlice = createSlice({ name: 'auth', initialState, @@ -90,6 +179,9 @@ const authSlice = createSlice({ clearError: state => { state.error = null; }, + updateAccessToken: (state, action: PayloadAction) => { + state.accessToken = action.payload; + }, }, extraReducers: builder => { builder @@ -109,11 +201,41 @@ const authSlice = createSlice({ state.loading = false; state.error = action.payload as string || 'Login failed'; state.isAuthenticated = false; + }) + .addCase(refreshToken.pending, state => { + // Don't set loading to true for refresh token to avoid UI flicker + state.error = null; + }) + .addCase(refreshToken.fulfilled, (state, action) => { + state.accessToken = action.payload.accessToken; + state.error = null; + }) + .addCase(refreshToken.rejected, (state, action) => { + // If refresh token fails, logout the user + state.user = null; + state.accessToken = null; + state.refreshToken = null; + state.error = action.payload as string || 'Token refresh failed'; + state.isAuthenticated = false; + }) + .addCase(register.pending, state => { + state.loading = true; + state.error = null; + }) + .addCase(register.fulfilled, (state, action) => { + state.loading = false; + state.error = null; + // Note: Registration doesn't automatically log in the user + // They need to login separately after successful registration + }) + .addCase(register.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string || 'Registration failed'; }); }, }); -export const { logout, clearError } = authSlice.actions; +export const { logout, clearError, updateAccessToken } = authSlice.actions; export default authSlice; diff --git a/src/modules/crm/components/CrmDataCards.tsx b/src/modules/crm/components/CrmDataCards.tsx index 7619b09..d24f32c 100644 --- a/src/modules/crm/components/CrmDataCards.tsx +++ b/src/modules/crm/components/CrmDataCards.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import { useTheme } from '@/shared/styles/useTheme'; -import type { CrmLead, CrmTask, CrmContact, CrmDeal } from '../types/CrmTypes'; +import type { CrmLead, CrmTask, CrmContact, CrmDeal, CrmSalesOrder, CrmPurchaseOrder, CrmInvoice } from '../types/CrmTypes'; interface BaseCardProps { onPress: () => void; @@ -24,6 +24,18 @@ interface DealCardProps extends BaseCardProps { deal: CrmDeal; } +interface SalesOrderCardProps extends BaseCardProps { + salesOrder: CrmSalesOrder; +} + +interface PurchaseOrderCardProps extends BaseCardProps { + purchaseOrder: CrmPurchaseOrder; +} + +interface InvoiceCardProps extends BaseCardProps { + invoice: CrmInvoice; +} + const getStatusColor = (status: string, colors: any) => { // return '#3AA0FF'; switch (status.toLowerCase()) { @@ -289,6 +301,189 @@ export const DealCard: React.FC = ({ deal, onPress }) => { ); }; +export const SalesOrderCard: React.FC = ({ salesOrder, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {salesOrder.Subject} + + + + {salesOrder.Status} + + + + + SO: {salesOrder.SO_Number} + + + + + + + + {salesOrder.$currency_symbol}{salesOrder.Grand_Total?.toLocaleString()} + + + + + + Account: {salesOrder.Account_Name?.name || 'N/A'} + + + + + + {salesOrder.Billing_City}, {salesOrder.Billing_Country} + + + + + + Carrier: {salesOrder.Carrier} + + + + + + + Created: {new Date(salesOrder.Created_Time)?.toLocaleDateString()} + + + + ); +}; + +export const PurchaseOrderCard: React.FC = ({ purchaseOrder, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {purchaseOrder.Subject} + + + + {purchaseOrder.Status} + + + + + PO: {purchaseOrder.PO_Number || 'N/A'} + + + + + + + + {purchaseOrder.$currency_symbol}{purchaseOrder.Grand_Total?.toLocaleString()} + + + + + + Vendor: {purchaseOrder.Vendor_Name?.name || 'N/A'} + + + + + + {purchaseOrder.Billing_City}, {purchaseOrder.Billing_Country} + + + + + + Carrier: {purchaseOrder.Carrier} + + + + + + + PO Date: {new Date(purchaseOrder.PO_Date)?.toLocaleDateString()} + + + + ); +}; + +export const InvoiceCard: React.FC = ({ invoice, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {invoice.Subject} + + + + {invoice.Status} + + + + + Invoice: {invoice.Invoice_Number} + + + + + + + + {invoice.$currency_symbol}{invoice.Grand_Total?.toLocaleString()} + + + + + + Account: {invoice.Account_Name?.name || 'N/A'} + + + + + + {invoice.Billing_City}, {invoice.Billing_Country} + + + + + + Due: {new Date(invoice.Due_Date)?.toLocaleDateString()} + + + + + + + Invoice Date: {new Date(invoice.Invoice_Date)?.toLocaleDateString()} + + + + ); +}; + const styles = StyleSheet.create({ card: { borderRadius: 12, diff --git a/src/modules/crm/screens/CrmDashboardScreen.tsx b/src/modules/crm/screens/CrmDashboardScreen.tsx index c8c6924..0be55a3 100644 --- a/src/modules/crm/screens/CrmDashboardScreen.tsx +++ b/src/modules/crm/screens/CrmDashboardScreen.tsx @@ -131,6 +131,134 @@ const CrmDashboardScreen: React.FC = () => { /> + {/* Sales Orders & Purchase Orders Row */} + + + + + + + + {/* Invoices Row */} + + + + + + + + {/* Customer & Sales KPIs Row 1 */} + + + + + + + + {/* Customer & Sales KPIs Row 2 */} + + + + + + + {/* Lead Status Distribution - Pie Chart */} Lead Status Distribution @@ -236,6 +364,143 @@ const CrmDashboardScreen: React.FC = () => { + {/* Sales Orders by Status - Donut Chart */} + + Sales Orders by Status + + + ({ + label: status, + value: count, + color: getStatusColor(status) + }))} + colors={colors} + fonts={fonts} + size={140} + /> + + {/* Legend */} + + {Object.entries(crmStats.salesOrders.byStatus).map(([status, count]) => ( + + + + {status} ({count}) + + + ))} + + + + + {/* Purchase Orders by Vendor - Pie Chart */} + + Purchase Orders by Vendor + + + ({ + label: vendor, + value: count, + color: getStatusColor(vendor) + }))} + colors={colors} + fonts={fonts} + size={140} + /> + + {/* Legend */} + + {Object.entries(crmStats.purchaseOrders.byVendor).map(([vendor, count]) => ( + + + + {vendor} ({count}) + + + ))} + + + + + {/* Invoices by Status - Stacked Bar Chart */} + + Invoices by Status + + + ({ + label: status, + value: count, + color: getStatusColor(status) + }))} + colors={colors} + fonts={fonts} + height={120} + /> + + {/* Legend */} + + {Object.entries(crmStats.invoices.byStatus).map(([status, count]) => ( + + + + {status} ({count}) + + + ))} + + + + + {/* Customer & Sales KPIs Summary */} + + Customer & Sales KPIs Summary + + + + Sales Cycle Length + + {crmStats.customerKPIs.salesCycleLength} days + + + Avg. time from first touch to close + + + + + Customer LTV + + {formatCurrency(crmStats.customerKPIs.customerLifetimeValue)} + + + Average lifetime value per customer + + + + + LTV/CAC Ratio + = 3 ? '#22C55E' : '#EF4444', fontFamily: fonts.bold }]}> + {crmStats.customerKPIs.ltvToCacRatio}x + + + {crmStats.customerKPIs.ltvToCacRatio >= 3 ? 'Healthy ratio' : 'Needs improvement'} + + + + + Churn Rate + + {crmStats.customerKPIs.churnRate}% + + + {crmStats.customerKPIs.churnRate <= 5 ? 'Low churn' : 'High churn risk'} + + + + + {/* Lists */} @@ -355,6 +620,31 @@ const styles = StyleSheet.create({ paddingVertical: 20, fontStyle: 'italic' }, + kpiSummaryGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + marginTop: 8, + }, + kpiSummaryItem: { + width: '48%', + marginBottom: 16, + padding: 12, + backgroundColor: '#F8F9FA', + borderRadius: 8, + }, + kpiSummaryLabel: { + fontSize: 12, + marginBottom: 4, + }, + kpiSummaryValue: { + fontSize: 18, + marginBottom: 4, + }, + kpiSummaryDesc: { + fontSize: 11, + lineHeight: 14, + }, }); // Helper functions for color coding diff --git a/src/modules/crm/screens/ZohoCrmDataScreen.tsx b/src/modules/crm/screens/ZohoCrmDataScreen.tsx index 1c907f8..390712b 100644 --- a/src/modules/crm/screens/ZohoCrmDataScreen.tsx +++ b/src/modules/crm/screens/ZohoCrmDataScreen.tsx @@ -15,13 +15,16 @@ import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui'; import { useTheme } from '@/shared/styles/useTheme'; import { showError, showSuccess, showInfo } from '@/shared/utils/Toast'; -import type { CrmData, CrmLead, CrmTask, CrmContact, CrmDeal } from '../types/CrmTypes'; -import { LeadCard, TaskCard, ContactCard, DealCard } from '../components/CrmDataCards'; +import type { CrmData, CrmLead, CrmTask, CrmContact, CrmDeal, CrmSalesOrder, CrmPurchaseOrder, CrmInvoice } from '../types/CrmTypes'; +import { LeadCard, TaskCard, ContactCard, DealCard, SalesOrderCard, PurchaseOrderCard, InvoiceCard } from '../components/CrmDataCards'; import { selectLeads, selectTasks, selectContacts, selectDeals, + selectSalesOrders, + selectPurchaseOrders, + selectInvoices, selectCrmLoading, selectCrmErrors } from '../store/selectors'; @@ -31,7 +34,7 @@ import type { RootState } from '@/store/store'; const ZohoCrmDataScreen: React.FC = () => { const { colors, fonts, spacing, shadows } = useTheme(); const dispatch = useDispatch(); - const [selectedTab, setSelectedTab] = useState<'leads' | 'tasks' | 'contacts' | 'deals'>('leads'); + const [selectedTab, setSelectedTab] = useState<'leads' | 'tasks' | 'contacts' | 'deals' | 'salesOrders' | 'purchaseOrders' | 'invoices'>('leads'); const [refreshing, setRefreshing] = useState(false); // Redux selectors @@ -39,6 +42,9 @@ const ZohoCrmDataScreen: React.FC = () => { const tasks = useSelector(selectTasks); const contacts = useSelector(selectContacts); const deals = useSelector(selectDeals); + const salesOrders = useSelector(selectSalesOrders); + const purchaseOrders = useSelector(selectPurchaseOrders); + const invoices = useSelector(selectInvoices); const loading = useSelector(selectCrmLoading); const errors = useSelector(selectCrmErrors); @@ -48,7 +54,10 @@ const ZohoCrmDataScreen: React.FC = () => { tasks: tasks || [], contacts: contacts || [], deals: deals || [], - }), [leads, tasks, contacts, deals]); + salesOrders: salesOrders || [], + purchaseOrders: purchaseOrders || [], + invoices: invoices || [], + }), [leads, tasks, contacts, deals, salesOrders, purchaseOrders, invoices]); // Fetch CRM data using Redux const fetchCrmData = async (showRefresh = false) => { @@ -88,8 +97,10 @@ const ZohoCrmDataScreen: React.FC = () => { }; // Get current loading state and error - const isLoading = loading.leads || loading.tasks || loading.contacts || loading.deals; - const hasError = errors.leads || errors.tasks || errors.contacts || errors.deals; + const isLoading = loading.leads || loading.tasks || loading.contacts || loading.deals || + loading.salesOrders || loading.purchaseOrders || loading.invoices; + const hasError = errors.leads || errors.tasks || errors.contacts || errors.deals || + errors.salesOrders || errors.purchaseOrders || errors.invoices; // Tab configuration @@ -98,6 +109,9 @@ const ZohoCrmDataScreen: React.FC = () => { { key: 'tasks', label: 'Tasks', icon: 'check-circle', count: crmData.tasks.length }, { key: 'contacts', label: 'Contacts', icon: 'account-group', count: crmData.contacts.length }, { key: 'deals', label: 'Deals', icon: 'handshake', count: crmData.deals.length }, + { key: 'salesOrders', label: 'Sales Orders', icon: 'shopping', count: crmData.salesOrders.length }, + { key: 'purchaseOrders', label: 'Purchase Orders', icon: 'cart', count: crmData.purchaseOrders.length }, + { key: 'invoices', label: 'Invoices', icon: 'receipt', count: crmData.invoices.length }, ] as const; if (isLoading && !crmData.leads.length) { @@ -175,6 +189,54 @@ const ZohoCrmDataScreen: React.FC = () => { contentContainerStyle={styles.listContainer} /> ); + case 'salesOrders': + return ( + ( + handleCardPress(item, 'Sales Order')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + case 'purchaseOrders': + return ( + ( + handleCardPress(item, 'Purchase Order')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + case 'invoices': + return ( + ( + handleCardPress(item, 'Invoice')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); default: return null; } diff --git a/src/modules/crm/services/crmAPI.ts b/src/modules/crm/services/crmAPI.ts index dc76e4d..693a898 100644 --- a/src/modules/crm/services/crmAPI.ts +++ b/src/modules/crm/services/crmAPI.ts @@ -10,7 +10,7 @@ import type { } from '../types/CrmTypes'; // Available CRM resource types -export type CrmResourceType = 'leads' | 'tasks' | 'deals' | 'contacts'; +export type CrmResourceType = 'leads' | 'tasks' | 'deals' | 'contacts' | 'sales-orders' | 'purchase-orders' | 'invoices'; // Base API endpoint const CRM_BASE_URL = '/api/v1/integrations/data'; @@ -45,5 +45,15 @@ export const crmAPI = { getDeals: (params?: CrmSearchParams) => crmAPI.getCrmData('deals', params), + + // New API endpoints for sales orders, purchase orders, and invoices + getSalesOrders: (params?: CrmSearchParams) => + http.get>>(`/api/v1/integrations/sales-orders?provider=zoho`, params), + + getPurchaseOrders: (params?: CrmSearchParams) => + http.get>>(`/api/v1/integrations/purchase-orders?provider=zoho`, params), + + getInvoices: (params?: CrmSearchParams) => + http.get>>(`/api/v1/integrations/invoices?provider=zoho`, params), }; diff --git a/src/modules/crm/store/crmSlice.ts b/src/modules/crm/store/crmSlice.ts index 94e68da..8c6620b 100644 --- a/src/modules/crm/store/crmSlice.ts +++ b/src/modules/crm/store/crmSlice.ts @@ -5,6 +5,9 @@ import type { CrmTask, CrmContact, CrmDeal, + CrmSalesOrder, + CrmPurchaseOrder, + CrmInvoice, CrmStats, CrmSearchParams, CrmApiResponse, @@ -18,6 +21,9 @@ export interface CrmState { tasks: CrmTask[]; contacts: CrmContact[]; deals: CrmDeal[]; + salesOrders: CrmSalesOrder[]; + purchaseOrders: CrmPurchaseOrder[]; + invoices: CrmInvoice[]; // Loading states loading: { @@ -25,6 +31,9 @@ export interface CrmState { tasks: boolean; contacts: boolean; deals: boolean; + salesOrders: boolean; + purchaseOrders: boolean; + invoices: boolean; stats: boolean; }; @@ -34,6 +43,9 @@ export interface CrmState { tasks: string | null; contacts: string | null; deals: string | null; + salesOrders: string | null; + purchaseOrders: string | null; + invoices: string | null; stats: string | null; }; @@ -43,6 +55,9 @@ export interface CrmState { tasks: { page: number; count: number; moreRecords: boolean }; contacts: { page: number; count: number; moreRecords: boolean }; deals: { page: number; count: number; moreRecords: boolean }; + salesOrders: { page: number; count: number; moreRecords: boolean }; + purchaseOrders: { page: number; count: number; moreRecords: boolean }; + invoices: { page: number; count: number; moreRecords: boolean }; }; // Statistics @@ -54,6 +69,9 @@ export interface CrmState { tasks: string | null; contacts: string | null; deals: string | null; + salesOrders: string | null; + purchaseOrders: string | null; + invoices: string | null; stats: string | null; }; } @@ -64,11 +82,17 @@ const initialState: CrmState = { tasks: [], contacts: [], deals: [], + salesOrders: [], + purchaseOrders: [], + invoices: [], loading: { leads: false, tasks: false, contacts: false, deals: false, + salesOrders: false, + purchaseOrders: false, + invoices: false, stats: false, }, errors: { @@ -76,6 +100,9 @@ const initialState: CrmState = { tasks: null, contacts: null, deals: null, + salesOrders: null, + purchaseOrders: null, + invoices: null, stats: null, }, pagination: { @@ -83,6 +110,9 @@ const initialState: CrmState = { tasks: { page: 1, count: 0, moreRecords: false }, contacts: { page: 1, count: 0, moreRecords: false }, deals: { page: 1, count: 0, moreRecords: false }, + salesOrders: { page: 1, count: 0, moreRecords: false }, + purchaseOrders: { page: 1, count: 0, moreRecords: false }, + invoices: { page: 1, count: 0, moreRecords: false }, }, stats: null, lastUpdated: { @@ -90,6 +120,9 @@ const initialState: CrmState = { tasks: null, contacts: null, deals: null, + salesOrders: null, + purchaseOrders: null, + invoices: null, stats: null, }, }; @@ -127,15 +160,50 @@ export const fetchDeals = createAsyncThunk( } ); +export const fetchSalesOrders = createAsyncThunk( + 'crm/fetchSalesOrders', + async (params?: CrmSearchParams) => { + const response = await crmAPI.getSalesOrders(params); + return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }; + } +); + +export const fetchPurchaseOrders = createAsyncThunk( + 'crm/fetchPurchaseOrders', + async (params?: CrmSearchParams) => { + const response = await crmAPI.getPurchaseOrders(params); + return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }; + } +); + +export const fetchInvoices = createAsyncThunk( + 'crm/fetchInvoices', + async (params?: CrmSearchParams) => { + const response = await crmAPI.getInvoices(params); + return response.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }; + } +); + // Fetch all CRM data export const fetchAllCrmData = createAsyncThunk( 'crm/fetchAllData', async (params?: CrmSearchParams) => { - const [leadsResponse, tasksResponse, contactsResponse, dealsResponse] = await Promise.all([ + const [ + leadsResponse, + tasksResponse, + contactsResponse, + dealsResponse, + salesOrdersResponse, + purchaseOrdersResponse, + invoicesResponse + ] = await Promise.all([ crmAPI.getLeads(params), crmAPI.getTasks(params), crmAPI.getContacts(params), crmAPI.getDeals(params), + crmAPI.getSalesOrders(params), + crmAPI.getPurchaseOrders(params), + crmAPI.getInvoices(params), ]); console.log('leads response data',leadsResponse) return { @@ -143,6 +211,9 @@ export const fetchAllCrmData = createAsyncThunk( tasks: tasksResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, contacts: contactsResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, deals: dealsResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, + salesOrders: salesOrdersResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, + purchaseOrders: purchaseOrdersResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, + invoices: invoicesResponse.data?.data || { data: [], info: { count: 0, moreRecords: false, page: 1 } }, }; } ); @@ -158,6 +229,9 @@ const crmSlice = createSlice({ tasks: null, contacts: null, deals: null, + salesOrders: null, + purchaseOrders: null, + invoices: null, stats: null, }; }, @@ -166,6 +240,9 @@ const crmSlice = createSlice({ state.tasks = []; state.contacts = []; state.deals = []; + state.salesOrders = []; + state.purchaseOrders = []; + state.invoices = []; state.stats = null; }, setLeadsPage: (state, action: PayloadAction) => { @@ -180,6 +257,15 @@ const crmSlice = createSlice({ setDealsPage: (state, action: PayloadAction) => { state.pagination.deals.page = action.payload; }, + setSalesOrdersPage: (state, action: PayloadAction) => { + state.pagination.salesOrders.page = action.payload; + }, + setPurchaseOrdersPage: (state, action: PayloadAction) => { + state.pagination.purchaseOrders.page = action.payload; + }, + setInvoicesPage: (state, action: PayloadAction) => { + state.pagination.invoices.page = action.payload; + }, }, extraReducers: (builder) => { // Fetch leads @@ -247,52 +333,124 @@ const crmSlice = createSlice({ state.errors.deals = action.error.message || 'Failed to fetch deals'; }) + // Fetch sales orders + .addCase(fetchSalesOrders.pending, (state) => { + state.loading.salesOrders = true; + state.errors.salesOrders = null; + }) + .addCase(fetchSalesOrders.fulfilled, (state, action) => { + state.loading.salesOrders = false; + state.salesOrders = action.payload.data || []; + state.pagination.salesOrders = action.payload.info || { page: 1, count: 0, moreRecords: false }; + state.lastUpdated.salesOrders = new Date().toISOString(); + }) + .addCase(fetchSalesOrders.rejected, (state, action) => { + state.loading.salesOrders = false; + state.errors.salesOrders = action.error.message || 'Failed to fetch sales orders'; + }) + + // Fetch purchase orders + .addCase(fetchPurchaseOrders.pending, (state) => { + state.loading.purchaseOrders = true; + state.errors.purchaseOrders = null; + }) + .addCase(fetchPurchaseOrders.fulfilled, (state, action) => { + state.loading.purchaseOrders = false; + state.purchaseOrders = action.payload.data || []; + state.pagination.purchaseOrders = action.payload.info || { page: 1, count: 0, moreRecords: false }; + state.lastUpdated.purchaseOrders = new Date().toISOString(); + }) + .addCase(fetchPurchaseOrders.rejected, (state, action) => { + state.loading.purchaseOrders = false; + state.errors.purchaseOrders = action.error.message || 'Failed to fetch purchase orders'; + }) + + // Fetch invoices + .addCase(fetchInvoices.pending, (state) => { + state.loading.invoices = true; + state.errors.invoices = null; + }) + .addCase(fetchInvoices.fulfilled, (state, action) => { + state.loading.invoices = false; + state.invoices = action.payload.data || []; + state.pagination.invoices = action.payload.info || { page: 1, count: 0, moreRecords: false }; + state.lastUpdated.invoices = new Date().toISOString(); + }) + .addCase(fetchInvoices.rejected, (state, action) => { + state.loading.invoices = false; + state.errors.invoices = action.error.message || 'Failed to fetch invoices'; + }) + // Fetch all CRM data .addCase(fetchAllCrmData.pending, (state) => { state.loading.leads = true; state.loading.tasks = true; state.loading.contacts = true; state.loading.deals = true; + state.loading.salesOrders = true; + state.loading.purchaseOrders = true; + state.loading.invoices = true; state.errors.leads = null; state.errors.tasks = null; state.errors.contacts = null; state.errors.deals = null; + state.errors.salesOrders = null; + state.errors.purchaseOrders = null; + state.errors.invoices = null; }) .addCase(fetchAllCrmData.fulfilled, (state, action) => { - const { leads, tasks, contacts, deals } = action.payload; + const { leads, tasks, contacts, deals, salesOrders, purchaseOrders, invoices } = action.payload; state.loading.leads = false; state.loading.tasks = false; state.loading.contacts = false; state.loading.deals = false; + state.loading.salesOrders = false; + state.loading.purchaseOrders = false; + state.loading.invoices = false; state.leads = leads.data || []; state.tasks = tasks.data || []; state.contacts = contacts.data || []; state.deals = deals.data || []; + state.salesOrders = salesOrders.data || []; + state.purchaseOrders = purchaseOrders.data || []; + state.invoices = invoices.data || []; state.pagination.leads = leads.info || { page: 1, count: 0, moreRecords: false }; state.pagination.tasks = tasks.info || { page: 1, count: 0, moreRecords: false }; state.pagination.contacts = contacts.info || { page: 1, count: 0, moreRecords: false }; state.pagination.deals = deals.info || { page: 1, count: 0, moreRecords: false }; + state.pagination.salesOrders = salesOrders.info || { page: 1, count: 0, moreRecords: false }; + state.pagination.purchaseOrders = purchaseOrders.info || { page: 1, count: 0, moreRecords: false }; + state.pagination.invoices = invoices.info || { page: 1, count: 0, moreRecords: false }; const now = new Date().toISOString(); state.lastUpdated.leads = now; state.lastUpdated.tasks = now; state.lastUpdated.contacts = now; state.lastUpdated.deals = now; + state.lastUpdated.salesOrders = now; + state.lastUpdated.purchaseOrders = now; + state.lastUpdated.invoices = now; }) .addCase(fetchAllCrmData.rejected, (state, action) => { state.loading.leads = false; state.loading.tasks = false; state.loading.contacts = false; state.loading.deals = false; + state.loading.salesOrders = false; + state.loading.purchaseOrders = false; + state.loading.invoices = false; const errorMessage = action.error.message || 'Failed to fetch CRM data'; state.errors.leads = errorMessage; state.errors.tasks = errorMessage; state.errors.contacts = errorMessage; state.errors.deals = errorMessage; + state.errors.salesOrders = errorMessage; + state.errors.purchaseOrders = errorMessage; + state.errors.invoices = errorMessage; }); }, }); @@ -304,6 +462,9 @@ export const { setTasksPage, setContactsPage, setDealsPage, + setSalesOrdersPage, + setPurchaseOrdersPage, + setInvoicesPage, } = crmSlice.actions; export default crmSlice.reducer; diff --git a/src/modules/crm/store/selectors.ts b/src/modules/crm/store/selectors.ts index 5e85c38..988f75a 100644 --- a/src/modules/crm/store/selectors.ts +++ b/src/modules/crm/store/selectors.ts @@ -9,6 +9,9 @@ export const selectLeads = (state: RootState) => state.crm.leads; export const selectTasks = (state: RootState) => state.crm.tasks; export const selectContacts = (state: RootState) => state.crm.contacts; export const selectDeals = (state: RootState) => state.crm.deals; +export const selectSalesOrders = (state: RootState) => state.crm.salesOrders; +export const selectPurchaseOrders = (state: RootState) => state.crm.purchaseOrders; +export const selectInvoices = (state: RootState) => state.crm.invoices; export const selectCrmLoading = (state: RootState) => state.crm.loading; export const selectCrmErrors = (state: RootState) => state.crm.errors; @@ -19,22 +22,31 @@ export const selectLeadsLoading = (state: RootState) => state.crm.loading.leads; export const selectTasksLoading = (state: RootState) => state.crm.loading.tasks; export const selectContactsLoading = (state: RootState) => state.crm.loading.contacts; export const selectDealsLoading = (state: RootState) => state.crm.loading.deals; +export const selectSalesOrdersLoading = (state: RootState) => state.crm.loading.salesOrders; +export const selectPurchaseOrdersLoading = (state: RootState) => state.crm.loading.purchaseOrders; +export const selectInvoicesLoading = (state: RootState) => state.crm.loading.invoices; // Error selectors export const selectLeadsError = (state: RootState) => state.crm.errors.leads; export const selectTasksError = (state: RootState) => state.crm.errors.tasks; export const selectContactsError = (state: RootState) => state.crm.errors.contacts; export const selectDealsError = (state: RootState) => state.crm.errors.deals; +export const selectSalesOrdersError = (state: RootState) => state.crm.errors.salesOrders; +export const selectPurchaseOrdersError = (state: RootState) => state.crm.errors.purchaseOrders; +export const selectInvoicesError = (state: RootState) => state.crm.errors.invoices; // Computed selectors for dashboard export const selectCrmStats = createSelector( - [selectLeads, selectTasks, selectContacts, selectDeals], - (leads, tasks, contacts, deals): CrmStats => { + [selectLeads, selectTasks, selectContacts, selectDeals, selectSalesOrders, selectPurchaseOrders, selectInvoices], + (leads, tasks, contacts, deals, salesOrders, purchaseOrders, invoices): CrmStats => { // Ensure arrays are defined and are actually arrays, fallback to empty arrays const safeLeads = Array.isArray(leads) ? leads : []; const safeTasks = Array.isArray(tasks) ? tasks : []; const safeContacts = Array.isArray(contacts) ? contacts : []; const safeDeals = Array.isArray(deals) ? deals : []; + const safeSalesOrders = Array.isArray(salesOrders) ? salesOrders : []; + const safePurchaseOrders = Array.isArray(purchaseOrders) ? purchaseOrders : []; + const safeInvoices = Array.isArray(invoices) ? invoices : []; // Calculate leads stats with safe property access and better fallbacks const leadsByStatus = safeLeads.reduce((acc, lead) => { @@ -84,6 +96,143 @@ export const selectCrmStats = createSelector( const wonDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Won'); const lostDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Lost'); + // Calculate sales orders stats + const salesOrdersByStatus = safeSalesOrders.reduce((acc, order) => { + const status = order?.Status && order.Status.trim() !== '' ? order.Status : 'Not Set'; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {} as Record); + + 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); + + 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); + + const purchaseOrdersByVendor = safePurchaseOrders.reduce((acc, order) => { + const vendor = order?.Vendor_Name?.name || 'Unknown'; + acc[vendor] = (acc[vendor] || 0) + 1; + return acc; + }, {} as Record); + + 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); + + const totalInvoiceValue = safeInvoices.reduce((sum, invoice) => { + const amount = invoice?.Grand_Total || 0; + return sum + (typeof amount === 'number' && !isNaN(amount) ? amount : 0); + }, 0); + + const overdueInvoices = safeInvoices.filter(invoice => { + if (!invoice?.Due_Date) return false; + return new Date(invoice.Due_Date) < new Date() && invoice.Status !== 'Paid'; + }); + + const paidInvoices = safeInvoices.filter(invoice => invoice?.Status === 'Paid'); + + // Calculate Customer & Sales KPIs + const calculateCustomerKPIs = () => { + // 1. Sales Cycle Length - Average days from deal creation to close + const closedDeals = safeDeals.filter(deal => + deal?.Stage === 'Closed Won' && deal?.Created_Time && deal?.Modified_Time + ); + const salesCycleLength = closedDeals.length > 0 + ? closedDeals.reduce((total, deal) => { + const created = new Date(deal.Created_Time); + const closed = new Date(deal.Modified_Time); + const daysDiff = Math.ceil((closed.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)); + return total + daysDiff; + }, 0) / closedDeals.length + : 0; + + // 2. Average Revenue Per Account - Total deal value / unique accounts + const uniqueAccounts = new Set( + safeDeals + .filter(deal => deal?.Account_Name?.name) + .map(deal => deal.Account_Name.name) + ); + const totalDealValue = safeDeals.reduce((sum, deal) => sum + (deal?.Amount || 0), 0); + const averageRevenuePerAccount = uniqueAccounts.size > 0 + ? totalDealValue / uniqueAccounts.size + : 0; + + // 3. Churn Rate - Lost leads / total leads (using lead status) + const lostLeads = safeLeads.filter(lead => + lead?.Lead_Status === 'Lost Lead' || lead?.Lead_Status === 'Unqualified' + ); + const churnRate = safeLeads.length > 0 + ? (lostLeads.length / safeLeads.length) * 100 + : 0; + + // 4. Customer Lifetime Value - Average deal value per customer + const wonDeals = safeDeals.filter(deal => deal?.Stage === 'Closed Won'); + const totalWonValue = wonDeals.reduce((sum, deal) => sum + (deal?.Amount || 0), 0); + const customerLifetimeValue = uniqueAccounts.size > 0 + ? totalWonValue / uniqueAccounts.size + : 0; + + // 5. LTV-to-CAC Ratio (estimated - using average deal value as proxy for LTV) + // Assuming CAC is 20% of average deal value (this would need real marketing spend data) + const estimatedCAC = customerLifetimeValue * 0.2; + const ltvToCacRatio = estimatedCAC > 0 + ? customerLifetimeValue / estimatedCAC + : 0; + + // 6. Customer Retention Rate + const customerRetentionRate = 100 - churnRate; + + // 7. Average Deal Size + const averageDealSize = safeDeals.length > 0 + ? totalDealValue / safeDeals.length + : 0; + + // 8. Conversion Rate - Converted leads / total leads + const convertedLeads = safeLeads.filter(lead => lead?.Lead_Status === 'Converted'); + const conversionRate = safeLeads.length > 0 + ? (convertedLeads.length / safeLeads.length) * 100 + : 0; + + // 9. Win Rate - Won deals / total deals + const winRate = safeDeals.length > 0 + ? (wonDeals.length / safeDeals.length) * 100 + : 0; + + return { + salesCycleLength: Math.round(salesCycleLength), + averageRevenuePerAccount: Math.round(averageRevenuePerAccount), + churnRate: Math.round(churnRate * 100) / 100, + customerLifetimeValue: Math.round(customerLifetimeValue), + ltvToCacRatio: Math.round(ltvToCacRatio * 100) / 100, + customerRetentionRate: Math.round(customerRetentionRate * 100) / 100, + averageDealSize: Math.round(averageDealSize), + conversionRate: Math.round(conversionRate * 100) / 100, + winRate: Math.round(winRate * 100) / 100, + }; + }; + + const customerKPIs = calculateCustomerKPIs(); + return { leads: { total: safeLeads.length, @@ -119,6 +268,29 @@ export const selectCrmStats = createSelector( .filter(deal => !['Closed Won', 'Closed Lost'].includes(deal.Stage)) .reduce((sum, deal) => sum + (deal.Amount || 0), 0), }, + salesOrders: { + total: safeSalesOrders.length, + totalValue: totalSalesOrderValue, + averageOrderValue: safeSalesOrders.length > 0 ? totalSalesOrderValue / safeSalesOrders.length : 0, + byStatus: salesOrdersByStatus, + byCountry: salesOrdersByCountry, + }, + purchaseOrders: { + total: safePurchaseOrders.length, + totalValue: totalPurchaseOrderValue, + averageOrderValue: safePurchaseOrders.length > 0 ? totalPurchaseOrderValue / safePurchaseOrders.length : 0, + byStatus: purchaseOrdersByStatus, + byVendor: purchaseOrdersByVendor, + }, + invoices: { + total: safeInvoices.length, + totalValue: totalInvoiceValue, + averageInvoiceValue: safeInvoices.length > 0 ? totalInvoiceValue / safeInvoices.length : 0, + byStatus: invoicesByStatus, + overdue: overdueInvoices.length, + paid: paidInvoices.length, + }, + customerKPIs, }; } ); diff --git a/src/modules/crm/types/CrmTypes.ts b/src/modules/crm/types/CrmTypes.ts index b99bc07..d227919 100644 --- a/src/modules/crm/types/CrmTypes.ts +++ b/src/modules/crm/types/CrmTypes.ts @@ -147,6 +147,339 @@ export interface CrmDeal { Tag: string[]; } +export interface CrmSalesOrder { + id: string; + Owner: { + name: string; + id: string; + email: string; + }; + $currency_symbol: string; + Customer_No?: string; + Tax: number; + Last_Activity_Time?: string; + $state: string; + $converted: boolean; + $process_flow: boolean; + Deal_Name: { + name: string; + id: string; + }; + Billing_Country: string; + $locked_for_me: boolean; + Carrier: string; + $approved: boolean; + Quote_Name?: string; + Status: string; + Grand_Total: number; + $approval: { + delegate: boolean; + takeover: boolean; + approve: boolean; + reject: boolean; + resubmit: boolean; + }; + Adjustment: number; + Billing_Street: string; + Created_Time: string; + $editable: boolean; + Billing_Code: string; + Product_Details: Array<{ + product: { + Product_Code: string; + name: string; + id: string; + }; + quantity: number; + Discount: number; + total_after_discount: number; + net_total: number; + book?: string; + Tax: number; + list_price: number; + unit_price?: number; + quantity_in_stock: number; + total: number; + id: string; + product_description?: string; + line_tax: any[]; + }>; + Excise_Duty?: number; + Shipping_City?: string; + Shipping_Country?: string; + Shipping_Code?: string; + Billing_City: string; + Purchase_Order?: string; + Created_By: { + name: string; + id: string; + email: string; + }; + Shipping_Street?: string; + Description?: string; + Discount: number; + Shipping_State?: string; + $review_process: { + approve: boolean; + reject: boolean; + resubmit: boolean; + }; + $layout_id: { + display_label: string; + name: string; + id: string; + }; + Modified_By: { + name: string; + id: string; + email: string; + }; + $review?: any; + Account_Name: { + name: string; + id: string; + }; + Sales_Commission?: number; + Modified_Time: string; + Due_Date?: string; + Terms_and_Conditions?: string; + Sub_Total: number; + Subject: string; + $orchestration: boolean; + Contact_Name: { + name: string; + id: string; + }; + $in_merge: boolean; + SO_Number: string; + Locked__s: boolean; + Billing_State: string; + $line_tax: any[]; + Tag: string[]; + $approval_state: string; + Pending?: any; +} + +export interface CrmPurchaseOrder { + id: string; + Owner: { + name: string; + id: string; + email: string; + }; + $currency_symbol: string; + Tax: number; + Last_Activity_Time?: string; + PO_Date: string; + $state: string; + $process_flow: boolean; + Billing_Country: string; + $locked_for_me: boolean; + Carrier: string; + $approved: boolean; + Status: string; + Grand_Total: number; + $approval: { + delegate: boolean; + takeover: boolean; + approve: boolean; + reject: boolean; + resubmit: boolean; + }; + PO_Number?: string; + Adjustment: number; + Billing_Street: string; + Created_Time: string; + $editable: boolean; + Billing_Code: string; + Product_Details: Array<{ + product: { + Product_Code: string; + name: string; + id: string; + }; + quantity: number; + Discount: number; + total_after_discount: number; + net_total: number; + book?: string; + Tax: number; + list_price: number; + unit_price: number; + quantity_in_stock: number; + total: number; + id: string; + product_description?: string; + line_tax: any[]; + }>; + Tracking_Number?: string; + Excise_Duty?: number; + Shipping_City?: string; + Shipping_Country?: string; + Shipping_Code?: string; + Billing_City: string; + Requisition_No?: string; + Created_By: { + name: string; + id: string; + email: string; + }; + Shipping_Street?: string; + Description?: string; + Discount: number; + Vendor_Name: { + name: string; + id: string; + }; + Shipping_State?: string; + $review_process: { + approve: boolean; + reject: boolean; + resubmit: boolean; + }; + $layout_id: { + display_label: string; + name: string; + id: string; + }; + Modified_By: { + name: string; + id: string; + email: string; + }; + $review?: any; + Sales_Commission?: number; + Modified_Time: string; + Due_Date?: string; + Terms_and_Conditions?: string; + Sub_Total: number; + Subject: string; + $orchestration: boolean; + Contact_Name?: { + name: string; + id: string; + }; + $in_merge: boolean; + Locked__s: boolean; + Billing_State: string; + $line_tax: any[]; + Tag: string[]; + $approval_state: string; +} + +export interface CrmInvoice { + id: string; + Owner: { + name: string; + id: string; + email: string; + }; + $currency_symbol: string; + Tax: number; + Last_Activity_Time?: string; + $state: string; + $process_flow: boolean; + Billing_Country: string; + $locked_for_me: boolean; + $approved: boolean; + Status: string; + Grand_Total: number; + $approval: { + delegate: boolean; + takeover: boolean; + approve: boolean; + reject: boolean; + resubmit: boolean; + }; + Adjustment: number; + Billing_Street: string; + Created_Time: string; + $editable: boolean; + Billing_Code: string; + Product_Details: Array<{ + product: { + Product_Code: string; + name: string; + id: string; + }; + quantity: number; + Discount: number; + total_after_discount: number; + net_total: number; + book?: string; + Tax: number; + list_price: number; + unit_price?: number; + quantity_in_stock: number; + total: number; + id: string; + product_description?: string; + line_tax: any[]; + }>; + Excise_Duty?: number; + Shipping_City?: string; + Shipping_Country?: string; + Shipping_Code?: string; + Billing_City: string; + Purchase_Order?: string; + Created_By: { + name: string; + id: string; + email: string; + }; + Shipping_Street?: string; + Description?: string; + Discount: number; + Shipping_State?: string; + $review_process: { + approve: boolean; + reject: boolean; + resubmit: boolean; + }; + $layout_id: { + display_label: string; + name: string; + id: string; + }; + Invoice_Date: string; + Modified_By: { + name: string; + id: string; + email: string; + }; + $review?: any; + Account_Name: { + name: string; + id: string; + }; + Sales_Order: { + name: string; + id: string; + }; + Deal_Name__s: { + name: string; + id: string; + }; + Sales_Commission?: number; + Modified_Time: string; + Due_Date: string; + Terms_and_Conditions?: string; + Sub_Total: number; + Invoice_Number: string; + Subject: string; + $orchestration: boolean; + Contact_Name: { + name: string; + id: string; + }; + $in_merge: boolean; + Locked__s: boolean; + Billing_State: string; + $line_tax: any[]; + Tag: string[]; + $approval_state: string; +} + // Enums and Union Types export type LeadStatus = 'New' | 'Contacted' | 'Qualified' | 'Unqualified' | 'Converted' | 'Lost Lead' | 'Not Contacted' | 'Junk Lead'; export type TaskType = 'Call' | 'Email' | 'Meeting' | 'Follow-up' | 'Demo' | 'Proposal' | 'Other'; @@ -162,6 +495,9 @@ export interface CrmData { tasks: CrmTask[]; contacts: CrmContact[]; deals: CrmDeal[]; + salesOrders: CrmSalesOrder[]; + purchaseOrders: CrmPurchaseOrder[]; + invoices: CrmInvoice[]; } // API Response Types @@ -264,6 +600,40 @@ export interface CrmStats { byStage: Record; pipelineValue: number; }; + salesOrders: { + total: number; + totalValue: number; + averageOrderValue: number; + byStatus: Record; + byCountry: Record; + }; + purchaseOrders: { + total: number; + totalValue: number; + averageOrderValue: number; + byStatus: Record; + byVendor: Record; + }; + invoices: { + total: number; + totalValue: number; + averageInvoiceValue: number; + byStatus: Record; + 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 diff --git a/src/modules/integrations/screens/IntegrationsHomeScreen.tsx b/src/modules/integrations/screens/IntegrationsHomeScreen.tsx index 5c6302a..b424f1d 100644 --- a/src/modules/integrations/screens/IntegrationsHomeScreen.tsx +++ b/src/modules/integrations/screens/IntegrationsHomeScreen.tsx @@ -5,7 +5,11 @@ import { useTheme } from '@/shared/styles/useTheme'; import type { StackNavigationProp } from '@react-navigation/stack'; import type { IntegrationsStackParamList } from '@/modules/integrations/navigation/IntegrationsNavigator'; import { useNavigation } from '@react-navigation/native'; +import { useDispatch } from 'react-redux'; import GradientBackground from '@/shared/components/layout/GradientBackground'; +import { Container, ConfirmModal } from '@/shared/components/ui'; +import { logout } from '@/modules/auth/store/authSlice'; +import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice'; type Nav = StackNavigationProp; @@ -30,12 +34,32 @@ interface Props { const IntegrationsHomeScreen: React.FC = () => { const { colors, shadows, fonts } = useTheme(); const navigation = useNavigation(); + const dispatch = useDispatch(); + + const [showLogout, setShowLogout] = React.useState(false); + const handleLogout = () => setShowLogout(true); + const handleConfirmLogout = () => { + setShowLogout(false); + dispatch(clearSelectedService()); + dispatch(logout()); + }; + const handleCancelLogout = () => setShowLogout(false); return ( - Choose a Service + {/* Header with title and logout button */} + + Choose a Service + + + + item.key} @@ -62,6 +86,17 @@ const IntegrationsHomeScreen: React.FC = () => { )} /> + + {/* Logout Confirmation Modal */} + ); }; @@ -70,12 +105,27 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - title: { - fontSize: 20, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', marginTop: 12, marginHorizontal: GUTTER, marginBottom: 4, }, + title: { + fontSize: 20, + flex: 1, + }, + logoutButton: { + width: 40, + height: 40, + borderRadius: 20, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 12, + }, card: { borderRadius: 12, borderWidth: 1, diff --git a/src/modules/integrations/screens/ZohoAuth.tsx b/src/modules/integrations/screens/ZohoAuth.tsx index 19b5665..2ee17e2 100644 --- a/src/modules/integrations/screens/ZohoAuth.tsx +++ b/src/modules/integrations/screens/ZohoAuth.tsx @@ -71,6 +71,8 @@ const getScopeForService = (_serviceKey?: ServiceKey): string => { // Zoho CRM (adjust modules per your needs) 'ZohoCRM.users.READ', 'ZohoCRM.modules.READ', + //settings + 'ZohoCRM.settings.READ', // Zoho Books (use granular scopes if preferred instead of FullAccess) 'ZohoBooks.FullAccess.READ', // Zoho People diff --git a/src/modules/zohoProjects/components/ZohoProjectsDataCards.tsx b/src/modules/zohoProjects/components/ZohoProjectsDataCards.tsx new file mode 100644 index 0000000..6e6ce3e --- /dev/null +++ b/src/modules/zohoProjects/components/ZohoProjectsDataCards.tsx @@ -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 = ({ project, onPress }) => { + const { colors, fonts, spacing, shadows } = useTheme(); + + const getStatusColor = (status: string) => { + const statusColors: Record = { + '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 ( + + + + + + {project.name} + + + + + {project.status?.name || 'Unknown'} + + + + + + + + + Owner: {project.owner?.full_name || project.owner?.name || 'Unknown'} + + + + + + + Type: {project.project_type || 'Unknown'} + + + + + + + Start: {formatDate(project.start_date)} + + + + + + + End: {formatDate(project.end_date)} + + + + + + + Progress + + + {project.percent_complete || 0}% + + + + + + + + + + + + {project.tasks?.closed_count || 0} Tasks + + + + + + {project.issues?.open_count || 0} Issues + + + + + + {project.milestones?.open_count || 0} Milestones + + + + + + ); +}; + +// Task Card Component +interface TaskCardProps { + task: ZohoTask; + onPress: () => void; +} + +export const TaskCard: React.FC = ({ task, onPress }) => { + const { colors, fonts, spacing, shadows } = useTheme(); + + const getPriorityColor = (priority: string) => { + const priorityColors: Record = { + '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 ( + + + + + + {task.name} + + + + + {task.priority?.toUpperCase() || 'NORMAL'} + + + + + + + + + Project: {task.project?.name || 'Unknown'} + + + + + + + Owner: {task.owners_and_work?.owners?.[0]?.name || 'Unassigned'} + + + + + + + Due: {formatDate(task.end_date)} + + + + + + + Status: {task.status?.name || 'Unknown'} + + + + + + + Completion + + + {task.completion_percentage || 0}% + + + + + + + + + + + + {task.duration?.value || '0h'} + + + + + + {task.billing_type || 'Non-billable'} + + + + + + ); +}; + +// Issue Card Component +interface IssueCardProps { + issue: ZohoIssue; + onPress: () => void; +} + +export const IssueCard: React.FC = ({ issue, onPress }) => { + const { colors, fonts, spacing, shadows } = useTheme(); + + const getSeverityColor = (severity: string) => { + const severityColors: Record = { + '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 ( + + + + + + {issue.name} + + + + + {issue.severity?.value?.toUpperCase() || 'NONE'} + + + + + + + + + Project: {issue.project?.name || 'Unknown'} + + + + + + + Assignee: {issue.assignee?.name || 'Unassigned'} + + + + + + + Created: {formatDate(issue.created_time)} + + + + + + + Status: {issue.status?.name || 'Unknown'} + + + + + + + Classification: {issue.classification?.value || 'Unknown'} + + + + + + + Module: {issue.module?.value || 'Unknown'} + + + + + ); +}; + +// Phase Card Component +interface PhaseCardProps { + phase: ZohoPhase; + onPress: () => void; +} + +export const PhaseCard: React.FC = ({ phase, onPress }) => { + const { colors, fonts, spacing, shadows } = useTheme(); + + const getStatusColor = (status: string) => { + const statusColors: Record = { + '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 ( + + + + + + {phase.name} + + + + + {phase.status?.name || 'Unknown'} + + + + + + + + + Project: {phase.project?.name || 'Unknown'} + + + + + + + Owner: {`${phase.owner?.first_name} ${phase.owner?.last_name}` || 'Unknown'} + + + + + + + Start: {formatDate(phase.start_date)} + + + + + + + End: {formatDate(phase.end_date)} + + + + + + + Type: {phase.status_type || 'Unknown'} + + + + + + + Flag: {phase.flag || 'None'} + + + + + ); +}; + +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, + }, +}); diff --git a/src/modules/zohoProjects/navigation/ZohoProjectsNavigator.tsx b/src/modules/zohoProjects/navigation/ZohoProjectsNavigator.tsx index 82e7ee4..3b18640 100644 --- a/src/modules/zohoProjects/navigation/ZohoProjectsNavigator.tsx +++ b/src/modules/zohoProjects/navigation/ZohoProjectsNavigator.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import ZohoProjectsDashboardScreen from '@/modules/zohoProjects/screens/ZohoProjectsDashboardScreen'; +import ZohoProjectsDataScreen from '@/modules/zohoProjects/screens/ZohoProjectsDataScreen'; const Stack = createStackNavigator(); const ZohoProjectsNavigator = () => ( + ); diff --git a/src/modules/zohoProjects/screens/ZohoProjectsDashboardScreen.tsx b/src/modules/zohoProjects/screens/ZohoProjectsDashboardScreen.tsx index 558916e..bbcd3b7 100644 --- a/src/modules/zohoProjects/screens/ZohoProjectsDashboardScreen.tsx +++ b/src/modules/zohoProjects/screens/ZohoProjectsDashboardScreen.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, ScrollView, RefreshControl, StyleSheet } from 'react-native'; +import { View, Text, ScrollView, RefreshControl, StyleSheet, TouchableOpacity } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialIcons'; import { useDispatch, useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui'; import { useTheme } from '@/shared/styles/useTheme'; @@ -25,6 +26,7 @@ import type { RootState } from '@/store/store'; const ZohoProjectsDashboardScreen: React.FC = () => { const { colors, fonts } = useTheme(); const dispatch = useDispatch(); + const navigation = useNavigation(); const [refreshing, setRefreshing] = useState(false); // Redux state @@ -44,6 +46,10 @@ const ZohoProjectsDashboardScreen: React.FC = () => { setRefreshing(false); }; + const handleNavigateToData = () => { + navigation.navigate('ZohoProjectsData' as never); + }; + // Loading state if (isLoading && stats.totalProjects === 0) { return ; @@ -114,7 +120,16 @@ const ZohoProjectsDashboardScreen: React.FC = () => { > Zoho Projects - + + + + + + @@ -399,6 +414,16 @@ const styles = StyleSheet.create({ padding: 16, backgroundColor: '#FFFFFF', }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + dataButton: { + padding: 8, + borderRadius: 8, + backgroundColor: '#F3F4F6', + }, title: { fontSize: 24, }, diff --git a/src/modules/zohoProjects/screens/ZohoProjectsDataScreen.tsx b/src/modules/zohoProjects/screens/ZohoProjectsDataScreen.tsx new file mode 100644 index 0000000..479de16 --- /dev/null +++ b/src/modules/zohoProjects/screens/ZohoProjectsDataScreen.tsx @@ -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(); + 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 ; + } + + if (hasError && !zohoProjectsData.projects.length) { + return ; + } + + const renderTabContent = () => { + switch (selectedTab) { + case 'projects': + return ( + ( + handleCardPress(item, 'Project')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + case 'tasks': + return ( + ( + handleCardPress(item, 'Task')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + case 'issues': + return ( + ( + handleCardPress(item, 'Issue')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + case 'phases': + return ( + ( + handleCardPress(item, 'Phase')} + /> + )} + keyExtractor={(item) => item.id} + numColumns={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ); + default: + return null; + } + }; + + return ( + + + } + > + {/* Header */} + + + Zoho Projects Data + + + + + + + {/* Tabs */} + + + {tabs.map((tab) => ( + setSelectedTab(tab.key)} + activeOpacity={0.8} + > + + + {tab.label} + + + + {tab.count} + + + + ))} + + + + {/* Content */} + + {renderTabContent()} + + + + ); +}; + +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; diff --git a/src/modules/zohoProjects/store/zohoProjectsSlice.ts b/src/modules/zohoProjects/store/zohoProjectsSlice.ts index 25190f7..2deabf2 100644 --- a/src/modules/zohoProjects/store/zohoProjectsSlice.ts +++ b/src/modules/zohoProjects/store/zohoProjectsSlice.ts @@ -79,6 +79,7 @@ export const fetchAllZohoProjectsData = createAsyncThunk( async (params: { refresh?: boolean } = {}, { dispatch }) => { // First fetch projects const projectsResponse = await dispatch(fetchZohoProjects(params)); + console.log('projectsResponse',projectsResponse) if (fetchZohoProjects.fulfilled.match(projectsResponse) && projectsResponse.payload) { const projects = projectsResponse.payload.data.data; diff --git a/src/services/http.ts b/src/services/http.ts index 790d686..26b9cde 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -1,10 +1,15 @@ import { create } from 'apisauce'; import { store } from '@/store/store'; -import { selectAccessToken } from '@/modules/auth/store/selectors'; +import { selectAccessToken ,selectRefreshToken } from '@/modules/auth/store/selectors'; +import { refreshToken, logout } from '@/modules/auth/store/authSlice'; +import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice'; + +// Store for pending requests that need to be retried +let pendingRequest: any = null; const http = create({ - baseURL: 'http://192.168.1.12:4000', - // baseURL: 'http://160.187.167.216', + // baseURL: 'http://192.168.1.24:4000', + baseURL: 'http://160.187.167.216', timeout: 10000, }); @@ -28,6 +33,7 @@ http.addRequestTransform((request) => { const state = store.getState(); const token = selectAccessToken(state); + const refreshToken=selectRefreshToken(state) if (token) { request.headers = { @@ -40,11 +46,130 @@ http.addRequestTransform((request) => { }); // Add response interceptor for error handling -http.addResponseTransform((response) => { +http.addResponseTransform(async (response) => { + console.log('unauthorized response',response) if (response.status === 401) { console.warn('Unauthorized request - token may be expired'); - // You could dispatch a logout action here if needed - // dispatch(logout()); + + // Skip refresh token logic for auth endpoints to avoid infinite loops + const authEndpoints = [ + '/api/v1/auth/login', + '/api/v1/auth/refresh', + '/api/v1/users/register', + '/api/v1/users/signup', + ]; + + const isAuthEndpoint = authEndpoints.some(endpoint => + response.config?.url?.startsWith(endpoint) + ); + + if (isAuthEndpoint) { + return; // Skip refresh token logic for auth endpoints + } + + // Store the original request for retry + pendingRequest = { + method: response.config?.method?.toUpperCase(), + url: response.config?.url, + data: response.config?.data, + params: response.config?.params, + headers: response.config?.headers, + }; + + // Get refresh token from store + const state = store.getState(); + const refreshTokenValue = selectRefreshToken(state); + + if (!refreshTokenValue) { + console.warn('No refresh token available - logging out'); + store.dispatch(logout()); + store.dispatch(clearSelectedService()); + return; + } + + try { + // Attempt to refresh the token + console.log('Attempting to refresh token...'); + const refreshResult = await store.dispatch(refreshToken(refreshTokenValue)); + + if (refreshToken.fulfilled.match(refreshResult)) { + console.log('Token refreshed successfully - retrying original request'); + + // Retry the original request with new token + if (pendingRequest) { + try { + const newState = store.getState(); + const newToken = selectAccessToken(newState); + + if (newToken) { + // Prepare headers with new token + const retryHeaders = { + ...pendingRequest.headers, + Authorization: `Bearer ${newToken}`, + }; + + // Retry the request + let retryResponse; + switch (pendingRequest.method) { + case 'GET': + retryResponse = await http.get(pendingRequest.url, pendingRequest.params, { + headers: retryHeaders, + }); + break; + case 'POST': + retryResponse = await http.post(pendingRequest.url, pendingRequest.data, { + headers: retryHeaders, + }); + break; + case 'PUT': + retryResponse = await http.put(pendingRequest.url, pendingRequest.data, { + headers: retryHeaders, + }); + break; + case 'DELETE': + retryResponse = await http.delete(pendingRequest.url, pendingRequest.params, { + headers: retryHeaders, + }); + break; + case 'PATCH': + retryResponse = await http.patch(pendingRequest.url, pendingRequest.data, { + headers: retryHeaders, + }); + break; + default: + console.warn('Unsupported HTTP method for retry:', pendingRequest.method); + return; + } + + console.log('Original request retried successfully:', retryResponse); + + // Update the original response with retry response data + if (retryResponse) { + response.data = retryResponse.data; + response.status = retryResponse.status; + response.ok = retryResponse.ok; + response.problem = retryResponse.problem; + } + } + } catch (retryError) { + console.error('Error retrying original request:', retryError); + } finally { + // Clear pending request + pendingRequest = null; + } + } + } else { + console.warn('Token refresh failed - logging out'); + store.dispatch(logout()); + store.dispatch(clearSelectedService()); + pendingRequest = null; + } + } catch (error) { + console.error('Error during token refresh:', error); + store.dispatch(logout()); + store.dispatch(clearSelectedService()); + pendingRequest = null; + } } // Log successful requests for debugging (optional) diff --git a/src/shared/constants/API_ENDPOINTS.ts b/src/shared/constants/API_ENDPOINTS.ts index c86ea89..667d490 100644 --- a/src/shared/constants/API_ENDPOINTS.ts +++ b/src/shared/constants/API_ENDPOINTS.ts @@ -1,5 +1,6 @@ export const API_ENDPOINTS = { AUTH_LOGIN: '/api/v1/auth/login', + REFRESH_TOKEN: '/api/v1/auth/refresh', USERSIGNUP:'/api/v1/users/register', MANAGE_TOKEN:'/api/v1/users/zoho/token ', HR_METRICS: '/hr/metrics', diff --git a/src/shared/utils/validation.ts b/src/shared/utils/validation.ts index aab237b..1fbfef7 100644 --- a/src/shared/utils/validation.ts +++ b/src/shared/utils/validation.ts @@ -23,8 +23,61 @@ export const validatePassword = (password: string): ValidationResult => { return { isValid: false, error: 'Password is required' }; } - if (password.length < 6) { - return { isValid: false, error: 'Password must be at least 6 characters long' }; + if (password.length < 8) { + return { isValid: false, error: 'Password must be at least 8 characters long' }; + } + + // Check for at least one uppercase letter + if (!/[A-Z]/.test(password)) { + return { isValid: false, error: 'Password must contain at least one uppercase letter' }; + } + + // Check for at least one lowercase letter + if (!/[a-z]/.test(password)) { + return { isValid: false, error: 'Password must contain at least one lowercase letter' }; + } + + // Check for at least one number + if (!/\d/.test(password)) { + return { isValid: false, error: 'Password must contain at least one number' }; + } + + // Check for at least one special character + if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + return { isValid: false, error: 'Password must contain at least one special character' }; + } + + return { isValid: true }; +}; + +export const validateName = (name: string, fieldName: string): ValidationResult => { + if (!name.trim()) { + return { isValid: false, error: `${fieldName} is required` }; + } + + if (name.trim().length < 1) { + return { isValid: false, error: `${fieldName} must be at least 1 characters long` }; + } + + if (name.trim().length > 50) { + return { isValid: false, error: `${fieldName} must be less than 50 characters` }; + } + + // Check for valid name characters (letters, spaces, hyphens, apostrophes) + if (!/^[a-zA-Z\s\-']+$/.test(name.trim())) { + return { isValid: false, error: `${fieldName} can only contain letters, spaces, hyphens, and apostrophes` }; + } + + return { isValid: true }; +}; + +export const validateConfirmPassword = (password: string, confirmPassword: string): ValidationResult => { + if (!confirmPassword) { + return { isValid: false, error: 'Please confirm your password' }; + } + + if (password !== confirmPassword) { + return { isValid: false, error: 'Passwords do not match' }; } return { isValid: true }; @@ -42,3 +95,41 @@ export const validateLoginForm = (email: string, password: string): { isValid: b }, }; }; + +export const validateSignupForm = ( + email: string, + password: string, + confirmPassword: string, + firstName: string, + lastName: string +): { + isValid: boolean; + errors: { + email?: string; + password?: string; + confirmPassword?: string; + firstName?: string; + lastName?: string; + } +} => { + const emailValidation = validateEmail(email); + const passwordValidation = validatePassword(password); + const confirmPasswordValidation = validateConfirmPassword(password, confirmPassword); + const firstNameValidation = validateName(firstName, 'First name'); + const lastNameValidation = validateName(lastName, 'Last name'); + + return { + isValid: emailValidation.isValid && + passwordValidation.isValid && + confirmPasswordValidation.isValid && + firstNameValidation.isValid && + lastNameValidation.isValid, + errors: { + email: emailValidation.error, + password: passwordValidation.error, + confirmPassword: confirmPasswordValidation.error, + firstName: firstNameValidation.error, + lastName: lastNameValidation.error, + }, + }; +};