first commit

main
Manu Krishna 2026-05-05 14:59:53 +05:30
parent 153d216a1c
commit 75653e4ca5
32 changed files with 18382 additions and 45 deletions

48
App.tsx
View File

@ -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;

View File

@ -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}"
} }

View File

@ -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"

View File

@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">mobile</string> <string name="app_name">IgCRM</string>
</resources> </resources>

View File

@ -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()

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

13418
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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",

84
scratch/fix_attendance.js Normal file
View File

@ -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!');

12
src/config/env.js Normal file
View File

@ -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;

28
src/constants/Colors.js Normal file
View File

@ -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;

View File

@ -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>
);
};

98
src/navigation/AppNav.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

561
src/screens/HomeScreen.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

207
src/screens/LoginScreen.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

186
src/screens/TasksScreen.js Normal file
View File

@ -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;

22
src/services/api.ts Normal file
View File

@ -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;

BIN
src/services/api.ts.bak Normal file

Binary file not shown.