Files
CBS-Mobile-App/src/modules/history/screens/TransferHistoryScreen.tsx
T
Abin ToppoandClaude Opus 5.5 f960a119e8 Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on
- Positive Pay on the 12 Oct /pps contract (account number, not account ID):
  OTP enrollment, cheque submit, saved cheques, detail and stop payment.
  Full flow passed on the phone against mobile-banking-backend.lincpay.
- Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on
  Home and the Statement screen; dated PDF export with the password rule.
- Onboarding slides and splash screen, Inter font, shared Icon and
  TypewriterText components, nearby branches via OpenStreetMap.
- Every feature switched on; the 14 Oct switch set is kept in
  featureConfig.stage-2026-10-14.ts. Loans run on demo data until the
  backend has /loans routes.
- npm run android wraps run-android for shells spawned from VS Code.
- OpenProject work packages updated to 14-10-2026 status, with screenshots
  of Positive Pay, statement, loans and cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:18:40 +05:30

446 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Icon } from '../../../components/Icon';
import DatePicker from 'react-native-date-picker';
import {
AppModal as Modal,
Button,
Card,
EmptyState,
Loader,
ScreenShell,
SegmentedControl,
} from '../../../components';
import { useTheme } from '../../../theme';
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
import type { HomeStackParamList } from '../../../navigation/types';
import type { ApiError } from '../../../types/api';
import type { BeneficiaryMode, FundTransferHistoryItem, FundTransferStatus } from '../../../types/cbs';
import { describeError } from '../../../services/api/cbsErrors';
import { toStatementDate } from '../../home/utils/statement';
import { useFundTransferHistory, type HistoryFilters } from '../hooks/useFundTransferHistory';
import { humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
type Props = NativeStackScreenProps<HomeStackParamList, 'TransferHistory'>;
const STATUS_FILTERS: ReadonlyArray<{ value: FundTransferStatus | 'ALL'; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'SUCCESS', label: 'Successful' },
{ value: 'FAILED', label: 'Failed' },
{ value: 'PROCESSING', label: 'In progress' },
{ value: 'PENDING_RECONCILIATION', label: 'Pending' },
{ value: 'MANUAL_REVIEW', label: 'Under review' },
];
const MODE_FILTERS: ReadonlyArray<{ value: BeneficiaryMode | 'ALL'; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'P2A', label: 'Account' },
{ value: 'P2P', label: 'Mobile' },
];
function TransferRow({
item,
onPress,
}: {
item: FundTransferHistoryItem;
onPress: (item: FundTransferHistoryItem) => void;
}) {
const theme = useTheme();
const copy = statusCopy(item.status);
const colors = toneColors(copy.tone, theme.colors);
// Shown only for failed and review states, and only when there is one.
const showReason =
Boolean(item.failureReason) && (item.status === 'FAILED' || item.status === 'MANUAL_REVIEW');
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${item.payeeName ?? 'Transfer'}, ${formatCurrency(item.amount)}, ${copy.label}`}
onPress={() => onPress(item)}
>
<Card style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
<Icon name={copy.icon} size={20} color={colors.tone} />
</View>
<View style={styles.copy}>
<Text
numberOfLines={1}
style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}
>
{item.payeeName || 'Payee'}
</Text>
{item.payeeDisplay ? (
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
{item.payeeDisplay}
</Text>
) : null}
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
{`${formatDate(item.createdAt)} · ${formatTime(item.createdAt)}`}
</Text>
{showReason ? (
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>
{humaniseReason(item.failureReason as string)}
</Text>
) : null}
</View>
<View style={styles.trailing}>
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
{formatCurrency(item.amount)}
</Text>
<View
style={[
styles.badge,
{ backgroundColor: colors.surface, borderColor: colors.border },
]}
>
{/* See TransferDetailScreen: `simple` stops Android clipping the
last word of a label drawn in a custom font. */}
<Text
textBreakStrategy="simple"
style={[theme.text.caption, styles.badgeText, { color: colors.tone }]}
>
{copy.label}
</Text>
</View>
</View>
</View>
</Card>
</Pressable>
);
}
export default function TransferHistoryScreen({ navigation }: Props) {
const theme = useTheme();
const [filters, setFilters] = useState<HistoryFilters>({ status: 'ALL', mode: 'ALL' });
const [rangeOpen, setRangeOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState<'from' | 'to' | null>(null);
const [tempFrom, setTempFrom] = useState(() => {
const date = new Date();
date.setDate(date.getDate() - 30);
return date;
});
const [tempTo, setTempTo] = useState(() => new Date());
const [rangeError, setRangeError] = useState<string | null>(null);
const { transfers, totalElements, hasMore, isLoading, isLoadingMore, isRefreshing, error, loadMore, reload } =
useFundTransferHistory(filters);
const apiError = error as ApiError | undefined;
const hasRange = Boolean(filters.fromDate && filters.toDate);
const openDetail = useCallback(
(item: FundTransferHistoryItem) => navigation.navigate('TransferDetail', { transferId: item.transferId }),
[navigation],
);
const applyRange = useCallback(() => {
if (tempFrom.getTime() > tempTo.getTime()) {
setRangeError('The start date cannot be after the end date.');
return;
}
setRangeError(null);
setRangeOpen(false);
setFilters((current) => ({
...current,
fromDate: toStatementDate(tempFrom),
toDate: toStatementDate(tempTo),
}));
}, [tempFrom, tempTo]);
const clearRange = useCallback(() => {
setRangeOpen(false);
setRangeError(null);
setFilters((current) => ({ ...current, fromDate: undefined, toDate: undefined }));
}, []);
const subtitle = useMemo(() => {
if (isLoading) return 'Loading your transfers';
return `${totalElements} ${totalElements === 1 ? 'transfer' : 'transfers'}`;
}, [isLoading, totalElements]);
const listHeader = (
<View style={styles.filters}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
{STATUS_FILTERS.map((option) => {
const active = filters.status === option.value;
return (
<Pressable
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected: active }}
onPress={() => setFilters((current) => ({ ...current, status: option.value }))}
style={[
styles.chip,
{
borderRadius: theme.radius.pill,
backgroundColor: active ? theme.colors.primary : theme.colors.surface,
borderColor: active ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
<Text
style={[
theme.text.bodySmall,
{ color: active ? theme.colors.textOnBrand : theme.colors.textPrimary },
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</ScrollView>
<SegmentedControl
options={MODE_FILTERS}
value={filters.mode}
onChange={(mode) => setFilters((current) => ({ ...current, mode }))}
accessibilityLabel="Transfer type"
/>
<Pressable
accessibilityRole="button"
onPress={() => setRangeOpen(true)}
style={[
styles.rangeButton,
{
backgroundColor: theme.colors.surface,
borderColor: hasRange ? theme.colors.primary : theme.colors.cardBorder,
borderRadius: theme.radius.md,
},
]}
>
<Icon name="calendar-outline" size={18} color={theme.colors.primary} />
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
{hasRange
? `${formatDate(filters.fromDate as string)} – ${formatDate(filters.toDate as string)}`
: 'Any date'}
</Text>
<Icon name="chevron-down" size={16} color={theme.colors.textMuted} />
</Pressable>
</View>
);
const renderEmpty = () => {
if (isLoading) {
return <Loader />;
}
if (apiError) {
const copy = describeError(apiError.code);
return (
<EmptyState
title={copy.title}
description={apiError.message || copy.description}
actionLabel="Try again"
onAction={reload}
/>
);
}
return (
<EmptyState
title="No transfers found"
description={
filters.status === 'ALL' && filters.mode === 'ALL' && !hasRange
? 'Transfers you make from the app will appear here.'
: 'Nothing matches these filters. Try widening them.'
}
/>
);
};
return (
<ScreenShell title="History" subtitle={subtitle} scrollable={false}>
<FlatList
data={transfers}
keyExtractor={(item) => item.transferId}
renderItem={({ item }) => <TransferRow item={item} onPress={openDetail} />}
ListHeaderComponent={listHeader}
ListEmptyComponent={renderEmpty}
ListFooterComponent={
isLoadingMore ? (
<ActivityIndicator style={styles.footer} color={theme.colors.primary} />
) : null
}
ItemSeparatorComponent={Separator}
onEndReached={hasMore ? loadMore : undefined}
onEndReachedThreshold={0.4}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={reload}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
/>
<Modal visible={rangeOpen} onClose={() => setRangeOpen(false)} title="Filter by date">
<View style={styles.modalBody}>
{(['from', 'to'] as const).map((which) => (
<View key={which} style={styles.dateField}>
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
{which === 'from' ? 'From' : 'To'}
</Text>
<Pressable
accessibilityRole="button"
onPress={() => setPickerOpen(which)}
style={[
styles.rangeButton,
{
backgroundColor: theme.colors.surfaceMuted,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.md,
},
]}
>
<Icon name="calendar-outline" size={18} color={theme.colors.primary} />
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
{formatDate(toStatementDate(which === 'from' ? tempFrom : tempTo))}
</Text>
</Pressable>
</View>
))}
{rangeError ? (
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>{rangeError}</Text>
) : null}
<View style={styles.modalActions}>
<Button title="Apply" onPress={applyRange} style={styles.modalButton} />
<Button
title={hasRange ? 'Clear dates' : 'Cancel'}
variant="secondary"
onPress={hasRange ? clearRange : () => setRangeOpen(false)}
style={styles.modalButton}
/>
</View>
</View>
<DatePicker
modal
open={pickerOpen !== null}
date={pickerOpen === 'to' ? tempTo : tempFrom}
maximumDate={new Date()}
mode="date"
title={pickerOpen === 'to' ? 'Select end date' : 'Select start date'}
onConfirm={(date) => {
if (pickerOpen === 'to') setTempTo(date);
else setTempFrom(date);
setPickerOpen(null);
setRangeError(null);
}}
onCancel={() => setPickerOpen(null)}
/>
</Modal>
</ScreenShell>
);
}
const Separator = () => <View style={styles.separator} />;
const styles = StyleSheet.create({
listContent: {
paddingBottom: 24,
flexGrow: 1,
},
filters: {
gap: 12,
marginBottom: 16,
},
chips: {
gap: 8,
},
chip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderWidth: 1,
},
rangeButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderWidth: 1,
},
rangeText: {
flex: 1,
},
separator: {
height: 10,
},
card: {
padding: 14,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
copy: {
flex: 1,
gap: 2,
},
trailing: {
alignItems: 'flex-end',
gap: 6,
},
badgeText: {
textAlign: 'right',
},
badge: {
maxWidth: 150,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
borderWidth: 1,
},
footer: {
marginVertical: 16,
},
modalBody: {
gap: 14,
},
dateField: {
gap: 6,
},
modalActions: {
flexDirection: 'row',
gap: 10,
marginTop: 6,
},
modalButton: {
flex: 1,
},
});