first commit
parent
153d216a1c
commit
75653e4ca5
48
App.tsx
48
App.tsx
|
|
@ -1,45 +1,13 @@
|
||||||
/**
|
import React from 'react';
|
||||||
* Sample React Native App
|
import { AuthProvider } from './src/context/AuthContext';
|
||||||
* https://github.com/facebook/react-native
|
import AppNav from './src/navigation/AppNav';
|
||||||
*
|
|
||||||
* @format
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NewAppScreen } from '@react-native/new-app-screen';
|
|
||||||
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
|
|
||||||
import {
|
|
||||||
SafeAreaProvider,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<AuthProvider>
|
||||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
<AppNav />
|
||||||
<AppContent />
|
</AuthProvider>
|
||||||
</SafeAreaProvider>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function AppContent() {
|
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<NewAppScreen
|
|
||||||
templateFileName="App.tsx"
|
|
||||||
safeAreaInsets={safeAreaInsets}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ react {
|
||||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||||
// extraPackagerArgs = []
|
// extraPackagerArgs = []
|
||||||
|
|
||||||
|
/* Debugging */
|
||||||
|
// Bundle the JS code for debug builds (allows offline testing)
|
||||||
|
// bundleInDebug = true (Not supported in this version)
|
||||||
|
|
||||||
/* Hermes Commands */
|
/* Hermes Commands */
|
||||||
// The hermes compiler command to run. By default it is 'hermesc'
|
// The hermes compiler command to run. By default it is 'hermesc'
|
||||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||||
|
|
@ -116,4 +120,5 @@ dependencies {
|
||||||
} else {
|
} else {
|
||||||
implementation jscFlavor
|
implementation jscFlavor
|
||||||
}
|
}
|
||||||
|
implementation "com.google.android.gms:play-services-location:${rootProject.ext.googlePlayServicesLocationVersion}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
|
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
|
|
@ -9,7 +13,7 @@
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
android:usesCleartextTraffic="true"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">mobile</string>
|
<string name="app_name">IgCRM</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ buildscript {
|
||||||
targetSdkVersion = 36
|
targetSdkVersion = 36
|
||||||
ndkVersion = "27.1.12297006"
|
ndkVersion = "27.1.12297006"
|
||||||
kotlinVersion = "2.1.20"
|
kotlinVersion = "2.1.20"
|
||||||
|
googlePlayServicesLocationVersion = "21.0.1"
|
||||||
}
|
}
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
|
|
@ -10,15 +10,28 @@
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"@react-native/new-app-screen": "0.83.1",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||||
|
"@react-navigation/native": "^7.1.26",
|
||||||
|
"@react-navigation/native-stack": "^7.9.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"lucide-react-native": "^1.8.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-native": "0.83.1",
|
"react-native": "0.83.1",
|
||||||
"@react-native/new-app-screen": "0.83.1",
|
"react-native-biometrics": "^3.0.1",
|
||||||
"react-native-safe-area-context": "^5.5.2"
|
"react-native-geolocation-service": "^5.3.1",
|
||||||
|
"react-native-keychain": "^10.0.0",
|
||||||
|
"react-native-maps": "^1.27.2",
|
||||||
|
"react-native-safe-area-context": "^5.6.2",
|
||||||
|
"react-native-screens": "^4.19.0",
|
||||||
|
"react-native-svg": "^15.15.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
"@babel/preset-env": "^7.25.3",
|
"@babel/preset-env": "^7.25.3",
|
||||||
"@babel/runtime": "^7.25.0",
|
"@babel/runtime": "^7.25.0",
|
||||||
|
"@bam.tech/react-native-make": "^3.0.3",
|
||||||
"@react-native-community/cli": "20.0.0",
|
"@react-native-community/cli": "20.0.0",
|
||||||
"@react-native-community/cli-platform-android": "20.0.0",
|
"@react-native-community/cli-platform-android": "20.0.0",
|
||||||
"@react-native-community/cli-platform-ios": "20.0.0",
|
"@react-native-community/cli-platform-ios": "20.0.0",
|
||||||
|
|
@ -38,4 +51,4 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const filePath = 'c:\\ignosidev\\Igcrm\\apps\\mobile\\src\\screens\\AttendanceScreen.js';
|
||||||
|
let content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// 1. Add Linking to imports
|
||||||
|
content = content.replace(
|
||||||
|
/import { View, Text, StyleSheet, PermissionsAndroid, Platform, Alert, ActivityIndicator, TouchableOpacity, FlatList, RefreshControl } from 'react-native';/,
|
||||||
|
"import { View, Text, StyleSheet, PermissionsAndroid, Platform, Alert, ActivityIndicator, TouchableOpacity, FlatList, RefreshControl, Linking } from 'react-native';"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Add openMap function and update renderHistoryItem
|
||||||
|
const openMapFunc = ` const openMap = (lat, lng) => {
|
||||||
|
const url = Platform.select({
|
||||||
|
ios: \`maps:0,0?q=\${lat},\${lng}\`,
|
||||||
|
android: \`geo:0,0?q=\${lat},\${lng}(Attendance Location)\`
|
||||||
|
});
|
||||||
|
Linking.openURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHistoryItem = ({ item }) => (
|
||||||
|
<View style={styles.historyCard}>
|
||||||
|
<View style={styles.dateBox}>
|
||||||
|
<Text style={styles.dateText}>{getDayMonth(item.checkInTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.timeBox}>
|
||||||
|
<View style={styles.timeRow}>
|
||||||
|
<Text style={styles.timeLabel}>In:</Text>
|
||||||
|
<Text style={styles.timeValue}>{formatDate(item.checkInTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.timeRow}>
|
||||||
|
<Text style={styles.timeLabel}>Out:</Text>
|
||||||
|
<Text style={styles.timeValue}>{item.checkOutTime ? formatDate(item.checkOutTime) : 'Active'}</Text>
|
||||||
|
</View>
|
||||||
|
{(item.checkInLat && item.checkInLng) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => openMap(item.checkInLat, item.checkInLng)}
|
||||||
|
style={styles.locationContainer}
|
||||||
|
>
|
||||||
|
<Text style={styles.locationText}>
|
||||||
|
📍 {item.checkInLat.toFixed(4)}, {item.checkInLng.toFixed(4)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.mapLink}>View Map</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statusIndicator, item.checkOutTime ? styles.statusCompleted : styles.statusActive]} />
|
||||||
|
</View>
|
||||||
|
);`;
|
||||||
|
|
||||||
|
content = content.replace(/const renderHistoryItem = \({ item }\) => \([\s\S]*?\n\s{4}\);/, openMapFunc);
|
||||||
|
|
||||||
|
// 3. Add styles
|
||||||
|
const newStyles = ` emptyText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 30,
|
||||||
|
color: Colors.textLight
|
||||||
|
},
|
||||||
|
locationContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: Colors.borderLight,
|
||||||
|
},
|
||||||
|
locationText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginRight: 10
|
||||||
|
},
|
||||||
|
mapLink: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.secondary,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textDecorationLine: 'underline'
|
||||||
|
}
|
||||||
|
});`;
|
||||||
|
|
||||||
|
content = content.replace(/emptyText: {[\s\S]*?\n\s{4}}\n}\);/, newStyles);
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
console.log('File updated successfully!');
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
// Environment Configuration
|
||||||
|
const ENV = {
|
||||||
|
dev: {
|
||||||
|
API_URL: 'http://192.168.29.100:3000', // Local Dev IP
|
||||||
|
},
|
||||||
|
prod: {
|
||||||
|
API_URL: 'https://crmapi.ignosimoney.in', // Change this to your public IP/Domain
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set to 'prod' when deploying
|
||||||
|
export default ENV.prod;
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
const Colors = {
|
||||||
|
// Brand Colors (Odoo 17/18)
|
||||||
|
primary: '#714B67', // Odoo Purple
|
||||||
|
secondary: '#00A09D', // Odoo Teal
|
||||||
|
accent: '#E7F3F2', // Light Teal Accent
|
||||||
|
|
||||||
|
// UI Colors
|
||||||
|
background: '#F8FAFC', // Light slate foundation
|
||||||
|
backgroundSecondary: '#F1F5F9',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
text: '#1E293B', // Dark slate text
|
||||||
|
textMuted: '#64748B', // Slate gray
|
||||||
|
textLight: '#94A3B8', // Light slate gray
|
||||||
|
border: '#E2E8F0',
|
||||||
|
borderLight: '#F1F5F9',
|
||||||
|
|
||||||
|
// Status Colors
|
||||||
|
success: '#00A09D',
|
||||||
|
warning: '#FACC15',
|
||||||
|
danger: '#EF4444',
|
||||||
|
info: '#3B82F6',
|
||||||
|
|
||||||
|
// Transparent Variants
|
||||||
|
primaryLight: 'rgba(113, 75, 103, 0.1)',
|
||||||
|
secondaryLight: 'rgba(0, 160, 157, 0.1)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Colors;
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React, { createContext, useState, useEffect } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import * as Keychain from 'react-native-keychain';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
export const AuthContext = createContext();
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [userToken, setUserToken] = useState(null);
|
||||||
|
const [userInfo, setUserInfo] = useState(null);
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post('/auth/login', { email, password });
|
||||||
|
const { access_token, user } = response.data;
|
||||||
|
|
||||||
|
setUserInfo(user);
|
||||||
|
setUserToken(access_token);
|
||||||
|
await AsyncStorage.setItem('userToken', access_token);
|
||||||
|
await AsyncStorage.setItem('userInfo', JSON.stringify(user));
|
||||||
|
|
||||||
|
// Save credentials for biometrics
|
||||||
|
console.log("Saving credentials to Keychain for:", email);
|
||||||
|
const saved = await Keychain.setGenericPassword(email, password, { service: 'igcrm_biometric' });
|
||||||
|
console.log("Keychain save result:", saved);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Login Error", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setUserToken(null);
|
||||||
|
AsyncStorage.removeItem('userToken');
|
||||||
|
AsyncStorage.removeItem('userInfo');
|
||||||
|
Keychain.resetGenericPassword();
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
let userToken = await AsyncStorage.getItem('userToken');
|
||||||
|
let userInfo = await AsyncStorage.getItem('userInfo');
|
||||||
|
|
||||||
|
if (userToken) {
|
||||||
|
setUserToken(userToken);
|
||||||
|
setUserInfo(JSON.parse(userInfo));
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`isLogged in error ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isLoggedIn();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ login, logout, isLoading, userToken, userInfo }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
import { LayoutDashboard, Briefcase, Users, MoreHorizontal, CheckSquare } from 'lucide-react-native';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
import LoginScreen from '../screens/LoginScreen';
|
||||||
|
import HomeScreen from '../screens/HomeScreen';
|
||||||
|
import AttendanceScreen from '../screens/AttendanceScreen';
|
||||||
|
import ClientListScreen from '../screens/ClientListScreen';
|
||||||
|
import AddClientScreen from '../screens/AddClientScreen';
|
||||||
|
import ClientDetailsScreen from '../screens/ClientDetailsScreen';
|
||||||
|
import EditClientScreen from '../screens/EditClientScreen';
|
||||||
|
import PipelineScreen from '../screens/PipelineScreen';
|
||||||
|
|
||||||
|
import EnquiryScreen from '../screens/EnquiryScreen';
|
||||||
|
import EnquiryListScreen from '../screens/EnquiryListScreen';
|
||||||
|
import ExpenseScreen from '../screens/ExpenseScreen';
|
||||||
|
import IncentiveScreen from '../screens/IncentiveScreen';
|
||||||
|
import LogActivityScreen from '../screens/LogActivityScreen';
|
||||||
|
import MyTargetScreen from '../screens/MyTargetScreen';
|
||||||
|
import TasksScreen from '../screens/TasksScreen';
|
||||||
|
import CallLogsScreen from '../screens/CallLogsScreen';
|
||||||
|
import ChangePasswordScreen from '../screens/ChangePasswordScreen';
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator();
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
const TabNavigator = () => (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
tabBarIcon: ({ focused, color, size }) => {
|
||||||
|
let Icon;
|
||||||
|
if (route.name === 'Dashboard') Icon = LayoutDashboard;
|
||||||
|
else if (route.name === 'Pipeline') Icon = Briefcase;
|
||||||
|
else if (route.name === 'Clients') Icon = Users;
|
||||||
|
else if (route.name === 'Tasks') Icon = CheckSquare;
|
||||||
|
else Icon = MoreHorizontal;
|
||||||
|
return <Icon size={size} color={color} />;
|
||||||
|
},
|
||||||
|
tabBarActiveTintColor: Colors.primary,
|
||||||
|
tabBarInactiveTintColor: Colors.textLight,
|
||||||
|
tabBarStyle: { height: 60, paddingBottom: 10 },
|
||||||
|
headerStyle: { backgroundColor: Colors.primary },
|
||||||
|
headerTintColor: '#fff',
|
||||||
|
headerTitleStyle: { fontWeight: 'bold' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Tab.Screen name="Dashboard" component={HomeScreen} />
|
||||||
|
<Tab.Screen name="Pipeline" component={PipelineScreen} />
|
||||||
|
<Tab.Screen name="Clients" component={ClientListScreen} />
|
||||||
|
<Tab.Screen name="Tasks" component={TasksScreen} />
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AppNav = () => {
|
||||||
|
const { isLoading, userToken } = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator size={'large'} color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContainer>
|
||||||
|
<Stack.Navigator>
|
||||||
|
{userToken !== null ? (
|
||||||
|
<>
|
||||||
|
<Stack.Screen name="Main" component={TabNavigator} options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="Attendance" component={AttendanceScreen} />
|
||||||
|
<Stack.Screen name="AddClient" component={AddClientScreen} />
|
||||||
|
<Stack.Screen name="ClientDetails" component={ClientDetailsScreen} options={{ title: 'Client Details' }} />
|
||||||
|
<Stack.Screen name="EditClient" component={EditClientScreen} options={{ title: 'Edit Client' }} />
|
||||||
|
<Stack.Screen name="EnquiryList" component={EnquiryListScreen} options={{ title: 'Enquiries' }} />
|
||||||
|
<Stack.Screen name="Enquiry" component={EnquiryScreen} options={{ title: 'Add Enquiry' }} />
|
||||||
|
<Stack.Screen name="Expense" component={ExpenseScreen} />
|
||||||
|
<Stack.Screen name="Incentive" component={IncentiveScreen} />
|
||||||
|
<Stack.Screen name="LogActivity" component={LogActivityScreen} options={{ title: 'Log Activity' }} />
|
||||||
|
<Stack.Screen name="MyTarget" component={MyTargetScreen} options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="TasksDetail" component={TasksScreen} options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="CallLogs" component={CallLogsScreen} options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="ChangePassword" component={ChangePasswordScreen} options={{ headerShown: false }} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Stack.Screen name="Login" component={LoginScreen} options={{ headerShown: false }} />
|
||||||
|
)}
|
||||||
|
</Stack.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppNav;
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, TextInput, Button, StyleSheet, Alert, ScrollView, Platform, PermissionsAndroid, ActivityIndicator } from 'react-native';
|
||||||
|
import Geolocation from 'react-native-geolocation-service';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const AddClientScreen = ({ navigation }) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [landmark, setLandmark] = useState('');
|
||||||
|
const [location, setLocation] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
|
|
||||||
|
const requestLocationPermission = async () => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
try {
|
||||||
|
const granted = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
{
|
||||||
|
title: "Location Permission",
|
||||||
|
message: "IgCRM needs access to your location to tag client location.",
|
||||||
|
buttonNeutral: "Ask Me Later",
|
||||||
|
buttonNegative: "Cancel",
|
||||||
|
buttonPositive: "OK"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentLocation = async () => {
|
||||||
|
const hasPermission = await requestLocationPermission();
|
||||||
|
if (!hasPermission) return;
|
||||||
|
|
||||||
|
setLocating(true);
|
||||||
|
Geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
console.log('Location success:', position);
|
||||||
|
setLocation(position.coords);
|
||||||
|
setLocating(false);
|
||||||
|
Alert.alert("Success", "Location Captured!");
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log('Location error:', error);
|
||||||
|
setLocating(false);
|
||||||
|
Alert.alert("Location Error", error.message);
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 10000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name || !phone) {
|
||||||
|
Alert.alert("Error", "Name and Phone are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Current Location Check Before Submit:', location);
|
||||||
|
if (!location) {
|
||||||
|
Alert.alert("Debug", "Location state is null! Did you click capture?");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
status: 'LEAD',
|
||||||
|
...(email ? { email } : {}),
|
||||||
|
...(address ? { address } : {}),
|
||||||
|
...(landmark ? { landmark } : {}),
|
||||||
|
...(location ? { lat: location.latitude, lng: location.longitude } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Submitting Payload:', JSON.stringify(payload, null, 2));
|
||||||
|
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/clients', payload);
|
||||||
|
Alert.alert("Success", "Client Added Successfully", [
|
||||||
|
{ text: "OK", onPress: () => navigation.goBack() }
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert("Error", "Failed to add client");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Text style={styles.label}>Name *</Text>
|
||||||
|
<TextInput style={styles.input} value={name} onChangeText={setName} />
|
||||||
|
|
||||||
|
<Text style={styles.label}>Phone *</Text>
|
||||||
|
<TextInput style={styles.input} value={phone} onChangeText={setPhone} keyboardType="phone-pad" />
|
||||||
|
|
||||||
|
<Text style={styles.label}>Email</Text>
|
||||||
|
<TextInput style={styles.input} value={email} onChangeText={setEmail} keyboardType="email-address" />
|
||||||
|
|
||||||
|
<Text style={styles.label}>Address</Text>
|
||||||
|
<TextInput style={styles.input} value={address} onChangeText={setAddress} multiline />
|
||||||
|
|
||||||
|
<Text style={styles.label}>Landmark</Text>
|
||||||
|
<TextInput style={styles.input} value={landmark} onChangeText={setLandmark} />
|
||||||
|
|
||||||
|
<View style={styles.locationContainer}>
|
||||||
|
<Button
|
||||||
|
title={locating ? "Locating..." : (location ? "Update Location" : "Capture Location")}
|
||||||
|
onPress={getCurrentLocation}
|
||||||
|
disabled={locating}
|
||||||
|
color={Colors.secondary}
|
||||||
|
/>
|
||||||
|
{location && (
|
||||||
|
<Text style={styles.locationText}>
|
||||||
|
Lat: {location.latitude.toFixed(4)}, Lng: {location.longitude.toFixed(4)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
|
||||||
|
<Button title={loading ? "Saving..." : "Save Client"} onPress={handleSubmit} disabled={loading} color={Colors.primary} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: Colors.background
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 5,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.border,
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: 10,
|
||||||
|
marginBottom: 15,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
color: Colors.text
|
||||||
|
},
|
||||||
|
locationContainer: {
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: Colors.backgroundSecondary,
|
||||||
|
borderRadius: 5
|
||||||
|
},
|
||||||
|
locationText: {
|
||||||
|
marginTop: 5,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: Colors.textMuted
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
height: 20
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AddClientScreen;
|
||||||
|
|
@ -0,0 +1,389 @@
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet, PermissionsAndroid, Platform, Alert, ActivityIndicator, TouchableOpacity, FlatList, RefreshControl, Linking } from 'react-native';
|
||||||
|
import Geolocation from 'react-native-geolocation-service';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const AttendanceScreen = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(true);
|
||||||
|
const [currentSession, setCurrentSession] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
|
||||||
|
const getCurrentLocation = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Geolocation.getCurrentPosition(
|
||||||
|
(position) => resolve(position.coords),
|
||||||
|
(error) => {
|
||||||
|
Alert.alert("Location Error", error.message);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: false, // Switch to false for maximum stability
|
||||||
|
timeout: 15000,
|
||||||
|
maximumAge: 10000,
|
||||||
|
// FORCE using the standard Android Location Manager
|
||||||
|
// instead of the Google Fused Provider (which is crashing)
|
||||||
|
forceLocationManager: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAttendanceData = async () => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/attendance/my-history');
|
||||||
|
const data = response.data;
|
||||||
|
setHistory(data);
|
||||||
|
|
||||||
|
if (data.length > 0 && !data[0].checkOutTime) {
|
||||||
|
setCurrentSession(data[0]);
|
||||||
|
} else {
|
||||||
|
setCurrentSession(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch history", error);
|
||||||
|
if (error.message === 'Network Error') {
|
||||||
|
Alert.alert("Network Error", "Could not connect to server. Please check your internet and if the API is running at " + api.defaults.baseURL);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchAttendanceData();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Periodic Location Tracking when Checked In
|
||||||
|
/*
|
||||||
|
useEffect(() => {
|
||||||
|
let interval;
|
||||||
|
if (currentSession && !currentSession.checkOutTime) {
|
||||||
|
console.log("Starting periodic tracking...");
|
||||||
|
const sendLocation = async () => {
|
||||||
|
try {
|
||||||
|
const coords = await getCurrentLocation();
|
||||||
|
await api.post('/locations', {
|
||||||
|
lat: coords.latitude,
|
||||||
|
lng: coords.longitude
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Periodic tracking failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sendLocation(); // Initial
|
||||||
|
interval = setInterval(sendLocation, 5 * 60 * 1000); // 5 mins
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
console.log("Stopping periodic tracking.");
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [currentSession]);
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Request Location Permission
|
||||||
|
const requestLocationPermission = async () => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
try {
|
||||||
|
const granted = await PermissionsAndroid.requestMultiple([
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fine = granted?.[PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION] === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
const coarse = granted?.[PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION] === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
|
||||||
|
if (!fine || !coarse) {
|
||||||
|
Alert.alert("Permission Denied", "Attendance requires location access. Please enable it in settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return fine && coarse;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentLocationReal = getCurrentLocation;
|
||||||
|
|
||||||
|
const handleCheckIn = async () => {
|
||||||
|
const hasPermission = await requestLocationPermission();
|
||||||
|
if (!hasPermission) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const coords = await getCurrentLocation();
|
||||||
|
await api.post('/attendance/check-in', {
|
||||||
|
latitude: coords.latitude,
|
||||||
|
longitude: coords.longitude
|
||||||
|
});
|
||||||
|
await fetchAttendanceData();
|
||||||
|
Alert.alert("Success", "Checked In!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert("Error", "Check-in failed.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckOut = async () => {
|
||||||
|
if (!currentSession?.id) return;
|
||||||
|
|
||||||
|
const hasPermission = await requestLocationPermission();
|
||||||
|
if (!hasPermission) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const coords = await getCurrentLocation();
|
||||||
|
await api.patch(`/attendance/check-out/${currentSession.id}`, {
|
||||||
|
latitude: coords.latitude,
|
||||||
|
longitude: coords.longitude
|
||||||
|
});
|
||||||
|
await fetchAttendanceData();
|
||||||
|
Alert.alert("Success", "Checked Out Successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert("Error", "Check-out failed.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '--:--';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const hours = date.getHours();
|
||||||
|
const mins = date.getMinutes();
|
||||||
|
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const h12 = hours % 12 || 12;
|
||||||
|
return `${h12}:${mins < 10 ? '0' + mins : mins} ${ampm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDayMonth = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
return `${date.getDate()} ${months[date.getMonth()]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMap = (lat, lng) => {
|
||||||
|
const url = Platform.select({
|
||||||
|
ios: `maps:0,0?q=${lat},${lng}`,
|
||||||
|
android: `geo:0,0?q=${lat},${lng}(Attendance Location)`
|
||||||
|
});
|
||||||
|
Linking.openURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHistoryItem = ({ item }) => (
|
||||||
|
<View style={styles.historyCard}>
|
||||||
|
<View style={styles.dateBox}>
|
||||||
|
<Text style={styles.dateText}>{getDayMonth(item.checkInTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.timeBox}>
|
||||||
|
<View style={styles.timeRow}>
|
||||||
|
<Text style={styles.timeLabel}>In:</Text>
|
||||||
|
<Text style={styles.timeValue}>{formatDate(item.checkInTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.timeRow}>
|
||||||
|
<Text style={styles.timeLabel}>Out:</Text>
|
||||||
|
<Text style={styles.timeValue}>{item.checkOutTime ? formatDate(item.checkOutTime) : 'Active'}</Text>
|
||||||
|
</View>
|
||||||
|
{(item.checkInLat && item.checkInLng) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => openMap(item.checkInLat, item.checkInLng)}
|
||||||
|
style={styles.locationContainer}
|
||||||
|
>
|
||||||
|
<Text style={styles.locationText}>
|
||||||
|
📍 {Number(item.checkInLat || 0).toFixed(4)}, {Number(item.checkInLng || 0).toFixed(4)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.mapLink}>View Map</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statusIndicator, item.checkOutTime ? styles.statusCompleted : styles.statusActive]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.headerTitle}>My Attendance</Text>
|
||||||
|
|
||||||
|
<View style={styles.actionCard}>
|
||||||
|
<Text style={styles.cardTitle}>Today's Status</Text>
|
||||||
|
<Text style={[styles.statusText, currentSession ? styles.textActive : styles.textInactive]}>
|
||||||
|
{currentSession ? 'Currently Checked In' : 'Not Checked In'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="large" color={Colors.primary} style={{ marginVertical: 10 }} />
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, currentSession ? styles.btnCheckout : styles.btnCheckin]}
|
||||||
|
onPress={currentSession ? handleCheckOut : handleCheckIn}
|
||||||
|
>
|
||||||
|
<Text style={styles.btnText}>
|
||||||
|
{currentSession ? "Check Out Now" : "Check In Now"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionHeader}>Recent History</Text>
|
||||||
|
{historyLoading ? (
|
||||||
|
<ActivityIndicator size="large" color={Colors.primary} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={history}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderHistoryItem}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
ListEmptyComponent={<Text style={styles.emptyText}>No attendance records found.</Text>}
|
||||||
|
refreshControl={<RefreshControl refreshing={historyLoading} onRefresh={fetchAttendanceData} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
padding: 20
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
marginBottom: 20
|
||||||
|
},
|
||||||
|
actionCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
elevation: 3,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 5,
|
||||||
|
marginBottom: 30
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginBottom: 5
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 20
|
||||||
|
},
|
||||||
|
textActive: { color: Colors.success },
|
||||||
|
textInactive: { color: Colors.textMuted },
|
||||||
|
actionButton: {
|
||||||
|
width: '100%',
|
||||||
|
paddingVertical: 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
btnCheckin: { backgroundColor: Colors.primary },
|
||||||
|
btnCheckout: { backgroundColor: Colors.danger },
|
||||||
|
btnText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
marginBottom: 15
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingBottom: 20
|
||||||
|
},
|
||||||
|
historyCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 15,
|
||||||
|
marginBottom: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
elevation: 1
|
||||||
|
},
|
||||||
|
dateBox: {
|
||||||
|
width: 60,
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: Colors.borderLight,
|
||||||
|
marginRight: 15
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
timeBox: {
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
timeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 4
|
||||||
|
},
|
||||||
|
timeLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.textLight,
|
||||||
|
width: 30
|
||||||
|
},
|
||||||
|
timeValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.text,
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
statusIndicator: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
marginLeft: 10
|
||||||
|
},
|
||||||
|
statusActive: { backgroundColor: Colors.success },
|
||||||
|
statusCompleted: { backgroundColor: Colors.textLight },
|
||||||
|
locationContainer: {
|
||||||
|
marginTop: 10,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e9ecef'
|
||||||
|
},
|
||||||
|
locationText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.text,
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
},
|
||||||
|
mapLink: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.primary,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: 5
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 30,
|
||||||
|
color: Colors.textLight
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AttendanceScreen;
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
import React, { useState, useEffect, useCallback, useContext } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator,
|
||||||
|
TextInput, Modal, StatusBar
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const CallLogsScreen = ({ navigation }) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { userInfo } = useContext(AuthContext);
|
||||||
|
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [datePreset, setDatePreset] = useState('7days'); // today, 7days, 30days
|
||||||
|
const [clientSearch, setClientSearch] = useState('');
|
||||||
|
const [showPresetModal, setShowPresetModal] = useState(false);
|
||||||
|
|
||||||
|
const getDatesFromPreset = (preset) => {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date();
|
||||||
|
if (preset === 'today') start.setDate(end.getDate());
|
||||||
|
else if (preset === '7days') start.setDate(end.getDate() - 7);
|
||||||
|
else if (preset === '30days') start.setDate(end.getDate() - 30);
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const { start: startDate, end: endDate } = getDatesFromPreset(datePreset);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/strategic-activities', {
|
||||||
|
params: {
|
||||||
|
startDate: startDate.toISOString().split('T')[0],
|
||||||
|
endDate: endDate.toISOString().split('T')[0],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let data = res.data;
|
||||||
|
|
||||||
|
// Client search filter (clientSearch)
|
||||||
|
if (clientSearch.trim()) {
|
||||||
|
const searchLow = clientSearch.toLowerCase();
|
||||||
|
data = data.filter(log => {
|
||||||
|
if (!log.metadata) return false;
|
||||||
|
try {
|
||||||
|
const meta = typeof log.metadata === 'string' ? JSON.parse(log.metadata) : log.metadata;
|
||||||
|
return meta?.clientName?.toLowerCase().includes(searchLow) || false;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show calls
|
||||||
|
data = data.filter(log => ['COLD_CALLING', 'WHATSAPP_CAMPAIGN', 'CALL'].includes(log.type));
|
||||||
|
|
||||||
|
setLogs(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [datePreset, clientSearch]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs])
|
||||||
|
);
|
||||||
|
|
||||||
|
const STATUS_MAP = {
|
||||||
|
QUALITY: { label: 'Quality Lead', color: '#16a34a', bg: '#dcfce7' },
|
||||||
|
POTENTIAL: { label: 'Potential', color: '#eab308', bg: '#fef9c3' },
|
||||||
|
DEMO: { label: 'Demo', color: '#a855f7', bg: '#f3e8ff' },
|
||||||
|
SALES: { label: 'Sales', color: '#0ea5e9', bg: '#e0f2fe' },
|
||||||
|
CLOSED: { label: 'Closed', color: '#ef4444', bg: '#fee2e2' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLog = ({ item }) => {
|
||||||
|
let meta = {};
|
||||||
|
try {
|
||||||
|
meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : (item.metadata || {});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const statusConverted = meta.convertedToStatus;
|
||||||
|
const statusConfig = statusConverted ? STATUS_MAP[statusConverted] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.logCard}>
|
||||||
|
<View style={styles.logHeader}>
|
||||||
|
<Text style={styles.logDate}>{new Date(item.createdAt).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}</Text>
|
||||||
|
{statusConfig ? (
|
||||||
|
<View style={[styles.badgeDynamic, { backgroundColor: statusConfig.bg }]}>
|
||||||
|
<Text style={[styles.badgeDynamicText, { color: statusConfig.color }]}>★ {statusConfig.label}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.badgeNormal}>
|
||||||
|
<Text style={styles.badgeNormalText}>Call Log</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.logClient}>{meta.clientName || 'No Client Linked'}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.logDesc}>{item.description}</Text>
|
||||||
|
|
||||||
|
<View style={styles.logFooter}>
|
||||||
|
<Text style={styles.logUser}>👤 {item.user?.name || 'Unknown'}</Text>
|
||||||
|
<Text style={styles.logType}>{item.type.replace('_', ' ')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
|
<StatusBar backgroundColor={Colors.primary} barStyle="light-content" />
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
|
||||||
|
<Text style={styles.backBtnText}>‹</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Call Logs & Conversions</Text>
|
||||||
|
<View style={{ width: 36 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.filterSection}>
|
||||||
|
<View style={styles.dateRow}>
|
||||||
|
<TouchableOpacity style={styles.dateBtn} onPress={() => setShowPresetModal(true)}>
|
||||||
|
<Text style={styles.dateLabel}>Date Range:</Text>
|
||||||
|
<Text style={styles.dateValue}>
|
||||||
|
{datePreset === 'today' ? 'Today' : datePreset === '7days' ? 'Last 7 Days' : 'Last 30 Days'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.searchBox}>
|
||||||
|
<Text style={styles.searchIcon}>🔍</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Filter by client name..."
|
||||||
|
value={clientSearch}
|
||||||
|
onChangeText={setClientSearch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Date Preset Modal */}
|
||||||
|
<Modal visible={showPresetModal} transparent animationType="fade">
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.presetModal}>
|
||||||
|
<Text style={styles.modalTitle}>Select Date Range</Text>
|
||||||
|
{['today', '7days', '30days'].map(preset => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={preset}
|
||||||
|
style={styles.presetOption}
|
||||||
|
onPress={() => { setDatePreset(preset); setShowPresetModal(false); }}
|
||||||
|
>
|
||||||
|
<Text style={styles.presetOptionText}>
|
||||||
|
{preset === 'today' ? 'Today' : preset === '7days' ? 'Last 7 Days' : 'Last 30 Days'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<TouchableOpacity style={styles.closeBtn} onPress={() => setShowPresetModal(false)}>
|
||||||
|
<Text style={styles.closeBtnText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{loading && logs.length === 0 ? (
|
||||||
|
<View style={styles.center}><ActivityIndicator size="large" color={Colors.primary} /></View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={logs}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={renderLog}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
ListEmptyComponent={<Text style={styles.emptyText}>No call logs found for this period.</Text>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#f1f5f9' },
|
||||||
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: Colors.primary, paddingHorizontal: 15, paddingVertical: 15, borderBottomLeftRadius: 20, borderBottomRightRadius: 20, elevation: 5, zIndex: 10 },
|
||||||
|
backBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
backBtnText: { color: 'white', fontSize: 24, fontWeight: '300', marginTop: -4 },
|
||||||
|
headerTitle: { color: 'white', fontSize: 18, fontWeight: '800' },
|
||||||
|
filterSection: { padding: 15, backgroundColor: 'white', borderBottomWidth: 1, borderBottomColor: '#e2e8f0', zIndex: 1 },
|
||||||
|
dateRow: { flexDirection: 'row', gap: 10, marginBottom: 10 },
|
||||||
|
dateBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', backgroundColor: '#f8fafc', padding: 10, borderRadius: 8, borderWidth: 1, borderColor: '#e2e8f0' },
|
||||||
|
dateLabel: { fontSize: 12, color: '#64748b', marginRight: 5 },
|
||||||
|
dateValue: { fontSize: 13, fontWeight: '700', color: '#334155' },
|
||||||
|
searchBox: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#f8fafc', borderRadius: 8, paddingHorizontal: 10, borderWidth: 1, borderColor: '#e2e8f0' },
|
||||||
|
searchIcon: { fontSize: 16, marginRight: 8 },
|
||||||
|
searchInput: { flex: 1, paddingVertical: 10, fontSize: 14, color: '#334155' },
|
||||||
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
list: { padding: 15, paddingBottom: 100 },
|
||||||
|
emptyText: { textAlign: 'center', marginTop: 40, color: '#94a3b8', fontSize: 14 },
|
||||||
|
logCard: { backgroundColor: 'white', borderRadius: 12, padding: 15, marginBottom: 15, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.05, shadowRadius: 4, elevation: 2 },
|
||||||
|
logHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
|
||||||
|
logDate: { fontSize: 11, color: '#64748b', fontWeight: '600' },
|
||||||
|
badgeDynamic: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
|
||||||
|
badgeDynamicText: { fontSize: 10, fontWeight: '800' },
|
||||||
|
badgeNormal: { backgroundColor: '#f1f5f9', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
|
||||||
|
badgeNormalText: { color: '#64748b', fontSize: 10, fontWeight: '700' },
|
||||||
|
logClient: { fontSize: 16, fontWeight: '800', color: '#1e293b', marginBottom: 4 },
|
||||||
|
logDesc: { fontSize: 13, color: '#475569', lineHeight: 18, marginBottom: 12 },
|
||||||
|
logFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderTopWidth: 1, borderTopColor: '#f1f5f9', paddingTop: 10 },
|
||||||
|
logUser: { fontSize: 12, fontWeight: '700', color: '#334155' },
|
||||||
|
logType: { fontSize: 10, color: '#94a3b8', fontWeight: '800', textTransform: 'uppercase' },
|
||||||
|
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center' },
|
||||||
|
presetModal: { width: '80%', backgroundColor: 'white', borderRadius: 12, padding: 20 },
|
||||||
|
modalTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 15, color: '#1e293b' },
|
||||||
|
presetOption: { paddingVertical: 15, borderBottomWidth: 1, borderBottomColor: '#f1f5f9' },
|
||||||
|
presetOptionText: { fontSize: 16, color: '#334155' },
|
||||||
|
closeBtn: { marginTop: 15, alignItems: 'center', paddingVertical: 10 },
|
||||||
|
closeBtnText: { color: Colors.primary, fontWeight: 'bold', fontSize: 16 }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CallLogsScreen;
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
import React, { useState, useContext } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
SafeAreaView
|
||||||
|
} from 'react-native';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
import { Key, Lock, ShieldCheck, ChevronLeft } from 'lucide-react-native';
|
||||||
|
|
||||||
|
const ChangePasswordScreen = ({ navigation }) => {
|
||||||
|
const { logout } = useContext(AuthContext);
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||||
|
Alert.alert('Error', 'Please fill in all fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
Alert.alert('Error', 'New passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
Alert.alert('Error', 'New password must be at least 6 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post('/users/me/change-password', {
|
||||||
|
oldPassword,
|
||||||
|
newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Success',
|
||||||
|
'Password updated successfully. Please login again with your new password.',
|
||||||
|
[{ text: 'OK', onPress: () => logout() }]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Password Change Error:', error);
|
||||||
|
|
||||||
|
let message = 'Failed to update password';
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
const data = error.response.data;
|
||||||
|
if (typeof data.message === 'string') {
|
||||||
|
message = data.message;
|
||||||
|
} else if (Array.isArray(data.message)) {
|
||||||
|
message = data.message.join('\n');
|
||||||
|
} else if (data.error) {
|
||||||
|
message = data.error;
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message = 'Server is unreachable. Please check your connection.';
|
||||||
|
} else {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert('Error', message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
style={styles.backButton}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} color={Colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Change Password</Text>
|
||||||
|
<View style={{ width: 40 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<View style={styles.shieldBg}>
|
||||||
|
<ShieldCheck size={60} color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.subtitle}>Update your account security</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Current Password</Text>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<Lock size={20} color={Colors.textLight} style={styles.inputIcon} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Enter current password"
|
||||||
|
secureTextEntry
|
||||||
|
value={oldPassword}
|
||||||
|
onChangeText={setOldPassword}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>New Password</Text>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<Key size={20} color={Colors.textLight} style={styles.inputIcon} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Minimum 6 characters"
|
||||||
|
secureTextEntry
|
||||||
|
value={newPassword}
|
||||||
|
onChangeText={setNewPassword}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Confirm New Password</Text>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<ShieldCheck size={20} color={Colors.textLight} style={styles.inputIcon} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Re-type new password"
|
||||||
|
secureTextEntry
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, loading && styles.buttonDisabled]}
|
||||||
|
onPress={handleChangePassword}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>Update Password</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
Note: For security reasons, you will be logged out after changing your password and will need to sign in again.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: 60,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginVertical: 30,
|
||||||
|
},
|
||||||
|
shieldBg: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
backgroundColor: Colors.primary + '10',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.textLight,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
marginBottom: 8,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e9ecef',
|
||||||
|
},
|
||||||
|
inputIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
height: 56,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.text,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 10,
|
||||||
|
shadowColor: Colors.primary,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
infoBox: {
|
||||||
|
backgroundColor: '#fffbeb',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginTop: 30,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#fef3c7',
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#b45309',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
fontWeight: '500',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChangePasswordScreen;
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Button, Linking, Platform, Alert, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const ClientDetailsScreen = ({ route, navigation }) => {
|
||||||
|
const { client: initialClient } = route.params;
|
||||||
|
const [client, setClient] = useState(initialClient);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchClientDetails = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/clients/${initialClient.id}`);
|
||||||
|
setClient(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
// Optionally alert user or just stick with initial data?
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchClientDetails();
|
||||||
|
}, [initialClient.id])
|
||||||
|
);
|
||||||
|
|
||||||
|
const openMap = () => {
|
||||||
|
if (!client.lat || !client.lng) {
|
||||||
|
Alert.alert("No Location", "This client does not have location data saved.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' });
|
||||||
|
const latLng = `${client.lat},${client.lng}`;
|
||||||
|
const label = client.name;
|
||||||
|
const url = Platform.select({
|
||||||
|
ios: `${scheme}${label}@${latLng}`,
|
||||||
|
android: `${scheme}${latLng}(${label})`
|
||||||
|
});
|
||||||
|
|
||||||
|
Linking.openURL(url).catch(err => {
|
||||||
|
console.error('An error occurred', err);
|
||||||
|
Alert.alert("Error", "Could not open map app.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !client) {
|
||||||
|
return <ActivityIndicator size="large" style={styles.loader} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.name}>{client.name}</Text>
|
||||||
|
<Text style={styles.status}>{client.status}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('EditClient', { client })} style={styles.editButton}>
|
||||||
|
<Text style={styles.editButtonText}>Edit</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
<Text style={styles.label}>Phone:</Text>
|
||||||
|
<Text style={styles.value} onPress={() => Linking.openURL(`tel:${client.phone}`)}>{client.phone}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Email:</Text>
|
||||||
|
<Text style={styles.value}>{client.email || 'N/A'}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Address:</Text>
|
||||||
|
<Text style={styles.value}>{client.address || 'N/A'}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Landmark:</Text>
|
||||||
|
<Text style={styles.value}>{client.landmark || 'N/A'}</Text>
|
||||||
|
|
||||||
|
{client.lat && client.lng ? (
|
||||||
|
<View style={styles.mapContainer}>
|
||||||
|
<Button title="Get Directions" onPress={openMap} color={Colors.secondary} />
|
||||||
|
<Text style={styles.coords}>
|
||||||
|
Lat: {client.lat.toFixed(4)}, Lng: {client.lng.toFixed(4)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.noLocation}>No location data available</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 20,
|
||||||
|
elevation: 3,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start'
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
backgroundColor: Colors.borderLight,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 5
|
||||||
|
},
|
||||||
|
editButtonText: {
|
||||||
|
color: Colors.text,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 14
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 5,
|
||||||
|
color: Colors.text
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.secondary,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 10
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: Colors.borderLight,
|
||||||
|
marginVertical: 15
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginBottom: 2
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.text,
|
||||||
|
marginBottom: 15
|
||||||
|
},
|
||||||
|
mapContainer: {
|
||||||
|
marginTop: 20,
|
||||||
|
alignItems: 'center'
|
||||||
|
},
|
||||||
|
coords: {
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.textLight
|
||||||
|
},
|
||||||
|
noLocation: {
|
||||||
|
marginTop: 20,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: Colors.textLight,
|
||||||
|
textAlign: 'center'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ClientDetailsScreen;
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, TextInput, StatusBar } from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const ClientListScreen = ({ navigation }) => {
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [filteredClients, setFilteredClients] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const fetchClients = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/clients');
|
||||||
|
setClients(response.data);
|
||||||
|
setFilteredClients(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchClients();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = (query) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
if (query) {
|
||||||
|
const lowerCaseQuery = query.toLowerCase();
|
||||||
|
const filtered = clients.filter(client =>
|
||||||
|
client.name.toLowerCase().includes(lowerCaseQuery) ||
|
||||||
|
(client.email && client.email.toLowerCase().includes(lowerCaseQuery)) ||
|
||||||
|
(client.phone && client.phone.includes(lowerCaseQuery))
|
||||||
|
);
|
||||||
|
setFilteredClients(filtered);
|
||||||
|
} else {
|
||||||
|
setFilteredClients(clients);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name) => {
|
||||||
|
if (!name) return 'C';
|
||||||
|
const parts = name.split(' ');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }) => (
|
||||||
|
<TouchableOpacity style={styles.card} onPress={() => navigation.navigate('ClientDetails', { client: item })} activeOpacity={0.8}>
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
<Text style={styles.avatarText}>{getInitials(item.name)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<Text style={styles.name}>{item.name}</Text>
|
||||||
|
<Text style={styles.details}>{item.phone}</Text>
|
||||||
|
{item.email ? <Text style={styles.subDetails}>{item.email}</Text> : null}
|
||||||
|
<View style={styles.statusBadge}>
|
||||||
|
<Text style={[styles.statusText, item.status === 'Active' ? styles.activeStatus : styles.inactiveStatus]}>
|
||||||
|
{item.status || 'Unknown'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.chevron}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar backgroundColor={Colors.background} barStyle="dark-content" />
|
||||||
|
<Text style={styles.headerTitle}>My Clients</Text>
|
||||||
|
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<Text style={styles.searchIcon}>🔍</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Search name, email, phone..."
|
||||||
|
placeholderTextColor={Colors.textLight}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={handleSearch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="large" color={Colors.primary} style={{ marginTop: 50 }} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filteredClients}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
ListEmptyComponent={<Text style={styles.emptyText}>No clients found.</Text>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.fab}
|
||||||
|
onPress={() => navigation.navigate('AddClient')}
|
||||||
|
>
|
||||||
|
<Text style={styles.fabText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
paddingTop: 10
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginBottom: 15
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginBottom: 10,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
height: 50,
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 5
|
||||||
|
},
|
||||||
|
searchIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
marginRight: 10,
|
||||||
|
opacity: 0.5
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.text
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 100
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 3
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: Colors.primaryLight,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 15
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: Colors.primary,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
marginBottom: 2
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginBottom: 2
|
||||||
|
},
|
||||||
|
subDetails: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.textLight
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
marginTop: 6,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
backgroundColor: Colors.borderLight,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 6
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600'
|
||||||
|
},
|
||||||
|
activeStatus: {
|
||||||
|
color: Colors.success
|
||||||
|
},
|
||||||
|
inactiveStatus: {
|
||||||
|
color: Colors.textMuted
|
||||||
|
},
|
||||||
|
chevron: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: Colors.border,
|
||||||
|
marginLeft: 10,
|
||||||
|
fontWeight: '300'
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 50,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.textLight
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
right: 20,
|
||||||
|
bottom: 30,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderRadius: 28,
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: Colors.primary,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 8
|
||||||
|
},
|
||||||
|
fabText: {
|
||||||
|
fontSize: 32,
|
||||||
|
color: 'white',
|
||||||
|
marginTop: -3
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ClientListScreen;
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, TextInput, StyleSheet, Alert, ScrollView, Platform, PermissionsAndroid, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
|
import Geolocation from 'react-native-geolocation-service';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const EditClientScreen = ({ navigation, route }) => {
|
||||||
|
const { client } = route.params;
|
||||||
|
|
||||||
|
const [name, setName] = useState(client.name);
|
||||||
|
const [phone, setPhone] = useState(client.phone);
|
||||||
|
const [email, setEmail] = useState(client.email || '');
|
||||||
|
const [address, setAddress] = useState(client.address || '');
|
||||||
|
const [landmark, setLandmark] = useState(client.landmark || '');
|
||||||
|
const [location, setLocation] = useState(client.lat && client.lng ? { latitude: client.lat, longitude: client.lng } : null);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
|
|
||||||
|
const requestLocationPermission = async () => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
try {
|
||||||
|
const granted = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
{
|
||||||
|
title: "Location Permission",
|
||||||
|
message: "IgCRM needs access to your location to tag client location.",
|
||||||
|
buttonNeutral: "Ask Me Later",
|
||||||
|
buttonNegative: "Cancel",
|
||||||
|
buttonPositive: "OK"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentLocation = async () => {
|
||||||
|
const hasPermission = await requestLocationPermission();
|
||||||
|
if (!hasPermission) return;
|
||||||
|
|
||||||
|
setLocating(true);
|
||||||
|
Geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
setLocation(position.coords);
|
||||||
|
setLocating(false);
|
||||||
|
Alert.alert("Success", "Location Updated!");
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
setLocating(false);
|
||||||
|
Alert.alert("Location Error", error.message);
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 10000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name || !phone) {
|
||||||
|
Alert.alert("Error", "Name and Phone are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
...(email ? { email } : {}),
|
||||||
|
...(address ? { address } : {}),
|
||||||
|
...(landmark ? { landmark } : {}),
|
||||||
|
...(location ? { lat: location.latitude, lng: location.longitude } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${client.id}`, payload);
|
||||||
|
Alert.alert("Success", "Client Updated Successfully", [
|
||||||
|
{ text: "OK", onPress: () => navigation.goBack() }
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert("Error", "Failed to update client");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Text style={styles.sectionHeader}>Basic Information</Text>
|
||||||
|
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>Full Name *</Text>
|
||||||
|
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="Enter client name" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>Phone Number *</Text>
|
||||||
|
<TextInput style={styles.input} value={phone} onChangeText={setPhone} keyboardType="phone-pad" placeholder="Enter phone number" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>Email Address</Text>
|
||||||
|
<TextInput style={styles.input} value={email} onChangeText={setEmail} keyboardType="email-address" placeholder="Enter email" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionHeader}>Address Details</Text>
|
||||||
|
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>Address</Text>
|
||||||
|
<TextInput style={[styles.input, styles.textArea]} value={address} onChangeText={setAddress} multiline numberOfLines={3} placeholder="Enter full address" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>Landmark</Text>
|
||||||
|
<TextInput style={styles.input} value={landmark} onChangeText={setLandmark} placeholder="Enter nearby landmark" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionHeader}>Location Tagging</Text>
|
||||||
|
|
||||||
|
<View style={styles.locationCard}>
|
||||||
|
<View style={styles.locationInfo}>
|
||||||
|
<Text style={styles.locationLabel}>
|
||||||
|
{location ? "Location Captured" : "No Location Set"}
|
||||||
|
</Text>
|
||||||
|
{location && (
|
||||||
|
<Text style={styles.coords}>
|
||||||
|
{location.latitude.toFixed(6)}, {location.longitude.toFixed(6)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.locationButton, locating && styles.disabledButton]}
|
||||||
|
onPress={getCurrentLocation}
|
||||||
|
disabled={locating}
|
||||||
|
>
|
||||||
|
{locating ? <ActivityIndicator color="white" size="small" /> : <Text style={styles.locationButtonText}>📍 Update</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitButton, loading && styles.disabledButton]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.submitButtonText}>Save Changes</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#334155',
|
||||||
|
marginBottom: 15,
|
||||||
|
marginTop: 10
|
||||||
|
},
|
||||||
|
formGroup: {
|
||||||
|
marginBottom: 15
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginBottom: 8
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.text
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
height: 100,
|
||||||
|
textAlignVertical: 'top'
|
||||||
|
},
|
||||||
|
locationCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.border
|
||||||
|
},
|
||||||
|
locationInfo: {
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
locationLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text
|
||||||
|
},
|
||||||
|
coords: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace'
|
||||||
|
},
|
||||||
|
locationButton: {
|
||||||
|
backgroundColor: Colors.secondary,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginLeft: 10
|
||||||
|
},
|
||||||
|
locationButtonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 14
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
height: 30
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: Colors.primary,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 5,
|
||||||
|
marginBottom: 30
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EditClientScreen;
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, TextInput, StatusBar } from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const EnquiryListScreen = ({ navigation }) => {
|
||||||
|
const [enquiries, setEnquiries] = useState([]);
|
||||||
|
const [filteredEnquiries, setFilteredEnquiries] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const fetchEnquiries = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/enquiries');
|
||||||
|
setEnquiries(response.data);
|
||||||
|
setFilteredEnquiries(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching enquiries:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchEnquiries();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = (query) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
if (query) {
|
||||||
|
const lowerCaseQuery = query.toLowerCase();
|
||||||
|
const filtered = enquiries.filter(enq =>
|
||||||
|
(enq.client?.name && enq.client.name.toLowerCase().includes(lowerCaseQuery)) ||
|
||||||
|
(enq.status && enq.status.toLowerCase().includes(lowerCaseQuery))
|
||||||
|
);
|
||||||
|
setFilteredEnquiries(filtered);
|
||||||
|
} else {
|
||||||
|
setFilteredEnquiries(enquiries);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name) => {
|
||||||
|
if (!name) return 'E';
|
||||||
|
const parts = name.split(' ');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const d = new Date(dateString);
|
||||||
|
return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }) => (
|
||||||
|
<TouchableOpacity style={styles.card} activeOpacity={0.8}>
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
<Text style={styles.avatarText}>{getInitials(item.client?.name)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<Text style={styles.name}>{item.client?.name || 'Unknown Client'}</Text>
|
||||||
|
<Text style={styles.details}>{item.products?.map(p => p.name).join(', ') || 'No Products'}</Text>
|
||||||
|
<Text style={styles.subDetails}>Created: {formatDate(item.createdAt)}</Text>
|
||||||
|
<View style={[styles.statusBadge, item.status === 'CLOSED' ? styles.closedStatus : styles.openStatus]}>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{item.status || 'OPEN'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar backgroundColor={Colors.background} barStyle="dark-content" />
|
||||||
|
<Text style={styles.headerTitle}>All Enquiries</Text>
|
||||||
|
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<Text style={styles.searchIcon}>🔍</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Search by client or status..."
|
||||||
|
placeholderTextColor={Colors.textLight}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={handleSearch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="large" color={Colors.primary} style={{ marginTop: 50 }} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filteredEnquiries}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
ListEmptyComponent={<Text style={styles.emptyText}>No enquiries found.</Text>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.fab}
|
||||||
|
onPress={() => navigation.navigate('Enquiry')}
|
||||||
|
>
|
||||||
|
<Text style={styles.fabText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
paddingTop: 10
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginBottom: 15
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginBottom: 10,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
height: 50,
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 5
|
||||||
|
},
|
||||||
|
searchIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
marginRight: 10,
|
||||||
|
opacity: 0.5
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.text
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 100
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 3
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: Colors.primaryLight,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 15
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: Colors.primary,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
marginBottom: 2
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginBottom: 2
|
||||||
|
},
|
||||||
|
subDetails: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.textLight
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
marginTop: 6,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 6
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: Colors.text,
|
||||||
|
},
|
||||||
|
openStatus: {
|
||||||
|
backgroundColor: '#ebf4ff', // light blue
|
||||||
|
},
|
||||||
|
closedStatus: {
|
||||||
|
backgroundColor: Colors.borderLight,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 50,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.textLight
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
right: 20,
|
||||||
|
bottom: 30,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderRadius: 28,
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: Colors.primary,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 8
|
||||||
|
},
|
||||||
|
fabText: {
|
||||||
|
fontSize: 32,
|
||||||
|
color: 'white',
|
||||||
|
marginTop: -3
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EnquiryListScreen;
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
|
import { View, Text, TextInput, Button, StyleSheet, Alert, ScrollView, TouchableOpacity, Modal } from 'react-native';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const EnquiryScreen = ({ navigation, route }) => {
|
||||||
|
// Optional: Pass clientId if adding enquiry for specific client
|
||||||
|
const { clientId } = route.params || {};
|
||||||
|
const { userInfo } = useContext(AuthContext);
|
||||||
|
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [selectedProducts, setSelectedProducts] = useState([]);
|
||||||
|
const [conversation, setConversation] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [selectedClient, setSelectedClient] = useState(clientId || null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProducts();
|
||||||
|
if (!clientId) {
|
||||||
|
fetchClients();
|
||||||
|
}
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/products');
|
||||||
|
setProducts(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchClients = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/clients');
|
||||||
|
setClients(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleProductSelection = (productId) => {
|
||||||
|
if (selectedProducts.includes(productId)) {
|
||||||
|
setSelectedProducts(selectedProducts.filter(id => id !== productId));
|
||||||
|
} else {
|
||||||
|
setSelectedProducts([...selectedProducts, productId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedClient) {
|
||||||
|
Alert.alert("Error", "Please select a client first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedProducts.length === 0) {
|
||||||
|
Alert.alert("Error", "Please select at least one product.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!userInfo?.id) {
|
||||||
|
Alert.alert("Error", "User session invalid.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/enquiries', {
|
||||||
|
clientId: selectedClient,
|
||||||
|
userId: userInfo.id,
|
||||||
|
conversation,
|
||||||
|
productIds: selectedProducts
|
||||||
|
});
|
||||||
|
Alert.alert("Success", "Enquiry Added Successfully", [
|
||||||
|
{ text: "OK", onPress: () => navigation.goBack() }
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert("Error", "Failed to add enquiry");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Text style={styles.label}>Client *</Text>
|
||||||
|
{clientId ? (
|
||||||
|
<Text style={styles.value}>Client Pre-selected</Text>
|
||||||
|
) : (
|
||||||
|
<View style={styles.pickerContainer}>
|
||||||
|
<TouchableOpacity onPress={() => setModalVisible(true)} style={styles.pickerButton}>
|
||||||
|
<Text>{selectedClient ? clients.find(c => c.id === selectedClient)?.name : "Select Client"}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>Products *</Text>
|
||||||
|
{products.length === 0 && <Text>No products available</Text>}
|
||||||
|
{products.map(product => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={product.id}
|
||||||
|
style={[styles.productItem, selectedProducts.includes(product.id) && styles.selectedProduct]}
|
||||||
|
onPress={() => toggleProductSelection(product.id)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.productText, selectedProducts.includes(product.id) && styles.selectedProductText]}>
|
||||||
|
{product.name} - ${product.price}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text style={styles.label}>Conversation / Notes</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.textArea]}
|
||||||
|
value={conversation}
|
||||||
|
onChangeText={setConversation}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button title={loading ? "Saving..." : "Save Enquiry"} onPress={handleSubmit} disabled={loading} color={Colors.primary} />
|
||||||
|
|
||||||
|
<Modal visible={modalVisible} animationType="slide">
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<Text style={styles.modalTitle}>Select Client</Text>
|
||||||
|
<ScrollView>
|
||||||
|
{clients.map(client => (
|
||||||
|
<TouchableOpacity key={client.id} onPress={() => { setSelectedClient(client.id); setModalVisible(false); }} style={styles.modalItem}>
|
||||||
|
<Text style={styles.modalItemText}>{client.name}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<Button title="Close" onPress={() => setModalVisible(false)} color={Colors.danger} />
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { padding: 20, backgroundColor: Colors.background, flexGrow: 1 },
|
||||||
|
label: { fontSize: 16, marginBottom: 5, fontWeight: 'bold', marginTop: 10, color: Colors.text },
|
||||||
|
value: { fontSize: 16, marginBottom: 15, color: Colors.text },
|
||||||
|
input: { borderWidth: 1, borderColor: Colors.border, borderRadius: 5, padding: 10, marginBottom: 15, backgroundColor: 'white', color: Colors.text },
|
||||||
|
textArea: { height: 100, textAlignVertical: 'top' },
|
||||||
|
pickerButton: { padding: 15, backgroundColor: Colors.backgroundSecondary, borderRadius: 5, marginBottom: 15, borderWidth: 1, borderColor: Colors.border },
|
||||||
|
productItem: { padding: 15, backgroundColor: 'white', borderRadius: 5, marginBottom: 10, borderWidth: 1, borderColor: Colors.borderLight },
|
||||||
|
selectedProduct: { backgroundColor: Colors.primary, borderColor: Colors.primary },
|
||||||
|
productText: { fontSize: 16, color: Colors.text },
|
||||||
|
selectedProductText: { color: 'white' },
|
||||||
|
modalContainer: { flex: 1, padding: 20, marginTop: 50, backgroundColor: 'white' },
|
||||||
|
modalTitle: { fontSize: 20, fontWeight: 'bold', marginBottom: 20, textAlign: 'center', color: Colors.text },
|
||||||
|
modalItem: { padding: 15, borderBottomWidth: 1, borderBottomColor: Colors.borderLight },
|
||||||
|
modalItemText: { fontSize: 18, color: Colors.text }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EnquiryScreen;
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import React, { useState, useContext, useEffect } from 'react';
|
||||||
|
import { View, Text, TextInput, Button, StyleSheet, Alert, ScrollView } from 'react-native';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const ExpenseScreen = ({ navigation }) => {
|
||||||
|
const { userInfo } = useContext(AuthContext);
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [expenses, setExpenses] = useState([]);
|
||||||
|
const [fetching, setFetching] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExpenses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchExpenses = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/expenses');
|
||||||
|
const myExpenses = response.data.filter(e => e.userId === userInfo?.id);
|
||||||
|
setExpenses(myExpenses);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!amount || !description) {
|
||||||
|
Alert.alert("Error", "Amount and Description are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedAmount = parseFloat(amount);
|
||||||
|
if (isNaN(parsedAmount) || parsedAmount <= 0) {
|
||||||
|
Alert.alert("Error", "Please enter a valid positive amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/expenses', {
|
||||||
|
userId: userInfo.id,
|
||||||
|
amount: parsedAmount,
|
||||||
|
description,
|
||||||
|
status: 'PENDING'
|
||||||
|
});
|
||||||
|
Alert.alert("Success", "Expense Submitted Successfully");
|
||||||
|
setAmount('');
|
||||||
|
setDescription('');
|
||||||
|
fetchExpenses();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert("Error", "Failed to submit expense");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Text style={styles.title}>Expense Management</Text>
|
||||||
|
|
||||||
|
<View style={styles.formCard}>
|
||||||
|
<Text style={styles.subTitle}>Add New Expense</Text>
|
||||||
|
<Text style={styles.label}>Amount (₹) *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={amount}
|
||||||
|
onChangeText={setAmount}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Description *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.textArea]}
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
multiline
|
||||||
|
placeholder="Lunch, Travel, etc."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button title={loading ? "Submitting..." : "Submit Expense"} onPress={handleSubmit} disabled={loading} color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.subTitle}>My Expenses</Text>
|
||||||
|
{fetching ? (
|
||||||
|
<Text style={{ textAlign: 'center', marginTop: 20 }}>Loading...</Text>
|
||||||
|
) : expenses.length === 0 ? (
|
||||||
|
<Text style={{ textAlign: 'center', marginTop: 20, color: 'gray' }}>No expenses submitted yet.</Text>
|
||||||
|
) : (
|
||||||
|
expenses.map(exp => (
|
||||||
|
<View key={exp.id} style={styles.card}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.desc}>{exp.description}</Text>
|
||||||
|
<Text style={styles.amount}>₹{exp.amount}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.date}>{new Date(exp.createdAt).toLocaleDateString()}</Text>
|
||||||
|
<Text style={[styles.status,
|
||||||
|
exp.status === 'APPROVED' ? styles.approved :
|
||||||
|
exp.status === 'REJECTED' ? styles.rejected : styles.pending
|
||||||
|
]}>{exp.status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { padding: 20, backgroundColor: Colors.background, flexGrow: 1 },
|
||||||
|
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20, textAlign: 'center', color: Colors.text },
|
||||||
|
subTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 15, marginTop: 10, color: Colors.text },
|
||||||
|
formCard: { backgroundColor: 'white', padding: 15, borderRadius: 8, marginBottom: 20, elevation: 2 },
|
||||||
|
label: { fontSize: 16, marginBottom: 5, fontWeight: 'bold', color: Colors.text },
|
||||||
|
input: { borderWidth: 1, borderColor: Colors.border, borderRadius: 5, padding: 10, marginBottom: 15, backgroundColor: 'white', color: Colors.text },
|
||||||
|
textArea: { height: 80, textAlignVertical: 'top' },
|
||||||
|
card: { backgroundColor: 'white', padding: 15, borderRadius: 8, marginBottom: 10, elevation: 2, borderLeftWidth: 4, borderLeftColor: Colors.primary },
|
||||||
|
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 5 },
|
||||||
|
desc: { fontSize: 16, fontWeight: '500', color: Colors.text },
|
||||||
|
amount: { fontSize: 16, fontWeight: 'bold', color: Colors.text },
|
||||||
|
date: { fontSize: 12, color: Colors.textMuted },
|
||||||
|
status: { fontSize: 12, fontWeight: 'bold', paddingHorizontal: 8, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
|
||||||
|
approved: { backgroundColor: Colors.accent, color: Colors.secondary },
|
||||||
|
rejected: { backgroundColor: Colors.backgroundSecondary, color: Colors.textMuted },
|
||||||
|
pending: { backgroundColor: Colors.backgroundSecondary, color: Colors.textMuted }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ExpenseScreen;
|
||||||
|
|
@ -0,0 +1,561 @@
|
||||||
|
import React, { useContext, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, StatusBar, Dimensions, Alert } from 'react-native';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { LogOut, Bell, User } from 'lucide-react-native';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
const HomeScreen = ({ navigation }) => {
|
||||||
|
const { userInfo, logout } = useContext(AuthContext);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
pipelineCount: 0,
|
||||||
|
monthlyRevenue: 0,
|
||||||
|
performance: null,
|
||||||
|
target: null,
|
||||||
|
overdueCount: 0,
|
||||||
|
todayCount: 0,
|
||||||
|
newCount: 0
|
||||||
|
});
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/dashboard/stats');
|
||||||
|
if (response.data) {
|
||||||
|
setStats({
|
||||||
|
pipelineCount: response.data.kpis.pipelineCount,
|
||||||
|
monthlyRevenue: response.data.kpis.monthlyRevenue,
|
||||||
|
performance: response.data.performance,
|
||||||
|
target: response.data.target,
|
||||||
|
overdueCount: response.data.kpis.overdueCount,
|
||||||
|
todayCount: response.data.kpis.todayCount,
|
||||||
|
newCount: response.data.kpis.newCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const notifRes = await api.get('/notifications/unread-count');
|
||||||
|
setUnreadCount(notifRes.data.count);
|
||||||
|
|
||||||
|
if (notifRes.data.count > 0) {
|
||||||
|
const latestNotifs = await api.get('/notifications/my');
|
||||||
|
const performanceAlert = latestNotifs.data.find(n => n.type === 'PERFORMANCE_ALERT' && !n.isRead);
|
||||||
|
|
||||||
|
if (performanceAlert) {
|
||||||
|
Alert.alert(
|
||||||
|
"Performance Alert ⚠️",
|
||||||
|
performanceAlert.body,
|
||||||
|
[{ text: "Check My Performance", onPress: () => navigation.navigate('MyTarget') }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard stats:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const StatCard = ({ title, value, color }) => (
|
||||||
|
<View style={[styles.statCard, { borderLeftColor: color }]}>
|
||||||
|
<Text style={styles.statValue}>{value}</Text>
|
||||||
|
<Text style={styles.statLabel}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenuCard = ({ title, icon, color, onPress }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.card}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconContainer, { backgroundColor: color + '15' }]}>
|
||||||
|
<Text style={styles.cardIcon}>{icon}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.cardTitle}>{title}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
if (value >= 1000) {
|
||||||
|
return `₹${(value / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
return `₹${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar backgroundColor={Colors.primary} barStyle="light-content" />
|
||||||
|
|
||||||
|
<ScrollView bounces={false} contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
|
{/* Header Section */}
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top + 20 }]}>
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>{userInfo?.name?.charAt(0) || 'U'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerTextContainer}>
|
||||||
|
<Text style={styles.greeting}>Good morning,</Text>
|
||||||
|
<Text style={styles.userName}>{userInfo?.name || 'User'}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('MyTarget')} style={styles.bellButton}>
|
||||||
|
<Bell size={24} color="white" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{unreadCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('ChangePassword')} style={styles.profileButton}>
|
||||||
|
<User size={20} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={logout} style={styles.settingsButton}>
|
||||||
|
<LogOut size={20} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Smart Priority Cards */}
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={[styles.priorityCard, { backgroundColor: '#FF6B6B' }]}>
|
||||||
|
<Text style={styles.priorityValue}>{stats.overdueCount}</Text>
|
||||||
|
<Text style={styles.priorityLabel}>Overdue</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.priorityCard, { backgroundColor: '#4DABF7' }]}>
|
||||||
|
<Text style={styles.priorityValue}>{stats.todayCount}</Text>
|
||||||
|
<Text style={styles.priorityLabel}>Today</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.priorityCard, { backgroundColor: '#51CF66' }]}>
|
||||||
|
<Text style={styles.priorityValue}>{stats.newCount}</Text>
|
||||||
|
<Text style={styles.priorityLabel}>New</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Score & Target Section */}
|
||||||
|
<View style={styles.focusContainer}>
|
||||||
|
<View style={styles.scoreBox}>
|
||||||
|
<Text style={styles.focusLabel}>PERFORMANCE SCORE</Text>
|
||||||
|
<Text style={[styles.scoreText, { color: stats.performance?.score > 80 ? '#27ae60' : stats.performance?.score > 50 ? '#f39c12' : '#e74c3c' }]}>
|
||||||
|
{stats.performance ? Math.round(stats.performance.score) : '--'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.tagText}>{stats.performance?.tag.replace('_', ' ') || 'NO DATA'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.targetBox}
|
||||||
|
onPress={() => navigation.navigate('MyTarget')}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<Text style={[styles.focusLabel, { marginBottom: 0 }]}>MONTHLY TARGET</Text>
|
||||||
|
<Text style={{ fontSize: 10 }}>↗️</Text>
|
||||||
|
</View>
|
||||||
|
{stats.target ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.progressBarBg}>
|
||||||
|
<View style={[styles.progressBarFill, { width: `${Math.min(100, (stats.target.achieved / stats.target.monthly) * 100)}%` }]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.targetRow}>
|
||||||
|
<Text style={styles.targetValue}>₹{(stats.target.achieved / 1000).toFixed(1)}k</Text>
|
||||||
|
<Text style={styles.targetGoal}>/ ₹{(stats.target.monthly / 1000).toFixed(0)}k</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.noTargetText}>No target assigned</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<View style={styles.quickActionsContainer}>
|
||||||
|
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||||
|
<View style={styles.quickActionsRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.quickActionBtn, { backgroundColor: '#eef2ff' }]}
|
||||||
|
onPress={() => navigation.navigate('LogActivity', { tab: 'call' })}
|
||||||
|
>
|
||||||
|
<Text style={styles.quickActionIcon}>📞</Text>
|
||||||
|
<Text style={[styles.quickActionLabel, { color: Colors.primary }]}>Log Call</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.quickActionBtn, { backgroundColor: '#fdf4ff' }]}
|
||||||
|
onPress={() => navigation.navigate('CallLogs')}
|
||||||
|
>
|
||||||
|
<Text style={styles.quickActionIcon}>📜</Text>
|
||||||
|
<Text style={[styles.quickActionLabel, { color: '#c026d3' }]}>Call Logs</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.quickActionBtn, { backgroundColor: '#f0fdf4' }]}
|
||||||
|
onPress={() => navigation.navigate('LogActivity', { tab: 'followup' })}
|
||||||
|
>
|
||||||
|
<Text style={styles.quickActionIcon}>📅</Text>
|
||||||
|
<Text style={[styles.quickActionLabel, { color: '#16a34a' }]}>Follow-up</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Dashboard Grid */}
|
||||||
|
<View style={styles.gridContainer}>
|
||||||
|
<Text style={styles.sectionTitle}>Main Activities</Text>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
<MenuCard
|
||||||
|
title="Attendance"
|
||||||
|
icon="📅"
|
||||||
|
color={Colors.primary}
|
||||||
|
onPress={() => navigation.navigate('Attendance')}
|
||||||
|
/>
|
||||||
|
<MenuCard
|
||||||
|
title="Enquiries"
|
||||||
|
icon="📝"
|
||||||
|
color={Colors.primary}
|
||||||
|
onPress={() => navigation.navigate('EnquiryList')}
|
||||||
|
/>
|
||||||
|
<MenuCard
|
||||||
|
title="Expenses"
|
||||||
|
icon="💸"
|
||||||
|
color={Colors.primary}
|
||||||
|
onPress={() => navigation.navigate('Expense')}
|
||||||
|
/>
|
||||||
|
<MenuCard
|
||||||
|
title="Incentives"
|
||||||
|
icon="🏆"
|
||||||
|
color={Colors.secondary}
|
||||||
|
onPress={() => navigation.navigate('Incentive')}
|
||||||
|
/>
|
||||||
|
<MenuCard
|
||||||
|
title="Marketing"
|
||||||
|
icon="📢"
|
||||||
|
color={Colors.primary}
|
||||||
|
onPress={() => navigation.navigate('LogActivity')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Odoo Promo/Tip Section */}
|
||||||
|
<View style={styles.tipCard}>
|
||||||
|
<View style={styles.tipIconContainer}>
|
||||||
|
<Text style={styles.tipIcon}>💡</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tipTextContainer}>
|
||||||
|
<Text style={styles.tipTitle}>Sales Tip</Text>
|
||||||
|
<Text style={styles.tipDescription}>Follow up on your proposition deals to increase won rate by 20%.</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
paddingBottom: 40,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
marginRight: 15,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgba(255,255,255,0.3)',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
headerTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
userName: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
bellButton: {
|
||||||
|
padding: 5,
|
||||||
|
marginRight: 10,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: '#ef4444',
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 16,
|
||||||
|
height: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.primary,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
settingsButton: {
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
settingsIcon: {
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
profileButton: {
|
||||||
|
padding: 5,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
statsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginTop: -25,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
priorityCard: {
|
||||||
|
width: (width - 60) / 3,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
priorityValue: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
priorityLabel: {
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
focusContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginTop: 20,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
scoreBox: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
width: '40%',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.border,
|
||||||
|
},
|
||||||
|
targetBox: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
width: '56%',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.border,
|
||||||
|
},
|
||||||
|
focusLabel: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '900',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
scoreText: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
tagText: {
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
progressBarBg: {
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: '#f1f3f5',
|
||||||
|
borderRadius: 3,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressBarFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
targetRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
targetValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
},
|
||||||
|
targetGoal: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
noTargetText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
statCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
width: (width - 56) / 2,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
gridContainer: {
|
||||||
|
padding: 20,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
quickActionsContainer: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
quickActionsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
quickActionBtn: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 16,
|
||||||
|
gap: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0,0,0,0.05)',
|
||||||
|
},
|
||||||
|
quickActionIcon: {
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
quickActionLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.primary,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: (width - 64) / 2,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.border,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
cardIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
},
|
||||||
|
tipCard: {
|
||||||
|
marginHorizontal: 24,
|
||||||
|
backgroundColor: Colors.accent,
|
||||||
|
borderRadius: 15,
|
||||||
|
padding: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: `${Colors.secondary}20`,
|
||||||
|
},
|
||||||
|
tipIconContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: `${Colors.secondary}20`,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 15,
|
||||||
|
},
|
||||||
|
tipIcon: {
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
tipTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
tipTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.secondary,
|
||||||
|
},
|
||||||
|
tipDescription: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#4a5568',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HomeScreen;
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
|
import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const IncentiveScreen = () => {
|
||||||
|
const { userInfo } = useContext(AuthContext);
|
||||||
|
const [incentives, setIncentives] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchIncentives();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchIncentives = async () => {
|
||||||
|
try {
|
||||||
|
// Assuming endpoint to get incentives for user
|
||||||
|
// Since getAll returns all, maybe we filter on client side or backend should have /my-incentives
|
||||||
|
// using getAll for now, filter client side if needed or assume backend handles "my" based on user role?
|
||||||
|
// Actually incentives usually are assigned to user.
|
||||||
|
const response = await api.get('/incentives');
|
||||||
|
// Filter
|
||||||
|
const myIncentives = response.data.filter(i => i.userId === userInfo?.id);
|
||||||
|
setIncentives(myIncentives);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Text style={styles.title}>Performance & Incentives</Text>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
) : (
|
||||||
|
incentives.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>No incentives found.</Text>
|
||||||
|
) : (
|
||||||
|
incentives.map((item) => (
|
||||||
|
<View key={item.id} style={styles.card}>
|
||||||
|
<Text style={styles.type}>{item.type} Target</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Target:</Text>
|
||||||
|
<Text style={styles.value}>₹{item.targetAmount}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Achieved:</Text>
|
||||||
|
<Text style={styles.value}>₹{item.achievedAmount}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Reward:</Text>
|
||||||
|
<Text style={styles.value}>{item.rewardAmount ? `₹${item.rewardAmount}` : 'N/A'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressFill,
|
||||||
|
{ width: `${Math.min((item.achievedAmount / item.targetAmount) * 100, 100)}%` }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.dates}>
|
||||||
|
{new Date(item.startDate).toLocaleDateString()} - {new Date(item.endDate).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { padding: 20, backgroundColor: Colors.background, flexGrow: 1 },
|
||||||
|
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20, textAlign: 'center', color: Colors.text },
|
||||||
|
card: { backgroundColor: 'white', padding: 15, borderRadius: 8, marginBottom: 15, elevation: 3 },
|
||||||
|
type: { fontSize: 18, fontWeight: 'bold', marginBottom: 10, color: Colors.primary },
|
||||||
|
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 5 },
|
||||||
|
label: { fontSize: 16, color: Colors.textMuted },
|
||||||
|
value: { fontSize: 16, fontWeight: 'bold', color: Colors.text },
|
||||||
|
emptyText: { textAlign: 'center', fontSize: 16, marginTop: 20, color: Colors.textMuted },
|
||||||
|
progressBar: { height: 10, backgroundColor: Colors.backgroundSecondary, borderRadius: 5, marginTop: 10, overflow: 'hidden' },
|
||||||
|
progressFill: { height: '100%', backgroundColor: Colors.secondary },
|
||||||
|
dates: { fontSize: 12, color: Colors.textLight, marginTop: 10, textAlign: 'right' }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default IncentiveScreen;
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
import React, { useState, useEffect, useContext, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, TouchableOpacity, ScrollView,
|
||||||
|
TextInput, Alert, ActivityIndicator, StatusBar, Modal, FlatList, Linking, Switch
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const ACTIVITY_TYPES = [
|
||||||
|
{ id: 'COLD_CALLING', label: 'Cold Calling', icon: '📞', funnelKey: 'calls' },
|
||||||
|
{ id: 'WHATSAPP_CAMPAIGN', label: 'WhatsApp Campaign', icon: '📱', funnelKey: 'calls' },
|
||||||
|
{ id: 'POSTER_PASTING', label: 'Poster Pasting', icon: '🖼️', funnelKey: null },
|
||||||
|
{ id: 'EXHIBITION', label: 'Exhibition/Event', icon: '🎪', funnelKey: null },
|
||||||
|
{ id: 'DATA_COLLECTION', label: 'Data Collection', icon: '📊', funnelKey: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'call', label: 'Log Call / Activity', icon: '📞' },
|
||||||
|
{ id: 'followup', label: 'Schedule Follow-up', icon: '📅' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LogActivityScreen = ({ navigation, route }) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { userInfo } = useContext(AuthContext);
|
||||||
|
const defaultTab = route?.params?.tab || 'call';
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [clientSearch, setClientSearch] = useState('');
|
||||||
|
const [clientModal, setClientModal] = useState(false);
|
||||||
|
|
||||||
|
// Call / Activity state
|
||||||
|
const [actType, setActType] = useState(null);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [quantity, setQuantity] = useState('1');
|
||||||
|
const [callClient, setCallClient] = useState(null);
|
||||||
|
const [updateClientStatus, setUpdateClientStatus] = useState(null);
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ id: 'QUALITY', label: 'Quality', color: '#16a34a', bg: '#dcfce7' },
|
||||||
|
{ id: 'POTENTIAL', label: 'Potential', color: '#eab308', bg: '#fef9c3' },
|
||||||
|
{ id: 'DEMO', label: 'Demo', color: '#a855f7', bg: '#f3e8ff' },
|
||||||
|
{ id: 'SALES', label: 'Sales', color: '#0ea5e9', bg: '#e0f2fe' },
|
||||||
|
{ id: 'CLOSED', label: 'Closed', color: '#ef4444', bg: '#fee2e2' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Followup state
|
||||||
|
const [fuClient, setFuClient] = useState(null);
|
||||||
|
const [fuNotes, setFuNotes] = useState('');
|
||||||
|
const [fuDate, setFuDate] = useState('');
|
||||||
|
const [fuTime, setFuTime] = useState('');
|
||||||
|
|
||||||
|
const handleCall = (phone) => {
|
||||||
|
if (!phone) return;
|
||||||
|
Linking.openURL(`tel:${phone}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/clients').then(r => setClients(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredClients = clients.filter(c =>
|
||||||
|
c.name?.toLowerCase().includes(clientSearch.toLowerCase()) ||
|
||||||
|
c.phone?.includes(clientSearch)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Submit Call/Activity ──────────────────────────────────────
|
||||||
|
const handleSubmitCall = async () => {
|
||||||
|
if (!actType) { Alert.alert('Error', 'Please select an activity type'); return; }
|
||||||
|
if (!description.trim()) { Alert.alert('Error', 'Please enter a description'); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/strategic-activities', {
|
||||||
|
type: actType,
|
||||||
|
description,
|
||||||
|
leadsGenerated: parseInt(quantity) || 0,
|
||||||
|
updateClientStatus,
|
||||||
|
metadata: { clientId: callClient?.id, clientName: callClient?.name }
|
||||||
|
});
|
||||||
|
Alert.alert('Done! ✅', `${actType.replace('_', ' ')} logged successfully.`, [
|
||||||
|
{ text: 'Log Another', onPress: () => { setActType(null); setDescription(''); setQuantity('1'); setCallClient(null); setUpdateClientStatus(null); } },
|
||||||
|
{ text: 'Go to Tasks', onPress: () => navigation.navigate('Main', { screen: 'Tasks' }) },
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Error', 'Failed to log activity.');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Submit Followup ────────────────────────────────────────────
|
||||||
|
const handleSubmitFollowup = async () => {
|
||||||
|
if (!fuClient) { Alert.alert('Error', 'Please select a client'); return; }
|
||||||
|
if (!fuNotes.trim()) { Alert.alert('Error', 'Please add a note'); return; }
|
||||||
|
if (!fuDate || !fuTime) { Alert.alert('Error', 'Please set date and time'); return; }
|
||||||
|
const dateStr = `${fuDate}T${fuTime}:00`;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/followups', {
|
||||||
|
clientId: fuClient.id,
|
||||||
|
userId: userInfo?.id,
|
||||||
|
notes: fuNotes,
|
||||||
|
date: new Date(dateStr).toISOString(),
|
||||||
|
status: 'PENDING',
|
||||||
|
});
|
||||||
|
Alert.alert('Scheduled! 📅', `Follow-up with ${fuClient.name} scheduled.`, [
|
||||||
|
{ text: 'Schedule Another', onPress: () => { setFuClient(null); setFuNotes(''); setFuDate(''); setFuTime(''); } },
|
||||||
|
{ text: 'View Tasks', onPress: () => navigation.navigate('Main', { screen: 'Tasks' }) },
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Error', 'Failed to schedule follow-up.');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const ClientPicker = ({ selected, onSelect }) => (
|
||||||
|
<>
|
||||||
|
<View style={styles.clientPickerContainer}>
|
||||||
|
<TouchableOpacity style={styles.clientPicker} onPress={() => setClientModal(true)}>
|
||||||
|
<Text style={selected ? styles.clientPickerSelected : styles.clientPickerPlaceholder} numberOfLines={1}>
|
||||||
|
{selected ? `${selected.name} • ${selected.phone}` : 'Tap to select client...'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.clientPickerArrow}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{selected?.phone && (
|
||||||
|
<TouchableOpacity style={styles.inlineCallBtn} onPress={() => handleCall(selected.phone)}>
|
||||||
|
<Text style={styles.inlineCallIcon}>📞</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Modal visible={clientModal} animationType="slide" onRequestClose={() => setClientModal(false)}>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Select Client</Text>
|
||||||
|
<TouchableOpacity onPress={() => setClientModal(false)}><Text style={styles.modalClose}>✕</Text></TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Search by name or phone..."
|
||||||
|
value={clientSearch}
|
||||||
|
onChangeText={setClientSearch}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<FlatList
|
||||||
|
data={filteredClients}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity style={styles.clientRow} onPress={() => { onSelect(item); setClientModal(false); setClientSearch(''); }}>
|
||||||
|
<View style={styles.clientAvatar}>
|
||||||
|
<Text style={styles.clientAvatarText}>{item.name?.charAt(0)}</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.clientRowName}>{item.name}</Text>
|
||||||
|
<Text style={styles.clientRowPhone}>{item.phone}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={<Text style={styles.emptyText}>No clients found</Text>}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
|
<StatusBar backgroundColor={Colors.primary} barStyle="light-content" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
|
||||||
|
<Text style={styles.backBtnText}>‹</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Quick Actions</Text>
|
||||||
|
<View style={{ width: 36 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<View style={styles.tabs}>
|
||||||
|
{TABS.map(t => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t.id}
|
||||||
|
style={[styles.tab, activeTab === t.id && styles.tabActive]}
|
||||||
|
onPress={() => setActiveTab(t.id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.tabIcon}>{t.icon}</Text>
|
||||||
|
<Text style={[styles.tabLabel, activeTab === t.id && styles.tabLabelActive]}>{t.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
||||||
|
|
||||||
|
{/* ── CALL / ACTIVITY TAB ── */}
|
||||||
|
{activeTab === 'call' && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.section}>Activity Type</Text>
|
||||||
|
<View style={styles.typeGrid}>
|
||||||
|
{ACTIVITY_TYPES.map(a => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={a.id}
|
||||||
|
style={[styles.typeCard, actType === a.id && styles.typeCardActive]}
|
||||||
|
onPress={() => setActType(a.id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.typeIcon}>{a.icon}</Text>
|
||||||
|
<Text style={[styles.typeLabel, actType === a.id && styles.typeLabelActive]}>{a.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.section}>Linked Client (Optional)</Text>
|
||||||
|
<ClientPicker selected={callClient} onSelect={setCallClient} />
|
||||||
|
|
||||||
|
{callClient && (
|
||||||
|
<View style={styles.statusSection}>
|
||||||
|
<View style={{ marginBottom: 10 }}>
|
||||||
|
<Text style={styles.switchLabel}>Update Client Status</Text>
|
||||||
|
<Text style={styles.switchSub}>Optional: Automatically change status after this call.</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8, paddingBottom: 5 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.statusPill, updateClientStatus === null && styles.statusPillActive]}
|
||||||
|
onPress={() => setUpdateClientStatus(null)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.statusPillText, updateClientStatus === null && { color: 'white' }]}>No Change</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{STATUS_OPTIONS.map(opt => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={opt.id}
|
||||||
|
style={[styles.statusPill, { borderColor: opt.color }, updateClientStatus === opt.id && { backgroundColor: opt.color }]}
|
||||||
|
onPress={() => setUpdateClientStatus(opt.id)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.statusPillText, { color: opt.color }, updateClientStatus === opt.id && { color: 'white' }]}>{opt.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.section}>Description *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.textArea}
|
||||||
|
placeholder="What did you do? e.g. Called 20 leads from the database list..."
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.section}>Quantity (Leads Generated)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="How many leads did this generate?"
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={quantity}
|
||||||
|
onChangeText={setQuantity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity style={[styles.submitBtn, loading && { opacity: 0.6 }]} onPress={handleSubmitCall} disabled={loading}>
|
||||||
|
{loading ? <ActivityIndicator color="white" /> : <Text style={styles.submitBtnText}>📤 Log Activity</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── FOLLOWUP TAB ── */}
|
||||||
|
{activeTab === 'followup' && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.section}>Client *</Text>
|
||||||
|
<ClientPicker selected={fuClient} onSelect={setFuClient} />
|
||||||
|
|
||||||
|
<Text style={styles.section}>Notes / Task Description *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.textArea}
|
||||||
|
placeholder="What needs to be done? e.g. Call back regarding demo pricing..."
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
value={fuNotes}
|
||||||
|
onChangeText={setFuNotes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.section}>Follow-up Date *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
value={fuDate}
|
||||||
|
onChangeText={setFuDate}
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.section}>Follow-up Time *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="HH:MM (24h format, e.g. 14:30)"
|
||||||
|
value={fuTime}
|
||||||
|
onChangeText={setFuTime}
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.reminderBox}>
|
||||||
|
<Text style={styles.reminderText}>📲 You'll receive a mobile alert at the scheduled time to complete this follow-up.</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={[styles.submitBtn, { backgroundColor: '#6366f1' }, loading && { opacity: 0.6 }]} onPress={handleSubmitFollowup} disabled={loading}>
|
||||||
|
{loading ? <ActivityIndicator color="white" /> : <Text style={styles.submitBtnText}>📅 Schedule Follow-up</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#f8f9fa' },
|
||||||
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: Colors.primary, paddingHorizontal: 16, paddingBottom: 14, paddingTop: 10 },
|
||||||
|
backBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
backBtnText: { color: 'white', fontSize: 24, fontWeight: '300', lineHeight: 28 },
|
||||||
|
headerTitle: { color: 'white', fontSize: 18, fontWeight: '900' },
|
||||||
|
tabs: { flexDirection: 'row', backgroundColor: 'white', borderBottomWidth: 1, borderBottomColor: '#e2e8f0' },
|
||||||
|
tab: { flex: 1, alignItems: 'center', paddingVertical: 12, borderBottomWidth: 3, borderBottomColor: 'transparent', flexDirection: 'row', justifyContent: 'center', gap: 6 },
|
||||||
|
tabActive: { borderBottomColor: Colors.primary },
|
||||||
|
tabIcon: { fontSize: 16 },
|
||||||
|
tabLabel: { fontSize: 12, fontWeight: '700', color: '#94a3b8' },
|
||||||
|
tabLabelActive: { color: Colors.primary },
|
||||||
|
body: { padding: 16, paddingBottom: 48 },
|
||||||
|
section: { fontSize: 11, fontWeight: '900', color: '#64748b', textTransform: 'uppercase', letterSpacing: 1, marginTop: 20, marginBottom: 10 },
|
||||||
|
typeGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||||
|
typeCard: { width: '47%', backgroundColor: 'white', padding: 14, borderRadius: 14, alignItems: 'center', borderWidth: 1.5, borderColor: '#e2e8f0' },
|
||||||
|
typeCardActive: { borderColor: Colors.primary, backgroundColor: '#f0f4ff' },
|
||||||
|
typeIcon: { fontSize: 26, marginBottom: 6 },
|
||||||
|
typeLabel: { fontSize: 11, fontWeight: '700', color: '#64748b', textAlign: 'center' },
|
||||||
|
typeLabelActive: { color: Colors.primary },
|
||||||
|
clientPickerContainer: { flexDirection: 'row', gap: 10, alignItems: 'center' },
|
||||||
|
clientPicker: { flex: 1, backgroundColor: 'white', borderRadius: 12, borderWidth: 1.5, borderColor: '#e2e8f0', padding: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
inlineCallBtn: { backgroundColor: '#eef2ff', width: 48, height: 48, borderRadius: 12, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: 'rgba(0,0,0,0.05)' },
|
||||||
|
inlineCallIcon: { fontSize: 18 },
|
||||||
|
clientPickerPlaceholder: { color: '#94a3b8', fontSize: 14 },
|
||||||
|
clientPickerSelected: { color: '#1e293b', fontSize: 14, fontWeight: '700' },
|
||||||
|
clientPickerArrow: { color: '#94a3b8', fontSize: 20, fontWeight: '300' },
|
||||||
|
input: { backgroundColor: 'white', borderRadius: 12, borderWidth: 1.5, borderColor: '#e2e8f0', padding: 14, fontSize: 15 },
|
||||||
|
textArea: { backgroundColor: 'white', borderRadius: 12, borderWidth: 1.5, borderColor: '#e2e8f0', padding: 14, fontSize: 15, minHeight: 100, textAlignVertical: 'top' },
|
||||||
|
reminderBox: { backgroundColor: '#f0f4ff', borderRadius: 12, padding: 14, marginTop: 16, borderLeftWidth: 4, borderLeftColor: '#6366f1' },
|
||||||
|
reminderText: { fontSize: 12, color: '#6366f1', fontWeight: '600', lineHeight: 18 },
|
||||||
|
submitBtn: { backgroundColor: Colors.primary, borderRadius: 14, padding: 18, alignItems: 'center', marginTop: 24, elevation: 4, shadowColor: Colors.primary, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8 },
|
||||||
|
submitBtnText: { color: 'white', fontSize: 16, fontWeight: '900' },
|
||||||
|
// Modal
|
||||||
|
modalContainer: { flex: 1, backgroundColor: '#f8f9fa' },
|
||||||
|
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 20, backgroundColor: Colors.primary },
|
||||||
|
modalTitle: { color: 'white', fontSize: 18, fontWeight: '900' },
|
||||||
|
modalClose: { color: 'white', fontSize: 22, fontWeight: '300' },
|
||||||
|
searchInput: { margin: 12, padding: 14, backgroundColor: 'white', borderRadius: 12, borderWidth: 1, borderColor: '#e2e8f0', fontSize: 15 },
|
||||||
|
clientRow: { flexDirection: 'row', alignItems: 'center', padding: 16, borderBottomWidth: 1, borderBottomColor: '#f1f5f9', backgroundColor: 'white', marginHorizontal: 12, marginBottom: 4, borderRadius: 10 },
|
||||||
|
clientAvatar: { width: 40, height: 40, borderRadius: 20, backgroundColor: Colors.primary + '20', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||||
|
clientAvatarText: { color: Colors.primary, fontWeight: '900', fontSize: 16 },
|
||||||
|
clientRowName: { fontSize: 15, fontWeight: '700', color: '#1e293b' },
|
||||||
|
clientRowPhone: { fontSize: 12, color: '#94a3b8', marginTop: 2 },
|
||||||
|
emptyText: { textAlign: 'center', color: '#9ca3af', padding: 20, marginTop: 20 },
|
||||||
|
statusSection: { backgroundColor: '#f9fafb', padding: 15, borderRadius: 12, marginBottom: 20, borderWidth: 1, borderColor: '#f3f4f6' },
|
||||||
|
statusPill: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, borderWidth: 1, borderColor: '#cbd5e1', backgroundColor: 'white' },
|
||||||
|
statusPillActive: { backgroundColor: '#64748b', borderColor: '#64748b' },
|
||||||
|
statusPillText: { fontSize: 12, fontWeight: '700', color: '#64748b' },
|
||||||
|
switchLabel: { fontSize: 14, fontWeight: '700', color: '#374151' },
|
||||||
|
switchSub: { fontSize: 11, color: '#6b7280', marginTop: 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LogActivityScreen;
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
import React, { useContext, useState, useEffect } from 'react';
|
||||||
|
import { View, Text, TextInput, StyleSheet, TouchableOpacity, ActivityIndicator, Image, Alert, Dimensions } from 'react-native';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import ReactNativeBiometrics, { BiometryTypes } from 'react-native-biometrics';
|
||||||
|
import * as Keychain from 'react-native-keychain';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
const LoginScreen = ({ navigation }) => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const { login, isLoading } = useContext(AuthContext);
|
||||||
|
const [biometryType, setBiometryType] = useState(null);
|
||||||
|
|
||||||
|
const rnBiometrics = new ReactNativeBiometrics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkBiometrics();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkBiometrics = async () => {
|
||||||
|
try {
|
||||||
|
const { available, biometryType } = await rnBiometrics.isSensorAvailable();
|
||||||
|
if (available && biometryType) {
|
||||||
|
setBiometryType(biometryType);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Biometrics not available', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBiometricLogin = async () => {
|
||||||
|
try {
|
||||||
|
const { success } = await rnBiometrics.simplePrompt({ promptMessage: 'Confirm biometric login' });
|
||||||
|
if (success) {
|
||||||
|
// Get stored credentials
|
||||||
|
console.log("Retrieving credentials from Keychain...");
|
||||||
|
const credentials = await Keychain.getGenericPassword({ service: 'igcrm_biometric' });
|
||||||
|
console.log("Keychain credentials retrieved:", credentials ? "YES (Username: " + credentials.username + ")" : "NO");
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
await login(credentials.username, credentials.password);
|
||||||
|
} else {
|
||||||
|
Alert.alert("Error", "No saved credentials found. Please sign in with password once.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.alert("Failed", "Biometric verification failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Biometric error', error);
|
||||||
|
Alert.alert("Error", "Biometric login unavailable. Ensure you have signed in with password first.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.headerContainer}>
|
||||||
|
<Text style={styles.headerTitle}>Welcome Back</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>Sign in to continue to IgCRM</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formContainer}>
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>Email Address</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={email}
|
||||||
|
placeholder="john@example.com"
|
||||||
|
onChangeText={text => setEmail(text)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>Password</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
placeholder="••••••••"
|
||||||
|
onChangeText={text => setPassword(text)}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.forgotPassword} onPress={() => Alert.alert("Reset", "Contact Admin to reset password")}>
|
||||||
|
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size="large" color={Colors.primary} style={{ marginTop: 20 }} />
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.loginButton}
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error.response?.status === 401
|
||||||
|
? "Invalid email or password"
|
||||||
|
: "An error occurred during login. Please try again.";
|
||||||
|
Alert.alert("Login Failed", msg);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.loginButtonText}>Sign In</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{biometryType && (
|
||||||
|
<TouchableOpacity style={styles.biometricButton} onPress={handleBiometricLogin}>
|
||||||
|
<Text style={styles.biometricText}>
|
||||||
|
Login with {biometryType === BiometryTypes.Biometrics ? 'Biometrics' : biometryType}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
},
|
||||||
|
headerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: 10
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.accent,
|
||||||
|
opacity: 0.8
|
||||||
|
},
|
||||||
|
formContainer: {
|
||||||
|
flex: 2,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
borderTopLeftRadius: 30,
|
||||||
|
borderTopRightRadius: 30,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 40,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginBottom: 8,
|
||||||
|
marginLeft: 4
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: Colors.card,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.text,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.border,
|
||||||
|
marginBottom: 20
|
||||||
|
},
|
||||||
|
forgotPassword: {
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
marginBottom: 30
|
||||||
|
},
|
||||||
|
forgotPasswordText: {
|
||||||
|
color: Colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 14
|
||||||
|
},
|
||||||
|
loginButton: {
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: Colors.primary,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 5
|
||||||
|
},
|
||||||
|
loginButtonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
biometricButton: {
|
||||||
|
marginTop: 30,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 10
|
||||||
|
},
|
||||||
|
biometricText: {
|
||||||
|
color: Colors.textMuted,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LoginScreen;
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
import React, { useState, useEffect, useContext, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, ScrollView, TouchableOpacity,
|
||||||
|
RefreshControl, Animated, Dimensions, StatusBar, Alert
|
||||||
|
} from 'react-native';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
const MyTargetScreen = ({ navigation }) => {
|
||||||
|
const { userInfo } = useContext(AuthContext);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [target, setTarget] = useState(null);
|
||||||
|
const [dashStats, setDashStats] = useState(null);
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const [followups, setFollowups] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const progressAnim = useState(new Animated.Value(0))[0];
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [dashRes, notifRes, followRes] = await Promise.all([
|
||||||
|
api.get('/dashboard/stats'),
|
||||||
|
api.get('/notifications/my'),
|
||||||
|
api.get('/followups'),
|
||||||
|
]);
|
||||||
|
const t = dashRes.data?.target;
|
||||||
|
setTarget(t);
|
||||||
|
setDashStats(dashRes.data);
|
||||||
|
const targetNotifs = notifRes.data.filter(n => ['TARGET', 'TARGET_UPDATE', 'FOLLOWUP_ASSIGNED', 'PERFORMANCE_ALERT'].includes(n.type));
|
||||||
|
setNotifications(targetNotifs);
|
||||||
|
|
||||||
|
const myFollowups = followRes.data.filter(f => f.userId === userInfo.id && f.status === 'PENDING');
|
||||||
|
setFollowups(myFollowups);
|
||||||
|
|
||||||
|
// Animate progress bar
|
||||||
|
if (t) {
|
||||||
|
const pct = Math.min(1, t.achieved / t.monthly);
|
||||||
|
Animated.timing(progressAnim, {
|
||||||
|
toValue: pct,
|
||||||
|
duration: 1200,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('MyTarget fetch error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkDone = async (id) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/followups/${id}`, { status: 'DONE' });
|
||||||
|
setFollowups(followups.filter(f => f.id !== id));
|
||||||
|
Alert.alert("Success", "Follow-up marked as completed.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert("Error", "Failed to update status.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatINR = (v) => {
|
||||||
|
if (!v) return '₹0';
|
||||||
|
if (v >= 100000) return `₹${(v / 100000).toFixed(1)}L`;
|
||||||
|
if (v >= 1000) return `₹${(v / 1000).toFixed(0)}k`;
|
||||||
|
return `₹${v}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pct = target ? Math.min(100, Math.round((target.achieved / target.monthly) * 100)) : 0;
|
||||||
|
const minPct = target ? Math.min(100, Math.round((target.achieved / target.minimum) * 100)) : 0;
|
||||||
|
const gap = target ? Math.max(0, target.monthly - target.achieved) : 0;
|
||||||
|
const qualifiedMin = target ? target.achieved >= target.minimum : false;
|
||||||
|
|
||||||
|
const benchmarks = target ? [
|
||||||
|
{ label: 'Daily Leads', value: target.dailyLead, icon: '🎯', color: '#6366f1', bg: '#eef2ff' },
|
||||||
|
{ label: 'Demos / Month', value: target.requiredDemos || Math.ceil(target.monthly / 40000) * 3, icon: '📊', color: '#0ea5e9', bg: '#f0f9ff' },
|
||||||
|
{ label: 'Potentials', value: target.requiredPotential || Math.ceil(target.monthly / 40000) * 6, icon: '💡', color: '#f59e0b', bg: '#fffbeb' },
|
||||||
|
{ label: 'Closures Goal', value: target.requiredClosures || Math.ceil(target.monthly / 40000), icon: '🏆', color: '#10b981', bg: '#f0fdf4' },
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<Text style={{ color: Colors.textMuted }}>Loading your targets...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar backgroundColor={Colors.primary} barStyle="light-content" />
|
||||||
|
<ScrollView
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={[Colors.primary]} />}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top + 20 }]}>
|
||||||
|
<Text style={styles.headerTitle}>My Target</Text>
|
||||||
|
<Text style={styles.headerSub}>Track where you stand this month</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{target ? (
|
||||||
|
<>
|
||||||
|
{/* Main Progress Card */}
|
||||||
|
<View style={styles.progressCard}>
|
||||||
|
<View style={styles.progressHeader}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.progressLabel}>MONTHLY TARGET</Text>
|
||||||
|
<Text style={styles.progressMain}>{formatINR(target.monthly)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.badge, { backgroundColor: qualifiedMin ? '#dcfce7' : '#fef3c7' }]}>
|
||||||
|
<Text style={[styles.badgeText, { color: qualifiedMin ? '#16a34a' : '#d97706' }]}>
|
||||||
|
{qualifiedMin ? '✅ Min Reached' : '⏳ In Progress'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Achievement Bar */}
|
||||||
|
<View style={styles.barSection}>
|
||||||
|
<View style={styles.barRow}>
|
||||||
|
<Text style={styles.barLabel}>Achievement</Text>
|
||||||
|
<Text style={styles.barPct}>{pct}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.barBg}>
|
||||||
|
<Animated.View style={[styles.barFill, {
|
||||||
|
width: progressAnim.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }),
|
||||||
|
backgroundColor: Colors.primary
|
||||||
|
}]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.barLegend}>
|
||||||
|
<Text style={styles.barLegendText}>Achieved: {formatINR(target.achieved)}</Text>
|
||||||
|
<Text style={styles.barLegendText}>Gap: {formatINR(gap)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Min Target Bar */}
|
||||||
|
<View style={styles.barSection}>
|
||||||
|
<View style={styles.barRow}>
|
||||||
|
<Text style={styles.barLabel}>Minimum Target</Text>
|
||||||
|
<Text style={[styles.barPct, { color: qualifiedMin ? '#16a34a' : '#d97706' }]}>{minPct}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.barBg}>
|
||||||
|
<View style={[styles.barFill, { width: `${minPct}%`, backgroundColor: qualifiedMin ? '#10b981' : '#f59e0b' }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.barLegendText}>Minimum: {formatINR(target.minimum)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Weekly Target */}
|
||||||
|
<View style={styles.weeklyRow}>
|
||||||
|
<View style={styles.weeklyCard}>
|
||||||
|
<Text style={styles.weeklyLabel}>WEEKLY TARGET</Text>
|
||||||
|
<Text style={styles.weeklyValue}>{formatINR(target.weekly)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.weeklyCard}>
|
||||||
|
<Text style={styles.weeklyLabel}>AVG DEAL VALUE</Text>
|
||||||
|
<Text style={styles.weeklyValue}>{formatINR(target.avgDealValue || 40000)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Activity Benchmarks */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>📋 Activity Benchmarks</Text>
|
||||||
|
<View style={styles.benchmarkGrid}>
|
||||||
|
{benchmarks.map((b, i) => (
|
||||||
|
<View key={i} style={[styles.benchmarkCard, { backgroundColor: b.bg }]}>
|
||||||
|
<Text style={styles.benchmarkIcon}>{b.icon}</Text>
|
||||||
|
<Text style={[styles.benchmarkValue, { color: b.color }]}>{b.value}</Text>
|
||||||
|
<Text style={styles.benchmarkLabel}>{b.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Performance Score */}
|
||||||
|
{dashStats?.performance && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>⚡ Performance Score</Text>
|
||||||
|
<View style={styles.scoreCard}>
|
||||||
|
<View style={styles.scoreCircle}>
|
||||||
|
<Text style={styles.scoreNum}>{Math.round(dashStats.performance.score)}</Text>
|
||||||
|
<Text style={styles.scoreMax}>/100</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.scoreBreakdown}>
|
||||||
|
{[
|
||||||
|
{ label: 'Revenue', v: dashStats.performance.breakdown.revenue, max: 40 },
|
||||||
|
{ label: 'Conversion', v: dashStats.performance.breakdown.conversion, max: 20 },
|
||||||
|
{ label: 'Activity', v: dashStats.performance.breakdown.activity, max: 15 },
|
||||||
|
{ label: 'Discipline', v: dashStats.performance.breakdown.discipline, max: 15 },
|
||||||
|
].map((item, idx) => (
|
||||||
|
<View key={idx} style={styles.scoreRow}>
|
||||||
|
<Text style={styles.scoreLabelText}>{item.label}</Text>
|
||||||
|
<View style={styles.scoreMiniBar}>
|
||||||
|
<View style={[styles.scoreMiniBarFill, { width: `${(item.v / item.max) * 100}%` }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.scoreVal}>{Math.round(item.v)}/{item.max}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.noTarget}>
|
||||||
|
<Text style={styles.noTargetIcon}>🎯</Text>
|
||||||
|
<Text style={styles.noTargetTitle}>No target assigned yet</Text>
|
||||||
|
<Text style={styles.noTargetSub}>Your manager will set your target soon.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Follow-ups Section */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>📅 Pending Follow-ups</Text>
|
||||||
|
{followups.length === 0 ? (
|
||||||
|
<View style={styles.emptyFollowup}>
|
||||||
|
<Text style={styles.emptyText}>No pending tasks for now. Well done!</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
followups.map((f, i) => (
|
||||||
|
<View key={i} style={styles.followupCard}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.followupClient}>{f.client?.name || 'Unknown Client'}</Text>
|
||||||
|
<Text style={styles.followupNotes}>{f.notes}</Text>
|
||||||
|
<Text style={styles.followupDate}>
|
||||||
|
Due: {new Date(f.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.doneButton}
|
||||||
|
onPress={() => handleMarkDone(f.id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.doneButtonText}>Done</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Notifications Log */}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>🔔 Target Notifications</Text>
|
||||||
|
{notifications.slice(0, 5).map((n, i) => (
|
||||||
|
<View key={i} style={[styles.notifCard, { borderLeftColor: n.type === 'TARGET' ? Colors.primary : '#f59e0b' }]}>
|
||||||
|
<Text style={styles.notifTitle}>{n.title}</Text>
|
||||||
|
<Text style={styles.notifBody}>{n.body}</Text>
|
||||||
|
<Text style={styles.notifDate}>{new Date(n.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#f8f9fa' },
|
||||||
|
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
header: {
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
headerTitle: { color: 'white', fontSize: 28, fontWeight: '900', letterSpacing: -0.5 },
|
||||||
|
headerSub: { color: 'rgba(255,255,255,0.7)', fontSize: 13, marginTop: 4 },
|
||||||
|
progressCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: -18,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 12,
|
||||||
|
},
|
||||||
|
progressHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 },
|
||||||
|
progressLabel: { fontSize: 9, fontWeight: '900', color: Colors.textMuted, letterSpacing: 1.5, textTransform: 'uppercase' },
|
||||||
|
progressMain: { fontSize: 30, fontWeight: '900', color: Colors.text, marginTop: 4 },
|
||||||
|
badge: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 20 },
|
||||||
|
badgeText: { fontSize: 11, fontWeight: '700' },
|
||||||
|
barSection: { marginBottom: 16 },
|
||||||
|
barRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
||||||
|
barLabel: { fontSize: 11, fontWeight: '700', color: Colors.textMuted },
|
||||||
|
barPct: { fontSize: 12, fontWeight: '900', color: Colors.primary },
|
||||||
|
barBg: { height: 8, backgroundColor: '#f1f5f9', borderRadius: 4, overflow: 'hidden' },
|
||||||
|
barFill: { height: '100%', borderRadius: 4 },
|
||||||
|
barLegend: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 5 },
|
||||||
|
barLegendText: { fontSize: 10, color: Colors.textMuted, fontWeight: '600' },
|
||||||
|
weeklyRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
||||||
|
weeklyCard: { width: '48%', backgroundColor: '#f8f9fa', borderRadius: 12, padding: 12, alignItems: 'center' },
|
||||||
|
weeklyLabel: { fontSize: 8, fontWeight: '900', color: Colors.textMuted, letterSpacing: 1, textTransform: 'uppercase' },
|
||||||
|
weeklyValue: { fontSize: 16, fontWeight: '900', color: Colors.text, marginTop: 4 },
|
||||||
|
section: { marginHorizontal: 16, marginTop: 20 },
|
||||||
|
sectionTitle: { fontSize: 14, fontWeight: '800', color: Colors.text, marginBottom: 12 },
|
||||||
|
benchmarkGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 10 },
|
||||||
|
benchmarkCard: {
|
||||||
|
width: (width - 52) / 2,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
benchmarkIcon: { fontSize: 28, marginBottom: 6 },
|
||||||
|
benchmarkValue: { fontSize: 26, fontWeight: '900' },
|
||||||
|
benchmarkLabel: { fontSize: 10, fontWeight: '700', color: '#64748b', marginTop: 4, textAlign: 'center' },
|
||||||
|
scoreCard: { backgroundColor: 'white', borderRadius: 20, padding: 20, flexDirection: 'row', elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 8 },
|
||||||
|
scoreCircle: { width: 80, height: 80, borderRadius: 40, backgroundColor: Colors.primary + '15', justifyContent: 'center', alignItems: 'center', marginRight: 16 },
|
||||||
|
scoreNum: { fontSize: 28, fontWeight: '900', color: Colors.primary },
|
||||||
|
scoreMax: { fontSize: 10, color: Colors.textMuted, fontWeight: '700' },
|
||||||
|
scoreBreakdown: { flex: 1, justifyContent: 'center', gap: 8 },
|
||||||
|
scoreRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
scoreLabelText: { width: 70, fontSize: 10, fontWeight: '700', color: Colors.textMuted },
|
||||||
|
scoreMiniBar: { flex: 1, height: 5, backgroundColor: '#f1f5f9', borderRadius: 3, overflow: 'hidden', marginHorizontal: 8 },
|
||||||
|
scoreMiniBarFill: { height: '100%', backgroundColor: Colors.primary, borderRadius: 3 },
|
||||||
|
scoreVal: { fontSize: 10, fontWeight: '900', color: Colors.text, width: 35, textAlign: 'right' },
|
||||||
|
noTarget: { alignItems: 'center', padding: 48 },
|
||||||
|
noTargetIcon: { fontSize: 56, marginBottom: 16 },
|
||||||
|
noTargetTitle: { fontSize: 18, fontWeight: '800', color: Colors.text },
|
||||||
|
noTargetSub: { fontSize: 13, color: Colors.textMuted, marginTop: 8, textAlign: 'center' },
|
||||||
|
notifCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 4,
|
||||||
|
},
|
||||||
|
notifTitle: { fontSize: 13, fontWeight: '800', color: Colors.text },
|
||||||
|
notifBody: { fontSize: 12, color: Colors.textMuted, marginTop: 4, lineHeight: 18 },
|
||||||
|
notifDate: { fontSize: 10, color: Colors.textLight, marginTop: 6, fontWeight: '600' },
|
||||||
|
emptyFollowup: { backgroundColor: 'white', borderRadius: 12, padding: 20, alignItems: 'center', borderStyle: 'dashed', borderWidth: 1, borderColor: '#cbd5e1' },
|
||||||
|
emptyText: { fontSize: 12, color: '#94a3b8', fontStyle: 'italic' },
|
||||||
|
followupCard: { backgroundColor: 'white', borderRadius: 16, padding: 16, marginBottom: 12, flexDirection: 'row', alignItems: 'center', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 3 },
|
||||||
|
followupClient: { fontSize: 14, fontWeight: '900', color: Colors.primary, marginBottom: 4 },
|
||||||
|
followupNotes: { fontSize: 12, color: Colors.text, lineHeight: 18, marginBottom: 6 },
|
||||||
|
followupDate: { fontSize: 10, color: Colors.textMuted, fontWeight: '700' },
|
||||||
|
doneButton: { backgroundColor: Colors.primary, paddingHorizontal: 16, paddingVertical: 8, borderRadius: 10, marginLeft: 12 },
|
||||||
|
doneButtonText: { color: 'white', fontSize: 12, fontWeight: '900' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MyTargetScreen;
|
||||||
|
|
@ -0,0 +1,531 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, SafeAreaView, Alert, Modal, TextInput, ScrollView } from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const PipelineScreen = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [opportunities, setOpportunities] = useState([]);
|
||||||
|
const [selectedStage, setSelectedStage] = useState('LEAD');
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
{ id: 'LEAD', label: 'Lead' },
|
||||||
|
{ id: 'QUALIFIED', label: 'Qual' },
|
||||||
|
{ id: 'POTENTIAL', label: 'Poten' },
|
||||||
|
{ id: 'DEMO', label: 'Demo' },
|
||||||
|
{ id: 'WON', label: 'Won' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [selectedOpp, setSelectedOpp] = useState(null);
|
||||||
|
const [updateData, setUpdateData] = useState({});
|
||||||
|
|
||||||
|
const fetchOpportunities = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data } = await api.get('/opportunities');
|
||||||
|
setOpportunities(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch opportunities', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchOpportunities();
|
||||||
|
}, [fetchOpportunities])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenModal = (item) => {
|
||||||
|
setSelectedOpp(item);
|
||||||
|
setUpdateData({
|
||||||
|
stage: item.stage,
|
||||||
|
demoPersonName: item.demoPersonName || '',
|
||||||
|
demoContactDetails: item.demoContactDetails || '',
|
||||||
|
expectedCloseDate: item.expectedCloseDate ? item.expectedCloseDate.split('T')[0] : '',
|
||||||
|
competitorMention: item.competitorMention || '',
|
||||||
|
keyQueries: item.keyQueries || '',
|
||||||
|
paymentMode: item.paymentMode || '',
|
||||||
|
specialRate: item.specialRate ? String(item.specialRate) : '',
|
||||||
|
freeOffers: item.freeOffers || '',
|
||||||
|
negotiationRemarks: item.negotiationRemarks || '',
|
||||||
|
value: String(item.value)
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...updateData,
|
||||||
|
value: Number(updateData.value),
|
||||||
|
specialRate: updateData.specialRate ? Number(updateData.specialRate) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.patch(`/opportunities/${selectedOpp.id}`, payload);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchOpportunities();
|
||||||
|
Alert.alert("Success", "Opportunity updated");
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error.response?.data?.message || error.message;
|
||||||
|
Alert.alert("Update Failed", msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }) => (
|
||||||
|
<TouchableOpacity style={styles.card} activeOpacity={0.7} onPress={() => handleOpenModal(item)}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||||
|
<Text style={styles.cardValue}>₹{item.value.toLocaleString()}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<View style={styles.clientContainer}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>{item.client?.name?.charAt(0)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.clientName}>{item.client?.name}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.priorityBadge, { backgroundColor: item.priority === 'High' ? '#fee2e2' : '#fef3c7' }]}>
|
||||||
|
<Text style={[styles.priorityText, { color: item.priority === 'High' ? '#ef4444' : '#f59e0b' }]}>
|
||||||
|
{item.priority || 'Normal'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.stage === 'WON' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.workOrderButton}
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/work-orders/from-opportunity', { opportunityId: item.id });
|
||||||
|
Alert.alert("Success", "Work order created!");
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert("Error", "Already converted or failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.workOrderButtonText}>Start Work Order</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredItems = opportunities.filter(item => item.stage === selectedStage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
{/* Stage Selector */}
|
||||||
|
<View style={styles.stageBar}>
|
||||||
|
{stages.map((stage) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={stage.id}
|
||||||
|
onPress={() => setSelectedStage(stage.id)}
|
||||||
|
style={[
|
||||||
|
styles.stageItem,
|
||||||
|
selectedStage === stage.id && styles.activeStageItem
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.stageLabel,
|
||||||
|
selectedStage === stage.id && styles.activeStageLabel
|
||||||
|
]}>
|
||||||
|
{stage.label}
|
||||||
|
</Text>
|
||||||
|
<View style={[
|
||||||
|
styles.stageIndicator,
|
||||||
|
selectedStage === stage.id && { backgroundColor: selectedStage === 'WON' ? Colors.secondary : Colors.primary }
|
||||||
|
]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator size="large" color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filteredItems}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.listContainer}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>No opportunities in this stage</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
onRefresh={fetchOpportunities}
|
||||||
|
refreshing={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* UPDATE MODAL */}
|
||||||
|
<Modal
|
||||||
|
visible={isModalOpen}
|
||||||
|
animationType="slide"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={() => setIsModalOpen(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Update Stage</Text>
|
||||||
|
<TouchableOpacity onPress={() => setIsModalOpen(false)}>
|
||||||
|
<Text style={styles.closeButton}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.modalForm}>
|
||||||
|
<Text style={styles.label}>Current Stage</Text>
|
||||||
|
<View style={styles.stagePicker}>
|
||||||
|
{stages.map(s => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={s.id}
|
||||||
|
style={[styles.stageChip, updateData.stage === s.id && styles.activeStageChip]}
|
||||||
|
onPress={() => setUpdateData({...updateData, stage: s.id})}
|
||||||
|
>
|
||||||
|
<Text style={[styles.stageChipText, updateData.stage === s.id && styles.activeStageChipText]}>{s.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Expected Revenue (₹)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={updateData.value}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, value: t})}
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(updateData.stage === 'DEMO' || updateData.stage === 'WON') && (
|
||||||
|
<View style={styles.mandatorySection}>
|
||||||
|
<Text style={styles.sectionHeader}>DEMO DETAILS (MANDATORY)</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Person Name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={updateData.demoPersonName}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, demoPersonName: t})}
|
||||||
|
placeholder="Name of person met"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Contact Details</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={updateData.demoContactDetails}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, demoContactDetails: t})}
|
||||||
|
placeholder="Phone or Email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Expected Closing Date (YYYY-MM-DD)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={updateData.expectedCloseDate}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, expectedCloseDate: t})}
|
||||||
|
placeholder="2024-12-31"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Competitor Mention</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={updateData.competitorMention}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, competitorMention: t})}
|
||||||
|
placeholder="None or Competitor Name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Queries / Objections</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, {height: 60}]}
|
||||||
|
multiline
|
||||||
|
value={updateData.keyQueries}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, keyQueries: t})}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateData.stage === 'WON' && (
|
||||||
|
<View style={[styles.mandatorySection, {backgroundColor: '#f0fdf4', borderColor: '#bbf7d0'}]}>
|
||||||
|
<Text style={[styles.sectionHeader, {color: '#166534'}]}>CLOSING DETAILS (MANDATORY)</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Payment Mode</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={updateData.paymentMode}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, paymentMode: t})}
|
||||||
|
placeholder="Cash / Bank Transfer / UPI"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Special Rate (Optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={updateData.specialRate}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, specialRate: t})}
|
||||||
|
placeholder="Final agreed rate"
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Negotiation Remarks</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={updateData.negotiationRemarks}
|
||||||
|
onChangeText={t => setUpdateData({...updateData, negotiationRemarks: t})}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.saveButton} onPress={handleUpdate}>
|
||||||
|
<Text style={styles.saveButtonText}>SAVE UPDATE</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{height: 40}} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
},
|
||||||
|
stageBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#edf2f7',
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
stageItem: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
activeStageItem: {
|
||||||
|
// backgroundColor: '#fdf2f8',
|
||||||
|
},
|
||||||
|
stageLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
activeStageLabel: {
|
||||||
|
color: Colors.text,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
stageIndicator: {
|
||||||
|
height: 3,
|
||||||
|
width: '60%',
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: Colors.primary,
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
cardValue: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: Colors.primary,
|
||||||
|
},
|
||||||
|
cardFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
clientContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: Colors.border,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.textMuted,
|
||||||
|
},
|
||||||
|
priorityBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
priorityText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
padding: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: '#a0aec0',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
workOrderButton: {
|
||||||
|
marginTop: 15,
|
||||||
|
backgroundColor: Colors.secondary,
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
workOrderButtonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
height: '85%',
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#f1f5f9',
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.text,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
color: Colors.textMuted,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modalForm: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
marginBottom: 8,
|
||||||
|
marginTop: 15,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.text,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e2e8f0',
|
||||||
|
},
|
||||||
|
stagePicker: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
stageChip: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#f1f5f9',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e2e8f0',
|
||||||
|
},
|
||||||
|
activeStageChip: {
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderColor: Colors.primary,
|
||||||
|
},
|
||||||
|
stageChipText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: Colors.textMuted,
|
||||||
|
},
|
||||||
|
activeStageChipText: {
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
mandatorySection: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 15,
|
||||||
|
backgroundColor: '#f0f7ff',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#bae6fd',
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'black',
|
||||||
|
color: '#0369a1',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 30,
|
||||||
|
shadowColor: Colors.primary,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 16,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PipelineScreen;
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import React, { useState, useCallback, useContext } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, SectionList, TouchableOpacity,
|
||||||
|
RefreshControl, StatusBar, Alert, Linking
|
||||||
|
} from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Colors from '../constants/Colors';
|
||||||
|
|
||||||
|
const TasksScreen = ({ navigation }) => {
|
||||||
|
const { userInfo } = useContext(AuthContext);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [sections, setSections] = useState([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [activeFilter, setActiveFilter] = useState('ALL'); // ALL, PENDING, DONE
|
||||||
|
|
||||||
|
const groupByDay = (followups) => {
|
||||||
|
const map = {};
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const tomorrow = new Date(today.getTime() + 86400000);
|
||||||
|
|
||||||
|
followups.forEach(f => {
|
||||||
|
const d = new Date(f.date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
let label;
|
||||||
|
if (d.getTime() === today.getTime()) label = 'Today';
|
||||||
|
else if (d.getTime() < today.getTime()) label = `Overdue — ${d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}`;
|
||||||
|
else if (d.getTime() === tomorrow.getTime()) label = 'Tomorrow';
|
||||||
|
else label = d.toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'short' });
|
||||||
|
|
||||||
|
if (!map[label]) map[label] = { title: label, data: [], ts: d.getTime() };
|
||||||
|
map[label].data.push(f);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(map).sort((a, b) => a.ts - b.ts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ userId: userInfo.id });
|
||||||
|
if (activeFilter !== 'ALL') params.append('status', activeFilter);
|
||||||
|
const res = await api.get(`/followups?${params.toString()}`);
|
||||||
|
setSections(groupByDay(res.data));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('TasksScreen fetch error', e);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(useCallback(() => { fetchTasks(); }, [activeFilter]));
|
||||||
|
|
||||||
|
const handleMarkDone = async (id) => {
|
||||||
|
Alert.alert('Mark as Done?', 'This will complete the task and dismiss the notification.', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Done ✓', onPress: async () => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/followups/${id}`, { status: 'DONE' });
|
||||||
|
fetchTasks();
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Error', 'Could not update task.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCall = (phone) => {
|
||||||
|
if (!phone) return;
|
||||||
|
Linking.openURL(`tel:${phone}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTask = ({ item }) => {
|
||||||
|
const isPending = item.status === 'PENDING';
|
||||||
|
const isOverdue = isPending && new Date(item.date) < new Date();
|
||||||
|
return (
|
||||||
|
<View style={[styles.card, isOverdue && styles.cardOverdue, !isPending && styles.cardDone]}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: isOverdue ? '#ef4444' : isPending ? Colors.primary : '#10b981' }]} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={styles.clientName}>{item.client?.name || 'Unknown Client'}</Text>
|
||||||
|
{item.client?.phone && (
|
||||||
|
<TouchableOpacity onPress={() => handleCall(item.client.phone)}>
|
||||||
|
<Text style={styles.callIcon}>📞</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.notes} numberOfLines={2}>{item.notes}</Text>
|
||||||
|
<Text style={styles.time}>
|
||||||
|
{new Date(item.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{item.user?.name ? ` • Assigned by ${item.user.name}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isPending && (
|
||||||
|
<TouchableOpacity style={styles.doneBtn} onPress={() => handleMarkDone(item.id)}>
|
||||||
|
<Text style={styles.doneBtnText}>Done</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{!isPending && (
|
||||||
|
<View style={styles.completedBadge}>
|
||||||
|
<Text style={styles.completedText}>✓</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar backgroundColor={Colors.primary} barStyle="light-content" />
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top + 16 }]}>
|
||||||
|
<Text style={styles.headerTitle}>My Tasks</Text>
|
||||||
|
<Text style={styles.headerSub}>Sorted by date</Text>
|
||||||
|
<View style={styles.filterRow}>
|
||||||
|
{['ALL', 'PENDING', 'DONE'].map(f => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
style={[styles.filterBtn, activeFilter === f && styles.filterBtnActive]}
|
||||||
|
onPress={() => setActiveFilter(f)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.filterText, activeFilter === f && styles.filterTextActive]}>{f}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SectionList
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={renderTask}
|
||||||
|
renderSectionHeader={({ section }) => (
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||||
|
<Text style={styles.sectionCount}>{section.data.length} task{section.data.length !== 1 ? 's' : ''}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); fetchTasks(); }} colors={[Colors.primary]} />}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyIcon}>🎉</Text>
|
||||||
|
<Text style={styles.emptyTitle}>All Clear!</Text>
|
||||||
|
<Text style={styles.emptySub}>No tasks match this filter.</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#f1f5f9' },
|
||||||
|
header: { backgroundColor: Colors.primary, paddingHorizontal: 20, paddingBottom: 20 },
|
||||||
|
headerTitle: { color: 'white', fontSize: 26, fontWeight: '900' },
|
||||||
|
headerSub: { color: 'rgba(255,255,255,0.7)', fontSize: 12, marginTop: 2, marginBottom: 14 },
|
||||||
|
filterRow: { flexDirection: 'row', gap: 8 },
|
||||||
|
filterBtn: { paddingHorizontal: 16, paddingVertical: 6, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.2)' },
|
||||||
|
filterBtnActive: { backgroundColor: 'white' },
|
||||||
|
filterText: { color: 'rgba(255,255,255,0.8)', fontSize: 12, fontWeight: '700' },
|
||||||
|
filterTextActive: { color: Colors.primary },
|
||||||
|
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingTop: 20, paddingBottom: 8 },
|
||||||
|
sectionTitle: { fontSize: 13, fontWeight: '900', color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||||
|
sectionCount: { fontSize: 11, color: '#94a3b8', fontWeight: '700' },
|
||||||
|
card: { backgroundColor: 'white', marginHorizontal: 16, marginBottom: 8, borderRadius: 14, padding: 14, flexDirection: 'row', alignItems: 'center', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4 },
|
||||||
|
cardOverdue: { borderLeftWidth: 4, borderLeftColor: '#ef4444' },
|
||||||
|
cardDone: { opacity: 0.65 },
|
||||||
|
dot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
|
||||||
|
clientName: { fontSize: 14, fontWeight: '800', color: '#1e293b', marginBottom: 3, flex: 1 },
|
||||||
|
callIcon: { fontSize: 18, paddingHorizontal: 10 },
|
||||||
|
notes: { fontSize: 12, color: '#64748b', lineHeight: 17, marginBottom: 5 },
|
||||||
|
time: { fontSize: 10, color: '#94a3b8', fontWeight: '600' },
|
||||||
|
doneBtn: { backgroundColor: Colors.primary, paddingHorizontal: 14, paddingVertical: 8, borderRadius: 10, marginLeft: 10 },
|
||||||
|
doneBtnText: { color: 'white', fontSize: 11, fontWeight: '900' },
|
||||||
|
completedBadge: { width: 28, height: 28, borderRadius: 14, backgroundColor: '#dcfce7', justifyContent: 'center', alignItems: 'center', marginLeft: 10 },
|
||||||
|
completedText: { color: '#16a34a', fontWeight: '900', fontSize: 14 },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 80 },
|
||||||
|
emptyIcon: { fontSize: 48, marginBottom: 12 },
|
||||||
|
emptyTitle: { fontSize: 18, fontWeight: '800', color: '#1e293b' },
|
||||||
|
emptySub: { fontSize: 13, color: '#94a3b8', marginTop: 6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TasksScreen;
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
import ENV from '../config/env';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: ENV.API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
const token = await AsyncStorage.getItem('userToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
Binary file not shown.
Loading…
Reference in New Issue