675 lines
20 KiB
TypeScript
675 lines
20 KiB
TypeScript
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 { FeatureKey } from '../../../constants/featureKeys';
|
|
import { useFeature } from '../../../features';
|
|
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
|
import { useTheme, fontFamily } 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';
|
|
|
|
/**
|
|
* Each action is gated on the route it opens. Without this the buttons
|
|
* render whatever the config says, while the navigator refuses to register
|
|
* the destination — a control that looks live and does nothing.
|
|
*/
|
|
const canOpenFd = useFeature(FeatureKey.SERVICES_DEPOSITS_OPEN_FD);
|
|
const canOpenRd = useFeature(FeatureKey.SERVICES_DEPOSITS_OPEN_RD);
|
|
const canCalculate = useFeature(FeatureKey.SERVICES_DEPOSITS_CALCULATOR);
|
|
const showQuickActions = canOpenFd || canOpenRd || canCalculate;
|
|
|
|
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>
|
|
|
|
{showQuickActions ? (
|
|
<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}>
|
|
{canOpenFd ? (
|
|
<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>
|
|
) : null}
|
|
{canOpenRd ? (
|
|
<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>
|
|
) : null}
|
|
</View>
|
|
{canCalculate ? (
|
|
<Button
|
|
title="Calculator"
|
|
variant="secondary"
|
|
style={styles.actionButton}
|
|
onPress={handleOpenCalculator}
|
|
leftAccessory={
|
|
<Ionicons
|
|
name="calculator-outline"
|
|
size={18}
|
|
color={theme.colors.textPrimary}
|
|
/>
|
|
}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
</Card>
|
|
) : null}
|
|
|
|
{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={
|
|
canOpenFd
|
|
? 'Open a new fixed or recurring deposit to start building your savings ladder.'
|
|
: 'You have no fixed or recurring deposits with us. Visit your branch to open one.'
|
|
}
|
|
actionLabel={canOpenFd ? 'Open fixed deposit' : undefined}
|
|
onAction={canOpenFd ? handleOpenFixedDeposit : undefined}
|
|
/>
|
|
) : 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,
|
|
fontFamily: fontFamily.bold,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.8,
|
|
},
|
|
heroTitle: {
|
|
fontSize: 20,
|
|
fontFamily: fontFamily.bold,
|
|
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,
|
|
fontFamily: fontFamily.bold,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.8,
|
|
},
|
|
heroHighlightValue: {
|
|
fontSize: 28,
|
|
fontFamily: fontFamily.bold,
|
|
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,
|
|
fontFamily: fontFamily.bold,
|
|
letterSpacing: -0.5,
|
|
},
|
|
heroBadgeLabel: {
|
|
fontSize: 11,
|
|
fontFamily: fontFamily.bold,
|
|
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,
|
|
fontFamily: fontFamily.bold,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.8,
|
|
},
|
|
heroMetricValue: {
|
|
fontSize: 20,
|
|
fontFamily: fontFamily.bold,
|
|
letterSpacing: -0.4,
|
|
},
|
|
heroMetricCaption: {
|
|
fontSize: 12,
|
|
lineHeight: 18,
|
|
},
|
|
actionCard: {
|
|
gap: 16,
|
|
},
|
|
sectionHeader: {
|
|
gap: 4,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 18,
|
|
fontFamily: fontFamily.bold,
|
|
},
|
|
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,
|
|
fontFamily: fontFamily.bold,
|
|
},
|
|
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,
|
|
fontFamily: fontFamily.bold,
|
|
},
|
|
watchMeta: {
|
|
fontSize: 12,
|
|
fontFamily: fontFamily.medium,
|
|
},
|
|
watchDays: {
|
|
fontSize: 13,
|
|
fontFamily: fontFamily.bold,
|
|
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,
|
|
fontFamily: fontFamily.bold,
|
|
letterSpacing: -0.4,
|
|
},
|
|
portfolioCountLabel: {
|
|
fontSize: 11,
|
|
fontFamily: fontFamily.bold,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.8,
|
|
},
|
|
filterWrap: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 10,
|
|
},
|
|
bottomSpacer: {
|
|
height: 60,
|
|
},
|
|
});
|