Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user