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 { ThemeProvider, useTheme } from './theme';
|
||||
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 { NotificationService } from '@services';
|
||||
import BootSplash from 'react-native-bootsplash';
|
||||
@ -35,9 +35,13 @@ const AppInit = () => {
|
||||
dispatch(getStatusList());
|
||||
dispatch(getSourceList());
|
||||
dispatch(getCountryList());
|
||||
dispatch(getLanguageList());
|
||||
dispatch(getCurrencyList());
|
||||
}
|
||||
}, [dispatch, token]);
|
||||
|
||||
|
||||
|
||||
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 './leadDetailsApi';
|
||||
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';
|
||||
|
||||
export const getStatusListApi = async (): Promise<StatusListItem[]> => {
|
||||
@ -15,3 +15,15 @@ export const getCountryListApi = async (): Promise<CountryListItem[]> => {
|
||||
const url = 'api/countrylist';
|
||||
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 './statCard';
|
||||
export * from './profileInfoRow';
|
||||
export * from './customerItemCard';
|
||||
export * from './customerDetailHeader';
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export interface StatCardProps {
|
||||
label: string;
|
||||
count: number;
|
||||
color: string;
|
||||
isSelected?: boolean;
|
||||
onPress?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ export const StatCard: React.FC<StatCardProps> = ({
|
||||
color,
|
||||
isSelected = false,
|
||||
onPress,
|
||||
style,
|
||||
}) => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
@ -25,6 +26,7 @@ export const StatCard: React.FC<StatCardProps> = ({
|
||||
styles.card,
|
||||
{ borderColor },
|
||||
isSelected && styles.cardSelected,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{/* 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 { Text, View, FlatList, TouchableOpacity, Linking } from 'react-native';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Text, View, FlatList, Alert } from 'react-native';
|
||||
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 { MOCK_CUSTOMERS } from '../../mock-data/customers';
|
||||
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 = () => {
|
||||
const { theme: colors } = useTheme();
|
||||
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) => {
|
||||
Linking.openURL(`mailto:${email}`).catch(() => {});
|
||||
const handleDeleteCustomer = (customer: CustomerItem) => {
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={MOCK_CUSTOMERS}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.customerCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View>
|
||||
<Text style={styles.customerName}>{item.name}</Text>
|
||||
<Text style={styles.contactPerson}>
|
||||
Contact: {item.contactPerson}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>
|
||||
{item.activeProjects} active projects
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* Search Header */}
|
||||
<View style={styles.header}>
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChangeText={setSearchTerm}
|
||||
placeholder="Search customers..."
|
||||
style={styles.searchInput}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.callButton]}
|
||||
onPress={() => handleCall(item.phone)}
|
||||
>
|
||||
<Icon name="call" size={16} color={colors.icon} />
|
||||
<Text style={styles.callButtonText}>Call</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Stat Cards - Grid aligned across the screen width */}
|
||||
<View style={styles.countsContainer}>
|
||||
{statCards.map(s => (
|
||||
<StatCard
|
||||
key={s.key}
|
||||
label={s.label}
|
||||
count={s.count}
|
||||
color={s.color}
|
||||
isSelected={selectedFilter === s.key}
|
||||
style={styles.statCardFlex}
|
||||
onPress={() =>
|
||||
setSelectedFilter(prev => (prev === s.key ? 'all' : s.key))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.emailButton]}
|
||||
onPress={() => handleEmail(item.email)}
|
||||
>
|
||||
<Icon name="mail" size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.emailButtonText}>Email</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Customer List */}
|
||||
{loading ? (
|
||||
<Loader message="Loading customers..." />
|
||||
) : error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,86 +1,85 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { ThemeColors } from '../../theme';
|
||||
|
||||
export const getStyles = (colors: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
customerCard: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#0F172A',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
customerName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
contactPerson: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: colors.border,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.icon,
|
||||
},
|
||||
cardFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 0.48,
|
||||
height: 38,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
callButton: {
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
callButtonText: {
|
||||
color: colors.icon,
|
||||
fontWeight: '600',
|
||||
fontSize: 13,
|
||||
marginLeft: 6,
|
||||
},
|
||||
emailButton: {
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
emailButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
fontSize: 13,
|
||||
marginLeft: 6,
|
||||
},
|
||||
});
|
||||
export const getStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
// Header
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
searchInput: {
|
||||
width: '100%',
|
||||
},
|
||||
// Stat Cards container aligned evenly across screen width
|
||||
countsContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 12,
|
||||
gap: 10,
|
||||
},
|
||||
statCardFlex: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
// List
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
// States
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
errorText: {
|
||||
color: '#EF4444',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 80,
|
||||
padding: 32,
|
||||
gap: 10,
|
||||
},
|
||||
emptyIconWrap: {
|
||||
width: 76,
|
||||
height: 76,
|
||||
borderRadius: 38,
|
||||
backgroundColor: colors.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
shadowColor: '#5B4CF5',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.10,
|
||||
shadowRadius: 10,
|
||||
elevation: 3,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
maxWidth: 280,
|
||||
},
|
||||
});
|
||||
|
||||
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 './tasks';
|
||||
export * from './tickets';
|
||||
export * from './customerDetails';
|
||||
export * from './addCustomer';
|
||||
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ import { route } from '@utils';
|
||||
|
||||
type LeadsScreenNavigationProp = NativeStackNavigationProp<
|
||||
LeadsStackParamList,
|
||||
'leads'
|
||||
'leadsList'
|
||||
>;
|
||||
|
||||
export const LeadsScreen = () => {
|
||||
|
||||
@ -14,7 +14,7 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { getStyles } from './login.styles';
|
||||
import { useTheme } from '@theme';
|
||||
import { useAppDispatch, useAppSelector, RootState, login } from '@store';
|
||||
import { useAppDispatch, useAppSelector, RootState, login, sendFcmToken } from '@store';
|
||||
import { LoginRequest } from '@interfaces';
|
||||
import { NotificationService } from '@services';
|
||||
import { backGroundImage } from '@utils';
|
||||
@ -34,6 +34,7 @@ export const LoginScreen = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [focusedField, setFocusedField] = useState<string | null>(null);
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [deviceToken, setDeviceToken] = useState<string | null>(null);
|
||||
|
||||
const { loginLoading, loginError, loginSuccess, token, user_data } =
|
||||
useAppSelector((state: RootState) => state.auth);
|
||||
@ -58,13 +59,14 @@ export const LoginScreen = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceToken = await NotificationService.getFCMToken();
|
||||
console.log('deviceToken-', deviceToken)
|
||||
const fcmToken = await NotificationService.getFCMToken();
|
||||
setDeviceToken(fcmToken);
|
||||
console.log('deviceToken-', fcmToken)
|
||||
|
||||
const payload: LoginRequest = {
|
||||
email,
|
||||
password,
|
||||
device_token: deviceToken ?? '',
|
||||
device_token: fcmToken ?? '',
|
||||
};
|
||||
console.log('payload-', payload)
|
||||
console.log('tenancy-', config.BASE_URL)
|
||||
@ -113,6 +115,10 @@ export const LoginScreen = () => {
|
||||
|
||||
useEffect(() => {
|
||||
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({
|
||||
index: 0,
|
||||
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 './leadDetails';
|
||||
export * from './list';
|
||||
export * from './customers';
|
||||
export * from './fcmToken';
|
||||
|
||||
|
||||
@ -23,3 +23,24 @@ export interface CountryListItem {
|
||||
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 = [
|
||||
{ id: '1', name: 'Acme Corporation', contactPerson: 'Alice Vance', phone: '+1 (555) 019-2834', email: 'billing@acme.com', activeProjects: 3 },
|
||||
{ id: '2', name: 'Stark Industries', contactPerson: 'Tony Stark', phone: '+1 (555) 012-9842', email: 'pepper@stark.com', activeProjects: 5 },
|
||||
{ id: '3', name: 'Wayne Enterprises', contactPerson: 'Bruce Wayne', phone: '+1 (555) 014-7291', email: 'lucius@wayne.co', activeProjects: 1 },
|
||||
{ id: '4', name: 'Oscorp Tech', contactPerson: 'Norman Osborn', phone: '+1 (555) 017-3849', email: 'norman@oscorp.org', activeProjects: 2 },
|
||||
{ id: '5', name: 'Umbrella Corp', contactPerson: 'Albert Wesker', phone: '+1 (555) 015-8492', email: 'wesker@umbrella.com', activeProjects: 0 },
|
||||
];
|
||||
export const customerGroups = [
|
||||
{ id: 1, name: 'App Development' },
|
||||
{ id: 2, name: 'Basic' },
|
||||
{ id: 3, name: 'PHP' },
|
||||
{ id: 4, name: 'Prospect' },
|
||||
{ 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 { createDrawerNavigator } from '@react-navigation/drawer';
|
||||
import { TabStack } from './tabStack';
|
||||
import { CustomersStack } from './customersStack';
|
||||
import { CustomDrawerContent } from './customDrawerContent';
|
||||
import {
|
||||
CustomersScreen,
|
||||
ProposalsScreen,
|
||||
EstimatesScreen,
|
||||
InvoicesScreen,
|
||||
@ -53,7 +53,11 @@ export const DrawerStack = () => {
|
||||
component={TabStack}
|
||||
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.estimates} component={EstimatesScreen} />
|
||||
<Drawer.Screen name={route.invoices} component={InvoicesScreen} />
|
||||
|
||||
@ -7,10 +7,10 @@ import { route, RouteParams } from '@utils';
|
||||
import {
|
||||
DashboardScreen,
|
||||
AddLeadScreen,
|
||||
CustomersScreen,
|
||||
ProfileScreen,
|
||||
} from '@features';
|
||||
import { LeadsStack } from './leadsStack';
|
||||
import { CustomersStack } from './customersStack';
|
||||
import { useTheme } from '@theme';
|
||||
import { getStyles } from './tabStack.styles';
|
||||
|
||||
@ -103,10 +103,10 @@ export const TabStack = () => {
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={route.customers}
|
||||
component={CustomersScreen}
|
||||
component={CustomersStack}
|
||||
options={{
|
||||
tabBarLabel: 'Customers',
|
||||
headerTitle: 'Customers',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
|
||||
@ -102,7 +102,7 @@ export async function displayNotification(
|
||||
android: {
|
||||
channelId: CHANNEL_ID,
|
||||
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' },
|
||||
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 './sourcelist';
|
||||
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 sourceListReducer from './commonReducers/sourcelist/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 leadDetailsReducer from '../features/leadDetails/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({
|
||||
auth: authReducer,
|
||||
statusList: statusListReducer,
|
||||
sourceList: sourceListReducer,
|
||||
countryList: countryListReducer,
|
||||
languageList: languageListReducer,
|
||||
currencyList: currencyListReducer,
|
||||
fcmToken: fcmTokenReducer,
|
||||
leads: leadsReducer,
|
||||
leadDetails: leadDetailsReducer,
|
||||
addLead: addLeadReducer,
|
||||
customers: customersReducer,
|
||||
customerDetails: customerDetailsReducer,
|
||||
addCustomer: addCustomerReducer,
|
||||
});
|
||||
|
||||
const rootReducer = (state: any, action: any) => {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { CustomerItem } from '@interfaces';
|
||||
|
||||
// ─── Route Name Constants ────────────────────────────────────────────────────
|
||||
export const route = {
|
||||
// Auth
|
||||
@ -21,12 +23,16 @@ export const route = {
|
||||
leadProposals: 'leadProposals',
|
||||
leadTasks: 'leadTasks',
|
||||
leadNotes: 'leadNotes',
|
||||
customersList: 'customersList',
|
||||
customerDetails: 'customerDetails',
|
||||
addCustomer: 'addCustomer',
|
||||
profile: 'profile',
|
||||
} as const;
|
||||
|
||||
// ─── Route Param Types ────────────────────────────────────────────────────────
|
||||
// Use `undefined` for screens that take no params.
|
||||
// Add typed params here when a screen needs them, e.g. addLead: { leadId: string }
|
||||
// ─── Route Param Types ────────────────────────────────────────────────────────
|
||||
export type RouteParams = {
|
||||
login: undefined;
|
||||
dashboard: undefined;
|
||||
@ -44,6 +50,10 @@ export type RouteParams = {
|
||||
leadProposals: { leadId: string };
|
||||
leadTasks: { leadId: string };
|
||||
leadNotes: { leadId: string };
|
||||
customersList: undefined;
|
||||
customerDetails: { userid: string; customer?: CustomerItem };
|
||||
addCustomer: undefined;
|
||||
profile: undefined;
|
||||
};
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user