feat(app): support multi-tenancy

This commit is contained in:
uttam05111990 2026-08-12 18:21:02 +05:30
parent 18077e260c
commit b967baa235
13 changed files with 144 additions and 112 deletions

View File

@ -11,7 +11,7 @@ export const updateProfileApi = async (
if (payload.firstname !== undefined) formData.append('firstname', payload.firstname); if (payload.firstname !== undefined) formData.append('firstname', payload.firstname);
if (payload.lastname !== undefined) formData.append('lastname', payload.lastname); if (payload.lastname !== undefined) formData.append('lastname', payload.lastname);
if (payload.phone !== undefined) formData.append('phone', payload.phone); if (payload.phonenumber !== undefined) formData.append('phonenumber', payload.phonenumber);
if (payload.email_signature !== undefined) formData.append('email_signature', payload.email_signature); if (payload.email_signature !== undefined) formData.append('email_signature', payload.email_signature);
if (payload.password) formData.append('password', payload.password); if (payload.password) formData.append('password', payload.password);
if (payload.passwordr) formData.append('passwordr', payload.passwordr); if (payload.passwordr) formData.append('passwordr', payload.passwordr);

View File

@ -3,7 +3,6 @@ import {
View, View,
Text, Text,
ActivityIndicator, ActivityIndicator,
ScrollView,
TouchableOpacity, TouchableOpacity,
} from 'react-native'; } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
@ -143,11 +142,7 @@ export const ProjectActivityFeed = ({
</View> </View>
) : ( ) : (
<> <>
<ScrollView <View style={styles.listContent}>
style={styles.listContent}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
>
{displayData.map((item, index) => ( {displayData.map((item, index) => (
<ActivityRow <ActivityRow
key={item.id} key={item.id}
@ -158,7 +153,7 @@ export const ProjectActivityFeed = ({
textMutedColor={colors.textMuted} textMutedColor={colors.textMuted}
/> />
))} ))}
</ScrollView> </View>
{hasMore && ( {hasMore && (
<View style={styles.footer}> <View style={styles.footer}>

View File

@ -15,7 +15,7 @@ import Icon from 'react-native-vector-icons/Ionicons';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { FormInput, Loader } from '@components'; import { FormInput, Loader } from '@components';
import { useAppDispatch, useAppSelector, RootState, updateUserData } from '@store'; import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getStyles } from './editProfile.styles'; import { getStyles } from './editProfile.styles';
import { updateProfile, getStaffDetails, resetEditProfileState } from './thunk'; import { updateProfile, getStaffDetails, resetEditProfileState } from './thunk';
import ImagePicker from 'react-native-image-crop-picker'; import ImagePicker from 'react-native-image-crop-picker';
@ -43,6 +43,9 @@ export const EditProfileScreen = () => {
const [profileImageMime, setProfileImageMime] = useState('image/jpeg'); const [profileImageMime, setProfileImageMime] = useState('image/jpeg');
const [profileImageName, setProfileImageName] = useState('profile_image.jpg'); const [profileImageName, setProfileImageName] = useState('profile_image.jpg');
const [isImageChanged, setIsImageChanged] = useState(false); const [isImageChanged, setIsImageChanged] = useState(false);
// UI toggle state
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const resolveServerUrl = async (path?: string | null) => { const resolveServerUrl = async (path?: string | null) => {
if (!path) return ''; if (!path) return '';
@ -62,36 +65,26 @@ export const EditProfileScreen = () => {
return `${cleanBase}/${cleanRelPath}`; return `${cleanBase}/${cleanRelPath}`;
}; };
useEffect(() => {
resolveServerUrl(userData?.profile_image).then((url) => {
setProfileImageUri(url);
});
}, [userData?.profile_image]);
// UI toggle state
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Fetch staff details on mount // Fetch staff details on mount
// useEffect(() => { useEffect(() => {
// if (userData?.staffid) { if (userData?.staffid) {
// dispatch(getStaffDetails(userData.staffid)); dispatch(getStaffDetails(userData.staffid));
// } }
// }, [dispatch, userData?.staffid]); }, [dispatch, userData?.staffid]);
// Update local form state when details are loaded from the server // Update local form state when details are loaded from the server
// useEffect(() => { useEffect(() => {
// if (staffDetails) { if (staffDetails) {
// setFirstname(staffDetails.firstname || ''); setFirstname(staffDetails.firstname || '');
// setLastname(staffDetails.lastname || ''); setLastname(staffDetails.lastname || '');
// setEmail(staffDetails.email || ''); setEmail(staffDetails.email || '');
// setPhone(staffDetails.phonenumber || ''); setPhone(staffDetails.phonenumber || '');
// setEmailSignature(staffDetails.email_signature || ''); setEmailSignature(staffDetails.email_signature || '');
// resolveServerUrl(userData?.profile_image).then((url) => { resolveServerUrl(userData?.profile_image).then((url) => {
// setProfileImageUri(url); setProfileImageUri(url);
// }); });
// } }
// }, [staffDetails, userData?.profile_image]); }, [staffDetails, userData?.profile_image]);
// Reset edit profile state on unmount // Reset edit profile state on unmount
useEffect(() => { useEffect(() => {
@ -100,6 +93,34 @@ export const EditProfileScreen = () => {
}; };
}, [dispatch]); }, [dispatch]);
// Navigate back on success
useEffect(() => {
if (successMessage) {
Alert.alert('Success', successMessage, [
{
text: 'OK',
onPress: () => {
dispatch(resetEditProfileState());
navigation.goBack();
},
},
]);
}
}, [successMessage]);
// Show error message from state
useEffect(() => {
if (error) {
Alert.alert('Error', error, [
{
text: 'OK',
onPress: () => dispatch(resetEditProfileState()),
},
]);
}
}, [error]);
const handleCameraLaunch = () => { const handleCameraLaunch = () => {
ImagePicker.openCamera({ ImagePicker.openCamera({
width: 400, width: 400,
@ -203,40 +224,13 @@ export const EditProfileScreen = () => {
email, email,
firstname: firstname || undefined, firstname: firstname || undefined,
lastname: lastname || undefined, lastname: lastname || undefined,
phone: phone || undefined, phonenumber: phone || undefined,
email_signature: emailSignature || undefined, email_signature: emailSignature || undefined,
password: password || undefined, password: password || undefined,
passwordr: passwordr || undefined, passwordr: passwordr || undefined,
profile_image: profileImagePayload, profile_image: profileImagePayload,
}), }),
) );
.unwrap()
.then((response) => {
// Success
const updatedImage = response.user_data?.profile_image || userData?.profile_image;
dispatch(
updateUserData({
firstname,
lastname,
email,
phonenumber: phone,
email_signature: emailSignature,
profile_image: updatedImage,
}),
);
Alert.alert('Success', 'Profile updated successfully.', [
{
text: 'OK',
onPress: () => {
dispatch(resetEditProfileState());
navigation.goBack();
},
},
]);
})
.catch((err) => {
Alert.alert('Error', err || 'Failed to update profile');
});
}; };
const staffId = userData?.staffid || ''; const staffId = userData?.staffid || '';

View File

@ -114,9 +114,9 @@ export const LeadsScreen = () => {
placeholder="Search leads, companies, emails..." placeholder="Search leads, companies, emails..."
style={styles.searchInput} style={styles.searchInput}
/> />
<TouchableOpacity style={styles.filterButton}> {/* <TouchableOpacity style={styles.filterButton}>
<Icon name="filter-outline" size={20} color={colors.text} /> <Icon name="filter-outline" size={20} color={colors.text} />
</TouchableOpacity> </TouchableOpacity> */}
</View> </View>
{/* Status count cards */} {/* Status count cards */}

View File

@ -17,8 +17,8 @@ import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState, login } from '@store'; import { useAppDispatch, useAppSelector, RootState, login } from '@store';
import { LoginRequest } from '@interfaces'; import { LoginRequest } from '@interfaces';
import { NotificationService } from '@services'; import { NotificationService } from '@services';
import { backGroundImage } from '@utils'; import { backGroundImage, TENANCY_OPTIONS } from '@utils';
import { FormInput } from '@components'; import { FormInput, FormPicker } from '@components';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import DeviceInfo from 'react-native-device-info'; import DeviceInfo from 'react-native-device-info';
@ -97,8 +97,14 @@ export const LoginScreen = () => {
const savedPassword = await AsyncStorage.getItem('password'); const savedPassword = await AsyncStorage.getItem('password');
if (savedBaseUrl) { if (savedBaseUrl) {
const cleanBaseUrl = savedBaseUrl.replace(/^(https?:\/\/)?/, ''); const matched = TENANCY_OPTIONS.find(
setTenancy(cleanBaseUrl); opt =>
opt.value.trim().replace(/\/+$/, '').toLowerCase() ===
savedBaseUrl.trim().replace(/\/+$/, '').toLowerCase(),
);
setTenancy(matched ? matched.value : savedBaseUrl);
} else if (TENANCY_OPTIONS.length > 0) {
setTenancy(TENANCY_OPTIONS[0].value);
} }
if (savedEmail) setEmail(savedEmail); if (savedEmail) setEmail(savedEmail);
if (savedPassword) setPassword(savedPassword); if (savedPassword) setPassword(savedPassword);
@ -180,39 +186,13 @@ export const LoginScreen = () => {
</View> </View>
) : null} ) : null}
{/* Tenancy Field */} {/* Tenancy Environment Field */}
<FormInput <FormPicker
label="Tenancy Name" label="Tenancy Name"
value={tenancy} value={tenancy}
onChangeText={(text) => { onValueChange={(val) => setTenancy(val)}
const cleaned = text.replace(/^(https?:\/\/)?/, ''); options={TENANCY_OPTIONS}
setTenancy(cleaned); placeholder="Select Tenancy Environment"
}}
prefix="https://"
placeholder="company-name"
autoCapitalize="none"
autoCorrect={false}
containerStyle={styles.fieldContainer}
inputContainerStyle={[
styles.inputWrapper,
focusedField === 'tenancy' && styles.inputWrapperFocused,
]}
onFocus={() => {
setFocusedField('tenancy');
setTimeout(() => scrollViewRef.current?.scrollToEnd({ animated: true }), 100);
}}
onBlur={() => setFocusedField(null)}
leftIcon={
<Icon
name="business-outline"
size={20}
color={
focusedField === 'tenancy'
? colors.icon
: colors.textSecondary
}
/>
}
/> />
{/* Email Field */} {/* Email Field */}

View File

@ -3,7 +3,7 @@ export interface UpdateProfilePayload {
email: string; email: string;
firstname?: string; firstname?: string;
lastname?: string; lastname?: string;
phone?: string; phonenumber?: string;
email_signature?: string; email_signature?: string;
password?: string; password?: string;
passwordr?: string; passwordr?: string;

View File

@ -156,4 +156,10 @@ export const getStyles = (colors: ThemeColors) =>
fontWeight: '700', fontWeight: '700',
color: '#EF4444', color: '#EF4444',
}, },
versionText: {
fontSize: 11,
color: colors.textMuted,
textAlign: 'center',
marginTop: 10,
},
}); });

View File

@ -222,6 +222,7 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
/> />
<Text style={styles.logoutText}>Sign Out</Text> <Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.versionText}>App Version: 1.0.0</Text>
</View> </View>
</View> </View>
); );

View File

@ -1,9 +1,8 @@
import { createAction, createReducer } from '@reduxjs/toolkit'; import { createReducer } from '@reduxjs/toolkit';
import { login } from './thunk'; import { login } from './thunk';
import { UserData } from '@interfaces'; import { UserData } from '@interfaces';
import { updateProfile } from '../../../features/editProfile/thunk';
// Patch user_data in auth state after a successful profile update
export const updateUserData = createAction<Partial<UserData>>('auth/updateUserData');
export interface LoginState { export interface LoginState {
loginSuccess: boolean; loginSuccess: boolean;
@ -50,9 +49,10 @@ export const authReducer = createReducer(initialState, builder => {
acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed'; acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed';
acc.loginSuccess = false; acc.loginSuccess = false;
}) })
.addCase(updateUserData, (acc, action) => { // Sync auth user_data after a successful profile update
if (acc.user_data) { .addCase(updateProfile.fulfilled, (acc, action) => {
acc.user_data = { ...acc.user_data, ...action.payload }; if (acc.user_data && action.payload.user_data) {
acc.user_data = { ...acc.user_data, ...action.payload.user_data };
} }
}); });
}); });

View File

@ -1,22 +1,26 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import config from 'react-native-config';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { getAuthTokenForBaseUrl } from '@utils';
import {Store} from '@reduxjs/toolkit'; import {Store} from '@reduxjs/toolkit';
const axiosInstance: AxiosInstance = axios.create({ const axiosInstance: AxiosInstance = axios.create({
timeout: 15000, timeout: 15000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
authtoken: config.AUTH_TOKEN,
}, },
}); });
axiosInstance.interceptors.request.use( axiosInstance.interceptors.request.use(
async request => { async request => {
// Dynamically set baseURL from AsyncStorage on every request // Dynamically set baseURL and authtoken from AsyncStorage on every request
const storedBaseUrl = await AsyncStorage.getItem('base_url'); const storedBaseUrl = await AsyncStorage.getItem('base_url');
if (storedBaseUrl) { if (storedBaseUrl) {
request.baseURL = storedBaseUrl; request.baseURL = storedBaseUrl;
const authToken = getAuthTokenForBaseUrl(storedBaseUrl);
if (authToken) {
request.headers.authtoken = authToken;
}
} }
if (request.data instanceof FormData) { if (request.data instanceof FormData) {

33
app/utils/enviroment.ts Normal file
View File

@ -0,0 +1,33 @@
import config from 'react-native-config';
export const normalizeUrl = (url?: string): string => {
if (!url) return '';
return url.trim().replace(/\/+$/, '').toLowerCase();
};
export const getAuthTokenForBaseUrl = (baseUrl: string | null): string => {
if (!baseUrl) return '';
const currentUrl = normalizeUrl(baseUrl);
const envConfigs = [
{
baseUrl: config.DEMO_CONVEXCRM_BASE_URL,
authToken: config.DEMO_CONVEXCRM_AUTH_TOKEN,
},
{
baseUrl: config.CONVEXSOL_CONVEXCRM_BASE_URL,
authToken: config.CONVEXSOL_CONVEXCRM_AUTH_TOKEN,
},
{
baseUrl: config.INTERNAL_CONVEXCRM_BASE_URL,
authToken: config.INTERNAL_CONVEXCRM_AUTH_TOKEN,
},
];
const matched = envConfigs.find(
item => item.baseUrl && normalizeUrl(item.baseUrl) === currentUrl,
);
return matched?.authToken || '';
};

View File

@ -3,3 +3,5 @@ export * from './assets';
export * from './api'; export * from './api';
export * from './helper'; export * from './helper';
export * from './navigationRef'; export * from './navigationRef';
export * from './tenencyOptions';
export * from './enviroment';

View File

@ -0,0 +1,17 @@
import config from 'react-native-config';
import { FormPickerOption } from '@components';
export const TENANCY_OPTIONS: FormPickerOption[] = [
{
label: 'Demo Convex CRM (demo-convexcrm.convexsol.co)',
value: config.DEMO_CONVEXCRM_BASE_URL || '',
},
{
label: 'ConvexSol CRM (convexsol.convexcrm.com)',
value: config.CONVEXSOL_CONVEXCRM_BASE_URL || '',
},
{
label: 'Internal CRM (internal.convexcrm.com)',
value: config.INTERNAL_CONVEXCRM_BASE_URL || '',
},
];