Fix form keyboard issues and sticky footers in agent screens

This commit is contained in:
Abin Toppo
2026-05-26 16:34:05 +05:30
commit 9ac5e95d52
410 changed files with 86650 additions and 0 deletions
@@ -0,0 +1,544 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Alert, Platform, KeyboardAvoidingView } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInRight, FadeOutLeft } from 'react-native-reanimated';
import { Input, Button, DatePicker, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useAccountOpeningForm } from '../hooks/useAccountOpeningForm';
import { ACCOUNT_TYPES } from '../constants/agentConstants';
import { formatAgentCurrency } from '../utils/agentFormatters';
const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepIndex: number }) => {
const theme = useTheme();
return (
<View style={styles.stepContainer}>
<View style={styles.stepRow}>
{steps.map((s, i) => {
const isPassed = i <= currentStepIndex;
const isActive = i === currentStepIndex;
return (
<View key={s.id} style={styles.stepWrapper}>
<View style={[
styles.stepDot,
{
backgroundColor: isPassed ? theme.colors.primary : theme.colors.surface,
borderColor: isPassed ? theme.colors.primary : theme.colors.cardBorder,
transform: [{ scale: isActive ? 1.15 : 1 }]
}
]}>
{i < currentStepIndex ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
) : (
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontWeight: '700' }}>{i + 1}</Text>
)}
</View>
{i < steps.length - 1 && <View style={[styles.stepLine, { backgroundColor: i < currentStepIndex ? theme.colors.primary : theme.colors.cardBorder }]} />}
</View>
);
})}
</View>
<View style={styles.stepLabelRow}>
{steps.map((s, i) => (
<Text
key={s.id}
style={[
styles.stepLabel,
{ color: i <= currentStepIndex ? theme.colors.primary : theme.colors.textMuted },
i === currentStepIndex && { fontWeight: '700' },
]}
>
{s.label}
</Text>
))}
</View>
</View>
);
};
export default function AccountOpeningScreen({ navigation }: { navigation: NativeStackNavigationProp<AgentLoansStackParamList> }) {
const theme = useTheme();
const { draft, step, nextStep, prevStep, updateField, setAccountType, submit, isSubmitting } = useAccountOpeningForm();
const handleNext = () => nextStep();
const handleBack = () => prevStep();
const handleSubmit = async () => {
const res = await submit();
if (res) {
Alert.alert(
'Account Created',
`Account Number: ${res.accountNumber}\nInitial Deposit collected.`,
[{ text: 'OK', onPress: () => navigation.navigate('AgentLoansMain', { initialTab: 'loans' }) }]
);
}
};
const steps = [
{ id: 'type', label: 'Type' },
{ id: 'personal', label: 'Personal' },
{ id: 'kyc', label: 'KYC' },
{ id: 'deposit', label: 'Deposit' },
{ id: 'review', label: 'Review' },
];
const currentStepIndex = steps.findIndex(s => s.id === step);
return (
<View style={styles.flexContainer}>
{/* Local header */}
<View style={styles.localHeader}>
<Pressable onPress={() => navigation.goBack()} hitSlop={12} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.colors.textPrimary} />
</Pressable>
<Text style={[styles.localHeaderTitle, { color: theme.colors.textPrimary }]}>NEW ACCOUNT</Text>
</View>
<StepIndicator steps={steps} currentStepIndex={currentStepIndex} />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.flex}>
<KeyboardAwareScrollView contentContainerStyle={styles.scrollContent} style={styles.flex}>
{step === 'type' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Select Account Type</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Choose the type of account you want to open for the customer</Text>
{ACCOUNT_TYPES.map(type => (
<Pressable
key={type.id}
style={[
styles.typeCard,
{
backgroundColor: theme.colors.surface,
borderColor: draft.accountType === type.id ? theme.colors.primary : theme.colors.cardBorder,
borderWidth: draft.accountType === type.id ? 2 : 1,
}
]}
onPress={() => setAccountType(type.id)}
>
<View style={[styles.typeIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name={type.icon as any} size={28} color={theme.colors.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.typeLabel, { color: theme.colors.textPrimary }]}>{type.label}</Text>
<Text style={[styles.typeDesc, { color: theme.colors.textMuted }]}>{type.description}</Text>
</View>
<View style={[styles.radio, { borderColor: draft.accountType === type.id ? theme.colors.primary : theme.colors.cardBorder }]}>
{draft.accountType === type.id && <View style={[styles.radioFill, { backgroundColor: theme.colors.primary }]} />}
</View>
</Pressable>
))}
</Animated.View>
)}
{step === 'personal' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Personal Information</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Enter customer's basic details</Text>
<View style={styles.formGroup}>
<Input
label="Full Name"
placeholder="e.g. Amit Sharma"
value={draft.customerName}
onChangeText={v => updateField('customerName', v)}
/>
<Input
label="Phone Number"
placeholder="10-digit mobile number"
value={draft.phone}
onChangeText={v => updateField('phone', v)}
keyboardType="number-pad"
maxLength={10}
/>
<DatePicker
label="Date of Birth"
placeholder="DD/MM/YYYY"
value={draft.dateOfBirth}
onDateChange={v => updateField('dateOfBirth', v)}
maxDate={new Date()}
/>
<Input
label="Address"
placeholder="House/Flat No, Street, Area"
value={draft.address}
onChangeText={v => updateField('address', v)}
multiline
/>
</View>
</Animated.View>
)}
{step === 'kyc' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>KYC & Nominee</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Identity verification & nominee details</Text>
<View style={styles.formGroup}>
<Input
label="Aadhaar Number"
placeholder="12-digit Aadhaar number"
value={draft.aadhaarNumber}
onChangeText={v => updateField('aadhaarNumber', v)}
keyboardType="number-pad"
maxLength={12}
/>
<Input
label="PAN Number"
placeholder="e.g. ABCDE1234F"
value={draft.panNumber}
onChangeText={v => updateField('panNumber', v)}
autoCapitalize="characters"
maxLength={10}
/>
</View>
<View style={[styles.sectionDivider, { borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.sectionDividerText, { color: theme.colors.textMuted, backgroundColor: theme.colors.background }]}>Nominee</Text>
</View>
<View style={styles.formGroup}>
<Input
label="Nominee Name"
placeholder="e.g. Sunita Sharma"
value={draft.nomineeName}
onChangeText={v => updateField('nomineeName', v)}
/>
<Input
label="Relationship"
placeholder="e.g. Spouse, Parent, Child"
value={draft.nomineeRelation}
onChangeText={v => updateField('nomineeRelation', v)}
/>
</View>
</Animated.View>
)}
{step === 'deposit' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Initial Deposit</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Collect the opening deposit from customer</Text>
<View style={styles.formGroup}>
<Input
label="Initial Deposit Amount"
placeholder="e.g. 1000"
value={draft.initialDeposit}
onChangeText={v => updateField('initialDeposit', v)}
keyboardType="number-pad"
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
/>
{(draft.accountType === 'RD' || draft.accountType === 'DDS') && (
<Input
label={`${draft.accountType === 'RD' ? 'Monthly' : 'Daily'} Installment`}
placeholder={draft.accountType === 'RD' ? 'e.g. 500' : 'e.g. 100'}
value={draft.monthlyInstallment}
onChangeText={v => updateField('monthlyInstallment', v)}
keyboardType="number-pad"
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
/>
)}
</View>
<View style={[styles.warningBox, { backgroundColor: `${theme.colors.info}10`, borderColor: `${theme.colors.info}30` }]}>
<Ionicons name="cash-outline" size={20} color={theme.colors.info} />
<Text style={[styles.warningText, { color: theme.colors.info }]}>
Please collect the initial deposit amount from the customer in cash before proceeding.
</Text>
</View>
</Animated.View>
)}
{step === 'review' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Review Application</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Please verify all details before submitting</Text>
<View style={[styles.reviewCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Account Type</Text>
<Text style={[styles.reviewValue, { color: theme.colors.primary }]}>{draft.accountType} Account</Text>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Name</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.customerName}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Phone</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.phone}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>DOB</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.dateOfBirth}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Aadhaar</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.aadhaarNumber}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>PAN</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.panNumber}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Nominee</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.nomineeName} ({draft.nomineeRelation})</Text>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Initial Deposit</Text>
<Text style={[styles.reviewValue, { color: theme.colors.success, fontWeight: '800', fontSize: 16 }]}>
{formatAgentCurrency(Number(draft.initialDeposit))}
</Text>
</View>
</View>
</Animated.View>
)}
</KeyboardAwareScrollView>
</KeyboardAvoidingView>
{/* Fixed footer buttons */}
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
<View style={styles.footerRow}>
<View style={styles.col}>
{step !== 'type' && (
<Button
title="Back"
variant="secondary"
onPress={handleBack}
/>
)}
</View>
{step !== 'type' && <View style={{ width: 16 }} />}
<View style={styles.col}>
{step !== 'review' ? (
<Button
title="Next"
onPress={handleNext}
/>
) : (
<Button
title="Submit & Open"
onPress={handleSubmit}
loading={isSubmitting}
/>
)}
</View>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
flexContainer: {
flex: 1,
},
localHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 12,
gap: 8,
},
backBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
localHeaderTitle: {
fontSize: 18,
fontWeight: '800',
letterSpacing: 0.5,
},
// ── Step indicator ─────────────────────────
stepContainer: {
paddingTop: 8,
paddingBottom: 16,
},
stepRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
stepWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
stepDot: {
width: 28,
height: 28,
borderRadius: 14,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
stepLine: {
width: 24,
height: 2,
marginHorizontal: 4,
borderRadius: 1,
},
stepLabelRow: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 8,
marginTop: 8,
},
stepLabel: {
fontSize: 10,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.3,
width: 54,
textAlign: 'center',
},
// ── Form content ──────────────────────────
scrollContent: {
padding: 16,
paddingBottom: 24,
},
stepView: {
gap: 4,
},
stepTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 4,
},
stepSubtitle: {
fontSize: 13,
lineHeight: 18,
marginBottom: 20,
},
formGroup: {
gap: 16,
},
// ── Account type cards ────────────────────
typeCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
marginBottom: 12,
gap: 16,
},
typeIcon: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
typeLabel: {
fontSize: 16,
fontWeight: '700',
marginBottom: 4,
},
typeDesc: {
fontSize: 12,
lineHeight: 18,
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
radioFill: {
width: 10,
height: 10,
borderRadius: 5,
},
// ── Section divider ───────────────────────
sectionDivider: {
borderTopWidth: 1,
marginVertical: 20,
alignItems: 'center',
},
sectionDividerText: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: 12,
marginTop: -8,
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
// ── Info box ──────────────────────────────
warningBox: {
flexDirection: 'row',
padding: 16,
borderRadius: 12,
borderWidth: 1,
marginTop: 20,
gap: 12,
},
warningText: {
flex: 1,
fontSize: 13,
lineHeight: 18,
fontWeight: '500',
},
// ── Review card ───────────────────────────
reviewCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
reviewRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
},
reviewLabel: {
fontSize: 13,
fontWeight: '500',
},
reviewValue: {
fontSize: 14,
fontWeight: '600',
textAlign: 'right',
flexShrink: 1,
marginLeft: 12,
},
// ── Footer ────────────────────────────────
footer: {
padding: 16,
borderTopWidth: 1,
paddingBottom: TAB_BAR_HEIGHT + 10,
},
footerRow: {
flexDirection: 'row',
},
col: {
flex: 1,
},
});
@@ -0,0 +1,216 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { useCustomerForm } from '../hooks/useCustomerForm';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'AddEditCustomer'>;
const FormSection = ({ title, icon, children }: { title: string, icon: string, children: React.ReactNode }) => {
const theme = useTheme();
return (
<Animated.View entering={FadeInUp} style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
</View>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
</View>
<View style={styles.sectionContent}>
{children}
</View>
</Animated.View>
);
};
export default function AddEditCustomerScreen({ route, navigation }: Props) {
const { customerId } = route.params;
const theme = useTheme();
const existingCustomer = useAppSelector(state =>
customerId ? selectCustomerById(customerId)(state) : null
);
const { data, errors, updateField, submit, isSubmitting } = useCustomerForm(existingCustomer || undefined);
const isEdit = !!existingCustomer;
const handleSubmit = async () => {
const res = await submit();
if (res) {
navigation.goBack();
}
};
return (
<ScreenShell title={isEdit ? 'Edit Customer' : 'Add New Customer'} scrollable={false} keyboardAware={false} withTabBar={false}>
<View style={styles.flexContainer}>
<KeyboardAwareScrollView contentContainerStyle={styles.scrollContent} style={styles.flex}>
<FormSection title="Personal Information" icon="person-outline">
<Input
label="Full Name"
placeholder="e.g. Amit Sharma"
value={data.name}
onChangeText={(v) => updateField('name', v)}
error={errors.name}
/>
<Input
label="Phone Number"
placeholder="10-digit number"
value={data.phone}
onChangeText={(v) => updateField('phone', v)}
keyboardType="number-pad"
maxLength={10}
error={errors.phone}
leftAccessory={<Text style={{ color: theme.colors.textPrimary, fontWeight: 'bold' }}>+91</Text>}
/>
<Input
label="Email Address (Optional)"
placeholder="e.g. amit@example.com"
value={data.email}
onChangeText={(v) => updateField('email', v)}
keyboardType="email-address"
autoCapitalize="none"
error={errors.email}
/>
</FormSection>
<FormSection title="KYC Details" icon="finger-print-outline">
<Input
label="Aadhaar Number"
placeholder="12-digit number"
value={data.aadhaarNumber}
onChangeText={(v) => updateField('aadhaarNumber', v)}
keyboardType="number-pad"
maxLength={12}
error={errors.aadhaarNumber}
/>
<Input
label="PAN Number"
placeholder="e.g. ABCDE1234F"
value={data.panNumber}
onChangeText={(v) => updateField('panNumber', v)}
autoCapitalize="characters"
maxLength={10}
error={errors.panNumber}
/>
</FormSection>
<FormSection title="Address" icon="location-outline">
<Input
label="Street Address"
placeholder="House/Flat No, Area"
value={data.address}
onChangeText={(v) => updateField('address', v)}
error={errors.address}
/>
<View style={styles.row}>
<View style={styles.col}>
<Input
label="City"
placeholder="e.g. Kolkata"
value={data.city}
onChangeText={(v) => updateField('city', v)}
error={errors.city}
/>
</View>
<View style={{ width: 12 }} />
<View style={styles.col}>
<Input
label="State"
placeholder="e.g. West Bengal"
value={data.state}
onChangeText={(v) => updateField('state', v)}
error={errors.state}
/>
</View>
</View>
<Input
label="Pincode"
placeholder="6-digit pincode"
value={data.pincode}
onChangeText={(v) => updateField('pincode', v)}
keyboardType="number-pad"
maxLength={6}
error={errors.pincode}
/>
</FormSection>
</KeyboardAwareScrollView>
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
<Button
title={isEdit ? 'Save Changes' : 'Add Customer'}
onPress={handleSubmit}
loading={isSubmitting}
/>
</View>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
flexContainer: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 24,
},
section: {
borderRadius: 16,
borderWidth: 1,
marginBottom: 24,
overflow: 'hidden',
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
sectionIcon: {
width: 32,
height: 32,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
sectionTitle: {
fontSize: 15,
fontWeight: '700',
},
sectionContent: {
padding: 16,
paddingBottom: 16,
gap: 16,
},
row: {
flexDirection: 'row',
},
col: {
flex: 1,
},
footer: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 10,
borderTopWidth: 1,
},
});
@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { View, StyleSheet, ScrollView, Alert, Platform, KeyboardAvoidingView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenShell, Input, Button } from '../../../components';
import { useTheme } from '../../../theme';
import type { AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
type Props = NativeStackScreenProps<AgentProfileStackParamList, 'AgentChangePassword'>;
export default function AgentChangePasswordScreen({ navigation }: Props) {
const theme = useTheme();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = () => {
if (!currentPassword || !newPassword || !confirmPassword) {
Alert.alert('Error', 'Please fill in all fields.');
return;
}
if (newPassword !== confirmPassword) {
Alert.alert('Error', 'New passwords do not match.');
return;
}
setLoading(true);
// Simulate API call
setTimeout(() => {
setLoading(false);
Alert.alert('Success', 'Your password has been changed successfully.', [
{ text: 'OK', onPress: () => navigation.goBack() }
]);
}, 1500);
};
return (
<ScreenShell title="Change Password" scrollable={false} keyboardAware={false}>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 20}
>
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled" automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Input
label="Current Password"
value={currentPassword}
onChangeText={setCurrentPassword}
secureTextEntry
/>
<Input
label="New Password"
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
<Input
label="Confirm New Password"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
<Button
title="Update Password"
onPress={handleSubmit}
loading={loading}
style={{ marginTop: 24 }}
/>
</ScrollView>
</KeyboardAvoidingView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
},
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
});
@@ -0,0 +1,316 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, RefreshControl, Pressable } from 'react-native';
import { CompositeNavigationProp } from '@react-navigation/native';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppSelector, useAppDispatch } from '../../../app/hooks';
import type { AgentTabParamList, AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
import { useAgentDashboard } from '../hooks/useAgentDashboard';
import { formatAgentCurrency, getGreeting } from '../utils/agentFormatters';
import { agentActions } from '../store/agentSlice';
import type { CustomerFilter } from '../types/agentTypes';
import { AgentStatCard } from '../components/AgentStatCard';
import { AgentMiniChart } from '../components/AgentMiniChart';
import { AgentProgressRing } from '../components/AgentProgressRing';
import { AgentSectionHeader } from '../components/AgentSectionHeader';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
type AgentDashboardNavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentDashboardStackParamList, 'AgentDashboardMain'>,
BottomTabNavigationProp<AgentTabParamList>
>;
export default function AgentDashboardScreen({ navigation }: { navigation: AgentDashboardNavigationProp }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const profile = useAppSelector(state => state.user.profile);
const { stats, isLoading, isRefreshing, error, onRefresh } = useAgentDashboard();
// ── Navigation helpers ──────────────────────────────────────
const goToCustomers = (filter: CustomerFilter = 'all') => {
dispatch(agentActions.setCustomerFilter(filter));
navigation.navigate('AgentCustomers', { screen: 'CustomerList' });
};
const goToCollections = () => {
navigation.navigate('AgentCollections', { screen: 'DailyCollection' });
};
const goToCollectionHistory = () => {
navigation.navigate('AgentCollections', { screen: 'CollectionHistory' });
};
const goToCommission = () => {
navigation.navigate('AgentLoans', { screen: 'CommissionDashboard' });
};
const goToRoute = () => {
navigation.navigate('AgentRoute');
};
if (isLoading) {
return (
<ScreenShell scrollable={false}>
<View style={styles.header}>
<Text style={[styles.greeting, { color: theme.colors.textMuted }]}>{getGreeting()},</Text>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName || 'Agent'}</Text>
</View>
<AgentSkeletonLoader type="dashboard" />
</ScreenShell>
);
}
if (error || !stats) {
return (
<ScreenShell scrollable={false}>
<AgentEmptyState
title="Dashboard Unavailable"
description="We couldn't load your dashboard data. Please try again."
icon="alert-circle-outline"
actionTitle="Retry"
onAction={onRefresh}
/>
</ScreenShell>
);
}
const collectionProgress = stats.possibleCollection > 0
? (stats.actualCollection / stats.possibleCollection) * 100
: 0;
return (
<ScreenShell scrollable={false}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} colors={[theme.colors.primary]} />
}
>
<View style={styles.header}>
<View>
<Text style={[styles.greeting, { color: theme.colors.textSecondary }]}>{getGreeting()},</Text>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName || 'Agent'}</Text>
</View>
<View style={[styles.avatarFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="person" size={20} color={theme.colors.primary} />
</View>
</View>
{/* Customer Stats Row */}
<Animated.View entering={FadeIn.duration(400)} style={styles.section}>
<AgentSectionHeader
title="Customer Overview"
actionTitle="View All"
onAction={() => goToCustomers('all')}
/>
<View style={styles.row}>
<AgentStatCard
index={0}
title="Total Customers"
value={stats.totalCustomers.toString()}
icon="people-outline"
color={theme.colors.info}
onPress={() => goToCustomers('all')}
/>
<View style={{ width: 12 }} />
<AgentStatCard
index={1}
title="Paid Customers"
value={stats.paidCustomers.toString()}
icon="checkmark-circle-outline"
color={theme.colors.success}
trend={12}
onPress={() => goToCustomers('paid')}
/>
</View>
<View style={[styles.row, { marginTop: 12 }]}>
<AgentStatCard
index={2}
title="Pending Customers"
value={stats.pendingCustomers.toString()}
icon="time-outline"
color={theme.colors.warning}
onPress={() => goToCustomers('pending')}
/>
<View style={{ width: 12 }} />
<AgentStatCard
index={3}
title="Pending Amount"
value={formatAgentCurrency(stats.pendingAmount)}
icon="alert-circle-outline"
color={theme.colors.danger}
onPress={() => goToCustomers('pending')}
/>
</View>
</Animated.View>
{/* Collection Section */}
<Animated.View entering={FadeIn.duration(500)} style={styles.section}>
<AgentSectionHeader
title="Collection Performance"
actionTitle="View All"
onAction={goToCollections}
/>
<View style={styles.row}>
<AgentStatCard
index={4}
title="Today's Collection"
value={formatAgentCurrency(stats.todayCollection)}
icon="wallet-outline"
gradient={['#2563EB', '#3B82F6']}
onPress={goToCollections}
/>
<View style={{ width: 12 }} />
<AgentStatCard
index={5}
title="This Week"
value={formatAgentCurrency(stats.weeklyCollection)}
icon="trending-up-outline"
gradient={['#059669', '#10B981']}
onPress={goToCollectionHistory}
/>
</View>
<Pressable onPress={goToCollectionHistory}>
<View style={[styles.chartCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.chartHeaderRow}>
<View>
<Text style={[styles.chartTitle, { color: theme.colors.textMuted }]}>Monthly Collection</Text>
<Text style={[styles.chartValue, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(stats.monthlyCollection)}</Text>
</View>
<AgentProgressRing
progress={collectionProgress}
size={56}
strokeWidth={5}
color={theme.colors.primary}
value={`${Math.round(collectionProgress)}%`}
/>
</View>
<View style={styles.chartDivider} />
<Text style={[styles.chartSubtitle, { color: theme.colors.textMuted }]}>Last 7 Days Trend</Text>
<AgentMiniChart
data={stats.weeklyCollectionTrend}
height={80}
color={theme.colors.primary}
/>
</View>
</Pressable>
</Animated.View>
{/* Commission & Routing */}
<Animated.View entering={FadeIn.duration(600)} style={styles.section}>
<AgentSectionHeader title="Insights" />
<View style={styles.row}>
<AgentStatCard
index={6}
title="Commission Earned"
value={formatAgentCurrency(stats.commissionEarned)}
icon="star-outline"
color="#F59E0B"
onPress={goToCommission}
/>
<View style={{ width: 12 }} />
<AgentStatCard
index={7}
title="Route Completion"
value={`${stats.completedVisits}/${stats.todayVisits}`}
icon="map-outline"
color="#8B5CF6"
trendText={`${Math.round((stats.completedVisits / stats.todayVisits) * 100)}% done`}
onPress={goToRoute}
/>
</View>
</Animated.View>
<View style={styles.footerSpacer} />
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
padding: 16,
paddingBottom: 100, // accommodate bottom tab
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
marginTop: 8,
},
greeting: {
fontSize: 14,
fontWeight: '500',
marginBottom: 4,
},
name: {
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.5,
},
avatarFrame: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
section: {
marginBottom: 32,
},
row: {
flexDirection: 'row',
},
chartCard: {
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginTop: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
chartHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
chartTitle: {
fontSize: 12,
fontWeight: '600',
marginBottom: 4,
},
chartValue: {
fontSize: 22,
fontWeight: '800',
letterSpacing: -0.5,
},
chartDivider: {
height: 1,
backgroundColor: 'rgba(0,0,0,0.05)',
marginVertical: 16,
},
chartSubtitle: {
fontSize: 11,
fontWeight: '500',
marginBottom: 12,
},
footerSpacer: {
height: 40,
},
});
@@ -0,0 +1,61 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ScreenShell, Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppDispatch } from '../../../app/hooks';
import { logoutSession } from '../../auth/store/authThunk';
import { Button } from '../../../components/Button';
export default function AgentHomeScreen() {
const theme = useTheme();
const dispatch = useAppDispatch();
const handleLogout = () => {
dispatch(logoutSession());
};
return (
<ScreenShell>
<View style={styles.container}>
<Card style={styles.card}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Agent Portal</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Welcome to the CBS Agent dashboard. Manage your clients and commissions here.
</Text>
<Button
title="Secure Logout"
onPress={handleLogout}
style={styles.logoutButton}
variant="secondary"
/>
</Card>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
justifyContent: 'center',
},
card: {
padding: 24,
alignItems: 'center',
gap: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
subtitle: {
fontSize: 16,
textAlign: 'center',
},
logoutButton: {
marginTop: 24,
width: '100%',
},
});
@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { View, StyleSheet, Text, Pressable, ScrollView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeInRight, FadeInLeft } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import LoanRequestListScreen from './LoanRequestListScreen';
import CommissionDashboardScreen from './CommissionDashboardScreen';
import AccountOpeningScreen from './AccountOpeningScreen';
type Props = NativeStackScreenProps<AgentLoansStackParamList, 'AgentLoansMain'>;
type TabKey = 'loans' | 'commission' | 'account';
export default function AgentLoansScreen({ route, navigation }: Props) {
const theme = useTheme();
const [activeTab, setActiveTab] = useState<TabKey>(route.params?.initialTab || 'loans');
const tabs: { key: TabKey; label: string; icon: string }[] = [
{ key: 'loans', label: 'Loans', icon: 'document-text-outline' },
{ key: 'commission', label: 'Commission', icon: 'star-outline' },
{ key: 'account', label: 'New A/C', icon: 'person-add-outline' },
];
return (
<ScreenShell scrollable={false} withTabBar={false} keyboardAware={false}>
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Agent Workspace</Text>
</View>
<View style={styles.tabContainer}>
{tabs.map((tab) => {
const isActive = activeTab === tab.key;
return (
<Pressable
key={tab.key}
style={[
styles.tab,
{
backgroundColor: isActive ? theme.colors.primary : theme.colors.surface,
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
}
]}
onPress={() => setActiveTab(tab.key)}
>
<Ionicons
name={tab.icon as any}
size={16}
color={isActive ? theme.colors.white : theme.colors.textMuted}
/>
<Text style={[
styles.tabLabel,
{ color: isActive ? theme.colors.white : theme.colors.textSecondary }
]}>
{tab.label}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.contentArea}>
{activeTab === 'loans' && (
<Animated.View entering={FadeInLeft} style={{ flex: 1 }}>
<LoanRequestListScreen navigation={navigation as any} />
</Animated.View>
)}
{activeTab === 'commission' && (
<Animated.View entering={FadeIn} style={{ flex: 1 }}>
<CommissionDashboardScreen />
</Animated.View>
)}
{activeTab === 'account' && (
<Animated.View entering={FadeInRight} style={{ flex: 1 }}>
<AccountOpeningScreen navigation={navigation as any} />
</Animated.View>
)}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
header: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
},
headerTitle: {
fontSize: 22,
fontWeight: '800',
letterSpacing: -0.5,
},
tabContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
marginBottom: 12,
gap: 8,
},
tab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
gap: 6,
},
tabLabel: {
fontSize: 12,
fontWeight: '700',
},
contentArea: {
flex: 1,
},
});
@@ -0,0 +1,231 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-native';
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
import { logoutSession } from '../../auth/store/authThunk';
type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentProfileStackParamList, 'AgentProfileMain'>,
BottomTabNavigationProp<AgentTabParamList>
>;
export default function AgentProfileScreen({ navigation }: { navigation: NavigationProp }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const profile = useAppSelector(state => state.user.profile);
const handleLogout = () => {
Alert.alert(
'Log Out',
'Are you sure you want to log out of your agent account?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Log Out',
style: 'destructive',
onPress: () => {
dispatch(logoutSession());
}
}
]
);
};
const SettingsRow = ({ icon, label, onPress, destructive = false }: { icon: string, label: string, onPress: () => void, destructive?: boolean }) => (
<Pressable
style={styles.settingsRow}
onPress={onPress}
>
<View style={styles.settingsRowLeft}>
<View style={[styles.settingsIconFrame, { backgroundColor: destructive ? `${theme.colors.danger}15` : `${theme.colors.primary}15` }]}>
<Ionicons name={icon as any} size={20} color={destructive ? theme.colors.danger : theme.colors.primary} />
</View>
<Text style={[styles.settingsLabel, { color: destructive ? theme.colors.danger : theme.colors.textPrimary }]}>{label}</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
</Pressable>
);
return (
<ScreenShell title="Agent Profile" scrollable={false}>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Animated.View entering={FadeInUp} style={[styles.profileCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{profile?.firstName?.charAt(0) || 'A'}</Text>
</View>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName} {profile?.lastName}</Text>
<Text style={[styles.role, { color: theme.colors.textMuted }]}>Field Agent • {profile?.email}</Text>
<View style={[styles.badge, { backgroundColor: `${theme.colors.success}15` }]}>
<View style={[styles.statusDot, { backgroundColor: theme.colors.success }]} />
<Text style={[styles.badgeText, { color: theme.colors.success }]}>Active Agent</Text>
</View>
</Animated.View>
<Animated.View entering={FadeInUp.delay(100)}>
<Text style={[styles.sectionTitle, { color: theme.colors.textMuted }]}>Account Settings</Text>
<View style={[styles.settingsGroup, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<SettingsRow
icon="key-outline"
label="Change Password"
onPress={() => navigation.navigate('AgentChangePassword')}
/>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<SettingsRow
icon="chatbubble-ellipses-outline"
label="SMS Log"
onPress={() => navigation.navigate('SmsHistory')}
/>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<SettingsRow
icon="notifications-outline"
label="Notification Settings"
onPress={() => {}}
/>
</View>
</Animated.View>
<Animated.View entering={FadeInUp.delay(200)}>
<Text style={[styles.sectionTitle, { color: theme.colors.textMuted, marginTop: 24 }]}>Support & Legal</Text>
<View style={[styles.settingsGroup, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<SettingsRow
icon="help-circle-outline"
label="Help & Support"
onPress={() => {}}
/>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<SettingsRow
icon="document-text-outline"
label="Terms & Conditions"
onPress={() => {}}
/>
</View>
</Animated.View>
<View style={{ marginTop: 32 }}>
<Button
title="Log Out"
variant="secondary"
onPress={handleLogout}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
textStyle={{ color: theme.colors.danger }}
/>
</View>
<View style={styles.footerInfo}>
<Text style={[styles.version, { color: theme.colors.textMuted }]}>App Version 1.0.0</Text>
<Text style={[styles.version, { color: theme.colors.textMuted }]}>Agent Module v1</Text>
</View>
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
profileCard: {
alignItems: 'center',
padding: 24,
borderRadius: 16,
borderWidth: 1,
marginBottom: 24,
},
avatarBig: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
avatarText: {
fontSize: 32,
fontWeight: '800',
},
name: {
fontSize: 20,
fontWeight: '800',
marginBottom: 4,
},
role: {
fontSize: 14,
marginBottom: 12,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
gap: 6,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
badgeText: {
fontSize: 12,
fontWeight: '700',
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
marginLeft: 8,
},
settingsGroup: {
borderRadius: 16,
borderWidth: 1,
overflow: 'hidden',
},
settingsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
},
settingsRowLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
settingsIconFrame: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
settingsLabel: {
fontSize: 15,
fontWeight: '600',
},
divider: {
height: 1,
marginLeft: 64, // Align with text
},
footerInfo: {
alignItems: 'center',
marginTop: 32,
gap: 4,
},
version: {
fontSize: 12,
},
});
@@ -0,0 +1,100 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import type { AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
type Props = NativeStackScreenProps<AgentDashboardStackParamList, 'AgentRoute'>;
export default function AgentRouteScreen({ navigation }: Props) {
const theme = useTheme();
return (
<ScreenShell title="My Route" scrollable={false}>
<View style={styles.container}>
<View style={styles.mapPlaceholder}>
<Ionicons name="map-outline" size={64} color={theme.colors.textMuted} />
<Text style={[styles.mapText, { color: theme.colors.textMuted }]}>Map Integration Coming Soon</Text>
</View>
<View style={[styles.infoCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.infoTitle, { color: theme.colors.textPrimary }]}>Route Summary</Text>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Total Stops</Text>
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>12</Text>
</View>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Completed</Text>
<Text style={[styles.statValue, { color: theme.colors.success }]}>5</Text>
</View>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Remaining</Text>
<Text style={[styles.statValue, { color: theme.colors.warning }]}>7</Text>
</View>
</View>
<Button
title="Start Navigation"
onPress={() => {}}
style={{ marginTop: 'auto', marginBottom: 24 }}
/>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
mapPlaceholder: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.02)',
borderRadius: 16,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(0,0,0,0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
mapText: {
marginTop: 16,
fontSize: 16,
fontWeight: '600',
},
infoCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
infoTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 12,
},
divider: {
height: 1,
width: '100%',
marginBottom: 12,
},
statRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
statLabel: {
fontSize: 14,
},
statValue: {
fontSize: 16,
fontWeight: '700',
},
});
@@ -0,0 +1,115 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { useGetCollectionHistoryQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectCollectionHistory } from '../store/agentSelectors';
import { CollectionEntryCard } from '../components/CollectionEntryCard';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
import { formatAgentCurrency, formatDate } from '../utils/agentFormatters';
type Props = NativeStackScreenProps<AgentCollectionsStackParamList, 'CollectionHistory'>;
export default function CollectionHistoryScreen({ navigation }: Props) {
const theme = useTheme();
const dispatch = useAppDispatch();
const history = useAppSelector(selectCollectionHistory);
const { data, isLoading, refetch, isFetching } = useGetCollectionHistoryQuery({});
useEffect(() => {
if (data) {
dispatch(agentActions.setCollectionHistory(data));
}
}, [data, dispatch]);
const totalCollected = history.reduce((sum, item) => sum + item.collectedAmount, 0);
return (
<ScreenShell title="Collection History" scrollable={false}>
{/* Summary Header */}
<View style={[styles.summaryHeader, { backgroundColor: theme.colors.surface, borderBottomColor: theme.colors.cardBorder }]}>
<View style={styles.summaryItem}>
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Total Entries</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{history.length}</Text>
</View>
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.summaryItem}>
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Amount Collected</Text>
<Text style={[styles.summaryValue, { color: theme.colors.primary }]}>{formatAgentCurrency(totalCollected)}</Text>
</View>
</View>
<View style={styles.listArea}>
{isLoading && history.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : (
<FlatList
data={history}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />
}
ListEmptyComponent={() => (
<AgentEmptyState
title="No history found"
description="There are no past collection records."
/>
)}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInUp.delay(index * 50)}>
<CollectionEntryCard
entry={item}
onPress={() => item.status !== 'pending' && item.receiptId ? navigation.navigate('CollectionReceipt', { entryId: item.receiptId }) : null}
/>
</Animated.View>
)}
/>
)}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
summaryHeader: {
flexDirection: 'row',
padding: 16,
borderBottomWidth: 1,
},
summaryItem: {
flex: 1,
},
summaryLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
summaryValue: {
fontSize: 20,
fontWeight: '800',
},
vDivider: {
width: 1,
height: '100%',
marginHorizontal: 16,
},
listArea: {
flex: 1,
},
listContent: {
padding: 16,
paddingBottom: 40,
},
});
@@ -0,0 +1,204 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, SlideInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { formatFullCurrency, formatDateTime } from '../utils/agentFormatters';
// Since this is a mock receipt screen, we will just simulate receipt data based on the entry ID
// In a real app, we would fetch the receipt details from the API
type Props = NativeStackScreenProps<AgentCollectionsStackParamList, 'CollectionReceipt'>;
export default function CollectionReceiptScreen({ route, navigation }: Props) {
const { entryId } = route.params;
const theme = useTheme();
const receipt = {
receiptNo: entryId,
customerName: 'Customer',
accountNumber: 'CBS00000000',
amount: 500,
date: new Date().toISOString(),
mode: 'cash',
agentName: 'Rajesh Kumar',
agentCode: 'AGT-2024-0147',
};
const InfoRow = ({ label, value, strong = false }: { label: string, value: string, strong?: boolean }) => (
<View style={styles.infoRow}>
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>{label}</Text>
<Text style={[
styles.infoValue,
{
color: strong ? theme.colors.textPrimary : theme.colors.textSecondary,
fontWeight: strong ? '700' : '500'
}
]}>
{value}
</Text>
</View>
);
return (
<ScreenShell title="Receipt" scrollable={false}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<Animated.View entering={SlideInUp.springify()} style={[styles.receiptCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.successHeader}>
<View style={[styles.successIcon, { backgroundColor: `${theme.colors.success}15` }]}>
<Ionicons name="checkmark" size={32} color={theme.colors.success} />
</View>
<Text style={[styles.successTitle, { color: theme.colors.success }]}>Payment Successful</Text>
<Text style={[styles.successAmount, { color: theme.colors.textPrimary }]}>{formatFullCurrency(receipt.amount)}</Text>
</View>
<View style={styles.dividerContainer}>
<View style={[styles.circleCutout, styles.leftCutout, { backgroundColor: theme.colors.background }]} />
<View style={[styles.dashedLine, { borderColor: theme.colors.cardBorder }]} />
<View style={[styles.circleCutout, styles.rightCutout, { backgroundColor: theme.colors.background }]} />
</View>
<View style={styles.detailsSection}>
<InfoRow label="Receipt No" value={receipt.receiptNo} strong />
<InfoRow label="Date & Time" value={formatDateTime(receipt.date)} />
<InfoRow label="Customer Name" value={receipt.customerName} strong />
<InfoRow label="Account Number" value={receipt.accountNumber} />
<InfoRow label="Payment Mode" value={receipt.mode.toUpperCase()} />
<View style={[styles.solidDivider, { backgroundColor: theme.colors.cardBorder }]} />
<InfoRow label="Agent Name" value={receipt.agentName} />
<InfoRow label="Agent Code" value={receipt.agentCode} />
</View>
</Animated.View>
<Animated.View entering={FadeIn.delay(300)} style={styles.actionsRow}>
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Ionicons name="share-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Share PDF</Text>
</Pressable>
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Ionicons name="print-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Print</Text>
</Pressable>
</Animated.View>
<Button
title="Done"
onPress={() => navigation.navigate('DailyCollection')}
style={{ marginTop: 24 }}
/>
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
padding: 16,
},
receiptCard: {
borderRadius: 16,
borderWidth: 1,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 12,
elevation: 3,
},
successHeader: {
alignItems: 'center',
padding: 24,
},
successIcon: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
successTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 8,
},
successAmount: {
fontSize: 32,
fontWeight: '800',
},
dividerContainer: {
flexDirection: 'row',
alignItems: 'center',
height: 24,
position: 'relative',
},
dashedLine: {
flex: 1,
height: 1,
borderWidth: 1,
borderStyle: 'dashed',
marginHorizontal: 12,
},
circleCutout: {
width: 24,
height: 24,
borderRadius: 12,
position: 'absolute',
top: 0,
},
leftCutout: {
left: -12,
},
rightCutout: {
right: -12,
},
detailsSection: {
padding: 24,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
infoLabel: {
fontSize: 13,
},
infoValue: {
fontSize: 14,
},
solidDivider: {
height: 1,
width: '100%',
marginVertical: 16,
},
actionsRow: {
flexDirection: 'row',
gap: 16,
marginTop: 24,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
gap: 8,
},
actionBtnText: {
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,215 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
import Animated, { FadeInUp } from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useGetCommissionSummaryQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectCommissionSummary } from '../store/agentSelectors';
import { formatAgentCurrency, formatPercentage } from '../utils/agentFormatters';
import { CommissionSummaryCard } from '../components/CommissionSummaryCard';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
export default function CommissionDashboardScreen() {
const theme = useTheme();
const dispatch = useAppDispatch();
const summary = useAppSelector(selectCommissionSummary);
const { data, isLoading, refetch, isFetching } = useGetCommissionSummaryQuery();
useEffect(() => {
if (data) dispatch(agentActions.setCommissionSummary(data));
}, [data, dispatch]);
if (isLoading && !summary) {
return <AgentSkeletonLoader type="dashboard" />;
}
if (!summary) {
return <AgentEmptyState title="Unavailable" description="Could not load commission data" />;
}
return (
<View style={styles.container}>
<FlatList
data={summary.history}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
ListHeaderComponent={() => (
<View style={styles.headerArea}>
<View style={[styles.totalCard, { backgroundColor: theme.colors.primary }]}>
<View style={styles.totalRow}>
<View>
<Text style={styles.totalLabel}>Total Earned Till Date</Text>
<Text style={styles.totalValue}>{formatAgentCurrency(summary.totalEarned)}</Text>
</View>
<View style={styles.iconCircle}>
<Ionicons name="star" size={24} color={theme.colors.primary} />
</View>
</View>
<View style={styles.totalDivider} />
<View style={styles.totalRow}>
<Text style={styles.lastPaidLabel}>Last Paid: {summary.lastPaidDate}</Text>
<Text style={styles.lastPaidValue}>{formatAgentCurrency(summary.lastPaidAmount)}</Text>
</View>
</View>
<View style={{ marginTop: 16, marginBottom: 24 }}>
<CommissionSummaryCard summary={summary} />
</View>
<Text style={[styles.historyTitle, { color: theme.colors.textPrimary }]}>History</Text>
</View>
)}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.historyCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.historyHeader}>
<Text style={[styles.historyMonth, { color: theme.colors.textPrimary }]}>{item.month} {item.year}</Text>
<View style={[styles.statusBadge, { backgroundColor: item.status === 'paid' ? `${theme.colors.success}15` : `${theme.colors.warning}15` }]}>
<Text style={[styles.statusText, { color: item.status === 'paid' ? theme.colors.success : theme.colors.warning }]}>
{item.status.toUpperCase()}
</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.historyBody}>
<View style={styles.historyCol}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Collection</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.actualCollection)}</Text>
</View>
<View style={styles.historyCol}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Commission</Text>
<Text style={[styles.value, { color: theme.colors.primary }]}>{formatAgentCurrency(item.actualCommission)}</Text>
</View>
<View style={styles.historyCol}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Loss</Text>
<Text style={[styles.value, { color: item.loss > 0 ? theme.colors.danger : theme.colors.textPrimary }]}>
{formatAgentCurrency(item.loss)}
</Text>
</View>
</View>
</Animated.View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
listContent: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
headerArea: {
marginBottom: 8,
},
totalCard: {
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
totalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
totalLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 13,
fontWeight: '500',
marginBottom: 4,
},
totalValue: {
color: '#fff',
fontSize: 28,
fontWeight: '800',
letterSpacing: -0.5,
},
iconCircle: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
totalDivider: {
height: 1,
backgroundColor: 'rgba(255,255,255,0.2)',
marginVertical: 16,
},
lastPaidLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
},
lastPaidValue: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
},
historyTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 12,
},
historyCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
marginBottom: 12,
},
historyHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
historyMonth: {
fontSize: 16,
fontWeight: '700',
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
historyBody: {
flexDirection: 'row',
justifyContent: 'space-between',
},
historyCol: {
flex: 1,
},
label: {
fontSize: 11,
marginBottom: 4,
},
value: {
fontSize: 15,
fontWeight: '700',
},
});
@@ -0,0 +1,613 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable, Platform, KeyboardAvoidingView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInRight, FadeOutLeft, FadeIn } from 'react-native-reanimated';
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useLoanRequestForm } from '../hooks/useLoanRequestForm';
import { LOAN_CATEGORIES } from '../constants/agentConstants';
import { useAppSelector } from '../../../app/hooks';
import { selectFilteredCustomers } from '../store/agentSelectors';
type Props = NativeStackScreenProps<AgentLoansStackParamList, 'CreateLoanRequest'>;
const STEPS = [
{ id: 1, label: 'Category', icon: 'grid-outline' },
{ id: 2, label: 'Details', icon: 'document-text-outline' },
{ id: 3, label: 'Review', icon: 'checkmark-circle-outline' },
];
// ── Step Indicator ──────────────────────────
const StepIndicator = ({ step }: { step: number }) => {
const theme = useTheme();
return (
<View style={styles.stepContainer}>
{STEPS.map((s, i) => {
const isCompleted = step > s.id;
const isActive = step === s.id;
const isPending = step < s.id;
return (
<React.Fragment key={s.id}>
<View style={styles.stepItem}>
<View style={[
styles.stepDot,
{
backgroundColor: isCompleted ? theme.colors.success : isActive ? theme.colors.primary : theme.colors.surface,
borderColor: isCompleted ? theme.colors.success : isActive ? theme.colors.primary : theme.colors.cardBorder,
},
]}>
{isCompleted ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
) : (
<Text style={{
color: isActive ? theme.colors.white : theme.colors.textMuted,
fontSize: 11,
fontWeight: '800',
}}>{s.id}</Text>
)}
</View>
<Text style={[
styles.stepLabel,
{
color: isActive ? theme.colors.primary : isCompleted ? theme.colors.success : theme.colors.textMuted,
fontWeight: isActive ? '700' : '500',
},
]}>{s.label}</Text>
</View>
{i < STEPS.length - 1 && (
<View style={[
styles.stepLine,
{ backgroundColor: isCompleted ? theme.colors.success : theme.colors.cardBorder },
]} />
)}
</React.Fragment>
);
})}
</View>
);
};
// ── Section Card Wrapper ────────────────────
const SectionCard = ({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) => {
const theme = useTheme();
return (
<View style={[styles.sectionCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
{subtitle && <Text style={[styles.sectionSubtitle, { color: theme.colors.textMuted }]}>{subtitle}</Text>}
<View style={styles.sectionBody}>{children}</View>
</View>
);
};
// ── Review Row ──────────────────────────────
const ReviewRow = ({ label, value, accent }: { label: string; value: string; accent?: boolean }) => {
const theme = useTheme();
return (
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>{label}</Text>
<Text style={[styles.reviewValue, { color: accent ? theme.colors.primary : theme.colors.textPrimary }]}>{value || '—'}</Text>
</View>
);
};
export default function CreateLoanRequestScreen({ route, navigation }: Props) {
const theme = useTheme();
const { category, customerId } = route.params;
const customers = useAppSelector(selectFilteredCustomers);
const { data, errors, updateField, submit, isSubmitting, validate } = useLoanRequestForm(category, customerId);
const [step, setStep] = useState(1);
const selectedCategory = LOAN_CATEGORIES.find(c => c.id === data.category);
const handleNext = () => {
if (step === 1 && !data.category) return;
if (step === 1 && !data.customerName) return;
if (step === 2) {
if (!validate()) return;
}
setStep(s => Math.min(3, s + 1));
};
const handleBack = () => {
if (step === 1) navigation.goBack();
else setStep(s => Math.max(1, s - 1));
};
const handleSubmit = async () => {
const res = await submit();
if (res) {
navigation.goBack();
}
};
return (
<ScreenShell
title="New Loan Request"
scrollable={false}
withTabBar={false}
fixedFooter={
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
{/* Progress indicator */}
<View style={styles.progressRow}>
<Text style={[styles.progressText, { color: theme.colors.textMuted }]}>
Step {step} of {STEPS.length}
</Text>
<View style={[styles.progressTrack, { backgroundColor: theme.colors.cardBorder }]}>
<View style={[styles.progressFill, { width: `${(step / STEPS.length) * 100}%`, backgroundColor: theme.colors.primary }]} />
</View>
</View>
<View style={styles.row}>
<View style={styles.col}>
<Button
title={step === 1 ? 'Cancel' : 'Back'}
variant="secondary"
onPress={handleBack}
/>
</View>
<View style={{ width: 12 }} />
<View style={styles.col}>
{step < 3 ? (
<Button
title="Continue"
onPress={handleNext}
/>
) : (
<Button
title="Submit Request"
onPress={handleSubmit}
loading={isSubmitting}
/>
)}
</View>
</View>
</View>
}
>
<View style={styles.flexContainer}>
<StepIndicator step={step} />
<KeyboardAwareScrollView
contentContainerStyle={styles.scrollContent}
style={styles.flex}
>
{/* ═══════════ STEP 1: Category & Customer ═══════════ */}
{step === 1 && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
<SectionCard title="Loan Type" subtitle="Choose the category that best fits">
<View style={styles.categoryList}>
{LOAN_CATEGORIES.map(cat => {
const isSelected = data.category === cat.id;
return (
<Pressable
key={cat.id}
style={[
styles.catCard,
{
backgroundColor: isSelected ? `${cat.color}12` : theme.colors.surfaceMuted,
borderColor: isSelected ? cat.color : 'transparent',
borderWidth: isSelected ? 1.5 : 1,
},
]}
onPress={() => updateField('category', cat.id as any)}
>
<View style={[styles.catIcon, { backgroundColor: `${cat.color}20` }]}>
<Ionicons name={cat.icon as any} size={22} color={cat.color} />
</View>
<Text style={[
styles.catLabel,
{ color: isSelected ? cat.color : theme.colors.textPrimary },
]}>{cat.label}</Text>
{isSelected && (
<View style={[styles.catCheck, { backgroundColor: cat.color }]}>
<Ionicons name="checkmark" size={12} color={theme.colors.white} />
</View>
)}
</Pressable>
);
})}
</View>
{errors.category && (
<Text style={[styles.errorText, { color: theme.colors.danger }]}>{errors.category}</Text>
)}
</SectionCard>
<SectionCard title="Customer Details" subtitle="Who is applying for the loan?">
<Input
label="Full Name"
placeholder="e.g. Amit Sharma"
value={data.customerName}
onChangeText={v => updateField('customerName', v)}
error={errors.customerName}
/>
<Input
label="Phone Number"
placeholder="10-digit mobile number"
value={data.customerPhone}
onChangeText={v => updateField('customerPhone', v)}
keyboardType="number-pad"
maxLength={10}
leftAccessory={<Text style={{ color: theme.colors.textMuted, fontWeight: '600', fontSize: 13 }}>+91</Text>}
/>
</SectionCard>
</Animated.View>
)}
{/* ═══════════ STEP 2: Loan Details ═══════════ */}
{step === 2 && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
{selectedCategory && (
<Animated.View entering={FadeIn} style={[styles.selectedBanner, { backgroundColor: `${selectedCategory.color}12`, borderColor: `${selectedCategory.color}30` }]}>
<View style={[styles.bannerIcon, { backgroundColor: `${selectedCategory.color}20` }]}>
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedCategory.color} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.bannerTitle, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
<Text style={[styles.bannerSub, { color: theme.colors.textMuted }]}>for {data.customerName}</Text>
</View>
</Animated.View>
)}
<SectionCard title="Loan Amount & Tenure">
<Input
label="Loan Amount"
placeholder="Min ₹5,000"
value={data.amount}
onChangeText={v => updateField('amount', v)}
keyboardType="number-pad"
error={errors.amount}
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '800', color: theme.colors.primary }}>₹</Text>}
/>
<View style={styles.row}>
<View style={styles.col}>
<Input
label="Tenure"
placeholder="e.g. 12"
value={data.tenureMonths}
onChangeText={v => updateField('tenureMonths', v)}
keyboardType="number-pad"
error={errors.tenureMonths}
rightAccessory={<Text style={{ fontSize: 12, color: theme.colors.textMuted, fontWeight: '600' }}>months</Text>}
/>
</View>
<View style={{ width: 12 }} />
<View style={styles.col}>
<Input
label="Monthly Income"
placeholder="Approx"
value={data.monthlyIncome}
onChangeText={v => updateField('monthlyIncome', v)}
keyboardType="number-pad"
error={errors.monthlyIncome}
leftAccessory={<Text style={{ fontSize: 14, fontWeight: '700', color: theme.colors.textMuted }}>₹</Text>}
/>
</View>
</View>
</SectionCard>
<SectionCard title="Purpose of Loan">
<Input
placeholder="e.g. Home renovation, medical expenses..."
value={data.purpose}
onChangeText={v => updateField('purpose', v)}
error={errors.purpose}
multiline
/>
</SectionCard>
</Animated.View>
)}
{/* ═══════════ STEP 3: Review & Submit ═══════════ */}
{step === 3 && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
{/* Summary Card */}
<View style={[styles.reviewCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
{/* Header badge */}
{selectedCategory && (
<View style={[styles.reviewBadge, { backgroundColor: `${selectedCategory.color}12` }]}>
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedCategory.color} />
<Text style={[styles.reviewBadgeText, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
</View>
)}
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
<ReviewRow label="Customer" value={data.customerName} />
<ReviewRow label="Phone" value={data.customerPhone ? `+91 ${data.customerPhone}` : ''} />
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
<ReviewRow label="Amount" value={data.amount ? `₹ ${Number(data.amount).toLocaleString('en-IN')}` : ''} accent />
<ReviewRow label="Tenure" value={data.tenureMonths ? `${data.tenureMonths} months` : ''} />
<ReviewRow label="Monthly Income" value={data.monthlyIncome ? `₹ ${Number(data.monthlyIncome).toLocaleString('en-IN')}` : ''} />
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
<ReviewRow label="Purpose" value={data.purpose} />
</View>
{/* Documents info */}
<View style={[styles.docsCard, { backgroundColor: `${theme.colors.info}08`, borderColor: `${theme.colors.info}25` }]}>
<Ionicons name="document-attach-outline" size={20} color={theme.colors.info} />
<View style={{ flex: 1 }}>
<Text style={[styles.docsTitle, { color: theme.colors.info }]}>Documents can be added later</Text>
<Text style={[styles.docsText, { color: theme.colors.textMuted }]}>
You can upload Aadhaar, PAN, income proof & photo from the request details page after submission.
</Text>
</View>
</View>
{/* Consent */}
<View style={[styles.consentBox, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="shield-checkmark-outline" size={18} color={theme.colors.success} />
<Text style={[styles.consentText, { color: theme.colors.textMuted }]}>
By submitting, you confirm the above details are accurate and the customer has given consent for this application.
</Text>
</View>
</Animated.View>
)}
</KeyboardAwareScrollView>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
flexContainer: {
flex: 1,
},
// ── Step Indicator ────────────────────────
stepContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
paddingHorizontal: 24,
},
stepItem: {
alignItems: 'center',
gap: 6,
},
stepDot: {
width: 28,
height: 28,
borderRadius: 14,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
stepLabel: {
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
stepLine: {
flex: 1,
height: 2,
marginHorizontal: 8,
marginBottom: 18,
borderRadius: 1,
},
// ── Scroll Content ────────────────────────
scrollContent: {
padding: 16,
paddingBottom: 24,
gap: 14,
},
// ── Section Cards ─────────────────────────
sectionCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
sectionTitle: {
fontSize: 17,
fontWeight: '700',
marginBottom: 2,
},
sectionSubtitle: {
fontSize: 12,
lineHeight: 16,
marginBottom: 4,
},
sectionBody: {
marginTop: 12,
gap: 14,
},
// ── Category Cards ────────────────────────
categoryList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
catCard: {
width: '31%',
borderRadius: 14,
paddingVertical: 14,
paddingHorizontal: 8,
alignItems: 'center',
gap: 8,
position: 'relative',
},
catIcon: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
},
catLabel: {
fontSize: 11,
fontWeight: '600',
textAlign: 'center',
lineHeight: 14,
},
catCheck: {
position: 'absolute',
top: 6,
right: 6,
width: 18,
height: 18,
borderRadius: 9,
alignItems: 'center',
justifyContent: 'center',
},
// ── Selected Banner ───────────────────────
selectedBanner: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
borderWidth: 1,
gap: 12,
},
bannerIcon: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
bannerTitle: {
fontSize: 14,
fontWeight: '700',
},
bannerSub: {
fontSize: 12,
marginTop: 1,
},
// ── Form Layout ───────────────────────────
row: {
flexDirection: 'row',
},
col: {
flex: 1,
},
errorText: {
fontSize: 12,
marginTop: 6,
fontWeight: '500',
},
// ── Review Card ───────────────────────────
reviewCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
reviewBadge: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
gap: 6,
marginBottom: 12,
},
reviewBadgeText: {
fontSize: 13,
fontWeight: '700',
},
reviewDivider: {
height: 1,
marginVertical: 10,
},
reviewRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingVertical: 6,
},
reviewLabel: {
fontSize: 13,
fontWeight: '500',
minWidth: 80,
},
reviewValue: {
fontSize: 14,
fontWeight: '600',
textAlign: 'right',
flexShrink: 1,
marginLeft: 16,
},
// ── Docs Info ─────────────────────────────
docsCard: {
flexDirection: 'row',
padding: 14,
borderRadius: 12,
borderWidth: 1,
gap: 12,
alignItems: 'flex-start',
},
docsTitle: {
fontSize: 13,
fontWeight: '700',
marginBottom: 3,
},
docsText: {
fontSize: 12,
lineHeight: 17,
},
// ── Consent ───────────────────────────────
consentBox: {
flexDirection: 'row',
padding: 14,
borderRadius: 12,
gap: 10,
alignItems: 'flex-start',
},
consentText: {
flex: 1,
fontSize: 11,
lineHeight: 16,
},
// ── Footer ────────────────────────────────
footer: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: TAB_BAR_HEIGHT + 10,
borderTopWidth: 1,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
gap: 10,
},
progressText: {
fontSize: 11,
fontWeight: '600',
minWidth: 60,
},
progressTrack: {
flex: 1,
height: 3,
borderRadius: 2,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 2,
},
});
@@ -0,0 +1,327 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import { useDeleteAgentCustomerMutation } from '../services/agentApi';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { formatAgentCurrency, formatDate, maskAadhaar, maskPhone } from '../utils/agentFormatters';
import { AgentSectionHeader } from '../components/AgentSectionHeader';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'CustomerDetail'>;
export default function CustomerDetailScreen({ route, navigation }: Props) {
const { customerId } = route.params;
const theme = useTheme();
const customer = useAppSelector(selectCustomerById(customerId));
const [deleteCustomer, { isLoading: isDeleting }] = useDeleteAgentCustomerMutation();
if (!customer) {
return (
<ScreenShell title="Customer Detail">
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: theme.colors.textMuted }}>Customer not found</Text>
</View>
</ScreenShell>
);
}
const handleDelete = () => {
Alert.alert(
'Delete Customer',
'Are you sure you want to remove this customer? This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await deleteCustomer(customer.id).unwrap();
navigation.goBack();
} catch (error) {
Alert.alert('Error', 'Failed to delete customer');
}
}
}
]
);
};
const InfoRow = ({ label, value, icon, masked = false }: { label: string, value: string, icon?: string, masked?: boolean }) => (
<View style={styles.infoRow}>
<View style={styles.infoLabelArea}>
{icon && <Ionicons name={icon as any} size={16} color={theme.colors.textMuted} style={{ marginRight: 6 }} />}
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>{label}</Text>
</View>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
{masked ? '•••• •••• ' + value.slice(-4) : value}
</Text>
</View>
);
return (
<ScreenShell title="Customer Profile" scrollable={false}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
{/* Profile Header */}
<Animated.View entering={FadeInUp.delay(100)} style={styles.headerCard}>
<View style={styles.profileTopRow}>
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
<Text style={[styles.avatarBigText, { color: theme.colors.primary }]}>
{customer.name.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.headerInfo}>
<Text style={[styles.headerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
<Text style={[styles.headerPhone, { color: theme.colors.textSecondary }]}>{maskPhone(customer.phone)}</Text>
<View style={[styles.statusChip, { backgroundColor: customer.status === 'active' ? `${theme.colors.success}15` : `${theme.colors.danger}15` }]}>
<Text style={[styles.statusText, { color: customer.status === 'active' ? theme.colors.success : theme.colors.danger }]}>
{customer.status.toUpperCase()}
</Text>
</View>
</View>
</View>
<View style={styles.actionButtonsRow}>
<Pressable style={styles.iconBtn} onPress={() => {/* Implement call */}}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.info}15` }]}>
<Ionicons name="call" size={20} color={theme.colors.info} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.info }]}>Call</Text>
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => {/* Implement SMS */}}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.success}15` }]}>
<Ionicons name="chatbubble" size={20} color={theme.colors.success} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.success }]}>SMS</Text>
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => navigation.navigate('AddEditCustomer', { customerId: customer.id })}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="create" size={20} color={theme.colors.primary} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
</Pressable>
</View>
</Animated.View>
{/* Account Details */}
<Animated.View entering={FadeInUp.delay(200)} style={styles.section}>
<AgentSectionHeader title="Account Details" />
{customer.accounts.map(acc => (
<View key={acc.id} style={[styles.dataCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.accHeaderRow}>
<Text style={[styles.accType, { color: theme.colors.textPrimary }]}>{acc.type} A/C</Text>
<Text style={[styles.accNumber, { color: theme.colors.textMuted }]}>{acc.accountNumber}</Text>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.financialRow}>
<View style={styles.finItem}>
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Monthly EMI</Text>
<Text style={[styles.finValue, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(acc.emiAmount)}</Text>
</View>
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.finItem}>
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Total Balance</Text>
<Text style={[styles.finValue, { color: theme.colors.primary }]}>{formatAgentCurrency(acc.balance)}</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.financialRow}>
<View style={styles.finItem}>
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Total Due</Text>
<Text style={[styles.finValue, { color: acc.totalDue > 0 ? theme.colors.danger : theme.colors.success }]}>
{formatAgentCurrency(acc.totalDue)}
</Text>
</View>
<Button
title="View History"
variant="ghost"
fullWidth={false}
onPress={() => navigation.navigate('CustomerInstallmentHistory', { customerId: customer.id })}
/>
</View>
</View>
))}
</Animated.View>
{/* KYC & Personal Details */}
<Animated.View entering={FadeInUp.delay(300)} style={styles.section}>
<AgentSectionHeader title="KYC & Information" />
<View style={[styles.dataCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<InfoRow label="Aadhaar" value={customer.kyc.aadhaarNumber} icon="finger-print-outline" masked />
<InfoRow label="PAN Number" value={customer.kyc.panNumber} icon="card-outline" masked />
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<InfoRow label="Joined Date" value={formatDate(customer.joinedDate)} icon="calendar-outline" />
<InfoRow label="Address" value={`${customer.address}, ${customer.city}, ${customer.state} - ${customer.pincode}`} icon="location-outline" />
</View>
</Animated.View>
{/* Delete Area */}
<View style={styles.deleteArea}>
<Button
title="Remove Customer"
variant="ghost"
loading={isDeleting}
onPress={handleDelete}
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
/>
</View>
<View style={styles.footerSpacer} />
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
padding: 16,
},
headerCard: {
marginBottom: 32,
alignItems: 'center',
},
profileTopRow: {
alignItems: 'center',
marginBottom: 24,
},
avatarBig: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
avatarBigText: {
fontSize: 32,
fontWeight: '800',
},
headerInfo: {
alignItems: 'center',
},
headerName: {
fontSize: 22,
fontWeight: '800',
marginBottom: 4,
},
headerPhone: {
fontSize: 15,
marginBottom: 8,
},
statusChip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
actionButtonsRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 32,
},
iconBtn: {
alignItems: 'center',
},
iconFrame: {
width: 50,
height: 50,
borderRadius: 25,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
iconLabel: {
fontSize: 12,
fontWeight: '600',
},
section: {
marginBottom: 24,
},
dataCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
accHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
accType: {
fontSize: 16,
fontWeight: '700',
},
accNumber: {
fontSize: 14,
fontFamily: 'monospace',
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
financialRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
finItem: {
flex: 1,
},
finLabel: {
fontSize: 12,
marginBottom: 4,
},
finValue: {
fontSize: 18,
fontWeight: '800',
},
vDivider: {
width: 1,
height: 40,
marginHorizontal: 16,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
infoLabelArea: {
flexDirection: 'row',
alignItems: 'center',
width: '40%',
},
infoLabel: {
fontSize: 13,
},
infoValue: {
fontSize: 14,
fontWeight: '600',
flex: 1,
textAlign: 'right',
},
deleteArea: {
marginTop: 16,
},
footerSpacer: {
height: 40,
},
});
@@ -0,0 +1,154 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { formatAgentCurrency, formatDateTime } from '../utils/agentFormatters';
import { AgentEmptyState } from '../components/AgentEmptyState';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'CustomerInstallmentHistory'>;
export default function CustomerInstallmentHistoryScreen({ route, navigation }: Props) {
const { customerId } = route.params;
const theme = useTheme();
const customer = useAppSelector(selectCustomerById(customerId));
if (!customer) {
return (
<ScreenShell title="Installment History">
<AgentEmptyState title="Error" description="Customer not found" />
</ScreenShell>
);
}
// Generate mock history based on totalCollected for demo purposes
const history = Array.from({ length: 10 }).map((_, i) => {
const isPaid = i < (customer.totalCollected / 500);
const date = new Date();
date.setDate(date.getDate() - (i * 30));
return {
id: `hist-${i}`,
amount: 500,
date: date.toISOString(),
status: isPaid ? 'paid' : 'pending',
receiptNo: isPaid ? `RCP-${date.getFullYear()}${String(date.getMonth()+1).padStart(2,'0')}${String(date.getDate()).padStart(2,'0')}-${1000+i}` : null,
};
});
const renderItem = ({ item, index }: { item: any, index: number }) => (
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.leftSide}>
<View style={[styles.iconFrame, { backgroundColor: item.status === 'paid' ? `${theme.colors.success}15` : `${theme.colors.warning}15` }]}>
<Ionicons
name={item.status === 'paid' ? 'checkmark-circle' : 'time'}
size={20}
color={item.status === 'paid' ? theme.colors.success : theme.colors.warning}
/>
</View>
<View>
<Text style={[styles.date, { color: theme.colors.textPrimary }]}>{formatDateTime(item.date)}</Text>
{item.receiptNo ? (
<Text style={[styles.receipt, { color: theme.colors.textMuted }]}>{item.receiptNo}</Text>
) : (
<Text style={[styles.receipt, { color: theme.colors.textMuted }]}>Pending Installment</Text>
)}
</View>
</View>
<View style={styles.rightSide}>
<Text style={[styles.amount, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.amount)}</Text>
<Text style={[styles.status, { color: item.status === 'paid' ? theme.colors.success : theme.colors.warning }]}>
{item.status.toUpperCase()}
</Text>
</View>
</Animated.View>
);
return (
<ScreenShell title="Installment History" scrollable={false}>
<View style={styles.header}>
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
<Text style={[styles.accNumber, { color: theme.colors.textMuted }]}>
{customer.accounts[0]?.type} A/C: {customer.accounts[0]?.accountNumber}
</Text>
</View>
<FlatList
data={history}
keyExtractor={item => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
header: {
padding: 16,
paddingTop: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
customerName: {
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
},
accNumber: {
fontSize: 14,
fontFamily: 'monospace',
},
listContent: {
padding: 16,
},
card: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
},
leftSide: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconFrame: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
date: {
fontSize: 14,
fontWeight: '600',
marginBottom: 2,
},
receipt: {
fontSize: 11,
},
rightSide: {
alignItems: 'flex-end',
},
amount: {
fontSize: 15,
fontWeight: '700',
marginBottom: 2,
},
status: {
fontSize: 10,
fontWeight: '700',
},
});
@@ -0,0 +1,232 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, FlatList, RefreshControl, TextInput, Pressable, Text } from 'react-native';
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { useGetAgentCustomersQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectFilteredCustomers, selectCustomerFilter, selectCustomerSearch } from '../store/agentSelectors';
import { CUSTOMER_FILTERS } from '../constants/agentConstants';
import type { CustomerFilter } from '../types/agentTypes';
import { CustomerCard } from '../components/CustomerCard';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentCustomersStackParamList, 'CustomerList'>,
BottomTabNavigationProp<AgentTabParamList>
>;
export default function CustomerListScreen({ navigation }: { navigation: NavigationProp }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const customers = useAppSelector(selectFilteredCustomers);
const activeFilter = useAppSelector(selectCustomerFilter);
const searchQuery = useAppSelector(selectCustomerSearch);
const { data, isLoading, isFetching, refetch, error } = useGetAgentCustomersQuery(undefined, {
refetchOnMountOrArgChange: true,
});
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
if (data) {
dispatch(agentActions.setCustomers(data));
}
}, [data, dispatch]);
const handleRefresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const handleSearch = (text: string) => {
dispatch(agentActions.setCustomerSearch(text));
};
const handleFilter = (filter: CustomerFilter) => {
dispatch(agentActions.setCustomerFilter(filter));
};
return (
<ScreenShell title="Customers" scrollable={false}>
{/* Search Bar */}
<View style={styles.headerArea}>
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />
<TextInput
style={[styles.searchInput, { color: theme.colors.textPrimary }]}
placeholder="Search by name or phone..."
placeholderTextColor={theme.colors.textMuted}
value={searchQuery}
onChangeText={handleSearch}
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => handleSearch('')} style={styles.clearBtn}>
<Ionicons name="close-circle" size={16} color={theme.colors.textMuted} />
</Pressable>
)}
</View>
{/* Filters */}
<View style={styles.filterArea}>
<FlatList
data={CUSTOMER_FILTERS}
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={item => item.id}
contentContainerStyle={styles.filterList}
renderItem={({ item }) => {
const isActive = activeFilter === item.id;
return (
<Pressable
style={[
styles.filterChip,
{
backgroundColor: isActive ? theme.colors.primary : theme.colors.surface,
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
}
]}
onPress={() => handleFilter(item.id)}
>
<Text style={[
styles.filterText,
{ color: isActive ? theme.colors.white : theme.colors.textSecondary }
]}>
{item.label}
</Text>
</Pressable>
);
}}
/>
</View>
</View>
{/* List Area */}
<View style={styles.listArea}>
{isLoading && customers.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : error && customers.length === 0 ? (
<AgentEmptyState
title="Unable to load customers"
description="We encountered an error while fetching your customer list."
actionTitle="Retry"
onAction={handleRefresh}
/>
) : (
<FlatList
data={customers}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} colors={[theme.colors.primary]} />
}
ListEmptyComponent={() => (
<AgentEmptyState
title="No customers found"
description={searchQuery ? `No matches for "${searchQuery}"` : "You don't have any customers in this category yet."}
icon="people-outline"
actionTitle={searchQuery ? "Clear Search" : undefined}
onAction={searchQuery ? () => handleSearch('') : undefined}
/>
)}
renderItem={({ item, index }) => (
<Animated.View entering={FadeIn.delay(Math.min(index * 50, 500))} exiting={FadeOut}>
<CustomerCard
customer={item}
onPress={() => navigation.navigate('CustomerDetail', { customerId: item.id })}
/>
</Animated.View>
)}
/>
)}
</View>
{/* FAB to add new customer */}
<Pressable
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
onPress={() => navigation.navigate('AddEditCustomer', {})}
>
<Ionicons name="add" size={24} color={theme.colors.white} />
</Pressable>
</ScreenShell>
);
}
const styles = StyleSheet.create({
headerArea: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
zIndex: 10,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
height: 48,
borderRadius: 24,
borderWidth: 1,
paddingHorizontal: 16,
marginBottom: 16,
},
searchInput: {
flex: 1,
height: '100%',
marginLeft: 8,
fontSize: 15,
},
clearBtn: {
padding: 4,
},
filterArea: {
marginBottom: 8,
},
filterList: {
gap: 8,
},
filterChip: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
borderWidth: 1,
},
filterText: {
fontSize: 13,
fontWeight: '600',
},
listArea: {
flex: 1,
},
listContent: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
fab: {
position: 'absolute',
right: 20,
bottom: TAB_BAR_HEIGHT + 20, // above tab bar
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 5,
},
});
@@ -0,0 +1,498 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, FlatList, RefreshControl, Pressable } from 'react-native';
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
import { ScreenShell, Button, Input } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { useGetTodayCollectionsQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectTodayCollections, selectTodayCollectionSummary, selectOfflineQueue } from '../store/agentSelectors';
import { formatAgentCurrency } from '../utils/agentFormatters';
import { useCollectionForm } from '../hooks/useCollectionForm';
import { COLLECTION_MODES } from '../constants/agentConstants';
import type { CollectionEntry } from '../types/agentTypes';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentCollectionsStackParamList, 'DailyCollection'>,
BottomTabNavigationProp<AgentTabParamList>
>;
const CollectionForm = ({ selectedEntry, navigation, bottomSheetRef }: { selectedEntry: CollectionEntry | null, navigation: any, bottomSheetRef: any }) => {
const theme = useTheme();
if (!selectedEntry) return null;
const { amount, setAmount, mode, setMode, error: formError, submit, isLoading: isSubmitting } = useCollectionForm(selectedEntry.id, selectedEntry.emiAmount);
const handleSubmit = async () => {
const receipt = await submit();
if (receipt) {
bottomSheetRef.current?.close();
navigation.navigate('CollectionReceipt', { entryId: receipt.id });
// In real app, we'd pass the full receipt object or fetch it there.
// For mock, we just pass ID.
} else {
// Fallback or error handled in hook
bottomSheetRef.current?.close();
}
};
return (
<View style={styles.sheetContent}>
<Text style={[styles.sheetTitle, { color: theme.colors.textPrimary }]}>Collect Payment</Text>
<View style={styles.sheetCustomerInfo}>
<Text style={[styles.sheetCustomerName, { color: theme.colors.textPrimary }]}>{selectedEntry.customerName}</Text>
<Text style={[styles.sheetCustomerMeta, { color: theme.colors.textMuted }]}>
{selectedEntry.accountType} • EMI: {formatAgentCurrency(selectedEntry.emiAmount)}
</Text>
</View>
<Input
label="Collection Amount"
value={amount}
onChangeText={setAmount}
keyboardType="number-pad"
error={formError || undefined}
leftAccessory={<Text style={{ fontSize: 18, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
/>
<Text style={[styles.modeLabel, { color: theme.colors.textMuted }]}>Payment Mode</Text>
<View style={styles.modeRow}>
{COLLECTION_MODES.map(m => (
<Pressable
key={m.id}
style={[
styles.modeChip,
{
backgroundColor: mode === m.id ? `${theme.colors.primary}15` : theme.colors.surface,
borderColor: mode === m.id ? theme.colors.primary : theme.colors.cardBorder,
}
]}
onPress={() => setMode(m.id)}
>
<Ionicons name={m.icon as any} size={16} color={mode === m.id ? theme.colors.primary : theme.colors.textMuted} />
<Text style={[styles.modeText, { color: mode === m.id ? theme.colors.primary : theme.colors.textMuted }]}>{m.label}</Text>
</Pressable>
))}
</View>
<Button
title="Confirm Collection"
onPress={handleSubmit}
loading={isSubmitting}
style={{ marginTop: 24 }}
/>
</View>
);
};
export default function DailyCollectionScreen({ navigation }: { navigation: NavigationProp }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const entries = useAppSelector(selectTodayCollections);
const summary = useAppSelector(selectTodayCollectionSummary);
const offlineQueue = useAppSelector(selectOfflineQueue);
const { data, isLoading, isFetching, refetch, error } = useGetTodayCollectionsQuery(undefined, {
refetchOnMountOrArgChange: true,
});
const [refreshing, setRefreshing] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<CollectionEntry | null>(null);
const [filter, setFilter] = useState<'all' | 'pending' | 'collected'>('all');
const filteredEntries = entries.filter(entry => {
if (filter === 'all') return true;
if (filter === 'collected') return entry.status === 'collected' || entry.status === 'partial';
if (filter === 'pending') return entry.status === 'pending';
return true;
});
const bottomSheetRef = React.useRef<BottomSheet>(null);
useEffect(() => {
if (data) {
dispatch(agentActions.setTodayCollections(data));
}
}, [data, dispatch]);
const handleRefresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const handleEntryPress = (entry: CollectionEntry) => {
if (entry.status !== 'pending') return; // Already collected
setSelectedEntry(entry);
bottomSheetRef.current?.expand();
};
return (
<ScreenShell title="Daily Collection" scrollable={false}>
<View style={styles.header}>
{/* Summary Dashboard */}
<Animated.View entering={FadeIn} style={[styles.summaryCard, { backgroundColor: theme.colors.primary }]}>
<View style={styles.summaryRow}>
<View>
<Text style={styles.summaryLabel}>Total Collected Today</Text>
<Text style={styles.summaryValue}>{formatAgentCurrency(summary.totalCollected)}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={styles.summaryLabel}>Completion</Text>
<Text style={styles.summaryValue}>{summary.collectedCount}/{summary.total}</Text>
</View>
</View>
<View style={styles.progressTrack}>
<View style={[styles.progressBar, { width: `${summary.total > 0 ? (summary.collectedCount / summary.total) * 100 : 0}%` }]} />
</View>
</Animated.View>
{offlineQueue.length > 0 && (
<View style={[styles.offlineBanner, { backgroundColor: theme.colors.warning }]}>
<Ionicons name="cloud-offline" size={16} color="#fff" />
<Text style={styles.offlineText}>{offlineQueue.length} entries waiting to sync</Text>
</View>
)}
</View>
<View style={styles.listArea}>
<View style={styles.listHeader}>
<Text style={[styles.listTitle, { color: theme.colors.textPrimary }]}>Today's Schedule</Text>
<Pressable onPress={() => navigation.navigate('CollectionHistory')}>
<Text style={[styles.historyLink, { color: theme.colors.primary }]}>History</Text>
</Pressable>
</View>
<View style={styles.filterRow}>
{(['all', 'pending', 'collected'] as const).map(f => (
<Pressable
key={f}
style={[
styles.filterChip,
{
backgroundColor: filter === f ? theme.colors.primary : theme.colors.surface,
borderColor: filter === f ? theme.colors.primary : theme.colors.cardBorder,
}
]}
onPress={() => setFilter(f)}
>
<Text style={[
styles.filterText,
{ color: filter === f ? '#fff' : theme.colors.textSecondary }
]}>
{f === 'all' ? 'All' : f === 'pending' ? 'Pending' : 'Collected'}
</Text>
</Pressable>
))}
</View>
{isLoading && entries.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : error && entries.length === 0 ? (
<AgentEmptyState
title="Unable to load schedule"
description="We encountered an error while fetching your daily collections."
actionTitle="Retry"
onAction={handleRefresh}
/>
) : (
<FlatList
data={filteredEntries}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} colors={[theme.colors.primary]} />
}
ListEmptyComponent={() => (
<AgentEmptyState
title="No collections today"
description="Your schedule for today is empty."
icon="calendar-outline"
/>
)}
renderItem={({ item, index }) => {
const isCollected = item.status === 'collected';
const isPartial = item.status === 'partial';
return (
<Animated.View entering={SlideInDown.delay(index * 50)}>
<Pressable
style={[
styles.entryCard,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
opacity: isCollected ? 0.7 : 1,
}
]}
onPress={() => handleEntryPress(item)}
>
<View style={styles.entryLeft}>
<View style={[styles.avatarBox, { backgroundColor: `${theme.colors.primary}15` }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{item.customerName.charAt(0)}</Text>
</View>
<View>
<Text style={[styles.entryName, { color: theme.colors.textPrimary }]}>{item.customerName}</Text>
<Text style={[styles.entryMeta, { color: theme.colors.textMuted }]}>{item.accountType} • EMI: {formatAgentCurrency(item.emiAmount)}</Text>
</View>
</View>
<View style={styles.entryRight}>
{isCollected || isPartial ? (
<>
<Text style={[styles.entryAmount, { color: theme.colors.success }]}>{formatAgentCurrency(item.collectedAmount)}</Text>
<View style={[styles.statusBadge, { backgroundColor: `${theme.colors.success}15` }]}>
<Text style={[styles.statusText, { color: theme.colors.success }]}>Collected</Text>
</View>
</>
) : (
<View style={[styles.collectBtn, { backgroundColor: theme.colors.primary }]}>
<Text style={styles.collectBtnText}>Collect</Text>
</View>
)}
</View>
</Pressable>
</Animated.View>
);
}}
/>
)}
</View>
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={['50%', '65%']}
enablePanDownToClose
backgroundStyle={{ backgroundColor: theme.colors.surface }}
handleIndicatorStyle={{ backgroundColor: theme.colors.cardBorder }}
>
<BottomSheetView style={styles.sheetContainer}>
<CollectionForm selectedEntry={selectedEntry} navigation={navigation} bottomSheetRef={bottomSheetRef} />
</BottomSheetView>
</BottomSheet>
</ScreenShell>
);
}
const styles = StyleSheet.create({
header: {
paddingHorizontal: 16,
paddingTop: 16,
zIndex: 10,
},
summaryCard: {
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
},
summaryLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
summaryValue: {
color: '#fff',
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.5,
},
progressTrack: {
height: 6,
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 3,
overflow: 'hidden',
},
progressBar: {
height: '100%',
backgroundColor: '#fff',
borderRadius: 3,
},
offlineBanner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
borderRadius: 8,
marginTop: 12,
gap: 8,
},
offlineText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
listArea: {
flex: 1,
marginTop: 16,
},
listHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 12,
},
listTitle: {
fontSize: 16,
fontWeight: '700',
},
historyLink: {
fontSize: 14,
fontWeight: '600',
},
filterRow: {
flexDirection: 'row',
paddingHorizontal: 16,
marginBottom: 16,
gap: 8,
},
filterChip: {
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
},
filterText: {
fontSize: 12,
fontWeight: '600',
},
listContent: {
padding: 16,
paddingTop: 0,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
entryCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
},
entryLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatarBox: {
width: 44,
height: 44,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
fontSize: 18,
fontWeight: '700',
},
entryName: {
fontSize: 15,
fontWeight: '700',
marginBottom: 2,
},
entryMeta: {
fontSize: 12,
},
entryRight: {
alignItems: 'flex-end',
gap: 4,
},
collectBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
collectBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
entryAmount: {
fontSize: 15,
fontWeight: '800',
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
sheetContainer: {
flex: 1,
padding: 24,
},
sheetContent: {
flex: 1,
},
sheetTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 24,
},
sheetCustomerInfo: {
marginBottom: 24,
},
sheetCustomerName: {
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
},
sheetCustomerMeta: {
fontSize: 14,
},
modeLabel: {
fontSize: 13,
fontWeight: '600',
marginBottom: 12,
marginTop: 16,
},
modeRow: {
flexDirection: 'row',
gap: 12,
},
modeChip: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
gap: 6,
},
modeText: {
fontSize: 13,
fontWeight: '600',
},
});
@@ -0,0 +1,183 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, FlatList, Pressable, RefreshControl } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { SlideInRight } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useGetAgentLoanRequestsQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectLoanRequests } from '../store/agentSelectors';
import { formatAgentCurrency, formatDate } from '../utils/agentFormatters';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
export default function LoanRequestListScreen({ navigation }: { navigation: NativeStackNavigationProp<AgentLoansStackParamList> }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const requests = useAppSelector(selectLoanRequests);
const { data, isLoading, refetch, isFetching } = useGetAgentLoanRequestsQuery();
useEffect(() => {
if (data) dispatch(agentActions.setLoanRequests(data));
}, [data, dispatch]);
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Recent Requests</Text>
<Pressable
style={[styles.newBtn, { backgroundColor: `${theme.colors.primary}15` }]}
onPress={() => navigation.navigate('CreateLoanRequest', {})}
>
<Ionicons name="add" size={16} color={theme.colors.primary} />
<Text style={[styles.newBtnText, { color: theme.colors.primary }]}>New Request</Text>
</Pressable>
</View>
{isLoading && requests.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : (
<FlatList
data={requests}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
ListEmptyComponent={() => (
<AgentEmptyState
title="No Loan Requests"
description="You haven't submitted any loan requests yet."
icon="document-text-outline"
/>
)}
renderItem={({ item, index }) => {
const isApproved = item.status === 'approved' || item.status === 'disbursed';
const isRejected = item.status === 'rejected';
const statusColor = isApproved ? theme.colors.success : isRejected ? theme.colors.danger : theme.colors.warning;
return (
<Animated.View entering={SlideInRight.delay(index * 50)}>
<Pressable style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.cardHeader}>
<View style={styles.customerInfo}>
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{item.customerName}</Text>
<Text style={[styles.category, { color: theme.colors.textMuted }]}>{item.category} Loan</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: `${statusColor}15` }]}>
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.replace('_', ' ').toUpperCase()}</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.cardBody}>
<View>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Amount</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.amount)}</Text>
</View>
<View>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Tenure</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{item.tenureMonths} mos</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Applied On</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatDate(item.appliedDate)}</Text>
</View>
</View>
</Pressable>
</Animated.View>
);
}}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 12,
},
title: {
fontSize: 16,
fontWeight: '700',
},
newBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
gap: 4,
},
newBtnText: {
fontSize: 12,
fontWeight: '700',
},
listContent: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
marginBottom: 12,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
customerInfo: {
flex: 1,
},
customerName: {
fontSize: 16,
fontWeight: '700',
marginBottom: 2,
},
category: {
fontSize: 12,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
cardBody: {
flexDirection: 'row',
justifyContent: 'space-between',
},
label: {
fontSize: 11,
marginBottom: 4,
},
value: {
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,134 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
import { useGetSmsLogsQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectSmsLogs } from '../store/agentSelectors';
import { formatDateTime } from '../utils/agentFormatters';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
type Props = NativeStackScreenProps<AgentProfileStackParamList, 'SmsHistory'>;
export default function SmsHistoryScreen({ navigation }: Props) {
const theme = useTheme();
const dispatch = useAppDispatch();
const logs = useAppSelector(selectSmsLogs);
const { data, isLoading, refetch, isFetching } = useGetSmsLogsQuery();
useEffect(() => {
if (data) dispatch(agentActions.setSmsLogs(data));
}, [data, dispatch]);
return (
<ScreenShell title="SMS Logs" scrollable={false}>
<View style={styles.container}>
{isLoading && logs.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : (
<FlatList
data={logs}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
ListEmptyComponent={() => (
<AgentEmptyState
title="No SMS Logs"
description="There is no record of SMS messages sent."
icon="chatbubble-outline"
/>
)}
renderItem={({ item, index }) => {
const isDelivered = item.status === 'delivered';
const isFailed = item.status === 'failed';
const statusColor = isDelivered ? theme.colors.success : isFailed ? theme.colors.danger : theme.colors.warning;
const statusIcon = isDelivered ? 'checkmark-done' : isFailed ? 'close-circle' : 'time';
return (
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.cardHeader}>
<View style={styles.headerLeft}>
<Ionicons name="chatbubble-ellipses" size={16} color={theme.colors.primary} />
<Text style={[styles.phone, { color: theme.colors.textPrimary }]}>{item.customerPhone}</Text>
</View>
<View style={styles.headerRight}>
<Ionicons name={statusIcon} size={14} color={statusColor} />
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.toUpperCase()}</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<Text style={[styles.message, { color: theme.colors.textSecondary }]}>{item.message}</Text>
<Text style={[styles.date, { color: theme.colors.textMuted }]}>{formatDateTime(item.sentAt)}</Text>
</Animated.View>
);
}}
/>
)}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
listContent: {
padding: 16,
},
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
marginBottom: 12,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
phone: {
fontSize: 14,
fontWeight: '700',
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginBottom: 12,
},
message: {
fontSize: 13,
lineHeight: 18,
marginBottom: 8,
},
date: {
fontSize: 11,
textAlign: 'right',
},
});