Files
CBS-Mobile-App/src/modules/deposit/screens/DepositsOverviewScreen.tsx
T

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,
},
});