Fix form keyboard issues and sticky footers in agent screens

This commit is contained in:
Abin Toppo
2026-05-26 16:34:05 +05:30
commit 9ac5e95d52
410 changed files with 86650 additions and 0 deletions
@@ -0,0 +1,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,
};
};
+12
View File
@@ -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 },
});
+338
View File
@@ -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;
+31
View File
@@ -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;
+162
View File
@@ -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;
+47
View File
@@ -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;
},
);
+1
View File
@@ -0,0 +1 @@
export type { LoanProduct } from '../../../types/app';
+52
View File
@@ -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);
});
});
});
+83
View File
@@ -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 };
};