- 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>
446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
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,
|
||
},
|
||
});
|