Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user