Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { Card, Input, ScreenShell } from '../../../components';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { DepositOptionChip } from '../components/DepositOptionChip';
|
||||
import {
|
||||
selectFixedDepositProduct,
|
||||
selectRecurringDepositProduct,
|
||||
} from '../store/depositSelectors';
|
||||
import {
|
||||
calculateFixedDepositMaturity,
|
||||
calculateRecurringDepositMaturity,
|
||||
} from '../utils/depositCalculators';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'DepositCalculator'>,
|
||||
BottomTabScreenProps<MainTabParamList, 'Services'>
|
||||
>;
|
||||
|
||||
type CalculatorMode = 'fd' | 'rd';
|
||||
type TenureUnit = 'months' | 'years';
|
||||
|
||||
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
|
||||
|
||||
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d.]/g, ''));
|
||||
|
||||
export default function DepositCalculatorScreen({ route }: Props) {
|
||||
const theme = useTheme();
|
||||
const fixedProduct = useAppSelector(selectFixedDepositProduct);
|
||||
const recurringProduct = useAppSelector(selectRecurringDepositProduct);
|
||||
|
||||
const [mode, setMode] = useState<CalculatorMode>(route.params?.initialKind ?? 'fd');
|
||||
const [amount, setAmount] = useState('100000');
|
||||
const [monthlyInstallment, setMonthlyInstallment] = useState('5000');
|
||||
const [rate, setRate] = useState(String(fixedProduct?.rate ?? 7.1));
|
||||
const [tenureValue, setTenureValue] = useState('12');
|
||||
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
|
||||
|
||||
useEffect(() => {
|
||||
if (route.params?.initialKind) {
|
||||
setMode(route.params.initialKind);
|
||||
}
|
||||
}, [route.params?.initialKind]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextRate =
|
||||
mode === 'fd' ? fixedProduct?.rate ?? 7.1 : recurringProduct?.rate ?? 6.8;
|
||||
|
||||
setRate(String(nextRate));
|
||||
}, [fixedProduct?.rate, mode, recurringProduct?.rate]);
|
||||
|
||||
const tenureMonths = useMemo(() => {
|
||||
const numericValue = toPositiveNumber(tenureValue);
|
||||
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
|
||||
}, [tenureUnit, tenureValue]);
|
||||
|
||||
const amountNumber = useMemo(() => toPositiveNumber(amount), [amount]);
|
||||
const monthlyInstallmentNumber = useMemo(
|
||||
() => toPositiveNumber(monthlyInstallment),
|
||||
[monthlyInstallment],
|
||||
);
|
||||
const rateNumber = useMemo(() => Number(rate), [rate]);
|
||||
|
||||
const maturityAmount = useMemo(
|
||||
() =>
|
||||
mode === 'fd'
|
||||
? calculateFixedDepositMaturity(amountNumber, rateNumber, tenureMonths, 'cumulative')
|
||||
: calculateRecurringDepositMaturity(
|
||||
monthlyInstallmentNumber,
|
||||
rateNumber,
|
||||
tenureMonths,
|
||||
),
|
||||
[amountNumber, mode, monthlyInstallmentNumber, rateNumber, tenureMonths],
|
||||
);
|
||||
|
||||
const totalInvestment =
|
||||
mode === 'fd' ? amountNumber : monthlyInstallmentNumber * tenureMonths;
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Deposit calculator"
|
||||
subtitle="Compare projected returns for fixed and recurring deposits"
|
||||
scrollable={false}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
|
||||
Calculator type
|
||||
</Text>
|
||||
<View style={styles.optionRow}>
|
||||
<DepositOptionChip
|
||||
label="Fixed deposit"
|
||||
selected={mode === 'fd'}
|
||||
onPress={() => setMode('fd')}
|
||||
/>
|
||||
<DepositOptionChip
|
||||
label="Recurring deposit"
|
||||
selected={mode === 'rd'}
|
||||
onPress={() => setMode('rd')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{mode === 'fd' ? (
|
||||
<Input
|
||||
label="Principal amount"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
keyboardType="number-pad"
|
||||
placeholder="100000"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
label="Monthly deposit"
|
||||
value={monthlyInstallment}
|
||||
onChangeText={setMonthlyInstallment}
|
||||
keyboardType="number-pad"
|
||||
placeholder="5000"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Interest rate (%)"
|
||||
value={rate}
|
||||
onChangeText={setRate}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="7.1"
|
||||
/>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
|
||||
Tenure
|
||||
</Text>
|
||||
<Input
|
||||
value={tenureValue}
|
||||
onChangeText={setTenureValue}
|
||||
keyboardType="number-pad"
|
||||
placeholder="12"
|
||||
/>
|
||||
<View style={styles.optionRow}>
|
||||
{TENURE_UNITS.map((unit) => (
|
||||
<DepositOptionChip
|
||||
key={unit}
|
||||
label={unit === 'months' ? 'Months' : 'Years'}
|
||||
selected={tenureUnit === unit}
|
||||
onPress={() => setTenureUnit(unit)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.resultCard}>
|
||||
<View style={styles.resultHeader}>
|
||||
<View style={[styles.resultIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="calculator-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.resultCopy}>
|
||||
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]}>
|
||||
Estimated maturity
|
||||
</Text>
|
||||
<Text style={[styles.resultSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Based on the rate and tenure entered above
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.resultValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.round(maturityAmount))}
|
||||
</Text>
|
||||
|
||||
<View style={styles.resultGrid}>
|
||||
<View style={[styles.resultTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total contribution
|
||||
</Text>
|
||||
<Text style={[styles.resultTileValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(totalInvestment || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.resultTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated gains
|
||||
</Text>
|
||||
<Text style={[styles.resultTileValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.max(Math.round(maturityAmount) - totalInvestment, 0))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
keyboardAvoider: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
formCard: {
|
||||
gap: 18,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 10,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
resultCard: {
|
||||
gap: 18,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
resultIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
resultCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
resultSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
resultValue: {
|
||||
fontSize: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.8,
|
||||
},
|
||||
resultGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
resultTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
resultLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
resultTileValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { AppModal, Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { TransactionItem } from '../../cards/components/TransactionItem';
|
||||
import { ExpandableSection } from '../../cards/components/ExpandableSection';
|
||||
import { DepositStatusPill } from '../components/DepositStatusPill';
|
||||
import {
|
||||
useCloseDepositMutation,
|
||||
useGetDepositDetailQuery,
|
||||
} from '../services/depositApi';
|
||||
import {
|
||||
calculatePrematureClosureAmount,
|
||||
getDaysUntilMaturity,
|
||||
getDepositProgress,
|
||||
isDepositNearMaturity,
|
||||
} from '../utils/depositCalculators';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'DepositDetails'>,
|
||||
BottomTabScreenProps<MainTabParamList, 'Services'>
|
||||
>;
|
||||
|
||||
export default function DepositDetailsScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||
const { data: deposit, isLoading, isError, refetch } = useGetDepositDetailQuery({
|
||||
depositId: route.params.depositId,
|
||||
kind: route.params.kind,
|
||||
});
|
||||
const [closeDeposit, closeState] = useCloseDepositMutation();
|
||||
|
||||
const nearMaturity = deposit ? isDepositNearMaturity(deposit) : false;
|
||||
const closureQuote = deposit ? calculatePrematureClosureAmount(deposit) : null;
|
||||
const progress = deposit
|
||||
? getDepositProgress(deposit.startDate, deposit.maturityDate)
|
||||
: 0;
|
||||
const daysUntilMaturity = deposit
|
||||
? Math.max(getDaysUntilMaturity(deposit.maturityDate), 0)
|
||||
: 0;
|
||||
|
||||
const history = useMemo(
|
||||
() =>
|
||||
(deposit?.transactions ?? []).map((transaction) => ({
|
||||
id: transaction.id,
|
||||
merchant: transaction.title,
|
||||
amount: transaction.amount,
|
||||
direction: transaction.direction,
|
||||
status: transaction.status,
|
||||
occurredAt: transaction.occurredAt,
|
||||
category: transaction.category,
|
||||
})),
|
||||
[deposit?.transactions],
|
||||
);
|
||||
|
||||
const handleCloseDeposit = async () => {
|
||||
if (!deposit) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await closeDeposit({
|
||||
depositId: deposit.id,
|
||||
kind: deposit.kind,
|
||||
}).unwrap();
|
||||
|
||||
setConfirmVisible(false);
|
||||
toast(
|
||||
deposit.status === 'matured' ? 'Maturity proceeds credited' : 'Deposit closed',
|
||||
'success',
|
||||
deposit.status === 'matured'
|
||||
? 'The maturity amount has been credited to your linked account.'
|
||||
: 'Closure request completed successfully.',
|
||||
);
|
||||
} catch {
|
||||
toast(
|
||||
'Unable to close deposit',
|
||||
'error',
|
||||
'Please try again in a moment.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ScreenShell title="Deposit details" subtitle="Loading selected deposit">
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !deposit) {
|
||||
return (
|
||||
<ScreenShell title="Deposit details" subtitle="We could not load the selected deposit">
|
||||
<EmptyState
|
||||
title="Deposit unavailable"
|
||||
description="Pull back and try opening the deposit again."
|
||||
actionLabel="Retry"
|
||||
onAction={refetch}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const canClose = deposit.status !== 'closed' && deposit.canWithdrawPrematurely;
|
||||
const closeActionLabel =
|
||||
deposit.status === 'matured' ? 'Credit maturity proceeds' : 'Premature close';
|
||||
const investedAmount =
|
||||
deposit.kind === 'fd'
|
||||
? deposit.principalAmount
|
||||
: deposit.totalInvestment;
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Deposit details"
|
||||
subtitle={`${deposit.maskedNumber} · ${deposit.type}`}
|
||||
scrollable={false}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroTopRow}>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
{deposit.type}
|
||||
</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.maskedNumber}
|
||||
</Text>
|
||||
</View>
|
||||
<DepositStatusPill status={deposit.status} nearMaturity={nearMaturity} />
|
||||
</View>
|
||||
|
||||
<View style={styles.heroMetrics}>
|
||||
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Invested amount
|
||||
</Text>
|
||||
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(investedAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Maturity value
|
||||
</Text>
|
||||
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(deposit.maturityAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Interest earned
|
||||
</Text>
|
||||
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(deposit.interestEarned)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Tracking
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.heroMetricValue,
|
||||
{
|
||||
color:
|
||||
deposit.status === 'closed'
|
||||
? theme.colors.textSecondary
|
||||
: nearMaturity
|
||||
? theme.colors.warning
|
||||
: theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{deposit.status === 'closed'
|
||||
? 'Closed'
|
||||
: deposit.status === 'matured'
|
||||
? 'Ready'
|
||||
: `${daysUntilMaturity} days left`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressWrap}>
|
||||
<View style={[styles.progressTrack, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor:
|
||||
deposit.status === 'closed'
|
||||
? theme.colors.textMuted
|
||||
: nearMaturity
|
||||
? theme.colors.warning
|
||||
: theme.colors.accent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.progressCaption, { color: theme.colors.textSecondary }]}>
|
||||
Started on {formatDate(deposit.startDate)} · Matures on {formatDate(deposit.maturityDate)}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<ExpandableSection
|
||||
title="Deposit snapshot"
|
||||
subtitle="Principal, interest, payout, nominee, and settlement account"
|
||||
icon="document-text-outline"
|
||||
defaultExpanded
|
||||
>
|
||||
<View style={styles.detailGrid}>
|
||||
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Interest rate
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.interestRate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Tenure
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.tenureMonths} months
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Payout frequency
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.payoutLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Settlement account
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.settlementAccountMasked}
|
||||
</Text>
|
||||
</View>
|
||||
{deposit.nominee ? (
|
||||
<View style={[styles.detailTile, styles.detailTileWide, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Nominee
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.nominee.name} · {deposit.nominee.relationship}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ExpandableSection>
|
||||
|
||||
<ExpandableSection
|
||||
title="Returns and maturity"
|
||||
subtitle="Projected value, accrued gains, and current closure estimate"
|
||||
icon="analytics-outline"
|
||||
defaultExpanded
|
||||
>
|
||||
<View style={styles.detailGrid}>
|
||||
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total investment
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(deposit.totalInvestment)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated proceeds
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(closureQuote?.netAmount ?? deposit.maturityAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Penalty
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(closureQuote?.penaltyAmount ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
Maturity date
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(deposit.maturityDate)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ExpandableSection>
|
||||
|
||||
<ExpandableSection
|
||||
title="Transaction history"
|
||||
subtitle="Creation, installments, interest payouts, and closure credits"
|
||||
icon="swap-horizontal-outline"
|
||||
badge={`${history.length} entries`}
|
||||
>
|
||||
{history.length ? (
|
||||
history.map((item, index) => (
|
||||
<TransactionItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
showDivider={index !== history.length - 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No transactions yet"
|
||||
description="Transaction history will appear here after the deposit is created."
|
||||
/>
|
||||
)}
|
||||
</ExpandableSection>
|
||||
|
||||
<Card style={styles.actionsCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Actions
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Estimate new returns or close the deposit if you need funds earlier
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
title="Open calculator"
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
navigation.navigate('DepositCalculator', { initialKind: deposit.kind })
|
||||
}
|
||||
leftAccessory={<Ionicons name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
|
||||
{canClose ? (
|
||||
<Button
|
||||
title={closeActionLabel}
|
||||
variant={deposit.status === 'matured' ? 'primary' : 'danger'}
|
||||
loading={closeState.isLoading}
|
||||
onPress={() => setConfirmVisible(true)}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
name={deposit.status === 'matured' ? 'cash-outline' : 'close-circle-outline'}
|
||||
size={18}
|
||||
color={theme.colors.white}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
</ScrollView>
|
||||
|
||||
<AppModal
|
||||
visible={confirmVisible}
|
||||
title={closeActionLabel}
|
||||
description={
|
||||
deposit.status === 'matured'
|
||||
? 'The maturity amount will be credited to your linked settlement account.'
|
||||
: 'This will close the deposit before maturity and credit the net proceeds after penalty.'
|
||||
}
|
||||
onClose={() => setConfirmVisible(false)}
|
||||
>
|
||||
<View style={styles.modalList}>
|
||||
<View style={styles.modalRow}>
|
||||
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
|
||||
Net proceeds
|
||||
</Text>
|
||||
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(closureQuote?.netAmount ?? deposit.maturityAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.modalRow}>
|
||||
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
|
||||
Penalty
|
||||
</Text>
|
||||
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(closureQuote?.penaltyAmount ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.modalRow}>
|
||||
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
|
||||
Credit account
|
||||
</Text>
|
||||
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.settlementAccountMasked}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Button
|
||||
title="Cancel"
|
||||
variant="secondary"
|
||||
onPress={() => setConfirmVisible(false)}
|
||||
style={styles.modalButton}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<Button
|
||||
title="Confirm"
|
||||
variant={deposit.status === 'matured' ? 'primary' : 'danger'}
|
||||
onPress={handleCloseDeposit}
|
||||
loading={closeState.isLoading}
|
||||
style={styles.modalButton}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</View>
|
||||
</AppModal>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 18,
|
||||
},
|
||||
heroTopRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.6,
|
||||
},
|
||||
heroMetrics: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
heroMetricTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
heroMetricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroMetricValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '900',
|
||||
lineHeight: 22,
|
||||
},
|
||||
progressWrap: {
|
||||
gap: 10,
|
||||
},
|
||||
progressTrack: {
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
},
|
||||
progressCaption: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
detailGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
detailTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
detailTileWide: {
|
||||
width: '100%',
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
lineHeight: 20,
|
||||
},
|
||||
actionsCard: {
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
modalList: {
|
||||
gap: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
modalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
modalLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
modalValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
textAlign: 'right',
|
||||
flex: 1,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
|
||||
import { DepositAccountCard } from '../components/DepositAccountCard';
|
||||
import { DepositOptionChip } from '../components/DepositOptionChip';
|
||||
import { useDepositPortfolio } from '../hooks/useDepositPortfolio';
|
||||
import { getDaysUntilMaturity, isDepositNearMaturity } from '../utils/depositCalculators';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'DepositsOverview'>,
|
||||
BottomTabScreenProps<MainTabParamList, 'Services'>
|
||||
>;
|
||||
|
||||
type FilterKey = 'all' | 'fd' | 'rd' | 'maturing';
|
||||
|
||||
const FILTERS: Array<{ id: FilterKey; label: string }> = [
|
||||
{ id: 'all', label: 'All deposits' },
|
||||
{ id: 'fd', label: 'Fixed deposit' },
|
||||
{ id: 'rd', label: 'Recurring deposit' },
|
||||
{ id: 'maturing', label: 'Maturing soon' },
|
||||
];
|
||||
|
||||
export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const { deposits, isLoading, isError, onRefresh, refreshing } = useDepositPortfolio();
|
||||
const [filter, setFilter] = useState<FilterKey>(route.params?.initialKind ?? 'all');
|
||||
|
||||
useEffect(() => {
|
||||
if (route.params?.initialKind) {
|
||||
setFilter(route.params.initialKind);
|
||||
}
|
||||
}, [route.params?.initialKind]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const activeDeposits = deposits.filter((deposit) => deposit.status === 'active');
|
||||
const nearMaturityDeposits = deposits.filter((deposit) => isDepositNearMaturity(deposit));
|
||||
|
||||
return {
|
||||
totalDeposits: deposits.length,
|
||||
activeDepositsCount: activeDeposits.length,
|
||||
totalActiveValue: activeDeposits.reduce(
|
||||
(sum, deposit) => sum + deposit.totalInvestment,
|
||||
0,
|
||||
),
|
||||
projectedMaturityValue: deposits.reduce(
|
||||
(sum, deposit) => sum + deposit.maturityAmount,
|
||||
0,
|
||||
),
|
||||
nearMaturityDeposits,
|
||||
};
|
||||
}, [deposits]);
|
||||
|
||||
const filteredDeposits = useMemo(() => {
|
||||
switch (filter) {
|
||||
case 'fd':
|
||||
return deposits.filter((deposit) => deposit.kind === 'fd');
|
||||
case 'rd':
|
||||
return deposits.filter((deposit) => deposit.kind === 'rd');
|
||||
case 'maturing':
|
||||
return deposits.filter((deposit) => isDepositNearMaturity(deposit));
|
||||
case 'all':
|
||||
default:
|
||||
return deposits;
|
||||
}
|
||||
}, [deposits, filter]);
|
||||
|
||||
const selectedFilterLabel =
|
||||
FILTERS.find((item) => item.id === filter)?.label ?? 'All deposits';
|
||||
|
||||
const handleOpenFixedDeposit = () => navigation.navigate('OpenFixedDeposit');
|
||||
const handleOpenRecurringDeposit = () => navigation.navigate('OpenRecurringDeposit');
|
||||
const handleOpenCalculator = () => navigation.navigate('DepositCalculator');
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Deposits"
|
||||
subtitle="Fixed and recurring deposits, maturity tracking, and calculators"
|
||||
scrollable={false}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View style={styles.summaryGrid}>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroHeader}>
|
||||
<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.heroEyebrow, { color: theme.colors.primary }]}>
|
||||
Portfolio snapshot
|
||||
</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
Keep every deposit journey in one clear view
|
||||
</Text>
|
||||
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Review portfolio value, upcoming maturities, and quick actions without
|
||||
jumping across sections.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.heroHighlight,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<View style={styles.heroHighlightCopy}>
|
||||
<Text
|
||||
style={[
|
||||
styles.heroHighlightLabel,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
Active portfolio
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.heroHighlightValue,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{formatCompactCurrency(summary.totalActiveValue)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.heroHighlightCaption,
|
||||
{ color: theme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{summary.activeDepositsCount} active plans across fixed and recurring
|
||||
deposits
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.heroBadge,
|
||||
{ backgroundColor: `${theme.colors.warning}14` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroBadgeValue, { color: theme.colors.warning }]}>
|
||||
{summary.nearMaturityDeposits.length}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.heroBadgeLabel,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
maturing soon
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.heroMetrics}>
|
||||
<View
|
||||
style={[
|
||||
styles.heroMetricCard,
|
||||
{ backgroundColor: `${theme.colors.primary}08` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Projected maturity
|
||||
</Text>
|
||||
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(summary.projectedMaturityValue)}
|
||||
</Text>
|
||||
<Text style={[styles.heroMetricCaption, { color: theme.colors.textMuted }]}>
|
||||
Estimated portfolio value when all plans mature
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.heroMetricCard,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total deposits
|
||||
</Text>
|
||||
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
|
||||
{summary.totalDeposits}
|
||||
</Text>
|
||||
<Text style={[styles.heroMetricCaption, { color: theme.colors.textMuted }]}>
|
||||
{summary.totalDeposits === 1 ? '1 deposit' : `${summary.totalDeposits} deposits`} currently tracked
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
<Card style={styles.actionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Quick actions
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Start a new plan or estimate returns from one place
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionGrid}>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionColumn}>
|
||||
<Button
|
||||
title="Open FD"
|
||||
style={styles.actionButton}
|
||||
onPress={handleOpenFixedDeposit}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
name="trending-up-outline"
|
||||
size={18}
|
||||
color={theme.colors.white}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionColumn}>
|
||||
<Button
|
||||
title="Open RD"
|
||||
variant="secondary"
|
||||
style={styles.actionButton}
|
||||
onPress={handleOpenRecurringDeposit}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
name="repeat-outline"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
title="Calculator"
|
||||
variant="secondary"
|
||||
style={styles.actionButton}
|
||||
onPress={handleOpenCalculator}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
name="calculator-outline"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{summary.nearMaturityDeposits.length ? (
|
||||
<Card style={styles.watchCard}>
|
||||
<View style={styles.watchHeader}>
|
||||
<View style={[styles.watchIcon, { backgroundColor: `${theme.colors.warning}16` }]}>
|
||||
<Ionicons name="time-outline" size={18} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.watchCopy}>
|
||||
<Text style={[styles.watchTitle, { color: theme.colors.textPrimary }]}>
|
||||
Nearing maturity
|
||||
</Text>
|
||||
<Text style={[styles.watchSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Review and plan your next action before these deposits mature
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{summary.nearMaturityDeposits.map((deposit) => (
|
||||
<View
|
||||
key={`${deposit.id}_watch`}
|
||||
style={[styles.watchRow, { backgroundColor: theme.colors.surfaceMuted }]}
|
||||
>
|
||||
<View style={styles.watchRowCopy}>
|
||||
<Text style={[styles.watchDeposit, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.maskedNumber}
|
||||
</Text>
|
||||
<Text style={[styles.watchMeta, { color: theme.colors.textSecondary }]}>
|
||||
{deposit.type} · {formatCurrency(deposit.maturityAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.watchDays,
|
||||
{
|
||||
color: theme.colors.warning,
|
||||
backgroundColor: `${theme.colors.warning}12`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{Math.max(getDaysUntilMaturity(deposit.maturityDate), 0)} days
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<View style={styles.portfolioSection}>
|
||||
<View style={styles.portfolioHeader}>
|
||||
<View style={styles.portfolioCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Your deposits
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{filteredDeposits.length} shown in {selectedFilterLabel.toLowerCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.portfolioCountBadge,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.portfolioCountValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{summary.totalDeposits}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.portfolioCountLabel, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
total
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.filterWrap}>
|
||||
{FILTERS.map((item) => (
|
||||
<DepositOptionChip
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
selected={filter === item.id}
|
||||
onPress={() => setFilter(item.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoading && !deposits.length ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isError && !deposits.length ? (
|
||||
<EmptyState
|
||||
title="Unable to load deposits"
|
||||
description="Please pull to refresh or try again in a moment."
|
||||
actionLabel="Refresh"
|
||||
onAction={onRefresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && !filteredDeposits.length ? (
|
||||
<EmptyState
|
||||
title="No deposits found"
|
||||
description="Open a new fixed or recurring deposit to start building your savings ladder."
|
||||
actionLabel="Open fixed deposit"
|
||||
onAction={handleOpenFixedDeposit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{filteredDeposits.map((deposit) => (
|
||||
<DepositAccountCard
|
||||
key={deposit.id}
|
||||
deposit={deposit}
|
||||
onPress={() =>
|
||||
navigation.navigate('DepositDetails', {
|
||||
depositId: deposit.id,
|
||||
kind: deposit.kind,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
summaryGrid: {
|
||||
gap: 12,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 16,
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
heroIcon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '900',
|
||||
lineHeight: 26,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
heroHighlight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
},
|
||||
heroHighlightCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
heroHighlightLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroHighlightValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.8,
|
||||
},
|
||||
heroHighlightCaption: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
heroBadge: {
|
||||
minWidth: 88,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
heroBadgeValue: {
|
||||
fontSize: 22,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
heroBadgeLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
heroMetrics: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
heroMetricCard: {
|
||||
flex: 1,
|
||||
minWidth: 140,
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
heroMetricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroMetricValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.4,
|
||||
},
|
||||
heroMetricCaption: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actionCard: {
|
||||
gap: 16,
|
||||
},
|
||||
sectionHeader: {
|
||||
gap: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actionGrid: {
|
||||
gap: 12,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
actionButton: {
|
||||
width: '100%',
|
||||
},
|
||||
watchCard: {
|
||||
gap: 14,
|
||||
},
|
||||
watchHeader: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
watchIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
watchCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
watchTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
watchSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
watchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
},
|
||||
watchRowCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
watchDeposit: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
watchMeta: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
watchDays: {
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
portfolioSection: {
|
||||
gap: 14,
|
||||
},
|
||||
portfolioHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
portfolioCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
portfolioCountBadge: {
|
||||
minWidth: 72,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
portfolioCountValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.4,
|
||||
},
|
||||
portfolioCountLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
filterWrap: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: 60,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { DepositInterestPayoutType } from '../../../types/app';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { DepositOptionChip } from '../components/DepositOptionChip';
|
||||
import { useCreateFixedDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
|
||||
import { selectFixedDepositProduct } from '../store/depositSelectors';
|
||||
import { calculateFixedDepositMaturity } from '../utils/depositCalculators';
|
||||
import { validateFixedDepositInput } from '../utils/depositValidators';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'OpenFixedDeposit'>,
|
||||
BottomTabScreenProps<MainTabParamList, 'Services'>
|
||||
>;
|
||||
|
||||
type TenureUnit = 'months' | 'years';
|
||||
|
||||
const PAYOUT_OPTIONS: Array<{
|
||||
id: DepositInterestPayoutType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'cumulative', label: 'Cumulative' },
|
||||
{ id: 'non-cumulative', label: 'Non-cumulative' },
|
||||
];
|
||||
|
||||
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
|
||||
|
||||
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d]/g, ''));
|
||||
|
||||
export default function OpenFixedDepositScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
useGetDepositProductsQuery();
|
||||
const product = useAppSelector(selectFixedDepositProduct);
|
||||
const [createFixedDeposit, createState] = useCreateFixedDepositMutation();
|
||||
|
||||
const [amount, setAmount] = useState('');
|
||||
const [tenureValue, setTenureValue] = useState(
|
||||
String(product?.defaultTenureMonths ?? 12),
|
||||
);
|
||||
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
|
||||
const [payoutType, setPayoutType] =
|
||||
useState<DepositInterestPayoutType>('cumulative');
|
||||
const [errors, setErrors] = useState<{
|
||||
amount?: string;
|
||||
tenureMonths?: string;
|
||||
interestPayoutType?: string;
|
||||
}>({});
|
||||
|
||||
const tenureMonths = useMemo(() => {
|
||||
const numericValue = toPositiveNumber(tenureValue);
|
||||
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
|
||||
}, [tenureUnit, tenureValue]);
|
||||
|
||||
const amountNumber = useMemo(() => toPositiveNumber(amount), [amount]);
|
||||
|
||||
const maturityAmount = useMemo(() => {
|
||||
if (!product) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return calculateFixedDepositMaturity(
|
||||
amountNumber,
|
||||
product.rate,
|
||||
tenureMonths,
|
||||
payoutType,
|
||||
);
|
||||
}, [amountNumber, payoutType, product, tenureMonths]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!product) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateFixedDepositInput(
|
||||
{
|
||||
amount: amountNumber,
|
||||
tenureMonths,
|
||||
interestPayoutType: payoutType,
|
||||
},
|
||||
product,
|
||||
);
|
||||
|
||||
setErrors(validation);
|
||||
|
||||
if (Object.keys(validation).length) {
|
||||
toast('Check deposit inputs', 'error', 'Please correct the highlighted fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deposit = await createFixedDeposit({
|
||||
amount: amountNumber,
|
||||
tenureMonths,
|
||||
interestPayoutType: payoutType,
|
||||
}).unwrap();
|
||||
|
||||
toast('Fixed deposit created', 'success', 'Your new FD is now active.');
|
||||
navigation.replace('DepositDetails', {
|
||||
depositId: deposit.id,
|
||||
kind: deposit.kind,
|
||||
});
|
||||
} catch {
|
||||
toast('Unable to open fixed deposit', 'error', 'Please try again shortly.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<ScreenShell title="Open fixed deposit" subtitle="Loading fixed deposit product">
|
||||
<Card>
|
||||
<Text style={[styles.placeholderText, { color: theme.colors.textSecondary }]}>
|
||||
Loading product details...
|
||||
</Text>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Open fixed deposit"
|
||||
subtitle="Create a new FD using your existing deposit product settings"
|
||||
scrollable={false}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroHeader}>
|
||||
<View>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
{product.name}
|
||||
</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
{product.rate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.accent}12` }]}>
|
||||
<Ionicons
|
||||
name="shield-checkmark-outline"
|
||||
size={16}
|
||||
color={theme.colors.accentStrong}
|
||||
/>
|
||||
<Text style={[styles.heroPillText, { color: theme.colors.accentStrong }]}>
|
||||
Min {formatCurrency(product.minimumAmount ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.heroCaption, { color: theme.colors.textSecondary }]}>
|
||||
Maximum amount {formatCurrency(product.maximumAmount ?? 0)} · Tenure between{' '}
|
||||
{product.minimumTenureMonths} and {product.maximumTenureMonths} months
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<Input
|
||||
label="Deposit amount"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
keyboardType="number-pad"
|
||||
placeholder="100000"
|
||||
error={errors.amount}
|
||||
/>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>Tenure</Text>
|
||||
<Input
|
||||
value={tenureValue}
|
||||
onChangeText={setTenureValue}
|
||||
keyboardType="number-pad"
|
||||
placeholder={String(product.defaultTenureMonths)}
|
||||
error={errors.tenureMonths}
|
||||
/>
|
||||
<View style={styles.optionRow}>
|
||||
{TENURE_UNITS.map((unit) => (
|
||||
<DepositOptionChip
|
||||
key={unit}
|
||||
label={unit === 'months' ? 'Months' : 'Years'}
|
||||
selected={tenureUnit === unit}
|
||||
onPress={() => setTenureUnit(unit)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
|
||||
Interest payout
|
||||
</Text>
|
||||
<View style={styles.optionRow}>
|
||||
{PAYOUT_OPTIONS.map((option) => (
|
||||
<DepositOptionChip
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
selected={payoutType === option.id}
|
||||
onPress={() => setPayoutType(option.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{errors.interestPayoutType ? (
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>
|
||||
{errors.interestPayoutType}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.previewCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Maturity preview
|
||||
</Text>
|
||||
<View style={styles.previewGrid}>
|
||||
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Principal
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(amountNumber || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated maturity
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.round(maturityAmount))}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.previewTile,
|
||||
styles.previewTileWide,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated interest earned
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.max(Math.round(maturityAmount) - amountNumber, 0))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Create fixed deposit"
|
||||
onPress={handleSubmit}
|
||||
loading={createState.isLoading}
|
||||
leftAccessory={
|
||||
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={{ height: 50 }} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 10,
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 24,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
heroPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
heroPillText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
},
|
||||
heroCaption: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
formCard: {
|
||||
gap: 18,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 10,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
previewCard: {
|
||||
gap: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
previewGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
previewTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
previewTileWide: {
|
||||
width: '100%',
|
||||
},
|
||||
previewLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
previewValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '900',
|
||||
lineHeight: 22,
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { DepositOptionChip } from '../components/DepositOptionChip';
|
||||
import { useCreateRecurringDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
|
||||
import { selectRecurringDepositProduct } from '../store/depositSelectors';
|
||||
import { calculateRecurringDepositMaturity } from '../utils/depositCalculators';
|
||||
import { validateRecurringDepositInput } from '../utils/depositValidators';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'OpenRecurringDeposit'>,
|
||||
BottomTabScreenProps<MainTabParamList, 'Services'>
|
||||
>;
|
||||
|
||||
type TenureUnit = 'months' | 'years';
|
||||
|
||||
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
|
||||
|
||||
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d]/g, ''));
|
||||
|
||||
export default function OpenRecurringDepositScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
useGetDepositProductsQuery();
|
||||
const product = useAppSelector(selectRecurringDepositProduct);
|
||||
const [createRecurringDeposit, createState] = useCreateRecurringDepositMutation();
|
||||
|
||||
const [monthlyInstallment, setMonthlyInstallment] = useState('');
|
||||
const [tenureValue, setTenureValue] = useState(
|
||||
String(product?.defaultTenureMonths ?? 24),
|
||||
);
|
||||
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
|
||||
const [errors, setErrors] = useState<{
|
||||
monthlyInstallment?: string;
|
||||
tenureMonths?: string;
|
||||
}>({});
|
||||
|
||||
const installmentNumber = useMemo(
|
||||
() => toPositiveNumber(monthlyInstallment),
|
||||
[monthlyInstallment],
|
||||
);
|
||||
const tenureMonths = useMemo(() => {
|
||||
const numericValue = toPositiveNumber(tenureValue);
|
||||
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
|
||||
}, [tenureUnit, tenureValue]);
|
||||
|
||||
const totalInvestment = installmentNumber * tenureMonths;
|
||||
const maturityAmount = useMemo(() => {
|
||||
if (!product) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return calculateRecurringDepositMaturity(
|
||||
installmentNumber,
|
||||
product.rate,
|
||||
tenureMonths,
|
||||
);
|
||||
}, [installmentNumber, product, tenureMonths]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!product) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateRecurringDepositInput(
|
||||
{
|
||||
monthlyInstallment: installmentNumber,
|
||||
tenureMonths,
|
||||
},
|
||||
product,
|
||||
);
|
||||
|
||||
setErrors(validation);
|
||||
|
||||
if (Object.keys(validation).length) {
|
||||
toast('Check recurring deposit inputs', 'error', 'Please correct the highlighted fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deposit = await createRecurringDeposit({
|
||||
monthlyInstallment: installmentNumber,
|
||||
tenureMonths,
|
||||
}).unwrap();
|
||||
|
||||
toast('Recurring deposit created', 'success', 'Your new RD is now active.');
|
||||
navigation.replace('DepositDetails', {
|
||||
depositId: deposit.id,
|
||||
kind: deposit.kind,
|
||||
});
|
||||
} catch {
|
||||
toast('Unable to open recurring deposit', 'error', 'Please try again shortly.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<ScreenShell title="Open recurring deposit" subtitle="Loading recurring deposit product">
|
||||
<Card>
|
||||
<Text style={[styles.placeholderText, { color: theme.colors.textSecondary }]}>
|
||||
Loading product details...
|
||||
</Text>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Open recurring deposit"
|
||||
subtitle="Set a monthly installment and grow savings automatically"
|
||||
scrollable={false}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroHeader}>
|
||||
<View>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
{product.name}
|
||||
</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
{product.rate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="repeat-outline" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.heroPillText, { color: theme.colors.primary }]}>
|
||||
Min {formatCurrency(product.minimumInstallment ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.heroCaption, { color: theme.colors.textSecondary }]}>
|
||||
Maximum installment {formatCurrency(product.maximumInstallment ?? 0)} · Tenure
|
||||
between {product.minimumTenureMonths} and {product.maximumTenureMonths} months
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<Input
|
||||
label="Monthly installment"
|
||||
value={monthlyInstallment}
|
||||
onChangeText={setMonthlyInstallment}
|
||||
keyboardType="number-pad"
|
||||
placeholder="5000"
|
||||
error={errors.monthlyInstallment}
|
||||
/>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>Tenure</Text>
|
||||
<Input
|
||||
value={tenureValue}
|
||||
onChangeText={setTenureValue}
|
||||
keyboardType="number-pad"
|
||||
placeholder={String(product.defaultTenureMonths)}
|
||||
error={errors.tenureMonths}
|
||||
/>
|
||||
<View style={styles.optionRow}>
|
||||
{TENURE_UNITS.map((unit) => (
|
||||
<DepositOptionChip
|
||||
key={unit}
|
||||
label={unit === 'months' ? 'Months' : 'Years'}
|
||||
selected={tenureUnit === unit}
|
||||
onPress={() => setTenureUnit(unit)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.previewCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Growth preview
|
||||
</Text>
|
||||
<View style={styles.previewGrid}>
|
||||
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total investment
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(totalInvestment || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated maturity
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.round(maturityAmount))}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.previewTile,
|
||||
styles.previewTileWide,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated gains
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.max(Math.round(maturityAmount) - totalInvestment, 0))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Create recurring deposit"
|
||||
onPress={handleSubmit}
|
||||
loading={createState.isLoading}
|
||||
leftAccessory={
|
||||
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={{ height: 50 }} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 10,
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 24,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
heroPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
heroPillText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
},
|
||||
heroCaption: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
formCard: {
|
||||
gap: 18,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 10,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
previewCard: {
|
||||
gap: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
previewGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
previewTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
previewTileWide: {
|
||||
width: '100%',
|
||||
},
|
||||
previewLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
previewValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '900',
|
||||
lineHeight: 22,
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user