566 lines
18 KiB
TypeScript
566 lines
18 KiB
TypeScript
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,
|
|
},
|
|
});
|