diff --git a/app/App.tsx b/app/App.tsx index 2501891..bcbc33a 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -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; }; diff --git a/app/api/customersApi.ts b/app/api/customersApi.ts new file mode 100644 index 0000000..ecfa03a --- /dev/null +++ b/app/api/customersApi.ts @@ -0,0 +1,52 @@ +import { CustomerItem, DeleteCustomerResponse, AddCustomerPayload, AddCustomerResponse } from '@interfaces'; +import { api } from '@utils'; + +export const getCustomersApi = async (): Promise => { + return await api.get('/api/customers'); +}; + +export const getCustomerDetailsApi = async ( + userid: string, +): Promise => { + return await api.get(`/api/customers/${userid}`); +}; + +export const deleteCustomerApi = async ( + userid: string, +): Promise => { + return await api.delete(`/api/delete/customers/${userid}`); +}; + +export const addCustomerApi = async ( + payload: AddCustomerPayload, +): Promise => { + 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('/api/customers', formData); +}; + + + diff --git a/app/api/fcmTokenApi.ts b/app/api/fcmTokenApi.ts new file mode 100644 index 0000000..e85c786 --- /dev/null +++ b/app/api/fcmTokenApi.ts @@ -0,0 +1,12 @@ +import { FcmTokenPayload, FcmTokenResponse } from '@interfaces'; +import { api } from '@utils'; + +export const sendFcmTokenApi = async ( + payload: FcmTokenPayload, +): Promise => { + const formData = new FormData(); + formData.append('id', payload.id); + formData.append('fcm_token', payload.fcm_token); + + return await api.post('api/staff_fcm_token', formData); +}; diff --git a/app/api/index.ts b/app/api/index.ts index 9ef0b73..567d66d 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -2,3 +2,7 @@ export * from './authApi'; export * from './leadsApi'; export * from './leadDetailsApi'; export * from './listApi'; +export * from './customersApi'; +export * from './fcmTokenApi'; + + diff --git a/app/api/listApi.ts b/app/api/listApi.ts index 10c6720..9ff1b61 100644 --- a/app/api/listApi.ts +++ b/app/api/listApi.ts @@ -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 => { @@ -15,3 +15,15 @@ export const getCountryListApi = async (): Promise => { const url = 'api/countrylist'; return await api.get(url); }; + +export const getLanguageListApi = async (): Promise => { + const url = 'api/languagelist'; + return await api.get(url); +}; + +export const getCurrencyListApi = async (): Promise => { + const url = 'api/proposals/currencylist/0'; + return await api.get(url); +}; + + diff --git a/app/components/customerDetailHeader/customerDetailHeader.props.ts b/app/components/customerDetailHeader/customerDetailHeader.props.ts new file mode 100644 index 0000000..3372a51 --- /dev/null +++ b/app/components/customerDetailHeader/customerDetailHeader.props.ts @@ -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; +} diff --git a/app/components/customerDetailHeader/customerDetailHeader.styles.ts b/app/components/customerDetailHeader/customerDetailHeader.styles.ts new file mode 100644 index 0000000..2cbc8d1 --- /dev/null +++ b/app/components/customerDetailHeader/customerDetailHeader.styles.ts @@ -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, + }, + }); diff --git a/app/components/customerDetailHeader/customerDetailHeader.tsx b/app/components/customerDetailHeader/customerDetailHeader.tsx new file mode 100644 index 0000000..4da67b4 --- /dev/null +++ b/app/components/customerDetailHeader/customerDetailHeader.tsx @@ -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 = ({ + 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 ( + + + {initials} + + + {displayName} + {fullname && company ? ( + {fullname} + ) : null} + + + + + {active === '1' ? 'Active Customer' : 'Inactive'} + + + + {datecreated ? ( + + Added {formatDate(datecreated)} + + ) : null} + + + {/* Quick Actions */} + + {phonenumber ? ( + + + Call + + ) : null} + + {email ? ( + + + Email + + ) : null} + + {website ? ( + + + Website + + ) : null} + + + ); +}; diff --git a/app/components/customerDetailHeader/index.ts b/app/components/customerDetailHeader/index.ts new file mode 100644 index 0000000..dc8296d --- /dev/null +++ b/app/components/customerDetailHeader/index.ts @@ -0,0 +1,2 @@ +export * from './customerDetailHeader'; +export * from './customerDetailHeader.props'; diff --git a/app/components/customerItemCard/customerItemCard.props.ts b/app/components/customerItemCard/customerItemCard.props.ts new file mode 100644 index 0000000..fb26742 --- /dev/null +++ b/app/components/customerItemCard/customerItemCard.props.ts @@ -0,0 +1,8 @@ +import { CustomerItem } from '@interfaces'; + +export interface CustomerItemCardProps { + item: CustomerItem; + onPress?: () => void; + onDelete?: () => void; +} + diff --git a/app/components/customerItemCard/customerItemCard.styles.ts b/app/components/customerItemCard/customerItemCard.styles.ts new file mode 100644 index 0000000..383e3b9 --- /dev/null +++ b/app/components/customerItemCard/customerItemCard.styles.ts @@ -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, + }, + }); diff --git a/app/components/customerItemCard/customerItemCard.tsx b/app/components/customerItemCard/customerItemCard.tsx new file mode 100644 index 0000000..46c3585 --- /dev/null +++ b/app/components/customerItemCard/customerItemCard.tsx @@ -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 = ({ + item, + onPress, + onDelete, +}) => { + const { theme: colors } = useTheme(); + const styles = getStyles(colors); + + const displayName = item.company || item.fullname; + const initials = getInitials(displayName); + + return ( + + + + + {initials} + + + + {displayName} + + {item.fullname && item.company ? ( + + {item.fullname} + + ) : null} + {item.city || item.state ? ( + + + {' '}{[item.city, item.state].filter(Boolean).join(', ')} + + ) : null} + + + + + + + {item.active === '1' ? 'Active' : 'Inactive'} + + + {onDelete ? ( + + + + ) : null} + + + + + + handlePhonePress(item.phonenumber)}> + + Call + + + handleEmailPress(item.email)}> + + Email + + + + ); +}; diff --git a/app/components/customerItemCard/index.ts b/app/components/customerItemCard/index.ts new file mode 100644 index 0000000..b9d271b --- /dev/null +++ b/app/components/customerItemCard/index.ts @@ -0,0 +1,2 @@ +export * from './customerItemCard'; +export * from './customerItemCard.props'; diff --git a/app/components/index.ts b/app/components/index.ts index 5e2aa45..6b4d5d8 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -10,4 +10,8 @@ export * from './actionButton'; export * from './checkboxWithLabel'; export * from './statCard'; export * from './profileInfoRow'; +export * from './customerItemCard'; +export * from './customerDetailHeader'; + + diff --git a/app/components/statCard/statCard.props.ts b/app/components/statCard/statCard.props.ts index 807615a..d7c4911 100644 --- a/app/components/statCard/statCard.props.ts +++ b/app/components/statCard/statCard.props.ts @@ -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; } diff --git a/app/components/statCard/statCard.tsx b/app/components/statCard/statCard.tsx index 0900e5c..3d26d0f 100644 --- a/app/components/statCard/statCard.tsx +++ b/app/components/statCard/statCard.tsx @@ -10,6 +10,7 @@ export const StatCard: React.FC = ({ color, isSelected = false, onPress, + style, }) => { const { theme: colors } = useTheme(); const styles = getStyles(colors); @@ -25,6 +26,7 @@ export const StatCard: React.FC = ({ styles.card, { borderColor }, isSelected && styles.cardSelected, + style, ]} > {/* Top Status Pill Tag */} diff --git a/app/features/addCustomer/addCustomer.screen.tsx b/app/features/addCustomer/addCustomer.screen.tsx new file mode 100644 index 0000000..5d14b52 --- /dev/null +++ b/app/features/addCustomer/addCustomer.screen.tsx @@ -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([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 ( + + + + Customer Info + + + + + + + + + + {/* Groups Selection */} + + Groups + + + {customerGroups.map(group => { + const isSelected = selectedGroups.includes(group.id); + return ( + 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} + > + + {group.name} + + + ); + })} + + + + + + + + + + + + + {/* Primary Address */} + + Primary Address + + + + + + + + + + + + + + + + + + + + + + + {/* Billing Address */} + + + Billing Address + + Same as primary + + + + + {!billingSameAsGeneral && ( + <> + + + + + + + + + + + + + + + + + + + + + )} + + + {/* Shipping Address */} + + + Shipping Address + + Same as billing + + + + + {!shippingSameAsBilling && ( + <> + + + + + + + + + + + + + + + + + + + + + )} + + + {/* Submit Button */} + + {loading ? ( + + ) : ( + <> + + Create Customer + + )} + + + + ); +}; diff --git a/app/features/addCustomer/addCustomer.styles.ts b/app/features/addCustomer/addCustomer.styles.ts new file mode 100644 index 0000000..a26e75e --- /dev/null +++ b/app/features/addCustomer/addCustomer.styles.ts @@ -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, + }, + }); diff --git a/app/features/addCustomer/index.ts b/app/features/addCustomer/index.ts new file mode 100644 index 0000000..b9e2c03 --- /dev/null +++ b/app/features/addCustomer/index.ts @@ -0,0 +1,3 @@ +export * from './addCustomer.screen'; +export * from './thunk'; +export * from './reducers'; diff --git a/app/features/addCustomer/reducers.ts b/app/features/addCustomer/reducers.ts new file mode 100644 index 0000000..5045553 --- /dev/null +++ b/app/features/addCustomer/reducers.ts @@ -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; diff --git a/app/features/addCustomer/thunk.ts b/app/features/addCustomer/thunk.ts new file mode 100644 index 0000000..4eaa505 --- /dev/null +++ b/app/features/addCustomer/thunk.ts @@ -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( + 'addCustomer/addCustomer', + async (payload, { rejectWithValue }) => { + try { + return await addCustomerApi(payload); + } catch (error: any) { + return rejectWithValue(error.message || 'Failed to add customer'); + } + }, +); diff --git a/app/features/customerDetails/customerDetails.screen.tsx b/app/features/customerDetails/customerDetails.screen.tsx new file mode 100644 index 0000000..8d4e029 --- /dev/null +++ b/app/features/customerDetails/customerDetails.screen.tsx @@ -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(); + 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 ; + } + + if (error && !customer) { + return ( + + + {error} + + ); + } + + if (!customer) { + return ( + + + No customer details found. + + ); + } + + 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 ( + + {/* Top Header & Quick Actions */} + handleEmailPress(customer.email)} + onPhonePress={() => handlePhonePress(customer.phonenumber)} + onWebsitePress={() => handleWebsitePress(customer.website)} + /> + + {/* Contact Details */} + Contact Details + + {customer.email ? ( + <> + handleEmailPress(customer.email)} + activeOpacity={0.7}> + + + + + Email + {customer.email} + + + + + ) : null} + + {customer.phonenumber ? ( + <> + handlePhonePress(customer.phonenumber)} + activeOpacity={0.7}> + + + + + Phone + {customer.phonenumber} + + + + + ) : null} + + {customer.website ? ( + <> + handleWebsitePress(customer.website)} + activeOpacity={0.7}> + + + + + Website + {customer.website} + + + + + ) : null} + + {customer.vat ? ( + + + + + + VAT Number + {customer.vat} + + + ) : null} + + + {/* Address Information */} + Address Information + + {billingAddress ? ( + <> + + + + + + Billing Address + {billingAddress} + + + {shippingAddress ? : null} + + ) : null} + + {shippingAddress ? ( + + + + + + Shipping Address + {shippingAddress} + + + ) : null} + + {!billingAddress && !shippingAddress ? ( + No address information provided. + ) : null} + + + ); +}; diff --git a/app/features/customerDetails/customerDetails.styles.ts b/app/features/customerDetails/customerDetails.styles.ts new file mode 100644 index 0000000..b7b6761 --- /dev/null +++ b/app/features/customerDetails/customerDetails.styles.ts @@ -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, + }, + }); diff --git a/app/features/customerDetails/index.ts b/app/features/customerDetails/index.ts new file mode 100644 index 0000000..3d1bcab --- /dev/null +++ b/app/features/customerDetails/index.ts @@ -0,0 +1,3 @@ +export * from './customerDetails.screen'; +export * from './thunk'; +export * from './reducers'; diff --git a/app/features/customerDetails/reducers.ts b/app/features/customerDetails/reducers.ts new file mode 100644 index 0000000..6be8189 --- /dev/null +++ b/app/features/customerDetails/reducers.ts @@ -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; diff --git a/app/features/customerDetails/thunk.ts b/app/features/customerDetails/thunk.ts new file mode 100644 index 0000000..4ad59e7 --- /dev/null +++ b/app/features/customerDetails/thunk.ts @@ -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'); + } +}); diff --git a/app/features/customers/customers.screen.tsx b/app/features/customers/customers.screen.tsx index 403a36b..b45b54c 100644 --- a/app/features/customers/customers.screen.tsx +++ b/app/features/customers/customers.screen.tsx @@ -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(); - 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 }) => ( + handleCustomerPress(item)} + onDelete={() => handleDeleteCustomer(item)} + /> + ); + + + return ( - item.id} - contentContainerStyle={styles.listContent} - renderItem={({ item }) => ( - - - - {item.name} - - Contact: {item.contactPerson} - - - - - {item.activeProjects} active projects - - - + {/* Search Header */} + + + - - handleCall(item.phone)} - > - - Call - + {/* Stat Cards - Grid aligned across the screen width */} + + {statCards.map(s => ( + + setSelectedFilter(prev => (prev === s.key ? 'all' : s.key)) + } + /> + ))} + - handleEmail(item.email)} - > - - Email - + {/* Customer List */} + {loading ? ( + + ) : error ? ( + + {error} + + ) : ( + item.userid} + style={{ flex: 1 }} + contentContainerStyle={styles.listContent} + renderItem={renderCustomer} + ListEmptyComponent={() => ( + + + + + No Customers Found + + {searchTerm + ? `No customers match "${searchTerm}". Try a different search.` + : 'No customers yet. They will appear here.'} + - - )} - /> + )} + /> + )} ); }; diff --git a/app/features/customers/customers.styles.ts b/app/features/customers/customers.styles.ts index 121ae19..98d9901 100644 --- a/app/features/customers/customers.styles.ts +++ b/app/features/customers/customers.styles.ts @@ -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, + }, + }); diff --git a/app/features/customers/reducers.ts b/app/features/customers/reducers.ts new file mode 100644 index 0000000..75f41a8 --- /dev/null +++ b/app/features/customers/reducers.ts @@ -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; + diff --git a/app/features/customers/thunk.ts b/app/features/customers/thunk.ts new file mode 100644 index 0000000..54afecc --- /dev/null +++ b/app/features/customers/thunk.ts @@ -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( + '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'); + } +}); + diff --git a/app/features/index.ts b/app/features/index.ts index 9b9cc91..a54f07c 100644 --- a/app/features/index.ts +++ b/app/features/index.ts @@ -14,3 +14,7 @@ export * from './projects'; export * from './proposals'; export * from './tasks'; export * from './tickets'; +export * from './customerDetails'; +export * from './addCustomer'; + + diff --git a/app/features/leads/leads.screen.tsx b/app/features/leads/leads.screen.tsx index 3dab9a3..beee1dd 100644 --- a/app/features/leads/leads.screen.tsx +++ b/app/features/leads/leads.screen.tsx @@ -21,7 +21,7 @@ import { route } from '@utils'; type LeadsScreenNavigationProp = NativeStackNavigationProp< LeadsStackParamList, - 'leads' + 'leadsList' >; export const LeadsScreen = () => { diff --git a/app/features/login/login.screen.tsx b/app/features/login/login.screen.tsx index 1d05db0..f243aac 100644 --- a/app/features/login/login.screen.tsx +++ b/app/features/login/login.screen.tsx @@ -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(null); const [localError, setLocalError] = useState(''); + const [deviceToken, setDeviceToken] = useState(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' }], diff --git a/app/interfaces/customers.ts b/app/interfaces/customers.ts new file mode 100644 index 0000000..1e6f0db --- /dev/null +++ b/app/interfaces/customers.ts @@ -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; + + diff --git a/app/interfaces/fcmToken.ts b/app/interfaces/fcmToken.ts new file mode 100644 index 0000000..79b1960 --- /dev/null +++ b/app/interfaces/fcmToken.ts @@ -0,0 +1,9 @@ +export interface FcmTokenPayload { + id: string; + fcm_token: string; +} + +export interface FcmTokenResponse { + status: boolean; + message: string; +} diff --git a/app/interfaces/index.ts b/app/interfaces/index.ts index 9392ca8..f7bc61e 100644 --- a/app/interfaces/index.ts +++ b/app/interfaces/index.ts @@ -3,3 +3,6 @@ export * from './auth'; export * from './leads'; export * from './leadDetails'; export * from './list'; +export * from './customers'; +export * from './fcmToken'; + diff --git a/app/interfaces/list.ts b/app/interfaces/list.ts index d2c0759..af976b7 100644 --- a/app/interfaces/list.ts +++ b/app/interfaces/list.ts @@ -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[]; +} + + diff --git a/app/mock-data/customers.ts b/app/mock-data/customers.ts index 208021a..e17846b 100644 --- a/app/mock-data/customers.ts +++ b/app/mock-data/customers.ts @@ -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 }, -]; \ No newline at end of file + 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' }, + ]; \ No newline at end of file diff --git a/app/navigation/customersStack.tsx b/app/navigation/customersStack.tsx new file mode 100644 index 0000000..8af2de2 --- /dev/null +++ b/app/navigation/customersStack.tsx @@ -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(); + +export const CustomersStack = () => { + const { theme: colors } = useTheme(); + + return ( + + ({ + headerTitle: 'Customers', + headerRight: () => ( + 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 }}> + + + ), + })} + /> + + + + ); +}; + diff --git a/app/navigation/drawerStack.tsx b/app/navigation/drawerStack.tsx index 134ef5c..db811ab 100644 --- a/app/navigation/drawerStack.tsx +++ b/app/navigation/drawerStack.tsx @@ -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 }} /> - + diff --git a/app/navigation/tabStack.tsx b/app/navigation/tabStack.tsx index 356585e..e4b98ab 100644 --- a/app/navigation/tabStack.tsx +++ b/app/navigation/tabStack.tsx @@ -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 = () => { /> { + 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; diff --git a/app/store/commonReducers/currencylist/thunk.ts b/app/store/commonReducers/currencylist/thunk.ts new file mode 100644 index 0000000..5a12750 --- /dev/null +++ b/app/store/commonReducers/currencylist/thunk.ts @@ -0,0 +1,14 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getCurrencyListApi } from '@api'; +import { CurrencyListResponse } from '@interfaces'; + +export const getCurrencyList = createAsyncThunk( + 'currencylist/getCurrencyList', + async (_, { rejectWithValue }) => { + try { + return await getCurrencyListApi(); + } catch (error: any) { + return rejectWithValue(error.message || 'Failed to fetch currency list'); + } + }, +); diff --git a/app/store/commonReducers/fcmToken/index.ts b/app/store/commonReducers/fcmToken/index.ts new file mode 100644 index 0000000..40a5b4c --- /dev/null +++ b/app/store/commonReducers/fcmToken/index.ts @@ -0,0 +1,2 @@ +export * from './thunk'; +export * from './reducers'; diff --git a/app/store/commonReducers/fcmToken/reducers.ts b/app/store/commonReducers/fcmToken/reducers.ts new file mode 100644 index 0000000..036e141 --- /dev/null +++ b/app/store/commonReducers/fcmToken/reducers.ts @@ -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; diff --git a/app/store/commonReducers/fcmToken/thunk.ts b/app/store/commonReducers/fcmToken/thunk.ts new file mode 100644 index 0000000..2fe192a --- /dev/null +++ b/app/store/commonReducers/fcmToken/thunk.ts @@ -0,0 +1,14 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { sendFcmTokenApi } from '@api'; +import { FcmTokenPayload, FcmTokenResponse } from '@interfaces'; + +export const sendFcmToken = createAsyncThunk( + 'fcmToken/sendFcmToken', + async (payload, { rejectWithValue }) => { + try { + return await sendFcmTokenApi(payload); + } catch (error: any) { + return rejectWithValue(error.message || 'Failed to send FCM token'); + } + }, +); diff --git a/app/store/commonReducers/index.ts b/app/store/commonReducers/index.ts index badd083..b942557 100644 --- a/app/store/commonReducers/index.ts +++ b/app/store/commonReducers/index.ts @@ -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'; + + diff --git a/app/store/commonReducers/languagelist/index.ts b/app/store/commonReducers/languagelist/index.ts new file mode 100644 index 0000000..40a5b4c --- /dev/null +++ b/app/store/commonReducers/languagelist/index.ts @@ -0,0 +1,2 @@ +export * from './thunk'; +export * from './reducers'; diff --git a/app/store/commonReducers/languagelist/reducers.ts b/app/store/commonReducers/languagelist/reducers.ts new file mode 100644 index 0000000..31ed9e5 --- /dev/null +++ b/app/store/commonReducers/languagelist/reducers.ts @@ -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; diff --git a/app/store/commonReducers/languagelist/thunk.ts b/app/store/commonReducers/languagelist/thunk.ts new file mode 100644 index 0000000..ea327df --- /dev/null +++ b/app/store/commonReducers/languagelist/thunk.ts @@ -0,0 +1,14 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getLanguageListApi } from '@api'; +import { LanguageListItem } from '@interfaces'; + +export const getLanguageList = createAsyncThunk( + 'languagelist/getLanguageList', + async (_, { rejectWithValue }) => { + try { + return await getLanguageListApi(); + } catch (error: any) { + return rejectWithValue(error.message || 'Failed to fetch language list'); + } + }, +); diff --git a/app/store/rootReducer.ts b/app/store/rootReducer.ts index b2704a6..e109afc 100644 --- a/app/store/rootReducer.ts +++ b/app/store/rootReducer.ts @@ -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) => { diff --git a/app/utils/route.ts b/app/utils/route.ts index e7b8e99..3eebd20 100644 --- a/app/utils/route.ts +++ b/app/utils/route.ts @@ -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; }; +