Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import type { LoanDocument } from '../../../types/app';
|
||||
|
||||
interface DocumentItemProps {
|
||||
document: LoanDocument;
|
||||
onReupload?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; icon: string; label: string }> = {
|
||||
uploaded: { color: '#3B82F6', icon: 'time-outline', label: 'Processing' },
|
||||
verified: { color: '#10B981', icon: 'checkmark-circle', label: 'Verified' },
|
||||
rejected: { color: '#EF4444', icon: 'close-circle', label: 'Rejected' },
|
||||
};
|
||||
|
||||
const DocumentItemComponent = ({ document, onReupload }: DocumentItemProps) => {
|
||||
const theme = useTheme();
|
||||
const status = STATUS_CONFIG[document.status] || {
|
||||
color: '#3B82F6',
|
||||
icon: 'time-outline',
|
||||
label: 'Processing',
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.item, { borderColor: theme.colors.cardBorder, backgroundColor: theme.colors.surface }]}>
|
||||
<View style={[styles.icon, { backgroundColor: `${status.color}14` }]}>
|
||||
<Ionicons name="document-text-outline" size={20} color={status.color} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{document.name}</Text>
|
||||
<Text style={[styles.meta, { color: theme.colors.textMuted }]}>
|
||||
{document.type} · {formatDate(document.uploadedAt)}
|
||||
</Text>
|
||||
{document.rejectionReason && (
|
||||
<Text style={[styles.reason, { color: '#EF4444' }]}>{document.rejectionReason}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${status.color}14` }]}>
|
||||
<Ionicons name={status.icon as any} size={14} color={status.color} />
|
||||
<Text style={[styles.statusText, { color: status.color }]}>{status.label}</Text>
|
||||
</View>
|
||||
{document.status === 'rejected' && onReupload && (
|
||||
<Pressable onPress={onReupload} style={styles.reuploadBtn}>
|
||||
<Text style={[styles.reuploadText, { color: theme.colors.primary }]}>Re-upload</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
},
|
||||
icon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: { flex: 1, gap: 3 },
|
||||
name: { fontSize: 14, fontWeight: '700' },
|
||||
meta: { fontSize: 12 },
|
||||
reason: { fontSize: 11, marginTop: 2, lineHeight: 16 },
|
||||
right: { alignItems: 'flex-end', gap: 6 },
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
},
|
||||
statusText: { fontSize: 11, fontWeight: '700' },
|
||||
reuploadBtn: { paddingHorizontal: 4, paddingVertical: 2 },
|
||||
reuploadText: { fontSize: 12, fontWeight: '700' },
|
||||
});
|
||||
|
||||
export const DocumentItem = memo(DocumentItemComponent);
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { Card } from '../../../components';
|
||||
import type { LoanEligibilityResult } from '../../../types/app';
|
||||
|
||||
interface EligibilityResultCardProps {
|
||||
result: LoanEligibilityResult;
|
||||
}
|
||||
|
||||
const EligibilityResultCardComponent = ({ result }: EligibilityResultCardProps) => {
|
||||
const theme = useTheme();
|
||||
const isEligible = result.eligible;
|
||||
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: isEligible ? '#10B98114' : '#EF444414' },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={isEligible ? 'checkmark-circle' : 'close-circle'}
|
||||
size={32}
|
||||
color={isEligible ? '#10B981' : '#EF4444'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>
|
||||
{isEligible ? 'You are eligible!' : 'Not eligible'}
|
||||
</Text>
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
{isEligible
|
||||
? 'Based on your income and profile, you qualify for the following loan.'
|
||||
: result.reason ?? 'Your current profile does not meet the eligibility criteria.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isEligible && (
|
||||
<View style={[styles.detailsGrid, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>Eligible Amount</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(result.eligibleAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>Interest Rate</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{result.interestRate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>Max Tenure</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{result.maxTenureMonths} months
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>Estimated EMI</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.primary }]}>
|
||||
{formatCurrency(result.estimatedEmi)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { gap: 16 },
|
||||
header: { alignItems: 'center', gap: 8 },
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: { fontSize: 20, fontWeight: '900' },
|
||||
subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 20 },
|
||||
detailsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
gap: 12,
|
||||
},
|
||||
detailItem: { width: '45%', gap: 4 },
|
||||
detailLabel: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
detailValue: { fontSize: 16, fontWeight: '800' },
|
||||
});
|
||||
|
||||
export const EligibilityResultCard = memo(EligibilityResultCardComponent);
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import type { LoanEmiScheduleItem } from '../../../types/app';
|
||||
|
||||
interface EmiScheduleRowProps {
|
||||
item: LoanEmiScheduleItem;
|
||||
isHeader?: boolean;
|
||||
}
|
||||
|
||||
const EmiScheduleRowComponent = ({ item, isHeader }: EmiScheduleRowProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (isHeader) {
|
||||
return (
|
||||
<View style={[styles.row, styles.headerRow, { borderBottomColor: theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.cell, styles.monthCell, styles.headerText, { color: theme.colors.textSecondary }]}>Month</Text>
|
||||
<Text style={[styles.cell, styles.headerText, { color: theme.colors.textSecondary }]}>EMI</Text>
|
||||
<Text style={[styles.cell, styles.headerText, { color: theme.colors.textSecondary }]}>Principal</Text>
|
||||
<Text style={[styles.cell, styles.headerText, { color: theme.colors.textSecondary }]}>Interest</Text>
|
||||
<Text style={[styles.cell, styles.headerText, { color: theme.colors.textSecondary }]}>Balance</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.row, { borderBottomColor: theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.cell, styles.monthCell, { color: theme.colors.textPrimary }]}>{item.month}</Text>
|
||||
<Text style={[styles.cell, { color: theme.colors.textPrimary }]}>{formatCurrency(item.emiAmount)}</Text>
|
||||
<Text style={[styles.cell, { color: theme.colors.success }]}>{formatCurrency(item.principalComponent)}</Text>
|
||||
<Text style={[styles.cell, { color: theme.colors.warning }]}>{formatCurrency(item.interestComponent)}</Text>
|
||||
<Text style={[styles.cell, { color: theme.colors.textSecondary }]}>{formatCurrency(item.outstandingAfter)}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
headerRow: {
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 2,
|
||||
},
|
||||
cell: {
|
||||
flex: 1,
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
textAlign: 'right',
|
||||
},
|
||||
monthCell: {
|
||||
flex: 0.5,
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
|
||||
export const EmiScheduleRow = memo(EmiScheduleRowComponent);
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { Card, Button } from '../../../components';
|
||||
import type { LoanForeclosureQuote } from '../../../types/app';
|
||||
|
||||
interface ForeclosureSheetProps {
|
||||
quote: LoanForeclosureQuote;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ForeclosureSheetComponent = ({ quote, onConfirm, onCancel, isLoading }: ForeclosureSheetProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.icon, { backgroundColor: '#F59E0B14' }]}>
|
||||
<Ionicons name="warning-outline" size={24} color="#F59E0B" />
|
||||
</View>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Foreclosure Summary</Text>
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
Review the charges before proceeding with early closure.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.breakdown, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View style={styles.breakdownRow}>
|
||||
<Text style={[styles.breakdownLabel, { color: theme.colors.textSecondary }]}>Outstanding Principal</Text>
|
||||
<Text style={[styles.breakdownValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(quote.outstandingPrincipal)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.breakdownRow}>
|
||||
<Text style={[styles.breakdownLabel, { color: theme.colors.textSecondary }]}>Interest Due</Text>
|
||||
<Text style={[styles.breakdownValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(quote.interestDue)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.breakdownRow}>
|
||||
<Text style={[styles.breakdownLabel, { color: theme.colors.textSecondary }]}>Foreclosure Charges</Text>
|
||||
<Text style={[styles.breakdownValue, { color: theme.colors.warning }]}>
|
||||
{formatCurrency(quote.foreclosureCharges)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.totalRow, { borderTopColor: theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.totalLabel, { color: theme.colors.textPrimary }]}>Total Payable</Text>
|
||||
<Text style={[styles.totalValue, { color: theme.colors.primary }]}>
|
||||
{formatCurrency(quote.totalPayable)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button title="Proceed with Foreclosure" onPress={onConfirm} loading={isLoading} variant="danger" />
|
||||
<Button title="Cancel" onPress={onCancel} variant="ghost" />
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { gap: 16 },
|
||||
header: { alignItems: 'center', gap: 8 },
|
||||
icon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: { fontSize: 20, fontWeight: '900' },
|
||||
subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 20 },
|
||||
breakdown: { borderRadius: 16, padding: 16, gap: 12 },
|
||||
breakdownRow: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
breakdownLabel: { fontSize: 13, fontWeight: '600' },
|
||||
breakdownValue: { fontSize: 14, fontWeight: '700' },
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
totalLabel: { fontSize: 15, fontWeight: '800' },
|
||||
totalValue: { fontSize: 18, fontWeight: '900' },
|
||||
actions: { gap: 10 },
|
||||
});
|
||||
|
||||
export const ForeclosureSheet = memo(ForeclosureSheetComponent);
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { ActiveLoan } from '../../../types/app';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { Card } from '../../../components';
|
||||
|
||||
interface LoanAccountCardProps {
|
||||
loan: ActiveLoan;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; icon: string }> = {
|
||||
Active: { color: '#10B981', icon: 'checkmark-circle' },
|
||||
Overdue: { color: '#EF4444', icon: 'alert-circle' },
|
||||
Closed: { color: '#94A3B8', icon: 'close-circle' },
|
||||
};
|
||||
|
||||
const LoanAccountCardComponent = ({ loan, onPress }: LoanAccountCardProps) => {
|
||||
const theme = useTheme();
|
||||
const statusConfig = STATUS_CONFIG[loan.status] || {
|
||||
color: '#10B981',
|
||||
icon: 'checkmark-circle',
|
||||
};
|
||||
const progress = loan.totalEmis > 0 ? (loan.paidEmis / loan.totalEmis) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={[styles.loanType, { color: theme.colors.textPrimary }]}>{loan.type}</Text>
|
||||
<Text style={[styles.accountNumber, { color: theme.colors.textMuted }]}>
|
||||
{loan.loanAccountNumber}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${statusConfig.color}14` }]}>
|
||||
<Ionicons name={statusConfig.icon as any} size={14} color={statusConfig.color} />
|
||||
<Text style={[styles.statusText, { color: statusConfig.color }]}>{loan.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.amountRow, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View style={styles.amountItem}>
|
||||
<Text style={[styles.amountLabel, { color: theme.colors.textMuted }]}>Outstanding</Text>
|
||||
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(loan.outstandingBalance)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.amountItem}>
|
||||
<Text style={[styles.amountLabel, { color: theme.colors.textMuted }]}>EMI</Text>
|
||||
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(loan.emiAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress bar */}
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressHeader}>
|
||||
<Text style={[styles.progressLabel, { color: theme.colors.textSecondary }]}>
|
||||
{loan.paidEmis} of {loan.totalEmis} EMIs paid
|
||||
</Text>
|
||||
<Text style={[styles.progressPercent, { color: theme.colors.primary }]}>
|
||||
{Math.round(progress)}%
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.progressTrack, { backgroundColor: theme.colors.cardBorder }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: `${Math.min(progress, 100)}%`,
|
||||
backgroundColor: loan.status === 'Overdue' ? '#EF4444' : theme.colors.primary,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerItem}>
|
||||
<Ionicons name="calendar-outline" size={14} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.footerText, { color: theme.colors.textSecondary }]}>
|
||||
Next EMI: {formatDate(loan.nextEmiDate)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.footerItem}>
|
||||
<Ionicons name="trending-down-outline" size={14} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.footerText, { color: theme.colors.textSecondary }]}>
|
||||
{loan.interestRate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { gap: 14 },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' },
|
||||
headerLeft: { gap: 2 },
|
||||
loanType: { fontSize: 16, fontWeight: '800' },
|
||||
accountNumber: { fontSize: 12, fontWeight: '600' },
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
},
|
||||
statusText: { fontSize: 12, fontWeight: '700' },
|
||||
amountRow: { flexDirection: 'row', borderRadius: 14, padding: 14, gap: 12 },
|
||||
amountItem: { flex: 1, gap: 4 },
|
||||
amountLabel: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
amountValue: { fontSize: 16, fontWeight: '900' },
|
||||
progressSection: { gap: 8 },
|
||||
progressHeader: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
progressLabel: { fontSize: 12, fontWeight: '600' },
|
||||
progressPercent: { fontSize: 12, fontWeight: '800' },
|
||||
progressTrack: { height: 6, borderRadius: 3, overflow: 'hidden' },
|
||||
progressFill: { height: '100%', borderRadius: 3 },
|
||||
footer: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
footerItem: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
footerText: { fontSize: 12, fontWeight: '600' },
|
||||
});
|
||||
|
||||
export const LoanAccountCard = memo(LoanAccountCardComponent);
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text } from 'react-native';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface LoanFilterChipProps {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const LoanFilterChipComponent = ({ label, selected, onPress }: LoanFilterChipProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: selected ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: selected ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{ color: selected ? theme.colors.white : theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export const LoanFilterChip = memo(LoanFilterChipComponent);
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { LoanNotification } from '../../../types/app';
|
||||
|
||||
interface LoanNotificationItemProps {
|
||||
notification: LoanNotification;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: string; color: string }> = {
|
||||
emi_reminder: { icon: 'alarm-outline', color: '#F59E0B' },
|
||||
payment_success: { icon: 'checkmark-circle-outline', color: '#10B981' },
|
||||
payment_failure: { icon: 'close-circle-outline', color: '#EF4444' },
|
||||
approval: { icon: 'ribbon-outline', color: '#3B82F6' },
|
||||
rejection: { icon: 'sad-outline', color: '#EF4444' },
|
||||
disbursement: { icon: 'cash-outline', color: '#10B981' },
|
||||
};
|
||||
|
||||
const LoanNotificationItemComponent = ({ notification, onPress }: LoanNotificationItemProps) => {
|
||||
const theme = useTheme();
|
||||
const config = TYPE_CONFIG[notification.type] || {
|
||||
icon: 'alarm-outline',
|
||||
color: '#F59E0B',
|
||||
};
|
||||
const timeAgo = getTimeAgo(notification.createdAt);
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} style={[styles.item, !notification.read && { backgroundColor: `${theme.colors.primary}04` }]}>
|
||||
<View style={[styles.icon, { backgroundColor: `${config.color}14` }]}>
|
||||
<Ionicons name={config.icon as any} size={20} color={config.color} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
{!notification.read && <View style={[styles.unreadDot, { backgroundColor: theme.colors.primary }]} />}
|
||||
</View>
|
||||
<Text style={[styles.body, { color: theme.colors.textSecondary }]} numberOfLines={2}>
|
||||
{notification.body}
|
||||
</Text>
|
||||
<Text style={[styles.time, { color: theme.colors.textMuted }]}>{timeAgo}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
function getTimeAgo(dateString: string): string {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return new Intl.DateTimeFormat('en-IN', { day: '2-digit', month: 'short' }).format(date);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
},
|
||||
icon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: { flex: 1, gap: 3 },
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
title: { fontSize: 14, fontWeight: '700', flex: 1 },
|
||||
unreadDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
body: { fontSize: 12, lineHeight: 18 },
|
||||
time: { fontSize: 11, marginTop: 2 },
|
||||
});
|
||||
|
||||
export const LoanNotificationItem = memo(LoanNotificationItemComponent);
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { LoanProduct } from '../../../types/app';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency } from '../../../utils/formatters';
|
||||
import { Card } from '../../../components';
|
||||
|
||||
interface LoanOfferCardProps {
|
||||
product: LoanProduct;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
'Personal Loan': 'person-outline',
|
||||
'Home Loan': 'home-outline',
|
||||
'Car Loan': 'car-outline',
|
||||
'Gold Loan': 'diamond-outline',
|
||||
'Education Loan': 'school-outline',
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
'Personal Loan': '#6366F1',
|
||||
'Home Loan': '#3B82F6',
|
||||
'Car Loan': '#F59E0B',
|
||||
'Gold Loan': '#EAB308',
|
||||
'Education Loan': '#10B981',
|
||||
};
|
||||
|
||||
const LoanOfferCardComponent = ({ product, onPress }: LoanOfferCardProps) => {
|
||||
const theme = useTheme();
|
||||
const iconName = CATEGORY_ICONS[product.category] ?? 'cash-outline';
|
||||
const accentColor = CATEGORY_COLORS[product.category] ?? theme.colors.primary;
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: `${accentColor}14` }]}>
|
||||
<Ionicons name={iconName as any} size={22} color={accentColor} />
|
||||
</View>
|
||||
<View style={styles.headerCopy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{product.name}</Text>
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]} numberOfLines={2}>
|
||||
{product.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.rateRow, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View style={styles.rateItem}>
|
||||
<Text style={[styles.rateLabel, { color: theme.colors.textMuted }]}>Interest</Text>
|
||||
<Text style={[styles.rateValue, { color: theme.colors.textPrimary }]}>{product.interestRate}%</Text>
|
||||
</View>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<View style={styles.rateItem}>
|
||||
<Text style={[styles.rateLabel, { color: theme.colors.textMuted }]}>Max Amount</Text>
|
||||
<Text style={[styles.rateValue, { color: theme.colors.textPrimary }]}>{formatCompactCurrency(product.maxAmount)}</Text>
|
||||
</View>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<View style={styles.rateItem}>
|
||||
<Text style={[styles.rateLabel, { color: theme.colors.textMuted }]}>Tenure</Text>
|
||||
<Text style={[styles.rateValue, { color: theme.colors.textPrimary }]}>
|
||||
{product.tenureMonths >= 12 ? `${Math.floor(product.tenureMonths / 12)}Y` : `${product.tenureMonths}M`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{product.features.length > 0 && (
|
||||
<View style={styles.features}>
|
||||
{product.features.slice(0, 3).map((feature, index) => (
|
||||
<View key={index} style={[styles.featureBadge, { backgroundColor: `${accentColor}10` }]}>
|
||||
<Text style={[styles.featureText, { color: accentColor }]}>{feature}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { gap: 14 },
|
||||
header: { flexDirection: 'row', gap: 12 },
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerCopy: { flex: 1, gap: 4 },
|
||||
title: { fontSize: 16, fontWeight: '800' },
|
||||
description: { fontSize: 12, lineHeight: 18 },
|
||||
rateRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
},
|
||||
rateItem: { flex: 1, alignItems: 'center', gap: 4 },
|
||||
rateLabel: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
rateValue: { fontSize: 16, fontWeight: '900' },
|
||||
divider: { width: 1, height: 28 },
|
||||
features: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
featureBadge: { borderRadius: 999, paddingHorizontal: 10, paddingVertical: 5 },
|
||||
featureText: { fontSize: 11, fontWeight: '700' },
|
||||
});
|
||||
|
||||
export const LoanOfferCard = memo(LoanOfferCardComponent);
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { Card } from '../../../components';
|
||||
import type { ActiveLoan } from '../../../types/app';
|
||||
|
||||
interface LoanSummaryHeroProps {
|
||||
totalOutstanding: number;
|
||||
nextEmi: ActiveLoan | null;
|
||||
summary: {
|
||||
activeCount: number;
|
||||
overdueCount: number;
|
||||
totalLoans: number;
|
||||
totalEmiDue: number;
|
||||
};
|
||||
}
|
||||
|
||||
const LoanSummaryHeroComponent = ({ totalOutstanding, nextEmi, summary }: LoanSummaryHeroProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.heroIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="wallet-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.eyebrow, { color: theme.colors.primary }]}>Loan Portfolio</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
Manage your active loans, payments, and applications
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.highlight, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View style={styles.highlightCopy}>
|
||||
<Text style={[styles.highlightLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total Outstanding
|
||||
</Text>
|
||||
<Text style={[styles.highlightValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(totalOutstanding)}
|
||||
</Text>
|
||||
<Text style={[styles.highlightCaption, { color: theme.colors.textMuted }]}>
|
||||
{summary.activeCount} active · {summary.overdueCount} overdue
|
||||
</Text>
|
||||
</View>
|
||||
{summary.overdueCount > 0 && (
|
||||
<View style={[styles.alertBadge, { backgroundColor: '#EF444414' }]}>
|
||||
<Text style={[styles.alertBadgeValue, { color: '#EF4444' }]}>{summary.overdueCount}</Text>
|
||||
<Text style={[styles.alertBadgeLabel, { color: theme.colors.textSecondary }]}>overdue</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.metrics}>
|
||||
<View style={[styles.metricCard, { backgroundColor: `${theme.colors.primary}08` }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>Next EMI</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{nextEmi ? formatCurrency(nextEmi.emiAmount) : '—'}
|
||||
</Text>
|
||||
<Text style={[styles.metricCaption, { color: theme.colors.textMuted }]}>
|
||||
{nextEmi ? `Due ${formatDate(nextEmi.nextEmiDate)}` : 'No upcoming EMIs'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>Monthly EMIs</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(summary.totalEmiDue)}
|
||||
</Text>
|
||||
<Text style={[styles.metricCaption, { color: theme.colors.textMuted }]}>
|
||||
Combined monthly outflow
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { gap: 16 },
|
||||
header: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
|
||||
heroIcon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroCopy: { flex: 1, gap: 4 },
|
||||
eyebrow: { fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.8 },
|
||||
heroTitle: { fontSize: 18, fontWeight: '900', lineHeight: 24 },
|
||||
highlight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
},
|
||||
highlightCopy: { flex: 1, gap: 4 },
|
||||
highlightLabel: { fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.8 },
|
||||
highlightValue: { fontSize: 26, fontWeight: '900', letterSpacing: -0.8 },
|
||||
highlightCaption: { fontSize: 12, lineHeight: 18 },
|
||||
alertBadge: {
|
||||
minWidth: 72,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
alertBadgeValue: { fontSize: 22, fontWeight: '900', letterSpacing: -0.5 },
|
||||
alertBadgeLabel: { fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
metrics: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
|
||||
metricCard: { flex: 1, minWidth: 140, borderRadius: 18, padding: 14, gap: 6 },
|
||||
metricLabel: { fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.8 },
|
||||
metricValue: { fontSize: 18, fontWeight: '900', letterSpacing: -0.4 },
|
||||
metricCaption: { fontSize: 12, lineHeight: 18 },
|
||||
});
|
||||
|
||||
export const LoanSummaryHero = memo(LoanSummaryHeroComponent);
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import { Card } from '../../../components';
|
||||
|
||||
interface TimelineStep {
|
||||
stage: string;
|
||||
timestamp: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface LoanTimelineCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
timeline: TimelineStep[];
|
||||
}
|
||||
|
||||
const LoanTimelineCardComponent = ({ title, subtitle, timeline }: LoanTimelineCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{subtitle && (
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>{subtitle}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.timeline}>
|
||||
{timeline.map((step, index) => {
|
||||
const isLast = index === timeline.length - 1;
|
||||
const isCompleted = step.active && step.timestamp !== '';
|
||||
const isCurrent = step.active && step.timestamp === '' && index > 0
|
||||
? false
|
||||
: step.active && !timeline[index + 1]?.active;
|
||||
const isPending = !step.active;
|
||||
|
||||
let dotColor = theme.colors.textMuted;
|
||||
let iconName = 'ellipse-outline';
|
||||
if (isCompleted) {
|
||||
dotColor = theme.colors.success;
|
||||
iconName = 'checkmark-circle';
|
||||
} else if (isCurrent) {
|
||||
dotColor = theme.colors.primary;
|
||||
iconName = 'radio-button-on';
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={index} style={styles.stepRow}>
|
||||
<View style={styles.stepIndicator}>
|
||||
<Ionicons name={iconName as any} size={20} color={dotColor} />
|
||||
{!isLast && (
|
||||
<View
|
||||
style={[
|
||||
styles.connector,
|
||||
{
|
||||
backgroundColor: isCompleted ? theme.colors.success : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.stepContent, !isLast && styles.stepContentSpaced]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.stepLabel,
|
||||
{
|
||||
color: isPending ? theme.colors.textMuted : theme.colors.textPrimary,
|
||||
fontWeight: isCurrent ? '800' : '600',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{step.stage}
|
||||
</Text>
|
||||
{step.timestamp ? (
|
||||
<Text style={[styles.stepTime, { color: theme.colors.textMuted }]}>
|
||||
{formatDate(step.timestamp)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[styles.stepTime, { color: theme.colors.textMuted }]}>Pending</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { gap: 14 },
|
||||
header: { gap: 4 },
|
||||
title: { fontSize: 16, fontWeight: '800' },
|
||||
subtitle: { fontSize: 13, lineHeight: 18 },
|
||||
timeline: { gap: 0 },
|
||||
stepRow: { flexDirection: 'row', gap: 12 },
|
||||
stepIndicator: { alignItems: 'center', width: 24 },
|
||||
connector: { width: 2, flex: 1, minHeight: 20 },
|
||||
stepContent: { flex: 1, paddingTop: 1 },
|
||||
stepContentSpaced: { paddingBottom: 16 },
|
||||
stepLabel: { fontSize: 14 },
|
||||
stepTime: { fontSize: 12, marginTop: 2 },
|
||||
});
|
||||
|
||||
export const LoanTimelineCard = memo(LoanTimelineCardComponent);
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { LoanPaymentMethod } from '../../../types/app';
|
||||
|
||||
interface PaymentMethodTileProps {
|
||||
method: LoanPaymentMethod;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const METHOD_CONFIG: Record<LoanPaymentMethod, { icon: string; label: string; description: string }> = {
|
||||
'UPI': { icon: 'phone-portrait-outline', label: 'UPI', description: 'Pay via Google Pay, PhonePe, etc.' },
|
||||
'Net Banking': { icon: 'globe-outline', label: 'Net Banking', description: 'Pay from your bank account' },
|
||||
'Wallet': { icon: 'wallet-outline', label: 'Wallet', description: 'Pay from BankCBS Wallet' },
|
||||
};
|
||||
|
||||
const PaymentMethodTileComponent = ({ method, selected, onPress }: PaymentMethodTileProps) => {
|
||||
const theme = useTheme();
|
||||
const config = METHOD_CONFIG[method];
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<View
|
||||
style={[
|
||||
styles.tile,
|
||||
{
|
||||
borderColor: selected ? theme.colors.primary : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? `${theme.colors.primary}06` : theme.colors.surface,
|
||||
borderWidth: selected ? 2 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.icon, { backgroundColor: selected ? `${theme.colors.primary}14` : theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name={config.icon as any} size={22} color={selected ? theme.colors.primary : theme.colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>{config.label}</Text>
|
||||
<Text style={[styles.description, { color: theme.colors.textMuted }]}>{config.description}</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={selected ? 'radio-button-on' : 'radio-button-off'}
|
||||
size={22}
|
||||
color={selected ? theme.colors.primary : theme.colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tile: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
gap: 14,
|
||||
},
|
||||
icon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: { flex: 1, gap: 2 },
|
||||
label: { fontSize: 15, fontWeight: '700' },
|
||||
description: { fontSize: 12 },
|
||||
});
|
||||
|
||||
export const PaymentMethodTile = memo(PaymentMethodTileComponent);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { selectLoanState } from '../store/loanSelectors';
|
||||
import { calculateMonthlyEmi, generateAmortizationSchedule } from '../utils/emi';
|
||||
|
||||
export const useEmiCalculator = () => {
|
||||
const { principal, rate, tenureMonths } = useAppSelector(selectLoanState);
|
||||
|
||||
return useMemo(() => {
|
||||
const numericPrincipal = Number(principal) || 0;
|
||||
const numericRate = Number(rate) || 0;
|
||||
const numericTenure = Number(tenureMonths) || 1;
|
||||
|
||||
const monthlyEmi = calculateMonthlyEmi(numericPrincipal, numericRate, numericTenure);
|
||||
const totalRepayment = monthlyEmi * numericTenure;
|
||||
const totalInterest = totalRepayment - numericPrincipal;
|
||||
const amortizationSchedule = generateAmortizationSchedule(
|
||||
numericPrincipal,
|
||||
numericRate,
|
||||
numericTenure,
|
||||
);
|
||||
|
||||
return {
|
||||
monthlyEmi,
|
||||
totalRepayment,
|
||||
totalInterest,
|
||||
principalPercent: totalRepayment > 0 ? (numericPrincipal / totalRepayment) * 100 : 0,
|
||||
interestPercent: totalRepayment > 0 ? (totalInterest / totalRepayment) * 100 : 0,
|
||||
amortizationSchedule,
|
||||
};
|
||||
}, [principal, rate, tenureMonths]);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { selectDraft } from '../store/loanSelectors';
|
||||
import { loanActions } from '../store/loanSlice';
|
||||
import { useSubmitLoanApplicationMutation } from '../services/loanApi';
|
||||
import type { LoanCategory, EmploymentType } from '../../../types/app';
|
||||
|
||||
export type ApplicationStep = 'details' | 'personal' | 'documents' | 'review';
|
||||
|
||||
const STEPS: ApplicationStep[] = ['details', 'personal', 'documents', 'review'];
|
||||
|
||||
export interface StepValidation {
|
||||
isValid: boolean;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
export const useLoanApplication = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const draft = useAppSelector(selectDraft);
|
||||
const [submitApplication, { isLoading: isSubmitting }] = useSubmitLoanApplicationMutation();
|
||||
const [currentStep, setCurrentStep] = useState<ApplicationStep>('details');
|
||||
const [submitResult, setSubmitResult] = useState<{ applicationId: string } | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const stepIndex = STEPS.indexOf(currentStep);
|
||||
const isFirstStep = stepIndex === 0;
|
||||
const isLastStep = stepIndex === STEPS.length - 1;
|
||||
const progress = (stepIndex + 1) / STEPS.length;
|
||||
|
||||
const setCategory = useCallback(
|
||||
(category: LoanCategory) => dispatch(loanActions.setDraftCategory(category)),
|
||||
[dispatch],
|
||||
);
|
||||
const setAmount = useCallback(
|
||||
(amount: string) => dispatch(loanActions.setDraftAmount(amount)),
|
||||
[dispatch],
|
||||
);
|
||||
const setTenure = useCallback(
|
||||
(tenure: string) => dispatch(loanActions.setDraftTenure(tenure)),
|
||||
[dispatch],
|
||||
);
|
||||
const setIncome = useCallback(
|
||||
(income: string) => dispatch(loanActions.setDraftIncome(income)),
|
||||
[dispatch],
|
||||
);
|
||||
const setEmploymentType = useCallback(
|
||||
(type: EmploymentType) => dispatch(loanActions.setDraftEmploymentType(type)),
|
||||
[dispatch],
|
||||
);
|
||||
const setLiabilities = useCallback(
|
||||
(liabilities: string) => dispatch(loanActions.setDraftLiabilities(liabilities)),
|
||||
[dispatch],
|
||||
);
|
||||
const setPurpose = useCallback(
|
||||
(purpose: string) => dispatch(loanActions.setDraftPurpose(purpose)),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const validateCurrentStep = useCallback((): StepValidation => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
switch (currentStep) {
|
||||
case 'details':
|
||||
if (!draft.loanCategory) errors.loanCategory = 'Select a loan type';
|
||||
if (!draft.amount || Number(draft.amount) <= 0) errors.amount = 'Enter a valid amount';
|
||||
if (!draft.tenureMonths || Number(draft.tenureMonths) <= 0) errors.tenureMonths = 'Enter a valid tenure';
|
||||
if (!draft.purpose.trim()) errors.purpose = 'Enter purpose of loan';
|
||||
break;
|
||||
case 'personal':
|
||||
if (!draft.monthlyIncome || Number(draft.monthlyIncome) <= 0) errors.monthlyIncome = 'Enter your monthly income';
|
||||
if (!draft.employmentType) errors.employmentType = 'Select employment type';
|
||||
break;
|
||||
case 'documents':
|
||||
// Documents are optional at this stage but we check minimum
|
||||
break;
|
||||
case 'review':
|
||||
break;
|
||||
}
|
||||
|
||||
return { isValid: Object.keys(errors).length === 0, errors };
|
||||
}, [currentStep, draft]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (!isLastStep) {
|
||||
const nextStep = STEPS[stepIndex + 1];
|
||||
if (nextStep) {
|
||||
setCurrentStep(nextStep);
|
||||
}
|
||||
}
|
||||
}, [isLastStep, stepIndex]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (!isFirstStep) {
|
||||
const previousStep = STEPS[stepIndex - 1];
|
||||
if (previousStep) {
|
||||
setCurrentStep(previousStep);
|
||||
}
|
||||
}
|
||||
}, [isFirstStep, stepIndex]);
|
||||
|
||||
const goToStep = useCallback((step: ApplicationStep) => {
|
||||
setCurrentStep(step);
|
||||
}, []);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!draft.loanCategory) return;
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const result = await submitApplication({
|
||||
category: draft.loanCategory,
|
||||
amount: Number(draft.amount),
|
||||
tenureMonths: Number(draft.tenureMonths),
|
||||
purpose: draft.purpose,
|
||||
}).unwrap();
|
||||
setSubmitResult(result);
|
||||
dispatch(loanActions.resetDraft());
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
setSubmitError(err?.message ?? 'Failed to submit application');
|
||||
return null;
|
||||
}
|
||||
}, [draft, submitApplication, dispatch]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch(loanActions.resetDraft());
|
||||
setCurrentStep('details');
|
||||
setSubmitResult(null);
|
||||
setSubmitError(null);
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
currentStep,
|
||||
stepIndex,
|
||||
totalSteps: STEPS.length,
|
||||
progress,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSubmitting,
|
||||
submitResult,
|
||||
submitError,
|
||||
// Setters
|
||||
setCategory,
|
||||
setAmount,
|
||||
setTenure,
|
||||
setIncome,
|
||||
setEmploymentType,
|
||||
setLiabilities,
|
||||
setPurpose,
|
||||
// Navigation
|
||||
goNext,
|
||||
goBack,
|
||||
goToStep,
|
||||
validateCurrentStep,
|
||||
submit,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import {
|
||||
selectActiveLoans,
|
||||
selectActiveLoansStatus,
|
||||
selectApplications,
|
||||
selectApplicationsStatus,
|
||||
selectTotalOutstanding,
|
||||
selectNextEmi,
|
||||
selectUnreadNotificationCount,
|
||||
} from '../store/loanSelectors';
|
||||
import { fetchActiveLoans, fetchLoanApplications, fetchLoanNotifications } from '../store/loanThunk';
|
||||
|
||||
export const useLoanDashboard = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const activeLoans = useAppSelector(selectActiveLoans);
|
||||
const activeLoansStatus = useAppSelector(selectActiveLoansStatus);
|
||||
const applications = useAppSelector(selectApplications);
|
||||
const applicationsStatus = useAppSelector(selectApplicationsStatus);
|
||||
const totalOutstanding = useAppSelector(selectTotalOutstanding);
|
||||
const nextEmi = useAppSelector(selectNextEmi);
|
||||
const unreadNotifications = useAppSelector(selectUnreadNotificationCount);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLoansStatus === 'idle') {
|
||||
dispatch(fetchActiveLoans());
|
||||
}
|
||||
if (applicationsStatus === 'idle') {
|
||||
dispatch(fetchLoanApplications());
|
||||
dispatch(fetchLoanNotifications());
|
||||
}
|
||||
}, [dispatch, activeLoansStatus, applicationsStatus]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const activeCount = activeLoans.filter((l) => l.status === 'Active').length;
|
||||
const overdueCount = activeLoans.filter((l) => l.status === 'Overdue').length;
|
||||
const totalEmiDue = activeLoans
|
||||
.filter((l) => l.status === 'Active' || l.status === 'Overdue')
|
||||
.reduce((sum, l) => sum + l.emiAmount, 0);
|
||||
|
||||
return {
|
||||
activeCount,
|
||||
overdueCount,
|
||||
totalLoans: activeLoans.length,
|
||||
totalEmiDue,
|
||||
};
|
||||
}, [activeLoans]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([
|
||||
dispatch(fetchActiveLoans()),
|
||||
dispatch(fetchLoanApplications()),
|
||||
dispatch(fetchLoanNotifications()),
|
||||
]);
|
||||
setRefreshing(false);
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
activeLoans,
|
||||
applications,
|
||||
totalOutstanding,
|
||||
nextEmi,
|
||||
summary,
|
||||
unreadNotifications,
|
||||
isLoading: activeLoansStatus === 'loading',
|
||||
isError: activeLoansStatus === 'failed',
|
||||
refreshing,
|
||||
onRefresh,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import type { LoanCategory, LoanEligibilityResult } from '../../../types/app';
|
||||
import { useCheckEligibilityMutation } from '../services/loanApi';
|
||||
|
||||
export const useLoanEligibility = () => {
|
||||
const [checkEligibility, { isLoading }] = useCheckEligibilityMutation();
|
||||
const [result, setResult] = useState<LoanEligibilityResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const check = useCallback(
|
||||
async (params: {
|
||||
category: LoanCategory;
|
||||
monthlyIncome: number;
|
||||
existingLiabilities: number;
|
||||
}) => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await checkEligibility(params).unwrap();
|
||||
setResult(response);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to check eligibility');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[checkEligibility],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
check,
|
||||
reset,
|
||||
result,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { LoanNavigator } from '../../navigation/LoanNavigator';
|
||||
|
||||
export * from './store/loanSlice';
|
||||
export * from './store/loanSelectors';
|
||||
export * from './store/loanThunk';
|
||||
export * from './services/loanApi';
|
||||
export * from './hooks/useLoanDashboard';
|
||||
export * from './hooks/useLoanApplication';
|
||||
export * from './hooks/useLoanEligibility';
|
||||
export * from './hooks/useEmiCalculator';
|
||||
|
||||
export { LoanNavigator };
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import { Button, Card, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { useGetLoanDetailQuery, useGetEmiScheduleQuery, useGetForeclosureQuoteQuery } from '../services/loanApi';
|
||||
import { EmiScheduleRow } from '../components/EmiScheduleRow';
|
||||
import { ForeclosureSheet } from '../components/ForeclosureSheet';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'ActiveLoanDetails'>;
|
||||
|
||||
export default function ActiveLoanDetailsScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const { loanId } = route.params;
|
||||
const { data: loan, isLoading } = useGetLoanDetailQuery(loanId);
|
||||
const { data: schedule } = useGetEmiScheduleQuery(loanId);
|
||||
const { data: foreclosureQuote } = useGetForeclosureQuoteQuery(loanId);
|
||||
const [showSchedule, setShowSchedule] = useState(false);
|
||||
const [showForeclosure, setShowForeclosure] = useState(false);
|
||||
|
||||
if (isLoading || !loan) {
|
||||
return <ScreenShell title="Loan Details" scrollable={false}><Loader /></ScreenShell>;
|
||||
}
|
||||
|
||||
const progress = loan.totalEmis > 0 ? (loan.paidEmis / loan.totalEmis) * 100 : 0;
|
||||
|
||||
return (
|
||||
<ScreenShell title={loan.type} subtitle={loan.loanAccountNumber} scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||
{/* Loan Info */}
|
||||
<Card style={styles.infoCard}>
|
||||
<View style={styles.infoGrid}>
|
||||
<InfoItem label="Sanctioned" value={formatCurrency(loan.sanctionedAmount)} theme={theme} />
|
||||
<InfoItem label="Disbursed" value={formatCurrency(loan.disbursedAmount)} theme={theme} />
|
||||
<InfoItem label="Outstanding" value={formatCurrency(loan.outstandingBalance)} theme={theme} />
|
||||
<InfoItem label="Interest Rate" value={`${loan.interestRate}% p.a.`} theme={theme} />
|
||||
<InfoItem label="Disbursed Date" value={formatDate(loan.disbursedDate)} theme={theme} />
|
||||
<InfoItem label="Account" value={loan.accountNumber} theme={theme} />
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Repayment Progress */}
|
||||
<Card style={styles.progressCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Repayment Progress</Text>
|
||||
<View style={styles.progressHeader}>
|
||||
<Text style={[styles.progressLabel, { color: theme.colors.textSecondary }]}>
|
||||
{loan.paidEmis} of {loan.totalEmis} EMIs paid
|
||||
</Text>
|
||||
<Text style={[styles.progressPercent, { color: theme.colors.primary }]}>{Math.round(progress)}%</Text>
|
||||
</View>
|
||||
<View style={[styles.progressTrack, { backgroundColor: theme.colors.cardBorder }]}>
|
||||
<View style={[styles.progressFill, { width: `${Math.min(progress, 100)}%`, backgroundColor: theme.colors.primary }]} />
|
||||
</View>
|
||||
<View style={styles.emiDetails}>
|
||||
<View style={styles.emiDetailItem}>
|
||||
<Text style={[styles.emiDetailLabel, { color: theme.colors.textMuted }]}>Monthly EMI</Text>
|
||||
<Text style={[styles.emiDetailValue, { color: theme.colors.textPrimary }]}>{formatCurrency(loan.emiAmount)}</Text>
|
||||
</View>
|
||||
<View style={styles.emiDetailItem}>
|
||||
<Text style={[styles.emiDetailLabel, { color: theme.colors.textMuted }]}>Next EMI</Text>
|
||||
<Text style={[styles.emiDetailValue, { color: theme.colors.textPrimary }]}>{formatDate(loan.nextEmiDate)}</Text>
|
||||
</View>
|
||||
<View style={styles.emiDetailItem}>
|
||||
<Text style={[styles.emiDetailLabel, { color: theme.colors.textMuted }]}>Remaining</Text>
|
||||
<Text style={[styles.emiDetailValue, { color: theme.colors.textPrimary }]}>{loan.remainingTenure} months</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card style={styles.actionsCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Actions</Text>
|
||||
<View style={styles.actionGrid}>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionCol}>
|
||||
<Button title="Pay EMI" onPress={() => navigation.navigate('EmiPayment', { loanId })} leftAccessory={<Ionicons name="cash-outline" size={18} color={theme.colors.white} />} />
|
||||
</View>
|
||||
<View style={styles.actionCol}>
|
||||
<Button title="Foreclosure" variant="danger" onPress={() => setShowForeclosure(true)} leftAccessory={<Ionicons name="close-circle-outline" size={18} color={theme.colors.white} />} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Foreclosure Section */}
|
||||
{showForeclosure && foreclosureQuote && (
|
||||
<ForeclosureSheet
|
||||
quote={foreclosureQuote}
|
||||
onConfirm={() => setShowForeclosure(false)}
|
||||
onCancel={() => setShowForeclosure(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* EMI Schedule */}
|
||||
<Card style={styles.scheduleCard}>
|
||||
<View style={styles.scheduleHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>EMI Schedule</Text>
|
||||
<Text style={[styles.toggleText, { color: theme.colors.primary }]} onPress={() => setShowSchedule(!showSchedule)}>
|
||||
{showSchedule ? 'Hide' : 'Show'}
|
||||
</Text>
|
||||
</View>
|
||||
{showSchedule && schedule && (
|
||||
<View>
|
||||
<EmiScheduleRow item={{ month: 0, emiAmount: 0, principalComponent: 0, interestComponent: 0, outstandingAfter: 0 }} isHeader />
|
||||
{schedule.slice(0, 48).map((item) => (
|
||||
<EmiScheduleRow key={item.month} item={item} />
|
||||
))}
|
||||
{schedule.length > 48 && (
|
||||
<Text style={[styles.moreText, { color: theme.colors.textMuted }]}>
|
||||
...and {schedule.length - 48} more months
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Auto-Debit Info */}
|
||||
<Card style={styles.autoDebitCard}>
|
||||
<View style={styles.autoDebitRow}>
|
||||
<View style={styles.autoDebitCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Auto-Debit</Text>
|
||||
<Text style={[styles.autoDebitStatus, { color: loan.autoDebitEnabled ? '#10B981' : theme.colors.textMuted }]}>
|
||||
{loan.autoDebitEnabled ? 'Enabled' : 'Disabled'} · {loan.accountNumber}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={loan.autoDebitEnabled ? 'toggle' : 'toggle-outline'}
|
||||
size={36}
|
||||
color={loan.autoDebitEnabled ? '#10B981' : theme.colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value, theme }: { label: string; value: string; theme: any }) {
|
||||
return (
|
||||
<View style={infoStyles.item}>
|
||||
<Text style={[infoStyles.label, { color: theme.colors.textMuted }]}>{label}</Text>
|
||||
<Text style={[infoStyles.value, { color: theme.colors.textPrimary }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const infoStyles = StyleSheet.create({
|
||||
item: { width: '48%', gap: 4, marginBottom: 12 },
|
||||
label: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
value: { fontSize: 15, fontWeight: '700' },
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 16, paddingBottom: 24 },
|
||||
infoCard: { gap: 8 },
|
||||
infoGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
progressCard: { gap: 12 },
|
||||
sectionTitle: { fontSize: 16, fontWeight: '800' },
|
||||
progressHeader: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
progressLabel: { fontSize: 13, fontWeight: '600' },
|
||||
progressPercent: { fontSize: 13, fontWeight: '800' },
|
||||
progressTrack: { height: 8, borderRadius: 4, overflow: 'hidden' },
|
||||
progressFill: { height: '100%', borderRadius: 4 },
|
||||
emiDetails: { flexDirection: 'row', gap: 12 },
|
||||
emiDetailItem: { flex: 1, gap: 4 },
|
||||
emiDetailLabel: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
emiDetailValue: { fontSize: 14, fontWeight: '800' },
|
||||
actionsCard: { gap: 12 },
|
||||
actionGrid: { gap: 12 },
|
||||
actionRow: { flexDirection: 'row', gap: 12 },
|
||||
actionCol: { flex: 1 },
|
||||
scheduleCard: { gap: 12 },
|
||||
scheduleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
toggleText: { fontSize: 13, fontWeight: '700' },
|
||||
moreText: { textAlign: 'center', paddingVertical: 12, fontSize: 12, fontWeight: '600' },
|
||||
autoDebitCard: {},
|
||||
autoDebitRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
autoDebitCopy: { gap: 4 },
|
||||
autoDebitStatus: { fontSize: 13, fontWeight: '600' },
|
||||
spacer: { height: 60 },
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import type { EmploymentType } from '../../../types/app';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useLoanApplication } from '../hooks/useLoanApplication';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'ApplyLoan'>;
|
||||
|
||||
const EMPLOYMENT_TYPES: EmploymentType[] = ['Salaried', 'Self Employed', 'Business', 'Professional'];
|
||||
const STEPS_LABELS = ['Loan Details', 'Personal Info', 'Documents', 'Review & Submit'];
|
||||
|
||||
export default function ApplyLoanScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
draft,
|
||||
currentStep,
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
progress,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSubmitting,
|
||||
submitResult,
|
||||
submitError,
|
||||
setCategory,
|
||||
setAmount,
|
||||
setTenure,
|
||||
setIncome,
|
||||
setEmploymentType,
|
||||
setLiabilities,
|
||||
setPurpose,
|
||||
goNext,
|
||||
goBack,
|
||||
validateCurrentStep,
|
||||
submit,
|
||||
reset,
|
||||
} = useLoanApplication();
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const category = route.params?.category ?? draft.loanCategory ?? 'Personal Loan';
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft.loanCategory) {
|
||||
setCategory(route.params?.category ?? 'Personal Loan');
|
||||
}
|
||||
}, [draft.loanCategory, route.params?.category, setCategory]);
|
||||
|
||||
const handleNext = () => {
|
||||
const { isValid, errors: stepErrors } = validateCurrentStep();
|
||||
if (!isValid) {
|
||||
setErrors(stepErrors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
goNext();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await submit();
|
||||
};
|
||||
|
||||
if (submitResult) {
|
||||
return (
|
||||
<ScreenShell title="Application Submitted" scrollable={false}>
|
||||
<View style={styles.successContainer}>
|
||||
<View style={[styles.successIcon, { backgroundColor: '#10B98114' }]}>
|
||||
<Ionicons name="checkmark-circle" size={64} color="#10B981" />
|
||||
</View>
|
||||
<Text style={[styles.successTitle, { color: theme.colors.textPrimary }]}>Application Submitted!</Text>
|
||||
<Text style={[styles.successSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Your loan application has been submitted successfully. Reference: {submitResult.applicationId}
|
||||
</Text>
|
||||
<View style={styles.successActions}>
|
||||
<Button title="Track Application" onPress={() => { reset(); navigation.navigate('LoanTracking'); }} />
|
||||
<Button title="Back to Dashboard" variant="secondary" onPress={() => { reset(); navigation.navigate('LoanDashboard'); }} />
|
||||
</View>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell title={`Apply — ${category}`} subtitle={STEPS_LABELS[stepIndex]} scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
{/* Progress */}
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressRow}>
|
||||
{STEPS_LABELS.map((label, i) => (
|
||||
<View key={i} style={[styles.progressDot, { backgroundColor: i <= stepIndex ? theme.colors.primary : theme.colors.cardBorder }]} />
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.stepIndicator, { color: theme.colors.textMuted }]}>
|
||||
Step {stepIndex + 1} of {totalSteps}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Step 1: Loan Details */}
|
||||
{currentStep === 'details' && (
|
||||
<Card style={styles.formCard}>
|
||||
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Loan Details</Text>
|
||||
<Input label="Loan Amount (₹)" placeholder="e.g. 500000" keyboardType="numeric" value={draft.amount} onChangeText={setAmount} error={errors.amount} />
|
||||
<Input label="Tenure (months)" placeholder="e.g. 36" keyboardType="numeric" value={draft.tenureMonths} onChangeText={setTenure} error={errors.tenureMonths} />
|
||||
<Input label="Purpose" placeholder="e.g. Home renovation" value={draft.purpose} onChangeText={setPurpose} error={errors.purpose} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Personal Info */}
|
||||
{currentStep === 'personal' && (
|
||||
<Card style={styles.formCard}>
|
||||
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Personal & Financial</Text>
|
||||
<Input label="Monthly Income (₹)" placeholder="e.g. 75000" keyboardType="numeric" value={draft.monthlyIncome} onChangeText={setIncome} error={errors.monthlyIncome} />
|
||||
<Input label="Existing Liabilities (₹)" placeholder="e.g. 10000" keyboardType="numeric" value={draft.existingLiabilities} onChangeText={setLiabilities} />
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>EMPLOYMENT TYPE</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{EMPLOYMENT_TYPES.map((type) => (
|
||||
<Pressable key={type} onPress={() => setEmploymentType(type)} style={[styles.chip, { backgroundColor: draft.employmentType === type ? theme.colors.primary : theme.colors.surface, borderColor: draft.employmentType === type ? theme.colors.primary : theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.chipText, { color: draft.employmentType === type ? theme.colors.white : theme.colors.textSecondary }]}>{type}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
{errors.employmentType && <Text style={[styles.errorText, { color: theme.colors.error }]}>{errors.employmentType}</Text>}
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Documents */}
|
||||
{currentStep === 'documents' && (
|
||||
<Card style={styles.formCard}>
|
||||
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Document Upload</Text>
|
||||
<Text style={[styles.docInfo, { color: theme.colors.textSecondary }]}>
|
||||
Upload your KYC documents. Supported formats: PDF, JPG, PNG.
|
||||
</Text>
|
||||
{['Aadhaar Card', 'PAN Card', 'Salary Slip', 'Bank Statement'].map((doc) => (
|
||||
<Pressable key={doc} style={[styles.docRow, { borderColor: theme.colors.cardBorder }]}>
|
||||
<Ionicons name="cloud-upload-outline" size={20} color={theme.colors.primary} />
|
||||
<Text style={[styles.docLabel, { color: theme.colors.textPrimary }]}>{doc}</Text>
|
||||
<Text style={[styles.docAction, { color: theme.colors.primary }]}>Upload</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: Review */}
|
||||
{currentStep === 'review' && (
|
||||
<Card style={styles.formCard}>
|
||||
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Review Application</Text>
|
||||
<View style={[styles.reviewGrid, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<ReviewRow label="Loan Type" value={category} theme={theme} />
|
||||
<ReviewRow label="Amount" value={`₹${Number(draft.amount).toLocaleString('en-IN')}`} theme={theme} />
|
||||
<ReviewRow label="Tenure" value={`${draft.tenureMonths} months`} theme={theme} />
|
||||
<ReviewRow label="Purpose" value={draft.purpose || '—'} theme={theme} />
|
||||
<ReviewRow label="Monthly Income" value={`₹${Number(draft.monthlyIncome).toLocaleString('en-IN')}`} theme={theme} />
|
||||
<ReviewRow label="Employment" value={draft.employmentType ?? '—'} theme={theme} />
|
||||
</View>
|
||||
{submitError && <Text style={[styles.errorText, { color: theme.colors.error }]}>{submitError}</Text>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<View style={styles.navRow}>
|
||||
{!isFirstStep && <Button title="Back" variant="secondary" onPress={goBack} style={styles.navBtn} />}
|
||||
<Button
|
||||
title={isLastStep ? 'Submit Application' : 'Next'}
|
||||
onPress={isLastStep ? handleSubmit : handleNext}
|
||||
loading={isSubmitting}
|
||||
style={styles.navBtn}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value, theme }: { label: string; value: string; theme: any }) {
|
||||
return (
|
||||
<View style={reviewStyles.row}>
|
||||
<Text style={[reviewStyles.label, { color: theme.colors.textMuted }]}>{label}</Text>
|
||||
<Text style={[reviewStyles.value, { color: theme.colors.textPrimary }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const reviewStyles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8 },
|
||||
label: { fontSize: 13, fontWeight: '600' },
|
||||
value: { fontSize: 14, fontWeight: '700' },
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 16, paddingBottom: 24 },
|
||||
progressSection: { alignItems: 'center', gap: 6 },
|
||||
progressRow: { flexDirection: 'row', gap: 8 },
|
||||
progressDot: { width: 40, height: 4, borderRadius: 2 },
|
||||
stepIndicator: { fontSize: 12, fontWeight: '600' },
|
||||
formCard: { gap: 14 },
|
||||
cardTitle: { fontSize: 18, fontWeight: '800' },
|
||||
fieldGroup: { gap: 8 },
|
||||
fieldLabel: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5, marginLeft: 4 },
|
||||
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
chip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, borderWidth: 1 },
|
||||
chipText: { fontSize: 13, fontWeight: '700' },
|
||||
errorText: { fontSize: 12, fontWeight: '500', marginLeft: 4 },
|
||||
docInfo: { fontSize: 13, lineHeight: 20 },
|
||||
docRow: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14, borderRadius: 14, borderWidth: 1 },
|
||||
docLabel: { flex: 1, fontSize: 14, fontWeight: '600' },
|
||||
docAction: { fontSize: 13, fontWeight: '700' },
|
||||
reviewGrid: { borderRadius: 14, padding: 14, gap: 4 },
|
||||
navRow: { flexDirection: 'row', gap: 12 },
|
||||
navBtn: { flex: 1 },
|
||||
successContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 20 },
|
||||
successIcon: { width: 96, height: 96, borderRadius: 48, alignItems: 'center', justifyContent: 'center' },
|
||||
successTitle: { fontSize: 24, fontWeight: '900' },
|
||||
successSubtitle: { fontSize: 14, textAlign: 'center', lineHeight: 22 },
|
||||
successActions: { gap: 10, width: '100%' },
|
||||
spacer: { height: 40 },
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import type { LoanPaymentMethod } from '../../../types/app';
|
||||
import { Button, Card, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { useGetLoanDetailQuery, usePayEmiMutation, useGetLoanHistoryQuery } from '../services/loanApi';
|
||||
import { PaymentMethodTile } from '../components/PaymentMethodTile';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'EmiPayment'>;
|
||||
|
||||
const PAYMENT_METHODS: LoanPaymentMethod[] = ['UPI', 'Net Banking', 'Wallet'];
|
||||
|
||||
export default function EmiPaymentScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const { loanId } = route.params;
|
||||
const { data: loan, isLoading: loanLoading } = useGetLoanDetailQuery(loanId);
|
||||
const { data: history } = useGetLoanHistoryQuery();
|
||||
const [payEmi, { isLoading: isPaying }] = usePayEmiMutation();
|
||||
|
||||
const [selectedMethod, setSelectedMethod] = useState<LoanPaymentMethod>('UPI');
|
||||
const [paymentResult, setPaymentResult] = useState<{ success: boolean; ref: string } | null>(null);
|
||||
|
||||
const loanHistory = (history ?? []).filter((h) => h.loanId === loanId).slice(0, 5);
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!loan) return;
|
||||
try {
|
||||
const result = await payEmi({ loanId, amount: loan.emiAmount, method: selectedMethod }).unwrap();
|
||||
setPaymentResult({ success: result.success, ref: result.transactionRef });
|
||||
} catch {
|
||||
setPaymentResult({ success: false, ref: '' });
|
||||
}
|
||||
};
|
||||
|
||||
if (loanLoading || !loan) {
|
||||
return <ScreenShell title="Pay EMI" scrollable={false}><Loader /></ScreenShell>;
|
||||
}
|
||||
|
||||
if (paymentResult) {
|
||||
return (
|
||||
<ScreenShell title="Payment Result" scrollable={false}>
|
||||
<View style={styles.resultContainer}>
|
||||
<View style={[styles.resultIcon, { backgroundColor: paymentResult.success ? '#10B98114' : '#EF444414' }]}>
|
||||
<Ionicons name={paymentResult.success ? 'checkmark-circle' : 'close-circle'} size={64} color={paymentResult.success ? '#10B981' : '#EF4444'} />
|
||||
</View>
|
||||
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]}>
|
||||
{paymentResult.success ? 'Payment Successful!' : 'Payment Failed'}
|
||||
</Text>
|
||||
<Text style={[styles.resultSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{paymentResult.success
|
||||
? `EMI of ${formatCurrency(loan.emiAmount)} paid for ${loan.type}.\nRef: ${paymentResult.ref}`
|
||||
: 'Something went wrong. Please try again.'}
|
||||
</Text>
|
||||
<View style={styles.resultActions}>
|
||||
<Button title="Back to Loan" onPress={() => navigation.goBack()} />
|
||||
{!paymentResult.success && <Button title="Retry" variant="secondary" onPress={() => setPaymentResult(null)} />}
|
||||
</View>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell title="Pay EMI" subtitle={loan.type} scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||
{/* EMI Summary */}
|
||||
<Card style={styles.summaryCard}>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>EMI Amount</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.primary }]}>{formatCurrency(loan.emiAmount)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Due Date</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{formatDate(loan.nextEmiDate)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Payment Method */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Payment Method</Text>
|
||||
<View style={styles.methodList}>
|
||||
{PAYMENT_METHODS.map((method) => (
|
||||
<PaymentMethodTile key={method} method={method} selected={selectedMethod === method} onPress={() => setSelectedMethod(method)} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button title={`Pay ${formatCurrency(loan.emiAmount)}`} onPress={handlePay} loading={isPaying} leftAccessory={<Ionicons name="lock-closed-outline" size={18} color={theme.colors.white} />} />
|
||||
|
||||
{/* Payment History */}
|
||||
{loanHistory.length > 0 && (
|
||||
<Card style={styles.historyCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Recent Payments</Text>
|
||||
{loanHistory.map((record) => (
|
||||
<View key={record.id} style={[styles.historyRow, { borderBottomColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.historyLeft}>
|
||||
<Ionicons name={record.status === 'success' ? 'checkmark-circle' : 'close-circle'} size={18} color={record.status === 'success' ? '#10B981' : '#EF4444'} />
|
||||
<View style={styles.historyCopy}>
|
||||
<Text style={[styles.historyAmount, { color: theme.colors.textPrimary }]}>{formatCurrency(record.amount)}</Text>
|
||||
<Text style={[styles.historyMeta, { color: theme.colors.textMuted }]}>{record.method} · {formatDate(record.date)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 16, paddingBottom: 24 },
|
||||
summaryCard: {},
|
||||
summaryRow: { flexDirection: 'row', gap: 16 },
|
||||
summaryItem: { flex: 1, gap: 4 },
|
||||
summaryLabel: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
summaryValue: { fontSize: 20, fontWeight: '900' },
|
||||
section: { gap: 12 },
|
||||
sectionTitle: { fontSize: 16, fontWeight: '800' },
|
||||
methodList: { gap: 10 },
|
||||
historyCard: { gap: 12 },
|
||||
historyRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, borderBottomWidth: 1 },
|
||||
historyLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
historyCopy: { gap: 2 },
|
||||
historyAmount: { fontSize: 14, fontWeight: '700' },
|
||||
historyMeta: { fontSize: 12 },
|
||||
resultContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 20 },
|
||||
resultIcon: { width: 96, height: 96, borderRadius: 48, alignItems: 'center', justifyContent: 'center' },
|
||||
resultTitle: { fontSize: 24, fontWeight: '900' },
|
||||
resultSubtitle: { fontSize: 14, textAlign: 'center', lineHeight: 22 },
|
||||
resultActions: { gap: 10, width: '100%' },
|
||||
spacer: { height: 40 },
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import { Card, Input, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { loanActions } from '../store/loanSlice';
|
||||
import { useEmiCalculator } from '../hooks/useEmiCalculator';
|
||||
import { EmiScheduleRow } from '../components/EmiScheduleRow';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'LoanCalculator'>;
|
||||
|
||||
export default function LoanCalculatorScreen({ route }: Props) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const { monthlyEmi, totalRepayment, totalInterest, principalPercent, interestPercent, amortizationSchedule } = useEmiCalculator();
|
||||
const [showSchedule, setShowSchedule] = useState(false);
|
||||
|
||||
const [localPrincipal, setLocalPrincipal] = useState('500000');
|
||||
const [localRate, setLocalRate] = useState('8.45');
|
||||
const [localTenure, setLocalTenure] = useState('60');
|
||||
|
||||
useEffect(() => {
|
||||
if (route.params?.prefillAmount) setLocalPrincipal(String(route.params.prefillAmount));
|
||||
if (route.params?.prefillRate) setLocalRate(String(route.params.prefillRate));
|
||||
if (route.params?.prefillTenure) setLocalTenure(String(route.params.prefillTenure));
|
||||
}, [route.params]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loanActions.setPrincipal(localPrincipal));
|
||||
}, [localPrincipal, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loanActions.setRate(localRate));
|
||||
}, [localRate, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loanActions.setTenureMonths(localTenure));
|
||||
}, [localTenure, dispatch]);
|
||||
|
||||
return (
|
||||
<ScreenShell title="EMI Calculator" subtitle="Calculate monthly EMI and view amortization schedule" scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
{/* Input Card */}
|
||||
<Card style={styles.inputCard}>
|
||||
<Input
|
||||
label="Loan Amount (₹)"
|
||||
placeholder="Enter loan amount"
|
||||
keyboardType="numeric"
|
||||
value={localPrincipal}
|
||||
onChangeText={setLocalPrincipal}
|
||||
/>
|
||||
<Input
|
||||
label="Interest Rate (% p.a.)"
|
||||
placeholder="e.g. 8.45"
|
||||
keyboardType="decimal-pad"
|
||||
value={localRate}
|
||||
onChangeText={setLocalRate}
|
||||
/>
|
||||
<Input
|
||||
label="Tenure (months)"
|
||||
placeholder="e.g. 60"
|
||||
keyboardType="numeric"
|
||||
value={localTenure}
|
||||
onChangeText={setLocalTenure}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Result Card */}
|
||||
<Card style={styles.resultCard}>
|
||||
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>Monthly EMI</Text>
|
||||
<Text style={[styles.resultValue, { color: theme.colors.primary }]}>
|
||||
{formatCurrency(monthlyEmi)}
|
||||
</Text>
|
||||
|
||||
{/* Visual breakdown bar */}
|
||||
<View style={styles.breakdownBar}>
|
||||
<View style={[styles.principalBar, { width: `${principalPercent}%`, backgroundColor: theme.colors.primary }]} />
|
||||
<View style={[styles.interestBar, { width: `${interestPercent}%`, backgroundColor: theme.colors.warning }]} />
|
||||
</View>
|
||||
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={[styles.metricCard, { backgroundColor: `${theme.colors.primary}08` }]}>
|
||||
<View style={[styles.metricDot, { backgroundColor: theme.colors.primary }]} />
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>Principal</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Number(localPrincipal) || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricCard, { backgroundColor: '#F59E0B08' }]}>
|
||||
<View style={[styles.metricDot, { backgroundColor: theme.colors.warning }]} />
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>Interest</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(totalInterest)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.totalRow, { borderTopColor: theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.totalLabel, { color: theme.colors.textSecondary }]}>Total Repayment</Text>
|
||||
<Text style={[styles.totalValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(totalRepayment)}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Amortization Schedule Toggle */}
|
||||
<Card style={styles.scheduleCard}>
|
||||
<View style={styles.scheduleHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Amortization Schedule
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.toggleText, { color: theme.colors.primary }]}
|
||||
onPress={() => setShowSchedule(!showSchedule)}
|
||||
>
|
||||
{showSchedule ? 'Hide' : `Show (${amortizationSchedule.length} months)`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{showSchedule && (
|
||||
<View style={styles.scheduleTable}>
|
||||
<EmiScheduleRow
|
||||
item={{ month: 0, emiAmount: 0, principalComponent: 0, interestComponent: 0, outstandingAfter: 0 }}
|
||||
isHeader
|
||||
/>
|
||||
{amortizationSchedule.slice(0, 60).map((item) => (
|
||||
<EmiScheduleRow key={item.month} item={item} />
|
||||
))}
|
||||
{amortizationSchedule.length > 60 && (
|
||||
<Text style={[styles.moreText, { color: theme.colors.textMuted }]}>
|
||||
...and {amortizationSchedule.length - 60} more months
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 16, paddingBottom: 24 },
|
||||
inputCard: { gap: 14 },
|
||||
resultCard: { gap: 12, alignItems: 'center' },
|
||||
resultLabel: { fontSize: 12, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.8 },
|
||||
resultValue: { fontSize: 34, fontWeight: '900', letterSpacing: -1 },
|
||||
breakdownBar: {
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#E5E7EB',
|
||||
},
|
||||
principalBar: { height: '100%' },
|
||||
interestBar: { height: '100%' },
|
||||
metricsRow: { flexDirection: 'row', gap: 12, width: '100%' },
|
||||
metricCard: { flex: 1, borderRadius: 14, padding: 12, gap: 4 },
|
||||
metricDot: { width: 10, height: 10, borderRadius: 5 },
|
||||
metricLabel: { fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
metricValue: { fontSize: 16, fontWeight: '800' },
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
totalLabel: { fontSize: 13, fontWeight: '600' },
|
||||
totalValue: { fontSize: 18, fontWeight: '900' },
|
||||
scheduleCard: { gap: 12 },
|
||||
scheduleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
sectionTitle: { fontSize: 16, fontWeight: '800' },
|
||||
toggleText: { fontSize: 13, fontWeight: '700' },
|
||||
scheduleTable: { gap: 0 },
|
||||
moreText: { textAlign: 'center', paddingVertical: 12, fontSize: 12, fontWeight: '600' },
|
||||
spacer: { height: 40 },
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import React from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate, formatCompactCurrency } from '../../../utils/formatters';
|
||||
import { useLoanDashboard } from '../hooks/useLoanDashboard';
|
||||
import { LoanSummaryHero } from '../components/LoanSummaryHero';
|
||||
import { LoanAccountCard } from '../components/LoanAccountCard';
|
||||
import { LoanFilterChip } from '../components/LoanFilterChip';
|
||||
|
||||
import { LoanNotificationItem } from '../components/LoanNotificationItem';
|
||||
import { useGetLoanNotificationsQuery } from '../services/loanApi';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'LoanDashboard'>;
|
||||
|
||||
export default function LoanDashboardScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
activeLoans,
|
||||
applications,
|
||||
totalOutstanding,
|
||||
nextEmi,
|
||||
summary,
|
||||
unreadNotifications,
|
||||
isLoading,
|
||||
isError,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
} = useLoanDashboard();
|
||||
|
||||
// Mock notifications for demonstration on dashboard
|
||||
const { data: notifications } = useGetLoanNotificationsQuery();
|
||||
const recentNotifications = (notifications ?? []).slice(0, 2);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Loan Management" subtitle="Manage active loans and apply for new ones" scrollable={false}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Hero Summary */}
|
||||
<LoanSummaryHero totalOutstanding={totalOutstanding} nextEmi={nextEmi} summary={summary} />
|
||||
|
||||
{/* Notifications Section */}
|
||||
{recentNotifications.length > 0 && (
|
||||
<View style={styles.loanSection}>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>What's new</Text>
|
||||
{unreadNotifications > 0 && (
|
||||
<View style={[styles.unreadBadge, { backgroundColor: theme.colors.primary }]}>
|
||||
<Text style={styles.unreadCount}>{unreadNotifications}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Card style={styles.notificationCard}>
|
||||
{recentNotifications.map((notif, index) => (
|
||||
<View key={notif.id}>
|
||||
<LoanNotificationItem notification={notif} />
|
||||
{index < recentNotifications.length - 1 && (
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card style={styles.actionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Quick actions</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Apply for a new loan, calculate EMI, or manage payments
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGrid}>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Apply Loan"
|
||||
onPress={() => navigation.navigate('LoanTypesListing')}
|
||||
leftAccessory={<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="EMI Calculator"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('LoanCalculator', {})}
|
||||
leftAccessory={<Ionicons name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Track Application"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('LoanTracking')}
|
||||
leftAccessory={<Ionicons name="locate-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Payment History"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('LoanHistory')}
|
||||
leftAccessory={<Ionicons name="time-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
title="Documents"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('LoanDocuments', {})}
|
||||
leftAccessory={<Ionicons name="document-text-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Active Loans */}
|
||||
<View style={styles.loanSection}>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<View style={styles.sectionHeaderCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Active loans</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{activeLoans.length} loan{activeLoans.length !== 1 ? 's' : ''} in your portfolio
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoading && !activeLoans.length ? (
|
||||
<Card><Loader /></Card>
|
||||
) : null}
|
||||
|
||||
{isError && !activeLoans.length ? (
|
||||
<EmptyState
|
||||
title="Unable to load loans"
|
||||
description="Please pull to refresh or try again in a moment."
|
||||
actionLabel="Refresh"
|
||||
onAction={onRefresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && !activeLoans.length ? (
|
||||
<EmptyState
|
||||
title="No active loans"
|
||||
description="Apply for a loan to finance your dreams with competitive interest rates."
|
||||
actionLabel="Apply for Loan"
|
||||
onAction={() => navigation.navigate('LoanTypesListing')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeLoans.map((loan) => (
|
||||
<LoanAccountCard
|
||||
key={loan.id}
|
||||
loan={loan}
|
||||
onPress={() => navigation.navigate('ActiveLoanDetails', { loanId: loan.id })}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Recent Applications */}
|
||||
{applications.length > 0 && (
|
||||
<View style={styles.loanSection}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Recent applications</Text>
|
||||
{applications.map((app) => (
|
||||
<Card key={app.id} style={styles.applicationCard}>
|
||||
<View style={styles.appHeader}>
|
||||
<View style={styles.appHeaderLeft}>
|
||||
<Text style={[styles.appType, { color: theme.colors.textPrimary }]}>{app.type}</Text>
|
||||
<Text style={[styles.appAmount, { color: theme.colors.textSecondary }]}>
|
||||
{formatCurrency(app.amount)} · {formatDate(app.appliedDate)}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.appStatusBadge,
|
||||
{
|
||||
backgroundColor: app.status === 'Approved' ? '#10B98114' :
|
||||
app.status === 'Rejected' ? '#EF444414' : `${theme.colors.primary}14`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.appStatusText,
|
||||
{
|
||||
color: app.status === 'Approved' ? '#10B981' :
|
||||
app.status === 'Rejected' ? '#EF4444' : theme.colors.primary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{app.status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: { gap: 18, paddingBottom: 24 },
|
||||
actionCard: { gap: 16 },
|
||||
sectionHeader: { gap: 4 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '800' },
|
||||
sectionSubtitle: { fontSize: 13, lineHeight: 18 },
|
||||
actionGrid: { gap: 12 },
|
||||
actionRow: { flexDirection: 'row', gap: 12 },
|
||||
actionCol: { flex: 1 },
|
||||
loanSection: { gap: 14 },
|
||||
sectionHeaderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
sectionHeaderCopy: { flex: 1, gap: 4 },
|
||||
applicationCard: { gap: 8 },
|
||||
unreadBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
marginLeft: 8,
|
||||
},
|
||||
unreadCount: {
|
||||
color: '#FFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
},
|
||||
notificationCard: { padding: 4, gap: 0 },
|
||||
divider: { height: 1, marginHorizontal: 12 },
|
||||
appHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
appHeaderLeft: { gap: 2 },
|
||||
appType: { fontSize: 15, fontWeight: '700' },
|
||||
appAmount: { fontSize: 12, fontWeight: '500' },
|
||||
appStatusBadge: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999 },
|
||||
appStatusText: { fontSize: 12, fontWeight: '700' },
|
||||
bottomSpacer: { height: 60 },
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useGetLoanDocumentsQuery } from '../services/loanApi';
|
||||
import { DocumentItem } from '../components/DocumentItem';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'LoanDocuments'>;
|
||||
|
||||
export default function LoanDocumentsScreen(_props: Props) {
|
||||
const theme = useTheme();
|
||||
const { data: documents, isLoading, isError, refetch } = useGetLoanDocumentsQuery();
|
||||
|
||||
const verified = (documents ?? []).filter((d) => d.status === 'verified').length;
|
||||
const pending = (documents ?? []).filter((d) => d.status === 'uploaded').length;
|
||||
const rejected = (documents ?? []).filter((d) => d.status === 'rejected').length;
|
||||
|
||||
return (
|
||||
<ScreenShell title="Documents" subtitle="Manage your KYC and loan documents" scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||
{/* Summary */}
|
||||
<Card style={styles.summaryCard}>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={[styles.summaryBadge, { backgroundColor: '#10B98114' }]}>
|
||||
<Text style={[styles.summaryBadgeValue, { color: '#10B981' }]}>{verified}</Text>
|
||||
<Text style={[styles.summaryBadgeLabel, { color: theme.colors.textSecondary }]}>Verified</Text>
|
||||
</View>
|
||||
<View style={[styles.summaryBadge, { backgroundColor: '#3B82F614' }]}>
|
||||
<Text style={[styles.summaryBadgeValue, { color: '#3B82F6' }]}>{pending}</Text>
|
||||
<Text style={[styles.summaryBadgeLabel, { color: theme.colors.textSecondary }]}>Processing</Text>
|
||||
</View>
|
||||
<View style={[styles.summaryBadge, { backgroundColor: '#EF444414' }]}>
|
||||
<Text style={[styles.summaryBadgeValue, { color: '#EF4444' }]}>{rejected}</Text>
|
||||
<Text style={[styles.summaryBadgeLabel, { color: theme.colors.textSecondary }]}>Rejected</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Upload Button */}
|
||||
<Button
|
||||
title="Upload New Document"
|
||||
variant="secondary"
|
||||
leftAccessory={<Ionicons name="cloud-upload-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
onPress={() => {
|
||||
// TODO: Integrate document picker
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Document List */}
|
||||
{isLoading ? (
|
||||
<Card><Loader /></Card>
|
||||
) : isError ? (
|
||||
<EmptyState title="Unable to load documents" description="Please try again." actionLabel="Retry" onAction={refetch} />
|
||||
) : (documents ?? []).length === 0 ? (
|
||||
<EmptyState title="No documents uploaded" description="Upload your KYC documents to proceed with loan applications." />
|
||||
) : (
|
||||
<View style={styles.docList}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Uploaded Documents ({(documents ?? []).length})
|
||||
</Text>
|
||||
{(documents ?? []).map((doc) => (
|
||||
<DocumentItem
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onReupload={() => {
|
||||
// TODO: Integrate document picker for re-upload
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 14, paddingBottom: 24 },
|
||||
summaryCard: {},
|
||||
summaryRow: { flexDirection: 'row', gap: 12 },
|
||||
summaryBadge: { flex: 1, borderRadius: 16, alignItems: 'center', paddingVertical: 14, gap: 4 },
|
||||
summaryBadgeValue: { fontSize: 24, fontWeight: '900' },
|
||||
summaryBadgeLabel: { fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
docList: { gap: 10 },
|
||||
sectionTitle: { fontSize: 16, fontWeight: '800' },
|
||||
spacer: { height: 60 },
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import type { EmploymentType } from '../../../types/app';
|
||||
import { Button, Card, Input, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useLoanEligibility } from '../hooks/useLoanEligibility';
|
||||
import { EligibilityResultCard } from '../components/EligibilityResultCard';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'LoanEligibilityCheck'>;
|
||||
|
||||
const EMPLOYMENT_TYPES: EmploymentType[] = ['Salaried', 'Self Employed', 'Business', 'Professional'];
|
||||
|
||||
export default function LoanEligibilityCheckScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const category = route.params?.category ?? 'Personal Loan';
|
||||
const { check, result, isLoading, error, reset } = useLoanEligibility();
|
||||
|
||||
const [monthlyIncome, setMonthlyIncome] = useState('');
|
||||
const [existingLiabilities, setExistingLiabilities] = useState('');
|
||||
const [employmentType, setEmploymentType] = useState<EmploymentType | null>(null);
|
||||
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleCheck = async () => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!monthlyIncome || Number(monthlyIncome) <= 0) errors.income = 'Enter valid monthly income';
|
||||
if (!employmentType) errors.employment = 'Select employment type';
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
setValidationErrors({});
|
||||
await check({
|
||||
category,
|
||||
monthlyIncome: Number(monthlyIncome),
|
||||
existingLiabilities: Number(existingLiabilities) || 0,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Eligibility Check" subtitle={category} scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
{!result ? (
|
||||
<>
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Check your eligibility</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Enter your financial details to see how much you can borrow.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Monthly Income (₹)"
|
||||
placeholder="e.g. 75000"
|
||||
keyboardType="numeric"
|
||||
value={monthlyIncome}
|
||||
onChangeText={setMonthlyIncome}
|
||||
error={validationErrors.income}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Existing EMIs / Liabilities (₹)"
|
||||
placeholder="e.g. 10000"
|
||||
keyboardType="numeric"
|
||||
value={existingLiabilities}
|
||||
onChangeText={setExistingLiabilities}
|
||||
/>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
|
||||
EMPLOYMENT TYPE
|
||||
</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{EMPLOYMENT_TYPES.map((type) => (
|
||||
<Pressable
|
||||
key={type}
|
||||
onPress={() => setEmploymentType(type)}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: employmentType === type ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: employmentType === type ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.chipText,
|
||||
{ color: employmentType === type ? theme.colors.white : theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
{validationErrors.employment && (
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>
|
||||
{validationErrors.employment}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
title="Check Eligibility"
|
||||
onPress={handleCheck}
|
||||
loading={isLoading}
|
||||
leftAccessory={<Ionicons name="shield-checkmark-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EligibilityResultCard result={result} />
|
||||
<View style={styles.resultActions}>
|
||||
{result.eligible && (
|
||||
<Button
|
||||
title="Continue to Apply"
|
||||
onPress={() =>
|
||||
navigation.navigate('ApplyLoan', {
|
||||
category,
|
||||
eligibleAmount: result.eligibleAmount,
|
||||
})
|
||||
}
|
||||
leftAccessory={<Ionicons name="arrow-forward-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
)}
|
||||
<Button title="Check Again" variant="secondary" onPress={reset} />
|
||||
<Button
|
||||
title="EMI Calculator"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
navigation.navigate('LoanCalculator', {
|
||||
prefillAmount: result.eligibleAmount,
|
||||
prefillRate: result.interestRate,
|
||||
prefillTenure: result.maxTenureMonths,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 16, paddingBottom: 24 },
|
||||
formCard: { gap: 16 },
|
||||
sectionHeader: { gap: 4 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '800' },
|
||||
sectionSubtitle: { fontSize: 13, lineHeight: 18 },
|
||||
fieldGroup: { gap: 8 },
|
||||
fieldLabel: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5, marginLeft: 4 },
|
||||
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
chip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, borderWidth: 1 },
|
||||
chipText: { fontSize: 13, fontWeight: '700' },
|
||||
errorText: { fontSize: 12, fontWeight: '500', marginLeft: 4 },
|
||||
resultActions: { gap: 10 },
|
||||
spacer: { height: 40 },
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import { Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { useGetLoanHistoryQuery } from '../services/loanApi';
|
||||
import { LoanFilterChip } from '../components/LoanFilterChip';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'LoanHistory'>;
|
||||
|
||||
type FilterKey = 'all' | 'success' | 'failed';
|
||||
const FILTERS: { id: FilterKey; label: string }[] = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'success', label: 'Successful' },
|
||||
{ id: 'failed', label: 'Failed' },
|
||||
];
|
||||
|
||||
export default function LoanHistoryScreen(_props: Props) {
|
||||
const theme = useTheme();
|
||||
const { data: history, isLoading, isError, refetch } = useGetLoanHistoryQuery();
|
||||
const [filter, setFilter] = useState<FilterKey>('all');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const items = history ?? [];
|
||||
if (filter === 'all') return items;
|
||||
return items.filter((i) => i.status === filter);
|
||||
}, [history, filter]);
|
||||
|
||||
const totalPaid = useMemo(
|
||||
() => (history ?? []).filter((h) => h.status === 'success').reduce((sum, h) => sum + h.amount, 0),
|
||||
[history],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Payment History" subtitle="All EMI payments across your loans" scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||
{/* Summary */}
|
||||
<Card style={styles.summaryCard}>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Total Paid</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(totalPaid)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Transactions</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{(history ?? []).length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Filters */}
|
||||
<View style={styles.filterRow}>
|
||||
{FILTERS.map((f) => (
|
||||
<LoanFilterChip key={f.id} label={f.label} selected={filter === f.id} onPress={() => setFilter(f.id)} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<Card><Loader /></Card>
|
||||
) : isError ? (
|
||||
<EmptyState title="Unable to load history" description="Please try again." actionLabel="Retry" onAction={refetch} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState title="No payments found" description="EMI payment records will appear here." />
|
||||
) : (
|
||||
<Card style={styles.listCard}>
|
||||
{filtered.map((record, index) => (
|
||||
<View
|
||||
key={record.id}
|
||||
style={[
|
||||
styles.recordRow,
|
||||
index < filtered.length - 1 && { borderBottomWidth: 1, borderBottomColor: theme.colors.cardBorder },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.recordIcon, { backgroundColor: record.status === 'success' ? '#10B98114' : '#EF444414' }]}>
|
||||
<Ionicons
|
||||
name={record.status === 'success' ? 'checkmark-circle' : 'close-circle'}
|
||||
size={20}
|
||||
color={record.status === 'success' ? '#10B981' : '#EF4444'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.recordCopy}>
|
||||
<Text style={[styles.recordType, { color: theme.colors.textPrimary }]}>{record.loanType}</Text>
|
||||
<Text style={[styles.recordMeta, { color: theme.colors.textMuted }]}>
|
||||
{record.method} · {formatDate(record.date)}
|
||||
</Text>
|
||||
<Text style={[styles.recordRef, { color: theme.colors.textMuted }]}>Ref: {record.transactionRef}</Text>
|
||||
</View>
|
||||
<View style={styles.recordRight}>
|
||||
<Text style={[styles.recordAmount, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(record.amount)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.recordStatus,
|
||||
{ color: record.status === 'success' ? '#10B981' : '#EF4444' },
|
||||
]}
|
||||
>
|
||||
{record.status === 'success' ? 'Paid' : 'Failed'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 14, paddingBottom: 24 },
|
||||
summaryCard: {},
|
||||
summaryRow: { flexDirection: 'row', gap: 16 },
|
||||
summaryItem: { flex: 1, gap: 4 },
|
||||
summaryLabel: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
summaryValue: { fontSize: 22, fontWeight: '900' },
|
||||
filterRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
listCard: { gap: 0, padding: 0 },
|
||||
recordRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 14 },
|
||||
recordIcon: { width: 40, height: 40, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
|
||||
recordCopy: { flex: 1, gap: 2 },
|
||||
recordType: { fontSize: 14, fontWeight: '700' },
|
||||
recordMeta: { fontSize: 12 },
|
||||
recordRef: { fontSize: 10 },
|
||||
recordRight: { alignItems: 'flex-end', gap: 4 },
|
||||
recordAmount: { fontSize: 15, fontWeight: '800' },
|
||||
recordStatus: { fontSize: 11, fontWeight: '700' },
|
||||
spacer: { height: 60 },
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import { Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { useGetLoanApplicationsQuery } from '../services/loanApi';
|
||||
import { LoanTimelineCard } from '../components/LoanTimelineCard';
|
||||
import { LoanFilterChip } from '../components/LoanFilterChip';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'LoanTracking'>;
|
||||
|
||||
type FilterKey = 'all' | 'Submitted' | 'Under Review' | 'Approved' | 'Rejected' | 'Disbursed';
|
||||
const FILTERS: { id: FilterKey; label: string }[] = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'Submitted', label: 'Submitted' },
|
||||
{ id: 'Under Review', label: 'In Review' },
|
||||
{ id: 'Approved', label: 'Approved' },
|
||||
{ id: 'Rejected', label: 'Rejected' },
|
||||
];
|
||||
|
||||
export default function LoanTrackingScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { data: applications, isLoading, isError, refetch } = useGetLoanApplicationsQuery();
|
||||
const [filter, setFilter] = useState<FilterKey>('all');
|
||||
|
||||
const filtered = (applications ?? []).filter(
|
||||
(app) => filter === 'all' || app.status === filter,
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Application Tracking" subtitle="Track the status of your loan applications" scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||
{/* Filters */}
|
||||
<View style={styles.filterRow}>
|
||||
{FILTERS.map((f) => (
|
||||
<LoanFilterChip
|
||||
key={f.id}
|
||||
label={f.label}
|
||||
selected={filter === f.id}
|
||||
onPress={() => setFilter(f.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<Card><Loader /></Card>
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
title="Unable to load applications"
|
||||
description="Please try again."
|
||||
actionLabel="Retry"
|
||||
onAction={refetch}
|
||||
/>
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No applications found"
|
||||
description="You haven't applied for any loans yet."
|
||||
actionLabel="Apply for Loan"
|
||||
onAction={() => navigation.navigate('LoanTypesListing')}
|
||||
/>
|
||||
) : (
|
||||
filtered.map((app) => (
|
||||
<View key={app.id}>
|
||||
<LoanTimelineCard
|
||||
title={`${app.type} — ${formatCurrency(app.amount)}`}
|
||||
subtitle={`Applied on ${formatDate(app.appliedDate)} · Docs: ${app.documentsUploaded}/${app.documentsRequired}`}
|
||||
timeline={app.timeline}
|
||||
/>
|
||||
{app.approvedAmount && app.status === 'Approved' && (
|
||||
<Card style={styles.approvedCard}>
|
||||
<View style={styles.approvedRow}>
|
||||
<Text style={[styles.approvedLabel, { color: theme.colors.textSecondary }]}>Approved Amount</Text>
|
||||
<Text style={[styles.approvedValue, { color: '#10B981' }]}>{formatCurrency(app.approvedAmount)}</Text>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 14, paddingBottom: 24 },
|
||||
filterRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
approvedCard: { marginTop: -6 },
|
||||
approvedRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
approvedLabel: { fontSize: 13, fontWeight: '600' },
|
||||
approvedValue: { fontSize: 18, fontWeight: '900' },
|
||||
spacer: { height: 60 },
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import type { LoanStackParamList } from '../../../navigation/types';
|
||||
import { Loader, ScreenShell } from '../../../components';
|
||||
import { useGetLoanProductsQuery } from '../services/loanApi';
|
||||
import { LoanOfferCard } from '../components/LoanOfferCard';
|
||||
|
||||
type Props = NativeStackScreenProps<LoanStackParamList, 'LoanTypesListing'>;
|
||||
|
||||
export default function LoanTypesListingScreen({ navigation }: Props) {
|
||||
const { data: products, isLoading } = useGetLoanProductsQuery();
|
||||
|
||||
return (
|
||||
<ScreenShell title="Select Loan Type" subtitle="Choose the type of loan you want to apply for" scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
{(products ?? []).map((product) => (
|
||||
<LoanOfferCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onPress={() =>
|
||||
navigation.navigate('LoanEligibilityCheck', { category: product.category })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { paddingBottom: 24 },
|
||||
list: { gap: 14 },
|
||||
spacer: { height: 40 },
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import type {
|
||||
ActiveLoan,
|
||||
LoanApplicationStatus,
|
||||
LoanDocument,
|
||||
LoanEligibilityResult,
|
||||
LoanEmiScheduleItem,
|
||||
LoanForeclosureQuote,
|
||||
LoanNotification,
|
||||
LoanPaymentRecord,
|
||||
LoanProduct,
|
||||
LoanCategory,
|
||||
LoanPaymentMethod,
|
||||
} from '../../../types/app';
|
||||
import {
|
||||
demoLoanProducts,
|
||||
demoActiveLoans,
|
||||
demoLoanApplications,
|
||||
demoLoanDocuments,
|
||||
demoLoanPaymentHistory,
|
||||
demoLoanNotifications,
|
||||
demoForeclosureQuote,
|
||||
} from '../../../constants/mockData';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { resolveWithMock } from '../../../services/api/serviceHelpers';
|
||||
import { generateAmortizationSchedule } from '../utils/emi';
|
||||
|
||||
export const loanApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getLoanProducts: builder.query<LoanProduct[], void>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: demoLoanProducts,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<LoanProduct[]>(endpoints.loans.products);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['Loans'],
|
||||
}),
|
||||
|
||||
getActiveLoans: builder.query<ActiveLoan[], void>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: demoActiveLoans,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<ActiveLoan[]>(endpoints.loans.active);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['LoanActive'],
|
||||
}),
|
||||
|
||||
getLoanDetail: builder.query<ActiveLoan, string>({
|
||||
queryFn: (loanId) => {
|
||||
const fallbackLoan =
|
||||
demoActiveLoans.find((loan) => loan.id === loanId) ?? demoActiveLoans[0];
|
||||
|
||||
if (!fallbackLoan) {
|
||||
return {
|
||||
error: {
|
||||
status: 404,
|
||||
message: 'Loan not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return resolveWithMock({
|
||||
mockData: fallbackLoan,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<ActiveLoan>(endpoints.loans.detail(loanId));
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
},
|
||||
providesTags: (_result, _error, loanId) => [{ type: 'LoanActive', id: loanId }],
|
||||
}),
|
||||
|
||||
getEmiSchedule: builder.query<LoanEmiScheduleItem[], string>({
|
||||
queryFn: (loanId) => {
|
||||
const loan = demoActiveLoans.find((l) => l.id === loanId) ?? demoActiveLoans[0];
|
||||
if (!loan) {
|
||||
return {
|
||||
error: {
|
||||
status: 404,
|
||||
message: 'Loan not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
const schedule = generateAmortizationSchedule(
|
||||
loan.principalAmount,
|
||||
loan.interestRate,
|
||||
loan.tenureMonths,
|
||||
);
|
||||
return resolveWithMock({
|
||||
mockData: schedule,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<LoanEmiScheduleItem[]>(
|
||||
endpoints.loans.emiSchedule(loanId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
getLoanApplications: builder.query<LoanApplicationStatus[], void>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: demoLoanApplications,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<LoanApplicationStatus[]>(
|
||||
endpoints.loans.applications,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['LoanApplications'],
|
||||
}),
|
||||
|
||||
getLoanDocuments: builder.query<LoanDocument[], void>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: demoLoanDocuments,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<LoanDocument[]>(endpoints.loans.documents);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['LoanDocuments'],
|
||||
}),
|
||||
|
||||
getLoanHistory: builder.query<LoanPaymentRecord[], void>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: demoLoanPaymentHistory,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<LoanPaymentRecord[]>(endpoints.loans.history);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['LoanHistory'],
|
||||
}),
|
||||
|
||||
getLoanNotifications: builder.query<LoanNotification[], void>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: demoLoanNotifications,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<LoanNotification[]>(
|
||||
endpoints.loans.notifications,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['LoanNotifications'],
|
||||
}),
|
||||
|
||||
getForeclosureQuote: builder.query<LoanForeclosureQuote, string>({
|
||||
queryFn: (loanId) =>
|
||||
resolveWithMock({
|
||||
mockData: { ...demoForeclosureQuote, loanId },
|
||||
request: async () => {
|
||||
const response = await apiClient.get<LoanForeclosureQuote>(
|
||||
endpoints.loans.foreclosureQuote(loanId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
checkEligibility: builder.mutation<
|
||||
LoanEligibilityResult,
|
||||
{ category: LoanCategory; monthlyIncome: number; existingLiabilities: number }
|
||||
>({
|
||||
queryFn: ({ category, monthlyIncome, existingLiabilities }) => {
|
||||
const product = demoLoanProducts.find((p) => p.category === category);
|
||||
const disposableIncome = monthlyIncome - existingLiabilities;
|
||||
const maxEmi = disposableIncome * 0.5;
|
||||
const rate = product?.interestRate ?? 10;
|
||||
const monthlyRate = rate / 1200;
|
||||
const tenure = product?.tenureMonths ?? 60;
|
||||
const eligibleAmount = maxEmi > 0
|
||||
? Math.min(
|
||||
Math.floor((maxEmi * ((1 + monthlyRate) ** tenure - 1)) / (monthlyRate * (1 + monthlyRate) ** tenure)),
|
||||
product?.maxAmount ?? 0,
|
||||
)
|
||||
: 0;
|
||||
const emi = eligibleAmount > 0
|
||||
? (eligibleAmount * monthlyRate * (1 + monthlyRate) ** tenure) / ((1 + monthlyRate) ** tenure - 1)
|
||||
: 0;
|
||||
|
||||
return resolveWithMock({
|
||||
mockData: {
|
||||
eligible: eligibleAmount >= (product?.minAmount ?? 0),
|
||||
eligibleAmount,
|
||||
maxTenureMonths: tenure,
|
||||
interestRate: rate,
|
||||
estimatedEmi: Math.round(emi),
|
||||
reason: eligibleAmount < (product?.minAmount ?? 0)
|
||||
? 'Your disposable income is insufficient for the minimum loan amount.'
|
||||
: undefined,
|
||||
} as LoanEligibilityResult,
|
||||
request: async () => {
|
||||
const response = await apiClient.post<LoanEligibilityResult>(
|
||||
endpoints.loans.eligibility,
|
||||
{ category, monthlyIncome, existingLiabilities },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
delayMs: 600,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
submitLoanApplication: builder.mutation<
|
||||
{ applicationId: string; status: string },
|
||||
{ category: LoanCategory; amount: number; tenureMonths: number; purpose: string }
|
||||
>({
|
||||
queryFn: (payload) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
applicationId: `LAPP_${Date.now()}`,
|
||||
status: 'Submitted',
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post(endpoints.loans.apply, payload);
|
||||
return response.data;
|
||||
},
|
||||
delayMs: 800,
|
||||
}),
|
||||
invalidatesTags: ['LoanApplications'],
|
||||
}),
|
||||
|
||||
payEmi: builder.mutation<
|
||||
{ success: boolean; transactionRef: string },
|
||||
{ loanId: string; amount: number; method: LoanPaymentMethod }
|
||||
>({
|
||||
queryFn: (payload) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
success: true,
|
||||
transactionRef: `TXN${Date.now()}`,
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post(endpoints.loans.payEmi, payload);
|
||||
return response.data;
|
||||
},
|
||||
delayMs: 1200,
|
||||
}),
|
||||
invalidatesTags: ['LoanActive', 'LoanHistory'],
|
||||
}),
|
||||
|
||||
requestForeclosure: builder.mutation<
|
||||
{ success: boolean; referenceId: string },
|
||||
{ loanId: string }
|
||||
>({
|
||||
queryFn: (payload) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
success: true,
|
||||
referenceId: `FC${Date.now()}`,
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post(
|
||||
endpoints.loans.foreclosureQuote(payload.loanId),
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
delayMs: 1000,
|
||||
}),
|
||||
invalidatesTags: ['LoanActive'],
|
||||
}),
|
||||
|
||||
submitPrepayment: builder.mutation<
|
||||
{ success: boolean; newOutstanding: number },
|
||||
{ loanId: string; amount: number }
|
||||
>({
|
||||
queryFn: (payload) => {
|
||||
const loan = demoActiveLoans.find((l) => l.id === payload.loanId);
|
||||
return resolveWithMock({
|
||||
mockData: {
|
||||
success: true,
|
||||
newOutstanding: Math.max((loan?.outstandingBalance ?? 0) - payload.amount, 0),
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post(
|
||||
endpoints.loans.prepay(payload.loanId),
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
delayMs: 800,
|
||||
});
|
||||
},
|
||||
invalidatesTags: ['LoanActive'],
|
||||
}),
|
||||
|
||||
uploadDocument: builder.mutation<
|
||||
{ documentId: string; status: string },
|
||||
{ name: string; type: string; uri: string }
|
||||
>({
|
||||
queryFn: (payload) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
documentId: `LDOC_${Date.now()}`,
|
||||
status: 'uploaded',
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post(endpoints.loans.uploadDocument, payload);
|
||||
return response.data;
|
||||
},
|
||||
delayMs: 500,
|
||||
}),
|
||||
invalidatesTags: ['LoanDocuments'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetLoanProductsQuery,
|
||||
useGetActiveLoansQuery,
|
||||
useGetLoanDetailQuery,
|
||||
useGetEmiScheduleQuery,
|
||||
useGetLoanApplicationsQuery,
|
||||
useGetLoanDocumentsQuery,
|
||||
useGetLoanHistoryQuery,
|
||||
useGetLoanNotificationsQuery,
|
||||
useGetForeclosureQuoteQuery,
|
||||
useCheckEligibilityMutation,
|
||||
useSubmitLoanApplicationMutation,
|
||||
usePayEmiMutation,
|
||||
useRequestForeclosureMutation,
|
||||
useSubmitPrepaymentMutation,
|
||||
useUploadDocumentMutation,
|
||||
} = loanApi;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { RootState } from '../../../app/store';
|
||||
|
||||
export const selectLoanState = (state: RootState) => state.loan;
|
||||
export const selectLoanProducts = (state: RootState) => state.loan.products;
|
||||
export const selectActiveLoans = (state: RootState) => state.loan.activeLoans;
|
||||
export const selectActiveLoansStatus = (state: RootState) => state.loan.activeLoansStatus;
|
||||
export const selectApplications = (state: RootState) => state.loan.applications;
|
||||
export const selectApplicationsStatus = (state: RootState) => state.loan.applicationsStatus;
|
||||
export const selectNotifications = (state: RootState) => state.loan.notifications;
|
||||
export const selectSelectedLoanId = (state: RootState) => state.loan.selectedLoanId;
|
||||
export const selectDraft = (state: RootState) => state.loan.draft;
|
||||
|
||||
export const selectTotalOutstanding = (state: RootState) =>
|
||||
state.loan.activeLoans
|
||||
.filter((l) => l.status === 'Active' || l.status === 'Overdue')
|
||||
.reduce((sum, l) => sum + l.outstandingBalance, 0);
|
||||
|
||||
export const selectNextEmi = (state: RootState) => {
|
||||
const active = state.loan.activeLoans
|
||||
.filter((l) => l.status === 'Active' || l.status === 'Overdue')
|
||||
.sort((a, b) => new Date(a.nextEmiDate).getTime() - new Date(b.nextEmiDate).getTime());
|
||||
return active[0] ?? null;
|
||||
};
|
||||
|
||||
export const selectSelectedLoan = (state: RootState) => {
|
||||
if (!state.loan.selectedLoanId) return null;
|
||||
return state.loan.activeLoans.find((l) => l.id === state.loan.selectedLoanId) ?? null;
|
||||
};
|
||||
|
||||
export const selectUnreadNotificationCount = (state: RootState) =>
|
||||
state.loan.notifications.filter((n) => !n.read).length;
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type {
|
||||
ActiveLoan,
|
||||
AsyncStatus,
|
||||
LoanApplicationDraft,
|
||||
LoanApplicationStatus,
|
||||
LoanCategory,
|
||||
LoanNotification,
|
||||
LoanProduct,
|
||||
EmploymentType,
|
||||
} from '../../../types/app';
|
||||
import {
|
||||
seedLoanProducts,
|
||||
fetchActiveLoans,
|
||||
fetchLoanApplications,
|
||||
fetchLoanNotifications,
|
||||
} from './loanThunk';
|
||||
|
||||
interface LoanState {
|
||||
status: AsyncStatus;
|
||||
products: LoanProduct[];
|
||||
activeLoans: ActiveLoan[];
|
||||
activeLoansStatus: AsyncStatus;
|
||||
applications: LoanApplicationStatus[];
|
||||
applicationsStatus: AsyncStatus;
|
||||
notifications: LoanNotification[];
|
||||
notificationsStatus: AsyncStatus;
|
||||
selectedLoanId: string | null;
|
||||
// Calculator state
|
||||
principal: string;
|
||||
rate: string;
|
||||
tenureMonths: string;
|
||||
// Application draft
|
||||
draft: LoanApplicationDraft;
|
||||
}
|
||||
|
||||
const initialDraft: LoanApplicationDraft = {
|
||||
loanCategory: null,
|
||||
amount: '',
|
||||
tenureMonths: '',
|
||||
monthlyIncome: '',
|
||||
employmentType: null,
|
||||
existingLiabilities: '',
|
||||
purpose: '',
|
||||
documents: [],
|
||||
};
|
||||
|
||||
const initialState: LoanState = {
|
||||
status: 'idle',
|
||||
products: [],
|
||||
activeLoans: [],
|
||||
activeLoansStatus: 'idle',
|
||||
applications: [],
|
||||
applicationsStatus: 'idle',
|
||||
notifications: [],
|
||||
notificationsStatus: 'idle',
|
||||
selectedLoanId: null,
|
||||
principal: '500000',
|
||||
rate: '8.45',
|
||||
tenureMonths: '60',
|
||||
draft: { ...initialDraft },
|
||||
};
|
||||
|
||||
const loanSlice = createSlice({
|
||||
name: 'loan',
|
||||
initialState,
|
||||
reducers: {
|
||||
setPrincipal(state, action: PayloadAction<string>) {
|
||||
state.principal = action.payload;
|
||||
},
|
||||
setRate(state, action: PayloadAction<string>) {
|
||||
state.rate = action.payload;
|
||||
},
|
||||
setTenureMonths(state, action: PayloadAction<string>) {
|
||||
state.tenureMonths = action.payload;
|
||||
},
|
||||
setSelectedLoan(state, action: PayloadAction<string | null>) {
|
||||
state.selectedLoanId = action.payload;
|
||||
},
|
||||
setDraftCategory(state, action: PayloadAction<LoanCategory>) {
|
||||
state.draft.loanCategory = action.payload;
|
||||
},
|
||||
setDraftAmount(state, action: PayloadAction<string>) {
|
||||
state.draft.amount = action.payload;
|
||||
},
|
||||
setDraftTenure(state, action: PayloadAction<string>) {
|
||||
state.draft.tenureMonths = action.payload;
|
||||
},
|
||||
setDraftIncome(state, action: PayloadAction<string>) {
|
||||
state.draft.monthlyIncome = action.payload;
|
||||
},
|
||||
setDraftEmploymentType(state, action: PayloadAction<EmploymentType>) {
|
||||
state.draft.employmentType = action.payload;
|
||||
},
|
||||
setDraftLiabilities(state, action: PayloadAction<string>) {
|
||||
state.draft.existingLiabilities = action.payload;
|
||||
},
|
||||
setDraftPurpose(state, action: PayloadAction<string>) {
|
||||
state.draft.purpose = action.payload;
|
||||
},
|
||||
resetDraft(state) {
|
||||
state.draft = { ...initialDraft };
|
||||
},
|
||||
markNotificationRead(state, action: PayloadAction<string>) {
|
||||
const notification = state.notifications.find((n) => n.id === action.payload);
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Seed products
|
||||
.addCase(seedLoanProducts.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
})
|
||||
.addCase(seedLoanProducts.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.products = action.payload;
|
||||
})
|
||||
.addCase(seedLoanProducts.rejected, (state) => {
|
||||
state.status = 'failed';
|
||||
})
|
||||
// Active loans
|
||||
.addCase(fetchActiveLoans.pending, (state) => {
|
||||
state.activeLoansStatus = 'loading';
|
||||
})
|
||||
.addCase(fetchActiveLoans.fulfilled, (state, action) => {
|
||||
state.activeLoansStatus = 'succeeded';
|
||||
state.activeLoans = action.payload;
|
||||
})
|
||||
.addCase(fetchActiveLoans.rejected, (state) => {
|
||||
state.activeLoansStatus = 'failed';
|
||||
})
|
||||
// Applications
|
||||
.addCase(fetchLoanApplications.pending, (state) => {
|
||||
state.applicationsStatus = 'loading';
|
||||
})
|
||||
.addCase(fetchLoanApplications.fulfilled, (state, action) => {
|
||||
state.applicationsStatus = 'succeeded';
|
||||
state.applications = action.payload;
|
||||
})
|
||||
.addCase(fetchLoanApplications.rejected, (state) => {
|
||||
state.applicationsStatus = 'failed';
|
||||
})
|
||||
// Notifications
|
||||
.addCase(fetchLoanNotifications.pending, (state) => {
|
||||
state.notificationsStatus = 'loading';
|
||||
})
|
||||
.addCase(fetchLoanNotifications.fulfilled, (state, action) => {
|
||||
state.notificationsStatus = 'succeeded';
|
||||
state.notifications = action.payload;
|
||||
})
|
||||
.addCase(fetchLoanNotifications.rejected, (state) => {
|
||||
state.notificationsStatus = 'failed';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const loanActions = loanSlice.actions;
|
||||
export const loanReducer = loanSlice.reducer;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
demoLoanProducts,
|
||||
demoActiveLoans,
|
||||
demoLoanApplications,
|
||||
demoLoanNotifications,
|
||||
} from '../../../constants/mockData';
|
||||
import type {
|
||||
ActiveLoan,
|
||||
LoanApplicationStatus,
|
||||
LoanNotification,
|
||||
LoanProduct,
|
||||
} from '../../../types/app';
|
||||
import { sleep } from '../../../utils/async';
|
||||
|
||||
export const seedLoanProducts = createAsyncThunk<LoanProduct[]>(
|
||||
'loan/seedProducts',
|
||||
async () => {
|
||||
await sleep(160);
|
||||
return demoLoanProducts;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchActiveLoans = createAsyncThunk<ActiveLoan[]>(
|
||||
'loan/fetchActiveLoans',
|
||||
async () => {
|
||||
await sleep(280);
|
||||
return demoActiveLoans;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchLoanApplications = createAsyncThunk<LoanApplicationStatus[]>(
|
||||
'loan/fetchLoanApplications',
|
||||
async () => {
|
||||
await sleep(220);
|
||||
return demoLoanApplications;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchLoanNotifications = createAsyncThunk<LoanNotification[]>(
|
||||
'loan/fetchLoanNotifications',
|
||||
async () => {
|
||||
await sleep(180);
|
||||
return demoLoanNotifications;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export type { LoanProduct } from '../../../types/app';
|
||||
@@ -0,0 +1,52 @@
|
||||
import { calculateMonthlyEmi, generateAmortizationSchedule } from './emi';
|
||||
|
||||
describe('EMI Utilities', () => {
|
||||
describe('calculateMonthlyEmi', () => {
|
||||
it('should calculate correct EMI for standard inputs', () => {
|
||||
// Principal: 1,00,000, Rate: 12% p.a., Tenure: 12 months
|
||||
// Formula result: ~8885
|
||||
const emi = calculateMonthlyEmi(100000, 12, 12);
|
||||
expect(Math.round(emi)).toBe(8885);
|
||||
});
|
||||
|
||||
it('should calculate correct EMI for long tenure', () => {
|
||||
// Principal: 50,00,000, Rate: 8.5% p.a., Tenure: 240 months (20 years)
|
||||
const emi = calculateMonthlyEmi(5000000, 8.5, 240);
|
||||
expect(Math.round(emi)).toBe(43391);
|
||||
});
|
||||
|
||||
it('should return 0 for zero principal', () => {
|
||||
const emi = calculateMonthlyEmi(0, 10, 12);
|
||||
expect(emi).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAmortizationSchedule', () => {
|
||||
it('should return correct number of months', () => {
|
||||
const schedule = generateAmortizationSchedule(100000, 12, 12);
|
||||
expect(schedule.length).toBe(12);
|
||||
});
|
||||
|
||||
it('should have principal and interest components summing to EMI', () => {
|
||||
const principal = 100000;
|
||||
const rate = 12;
|
||||
const tenure = 12;
|
||||
const emi = calculateMonthlyEmi(principal, rate, tenure);
|
||||
const schedule = generateAmortizationSchedule(principal, rate, tenure);
|
||||
|
||||
schedule.forEach((row) => {
|
||||
expect(row.emiAmount).toBeCloseTo(emi, 0);
|
||||
expect(row.principalComponent + row.interestComponent).toBeCloseTo(emi, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should end with near zero balance', () => {
|
||||
const schedule = generateAmortizationSchedule(100000, 12, 12);
|
||||
const lastRow = schedule[schedule.length - 1];
|
||||
if (!lastRow) {
|
||||
throw new Error('Expected amortization schedule to have a final row');
|
||||
}
|
||||
expect(lastRow.outstandingAfter).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { LoanEmiScheduleItem } from '../../../types/app';
|
||||
|
||||
export const calculateMonthlyEmi = (principal: number, annualRate: number, tenureMonths: number) => {
|
||||
const monthlyRate = annualRate / 1200;
|
||||
|
||||
if (monthlyRate === 0) {
|
||||
return principal / tenureMonths;
|
||||
}
|
||||
|
||||
return (
|
||||
(principal * monthlyRate * (1 + monthlyRate) ** tenureMonths) /
|
||||
((1 + monthlyRate) ** tenureMonths - 1)
|
||||
);
|
||||
};
|
||||
|
||||
export const generateAmortizationSchedule = (
|
||||
principal: number,
|
||||
annualRate: number,
|
||||
tenureMonths: number,
|
||||
): LoanEmiScheduleItem[] => {
|
||||
const monthlyRate = annualRate / 1200;
|
||||
const emi = calculateMonthlyEmi(principal, annualRate, tenureMonths);
|
||||
const schedule: LoanEmiScheduleItem[] = [];
|
||||
let outstanding = principal;
|
||||
|
||||
for (let month = 1; month <= tenureMonths; month++) {
|
||||
const interestComponent = outstanding * monthlyRate;
|
||||
const principalComponent = emi - interestComponent;
|
||||
outstanding = Math.max(outstanding - principalComponent, 0);
|
||||
|
||||
schedule.push({
|
||||
month,
|
||||
emiAmount: Math.round(emi),
|
||||
principalComponent: Math.round(principalComponent),
|
||||
interestComponent: Math.round(interestComponent),
|
||||
outstandingAfter: Math.round(outstanding),
|
||||
});
|
||||
}
|
||||
|
||||
return schedule;
|
||||
};
|
||||
|
||||
export const calculateForeclosureAmount = (
|
||||
outstandingPrincipal: number,
|
||||
chargePercent: number,
|
||||
interestDue: number,
|
||||
): number => {
|
||||
const foreclosureCharges = outstandingPrincipal * (chargePercent / 100);
|
||||
return Math.round(outstandingPrincipal + interestDue + foreclosureCharges);
|
||||
};
|
||||
|
||||
export const calculatePrepaymentSavings = (
|
||||
outstanding: number,
|
||||
prepayAmount: number,
|
||||
annualRate: number,
|
||||
remainingTenure: number,
|
||||
): { newTenure: number; interestSaved: number } => {
|
||||
const newOutstanding = outstanding - prepayAmount;
|
||||
if (newOutstanding <= 0) {
|
||||
const totalInterestWithout = calculateMonthlyEmi(outstanding, annualRate, remainingTenure) * remainingTenure - outstanding;
|
||||
return { newTenure: 0, interestSaved: Math.round(totalInterestWithout) };
|
||||
}
|
||||
|
||||
const emi = calculateMonthlyEmi(outstanding, annualRate, remainingTenure);
|
||||
const monthlyRate = annualRate / 1200;
|
||||
|
||||
// Calculate new tenure with the same EMI
|
||||
let tempOutstanding = newOutstanding;
|
||||
let newTenure = 0;
|
||||
while (tempOutstanding > 0 && newTenure < remainingTenure) {
|
||||
const interest = tempOutstanding * monthlyRate;
|
||||
const principalPaid = emi - interest;
|
||||
if (principalPaid <= 0) break;
|
||||
tempOutstanding -= principalPaid;
|
||||
newTenure++;
|
||||
}
|
||||
|
||||
const totalInterestWithout = emi * remainingTenure - outstanding;
|
||||
const totalInterestWith = emi * newTenure - newOutstanding;
|
||||
const interestSaved = Math.max(Math.round(totalInterestWithout - totalInterestWith), 0);
|
||||
|
||||
return { newTenure, interestSaved };
|
||||
};
|
||||
Reference in New Issue
Block a user