feat(app): add customers and fix notificaton

This commit is contained in:
uttam 2026-07-23 17:50:06 +05:30
parent 0535845362
commit db1dc98300
54 changed files with 2349 additions and 147 deletions

View File

@ -4,7 +4,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { RootNavigator } from './navigation/rootNavigator'; import { RootNavigator } from './navigation/rootNavigator';
import { ThemeProvider, useTheme } from './theme'; import { ThemeProvider, useTheme } from './theme';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { store, persistor, useAppDispatch, useAppSelector, getStatusList, getSourceList, getCountryList } from '@store'; import { store, persistor, useAppDispatch, useAppSelector, getStatusList, getSourceList, getCountryList, getLanguageList, getCurrencyList } from '@store';
import { PersistGate } from 'redux-persist/integration/react'; import { PersistGate } from 'redux-persist/integration/react';
import { NotificationService } from '@services'; import { NotificationService } from '@services';
import BootSplash from 'react-native-bootsplash'; import BootSplash from 'react-native-bootsplash';
@ -35,9 +35,13 @@ const AppInit = () => {
dispatch(getStatusList()); dispatch(getStatusList());
dispatch(getSourceList()); dispatch(getSourceList());
dispatch(getCountryList()); dispatch(getCountryList());
dispatch(getLanguageList());
dispatch(getCurrencyList());
} }
}, [dispatch, token]); }, [dispatch, token]);
return null; return null;
}; };

52
app/api/customersApi.ts Normal file
View File

@ -0,0 +1,52 @@
import { CustomerItem, DeleteCustomerResponse, AddCustomerPayload, AddCustomerResponse } from '@interfaces';
import { api } from '@utils';
export const getCustomersApi = async (): Promise<CustomerItem[]> => {
return await api.get<CustomerItem[]>('/api/customers');
};
export const getCustomerDetailsApi = async (
userid: string,
): Promise<CustomerItem> => {
return await api.get<CustomerItem>(`/api/customers/${userid}`);
};
export const deleteCustomerApi = async (
userid: string,
): Promise<DeleteCustomerResponse> => {
return await api.delete<DeleteCustomerResponse>(`/api/delete/customers/${userid}`);
};
export const addCustomerApi = async (
payload: AddCustomerPayload,
): Promise<AddCustomerResponse> => {
const formData = new FormData();
formData.append('company', payload.company);
if (payload.vat) formData.append('vat', payload.vat);
if (payload.phonenumber) formData.append('phonenumber', payload.phonenumber);
if (payload.website) formData.append('website', payload.website);
if (payload.groups_in) formData.append('groups_in', payload.groups_in);
if (payload.default_language) formData.append('default_language', payload.default_language);
if (payload.default_currency) formData.append('default_currency', payload.default_currency);
if (payload.address) formData.append('address', payload.address);
if (payload.city) formData.append('city', payload.city);
if (payload.state) formData.append('state', payload.state);
if (payload.zip) formData.append('zip', payload.zip);
if (payload.country) formData.append('country', payload.country);
if (payload.billing_street) formData.append('billing_street', payload.billing_street);
if (payload.billing_city) formData.append('billing_city', payload.billing_city);
if (payload.billing_state) formData.append('billing_state', payload.billing_state);
if (payload.billing_zip) formData.append('billing_zip', payload.billing_zip);
if (payload.billing_country) formData.append('billing_country', payload.billing_country);
if (payload.shipping_street) formData.append('shipping_street', payload.shipping_street);
if (payload.shipping_city) formData.append('shipping_city', payload.shipping_city);
if (payload.shipping_state) formData.append('shipping_state', payload.shipping_state);
if (payload.shipping_zip) formData.append('shipping_zip', payload.shipping_zip);
if (payload.shipping_country) formData.append('shipping_country', payload.shipping_country);
if (payload.addedfrom) formData.append('addedfrom', payload.addedfrom);
return await api.post<AddCustomerResponse>('/api/customers', formData);
};

12
app/api/fcmTokenApi.ts Normal file
View File

@ -0,0 +1,12 @@
import { FcmTokenPayload, FcmTokenResponse } from '@interfaces';
import { api } from '@utils';
export const sendFcmTokenApi = async (
payload: FcmTokenPayload,
): Promise<FcmTokenResponse> => {
const formData = new FormData();
formData.append('id', payload.id);
formData.append('fcm_token', payload.fcm_token);
return await api.post<FcmTokenResponse>('api/staff_fcm_token', formData);
};

View File

@ -2,3 +2,7 @@ export * from './authApi';
export * from './leadsApi'; export * from './leadsApi';
export * from './leadDetailsApi'; export * from './leadDetailsApi';
export * from './listApi'; export * from './listApi';
export * from './customersApi';
export * from './fcmTokenApi';

View File

@ -1,4 +1,4 @@
import { StatusListItem, SourceListItem, CountryListItem } from '@interfaces'; import { StatusListItem, SourceListItem, CountryListItem, LanguageListItem, CurrencyListResponse } from '@interfaces';
import { api } from '@utils'; import { api } from '@utils';
export const getStatusListApi = async (): Promise<StatusListItem[]> => { export const getStatusListApi = async (): Promise<StatusListItem[]> => {
@ -15,3 +15,15 @@ export const getCountryListApi = async (): Promise<CountryListItem[]> => {
const url = 'api/countrylist'; const url = 'api/countrylist';
return await api.get<CountryListItem[]>(url); return await api.get<CountryListItem[]>(url);
}; };
export const getLanguageListApi = async (): Promise<LanguageListItem[]> => {
const url = 'api/languagelist';
return await api.get<LanguageListItem[]>(url);
};
export const getCurrencyListApi = async (): Promise<CurrencyListResponse> => {
const url = 'api/proposals/currencylist/0';
return await api.get<CurrencyListResponse>(url);
};

View File

@ -0,0 +1,12 @@
export interface CustomerDetailHeaderProps {
company: string;
fullname?: string | null;
active: string;
datecreated?: string | null;
email?: string | null;
phonenumber?: string | null;
website?: string | null;
onEmailPress?: () => void;
onPhonePress?: () => void;
onWebsitePress?: () => void;
}

View File

@ -0,0 +1,109 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
headerCard: {
backgroundColor: colors.card,
borderRadius: 16,
padding: 20,
alignItems: 'center',
marginBottom: 16,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
avatarCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.icon,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 12,
},
avatarInitial: {
color: '#FFFFFF',
fontSize: 26,
fontWeight: '700',
},
companyName: {
fontSize: 20,
fontWeight: '800',
color: colors.text,
textAlign: 'center',
},
fullName: {
fontSize: 14,
color: colors.textSecondary,
marginTop: 4,
textAlign: 'center',
},
badgeRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
gap: 8,
},
badge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
},
activeBadge: {
backgroundColor: '#D1FAE5',
},
inactiveBadge: {
backgroundColor: '#FEE2E2',
},
badgeText: {
fontSize: 12,
fontWeight: '700',
},
activeBadgeText: {
color: '#059669',
},
inactiveBadgeText: {
color: '#DC2626',
},
dateText: {
fontSize: 12,
color: colors.textMuted,
},
actionRow: {
flexDirection: 'row',
gap: 10,
marginTop: 16,
width: '100%',
},
actionBtn: {
flex: 1,
flexDirection: 'row',
height: 40,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
gap: 6,
borderWidth: 1,
},
primaryBtn: {
backgroundColor: colors.icon,
borderColor: colors.icon,
},
primaryBtnText: {
color: '#FFFFFF',
fontWeight: '600',
fontSize: 13,
},
secondaryBtn: {
backgroundColor: colors.card,
borderColor: colors.border,
},
secondaryBtnText: {
color: colors.text,
fontWeight: '600',
fontSize: 13,
},
});

View File

@ -0,0 +1,91 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme';
import { getStyles } from './customerDetailHeader.styles';
import { CustomerDetailHeaderProps } from './customerDetailHeader.props';
import { getInitials, formatDate } from '@utils';
export const CustomerDetailHeader: React.FC<CustomerDetailHeaderProps> = ({
company,
fullname,
active,
datecreated,
email,
phonenumber,
website,
onEmailPress,
onPhonePress,
onWebsitePress,
}) => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const displayName = company || fullname || 'Customer';
const initials = getInitials(displayName);
return (
<View style={styles.headerCard}>
<View style={styles.avatarCircle}>
<Text style={styles.avatarInitial}>{initials}</Text>
</View>
<Text style={styles.companyName}>{displayName}</Text>
{fullname && company ? (
<Text style={styles.fullName}>{fullname}</Text>
) : null}
<View style={styles.badgeRow}>
<View
style={[
styles.badge,
active === '1' ? styles.activeBadge : styles.inactiveBadge,
]}>
<Text
style={[
styles.badgeText,
active === '1' ? styles.activeBadgeText : styles.inactiveBadgeText,
]}>
{active === '1' ? 'Active Customer' : 'Inactive'}
</Text>
</View>
{datecreated ? (
<Text style={styles.dateText}>
Added {formatDate(datecreated)}
</Text>
) : null}
</View>
{/* Quick Actions */}
<View style={styles.actionRow}>
{phonenumber ? (
<TouchableOpacity
style={[styles.actionBtn, styles.primaryBtn]}
onPress={onPhonePress}>
<Icon name="call" size={16} color="#FFFFFF" />
<Text style={styles.primaryBtnText}>Call</Text>
</TouchableOpacity>
) : null}
{email ? (
<TouchableOpacity
style={[styles.actionBtn, styles.secondaryBtn]}
onPress={onEmailPress}>
<Icon name="mail" size={16} color={colors.text} />
<Text style={styles.secondaryBtnText}>Email</Text>
</TouchableOpacity>
) : null}
{website ? (
<TouchableOpacity
style={[styles.actionBtn, styles.secondaryBtn]}
onPress={onWebsitePress}>
<Icon name="globe" size={16} color={colors.text} />
<Text style={styles.secondaryBtnText}>Website</Text>
</TouchableOpacity>
) : null}
</View>
</View>
);
};

View File

@ -0,0 +1,2 @@
export * from './customerDetailHeader';
export * from './customerDetailHeader.props';

View File

@ -0,0 +1,8 @@
import { CustomerItem } from '@interfaces';
export interface CustomerItemCardProps {
item: CustomerItem;
onPress?: () => void;
onDelete?: () => void;
}

View File

@ -0,0 +1,126 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
customerCard: {
backgroundColor: colors.card,
borderRadius: 14,
padding: 16,
marginBottom: 12,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
borderBottomWidth: 1,
borderBottomColor: colors.border,
paddingBottom: 12,
marginBottom: 12,
},
cardHeaderRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
deleteButton: {
padding: 2,
},
cardHeaderLeft: {
flexDirection: 'row',
alignItems: 'flex-start',
flex: 1,
marginRight: 10,
},
avatarCircle: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: colors.icon,
justifyContent: 'center',
alignItems: 'center',
marginRight: 10,
},
avatarInitial: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '700',
},
cardInfo: {
flex: 1,
},
customerName: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
},
contactPerson: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 2,
},
location: {
fontSize: 12,
color: colors.textMuted,
marginTop: 3,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 20,
},
activeBadge: {
backgroundColor: '#D1FAE5',
},
inactiveBadge: {
backgroundColor: '#FEE2E2',
},
badgeText: {
fontSize: 11,
fontWeight: '700',
},
activeBadgeText: {
color: '#059669',
},
inactiveBadgeText: {
color: '#DC2626',
},
cardFooter: {
flexDirection: 'row',
gap: 10,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
height: 36,
borderRadius: 8,
borderWidth: 1,
},
callButton: {
borderColor: `${colors.icon}30`,
backgroundColor: `${colors.icon}10`,
},
callButtonText: {
color: colors.icon,
fontWeight: '600',
fontSize: 13,
marginLeft: 5,
},
emailButton: {
borderColor: colors.border,
backgroundColor: colors.surface,
},
emailButtonText: {
color: colors.textSecondary,
fontWeight: '600',
fontSize: 13,
marginLeft: 5,
},
});

View File

@ -0,0 +1,92 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '../../theme';
import { getStyles } from './customerItemCard.styles';
import { CustomerItemCardProps } from './customerItemCard.props';
import { handlePhonePress, handleEmailPress, getInitials } from '@utils';
export const CustomerItemCard: React.FC<CustomerItemCardProps> = ({
item,
onPress,
onDelete,
}) => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const displayName = item.company || item.fullname;
const initials = getInitials(displayName);
return (
<TouchableOpacity
activeOpacity={onPress ? 0.8 : 1}
onPress={onPress}
style={styles.customerCard}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderLeft}>
<View style={styles.avatarCircle}>
<Text style={styles.avatarInitial}>{initials}</Text>
</View>
<View style={styles.cardInfo}>
<Text style={styles.customerName} numberOfLines={1}>
{displayName}
</Text>
{item.fullname && item.company ? (
<Text style={styles.contactPerson} numberOfLines={1}>
{item.fullname}
</Text>
) : null}
{item.city || item.state ? (
<Text style={styles.location} numberOfLines={1}>
<Icon name="location-outline" size={11} color={colors.textMuted} />
{' '}{[item.city, item.state].filter(Boolean).join(', ')}
</Text>
) : null}
</View>
</View>
<View style={styles.cardHeaderRight}>
<View
style={[
styles.badge,
item.active === '1' ? styles.activeBadge : styles.inactiveBadge,
]}>
<Text
style={[
styles.badgeText,
item.active === '1' ? styles.activeBadgeText : styles.inactiveBadgeText,
]}>
{item.active === '1' ? 'Active' : 'Inactive'}
</Text>
</View>
{onDelete ? (
<TouchableOpacity
style={styles.deleteButton}
onPress={onDelete}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}>
<Icon name="trash-outline" size={18} color="#EF4444" />
</TouchableOpacity>
) : null}
</View>
</View>
<View style={styles.cardFooter}>
<TouchableOpacity
style={[styles.actionButton, styles.callButton]}
onPress={() => handlePhonePress(item.phonenumber)}>
<Icon name="call" size={15} color={colors.icon} />
<Text style={styles.callButtonText}>Call</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.emailButton]}
onPress={() => handleEmailPress(item.email)}>
<Icon name="mail" size={15} color={colors.textSecondary} />
<Text style={styles.emailButtonText}>Email</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
};

View File

@ -0,0 +1,2 @@
export * from './customerItemCard';
export * from './customerItemCard.props';

View File

@ -10,4 +10,8 @@ export * from './actionButton';
export * from './checkboxWithLabel'; export * from './checkboxWithLabel';
export * from './statCard'; export * from './statCard';
export * from './profileInfoRow'; export * from './profileInfoRow';
export * from './customerItemCard';
export * from './customerDetailHeader';

View File

@ -1,7 +1,10 @@
import { StyleProp, ViewStyle } from 'react-native';
export interface StatCardProps { export interface StatCardProps {
label: string; label: string;
count: number; count: number;
color: string; color: string;
isSelected?: boolean; isSelected?: boolean;
onPress?: () => void; onPress?: () => void;
style?: StyleProp<ViewStyle>;
} }

View File

@ -10,6 +10,7 @@ export const StatCard: React.FC<StatCardProps> = ({
color, color,
isSelected = false, isSelected = false,
onPress, onPress,
style,
}) => { }) => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
@ -25,6 +26,7 @@ export const StatCard: React.FC<StatCardProps> = ({
styles.card, styles.card,
{ borderColor }, { borderColor },
isSelected && styles.cardSelected, isSelected && styles.cardSelected,
style,
]} ]}
> >
{/* Top Status Pill Tag */} {/* Top Status Pill Tag */}

View File

@ -0,0 +1,484 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
KeyboardAvoidingView,
Platform,
Alert,
Switch,
ActivityIndicator,
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { getStyles } from './addCustomer.styles';
import { useTheme } from '@theme';
import { FormInput, FormPicker } from '@components';
import { RootState, useAppDispatch, useAppSelector } from '@store';
import { addCustomer, resetAddCustomerState } from './thunk';
import { getCustomers } from '../customers/thunk';
import { useNavigation } from '@react-navigation/native';
import { customerGroups } from '@mock-data';
export const AddCustomerScreen = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const dispatch = useAppDispatch();
const navigation = useNavigation();
const userData = useAppSelector((state: RootState) => state.auth.user_data);
const { loading, successMessage, error } = useAppSelector(
(state: RootState) => state.addCustomer,
);
const { items: countryItems } = useAppSelector(
(state: RootState) => state.countryList,
);
const { items: languageItems } = useAppSelector(
(state: RootState) => state.languageList,
);
const { currencies: currencyItems } = useAppSelector(
(state: RootState) => state.currencyList,
);
// Form inputs
const [company, setCompany] = useState('');
const [vat, setVat] = useState('');
const [phonenumber, setPhonenumber] = useState('');
const [website, setWebsite] = useState('');
const [selectedGroups, setSelectedGroups] = useState<number[]>([1]); // default group 1 selected
const [defaultLanguage, setDefaultLanguage] = useState('system_default');
const [defaultCurrency, setDefaultCurrency] = useState('1'); // default to USD
// Address
const [address, setAddress] = useState('');
const [city, setCity] = useState('');
const [state, setState] = useState('');
const [zip, setZip] = useState('');
const [country, setCountry] = useState('');
// Billing Address
const [billingStreet, setBillingStreet] = useState('');
const [billingCity, setBillingCity] = useState('');
const [billingState, setBillingState] = useState('');
const [billingZip, setBillingZip] = useState('');
const [billingCountry, setBillingCountry] = useState('');
// Shipping Address
const [shippingStreet, setShippingStreet] = useState('');
const [shippingCity, setShippingCity] = useState('');
const [shippingState, setShippingState] = useState('');
const [shippingZip, setShippingZip] = useState('');
const [shippingCountry, setShippingCountry] = useState('');
// Auto-fill switches
const [billingSameAsGeneral, setBillingSameAsGeneral] = useState(false);
const [shippingSameAsBilling, setShippingSameAsBilling] = useState(false);
const countryOptions = useMemo(
() => countryItems.map(c => ({ label: c.short_name, value: c.country_id })),
[countryItems],
);
const languageOptions = useMemo(
() => languageItems.map(lang => ({ label: lang.value, value: lang.id })),
[languageItems],
);
const currencyOptions = useMemo(
() => currencyItems.map(c => ({ label: `${c.name} (${c.symbol})`, value: c.id })),
[currencyItems],
);
// Handle auto-fill logic for Billing
useEffect(() => {
if (billingSameAsGeneral) {
setBillingStreet(address);
setBillingCity(city);
setBillingState(state);
setBillingZip(zip);
setBillingCountry(country);
}
}, [billingSameAsGeneral, address, city, state, zip, country]);
// Handle auto-fill logic for Shipping
useEffect(() => {
if (shippingSameAsBilling) {
setShippingStreet(billingStreet);
setShippingCity(billingCity);
setShippingState(billingState);
setShippingZip(billingZip);
setShippingCountry(billingCountry);
}
}, [shippingSameAsBilling, billingStreet, billingCity, billingState, billingZip, billingCountry]);
// Handle Success/Error from Redux State
useEffect(() => {
if (successMessage) {
Alert.alert('Success', successMessage, [
{
text: 'OK',
onPress: () => {
dispatch(resetAddCustomerState());
dispatch(getCustomers()); // Refresh customers list view
navigation.goBack();
},
},
]);
} else if (error) {
Alert.alert('Error', error);
dispatch(resetAddCustomerState());
}
}, [successMessage, error, dispatch, navigation]);
const toggleGroup = (groupId: number) => {
if (selectedGroups.includes(groupId)) {
setSelectedGroups(selectedGroups.filter(id => id !== groupId));
} else {
setSelectedGroups([...selectedGroups, groupId]);
}
};
const handleSubmit = () => {
if (!company.trim()) {
Alert.alert('Error', 'Company Name is required.');
return;
}
const staffId = userData?.staffid || '';
// Convert selectedGroups array to JSON format matching requirement: {"0":1,"1":2}
const groupsInObj: { [key: string]: number } = {};
selectedGroups.forEach((groupId, index) => {
groupsInObj[index.toString()] = groupId;
});
const payload = {
company: company.trim(),
vat: vat.trim(),
phonenumber: phonenumber.trim(),
website: website.trim(),
groups_in: JSON.stringify(groupsInObj),
default_language: defaultLanguage,
default_currency: defaultCurrency,
address: address.trim(),
city: city.trim(),
state: state.trim(),
zip: zip.trim(),
country: country,
billing_street: billingStreet.trim(),
billing_city: billingCity.trim(),
billing_state: billingState.trim(),
billing_zip: billingZip.trim(),
billing_country: billingCountry,
shipping_street: shippingStreet.trim(),
shipping_city: shippingCity.trim(),
shipping_state: shippingState.trim(),
shipping_zip: shippingZip.trim(),
shipping_country: shippingCountry,
addedfrom: staffId,
};
dispatch(addCustomer(payload));
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.container}
>
<ScrollView contentContainerStyle={styles.scrollContainer}>
<View style={styles.formCard}>
<Text style={styles.sectionHeader}>Customer Info</Text>
<FormInput
label="Company / Name *"
value={company}
onChangeText={setCompany}
placeholder="Enter company name"
autoCapitalize="words"
/>
<FormInput
label="VAT Number"
value={vat}
onChangeText={setVat}
placeholder="Enter VAT number"
keyboardType="numeric"
/>
<FormInput
label="Phone Number"
value={phonenumber}
onChangeText={setPhonenumber}
placeholder="Enter phone number"
keyboardType="phone-pad"
/>
<FormInput
label="Website"
value={website}
onChangeText={setWebsite}
placeholder="Enter website (e.g. www.google.com)"
keyboardType="url"
autoCapitalize="none"
/>
{/* Groups Selection */}
<Text style={{ fontSize: 13, color: colors.textSecondary, fontWeight: '600', marginBottom: 8 }}>
Groups
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
{customerGroups.map(group => {
const isSelected = selectedGroups.includes(group.id);
return (
<TouchableOpacity
key={group.id}
onPress={() => toggleGroup(group.id)}
style={{
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
borderColor: isSelected ? colors.icon : colors.border,
backgroundColor: isSelected ? colors.icon : colors.surface,
}}
activeOpacity={0.7}
>
<Text style={{ fontSize: 12, fontWeight: '600', color: isSelected ? '#FFFFFF' : colors.text }}>
{group.name}
</Text>
</TouchableOpacity>
);
})}
</View>
<View style={styles.row}>
<View style={styles.rowHalf}>
<FormPicker
label="Language"
value={defaultLanguage}
onValueChange={setDefaultLanguage}
options={languageOptions}
/>
</View>
<View style={styles.rowHalf}>
<FormPicker
label="Currency"
value={defaultCurrency}
onValueChange={setDefaultCurrency}
options={currencyOptions}
/>
</View>
</View>
</View>
{/* Primary Address */}
<View style={[styles.formCard, styles.cardSpacing]}>
<Text style={styles.sectionHeader}>Primary Address</Text>
<FormInput
label="Street Address"
value={address}
onChangeText={setAddress}
placeholder="Enter street address"
/>
<View style={styles.row}>
<View style={styles.rowHalf}>
<FormInput
label="City"
value={city}
onChangeText={setCity}
placeholder="City"
/>
</View>
<View style={styles.rowHalf}>
<FormInput
label="State"
value={state}
onChangeText={setState}
placeholder="State"
/>
</View>
</View>
<View style={styles.row}>
<View style={styles.rowHalf}>
<FormInput
label="Zip Code"
value={zip}
onChangeText={setZip}
placeholder="Zip"
keyboardType="numeric"
/>
</View>
<View style={styles.rowHalf}>
<FormPicker
label="Country"
value={country}
onValueChange={setCountry}
options={countryOptions}
placeholder="Select Country"
searchable
/>
</View>
</View>
</View>
{/* Billing Address */}
<View style={[styles.formCard, styles.cardSpacing]}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: colors.border, paddingBottom: 10, marginBottom: 20 }}>
<Text style={{ fontSize: 15, fontWeight: '700', color: colors.text }}>Billing Address</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Same as primary</Text>
<Switch
value={billingSameAsGeneral}
onValueChange={setBillingSameAsGeneral}
trackColor={{ false: colors.border, true: colors.icon }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{!billingSameAsGeneral && (
<>
<FormInput
label="Street Address"
value={billingStreet}
onChangeText={setBillingStreet}
placeholder="Enter billing address"
/>
<View style={styles.row}>
<View style={styles.rowHalf}>
<FormInput
label="City"
value={billingCity}
onChangeText={setBillingCity}
placeholder="City"
/>
</View>
<View style={styles.rowHalf}>
<FormInput
label="State"
value={billingState}
onChangeText={setBillingState}
placeholder="State"
/>
</View>
</View>
<View style={styles.row}>
<View style={styles.rowHalf}>
<FormInput
label="Zip Code"
value={billingZip}
onChangeText={setBillingZip}
placeholder="Zip"
keyboardType="numeric"
/>
</View>
<View style={styles.rowHalf}>
<FormPicker
label="Country"
value={billingCountry}
onValueChange={setBillingCountry}
options={countryOptions}
placeholder="Select Country"
searchable
/>
</View>
</View>
</>
)}
</View>
{/* Shipping Address */}
<View style={[styles.formCard, styles.cardSpacing]}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: colors.border, paddingBottom: 10, marginBottom: 20 }}>
<Text style={{ fontSize: 15, fontWeight: '700', color: colors.text }}>Shipping Address</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Same as billing</Text>
<Switch
value={shippingSameAsBilling}
onValueChange={setShippingSameAsBilling}
trackColor={{ false: colors.border, true: colors.icon }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{!shippingSameAsBilling && (
<>
<FormInput
label="Street Address"
value={shippingStreet}
onChangeText={setShippingStreet}
placeholder="Enter shipping address"
/>
<View style={styles.row}>
<View style={styles.rowHalf}>
<FormInput
label="City"
value={shippingCity}
onChangeText={setShippingCity}
placeholder="City"
/>
</View>
<View style={styles.rowHalf}>
<FormInput
label="State"
value={shippingState}
onChangeText={setShippingState}
placeholder="State"
/>
</View>
</View>
<View style={styles.row}>
<View style={styles.rowHalf}>
<FormInput
label="Zip Code"
value={shippingZip}
onChangeText={setShippingZip}
placeholder="Zip"
keyboardType="numeric"
/>
</View>
<View style={styles.rowHalf}>
<FormPicker
label="Country"
value={shippingCountry}
onValueChange={setShippingCountry}
options={countryOptions}
placeholder="Select Country"
searchable
/>
</View>
</View>
</>
)}
</View>
{/* Submit Button */}
<TouchableOpacity
style={[styles.submitButton, loading && { opacity: 0.8 }]}
onPress={handleSubmit}
disabled={loading}
activeOpacity={0.8}
>
{loading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Icon name="checkmark" size={20} color="#FFFFFF" style={styles.buttonIcon} />
<Text style={styles.submitButtonText}>Create Customer</Text>
</>
)}
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
};

View File

@ -0,0 +1,68 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
scrollContainer: {
padding: 16,
paddingBottom: 32,
},
formCard: {
backgroundColor: colors.card,
borderRadius: 16,
padding: 20,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
cardSpacing: {
marginTop: 16,
},
sectionHeader: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
marginBottom: 20,
borderBottomWidth: 1,
borderBottomColor: colors.border,
paddingBottom: 10,
},
// Two-column row layout
row: {
flexDirection: 'row',
gap: 12,
},
rowHalf: {
flex: 1,
},
// Submit
submitButton: {
backgroundColor: colors.icon,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 12,
height: 52,
marginTop: 24,
shadowColor: colors.icon,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
buttonIcon: {
marginRight: 8,
},
submitButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '700',
letterSpacing: 0.3,
},
});

View File

@ -0,0 +1,3 @@
export * from './addCustomer.screen';
export * from './thunk';
export * from './reducers';

View File

@ -0,0 +1,38 @@
import { createReducer } from '@reduxjs/toolkit';
import { addCustomer, resetAddCustomerState } from './thunk';
export interface AddCustomerState {
loading: boolean;
error: string | null;
successMessage: string | null;
}
const initialState: AddCustomerState = {
loading: false,
error: null,
successMessage: null,
};
export const addCustomerReducer = createReducer(initialState, builder => {
builder
.addCase(resetAddCustomerState, () => initialState)
.addCase(addCustomer.pending, acc => {
acc.loading = true;
acc.error = null;
acc.successMessage = null;
})
.addCase(addCustomer.fulfilled, (acc, action) => {
acc.loading = false;
acc.successMessage = 'Customer Added Successfully.';
acc.error = null;
})
.addCase(addCustomer.rejected, (acc, action) => {
acc.loading = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to add customer';
});
});
export default addCustomerReducer;

View File

@ -0,0 +1,16 @@
import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
import { addCustomerApi } from '@api';
import { AddCustomerPayload, AddCustomerResponse } from '@interfaces';
export const resetAddCustomerState = createAction('addCustomer/resetState');
export const addCustomer = createAsyncThunk<AddCustomerResponse, AddCustomerPayload>(
'addCustomer/addCustomer',
async (payload, { rejectWithValue }) => {
try {
return await addCustomerApi(payload);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to add customer');
}
},
);

View File

@ -0,0 +1,209 @@
import React, { useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { RouteProp, useRoute } from '@react-navigation/native';
import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getCustomerDetails } from './thunk';
import { getStyles } from './customerDetails.styles';
import { CustomersStackParamList } from '../../navigation/customersStack';
import { Loader, CustomerDetailHeader } from '@components';
import {
handleEmailPress,
handlePhonePress,
handleWebsitePress,
} from '@utils';
type CustomerDetailsRouteProp = RouteProp<
CustomersStackParamList,
'customerDetails'
>;
export const CustomerDetailsScreen = () => {
const route = useRoute<CustomerDetailsRouteProp>();
const dispatch = useAppDispatch();
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const userid = route.params?.userid || route.params?.customer?.userid;
const initialCustomer = route.params?.customer;
const { item: customerState, loading, error } = useAppSelector(
(state: RootState) => state.customerDetails,
);
useEffect(() => {
if (userid) {
dispatch(getCustomerDetails({ userid }));
}
}, [dispatch, userid]);
const customer = customerState || initialCustomer;
if (loading && !customer) {
return <Loader message="Loading customer details..." />;
}
if (error && !customer) {
return (
<View style={styles.centered}>
<Icon name="alert-circle-outline" size={40} color="#EF4444" />
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
if (!customer) {
return (
<View style={styles.centered}>
<Icon name="person-outline" size={40} color={colors.textMuted} />
<Text style={styles.infoValue}>No customer details found.</Text>
</View>
);
}
const billingAddress = [
customer.billing_street || customer.address,
customer.billing_city || customer.city,
customer.billing_state || customer.state,
customer.billing_zip || customer.zip,
customer.billing_country || customer.country,
]
.filter(Boolean)
.join(', ');
const shippingAddress = [
customer.shipping_street,
customer.shipping_city,
customer.shipping_state,
customer.shipping_zip,
customer.shipping_country,
]
.filter(Boolean)
.join(', ');
return (
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
{/* Top Header & Quick Actions */}
<CustomerDetailHeader
company={customer.company}
fullname={customer.fullname}
active={customer.active}
datecreated={customer.datecreated}
email={customer.email}
phonenumber={customer.phonenumber}
website={customer.website}
onEmailPress={() => handleEmailPress(customer.email)}
onPhonePress={() => handlePhonePress(customer.phonenumber)}
onWebsitePress={() => handleWebsitePress(customer.website)}
/>
{/* Contact Details */}
<Text style={styles.sectionTitle}>Contact Details</Text>
<View style={styles.infoCard}>
{customer.email ? (
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handleEmailPress(customer.email)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="mail-outline" size={16} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Email</Text>
<Text style={styles.infoValue}>{customer.email}</Text>
</View>
</TouchableOpacity>
<View style={styles.divider} />
</>
) : null}
{customer.phonenumber ? (
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handlePhonePress(customer.phonenumber)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="call-outline" size={16} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Phone</Text>
<Text style={styles.infoValue}>{customer.phonenumber}</Text>
</View>
</TouchableOpacity>
<View style={styles.divider} />
</>
) : null}
{customer.website ? (
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handleWebsitePress(customer.website)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="globe-outline" size={16} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Website</Text>
<Text style={styles.infoValue}>{customer.website}</Text>
</View>
</TouchableOpacity>
<View style={styles.divider} />
</>
) : null}
{customer.vat ? (
<View style={styles.infoRow}>
<View style={styles.infoIconWrap}>
<Icon name="receipt-outline" size={16} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>VAT Number</Text>
<Text style={styles.infoValue}>{customer.vat}</Text>
</View>
</View>
) : null}
</View>
{/* Address Information */}
<Text style={styles.sectionTitle}>Address Information</Text>
<View style={styles.infoCard}>
{billingAddress ? (
<>
<View style={styles.infoRow}>
<View style={styles.infoIconWrap}>
<Icon name="location-outline" size={16} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Billing Address</Text>
<Text style={styles.infoValue}>{billingAddress}</Text>
</View>
</View>
{shippingAddress ? <View style={styles.divider} /> : null}
</>
) : null}
{shippingAddress ? (
<View style={styles.infoRow}>
<View style={styles.infoIconWrap}>
<Icon name="navigate-outline" size={16} color={colors.icon} />
</View>
<View style={styles.infoMeta}>
<Text style={styles.infoLabel}>Shipping Address</Text>
<Text style={styles.infoValue}>{shippingAddress}</Text>
</View>
</View>
) : null}
{!billingAddress && !shippingAddress ? (
<Text style={styles.infoValue}>No address information provided.</Text>
) : null}
</View>
</ScrollView>
);
};

View File

@ -0,0 +1,192 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
scrollContent: {
padding: 16,
paddingBottom: 40,
},
// Header card
headerCard: {
backgroundColor: colors.card,
borderRadius: 16,
padding: 20,
alignItems: 'center',
marginBottom: 16,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
avatarCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.icon,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 12,
},
avatarInitial: {
color: '#FFFFFF',
fontSize: 26,
fontWeight: '700',
},
companyName: {
fontSize: 20,
fontWeight: '800',
color: colors.text,
textAlign: 'center',
},
fullName: {
fontSize: 14,
color: colors.textSecondary,
marginTop: 4,
textAlign: 'center',
},
badgeRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
gap: 8,
},
badge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
},
activeBadge: {
backgroundColor: '#D1FAE5',
},
inactiveBadge: {
backgroundColor: '#FEE2E2',
},
badgeText: {
fontSize: 12,
fontWeight: '700',
},
activeBadgeText: {
color: '#059669',
},
inactiveBadgeText: {
color: '#DC2626',
},
dateText: {
fontSize: 12,
color: colors.textMuted,
},
// Quick Actions
actionRow: {
flexDirection: 'row',
gap: 10,
marginBottom: 16,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
height: 42,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
gap: 6,
borderWidth: 1,
},
primaryBtn: {
backgroundColor: colors.icon,
borderColor: colors.icon,
},
primaryBtnText: {
color: '#FFFFFF',
fontWeight: '600',
fontSize: 13,
},
secondaryBtn: {
backgroundColor: colors.card,
borderColor: colors.border,
},
secondaryBtnText: {
color: colors.text,
fontWeight: '600',
fontSize: 13,
},
// Section title
sectionTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
marginTop: 8,
marginBottom: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// Card & Info rows
infoCard: {
backgroundColor: colors.card,
borderRadius: 14,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 2,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
},
infoIconWrap: {
width: 34,
height: 34,
borderRadius: 8,
backgroundColor: colors.surface,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
infoMeta: {
flex: 1,
},
infoLabel: {
fontSize: 12,
color: colors.textMuted,
fontWeight: '500',
},
infoValue: {
fontSize: 14,
color: colors.text,
fontWeight: '600',
marginTop: 2,
},
divider: {
height: 1,
backgroundColor: colors.border,
marginVertical: 4,
},
// Error & empty states
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
errorText: {
color: '#EF4444',
fontSize: 14,
textAlign: 'center',
marginTop: 10,
},
});

View File

@ -0,0 +1,3 @@
export * from './customerDetails.screen';
export * from './thunk';
export * from './reducers';

View File

@ -0,0 +1,37 @@
import { createReducer } from '@reduxjs/toolkit';
import { getCustomerDetails } from './thunk';
import { CustomerItem } from '@interfaces';
export interface CustomerDetailsState {
item: CustomerItem | null;
loading: boolean;
error: string | null;
}
const initialState: CustomerDetailsState = {
item: null,
loading: false,
error: null,
};
export const customerDetailsReducer = createReducer(initialState, builder => {
builder
.addCase(getCustomerDetails.pending, acc => {
acc.loading = true;
acc.error = null;
})
.addCase(getCustomerDetails.fulfilled, (acc, action) => {
acc.loading = false;
acc.item = action.payload;
acc.error = null;
})
.addCase(getCustomerDetails.rejected, (acc, action) => {
acc.loading = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to load customer details';
});
});
export default customerDetailsReducer;

View File

@ -0,0 +1,14 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getCustomerDetailsApi } from '@api';
import { CustomerItem } from '@interfaces';
export const getCustomerDetails = createAsyncThunk<
CustomerItem,
{ userid: string }
>('customerDetails/getCustomerDetails', async (payload, { rejectWithValue }) => {
try {
return await getCustomerDetailsApi(payload.userid);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to load customer details');
}
});

View File

@ -1,64 +1,174 @@
import React from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Text, View, FlatList, TouchableOpacity, Linking } from 'react-native'; import { Text, View, FlatList, Alert } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { getStyles } from './customers.styles'; import { getStyles } from './customers.styles';
import { MOCK_CUSTOMERS } from '../../mock-data/customers';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getCustomers, deleteCustomer, resetCustomersState } from './thunk';
import { CustomerItem } from '@interfaces';
import { SearchInput, Loader, StatCard, CustomerItemCard } from '@components';
import { CustomersStackParamList } from '../../navigation/customersStack';
import { route } from '@utils';
type CustomersScreenNavigationProp = NativeStackNavigationProp<
CustomersStackParamList,
'customersList'
>;
export const CustomersScreen = () => { export const CustomersScreen = () => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const dispatch = useAppDispatch();
const navigation = useNavigation<CustomersScreenNavigationProp>();
const handleCall = (phone: string) => {
Linking.openURL(`tel:${phone}`).catch(() => {}); const [searchTerm, setSearchTerm] = useState('');
const [selectedFilter, setSelectedFilter] = useState<'all' | 'active' | 'inactive'>('all');
const { items, loading, error, successMessage, deleteError } = useAppSelector(
(state: RootState) => state.customers,
);
useEffect(() => {
dispatch(getCustomers());
}, [dispatch]);
useEffect(() => {
if (successMessage) {
Alert.alert('Success', successMessage);
dispatch(resetCustomersState());
} else if (deleteError) {
Alert.alert('Error', deleteError);
dispatch(resetCustomersState());
}
}, [successMessage, deleteError, dispatch]);
// Stats
const totalCount = items.length;
const activeCount = items.filter(c => c.active === '1').length;
const inactiveCount = items.filter(c => c.active !== '1').length;
const statCards = [
{ label: 'Total', count: totalCount, color: colors.icon, key: 'all' },
{ label: 'Active', count: activeCount, color: '#10B981', key: 'active' },
{ label: 'Inactive', count: inactiveCount, color: '#EF4444', key: 'inactive' },
] as const;
// Filtered list
const filteredCustomers = useMemo(() => {
return items.filter(c => {
const matchesSearch = searchTerm
? c.company?.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.fullname?.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.email?.toLowerCase().includes(searchTerm.toLowerCase())
: true;
const matchesFilter =
selectedFilter === 'all'
? true
: selectedFilter === 'active'
? c.active === '1'
: c.active !== '1';
return matchesSearch && matchesFilter;
});
}, [items, searchTerm, selectedFilter]);
const handleCustomerPress = (customer: CustomerItem) => {
navigation.navigate(route.customerDetails, {
userid: customer.userid,
customer,
});
}; };
const handleEmail = (email: string) => { const handleDeleteCustomer = (customer: CustomerItem) => {
Linking.openURL(`mailto:${email}`).catch(() => {}); Alert.alert(
'Delete Customer',
`Are you sure you want to delete "${customer.company || customer.fullname || 'this customer'}"? This action cannot be undone.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
dispatch(deleteCustomer({ userid: customer.userid }));
},
},
],
);
}; };
const renderCustomer = ({ item }: { item: CustomerItem }) => (
<CustomerItemCard
item={item}
onPress={() => handleCustomerPress(item)}
onDelete={() => handleDeleteCustomer(item)}
/>
);
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Search Header */}
<View style={styles.header}>
<SearchInput
value={searchTerm}
onChangeText={setSearchTerm}
placeholder="Search customers..."
style={styles.searchInput}
/>
</View>
{/* Stat Cards - Grid aligned across the screen width */}
<View style={styles.countsContainer}>
{statCards.map(s => (
<StatCard
key={s.key}
label={s.label}
count={s.count}
color={s.color}
isSelected={selectedFilter === s.key}
style={styles.statCardFlex}
onPress={() =>
setSelectedFilter(prev => (prev === s.key ? 'all' : s.key))
}
/>
))}
</View>
{/* Customer List */}
{loading ? (
<Loader message="Loading customers..." />
) : error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : (
<FlatList <FlatList
data={MOCK_CUSTOMERS} data={filteredCustomers}
keyExtractor={item => item.id} keyExtractor={item => item.userid}
style={{ flex: 1 }}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
renderItem={({ item }) => ( renderItem={renderCustomer}
<View style={styles.customerCard}> ListEmptyComponent={() => (
<View style={styles.cardHeader}> <View style={styles.emptyContainer}>
<View> <View style={styles.emptyIconWrap}>
<Text style={styles.customerName}>{item.name}</Text> <Icon name="business-outline" size={32} color={colors.icon} />
<Text style={styles.contactPerson}> </View>
Contact: {item.contactPerson} <Text style={styles.emptyTitle}>No Customers Found</Text>
<Text style={styles.emptyText}>
{searchTerm
? `No customers match "${searchTerm}". Try a different search.`
: 'No customers yet. They will appear here.'}
</Text> </Text>
</View> </View>
<View style={styles.badge}>
<Text style={styles.badgeText}>
{item.activeProjects} active projects
</Text>
</View>
</View>
<View style={styles.cardFooter}>
<TouchableOpacity
style={[styles.actionButton, styles.callButton]}
onPress={() => handleCall(item.phone)}
>
<Icon name="call" size={16} color={colors.icon} />
<Text style={styles.callButtonText}>Call</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.emailButton]}
onPress={() => handleEmail(item.email)}
>
<Icon name="mail" size={16} color={colors.textSecondary} />
<Text style={styles.emailButtonText}>Email</Text>
</TouchableOpacity>
</View>
</View>
)} )}
/> />
)}
</View> </View>
); );
}; };

View File

@ -1,86 +1,85 @@
import { StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme'; import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) => StyleSheet.create({ export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background, backgroundColor: colors.background,
}, },
listContent: { // Header
padding: 16, header: {
}, paddingHorizontal: 16,
customerCard: {
backgroundColor: colors.card,
borderRadius: 14,
padding: 16,
marginBottom: 16,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
borderBottomWidth: 1,
borderBottomColor: colors.border,
paddingBottom: 14,
},
customerName: {
fontSize: 16,
fontWeight: '700',
color: colors.text,
},
contactPerson: {
fontSize: 13,
color: colors.textSecondary,
marginTop: 4,
},
badge: {
backgroundColor: colors.border,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
badgeText: {
fontSize: 11,
fontWeight: '600',
color: colors.icon,
},
cardFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingTop: 12, paddingTop: 12,
paddingBottom: 10,
}, },
actionButton: { searchInput: {
width: '100%',
},
// Stat Cards container aligned evenly across screen width
countsContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 12,
gap: 10,
},
statCardFlex: {
flex: 1,
minWidth: 0,
},
// List
listContent: {
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 40,
},
// States
errorContainer: {
flex: 1,
justifyContent: 'center', justifyContent: 'center',
flex: 0.48, alignItems: 'center',
height: 38, padding: 32,
borderRadius: 8,
borderWidth: 1,
}, },
callButton: { errorText: {
borderColor: colors.border, color: '#EF4444',
backgroundColor: colors.border, fontSize: 14,
textAlign: 'center',
}, },
callButtonText: { emptyContainer: {
color: colors.icon, alignItems: 'center',
fontWeight: '600', paddingTop: 80,
fontSize: 13, padding: 32,
marginLeft: 6, gap: 10,
}, },
emailButton: { emptyIconWrap: {
borderColor: colors.border, width: 76,
height: 76,
borderRadius: 38,
backgroundColor: colors.surface, backgroundColor: colors.surface,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 10,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.10,
shadowRadius: 10,
elevation: 3,
}, },
emailButtonText: { emptyTitle: {
color: colors.text,
fontSize: 17,
fontWeight: '700',
textAlign: 'center',
letterSpacing: 0.1,
},
emptyText: {
color: colors.textSecondary, color: colors.textSecondary,
fontWeight: '600',
fontSize: 13, fontSize: 13,
marginLeft: 6, textAlign: 'center',
lineHeight: 20,
maxWidth: 280,
}, },
}); });

View File

@ -0,0 +1,69 @@
import { createReducer } from '@reduxjs/toolkit';
import { CustomerItem } from '@interfaces';
import { getCustomers, deleteCustomer, resetCustomersState } from './thunk';
export interface CustomersState {
items: CustomerItem[];
loading: boolean;
error: string | null;
deleteLoading: boolean;
deleteError: string | null;
successMessage: string | null;
}
const initialState: CustomersState = {
items: [],
loading: false,
error: null,
deleteLoading: false,
deleteError: null,
successMessage: null,
};
export const customersReducer = createReducer(initialState, builder => {
builder
.addCase(getCustomers.pending, acc => {
acc.loading = true;
acc.error = null;
})
.addCase(getCustomers.fulfilled, (acc, action) => {
acc.loading = false;
acc.items = action.payload;
acc.error = null;
})
.addCase(getCustomers.rejected, (acc, action) => {
acc.loading = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to load customers';
})
.addCase(deleteCustomer.pending, acc => {
acc.deleteLoading = true;
acc.deleteError = null;
acc.successMessage = null;
})
.addCase(deleteCustomer.fulfilled, (acc, action) => {
acc.deleteLoading = false;
acc.deleteError = null;
acc.successMessage = action.payload.message || 'Customer Delete Successful.';
const deletedUserid = action.meta.arg.userid;
acc.items = acc.items.filter(c => c.userid !== deletedUserid);
})
.addCase(deleteCustomer.rejected, (acc, action) => {
acc.deleteLoading = false;
acc.deleteError =
(action.payload as string) ??
action.error.message ??
'Failed to delete customer';
})
.addCase(resetCustomersState, acc => {
acc.successMessage = null;
acc.deleteError = null;
});
});
export default customersReducer;

View File

@ -0,0 +1,29 @@
import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
import { getCustomersApi, deleteCustomerApi } from '@api';
import { CustomerItem, DeleteCustomerResponse } from '@interfaces';
export const resetCustomersState = createAction('customers/resetState');
export const getCustomers = createAsyncThunk<CustomerItem[]>(
'customers/getCustomers',
async (_, { rejectWithValue }) => {
try {
return await getCustomersApi();
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to load customers');
}
},
);
export const deleteCustomer = createAsyncThunk<
DeleteCustomerResponse,
{ userid: string }
>('customers/deleteCustomer', async (payload, { rejectWithValue }) => {
try {
return await deleteCustomerApi(payload.userid);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to delete customer');
}
});

View File

@ -14,3 +14,7 @@ export * from './projects';
export * from './proposals'; export * from './proposals';
export * from './tasks'; export * from './tasks';
export * from './tickets'; export * from './tickets';
export * from './customerDetails';
export * from './addCustomer';

View File

@ -21,7 +21,7 @@ import { route } from '@utils';
type LeadsScreenNavigationProp = NativeStackNavigationProp< type LeadsScreenNavigationProp = NativeStackNavigationProp<
LeadsStackParamList, LeadsStackParamList,
'leads' 'leadsList'
>; >;
export const LeadsScreen = () => { export const LeadsScreen = () => {

View File

@ -14,7 +14,7 @@ import { useNavigation } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { getStyles } from './login.styles'; import { getStyles } from './login.styles';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState, login } from '@store'; import { useAppDispatch, useAppSelector, RootState, login, sendFcmToken } from '@store';
import { LoginRequest } from '@interfaces'; import { LoginRequest } from '@interfaces';
import { NotificationService } from '@services'; import { NotificationService } from '@services';
import { backGroundImage } from '@utils'; import { backGroundImage } from '@utils';
@ -34,6 +34,7 @@ export const LoginScreen = () => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [focusedField, setFocusedField] = useState<string | null>(null); const [focusedField, setFocusedField] = useState<string | null>(null);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const [deviceToken, setDeviceToken] = useState<string | null>(null);
const { loginLoading, loginError, loginSuccess, token, user_data } = const { loginLoading, loginError, loginSuccess, token, user_data } =
useAppSelector((state: RootState) => state.auth); useAppSelector((state: RootState) => state.auth);
@ -58,13 +59,14 @@ export const LoginScreen = () => {
return; return;
} }
const deviceToken = await NotificationService.getFCMToken(); const fcmToken = await NotificationService.getFCMToken();
console.log('deviceToken-', deviceToken) setDeviceToken(fcmToken);
console.log('deviceToken-', fcmToken)
const payload: LoginRequest = { const payload: LoginRequest = {
email, email,
password, password,
device_token: deviceToken ?? '', device_token: fcmToken ?? '',
}; };
console.log('payload-', payload) console.log('payload-', payload)
console.log('tenancy-', config.BASE_URL) console.log('tenancy-', config.BASE_URL)
@ -113,6 +115,10 @@ export const LoginScreen = () => {
useEffect(() => { useEffect(() => {
if (loginSuccess && token && user_data) { if (loginSuccess && token && user_data) {
// Send FCM token to server immediately after login
if (deviceToken && user_data.staffid) {
dispatch(sendFcmToken({ id: user_data.staffid, fcm_token: deviceToken }));
}
navigation.reset({ navigation.reset({
index: 0, index: 0,
routes: [{ name: 'DrawerStack' }], routes: [{ name: 'DrawerStack' }],

View File

@ -0,0 +1,82 @@
export interface CustomerCustomField {
label: string;
value: string;
}
export interface DeleteCustomerResponse {
status: boolean;
message: string;
}
export interface CustomerItem {
userid: string;
company: string;
vat: string | null;
tax_label_id: string | null;
phonenumber: string;
country: string;
city: string;
zip: string;
state: string;
address: string;
website: string;
datecreated: string;
active: string;
leadid: string;
billing_street: string;
billing_city: string;
billing_state: string;
billing_zip: string;
billing_country: string;
shipping_street: string | null;
shipping_city: string | null;
shipping_state: string | null;
shipping_zip: string | null;
shipping_country: string;
longitude: string | null;
latitude: string | null;
default_language: string | null;
default_currency: string;
show_primary_contact: string;
stripe_id: string | null;
registration_confirmed: string;
addedfrom: string;
email: string;
fullname: string;
id: string | null;
groupid: string | null;
customer_id: string | null;
customfields?: CustomerCustomField[];
}
export type CustomerDetailsItem = CustomerItem;
export interface AddCustomerPayload {
company: string;
vat?: string;
phonenumber?: string;
website?: string;
groups_in?: string;
default_language?: string;
default_currency?: string;
address?: string;
city?: string;
state?: string;
zip?: string;
country?: string;
billing_street?: string;
billing_city?: string;
billing_state?: string;
billing_zip?: string;
billing_country?: string;
shipping_street?: string;
shipping_city?: string;
shipping_state?: string;
shipping_zip?: string;
shipping_country?: string;
addedfrom?: string;
}
export type AddCustomerResponse = string | number;

View File

@ -0,0 +1,9 @@
export interface FcmTokenPayload {
id: string;
fcm_token: string;
}
export interface FcmTokenResponse {
status: boolean;
message: string;
}

View File

@ -3,3 +3,6 @@ export * from './auth';
export * from './leads'; export * from './leads';
export * from './leadDetails'; export * from './leadDetails';
export * from './list'; export * from './list';
export * from './customers';
export * from './fcmToken';

View File

@ -23,3 +23,24 @@ export interface CountryListItem {
cctld: string; cctld: string;
} }
export interface LanguageListItem {
id: string;
value: string;
}
export interface CurrencyItem {
id: string;
symbol: string;
name: string;
decimal_separator: string;
thousand_separator: string;
placement: string;
isdefault: string;
}
export interface CurrencyListResponse {
base_currency: CurrencyItem;
currencies: CurrencyItem[];
}

View File

@ -1,7 +1,8 @@
export const MOCK_CUSTOMERS = [ export const customerGroups = [
{ id: '1', name: 'Acme Corporation', contactPerson: 'Alice Vance', phone: '+1 (555) 019-2834', email: 'billing@acme.com', activeProjects: 3 }, { id: 1, name: 'App Development' },
{ id: '2', name: 'Stark Industries', contactPerson: 'Tony Stark', phone: '+1 (555) 012-9842', email: 'pepper@stark.com', activeProjects: 5 }, { id: 2, name: 'Basic' },
{ id: '3', name: 'Wayne Enterprises', contactPerson: 'Bruce Wayne', phone: '+1 (555) 014-7291', email: 'lucius@wayne.co', activeProjects: 1 }, { id: 3, name: 'PHP' },
{ id: '4', name: 'Oscorp Tech', contactPerson: 'Norman Osborn', phone: '+1 (555) 017-3849', email: 'norman@oscorp.org', activeProjects: 2 }, { id: 4, name: 'Prospect' },
{ id: '5', name: 'Umbrella Corp', contactPerson: 'Albert Wesker', phone: '+1 (555) 015-8492', email: 'wesker@umbrella.com', activeProjects: 0 }, { id: 5, name: 'QA' },
{ id: 6, name: 'Reseller' },
]; ];

View File

@ -0,0 +1,76 @@
import React from 'react';
import { TouchableOpacity } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import Icon from 'react-native-vector-icons/Ionicons';
import { CustomersScreen, CustomerDetailsScreen, AddCustomerScreen } from '@features';
import { route, RouteParams } from '@utils';
import { useTheme } from '@theme';
export type CustomersStackParamList = Pick<
RouteParams,
'customersList' | 'customerDetails' | 'addCustomer'
>;
const Stack = createNativeStackNavigator<CustomersStackParamList>();
export const CustomersStack = () => {
const { theme: colors } = useTheme();
return (
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.header,
},
headerTitleStyle: {
fontSize: 17,
fontWeight: '700',
color: colors.text,
},
headerTitleAlign: 'center',
headerTintColor: colors.text,
headerShadowVisible: false,
}}>
<Stack.Screen
name={route.customersList}
component={CustomersScreen}
options={({ navigation }) => ({
headerTitle: 'Customers',
headerRight: () => (
<TouchableOpacity
onPress={() => navigation.navigate(route.addCustomer)}
style={{
marginRight: 8,
backgroundColor: colors.icon,
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
shadowColor: colors.icon,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 3,
}}
activeOpacity={0.7}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="add" size={20} color="#FFFFFF" />
</TouchableOpacity>
),
})}
/>
<Stack.Screen
name={route.customerDetails}
component={CustomerDetailsScreen}
options={{ headerTitle: 'Customer Details' }}
/>
<Stack.Screen
name={route.addCustomer}
component={AddCustomerScreen}
options={{ headerTitle: 'Add Customer' }}
/>
</Stack.Navigator>
);
};

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { createDrawerNavigator } from '@react-navigation/drawer'; import { createDrawerNavigator } from '@react-navigation/drawer';
import { TabStack } from './tabStack'; import { TabStack } from './tabStack';
import { CustomersStack } from './customersStack';
import { CustomDrawerContent } from './customDrawerContent'; import { CustomDrawerContent } from './customDrawerContent';
import { import {
CustomersScreen,
ProposalsScreen, ProposalsScreen,
EstimatesScreen, EstimatesScreen,
InvoicesScreen, InvoicesScreen,
@ -53,7 +53,11 @@ export const DrawerStack = () => {
component={TabStack} component={TabStack}
options={{ headerShown: false }} options={{ headerShown: false }}
/> />
<Drawer.Screen name={route.customers} component={CustomersScreen} /> <Drawer.Screen
name={route.customers}
component={CustomersStack}
options={{ headerShown: false }}
/>
<Drawer.Screen name={route.proposals} component={ProposalsScreen} /> <Drawer.Screen name={route.proposals} component={ProposalsScreen} />
<Drawer.Screen name={route.estimates} component={EstimatesScreen} /> <Drawer.Screen name={route.estimates} component={EstimatesScreen} />
<Drawer.Screen name={route.invoices} component={InvoicesScreen} /> <Drawer.Screen name={route.invoices} component={InvoicesScreen} />

View File

@ -7,10 +7,10 @@ import { route, RouteParams } from '@utils';
import { import {
DashboardScreen, DashboardScreen,
AddLeadScreen, AddLeadScreen,
CustomersScreen,
ProfileScreen, ProfileScreen,
} from '@features'; } from '@features';
import { LeadsStack } from './leadsStack'; import { LeadsStack } from './leadsStack';
import { CustomersStack } from './customersStack';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { getStyles } from './tabStack.styles'; import { getStyles } from './tabStack.styles';
@ -103,10 +103,10 @@ export const TabStack = () => {
/> />
<Tab.Screen <Tab.Screen
name={route.customers} name={route.customers}
component={CustomersScreen} component={CustomersStack}
options={{ options={{
tabBarLabel: 'Customers', tabBarLabel: 'Customers',
headerTitle: 'Customers', headerShown: false,
}} }}
/> />
<Tab.Screen <Tab.Screen

View File

@ -102,7 +102,7 @@ export async function displayNotification(
android: { android: {
channelId: CHANNEL_ID, channelId: CHANNEL_ID,
importance: AndroidImportance.HIGH, importance: AndroidImportance.HIGH,
smallIcon: 'ic_notification', // must exist in android/app/src/main/res/drawable smallIcon: 'ic_launcher', // use ic_notification once drawable is added to android/app/src/main/res/drawable
pressAction: { id: 'default' }, pressAction: { id: 'default' },
color: '#4F46E5', color: '#4F46E5',
}, },

View File

@ -0,0 +1,2 @@
export * from './thunk';
export * from './reducers';

View File

@ -0,0 +1,40 @@
import { createReducer } from '@reduxjs/toolkit';
import { getCurrencyList } from './thunk';
import { CurrencyItem } from '@interfaces';
export interface CurrencyListState {
currencies: CurrencyItem[];
baseCurrency: CurrencyItem | null;
loading: boolean;
error: string | null;
}
const initialState: CurrencyListState = {
currencies: [],
baseCurrency: null,
loading: false,
error: null,
};
export const currencyListReducer = createReducer(initialState, builder => {
builder
.addCase(getCurrencyList.pending, acc => {
acc.loading = true;
acc.error = null;
})
.addCase(getCurrencyList.fulfilled, (acc, action) => {
acc.loading = false;
acc.currencies = action.payload.currencies;
acc.baseCurrency = action.payload.base_currency;
acc.error = null;
})
.addCase(getCurrencyList.rejected, (acc, action) => {
acc.loading = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to fetch currency list';
});
});
export default currencyListReducer;

View File

@ -0,0 +1,14 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getCurrencyListApi } from '@api';
import { CurrencyListResponse } from '@interfaces';
export const getCurrencyList = createAsyncThunk<CurrencyListResponse>(
'currencylist/getCurrencyList',
async (_, { rejectWithValue }) => {
try {
return await getCurrencyListApi();
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch currency list');
}
},
);

View File

@ -0,0 +1,2 @@
export * from './thunk';
export * from './reducers';

View File

@ -0,0 +1,38 @@
import { createReducer } from '@reduxjs/toolkit';
import { sendFcmToken } from './thunk';
export interface FcmTokenState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: FcmTokenState = {
loading: false,
success: false,
error: null,
};
export const fcmTokenReducer = createReducer(initialState, builder => {
builder
.addCase(sendFcmToken.pending, acc => {
acc.loading = true;
acc.success = false;
acc.error = null;
})
.addCase(sendFcmToken.fulfilled, acc => {
acc.loading = false;
acc.success = true;
acc.error = null;
})
.addCase(sendFcmToken.rejected, (acc, action) => {
acc.loading = false;
acc.success = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to send FCM token';
});
});
export default fcmTokenReducer;

View File

@ -0,0 +1,14 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { sendFcmTokenApi } from '@api';
import { FcmTokenPayload, FcmTokenResponse } from '@interfaces';
export const sendFcmToken = createAsyncThunk<FcmTokenResponse, FcmTokenPayload>(
'fcmToken/sendFcmToken',
async (payload, { rejectWithValue }) => {
try {
return await sendFcmTokenApi(payload);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to send FCM token');
}
},
);

View File

@ -2,3 +2,8 @@ export * from './auth';
export * from './statuslist'; export * from './statuslist';
export * from './sourcelist'; export * from './sourcelist';
export * from './countrylist'; export * from './countrylist';
export * from './languagelist';
export * from './currencylist';
export * from './fcmToken';

View File

@ -0,0 +1,2 @@
export * from './thunk';
export * from './reducers';

View File

@ -0,0 +1,37 @@
import { createReducer } from '@reduxjs/toolkit';
import { getLanguageList } from './thunk';
import { LanguageListItem } from '@interfaces';
export interface LanguageListState {
items: LanguageListItem[];
loading: boolean;
error: string | null;
}
const initialState: LanguageListState = {
items: [],
loading: false,
error: null,
};
export const languageListReducer = createReducer(initialState, builder => {
builder
.addCase(getLanguageList.pending, acc => {
acc.loading = true;
acc.error = null;
})
.addCase(getLanguageList.fulfilled, (acc, action) => {
acc.loading = false;
acc.items = action.payload;
acc.error = null;
})
.addCase(getLanguageList.rejected, (acc, action) => {
acc.loading = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to fetch language list';
});
});
export default languageListReducer;

View File

@ -0,0 +1,14 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getLanguageListApi } from '@api';
import { LanguageListItem } from '@interfaces';
export const getLanguageList = createAsyncThunk<LanguageListItem[]>(
'languagelist/getLanguageList',
async (_, { rejectWithValue }) => {
try {
return await getLanguageListApi();
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch language list');
}
},
);

View File

@ -3,18 +3,30 @@ import authReducer from './commonReducers/auth/reducers';
import statusListReducer from './commonReducers/statuslist/reducers'; import statusListReducer from './commonReducers/statuslist/reducers';
import sourceListReducer from './commonReducers/sourcelist/reducers'; import sourceListReducer from './commonReducers/sourcelist/reducers';
import countryListReducer from './commonReducers/countrylist/reducers'; import countryListReducer from './commonReducers/countrylist/reducers';
import languageListReducer from './commonReducers/languagelist/reducers';
import currencyListReducer from './commonReducers/currencylist/reducers';
import fcmTokenReducer from './commonReducers/fcmToken/reducers';
import leadsReducer from '../features/leads/reducers'; import leadsReducer from '../features/leads/reducers';
import leadDetailsReducer from '../features/leadDetails/reducers'; import leadDetailsReducer from '../features/leadDetails/reducers';
import addLeadReducer from '../features/addLead/reducers'; import addLeadReducer from '../features/addLead/reducers';
import customersReducer from '../features/customers/reducers';
import customerDetailsReducer from '../features/customerDetails/reducers';
import addCustomerReducer from '../features/addCustomer/reducers';
const appReducer = combineReducers({ const appReducer = combineReducers({
auth: authReducer, auth: authReducer,
statusList: statusListReducer, statusList: statusListReducer,
sourceList: sourceListReducer, sourceList: sourceListReducer,
countryList: countryListReducer, countryList: countryListReducer,
languageList: languageListReducer,
currencyList: currencyListReducer,
fcmToken: fcmTokenReducer,
leads: leadsReducer, leads: leadsReducer,
leadDetails: leadDetailsReducer, leadDetails: leadDetailsReducer,
addLead: addLeadReducer, addLead: addLeadReducer,
customers: customersReducer,
customerDetails: customerDetailsReducer,
addCustomer: addCustomerReducer,
}); });
const rootReducer = (state: any, action: any) => { const rootReducer = (state: any, action: any) => {

View File

@ -1,3 +1,5 @@
import { CustomerItem } from '@interfaces';
// ─── Route Name Constants ──────────────────────────────────────────────────── // ─── Route Name Constants ────────────────────────────────────────────────────
export const route = { export const route = {
// Auth // Auth
@ -21,12 +23,16 @@ export const route = {
leadProposals: 'leadProposals', leadProposals: 'leadProposals',
leadTasks: 'leadTasks', leadTasks: 'leadTasks',
leadNotes: 'leadNotes', leadNotes: 'leadNotes',
customersList: 'customersList',
customerDetails: 'customerDetails',
addCustomer: 'addCustomer',
profile: 'profile', profile: 'profile',
} as const; } as const;
// ─── Route Param Types ──────────────────────────────────────────────────────── // ─── Route Param Types ────────────────────────────────────────────────────────
// Use `undefined` for screens that take no params. // Use `undefined` for screens that take no params.
// Add typed params here when a screen needs them, e.g. addLead: { leadId: string } // Add typed params here when a screen needs them, e.g. addLead: { leadId: string }
// ─── Route Param Types ────────────────────────────────────────────────────────
export type RouteParams = { export type RouteParams = {
login: undefined; login: undefined;
dashboard: undefined; dashboard: undefined;
@ -44,6 +50,10 @@ export type RouteParams = {
leadProposals: { leadId: string }; leadProposals: { leadId: string };
leadTasks: { leadId: string }; leadTasks: { leadId: string };
leadNotes: { leadId: string }; leadNotes: { leadId: string };
customersList: undefined;
customerDetails: { userid: string; customer?: CustomerItem };
addCustomer: undefined;
profile: undefined; profile: undefined;
}; };