feat(app): add customers and fix notificaton
This commit is contained in:
parent
0535845362
commit
db1dc98300
@ -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
52
app/api/customersApi.ts
Normal 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
12
app/api/fcmTokenApi.ts
Normal 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);
|
||||||
|
};
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
91
app/components/customerDetailHeader/customerDetailHeader.tsx
Normal file
91
app/components/customerDetailHeader/customerDetailHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
app/components/customerDetailHeader/index.ts
Normal file
2
app/components/customerDetailHeader/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './customerDetailHeader';
|
||||||
|
export * from './customerDetailHeader.props';
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { CustomerItem } from '@interfaces';
|
||||||
|
|
||||||
|
export interface CustomerItemCardProps {
|
||||||
|
item: CustomerItem;
|
||||||
|
onPress?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
126
app/components/customerItemCard/customerItemCard.styles.ts
Normal file
126
app/components/customerItemCard/customerItemCard.styles.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
92
app/components/customerItemCard/customerItemCard.tsx
Normal file
92
app/components/customerItemCard/customerItemCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
app/components/customerItemCard/index.ts
Normal file
2
app/components/customerItemCard/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './customerItemCard';
|
||||||
|
export * from './customerItemCard.props';
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
484
app/features/addCustomer/addCustomer.screen.tsx
Normal file
484
app/features/addCustomer/addCustomer.screen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
68
app/features/addCustomer/addCustomer.styles.ts
Normal file
68
app/features/addCustomer/addCustomer.styles.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
3
app/features/addCustomer/index.ts
Normal file
3
app/features/addCustomer/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './addCustomer.screen';
|
||||||
|
export * from './thunk';
|
||||||
|
export * from './reducers';
|
||||||
38
app/features/addCustomer/reducers.ts
Normal file
38
app/features/addCustomer/reducers.ts
Normal 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;
|
||||||
16
app/features/addCustomer/thunk.ts
Normal file
16
app/features/addCustomer/thunk.ts
Normal 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
209
app/features/customerDetails/customerDetails.screen.tsx
Normal file
209
app/features/customerDetails/customerDetails.screen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
192
app/features/customerDetails/customerDetails.styles.ts
Normal file
192
app/features/customerDetails/customerDetails.styles.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
3
app/features/customerDetails/index.ts
Normal file
3
app/features/customerDetails/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './customerDetails.screen';
|
||||||
|
export * from './thunk';
|
||||||
|
export * from './reducers';
|
||||||
37
app/features/customerDetails/reducers.ts
Normal file
37
app/features/customerDetails/reducers.ts
Normal 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;
|
||||||
14
app/features/customerDetails/thunk.ts
Normal file
14
app/features/customerDetails/thunk.ts
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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}>
|
||||||
<FlatList
|
{/* Search Header */}
|
||||||
data={MOCK_CUSTOMERS}
|
<View style={styles.header}>
|
||||||
keyExtractor={item => item.id}
|
<SearchInput
|
||||||
contentContainerStyle={styles.listContent}
|
value={searchTerm}
|
||||||
renderItem={({ item }) => (
|
onChangeText={setSearchTerm}
|
||||||
<View style={styles.customerCard}>
|
placeholder="Search customers..."
|
||||||
<View style={styles.cardHeader}>
|
style={styles.searchInput}
|
||||||
<View>
|
/>
|
||||||
<Text style={styles.customerName}>{item.name}</Text>
|
</View>
|
||||||
<Text style={styles.contactPerson}>
|
|
||||||
Contact: {item.contactPerson}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.badge}>
|
|
||||||
<Text style={styles.badgeText}>
|
|
||||||
{item.activeProjects} active projects
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.cardFooter}>
|
{/* Stat Cards - Grid aligned across the screen width */}
|
||||||
<TouchableOpacity
|
<View style={styles.countsContainer}>
|
||||||
style={[styles.actionButton, styles.callButton]}
|
{statCards.map(s => (
|
||||||
onPress={() => handleCall(item.phone)}
|
<StatCard
|
||||||
>
|
key={s.key}
|
||||||
<Icon name="call" size={16} color={colors.icon} />
|
label={s.label}
|
||||||
<Text style={styles.callButtonText}>Call</Text>
|
count={s.count}
|
||||||
</TouchableOpacity>
|
color={s.color}
|
||||||
|
isSelected={selectedFilter === s.key}
|
||||||
|
style={styles.statCardFlex}
|
||||||
|
onPress={() =>
|
||||||
|
setSelectedFilter(prev => (prev === s.key ? 'all' : s.key))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
{/* Customer List */}
|
||||||
style={[styles.actionButton, styles.emailButton]}
|
{loading ? (
|
||||||
onPress={() => handleEmail(item.email)}
|
<Loader message="Loading customers..." />
|
||||||
>
|
) : error ? (
|
||||||
<Icon name="mail" size={16} color={colors.textSecondary} />
|
<View style={styles.errorContainer}>
|
||||||
<Text style={styles.emailButtonText}>Email</Text>
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filteredCustomers}
|
||||||
|
keyExtractor={item => item.userid}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
renderItem={renderCustomer}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<View style={styles.emptyIconWrap}>
|
||||||
|
<Icon name="business-outline" size={32} color={colors.icon} />
|
||||||
|
</View>
|
||||||
|
<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>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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) =>
|
||||||
container: {
|
StyleSheet.create({
|
||||||
flex: 1,
|
container: {
|
||||||
backgroundColor: colors.background,
|
flex: 1,
|
||||||
},
|
backgroundColor: colors.background,
|
||||||
listContent: {
|
},
|
||||||
padding: 16,
|
// Header
|
||||||
},
|
header: {
|
||||||
customerCard: {
|
paddingHorizontal: 16,
|
||||||
backgroundColor: colors.card,
|
paddingTop: 12,
|
||||||
borderRadius: 14,
|
paddingBottom: 10,
|
||||||
padding: 16,
|
},
|
||||||
marginBottom: 16,
|
searchInput: {
|
||||||
shadowColor: '#0F172A',
|
width: '100%',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
},
|
||||||
shadowOpacity: 0.05,
|
// Stat Cards container aligned evenly across screen width
|
||||||
shadowRadius: 4,
|
countsContainer: {
|
||||||
elevation: 2,
|
flexDirection: 'row',
|
||||||
},
|
paddingHorizontal: 16,
|
||||||
cardHeader: {
|
paddingTop: 4,
|
||||||
flexDirection: 'row',
|
paddingBottom: 12,
|
||||||
justifyContent: 'space-between',
|
gap: 10,
|
||||||
alignItems: 'flex-start',
|
},
|
||||||
borderBottomWidth: 1,
|
statCardFlex: {
|
||||||
borderBottomColor: colors.border,
|
flex: 1,
|
||||||
paddingBottom: 14,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
customerName: {
|
// List
|
||||||
fontSize: 16,
|
listContent: {
|
||||||
fontWeight: '700',
|
paddingHorizontal: 16,
|
||||||
color: colors.text,
|
paddingTop: 6,
|
||||||
},
|
paddingBottom: 40,
|
||||||
contactPerson: {
|
},
|
||||||
fontSize: 13,
|
// States
|
||||||
color: colors.textSecondary,
|
errorContainer: {
|
||||||
marginTop: 4,
|
flex: 1,
|
||||||
},
|
justifyContent: 'center',
|
||||||
badge: {
|
alignItems: 'center',
|
||||||
backgroundColor: colors.border,
|
padding: 32,
|
||||||
paddingHorizontal: 8,
|
},
|
||||||
paddingVertical: 4,
|
errorText: {
|
||||||
borderRadius: 6,
|
color: '#EF4444',
|
||||||
},
|
fontSize: 14,
|
||||||
badgeText: {
|
textAlign: 'center',
|
||||||
fontSize: 11,
|
},
|
||||||
fontWeight: '600',
|
emptyContainer: {
|
||||||
color: colors.icon,
|
alignItems: 'center',
|
||||||
},
|
paddingTop: 80,
|
||||||
cardFooter: {
|
padding: 32,
|
||||||
flexDirection: 'row',
|
gap: 10,
|
||||||
justifyContent: 'space-between',
|
},
|
||||||
paddingTop: 12,
|
emptyIconWrap: {
|
||||||
},
|
width: 76,
|
||||||
actionButton: {
|
height: 76,
|
||||||
flexDirection: 'row',
|
borderRadius: 38,
|
||||||
alignItems: 'center',
|
backgroundColor: colors.surface,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flex: 0.48,
|
alignItems: 'center',
|
||||||
height: 38,
|
marginBottom: 10,
|
||||||
borderRadius: 8,
|
borderWidth: 1,
|
||||||
borderWidth: 1,
|
borderColor: colors.border,
|
||||||
},
|
shadowColor: '#5B4CF5',
|
||||||
callButton: {
|
shadowOffset: { width: 0, height: 4 },
|
||||||
borderColor: colors.border,
|
shadowOpacity: 0.10,
|
||||||
backgroundColor: colors.border,
|
shadowRadius: 10,
|
||||||
},
|
elevation: 3,
|
||||||
callButtonText: {
|
},
|
||||||
color: colors.icon,
|
emptyTitle: {
|
||||||
fontWeight: '600',
|
color: colors.text,
|
||||||
fontSize: 13,
|
fontSize: 17,
|
||||||
marginLeft: 6,
|
fontWeight: '700',
|
||||||
},
|
textAlign: 'center',
|
||||||
emailButton: {
|
letterSpacing: 0.1,
|
||||||
borderColor: colors.border,
|
},
|
||||||
backgroundColor: colors.surface,
|
emptyText: {
|
||||||
},
|
color: colors.textSecondary,
|
||||||
emailButtonText: {
|
fontSize: 13,
|
||||||
color: colors.textSecondary,
|
textAlign: 'center',
|
||||||
fontWeight: '600',
|
lineHeight: 20,
|
||||||
fontSize: 13,
|
maxWidth: 280,
|
||||||
marginLeft: 6,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|||||||
69
app/features/customers/reducers.ts
Normal file
69
app/features/customers/reducers.ts
Normal 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;
|
||||||
|
|
||||||
29
app/features/customers/thunk.ts
Normal file
29
app/features/customers/thunk.ts
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { route } from '@utils';
|
|||||||
|
|
||||||
type LeadsScreenNavigationProp = NativeStackNavigationProp<
|
type LeadsScreenNavigationProp = NativeStackNavigationProp<
|
||||||
LeadsStackParamList,
|
LeadsStackParamList,
|
||||||
'leads'
|
'leadsList'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const LeadsScreen = () => {
|
export const LeadsScreen = () => {
|
||||||
|
|||||||
@ -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' }],
|
||||||
|
|||||||
82
app/interfaces/customers.ts
Normal file
82
app/interfaces/customers.ts
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
9
app/interfaces/fcmToken.ts
Normal file
9
app/interfaces/fcmToken.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface FcmTokenPayload {
|
||||||
|
id: string;
|
||||||
|
fcm_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FcmTokenResponse {
|
||||||
|
status: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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' },
|
||||||
|
];
|
||||||
76
app/navigation/customersStack.tsx
Normal file
76
app/navigation/customersStack.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@ -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} />
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
2
app/store/commonReducers/currencylist/index.ts
Normal file
2
app/store/commonReducers/currencylist/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './thunk';
|
||||||
|
export * from './reducers';
|
||||||
40
app/store/commonReducers/currencylist/reducers.ts
Normal file
40
app/store/commonReducers/currencylist/reducers.ts
Normal 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;
|
||||||
14
app/store/commonReducers/currencylist/thunk.ts
Normal file
14
app/store/commonReducers/currencylist/thunk.ts
Normal 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
2
app/store/commonReducers/fcmToken/index.ts
Normal file
2
app/store/commonReducers/fcmToken/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './thunk';
|
||||||
|
export * from './reducers';
|
||||||
38
app/store/commonReducers/fcmToken/reducers.ts
Normal file
38
app/store/commonReducers/fcmToken/reducers.ts
Normal 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;
|
||||||
14
app/store/commonReducers/fcmToken/thunk.ts
Normal file
14
app/store/commonReducers/fcmToken/thunk.ts
Normal 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
app/store/commonReducers/languagelist/index.ts
Normal file
2
app/store/commonReducers/languagelist/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './thunk';
|
||||||
|
export * from './reducers';
|
||||||
37
app/store/commonReducers/languagelist/reducers.ts
Normal file
37
app/store/commonReducers/languagelist/reducers.ts
Normal 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;
|
||||||
14
app/store/commonReducers/languagelist/thunk.ts
Normal file
14
app/store/commonReducers/languagelist/thunk.ts
Normal 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -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) => {
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user