Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface AgentEmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
actionTitle?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export const AgentEmptyState = ({
|
||||
title,
|
||||
description,
|
||||
icon = 'document-text-outline',
|
||||
actionTitle,
|
||||
onAction,
|
||||
}: AgentEmptyStateProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: `${theme.colors.primary}10` }]}>
|
||||
<Ionicons name={icon as any} size={48} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
<Text style={[styles.description, { color: theme.colors.textMuted }]}>{description}</Text>
|
||||
|
||||
{actionTitle && onAction && (
|
||||
<Pressable
|
||||
style={[styles.button, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={onAction}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.white }]}>{actionTitle}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 32,
|
||||
minHeight: 300,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
button: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface AgentMiniChartProps {
|
||||
data: number[];
|
||||
color?: string;
|
||||
height?: number;
|
||||
labels?: string[];
|
||||
type?: 'bar' | 'line';
|
||||
}
|
||||
|
||||
export const AgentMiniChart = ({
|
||||
data,
|
||||
color,
|
||||
height = 100,
|
||||
labels,
|
||||
type = 'bar',
|
||||
}: AgentMiniChartProps) => {
|
||||
const theme = useTheme();
|
||||
const chartColor = color || theme.colors.primary;
|
||||
|
||||
const maxValue = Math.max(...data, 1);
|
||||
const animationProgress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
animationProgress.value = 0;
|
||||
animationProgress.value = withSpring(1, { damping: 15 });
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.chartArea, { height }]}>
|
||||
{data.map((val, i) => {
|
||||
const barHeight = (val / maxValue) * height;
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
height: barHeight * animationProgress.value,
|
||||
opacity: animationProgress.value,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View key={`bar-${i}`} style={styles.barContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bar,
|
||||
{ backgroundColor: chartColor },
|
||||
animatedStyle,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{labels && (
|
||||
<View style={styles.labelsArea}>
|
||||
{labels.map((label, i) => (
|
||||
<Text key={`label-${i}`} style={[styles.label, { color: theme.colors.textMuted }]}>
|
||||
{label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
chartArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
},
|
||||
barContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
height: '100%',
|
||||
},
|
||||
bar: {
|
||||
width: '100%',
|
||||
maxWidth: 24,
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: 4,
|
||||
},
|
||||
labelsArea: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 8,
|
||||
},
|
||||
label: {
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
import Animated, {
|
||||
useAnimatedProps,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
withDelay,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
interface AgentProgressRingProps {
|
||||
progress: number; // 0 to 100
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export const AgentProgressRing = ({
|
||||
progress,
|
||||
size = 120,
|
||||
strokeWidth = 10,
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: AgentProgressRingProps) => {
|
||||
const theme = useTheme();
|
||||
const ringColor = color || theme.colors.primary;
|
||||
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
|
||||
const animatedProgress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
animatedProgress.value = 0;
|
||||
animatedProgress.value = withDelay(
|
||||
300,
|
||||
withTiming(progress, {
|
||||
duration: 1500,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
);
|
||||
}, [progress]);
|
||||
|
||||
const animatedProps = useAnimatedProps(() => {
|
||||
const strokeDashoffset = circumference - (animatedProgress.value / 100) * circumference;
|
||||
return {
|
||||
strokeDashoffset,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: size, height: size }]}>
|
||||
<Svg width={size} height={size}>
|
||||
{/* Background Circle */}
|
||||
<Circle
|
||||
stroke={`${ringColor}20`}
|
||||
fill="none"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress Circle */}
|
||||
<AnimatedCircle
|
||||
stroke={ringColor}
|
||||
fill="none"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
animatedProps={animatedProps}
|
||||
strokeLinecap="round"
|
||||
rotation="-90"
|
||||
origin={`${size / 2}, ${size / 2}`}
|
||||
/>
|
||||
</Svg>
|
||||
<View style={styles.content}>
|
||||
{value && <Text style={[styles.value, { color: theme.colors.textPrimary, fontSize: size * 0.25 }]}>{value}</Text>}
|
||||
{label && <Text style={[styles.label, { color: theme.colors.textMuted, fontSize: size * 0.1 }]}>{label}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
value: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
label: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface AgentSectionHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actionTitle?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export const AgentSectionHeader = ({
|
||||
title,
|
||||
subtitle,
|
||||
actionTitle,
|
||||
onAction,
|
||||
}: AgentSectionHeaderProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{subtitle && (
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textMuted }]}>{subtitle}</Text>
|
||||
)}
|
||||
</View>
|
||||
{actionTitle && onAction && (
|
||||
<Pressable onPress={onAction}>
|
||||
<Text style={[styles.action, { color: theme.colors.primary }]}>{actionTitle}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
action: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, DimensionValue } from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface AgentSkeletonLoaderProps {
|
||||
type: 'card' | 'list' | 'dashboard';
|
||||
}
|
||||
|
||||
export const AgentSkeletonLoader = ({ type }: AgentSkeletonLoaderProps) => {
|
||||
const theme = useTheme();
|
||||
const opacity = useSharedValue(0.5);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 800 }),
|
||||
withTiming(0.5, { duration: 800 })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const SkeletonBlock = ({ width = '100%', height = 20, borderRadius = 4, mb = 0 }: { width?: DimensionValue; height?: number; borderRadius?: number; mb?: number }) => (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
marginBottom: mb,
|
||||
backgroundColor: theme.colors.cardBorder,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
if (type === 'dashboard') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
<SkeletonBlock width="48%" height={120} borderRadius={16} />
|
||||
<SkeletonBlock width="48%" height={120} borderRadius={16} />
|
||||
</View>
|
||||
<SkeletonBlock height={24} width="40%" mb={16} mt={24} />
|
||||
<SkeletonBlock height={180} borderRadius={16} mb={16} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'list') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<View key={i} style={[styles.card, { borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.row}>
|
||||
<SkeletonBlock width={40} height={40} borderRadius={20} />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<SkeletonBlock width="60%" mb={8} />
|
||||
<SkeletonBlock width="40%" height={14} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { borderColor: theme.colors.cardBorder }]}>
|
||||
<SkeletonBlock width="40%" height={24} mb={16} />
|
||||
<SkeletonBlock height={60} mb={12} />
|
||||
<SkeletonBlock height={60} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
card: {
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface AgentStatCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
trend?: number;
|
||||
trendText?: string;
|
||||
color?: string;
|
||||
index?: number;
|
||||
gradient?: [string, string];
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export const AgentStatCard = ({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
trendText,
|
||||
color,
|
||||
index = 0,
|
||||
gradient,
|
||||
onPress,
|
||||
}: AgentStatCardProps) => {
|
||||
const theme = useTheme();
|
||||
const baseColor = color || theme.colors.primary;
|
||||
|
||||
const isPositive = trend && trend > 0;
|
||||
const isNegative = trend && trend < 0;
|
||||
|
||||
const content = (
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${baseColor}20` }]}>
|
||||
<Ionicons name={icon as any} size={18} color={baseColor} />
|
||||
</View>
|
||||
{trend !== undefined && (
|
||||
<View style={[styles.trendBadge, { backgroundColor: isPositive ? `${theme.colors.success}20` : isNegative ? `${theme.colors.danger}20` : `${theme.colors.textMuted}20` }]}>
|
||||
<Ionicons
|
||||
name={isPositive ? 'trending-up' : isNegative ? 'trending-down' : 'remove'}
|
||||
size={12}
|
||||
color={isPositive ? theme.colors.success : isNegative ? theme.colors.danger : theme.colors.textMuted}
|
||||
/>
|
||||
<Text style={[styles.trendText, { color: isPositive ? theme.colors.success : isNegative ? theme.colors.danger : theme.colors.textMuted }]}>
|
||||
{Math.abs(trend)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.body}>
|
||||
<Text style={[styles.value, { color: gradient ? theme.colors.white : theme.colors.textPrimary }]}>{value}</Text>
|
||||
<Text style={[styles.title, { color: gradient ? 'rgba(255,255,255,0.8)' : theme.colors.textMuted }]}>{title}</Text>
|
||||
{trendText && (
|
||||
<Text style={[styles.trendDesc, { color: gradient ? 'rgba(255,255,255,0.6)' : theme.colors.textMuted }]}>{trendText}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Tap indicator arrow */}
|
||||
{onPress && (
|
||||
<View style={styles.tapIndicator}>
|
||||
<Ionicons name="chevron-forward" size={14} color={gradient ? 'rgba(255,255,255,0.4)' : theme.colors.textMuted} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const cardInner = gradient ? (
|
||||
<LinearGradient
|
||||
colors={gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
{content}
|
||||
</LinearGradient>
|
||||
) : content;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(index * 100).springify()}
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
}
|
||||
]}
|
||||
>
|
||||
{onPress ? (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.pressable,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
{cardInner}
|
||||
</Pressable>
|
||||
) : cardInner}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
minWidth: 140,
|
||||
},
|
||||
pressable: {
|
||||
flex: 1,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.75,
|
||||
transform: [{ scale: 0.97 }],
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
},
|
||||
iconFrame: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
trendBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
gap: 4,
|
||||
},
|
||||
trendText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
body: {
|
||||
gap: 4,
|
||||
},
|
||||
value: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
trendDesc: {
|
||||
fontSize: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
tapIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { CollectionEntry } from '../types/agentTypes';
|
||||
import { formatAgentCurrency, formatTime } from '../utils/agentFormatters';
|
||||
|
||||
interface CollectionEntryCardProps {
|
||||
entry: CollectionEntry;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export const CollectionEntryCard = ({ entry, onPress }: CollectionEntryCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const isCollected = entry.status === 'collected';
|
||||
const isPartial = entry.status === 'partial';
|
||||
const isPending = entry.status === 'pending';
|
||||
|
||||
const statusColor = isCollected
|
||||
? theme.colors.success
|
||||
: isPartial
|
||||
? theme.colors.warning
|
||||
: theme.colors.textMuted;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.leftContent}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: `${statusColor}15` }]}>
|
||||
<Ionicons
|
||||
name={isCollected ? 'checkmark-circle' : isPartial ? 'pie-chart' : 'time'}
|
||||
size={20}
|
||||
color={statusColor}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.details}>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{entry.customerName}</Text>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={[styles.meta, { color: theme.colors.textMuted }]}>{entry.accountType} • </Text>
|
||||
<Text style={[styles.meta, { color: theme.colors.textMuted }]}>
|
||||
{isPending ? 'Pending' : entry.collectedAt ? formatTime(entry.collectedAt) : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.rightContent}>
|
||||
<Text style={[styles.amount, { color: theme.colors.textPrimary }]}>
|
||||
{formatAgentCurrency(isPending ? entry.emiAmount : entry.collectedAmount)}
|
||||
</Text>
|
||||
{!isPending && (
|
||||
<Text style={[styles.mode, { color: theme.colors.textMuted }]}>
|
||||
via {entry.mode.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
},
|
||||
leftContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
details: {
|
||||
gap: 4,
|
||||
},
|
||||
name: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
},
|
||||
rightContent: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 4,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
mode: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { CommissionSummary } from '../types/agentTypes';
|
||||
import { formatAgentCurrency } from '../utils/agentFormatters';
|
||||
import { AgentProgressRing } from './AgentProgressRing';
|
||||
|
||||
interface CommissionSummaryCardProps {
|
||||
summary: CommissionSummary;
|
||||
}
|
||||
|
||||
export const CommissionSummaryCard = ({ summary }: CommissionSummaryCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const progress = summary.currentMonthExpected > 0
|
||||
? (summary.currentMonthActual / summary.currentMonthExpected) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={[styles.title, { color: theme.colors.textMuted }]}>Current Month</Text>
|
||||
<Text style={[styles.amount, { color: theme.colors.textPrimary }]}>
|
||||
{formatAgentCurrency(summary.currentMonthActual)}
|
||||
</Text>
|
||||
</View>
|
||||
<AgentProgressRing
|
||||
progress={progress}
|
||||
size={60}
|
||||
strokeWidth={6}
|
||||
color={theme.colors.success}
|
||||
value={`${Math.round(progress)}%`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Expected</Text>
|
||||
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatAgentCurrency(summary.currentMonthExpected)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Loss</Text>
|
||||
<Text style={[styles.statValue, { color: theme.colors.danger }]}>
|
||||
{formatAgentCurrency(summary.currentMonthLoss)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.rateBadge, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name="information-circle" size={14} color={theme.colors.primary} />
|
||||
<Text style={[styles.rateText, { color: theme.colors.primary }]}>
|
||||
Calculated at {summary.commissionRate}% commission rate
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginVertical: 16,
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
vDivider: {
|
||||
width: 1,
|
||||
height: 30,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
rateBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
gap: 6,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
rateText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { AgentCustomer } from '../types/agentTypes';
|
||||
import { formatAgentCurrency, maskPhone } from '../utils/agentFormatters';
|
||||
|
||||
interface CustomerCardProps {
|
||||
customer: AgentCustomer;
|
||||
onPress?: () => void;
|
||||
onAction?: () => void;
|
||||
actionIcon?: string;
|
||||
actionColor?: string;
|
||||
}
|
||||
|
||||
export const CustomerCard = ({
|
||||
customer,
|
||||
onPress,
|
||||
onAction,
|
||||
actionIcon,
|
||||
actionColor,
|
||||
}: CustomerCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const isOverdue = customer.status === 'overdue';
|
||||
const isPaid = customer.dueAmount === 0;
|
||||
|
||||
const statusColor = isPaid
|
||||
? theme.colors.success
|
||||
: isOverdue
|
||||
? theme.colors.danger
|
||||
: theme.colors.warning;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
transform: [{ scale: pressed ? 0.98 : 1 }],
|
||||
},
|
||||
]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.profileInfo}>
|
||||
<View style={[styles.avatar, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>
|
||||
{customer.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
|
||||
<View style={styles.phoneRow}>
|
||||
<Ionicons name="call-outline" size={12} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.phone, { color: theme.colors.textMuted }]}>
|
||||
{maskPhone(customer.phone)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${statusColor}15` }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{isPaid ? 'Paid' : isOverdue ? 'Overdue' : 'Pending'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.detailsRow}>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>Total Due</Text>
|
||||
<Text style={[styles.detailValue, { color: isOverdue ? theme.colors.danger : theme.colors.textPrimary }]}>
|
||||
{formatAgentCurrency(customer.dueAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>Total Collected</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatAgentCurrency(customer.totalCollected)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{onAction && (
|
||||
<Pressable
|
||||
style={[styles.actionBtn, { backgroundColor: `${actionColor || theme.colors.primary}15` }]}
|
||||
onPress={onAction}
|
||||
>
|
||||
<Ionicons name={actionIcon || 'chevron-forward'} size={18} color={actionColor || theme.colors.primary} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
profileInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
name: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
phoneRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
phone: {
|
||||
fontSize: 12,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
gap: 4,
|
||||
},
|
||||
statusDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginVertical: 12,
|
||||
},
|
||||
detailsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
detailItem: {
|
||||
flex: 1,
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
vDivider: {
|
||||
width: 1,
|
||||
height: 30,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
actionBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AgentLoanCategory, CollectionMode, NewAccountType, SmsType } from '../types/agentTypes';
|
||||
|
||||
export const COLLECTION_MODES: { id: CollectionMode; label: string; icon: string }[] = [
|
||||
{ id: 'cash', label: 'Cash', icon: 'cash-outline' },
|
||||
{ id: 'upi', label: 'UPI', icon: 'phone-portrait-outline' },
|
||||
{ id: 'bank_transfer', label: 'Bank Transfer', icon: 'swap-horizontal-outline' },
|
||||
];
|
||||
|
||||
export const ACCOUNT_TYPES: { id: NewAccountType; label: string; description: string; icon: string }[] = [
|
||||
{ id: 'DDS', label: 'DDS Account', description: 'Daily Deposit Scheme for regular savings', icon: 'calendar-outline' },
|
||||
{ id: 'RD', label: 'RD Account', description: 'Recurring Deposit with fixed monthly installments', icon: 'trending-up-outline' },
|
||||
{ id: 'Savings', label: 'Savings Account', description: 'Standard savings account with interest', icon: 'wallet-outline' },
|
||||
];
|
||||
|
||||
export const LOAN_CATEGORIES: { id: AgentLoanCategory; label: string; icon: string; color: string }[] = [
|
||||
{ id: 'Personal', label: 'Personal Loan', icon: 'person-outline', color: '#3B82F6' },
|
||||
{ id: 'Gold', label: 'Gold Loan', icon: 'diamond-outline', color: '#F59E0B' },
|
||||
{ id: 'Vehicle', label: 'Vehicle Loan', icon: 'car-outline', color: '#10B981' },
|
||||
{ id: 'Housing', label: 'Housing Loan', icon: 'home-outline', color: '#6366F1' },
|
||||
{ id: 'Agriculture', label: 'Agriculture Loan', icon: 'leaf-outline', color: '#22C55E' },
|
||||
{ id: 'Business', label: 'Business Loan', icon: 'briefcase-outline', color: '#EF4444' },
|
||||
];
|
||||
|
||||
export const SMS_TYPES: { id: SmsType; label: string; icon: string }[] = [
|
||||
{ id: 'balance', label: 'Balance Confirmation', icon: 'wallet-outline' },
|
||||
{ id: 'payment_success', label: 'Payment Success', icon: 'checkmark-circle-outline' },
|
||||
{ id: 'due_reminder', label: 'Due Reminder', icon: 'alarm-outline' },
|
||||
{ id: 'loan_status', label: 'Loan Status', icon: 'document-text-outline' },
|
||||
{ id: 'account_created', label: 'Account Created', icon: 'person-add-outline' },
|
||||
];
|
||||
|
||||
export const CUSTOMER_FILTERS = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'paid', label: 'Paid' },
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'overdue', label: 'Overdue' },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_COMMISSION_RATE = 2.5; // percentage
|
||||
|
||||
export const ACCOUNT_OPENING_STEPS: { id: string; label: string }[] = [
|
||||
{ id: 'type', label: 'Account Type' },
|
||||
{ id: 'personal', label: 'Personal Info' },
|
||||
{ id: 'kyc', label: 'KYC Details' },
|
||||
{ id: 'deposit', label: 'Initial Deposit' },
|
||||
{ id: 'review', label: 'Review' },
|
||||
];
|
||||
|
||||
export const LOAN_REQUEST_STEPS: { id: string; label: string }[] = [
|
||||
{ id: 'customer', label: 'Customer' },
|
||||
{ id: 'details', label: 'Loan Details' },
|
||||
{ id: 'documents', label: 'Documents' },
|
||||
{ id: 'review', label: 'Review' },
|
||||
];
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import { selectAccountDraft, selectAccountStep } from '../store/agentSelectors';
|
||||
import { useSubmitAccountOpeningMutation } from '../services/agentApi';
|
||||
import type { AccountOpeningStep, AccountOpeningDraft, NewAccountType } from '../types/agentTypes';
|
||||
import { validateRequired, validateAadhaar, validatePan, validatePhone, validateAmount } from '../utils/agentValidation';
|
||||
|
||||
export const useAccountOpeningForm = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const draft = useAppSelector(selectAccountDraft);
|
||||
const step = useAppSelector(selectAccountStep);
|
||||
|
||||
const [submitOpening, { isLoading }] = useSubmitAccountOpeningMutation();
|
||||
|
||||
const setStep = useCallback((newStep: AccountOpeningStep) => {
|
||||
dispatch(agentActions.setAccountOpeningStep(newStep));
|
||||
}, [dispatch]);
|
||||
|
||||
const updateField = useCallback((field: keyof AccountOpeningDraft, value: string) => {
|
||||
dispatch(agentActions.updateAccountDraftField({ field, value }));
|
||||
}, [dispatch]);
|
||||
|
||||
const setAccountType = useCallback((type: NewAccountType) => {
|
||||
dispatch(agentActions.setAccountType(type));
|
||||
}, [dispatch]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch(agentActions.resetAccountDraft());
|
||||
}, [dispatch]);
|
||||
|
||||
const validateStep = (currentStep: AccountOpeningStep): boolean => {
|
||||
// Simple validation for the multi-step form
|
||||
if (currentStep === 'type') {
|
||||
return !!draft.accountType;
|
||||
}
|
||||
|
||||
if (currentStep === 'personal') {
|
||||
return (
|
||||
!validateRequired(draft.customerName, 'Name') &&
|
||||
!validatePhone(draft.phone) &&
|
||||
!validateRequired(draft.address, 'Address')
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === 'kyc') {
|
||||
return (
|
||||
!validateAadhaar(draft.aadhaarNumber) &&
|
||||
!validatePan(draft.panNumber)
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === 'deposit') {
|
||||
return !validateAmount(draft.initialDeposit, 500);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (!validateStep(step)) return;
|
||||
|
||||
if (step === 'type') setStep('personal');
|
||||
else if (step === 'personal') setStep('kyc');
|
||||
else if (step === 'kyc') setStep('deposit');
|
||||
else if (step === 'deposit') setStep('review');
|
||||
}, [step, validateStep, setStep]);
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
if (step === 'review') setStep('deposit');
|
||||
else if (step === 'deposit') setStep('kyc');
|
||||
else if (step === 'kyc') setStep('personal');
|
||||
else if (step === 'personal') setStep('type');
|
||||
}, [step, setStep]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
try {
|
||||
const result = await submitOpening(draft).unwrap();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to submit account opening', error);
|
||||
return null;
|
||||
}
|
||||
}, [draft, submitOpening]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
step,
|
||||
setStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
updateField,
|
||||
setAccountType,
|
||||
reset,
|
||||
submit,
|
||||
isSubmitting: isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import { selectDashboardStats, selectIsRefreshing } from '../store/agentSelectors';
|
||||
import { useGetAgentDashboardQuery } from '../services/agentApi';
|
||||
|
||||
export const useAgentDashboard = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const stats = useAppSelector(selectDashboardStats);
|
||||
const isRefreshing = useAppSelector(selectIsRefreshing);
|
||||
|
||||
const { data, isLoading, isFetching, refetch, error } = useGetAgentDashboardQuery(undefined, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
dispatch(agentActions.setDashboardStats(data));
|
||||
dispatch(agentActions.setRefreshing(false));
|
||||
}
|
||||
}, [data, dispatch]);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
dispatch(agentActions.setRefreshing(true));
|
||||
refetch();
|
||||
}, [dispatch, refetch]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
isLoading: isLoading && !stats,
|
||||
isRefreshing,
|
||||
error,
|
||||
onRefresh,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { CollectionMode } from '../types/agentTypes';
|
||||
import { validateCollectionAmount } from '../utils/agentValidation';
|
||||
import { useSubmitCollectionMutation } from '../services/agentApi';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
|
||||
export const useCollectionForm = (entryId: string, emiAmount: number) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [amount, setAmount] = useState<string>(emiAmount.toString());
|
||||
const [mode, setMode] = useState<CollectionMode>('cash');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [submitCollection, { isLoading }] = useSubmitCollectionMutation();
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const err = validateCollectionAmount(amount, emiAmount);
|
||||
setError(err);
|
||||
return err === null;
|
||||
}, [amount, emiAmount]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!validate()) return null;
|
||||
|
||||
try {
|
||||
const receipt = await submitCollection({
|
||||
entryId,
|
||||
amount: Number(amount),
|
||||
mode,
|
||||
}).unwrap();
|
||||
|
||||
// Update local state optimisticly
|
||||
dispatch(agentActions.updateCollectionEntry({
|
||||
id: entryId,
|
||||
customerId: '',
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
accountId: '',
|
||||
accountType: '',
|
||||
emiAmount,
|
||||
collectedAmount: Number(amount),
|
||||
pendingAmount: Math.max(0, emiAmount - Number(amount)),
|
||||
status: Number(amount) >= emiAmount ? 'collected' : 'partial',
|
||||
mode,
|
||||
collectedAt: new Date().toISOString(),
|
||||
synced: true,
|
||||
} as any));
|
||||
|
||||
return receipt;
|
||||
} catch (err) {
|
||||
console.error('Failed to submit collection', err);
|
||||
// Fallback: Add to offline queue
|
||||
dispatch(agentActions.addToOfflineQueue({
|
||||
id: entryId,
|
||||
customerId: '',
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
accountId: '',
|
||||
accountType: '',
|
||||
emiAmount,
|
||||
collectedAmount: Number(amount),
|
||||
pendingAmount: Math.max(0, emiAmount - Number(amount)),
|
||||
status: Number(amount) >= emiAmount ? 'collected' : 'partial',
|
||||
mode,
|
||||
collectedAt: new Date().toISOString(),
|
||||
synced: false,
|
||||
} as any));
|
||||
return null;
|
||||
}
|
||||
}, [amount, mode, entryId, validate, submitCollection, dispatch, emiAmount]);
|
||||
|
||||
return {
|
||||
amount,
|
||||
setAmount,
|
||||
mode,
|
||||
setMode,
|
||||
error,
|
||||
submit,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { AgentCustomer } from '../types/agentTypes';
|
||||
import { validateCustomerForm } from '../utils/agentValidation';
|
||||
import { useAddAgentCustomerMutation, useUpdateAgentCustomerMutation } from '../services/agentApi';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
|
||||
export interface CustomerFormData {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
pincode: string;
|
||||
aadhaarNumber: string;
|
||||
panNumber: string;
|
||||
}
|
||||
|
||||
const initialData: CustomerFormData = {
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
pincode: '',
|
||||
aadhaarNumber: '',
|
||||
panNumber: '',
|
||||
};
|
||||
|
||||
export const useCustomerForm = (existingCustomer?: AgentCustomer) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [data, setData] = useState<CustomerFormData>(
|
||||
existingCustomer
|
||||
? {
|
||||
name: existingCustomer.name,
|
||||
phone: existingCustomer.phone,
|
||||
email: existingCustomer.email || '',
|
||||
address: existingCustomer.address,
|
||||
city: existingCustomer.city,
|
||||
state: existingCustomer.state,
|
||||
pincode: existingCustomer.pincode,
|
||||
aadhaarNumber: existingCustomer.kyc.aadhaarNumber,
|
||||
panNumber: existingCustomer.kyc.panNumber,
|
||||
}
|
||||
: initialData
|
||||
);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [addCustomer, { isLoading: isAdding }] = useAddAgentCustomerMutation();
|
||||
const [updateCustomer, { isLoading: isUpdating }] = useUpdateAgentCustomerMutation();
|
||||
|
||||
const updateField = useCallback((field: keyof CustomerFormData, value: string) => {
|
||||
setData((prev) => ({ ...prev, [field]: value }));
|
||||
// Clear error when user types
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const result = validateCustomerForm(data);
|
||||
setErrors(result.errors);
|
||||
return result.isValid;
|
||||
}, [data]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!validate()) return null;
|
||||
|
||||
try {
|
||||
if (existingCustomer) {
|
||||
const result = await updateCustomer({
|
||||
...existingCustomer,
|
||||
...data,
|
||||
kyc: {
|
||||
...existingCustomer.kyc,
|
||||
aadhaarNumber: data.aadhaarNumber,
|
||||
panNumber: data.panNumber,
|
||||
},
|
||||
}).unwrap();
|
||||
// Update the customer in the Redux store so the list reflects changes
|
||||
dispatch(agentActions.updateCustomer(result));
|
||||
return result;
|
||||
} else {
|
||||
const result = await addCustomer(data).unwrap();
|
||||
// Add the new customer to the Redux store so it appears in the list immediately
|
||||
dispatch(agentActions.addCustomer(result));
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to submit customer form', err);
|
||||
return null;
|
||||
}
|
||||
}, [data, existingCustomer, validate, addCustomer, updateCustomer, dispatch]);
|
||||
|
||||
return {
|
||||
data,
|
||||
errors,
|
||||
updateField,
|
||||
submit,
|
||||
isSubmitting: isAdding || isUpdating,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { AgentLoanCategory } from '../types/agentTypes';
|
||||
import { validateLoanRequest } from '../utils/agentValidation';
|
||||
import { useSubmitAgentLoanRequestMutation } from '../services/agentApi';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
|
||||
export interface LoanFormData {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
category: AgentLoanCategory | null;
|
||||
amount: string;
|
||||
tenureMonths: string;
|
||||
purpose: string;
|
||||
monthlyIncome: string;
|
||||
}
|
||||
|
||||
const initialData: LoanFormData = {
|
||||
customerId: '',
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
category: null,
|
||||
amount: '',
|
||||
tenureMonths: '12',
|
||||
purpose: '',
|
||||
monthlyIncome: '',
|
||||
};
|
||||
|
||||
export const useLoanRequestForm = (initialCategory?: AgentLoanCategory, customerId?: string) => {
|
||||
const [data, setData] = useState<LoanFormData>({
|
||||
...initialData,
|
||||
category: initialCategory || null,
|
||||
customerId: customerId || '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitRequest, { isLoading }] = useSubmitAgentLoanRequestMutation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const updateField = useCallback((field: keyof LoanFormData, value: string) => {
|
||||
setData((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const result = validateLoanRequest(data);
|
||||
setErrors(result.errors);
|
||||
return result.isValid;
|
||||
}, [data]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!validate()) return null;
|
||||
|
||||
try {
|
||||
const result = await submitRequest({
|
||||
...data,
|
||||
amount: Number(data.amount),
|
||||
tenureMonths: Number(data.tenureMonths),
|
||||
monthlyIncome: Number(data.monthlyIncome),
|
||||
category: data.category!,
|
||||
}).unwrap();
|
||||
|
||||
dispatch(agentActions.addLoanRequest(result));
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to submit loan request', err);
|
||||
return null;
|
||||
}
|
||||
}, [data, validate, submitRequest, dispatch]);
|
||||
|
||||
return {
|
||||
data,
|
||||
errors,
|
||||
updateField,
|
||||
submit,
|
||||
isSubmitting: isLoading,
|
||||
validate,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NavigatorScreenParams } from '@react-navigation/native';
|
||||
import type { AgentLoanCategory } from '../types/agentTypes';
|
||||
|
||||
export type AgentDashboardStackParamList = {
|
||||
AgentDashboardMain: undefined;
|
||||
AgentRoute: undefined;
|
||||
};
|
||||
|
||||
export type AgentCustomersStackParamList = {
|
||||
CustomerList: undefined;
|
||||
CustomerDetail: { customerId: string };
|
||||
AddEditCustomer: { customerId?: string };
|
||||
CustomerInstallmentHistory: { customerId: string };
|
||||
};
|
||||
|
||||
export type AgentCollectionsStackParamList = {
|
||||
DailyCollection: undefined;
|
||||
CollectionHistory: undefined;
|
||||
CollectionReceipt: { entryId: string };
|
||||
};
|
||||
|
||||
export type AgentLoansStackParamList = {
|
||||
AgentLoansMain: { initialTab?: 'loans' | 'commission' | 'account' };
|
||||
CreateLoanRequest: { category?: AgentLoanCategory; customerId?: string };
|
||||
LoanRequestDetail: { requestId: string };
|
||||
AccountOpening: undefined;
|
||||
CommissionDashboard: undefined;
|
||||
};
|
||||
|
||||
export type AgentProfileStackParamList = {
|
||||
AgentProfileMain: undefined;
|
||||
SmsHistory: undefined;
|
||||
AgentChangePassword: undefined;
|
||||
};
|
||||
|
||||
export type AgentTabParamList = {
|
||||
AgentDashboard: NavigatorScreenParams<AgentDashboardStackParamList>;
|
||||
AgentCustomers: NavigatorScreenParams<AgentCustomersStackParamList>;
|
||||
AgentCollections: NavigatorScreenParams<AgentCollectionsStackParamList>;
|
||||
AgentLoans: NavigatorScreenParams<AgentLoansStackParamList>;
|
||||
AgentProfile: NavigatorScreenParams<AgentProfileStackParamList>;
|
||||
};
|
||||
@@ -0,0 +1,544 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Alert, Platform, KeyboardAvoidingView } from 'react-native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInRight, FadeOutLeft } from 'react-native-reanimated';
|
||||
|
||||
import { Input, Button, DatePicker, KeyboardAwareScrollView } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useAccountOpeningForm } from '../hooks/useAccountOpeningForm';
|
||||
import { ACCOUNT_TYPES } from '../constants/agentConstants';
|
||||
import { formatAgentCurrency } from '../utils/agentFormatters';
|
||||
|
||||
const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepIndex: number }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<View style={styles.stepContainer}>
|
||||
<View style={styles.stepRow}>
|
||||
{steps.map((s, i) => {
|
||||
const isPassed = i <= currentStepIndex;
|
||||
const isActive = i === currentStepIndex;
|
||||
return (
|
||||
<View key={s.id} style={styles.stepWrapper}>
|
||||
<View style={[
|
||||
styles.stepDot,
|
||||
{
|
||||
backgroundColor: isPassed ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: isPassed ? theme.colors.primary : theme.colors.cardBorder,
|
||||
transform: [{ scale: isActive ? 1.15 : 1 }]
|
||||
}
|
||||
]}>
|
||||
{i < currentStepIndex ? (
|
||||
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
|
||||
) : (
|
||||
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontWeight: '700' }}>{i + 1}</Text>
|
||||
)}
|
||||
</View>
|
||||
{i < steps.length - 1 && <View style={[styles.stepLine, { backgroundColor: i < currentStepIndex ? theme.colors.primary : theme.colors.cardBorder }]} />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.stepLabelRow}>
|
||||
{steps.map((s, i) => (
|
||||
<Text
|
||||
key={s.id}
|
||||
style={[
|
||||
styles.stepLabel,
|
||||
{ color: i <= currentStepIndex ? theme.colors.primary : theme.colors.textMuted },
|
||||
i === currentStepIndex && { fontWeight: '700' },
|
||||
]}
|
||||
>
|
||||
{s.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AccountOpeningScreen({ navigation }: { navigation: NativeStackNavigationProp<AgentLoansStackParamList> }) {
|
||||
const theme = useTheme();
|
||||
const { draft, step, nextStep, prevStep, updateField, setAccountType, submit, isSubmitting } = useAccountOpeningForm();
|
||||
|
||||
const handleNext = () => nextStep();
|
||||
const handleBack = () => prevStep();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const res = await submit();
|
||||
if (res) {
|
||||
Alert.alert(
|
||||
'Account Created',
|
||||
`Account Number: ${res.accountNumber}\nInitial Deposit collected.`,
|
||||
[{ text: 'OK', onPress: () => navigation.navigate('AgentLoansMain', { initialTab: 'loans' }) }]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ id: 'type', label: 'Type' },
|
||||
{ id: 'personal', label: 'Personal' },
|
||||
{ id: 'kyc', label: 'KYC' },
|
||||
{ id: 'deposit', label: 'Deposit' },
|
||||
{ id: 'review', label: 'Review' },
|
||||
];
|
||||
|
||||
const currentStepIndex = steps.findIndex(s => s.id === step);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.flexContainer}>
|
||||
{/* Local header */}
|
||||
<View style={styles.localHeader}>
|
||||
<Pressable onPress={() => navigation.goBack()} hitSlop={12} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[styles.localHeaderTitle, { color: theme.colors.textPrimary }]}>NEW ACCOUNT</Text>
|
||||
</View>
|
||||
|
||||
<StepIndicator steps={steps} currentStepIndex={currentStepIndex} />
|
||||
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.flex}>
|
||||
<KeyboardAwareScrollView contentContainerStyle={styles.scrollContent} style={styles.flex}>
|
||||
{step === 'type' && (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
|
||||
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Select Account Type</Text>
|
||||
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Choose the type of account you want to open for the customer</Text>
|
||||
|
||||
{ACCOUNT_TYPES.map(type => (
|
||||
<Pressable
|
||||
key={type.id}
|
||||
style={[
|
||||
styles.typeCard,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: draft.accountType === type.id ? theme.colors.primary : theme.colors.cardBorder,
|
||||
borderWidth: draft.accountType === type.id ? 2 : 1,
|
||||
}
|
||||
]}
|
||||
onPress={() => setAccountType(type.id)}
|
||||
>
|
||||
<View style={[styles.typeIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name={type.icon as any} size={28} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.typeLabel, { color: theme.colors.textPrimary }]}>{type.label}</Text>
|
||||
<Text style={[styles.typeDesc, { color: theme.colors.textMuted }]}>{type.description}</Text>
|
||||
</View>
|
||||
<View style={[styles.radio, { borderColor: draft.accountType === type.id ? theme.colors.primary : theme.colors.cardBorder }]}>
|
||||
{draft.accountType === type.id && <View style={[styles.radioFill, { backgroundColor: theme.colors.primary }]} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{step === 'personal' && (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
|
||||
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Personal Information</Text>
|
||||
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Enter customer's basic details</Text>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Input
|
||||
label="Full Name"
|
||||
placeholder="e.g. Amit Sharma"
|
||||
value={draft.customerName}
|
||||
onChangeText={v => updateField('customerName', v)}
|
||||
/>
|
||||
<Input
|
||||
label="Phone Number"
|
||||
placeholder="10-digit mobile number"
|
||||
value={draft.phone}
|
||||
onChangeText={v => updateField('phone', v)}
|
||||
keyboardType="number-pad"
|
||||
maxLength={10}
|
||||
/>
|
||||
<DatePicker
|
||||
label="Date of Birth"
|
||||
placeholder="DD/MM/YYYY"
|
||||
value={draft.dateOfBirth}
|
||||
onDateChange={v => updateField('dateOfBirth', v)}
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
<Input
|
||||
label="Address"
|
||||
placeholder="House/Flat No, Street, Area"
|
||||
value={draft.address}
|
||||
onChangeText={v => updateField('address', v)}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{step === 'kyc' && (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
|
||||
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>KYC & Nominee</Text>
|
||||
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Identity verification & nominee details</Text>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Input
|
||||
label="Aadhaar Number"
|
||||
placeholder="12-digit Aadhaar number"
|
||||
value={draft.aadhaarNumber}
|
||||
onChangeText={v => updateField('aadhaarNumber', v)}
|
||||
keyboardType="number-pad"
|
||||
maxLength={12}
|
||||
/>
|
||||
<Input
|
||||
label="PAN Number"
|
||||
placeholder="e.g. ABCDE1234F"
|
||||
value={draft.panNumber}
|
||||
onChangeText={v => updateField('panNumber', v)}
|
||||
autoCapitalize="characters"
|
||||
maxLength={10}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.sectionDivider, { borderColor: theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.sectionDividerText, { color: theme.colors.textMuted, backgroundColor: theme.colors.background }]}>Nominee</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Input
|
||||
label="Nominee Name"
|
||||
placeholder="e.g. Sunita Sharma"
|
||||
value={draft.nomineeName}
|
||||
onChangeText={v => updateField('nomineeName', v)}
|
||||
/>
|
||||
<Input
|
||||
label="Relationship"
|
||||
placeholder="e.g. Spouse, Parent, Child"
|
||||
value={draft.nomineeRelation}
|
||||
onChangeText={v => updateField('nomineeRelation', v)}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{step === 'deposit' && (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
|
||||
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Initial Deposit</Text>
|
||||
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Collect the opening deposit from customer</Text>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Input
|
||||
label="Initial Deposit Amount"
|
||||
placeholder="e.g. 1000"
|
||||
value={draft.initialDeposit}
|
||||
onChangeText={v => updateField('initialDeposit', v)}
|
||||
keyboardType="number-pad"
|
||||
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
|
||||
/>
|
||||
{(draft.accountType === 'RD' || draft.accountType === 'DDS') && (
|
||||
<Input
|
||||
label={`${draft.accountType === 'RD' ? 'Monthly' : 'Daily'} Installment`}
|
||||
placeholder={draft.accountType === 'RD' ? 'e.g. 500' : 'e.g. 100'}
|
||||
value={draft.monthlyInstallment}
|
||||
onChangeText={v => updateField('monthlyInstallment', v)}
|
||||
keyboardType="number-pad"
|
||||
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.warningBox, { backgroundColor: `${theme.colors.info}10`, borderColor: `${theme.colors.info}30` }]}>
|
||||
<Ionicons name="cash-outline" size={20} color={theme.colors.info} />
|
||||
<Text style={[styles.warningText, { color: theme.colors.info }]}>
|
||||
Please collect the initial deposit amount from the customer in cash before proceeding.
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{step === 'review' && (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
|
||||
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Review Application</Text>
|
||||
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Please verify all details before submitting</Text>
|
||||
|
||||
<View style={[styles.reviewCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Account Type</Text>
|
||||
<Text style={[styles.reviewValue, { color: theme.colors.primary }]}>{draft.accountType} Account</Text>
|
||||
</View>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Name</Text>
|
||||
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.customerName}</Text>
|
||||
</View>
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Phone</Text>
|
||||
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.phone}</Text>
|
||||
</View>
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>DOB</Text>
|
||||
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.dateOfBirth}</Text>
|
||||
</View>
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Aadhaar</Text>
|
||||
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.aadhaarNumber}</Text>
|
||||
</View>
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>PAN</Text>
|
||||
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.panNumber}</Text>
|
||||
</View>
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Nominee</Text>
|
||||
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.nomineeName} ({draft.nomineeRelation})</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Initial Deposit</Text>
|
||||
<Text style={[styles.reviewValue, { color: theme.colors.success, fontWeight: '800', fontSize: 16 }]}>
|
||||
{formatAgentCurrency(Number(draft.initialDeposit))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</KeyboardAwareScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Fixed footer buttons */}
|
||||
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.footerRow}>
|
||||
<View style={styles.col}>
|
||||
{step !== 'type' && (
|
||||
<Button
|
||||
title="Back"
|
||||
variant="secondary"
|
||||
onPress={handleBack}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{step !== 'type' && <View style={{ width: 16 }} />}
|
||||
<View style={styles.col}>
|
||||
{step !== 'review' ? (
|
||||
<Button
|
||||
title="Next"
|
||||
onPress={handleNext}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
title="Submit & Open"
|
||||
onPress={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
flexContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
localHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 12,
|
||||
gap: 8,
|
||||
},
|
||||
backBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
localHeaderTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
// ── Step indicator ─────────────────────────
|
||||
stepContainer: {
|
||||
paddingTop: 8,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
stepWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
stepDot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1.5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stepLine: {
|
||||
width: 24,
|
||||
height: 2,
|
||||
marginHorizontal: 4,
|
||||
borderRadius: 1,
|
||||
},
|
||||
stepLabelRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingHorizontal: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.3,
|
||||
width: 54,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// ── Form content ──────────────────────────
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
stepView: {
|
||||
gap: 4,
|
||||
},
|
||||
stepTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
stepSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 20,
|
||||
},
|
||||
formGroup: {
|
||||
gap: 16,
|
||||
},
|
||||
|
||||
// ── Account type cards ────────────────────
|
||||
typeCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
gap: 16,
|
||||
},
|
||||
typeIcon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
typeLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
typeDesc: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
radio: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
radioFill: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
|
||||
// ── Section divider ───────────────────────
|
||||
sectionDivider: {
|
||||
borderTopWidth: 1,
|
||||
marginVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionDividerText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
paddingHorizontal: 12,
|
||||
marginTop: -8,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginVertical: 12,
|
||||
},
|
||||
|
||||
// ── Info box ──────────────────────────────
|
||||
warningBox: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
marginTop: 20,
|
||||
gap: 12,
|
||||
},
|
||||
warningText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// ── Review card ───────────────────────────
|
||||
reviewCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
},
|
||||
reviewRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
reviewLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
reviewValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
flexShrink: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
|
||||
// ── Footer ────────────────────────────────
|
||||
footer: {
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 10,
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
col: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { selectCustomerById } from '../store/agentSelectors';
|
||||
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useCustomerForm } from '../hooks/useCustomerForm';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'AddEditCustomer'>;
|
||||
|
||||
const FormSection = ({ title, icon, children }: { title: string, icon: string, children: React.ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Animated.View entering={FadeInUp} style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.sectionContent}>
|
||||
{children}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AddEditCustomerScreen({ route, navigation }: Props) {
|
||||
const { customerId } = route.params;
|
||||
const theme = useTheme();
|
||||
|
||||
const existingCustomer = useAppSelector(state =>
|
||||
customerId ? selectCustomerById(customerId)(state) : null
|
||||
);
|
||||
|
||||
const { data, errors, updateField, submit, isSubmitting } = useCustomerForm(existingCustomer || undefined);
|
||||
|
||||
const isEdit = !!existingCustomer;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const res = await submit();
|
||||
if (res) {
|
||||
navigation.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<ScreenShell title={isEdit ? 'Edit Customer' : 'Add New Customer'} scrollable={false} keyboardAware={false} withTabBar={false}>
|
||||
<View style={styles.flexContainer}>
|
||||
<KeyboardAwareScrollView contentContainerStyle={styles.scrollContent} style={styles.flex}>
|
||||
|
||||
<FormSection title="Personal Information" icon="person-outline">
|
||||
<Input
|
||||
label="Full Name"
|
||||
placeholder="e.g. Amit Sharma"
|
||||
value={data.name}
|
||||
onChangeText={(v) => updateField('name', v)}
|
||||
error={errors.name}
|
||||
/>
|
||||
<Input
|
||||
label="Phone Number"
|
||||
placeholder="10-digit number"
|
||||
value={data.phone}
|
||||
onChangeText={(v) => updateField('phone', v)}
|
||||
keyboardType="number-pad"
|
||||
maxLength={10}
|
||||
error={errors.phone}
|
||||
leftAccessory={<Text style={{ color: theme.colors.textPrimary, fontWeight: 'bold' }}>+91</Text>}
|
||||
/>
|
||||
<Input
|
||||
label="Email Address (Optional)"
|
||||
placeholder="e.g. amit@example.com"
|
||||
value={data.email}
|
||||
onChangeText={(v) => updateField('email', v)}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
error={errors.email}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="KYC Details" icon="finger-print-outline">
|
||||
<Input
|
||||
label="Aadhaar Number"
|
||||
placeholder="12-digit number"
|
||||
value={data.aadhaarNumber}
|
||||
onChangeText={(v) => updateField('aadhaarNumber', v)}
|
||||
keyboardType="number-pad"
|
||||
maxLength={12}
|
||||
error={errors.aadhaarNumber}
|
||||
/>
|
||||
<Input
|
||||
label="PAN Number"
|
||||
placeholder="e.g. ABCDE1234F"
|
||||
value={data.panNumber}
|
||||
onChangeText={(v) => updateField('panNumber', v)}
|
||||
autoCapitalize="characters"
|
||||
maxLength={10}
|
||||
error={errors.panNumber}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="Address" icon="location-outline">
|
||||
<Input
|
||||
label="Street Address"
|
||||
placeholder="House/Flat No, Area"
|
||||
value={data.address}
|
||||
onChangeText={(v) => updateField('address', v)}
|
||||
error={errors.address}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.col}>
|
||||
<Input
|
||||
label="City"
|
||||
placeholder="e.g. Kolkata"
|
||||
value={data.city}
|
||||
onChangeText={(v) => updateField('city', v)}
|
||||
error={errors.city}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width: 12 }} />
|
||||
<View style={styles.col}>
|
||||
<Input
|
||||
label="State"
|
||||
placeholder="e.g. West Bengal"
|
||||
value={data.state}
|
||||
onChangeText={(v) => updateField('state', v)}
|
||||
error={errors.state}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Input
|
||||
label="Pincode"
|
||||
placeholder="6-digit pincode"
|
||||
value={data.pincode}
|
||||
onChangeText={(v) => updateField('pincode', v)}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
error={errors.pincode}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
</KeyboardAwareScrollView>
|
||||
|
||||
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
|
||||
<Button
|
||||
title={isEdit ? 'Save Changes' : 'Add Customer'}
|
||||
onPress={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
flexContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
section: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
sectionIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
sectionContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 16,
|
||||
gap: 16,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
col: {
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
padding: 16,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 10,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, ScrollView, Alert, Platform, KeyboardAvoidingView } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { ScreenShell, Input, Button } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentProfileStackParamList, 'AgentChangePassword'>;
|
||||
|
||||
export default function AgentChangePasswordScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
Alert.alert('Error', 'Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
Alert.alert('Error', 'New passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
Alert.alert('Success', 'Your password has been changed successfully.', [
|
||||
{ text: 'OK', onPress: () => navigation.goBack() }
|
||||
]);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Change Password" scrollable={false} keyboardAware={false}>
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 20}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled" automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}>
|
||||
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Input
|
||||
label="Current Password"
|
||||
value={currentPassword}
|
||||
onChangeText={setCurrentPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
<Input
|
||||
label="New Password"
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Update Password"
|
||||
onPress={handleSubmit}
|
||||
loading={loading}
|
||||
style={{ marginTop: 24 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, RefreshControl, Pressable } from 'react-native';
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAppSelector, useAppDispatch } from '../../../app/hooks';
|
||||
import type { AgentTabParamList, AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useAgentDashboard } from '../hooks/useAgentDashboard';
|
||||
import { formatAgentCurrency, getGreeting } from '../utils/agentFormatters';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import type { CustomerFilter } from '../types/agentTypes';
|
||||
|
||||
import { AgentStatCard } from '../components/AgentStatCard';
|
||||
import { AgentMiniChart } from '../components/AgentMiniChart';
|
||||
import { AgentProgressRing } from '../components/AgentProgressRing';
|
||||
import { AgentSectionHeader } from '../components/AgentSectionHeader';
|
||||
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
|
||||
import { AgentEmptyState } from '../components/AgentEmptyState';
|
||||
|
||||
type AgentDashboardNavigationProp = CompositeNavigationProp<
|
||||
NativeStackNavigationProp<AgentDashboardStackParamList, 'AgentDashboardMain'>,
|
||||
BottomTabNavigationProp<AgentTabParamList>
|
||||
>;
|
||||
|
||||
export default function AgentDashboardScreen({ navigation }: { navigation: AgentDashboardNavigationProp }) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const profile = useAppSelector(state => state.user.profile);
|
||||
const { stats, isLoading, isRefreshing, error, onRefresh } = useAgentDashboard();
|
||||
|
||||
// ── Navigation helpers ──────────────────────────────────────
|
||||
const goToCustomers = (filter: CustomerFilter = 'all') => {
|
||||
dispatch(agentActions.setCustomerFilter(filter));
|
||||
navigation.navigate('AgentCustomers', { screen: 'CustomerList' });
|
||||
};
|
||||
|
||||
const goToCollections = () => {
|
||||
navigation.navigate('AgentCollections', { screen: 'DailyCollection' });
|
||||
};
|
||||
|
||||
const goToCollectionHistory = () => {
|
||||
navigation.navigate('AgentCollections', { screen: 'CollectionHistory' });
|
||||
};
|
||||
|
||||
const goToCommission = () => {
|
||||
navigation.navigate('AgentLoans', { screen: 'CommissionDashboard' });
|
||||
};
|
||||
|
||||
const goToRoute = () => {
|
||||
navigation.navigate('AgentRoute');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ScreenShell scrollable={false}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.greeting, { color: theme.colors.textMuted }]}>{getGreeting()},</Text>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName || 'Agent'}</Text>
|
||||
</View>
|
||||
<AgentSkeletonLoader type="dashboard" />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<ScreenShell scrollable={false}>
|
||||
<AgentEmptyState
|
||||
title="Dashboard Unavailable"
|
||||
description="We couldn't load your dashboard data. Please try again."
|
||||
icon="alert-circle-outline"
|
||||
actionTitle="Retry"
|
||||
onAction={onRefresh}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const collectionProgress = stats.possibleCollection > 0
|
||||
? (stats.actualCollection / stats.possibleCollection) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<ScreenShell scrollable={false}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} colors={[theme.colors.primary]} />
|
||||
}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={[styles.greeting, { color: theme.colors.textSecondary }]}>{getGreeting()},</Text>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName || 'Agent'}</Text>
|
||||
</View>
|
||||
<View style={[styles.avatarFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name="person" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Customer Stats Row */}
|
||||
<Animated.View entering={FadeIn.duration(400)} style={styles.section}>
|
||||
<AgentSectionHeader
|
||||
title="Customer Overview"
|
||||
actionTitle="View All"
|
||||
onAction={() => goToCustomers('all')}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<AgentStatCard
|
||||
index={0}
|
||||
title="Total Customers"
|
||||
value={stats.totalCustomers.toString()}
|
||||
icon="people-outline"
|
||||
color={theme.colors.info}
|
||||
onPress={() => goToCustomers('all')}
|
||||
/>
|
||||
<View style={{ width: 12 }} />
|
||||
<AgentStatCard
|
||||
index={1}
|
||||
title="Paid Customers"
|
||||
value={stats.paidCustomers.toString()}
|
||||
icon="checkmark-circle-outline"
|
||||
color={theme.colors.success}
|
||||
trend={12}
|
||||
onPress={() => goToCustomers('paid')}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.row, { marginTop: 12 }]}>
|
||||
<AgentStatCard
|
||||
index={2}
|
||||
title="Pending Customers"
|
||||
value={stats.pendingCustomers.toString()}
|
||||
icon="time-outline"
|
||||
color={theme.colors.warning}
|
||||
onPress={() => goToCustomers('pending')}
|
||||
/>
|
||||
<View style={{ width: 12 }} />
|
||||
<AgentStatCard
|
||||
index={3}
|
||||
title="Pending Amount"
|
||||
value={formatAgentCurrency(stats.pendingAmount)}
|
||||
icon="alert-circle-outline"
|
||||
color={theme.colors.danger}
|
||||
onPress={() => goToCustomers('pending')}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Collection Section */}
|
||||
<Animated.View entering={FadeIn.duration(500)} style={styles.section}>
|
||||
<AgentSectionHeader
|
||||
title="Collection Performance"
|
||||
actionTitle="View All"
|
||||
onAction={goToCollections}
|
||||
/>
|
||||
|
||||
<View style={styles.row}>
|
||||
<AgentStatCard
|
||||
index={4}
|
||||
title="Today's Collection"
|
||||
value={formatAgentCurrency(stats.todayCollection)}
|
||||
icon="wallet-outline"
|
||||
gradient={['#2563EB', '#3B82F6']}
|
||||
onPress={goToCollections}
|
||||
/>
|
||||
<View style={{ width: 12 }} />
|
||||
<AgentStatCard
|
||||
index={5}
|
||||
title="This Week"
|
||||
value={formatAgentCurrency(stats.weeklyCollection)}
|
||||
icon="trending-up-outline"
|
||||
gradient={['#059669', '#10B981']}
|
||||
onPress={goToCollectionHistory}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={goToCollectionHistory}>
|
||||
<View style={[styles.chartCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.chartHeaderRow}>
|
||||
<View>
|
||||
<Text style={[styles.chartTitle, { color: theme.colors.textMuted }]}>Monthly Collection</Text>
|
||||
<Text style={[styles.chartValue, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(stats.monthlyCollection)}</Text>
|
||||
</View>
|
||||
<AgentProgressRing
|
||||
progress={collectionProgress}
|
||||
size={56}
|
||||
strokeWidth={5}
|
||||
color={theme.colors.primary}
|
||||
value={`${Math.round(collectionProgress)}%`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.chartDivider} />
|
||||
|
||||
<Text style={[styles.chartSubtitle, { color: theme.colors.textMuted }]}>Last 7 Days Trend</Text>
|
||||
<AgentMiniChart
|
||||
data={stats.weeklyCollectionTrend}
|
||||
height={80}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
{/* Commission & Routing */}
|
||||
<Animated.View entering={FadeIn.duration(600)} style={styles.section}>
|
||||
<AgentSectionHeader title="Insights" />
|
||||
<View style={styles.row}>
|
||||
<AgentStatCard
|
||||
index={6}
|
||||
title="Commission Earned"
|
||||
value={formatAgentCurrency(stats.commissionEarned)}
|
||||
icon="star-outline"
|
||||
color="#F59E0B"
|
||||
onPress={goToCommission}
|
||||
/>
|
||||
<View style={{ width: 12 }} />
|
||||
<AgentStatCard
|
||||
index={7}
|
||||
title="Route Completion"
|
||||
value={`${stats.completedVisits}/${stats.todayVisits}`}
|
||||
icon="map-outline"
|
||||
color="#8B5CF6"
|
||||
trendText={`${Math.round((stats.completedVisits / stats.todayVisits) * 100)}% done`}
|
||||
onPress={goToRoute}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.footerSpacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 100, // accommodate bottom tab
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
marginTop: 8,
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
avatarFrame: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
chartCard: {
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginTop: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
chartHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
chartTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
chartValue: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
chartDivider: {
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
marginVertical: 16,
|
||||
},
|
||||
chartSubtitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
marginBottom: 12,
|
||||
},
|
||||
footerSpacer: {
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ScreenShell, Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { logoutSession } from '../../auth/store/authThunk';
|
||||
import { Button } from '../../../components/Button';
|
||||
|
||||
export default function AgentHomeScreen() {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logoutSession());
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<View style={styles.container}>
|
||||
<Card style={styles.card}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Agent Portal</Text>
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
Welcome to the CBS Agent dashboard. Manage your clients and commissions here.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
title="Secure Logout"
|
||||
onPress={handleLogout}
|
||||
style={styles.logoutButton}
|
||||
variant="secondary"
|
||||
/>
|
||||
</Card>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
logoutButton: {
|
||||
marginTop: 24,
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Text, Pressable, ScrollView } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeIn, FadeInRight, FadeInLeft } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
|
||||
|
||||
import LoanRequestListScreen from './LoanRequestListScreen';
|
||||
import CommissionDashboardScreen from './CommissionDashboardScreen';
|
||||
import AccountOpeningScreen from './AccountOpeningScreen';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentLoansStackParamList, 'AgentLoansMain'>;
|
||||
|
||||
type TabKey = 'loans' | 'commission' | 'account';
|
||||
|
||||
export default function AgentLoansScreen({ route, navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>(route.params?.initialTab || 'loans');
|
||||
|
||||
const tabs: { key: TabKey; label: string; icon: string }[] = [
|
||||
{ key: 'loans', label: 'Loans', icon: 'document-text-outline' },
|
||||
{ key: 'commission', label: 'Commission', icon: 'star-outline' },
|
||||
{ key: 'account', label: 'New A/C', icon: 'person-add-outline' },
|
||||
];
|
||||
|
||||
return (
|
||||
<ScreenShell scrollable={false} withTabBar={false} keyboardAware={false}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Agent Workspace</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabContainer}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.key;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
style={[
|
||||
styles.tab,
|
||||
{
|
||||
backgroundColor: isActive ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
|
||||
}
|
||||
]}
|
||||
onPress={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<Ionicons
|
||||
name={tab.icon as any}
|
||||
size={16}
|
||||
color={isActive ? theme.colors.white : theme.colors.textMuted}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.tabLabel,
|
||||
{ color: isActive ? theme.colors.white : theme.colors.textSecondary }
|
||||
]}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View style={styles.contentArea}>
|
||||
{activeTab === 'loans' && (
|
||||
<Animated.View entering={FadeInLeft} style={{ flex: 1 }}>
|
||||
<LoanRequestListScreen navigation={navigation as any} />
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{activeTab === 'commission' && (
|
||||
<Animated.View entering={FadeIn} style={{ flex: 1 }}>
|
||||
<CommissionDashboardScreen />
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{activeTab === 'account' && (
|
||||
<Animated.View entering={FadeInRight} style={{ flex: 1 }}>
|
||||
<AccountOpeningScreen navigation={navigation as any} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
gap: 6,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
contentArea: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-native';
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Button } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import type { AgentTabParamList, AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { logoutSession } from '../../auth/store/authThunk';
|
||||
|
||||
type NavigationProp = CompositeNavigationProp<
|
||||
NativeStackNavigationProp<AgentProfileStackParamList, 'AgentProfileMain'>,
|
||||
BottomTabNavigationProp<AgentTabParamList>
|
||||
>;
|
||||
|
||||
export default function AgentProfileScreen({ navigation }: { navigation: NavigationProp }) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const profile = useAppSelector(state => state.user.profile);
|
||||
|
||||
const handleLogout = () => {
|
||||
Alert.alert(
|
||||
'Log Out',
|
||||
'Are you sure you want to log out of your agent account?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Log Out',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
dispatch(logoutSession());
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsRow = ({ icon, label, onPress, destructive = false }: { icon: string, label: string, onPress: () => void, destructive?: boolean }) => (
|
||||
<Pressable
|
||||
style={styles.settingsRow}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.settingsRowLeft}>
|
||||
<View style={[styles.settingsIconFrame, { backgroundColor: destructive ? `${theme.colors.danger}15` : `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name={icon as any} size={20} color={destructive ? theme.colors.danger : theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.settingsLabel, { color: destructive ? theme.colors.danger : theme.colors.textPrimary }]}>{label}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Agent Profile" scrollable={false}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
|
||||
<Animated.View entering={FadeInUp} style={[styles.profileCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{profile?.firstName?.charAt(0) || 'A'}</Text>
|
||||
</View>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName} {profile?.lastName}</Text>
|
||||
<Text style={[styles.role, { color: theme.colors.textMuted }]}>Field Agent • {profile?.email}</Text>
|
||||
<View style={[styles.badge, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: theme.colors.success }]} />
|
||||
<Text style={[styles.badgeText, { color: theme.colors.success }]}>Active Agent</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(100)}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textMuted }]}>Account Settings</Text>
|
||||
<View style={[styles.settingsGroup, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<SettingsRow
|
||||
icon="key-outline"
|
||||
label="Change Password"
|
||||
onPress={() => navigation.navigate('AgentChangePassword')}
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<SettingsRow
|
||||
icon="chatbubble-ellipses-outline"
|
||||
label="SMS Log"
|
||||
onPress={() => navigation.navigate('SmsHistory')}
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<SettingsRow
|
||||
icon="notifications-outline"
|
||||
label="Notification Settings"
|
||||
onPress={() => {}}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(200)}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textMuted, marginTop: 24 }]}>Support & Legal</Text>
|
||||
<View style={[styles.settingsGroup, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<SettingsRow
|
||||
icon="help-circle-outline"
|
||||
label="Help & Support"
|
||||
onPress={() => {}}
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<SettingsRow
|
||||
icon="document-text-outline"
|
||||
label="Terms & Conditions"
|
||||
onPress={() => {}}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<View style={{ marginTop: 32 }}>
|
||||
<Button
|
||||
title="Log Out"
|
||||
variant="secondary"
|
||||
onPress={handleLogout}
|
||||
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
|
||||
textStyle={{ color: theme.colors.danger }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footerInfo}>
|
||||
<Text style={[styles.version, { color: theme.colors.textMuted }]}>App Version 1.0.0</Text>
|
||||
<Text style={[styles.version, { color: theme.colors.textMuted }]}>Agent Module v1</Text>
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 16,
|
||||
},
|
||||
profileCard: {
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 24,
|
||||
},
|
||||
avatarBig: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
},
|
||||
name: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
role: {
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
gap: 6,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 8,
|
||||
marginLeft: 8,
|
||||
},
|
||||
settingsGroup: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
settingsRowLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
settingsIconFrame: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingsLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
marginLeft: 64, // Align with text
|
||||
},
|
||||
footerInfo: {
|
||||
alignItems: 'center',
|
||||
marginTop: 32,
|
||||
gap: 4,
|
||||
},
|
||||
version: {
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Button } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentDashboardStackParamList, 'AgentRoute'>;
|
||||
|
||||
export default function AgentRouteScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<ScreenShell title="My Route" scrollable={false}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Ionicons name="map-outline" size={64} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.mapText, { color: theme.colors.textMuted }]}>Map Integration Coming Soon</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.infoCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.infoTitle, { color: theme.colors.textPrimary }]}>Route Summary</Text>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.statRow}>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Total Stops</Text>
|
||||
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>12</Text>
|
||||
</View>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Completed</Text>
|
||||
<Text style={[styles.statValue, { color: theme.colors.success }]}>5</Text>
|
||||
</View>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Remaining</Text>
|
||||
<Text style={[styles.statValue, { color: theme.colors.warning }]}>7</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Start Navigation"
|
||||
onPress={() => {}}
|
||||
style={{ marginTop: 'auto', marginBottom: 24 }}
|
||||
/>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
mapPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.02)',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
mapText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
infoCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useGetCollectionHistoryQuery } from '../services/agentApi';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import { selectCollectionHistory } from '../store/agentSelectors';
|
||||
|
||||
import { CollectionEntryCard } from '../components/CollectionEntryCard';
|
||||
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
|
||||
import { AgentEmptyState } from '../components/AgentEmptyState';
|
||||
import { formatAgentCurrency, formatDate } from '../utils/agentFormatters';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentCollectionsStackParamList, 'CollectionHistory'>;
|
||||
|
||||
export default function CollectionHistoryScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const history = useAppSelector(selectCollectionHistory);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useGetCollectionHistoryQuery({});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
dispatch(agentActions.setCollectionHistory(data));
|
||||
}
|
||||
}, [data, dispatch]);
|
||||
|
||||
const totalCollected = history.reduce((sum, item) => sum + item.collectedAmount, 0);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Collection History" scrollable={false}>
|
||||
{/* Summary Header */}
|
||||
<View style={[styles.summaryHeader, { backgroundColor: theme.colors.surface, borderBottomColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Total Entries</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{history.length}</Text>
|
||||
</View>
|
||||
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Amount Collected</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.primary }]}>{formatAgentCurrency(totalCollected)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.listArea}>
|
||||
{isLoading && history.length === 0 ? (
|
||||
<AgentSkeletonLoader type="list" />
|
||||
) : (
|
||||
<FlatList
|
||||
data={history}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />
|
||||
}
|
||||
ListEmptyComponent={() => (
|
||||
<AgentEmptyState
|
||||
title="No history found"
|
||||
description="There are no past collection records."
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item, index }) => (
|
||||
<Animated.View entering={FadeInUp.delay(index * 50)}>
|
||||
<CollectionEntryCard
|
||||
entry={item}
|
||||
onPress={() => item.status !== 'pending' && item.receiptId ? navigation.navigate('CollectionReceipt', { entryId: item.receiptId }) : null}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
summaryHeader: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
summaryItem: {
|
||||
flex: 1,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
},
|
||||
vDivider: {
|
||||
width: 1,
|
||||
height: '100%',
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
listArea: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeIn, SlideInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Button } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { formatFullCurrency, formatDateTime } from '../utils/agentFormatters';
|
||||
|
||||
// Since this is a mock receipt screen, we will just simulate receipt data based on the entry ID
|
||||
// In a real app, we would fetch the receipt details from the API
|
||||
|
||||
type Props = NativeStackScreenProps<AgentCollectionsStackParamList, 'CollectionReceipt'>;
|
||||
|
||||
export default function CollectionReceiptScreen({ route, navigation }: Props) {
|
||||
const { entryId } = route.params;
|
||||
const theme = useTheme();
|
||||
|
||||
const receipt = {
|
||||
receiptNo: entryId,
|
||||
customerName: 'Customer',
|
||||
accountNumber: 'CBS00000000',
|
||||
amount: 500,
|
||||
date: new Date().toISOString(),
|
||||
mode: 'cash',
|
||||
agentName: 'Rajesh Kumar',
|
||||
agentCode: 'AGT-2024-0147',
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value, strong = false }: { label: string, value: string, strong?: boolean }) => (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>{label}</Text>
|
||||
<Text style={[
|
||||
styles.infoValue,
|
||||
{
|
||||
color: strong ? theme.colors.textPrimary : theme.colors.textSecondary,
|
||||
fontWeight: strong ? '700' : '500'
|
||||
}
|
||||
]}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Receipt" scrollable={false}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
|
||||
<Animated.View entering={SlideInUp.springify()} style={[styles.receiptCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
|
||||
<View style={styles.successHeader}>
|
||||
<View style={[styles.successIcon, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<Ionicons name="checkmark" size={32} color={theme.colors.success} />
|
||||
</View>
|
||||
<Text style={[styles.successTitle, { color: theme.colors.success }]}>Payment Successful</Text>
|
||||
<Text style={[styles.successAmount, { color: theme.colors.textPrimary }]}>{formatFullCurrency(receipt.amount)}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.dividerContainer}>
|
||||
<View style={[styles.circleCutout, styles.leftCutout, { backgroundColor: theme.colors.background }]} />
|
||||
<View style={[styles.dashedLine, { borderColor: theme.colors.cardBorder }]} />
|
||||
<View style={[styles.circleCutout, styles.rightCutout, { backgroundColor: theme.colors.background }]} />
|
||||
</View>
|
||||
|
||||
<View style={styles.detailsSection}>
|
||||
<InfoRow label="Receipt No" value={receipt.receiptNo} strong />
|
||||
<InfoRow label="Date & Time" value={formatDateTime(receipt.date)} />
|
||||
<InfoRow label="Customer Name" value={receipt.customerName} strong />
|
||||
<InfoRow label="Account Number" value={receipt.accountNumber} />
|
||||
<InfoRow label="Payment Mode" value={receipt.mode.toUpperCase()} />
|
||||
|
||||
<View style={[styles.solidDivider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<InfoRow label="Agent Name" value={receipt.agentName} />
|
||||
<InfoRow label="Agent Code" value={receipt.agentCode} />
|
||||
</View>
|
||||
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeIn.delay(300)} style={styles.actionsRow}>
|
||||
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Ionicons name="share-outline" size={20} color={theme.colors.primary} />
|
||||
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Share PDF</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Ionicons name="print-outline" size={20} color={theme.colors.primary} />
|
||||
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Print</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
<Button
|
||||
title="Done"
|
||||
onPress={() => navigation.navigate('DailyCollection')}
|
||||
style={{ marginTop: 24 }}
|
||||
/>
|
||||
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
},
|
||||
receiptCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
successHeader: {
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
successIcon: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
successTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
},
|
||||
successAmount: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
},
|
||||
dividerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 24,
|
||||
position: 'relative',
|
||||
},
|
||||
dashedLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
circleCutout: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
},
|
||||
leftCutout: {
|
||||
left: -12,
|
||||
},
|
||||
rightCutout: {
|
||||
right: -12,
|
||||
},
|
||||
detailsSection: {
|
||||
padding: 24,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 13,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
},
|
||||
solidDivider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginVertical: 16,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginTop: 24,
|
||||
},
|
||||
actionBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
},
|
||||
actionBtnText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { useGetCommissionSummaryQuery } from '../services/agentApi';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import { selectCommissionSummary } from '../store/agentSelectors';
|
||||
import { formatAgentCurrency, formatPercentage } from '../utils/agentFormatters';
|
||||
|
||||
import { CommissionSummaryCard } from '../components/CommissionSummaryCard';
|
||||
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
|
||||
import { AgentEmptyState } from '../components/AgentEmptyState';
|
||||
|
||||
export default function CommissionDashboardScreen() {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const summary = useAppSelector(selectCommissionSummary);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useGetCommissionSummaryQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) dispatch(agentActions.setCommissionSummary(data));
|
||||
}, [data, dispatch]);
|
||||
|
||||
if (isLoading && !summary) {
|
||||
return <AgentSkeletonLoader type="dashboard" />;
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return <AgentEmptyState title="Unavailable" description="Could not load commission data" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={summary.history}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
|
||||
ListHeaderComponent={() => (
|
||||
<View style={styles.headerArea}>
|
||||
<View style={[styles.totalCard, { backgroundColor: theme.colors.primary }]}>
|
||||
<View style={styles.totalRow}>
|
||||
<View>
|
||||
<Text style={styles.totalLabel}>Total Earned Till Date</Text>
|
||||
<Text style={styles.totalValue}>{formatAgentCurrency(summary.totalEarned)}</Text>
|
||||
</View>
|
||||
<View style={styles.iconCircle}>
|
||||
<Ionicons name="star" size={24} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.totalDivider} />
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.lastPaidLabel}>Last Paid: {summary.lastPaidDate}</Text>
|
||||
<Text style={styles.lastPaidValue}>{formatAgentCurrency(summary.lastPaidAmount)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 16, marginBottom: 24 }}>
|
||||
<CommissionSummaryCard summary={summary} />
|
||||
</View>
|
||||
|
||||
<Text style={[styles.historyTitle, { color: theme.colors.textPrimary }]}>History</Text>
|
||||
</View>
|
||||
)}
|
||||
renderItem={({ item, index }) => (
|
||||
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.historyCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.historyHeader}>
|
||||
<Text style={[styles.historyMonth, { color: theme.colors.textPrimary }]}>{item.month} {item.year}</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: item.status === 'paid' ? `${theme.colors.success}15` : `${theme.colors.warning}15` }]}>
|
||||
<Text style={[styles.statusText, { color: item.status === 'paid' ? theme.colors.success : theme.colors.warning }]}>
|
||||
{item.status.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.historyBody}>
|
||||
<View style={styles.historyCol}>
|
||||
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Collection</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.actualCollection)}</Text>
|
||||
</View>
|
||||
<View style={styles.historyCol}>
|
||||
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Commission</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.primary }]}>{formatAgentCurrency(item.actualCommission)}</Text>
|
||||
</View>
|
||||
<View style={styles.historyCol}>
|
||||
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Loss</Text>
|
||||
<Text style={[styles.value, { color: item.loss > 0 ? theme.colors.danger : theme.colors.textPrimary }]}>
|
||||
{formatAgentCurrency(item.loss)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 16,
|
||||
},
|
||||
headerArea: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
totalCard: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 5,
|
||||
},
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
totalLabel: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
totalValue: {
|
||||
color: '#fff',
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
totalDivider: {
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
marginVertical: 16,
|
||||
},
|
||||
lastPaidLabel: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 12,
|
||||
},
|
||||
lastPaidValue: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
historyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
},
|
||||
historyCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
historyHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
historyMonth: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginVertical: 12,
|
||||
},
|
||||
historyBody: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
historyCol: {
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
marginBottom: 4,
|
||||
},
|
||||
value: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,613 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Platform, KeyboardAvoidingView } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInRight, FadeOutLeft, FadeIn } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useLoanRequestForm } from '../hooks/useLoanRequestForm';
|
||||
import { LOAN_CATEGORIES } from '../constants/agentConstants';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { selectFilteredCustomers } from '../store/agentSelectors';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentLoansStackParamList, 'CreateLoanRequest'>;
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, label: 'Category', icon: 'grid-outline' },
|
||||
{ id: 2, label: 'Details', icon: 'document-text-outline' },
|
||||
{ id: 3, label: 'Review', icon: 'checkmark-circle-outline' },
|
||||
];
|
||||
|
||||
// ── Step Indicator ──────────────────────────
|
||||
const StepIndicator = ({ step }: { step: number }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<View style={styles.stepContainer}>
|
||||
{STEPS.map((s, i) => {
|
||||
const isCompleted = step > s.id;
|
||||
const isActive = step === s.id;
|
||||
const isPending = step < s.id;
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
<View style={styles.stepItem}>
|
||||
<View style={[
|
||||
styles.stepDot,
|
||||
{
|
||||
backgroundColor: isCompleted ? theme.colors.success : isActive ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: isCompleted ? theme.colors.success : isActive ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}>
|
||||
{isCompleted ? (
|
||||
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
|
||||
) : (
|
||||
<Text style={{
|
||||
color: isActive ? theme.colors.white : theme.colors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
}}>{s.id}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.stepLabel,
|
||||
{
|
||||
color: isActive ? theme.colors.primary : isCompleted ? theme.colors.success : theme.colors.textMuted,
|
||||
fontWeight: isActive ? '700' : '500',
|
||||
},
|
||||
]}>{s.label}</Text>
|
||||
</View>
|
||||
{i < STEPS.length - 1 && (
|
||||
<View style={[
|
||||
styles.stepLine,
|
||||
{ backgroundColor: isCompleted ? theme.colors.success : theme.colors.cardBorder },
|
||||
]} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Section Card Wrapper ────────────────────
|
||||
const SectionCard = ({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<View style={[styles.sectionCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{subtitle && <Text style={[styles.sectionSubtitle, { color: theme.colors.textMuted }]}>{subtitle}</Text>}
|
||||
<View style={styles.sectionBody}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Review Row ──────────────────────────────
|
||||
const ReviewRow = ({ label, value, accent }: { label: string; value: string; accent?: boolean }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<View style={styles.reviewRow}>
|
||||
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>{label}</Text>
|
||||
<Text style={[styles.reviewValue, { color: accent ? theme.colors.primary : theme.colors.textPrimary }]}>{value || '—'}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CreateLoanRequestScreen({ route, navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { category, customerId } = route.params;
|
||||
const customers = useAppSelector(selectFilteredCustomers);
|
||||
|
||||
const { data, errors, updateField, submit, isSubmitting, validate } = useLoanRequestForm(category, customerId);
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
const selectedCategory = LOAN_CATEGORIES.find(c => c.id === data.category);
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1 && !data.category) return;
|
||||
if (step === 1 && !data.customerName) return;
|
||||
if (step === 2) {
|
||||
if (!validate()) return;
|
||||
}
|
||||
setStep(s => Math.min(3, s + 1));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 1) navigation.goBack();
|
||||
else setStep(s => Math.max(1, s - 1));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const res = await submit();
|
||||
if (res) {
|
||||
navigation.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="New Loan Request"
|
||||
scrollable={false}
|
||||
withTabBar={false}
|
||||
fixedFooter={
|
||||
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
|
||||
{/* Progress indicator */}
|
||||
<View style={styles.progressRow}>
|
||||
<Text style={[styles.progressText, { color: theme.colors.textMuted }]}>
|
||||
Step {step} of {STEPS.length}
|
||||
</Text>
|
||||
<View style={[styles.progressTrack, { backgroundColor: theme.colors.cardBorder }]}>
|
||||
<View style={[styles.progressFill, { width: `${(step / STEPS.length) * 100}%`, backgroundColor: theme.colors.primary }]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<View style={styles.col}>
|
||||
<Button
|
||||
title={step === 1 ? 'Cancel' : 'Back'}
|
||||
variant="secondary"
|
||||
onPress={handleBack}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width: 12 }} />
|
||||
<View style={styles.col}>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
title="Continue"
|
||||
onPress={handleNext}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
title="Submit Request"
|
||||
onPress={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View style={styles.flexContainer}>
|
||||
<StepIndicator step={step} />
|
||||
|
||||
<KeyboardAwareScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
style={styles.flex}
|
||||
>
|
||||
{/* ═══════════ STEP 1: Category & Customer ═══════════ */}
|
||||
{step === 1 && (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
|
||||
<SectionCard title="Loan Type" subtitle="Choose the category that best fits">
|
||||
<View style={styles.categoryList}>
|
||||
{LOAN_CATEGORIES.map(cat => {
|
||||
const isSelected = data.category === cat.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
style={[
|
||||
styles.catCard,
|
||||
{
|
||||
backgroundColor: isSelected ? `${cat.color}12` : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? cat.color : 'transparent',
|
||||
borderWidth: isSelected ? 1.5 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={() => updateField('category', cat.id as any)}
|
||||
>
|
||||
<View style={[styles.catIcon, { backgroundColor: `${cat.color}20` }]}>
|
||||
<Ionicons name={cat.icon as any} size={22} color={cat.color} />
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.catLabel,
|
||||
{ color: isSelected ? cat.color : theme.colors.textPrimary },
|
||||
]}>{cat.label}</Text>
|
||||
{isSelected && (
|
||||
<View style={[styles.catCheck, { backgroundColor: cat.color }]}>
|
||||
<Ionicons name="checkmark" size={12} color={theme.colors.white} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{errors.category && (
|
||||
<Text style={[styles.errorText, { color: theme.colors.danger }]}>{errors.category}</Text>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Customer Details" subtitle="Who is applying for the loan?">
|
||||
<Input
|
||||
label="Full Name"
|
||||
placeholder="e.g. Amit Sharma"
|
||||
value={data.customerName}
|
||||
onChangeText={v => updateField('customerName', v)}
|
||||
error={errors.customerName}
|
||||
/>
|
||||
<Input
|
||||
label="Phone Number"
|
||||
placeholder="10-digit mobile number"
|
||||
value={data.customerPhone}
|
||||
onChangeText={v => updateField('customerPhone', v)}
|
||||
keyboardType="number-pad"
|
||||
maxLength={10}
|
||||
leftAccessory={<Text style={{ color: theme.colors.textMuted, fontWeight: '600', fontSize: 13 }}>+91</Text>}
|
||||
/>
|
||||
</SectionCard>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* ═══════════ STEP 2: Loan Details ═══════════ */}
|
||||
{step === 2 && (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
|
||||
{selectedCategory && (
|
||||
<Animated.View entering={FadeIn} style={[styles.selectedBanner, { backgroundColor: `${selectedCategory.color}12`, borderColor: `${selectedCategory.color}30` }]}>
|
||||
<View style={[styles.bannerIcon, { backgroundColor: `${selectedCategory.color}20` }]}>
|
||||
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedCategory.color} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.bannerTitle, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
|
||||
<Text style={[styles.bannerSub, { color: theme.colors.textMuted }]}>for {data.customerName}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<SectionCard title="Loan Amount & Tenure">
|
||||
<Input
|
||||
label="Loan Amount"
|
||||
placeholder="Min ₹5,000"
|
||||
value={data.amount}
|
||||
onChangeText={v => updateField('amount', v)}
|
||||
keyboardType="number-pad"
|
||||
error={errors.amount}
|
||||
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '800', color: theme.colors.primary }}>₹</Text>}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.col}>
|
||||
<Input
|
||||
label="Tenure"
|
||||
placeholder="e.g. 12"
|
||||
value={data.tenureMonths}
|
||||
onChangeText={v => updateField('tenureMonths', v)}
|
||||
keyboardType="number-pad"
|
||||
error={errors.tenureMonths}
|
||||
rightAccessory={<Text style={{ fontSize: 12, color: theme.colors.textMuted, fontWeight: '600' }}>months</Text>}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width: 12 }} />
|
||||
<View style={styles.col}>
|
||||
<Input
|
||||
label="Monthly Income"
|
||||
placeholder="Approx"
|
||||
value={data.monthlyIncome}
|
||||
onChangeText={v => updateField('monthlyIncome', v)}
|
||||
keyboardType="number-pad"
|
||||
error={errors.monthlyIncome}
|
||||
leftAccessory={<Text style={{ fontSize: 14, fontWeight: '700', color: theme.colors.textMuted }}>₹</Text>}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Purpose of Loan">
|
||||
<Input
|
||||
placeholder="e.g. Home renovation, medical expenses..."
|
||||
value={data.purpose}
|
||||
onChangeText={v => updateField('purpose', v)}
|
||||
error={errors.purpose}
|
||||
multiline
|
||||
/>
|
||||
</SectionCard>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* ═══════════ STEP 3: Review & Submit ═══════════ */}
|
||||
{step === 3 && (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
|
||||
{/* Summary Card */}
|
||||
<View style={[styles.reviewCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
{/* Header badge */}
|
||||
{selectedCategory && (
|
||||
<View style={[styles.reviewBadge, { backgroundColor: `${selectedCategory.color}12` }]}>
|
||||
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedCategory.color} />
|
||||
<Text style={[styles.reviewBadgeText, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<ReviewRow label="Customer" value={data.customerName} />
|
||||
<ReviewRow label="Phone" value={data.customerPhone ? `+91 ${data.customerPhone}` : ''} />
|
||||
|
||||
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<ReviewRow label="Amount" value={data.amount ? `₹ ${Number(data.amount).toLocaleString('en-IN')}` : ''} accent />
|
||||
<ReviewRow label="Tenure" value={data.tenureMonths ? `${data.tenureMonths} months` : ''} />
|
||||
<ReviewRow label="Monthly Income" value={data.monthlyIncome ? `₹ ${Number(data.monthlyIncome).toLocaleString('en-IN')}` : ''} />
|
||||
|
||||
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<ReviewRow label="Purpose" value={data.purpose} />
|
||||
</View>
|
||||
|
||||
{/* Documents info */}
|
||||
<View style={[styles.docsCard, { backgroundColor: `${theme.colors.info}08`, borderColor: `${theme.colors.info}25` }]}>
|
||||
<Ionicons name="document-attach-outline" size={20} color={theme.colors.info} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.docsTitle, { color: theme.colors.info }]}>Documents can be added later</Text>
|
||||
<Text style={[styles.docsText, { color: theme.colors.textMuted }]}>
|
||||
You can upload Aadhaar, PAN, income proof & photo from the request details page after submission.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Consent */}
|
||||
<View style={[styles.consentBox, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="shield-checkmark-outline" size={18} color={theme.colors.success} />
|
||||
<Text style={[styles.consentText, { color: theme.colors.textMuted }]}>
|
||||
By submitting, you confirm the above details are accurate and the customer has given consent for this application.
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</KeyboardAwareScrollView>
|
||||
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
flexContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// ── Step Indicator ────────────────────────
|
||||
stepContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
stepItem: {
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
stepDot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1.5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
stepLine: {
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginHorizontal: 8,
|
||||
marginBottom: 18,
|
||||
borderRadius: 1,
|
||||
},
|
||||
|
||||
// ── Scroll Content ────────────────────────
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 24,
|
||||
gap: 14,
|
||||
},
|
||||
|
||||
// ── Section Cards ─────────────────────────
|
||||
sectionCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
sectionBody: {
|
||||
marginTop: 12,
|
||||
gap: 14,
|
||||
},
|
||||
|
||||
// ── Category Cards ────────────────────────
|
||||
categoryList: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
catCard: {
|
||||
width: '31%',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 8,
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
position: 'relative',
|
||||
},
|
||||
catIcon: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 21,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
catLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
lineHeight: 14,
|
||||
},
|
||||
catCheck: {
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 6,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// ── Selected Banner ───────────────────────
|
||||
selectedBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
},
|
||||
bannerIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
bannerTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
bannerSub: {
|
||||
fontSize: 12,
|
||||
marginTop: 1,
|
||||
},
|
||||
|
||||
// ── Form Layout ───────────────────────────
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
col: {
|
||||
flex: 1,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// ── Review Card ───────────────────────────
|
||||
reviewCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
},
|
||||
reviewBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
marginBottom: 12,
|
||||
},
|
||||
reviewBadgeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
reviewDivider: {
|
||||
height: 1,
|
||||
marginVertical: 10,
|
||||
},
|
||||
reviewRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
paddingVertical: 6,
|
||||
},
|
||||
reviewLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
minWidth: 80,
|
||||
},
|
||||
reviewValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
flexShrink: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
|
||||
// ── Docs Info ─────────────────────────────
|
||||
docsCard: {
|
||||
flexDirection: 'row',
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
docsTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
marginBottom: 3,
|
||||
},
|
||||
docsText: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
|
||||
// ── Consent ───────────────────────────────
|
||||
consentBox: {
|
||||
flexDirection: 'row',
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
gap: 10,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
consentText: {
|
||||
flex: 1,
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
},
|
||||
|
||||
// ── Footer ────────────────────────────────
|
||||
footer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 10,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
progressRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
gap: 10,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
minWidth: 60,
|
||||
},
|
||||
progressTrack: {
|
||||
flex: 1,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Button } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { selectCustomerById } from '../store/agentSelectors';
|
||||
import { useDeleteAgentCustomerMutation } from '../services/agentApi';
|
||||
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { formatAgentCurrency, formatDate, maskAadhaar, maskPhone } from '../utils/agentFormatters';
|
||||
|
||||
import { AgentSectionHeader } from '../components/AgentSectionHeader';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'CustomerDetail'>;
|
||||
|
||||
export default function CustomerDetailScreen({ route, navigation }: Props) {
|
||||
const { customerId } = route.params;
|
||||
const theme = useTheme();
|
||||
|
||||
const customer = useAppSelector(selectCustomerById(customerId));
|
||||
const [deleteCustomer, { isLoading: isDeleting }] = useDeleteAgentCustomerMutation();
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<ScreenShell title="Customer Detail">
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={{ color: theme.colors.textMuted }}>Customer not found</Text>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Delete Customer',
|
||||
'Are you sure you want to remove this customer? This action cannot be undone.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteCustomer(customer.id).unwrap();
|
||||
navigation.goBack();
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to delete customer');
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value, icon, masked = false }: { label: string, value: string, icon?: string, masked?: boolean }) => (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabelArea}>
|
||||
{icon && <Ionicons name={icon as any} size={16} color={theme.colors.textMuted} style={{ marginRight: 6 }} />}
|
||||
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>{label}</Text>
|
||||
</View>
|
||||
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
|
||||
{masked ? '•••• •••• ' + value.slice(-4) : value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Customer Profile" scrollable={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Animated.View entering={FadeInUp.delay(100)} style={styles.headerCard}>
|
||||
<View style={styles.profileTopRow}>
|
||||
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Text style={[styles.avatarBigText, { color: theme.colors.primary }]}>
|
||||
{customer.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerInfo}>
|
||||
<Text style={[styles.headerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
|
||||
<Text style={[styles.headerPhone, { color: theme.colors.textSecondary }]}>{maskPhone(customer.phone)}</Text>
|
||||
<View style={[styles.statusChip, { backgroundColor: customer.status === 'active' ? `${theme.colors.success}15` : `${theme.colors.danger}15` }]}>
|
||||
<Text style={[styles.statusText, { color: customer.status === 'active' ? theme.colors.success : theme.colors.danger }]}>
|
||||
{customer.status.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButtonsRow}>
|
||||
<Pressable style={styles.iconBtn} onPress={() => {/* Implement call */}}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.info}15` }]}>
|
||||
<Ionicons name="call" size={20} color={theme.colors.info} />
|
||||
</View>
|
||||
<Text style={[styles.iconLabel, { color: theme.colors.info }]}>Call</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.iconBtn} onPress={() => {/* Implement SMS */}}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<Ionicons name="chatbubble" size={20} color={theme.colors.success} />
|
||||
</View>
|
||||
<Text style={[styles.iconLabel, { color: theme.colors.success }]}>SMS</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.iconBtn} onPress={() => navigation.navigate('AddEditCustomer', { customerId: customer.id })}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name="create" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Account Details */}
|
||||
<Animated.View entering={FadeInUp.delay(200)} style={styles.section}>
|
||||
<AgentSectionHeader title="Account Details" />
|
||||
{customer.accounts.map(acc => (
|
||||
<View key={acc.id} style={[styles.dataCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.accHeaderRow}>
|
||||
<Text style={[styles.accType, { color: theme.colors.textPrimary }]}>{acc.type} A/C</Text>
|
||||
<Text style={[styles.accNumber, { color: theme.colors.textMuted }]}>{acc.accountNumber}</Text>
|
||||
</View>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.financialRow}>
|
||||
<View style={styles.finItem}>
|
||||
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Monthly EMI</Text>
|
||||
<Text style={[styles.finValue, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(acc.emiAmount)}</Text>
|
||||
</View>
|
||||
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<View style={styles.finItem}>
|
||||
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Total Balance</Text>
|
||||
<Text style={[styles.finValue, { color: theme.colors.primary }]}>{formatAgentCurrency(acc.balance)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.financialRow}>
|
||||
<View style={styles.finItem}>
|
||||
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Total Due</Text>
|
||||
<Text style={[styles.finValue, { color: acc.totalDue > 0 ? theme.colors.danger : theme.colors.success }]}>
|
||||
{formatAgentCurrency(acc.totalDue)}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
title="View History"
|
||||
variant="ghost"
|
||||
fullWidth={false}
|
||||
onPress={() => navigation.navigate('CustomerInstallmentHistory', { customerId: customer.id })}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
{/* KYC & Personal Details */}
|
||||
<Animated.View entering={FadeInUp.delay(300)} style={styles.section}>
|
||||
<AgentSectionHeader title="KYC & Information" />
|
||||
<View style={[styles.dataCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<InfoRow label="Aadhaar" value={customer.kyc.aadhaarNumber} icon="finger-print-outline" masked />
|
||||
<InfoRow label="PAN Number" value={customer.kyc.panNumber} icon="card-outline" masked />
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<InfoRow label="Joined Date" value={formatDate(customer.joinedDate)} icon="calendar-outline" />
|
||||
<InfoRow label="Address" value={`${customer.address}, ${customer.city}, ${customer.state} - ${customer.pincode}`} icon="location-outline" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Delete Area */}
|
||||
<View style={styles.deleteArea}>
|
||||
<Button
|
||||
title="Remove Customer"
|
||||
variant="ghost"
|
||||
loading={isDeleting}
|
||||
onPress={handleDelete}
|
||||
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
|
||||
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footerSpacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
},
|
||||
headerCard: {
|
||||
marginBottom: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
profileTopRow: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
avatarBig: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatarBigText: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
},
|
||||
headerInfo: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerName: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerPhone: {
|
||||
fontSize: 15,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statusChip: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
actionButtonsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 32,
|
||||
},
|
||||
iconBtn: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconFrame: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
iconLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
dataCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
},
|
||||
accHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
accType: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
accNumber: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginVertical: 12,
|
||||
},
|
||||
financialRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
finItem: {
|
||||
flex: 1,
|
||||
},
|
||||
finLabel: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
finValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
vDivider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
infoLabelArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '40%',
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 13,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
deleteArea: {
|
||||
marginTop: 16,
|
||||
},
|
||||
footerSpacer: {
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, FlatList } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { selectCustomerById } from '../store/agentSelectors';
|
||||
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { formatAgentCurrency, formatDateTime } from '../utils/agentFormatters';
|
||||
|
||||
import { AgentEmptyState } from '../components/AgentEmptyState';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'CustomerInstallmentHistory'>;
|
||||
|
||||
export default function CustomerInstallmentHistoryScreen({ route, navigation }: Props) {
|
||||
const { customerId } = route.params;
|
||||
const theme = useTheme();
|
||||
|
||||
const customer = useAppSelector(selectCustomerById(customerId));
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<ScreenShell title="Installment History">
|
||||
<AgentEmptyState title="Error" description="Customer not found" />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate mock history based on totalCollected for demo purposes
|
||||
const history = Array.from({ length: 10 }).map((_, i) => {
|
||||
const isPaid = i < (customer.totalCollected / 500);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (i * 30));
|
||||
return {
|
||||
id: `hist-${i}`,
|
||||
amount: 500,
|
||||
date: date.toISOString(),
|
||||
status: isPaid ? 'paid' : 'pending',
|
||||
receiptNo: isPaid ? `RCP-${date.getFullYear()}${String(date.getMonth()+1).padStart(2,'0')}${String(date.getDate()).padStart(2,'0')}-${1000+i}` : null,
|
||||
};
|
||||
});
|
||||
|
||||
const renderItem = ({ item, index }: { item: any, index: number }) => (
|
||||
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.leftSide}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: item.status === 'paid' ? `${theme.colors.success}15` : `${theme.colors.warning}15` }]}>
|
||||
<Ionicons
|
||||
name={item.status === 'paid' ? 'checkmark-circle' : 'time'}
|
||||
size={20}
|
||||
color={item.status === 'paid' ? theme.colors.success : theme.colors.warning}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[styles.date, { color: theme.colors.textPrimary }]}>{formatDateTime(item.date)}</Text>
|
||||
{item.receiptNo ? (
|
||||
<Text style={[styles.receipt, { color: theme.colors.textMuted }]}>{item.receiptNo}</Text>
|
||||
) : (
|
||||
<Text style={[styles.receipt, { color: theme.colors.textMuted }]}>Pending Installment</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.rightSide}>
|
||||
<Text style={[styles.amount, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.amount)}</Text>
|
||||
<Text style={[styles.status, { color: item.status === 'paid' ? theme.colors.success : theme.colors.warning }]}>
|
||||
{item.status.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Installment History" scrollable={false}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
|
||||
<Text style={[styles.accNumber, { color: theme.colors.textMuted }]}>
|
||||
{customer.accounts[0]?.type} A/C: {customer.accounts[0]?.accountNumber}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={history}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
padding: 16,
|
||||
paddingTop: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
customerName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
accNumber: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
},
|
||||
leftSide: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconFrame: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
date: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
receipt: {
|
||||
fontSize: 11,
|
||||
},
|
||||
rightSide: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
amount: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
status: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, StyleSheet, FlatList, RefreshControl, TextInput, Pressable, Text } from 'react-native';
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import type { AgentTabParamList, AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useGetAgentCustomersQuery } from '../services/agentApi';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import { selectFilteredCustomers, selectCustomerFilter, selectCustomerSearch } from '../store/agentSelectors';
|
||||
import { CUSTOMER_FILTERS } from '../constants/agentConstants';
|
||||
import type { CustomerFilter } from '../types/agentTypes';
|
||||
|
||||
import { CustomerCard } from '../components/CustomerCard';
|
||||
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
|
||||
import { AgentEmptyState } from '../components/AgentEmptyState';
|
||||
|
||||
type NavigationProp = CompositeNavigationProp<
|
||||
NativeStackNavigationProp<AgentCustomersStackParamList, 'CustomerList'>,
|
||||
BottomTabNavigationProp<AgentTabParamList>
|
||||
>;
|
||||
|
||||
export default function CustomerListScreen({ navigation }: { navigation: NavigationProp }) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const customers = useAppSelector(selectFilteredCustomers);
|
||||
const activeFilter = useAppSelector(selectCustomerFilter);
|
||||
const searchQuery = useAppSelector(selectCustomerSearch);
|
||||
|
||||
const { data, isLoading, isFetching, refetch, error } = useGetAgentCustomersQuery(undefined, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
dispatch(agentActions.setCustomers(data));
|
||||
}
|
||||
}, [data, dispatch]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleSearch = (text: string) => {
|
||||
dispatch(agentActions.setCustomerSearch(text));
|
||||
};
|
||||
|
||||
const handleFilter = (filter: CustomerFilter) => {
|
||||
dispatch(agentActions.setCustomerFilter(filter));
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Customers" scrollable={false}>
|
||||
{/* Search Bar */}
|
||||
<View style={styles.headerArea}>
|
||||
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />
|
||||
<TextInput
|
||||
style={[styles.searchInput, { color: theme.colors.textPrimary }]}
|
||||
placeholder="Search by name or phone..."
|
||||
placeholderTextColor={theme.colors.textMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => handleSearch('')} style={styles.clearBtn}>
|
||||
<Ionicons name="close-circle" size={16} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Filters */}
|
||||
<View style={styles.filterArea}>
|
||||
<FlatList
|
||||
data={CUSTOMER_FILTERS}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.filterList}
|
||||
renderItem={({ item }) => {
|
||||
const isActive = activeFilter === item.id;
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.filterChip,
|
||||
{
|
||||
backgroundColor: isActive ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleFilter(item.id)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.filterText,
|
||||
{ color: isActive ? theme.colors.white : theme.colors.textSecondary }
|
||||
]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* List Area */}
|
||||
<View style={styles.listArea}>
|
||||
{isLoading && customers.length === 0 ? (
|
||||
<AgentSkeletonLoader type="list" />
|
||||
) : error && customers.length === 0 ? (
|
||||
<AgentEmptyState
|
||||
title="Unable to load customers"
|
||||
description="We encountered an error while fetching your customer list."
|
||||
actionTitle="Retry"
|
||||
onAction={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<FlatList
|
||||
data={customers}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} colors={[theme.colors.primary]} />
|
||||
}
|
||||
ListEmptyComponent={() => (
|
||||
<AgentEmptyState
|
||||
title="No customers found"
|
||||
description={searchQuery ? `No matches for "${searchQuery}"` : "You don't have any customers in this category yet."}
|
||||
icon="people-outline"
|
||||
actionTitle={searchQuery ? "Clear Search" : undefined}
|
||||
onAction={searchQuery ? () => handleSearch('') : undefined}
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item, index }) => (
|
||||
<Animated.View entering={FadeIn.delay(Math.min(index * 50, 500))} exiting={FadeOut}>
|
||||
<CustomerCard
|
||||
customer={item}
|
||||
onPress={() => navigation.navigate('CustomerDetail', { customerId: item.id })}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* FAB to add new customer */}
|
||||
<Pressable
|
||||
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('AddEditCustomer', {})}
|
||||
>
|
||||
<Ionicons name="add" size={24} color={theme.colors.white} />
|
||||
</Pressable>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerArea: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
zIndex: 10,
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
marginLeft: 8,
|
||||
fontSize: 15,
|
||||
},
|
||||
clearBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
filterArea: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
filterList: {
|
||||
gap: 8,
|
||||
},
|
||||
filterChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
},
|
||||
filterText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
listArea: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 16,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
bottom: TAB_BAR_HEIGHT + 20, // above tab bar
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, Pressable } from 'react-native';
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
|
||||
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
|
||||
|
||||
import { ScreenShell, Button, Input } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import type { AgentTabParamList, AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useGetTodayCollectionsQuery } from '../services/agentApi';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import { selectTodayCollections, selectTodayCollectionSummary, selectOfflineQueue } from '../store/agentSelectors';
|
||||
import { formatAgentCurrency } from '../utils/agentFormatters';
|
||||
import { useCollectionForm } from '../hooks/useCollectionForm';
|
||||
import { COLLECTION_MODES } from '../constants/agentConstants';
|
||||
import type { CollectionEntry } from '../types/agentTypes';
|
||||
|
||||
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
|
||||
import { AgentEmptyState } from '../components/AgentEmptyState';
|
||||
|
||||
type NavigationProp = CompositeNavigationProp<
|
||||
NativeStackNavigationProp<AgentCollectionsStackParamList, 'DailyCollection'>,
|
||||
BottomTabNavigationProp<AgentTabParamList>
|
||||
>;
|
||||
|
||||
const CollectionForm = ({ selectedEntry, navigation, bottomSheetRef }: { selectedEntry: CollectionEntry | null, navigation: any, bottomSheetRef: any }) => {
|
||||
const theme = useTheme();
|
||||
if (!selectedEntry) return null;
|
||||
|
||||
const { amount, setAmount, mode, setMode, error: formError, submit, isLoading: isSubmitting } = useCollectionForm(selectedEntry.id, selectedEntry.emiAmount);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const receipt = await submit();
|
||||
if (receipt) {
|
||||
bottomSheetRef.current?.close();
|
||||
navigation.navigate('CollectionReceipt', { entryId: receipt.id });
|
||||
// In real app, we'd pass the full receipt object or fetch it there.
|
||||
// For mock, we just pass ID.
|
||||
} else {
|
||||
// Fallback or error handled in hook
|
||||
bottomSheetRef.current?.close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.sheetContent}>
|
||||
<Text style={[styles.sheetTitle, { color: theme.colors.textPrimary }]}>Collect Payment</Text>
|
||||
|
||||
<View style={styles.sheetCustomerInfo}>
|
||||
<Text style={[styles.sheetCustomerName, { color: theme.colors.textPrimary }]}>{selectedEntry.customerName}</Text>
|
||||
<Text style={[styles.sheetCustomerMeta, { color: theme.colors.textMuted }]}>
|
||||
{selectedEntry.accountType} • EMI: {formatAgentCurrency(selectedEntry.emiAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Collection Amount"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
keyboardType="number-pad"
|
||||
error={formError || undefined}
|
||||
leftAccessory={<Text style={{ fontSize: 18, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
|
||||
/>
|
||||
|
||||
<Text style={[styles.modeLabel, { color: theme.colors.textMuted }]}>Payment Mode</Text>
|
||||
<View style={styles.modeRow}>
|
||||
{COLLECTION_MODES.map(m => (
|
||||
<Pressable
|
||||
key={m.id}
|
||||
style={[
|
||||
styles.modeChip,
|
||||
{
|
||||
backgroundColor: mode === m.id ? `${theme.colors.primary}15` : theme.colors.surface,
|
||||
borderColor: mode === m.id ? theme.colors.primary : theme.colors.cardBorder,
|
||||
}
|
||||
]}
|
||||
onPress={() => setMode(m.id)}
|
||||
>
|
||||
<Ionicons name={m.icon as any} size={16} color={mode === m.id ? theme.colors.primary : theme.colors.textMuted} />
|
||||
<Text style={[styles.modeText, { color: mode === m.id ? theme.colors.primary : theme.colors.textMuted }]}>{m.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Confirm Collection"
|
||||
onPress={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
style={{ marginTop: 24 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DailyCollectionScreen({ navigation }: { navigation: NavigationProp }) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const entries = useAppSelector(selectTodayCollections);
|
||||
const summary = useAppSelector(selectTodayCollectionSummary);
|
||||
const offlineQueue = useAppSelector(selectOfflineQueue);
|
||||
|
||||
const { data, isLoading, isFetching, refetch, error } = useGetTodayCollectionsQuery(undefined, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedEntry, setSelectedEntry] = useState<CollectionEntry | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'collected'>('all');
|
||||
|
||||
const filteredEntries = entries.filter(entry => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'collected') return entry.status === 'collected' || entry.status === 'partial';
|
||||
if (filter === 'pending') return entry.status === 'pending';
|
||||
return true;
|
||||
});
|
||||
|
||||
const bottomSheetRef = React.useRef<BottomSheet>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
dispatch(agentActions.setTodayCollections(data));
|
||||
}
|
||||
}, [data, dispatch]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleEntryPress = (entry: CollectionEntry) => {
|
||||
if (entry.status !== 'pending') return; // Already collected
|
||||
setSelectedEntry(entry);
|
||||
bottomSheetRef.current?.expand();
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<ScreenShell title="Daily Collection" scrollable={false}>
|
||||
<View style={styles.header}>
|
||||
{/* Summary Dashboard */}
|
||||
<Animated.View entering={FadeIn} style={[styles.summaryCard, { backgroundColor: theme.colors.primary }]}>
|
||||
<View style={styles.summaryRow}>
|
||||
<View>
|
||||
<Text style={styles.summaryLabel}>Total Collected Today</Text>
|
||||
<Text style={styles.summaryValue}>{formatAgentCurrency(summary.totalCollected)}</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Text style={styles.summaryLabel}>Completion</Text>
|
||||
<Text style={styles.summaryValue}>{summary.collectedCount}/{summary.total}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressBar, { width: `${summary.total > 0 ? (summary.collectedCount / summary.total) * 100 : 0}%` }]} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{offlineQueue.length > 0 && (
|
||||
<View style={[styles.offlineBanner, { backgroundColor: theme.colors.warning }]}>
|
||||
<Ionicons name="cloud-offline" size={16} color="#fff" />
|
||||
<Text style={styles.offlineText}>{offlineQueue.length} entries waiting to sync</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.listArea}>
|
||||
<View style={styles.listHeader}>
|
||||
<Text style={[styles.listTitle, { color: theme.colors.textPrimary }]}>Today's Schedule</Text>
|
||||
<Pressable onPress={() => navigation.navigate('CollectionHistory')}>
|
||||
<Text style={[styles.historyLink, { color: theme.colors.primary }]}>History</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.filterRow}>
|
||||
{(['all', 'pending', 'collected'] as const).map(f => (
|
||||
<Pressable
|
||||
key={f}
|
||||
style={[
|
||||
styles.filterChip,
|
||||
{
|
||||
backgroundColor: filter === f ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: filter === f ? theme.colors.primary : theme.colors.cardBorder,
|
||||
}
|
||||
]}
|
||||
onPress={() => setFilter(f)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.filterText,
|
||||
{ color: filter === f ? '#fff' : theme.colors.textSecondary }
|
||||
]}>
|
||||
{f === 'all' ? 'All' : f === 'pending' ? 'Pending' : 'Collected'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{isLoading && entries.length === 0 ? (
|
||||
<AgentSkeletonLoader type="list" />
|
||||
) : error && entries.length === 0 ? (
|
||||
<AgentEmptyState
|
||||
title="Unable to load schedule"
|
||||
description="We encountered an error while fetching your daily collections."
|
||||
actionTitle="Retry"
|
||||
onAction={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredEntries}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} colors={[theme.colors.primary]} />
|
||||
}
|
||||
ListEmptyComponent={() => (
|
||||
<AgentEmptyState
|
||||
title="No collections today"
|
||||
description="Your schedule for today is empty."
|
||||
icon="calendar-outline"
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item, index }) => {
|
||||
const isCollected = item.status === 'collected';
|
||||
const isPartial = item.status === 'partial';
|
||||
|
||||
return (
|
||||
<Animated.View entering={SlideInDown.delay(index * 50)}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.entryCard,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
opacity: isCollected ? 0.7 : 1,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleEntryPress(item)}
|
||||
>
|
||||
<View style={styles.entryLeft}>
|
||||
<View style={[styles.avatarBox, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{item.customerName.charAt(0)}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[styles.entryName, { color: theme.colors.textPrimary }]}>{item.customerName}</Text>
|
||||
<Text style={[styles.entryMeta, { color: theme.colors.textMuted }]}>{item.accountType} • EMI: {formatAgentCurrency(item.emiAmount)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.entryRight}>
|
||||
{isCollected || isPartial ? (
|
||||
<>
|
||||
<Text style={[styles.entryAmount, { color: theme.colors.success }]}>{formatAgentCurrency(item.collectedAmount)}</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<Text style={[styles.statusText, { color: theme.colors.success }]}>Collected</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={[styles.collectBtn, { backgroundColor: theme.colors.primary }]}>
|
||||
<Text style={styles.collectBtnText}>Collect</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
index={-1}
|
||||
snapPoints={['50%', '65%']}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={{ backgroundColor: theme.colors.surface }}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.colors.cardBorder }}
|
||||
>
|
||||
<BottomSheetView style={styles.sheetContainer}>
|
||||
<CollectionForm selectedEntry={selectedEntry} navigation={navigation} bottomSheetRef={bottomSheetRef} />
|
||||
</BottomSheetView>
|
||||
</BottomSheet>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
zIndex: 10,
|
||||
},
|
||||
summaryCard: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 5,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
},
|
||||
summaryLabel: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
summaryValue: {
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
progressTrack: {
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 3,
|
||||
},
|
||||
offlineBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
marginTop: 12,
|
||||
gap: 8,
|
||||
},
|
||||
offlineText: {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
listArea: {
|
||||
flex: 1,
|
||||
marginTop: 16,
|
||||
},
|
||||
listHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
listTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
historyLink: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
gap: 8,
|
||||
},
|
||||
filterChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
},
|
||||
filterText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingTop: 0,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 16,
|
||||
},
|
||||
entryCard: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
},
|
||||
entryLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
avatarBox: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
entryName: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
entryMeta: {
|
||||
fontSize: 12,
|
||||
},
|
||||
entryRight: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 4,
|
||||
},
|
||||
collectBtn: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
collectBtnText: {
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
entryAmount: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
sheetContainer: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
},
|
||||
sheetContent: {
|
||||
flex: 1,
|
||||
},
|
||||
sheetTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
marginBottom: 24,
|
||||
},
|
||||
sheetCustomerInfo: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sheetCustomerName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
sheetCustomerMeta: {
|
||||
fontSize: 14,
|
||||
},
|
||||
modeLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
modeRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
modeChip: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
gap: 6,
|
||||
},
|
||||
modeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, Pressable, RefreshControl } from 'react-native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { SlideInRight } from 'react-native-reanimated';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useGetAgentLoanRequestsQuery } from '../services/agentApi';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import { selectLoanRequests } from '../store/agentSelectors';
|
||||
import { formatAgentCurrency, formatDate } from '../utils/agentFormatters';
|
||||
|
||||
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
|
||||
import { AgentEmptyState } from '../components/AgentEmptyState';
|
||||
|
||||
export default function LoanRequestListScreen({ navigation }: { navigation: NativeStackNavigationProp<AgentLoansStackParamList> }) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const requests = useAppSelector(selectLoanRequests);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useGetAgentLoanRequestsQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) dispatch(agentActions.setLoanRequests(data));
|
||||
}, [data, dispatch]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Recent Requests</Text>
|
||||
<Pressable
|
||||
style={[styles.newBtn, { backgroundColor: `${theme.colors.primary}15` }]}
|
||||
onPress={() => navigation.navigate('CreateLoanRequest', {})}
|
||||
>
|
||||
<Ionicons name="add" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.newBtnText, { color: theme.colors.primary }]}>New Request</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isLoading && requests.length === 0 ? (
|
||||
<AgentSkeletonLoader type="list" />
|
||||
) : (
|
||||
<FlatList
|
||||
data={requests}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
|
||||
ListEmptyComponent={() => (
|
||||
<AgentEmptyState
|
||||
title="No Loan Requests"
|
||||
description="You haven't submitted any loan requests yet."
|
||||
icon="document-text-outline"
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item, index }) => {
|
||||
const isApproved = item.status === 'approved' || item.status === 'disbursed';
|
||||
const isRejected = item.status === 'rejected';
|
||||
|
||||
const statusColor = isApproved ? theme.colors.success : isRejected ? theme.colors.danger : theme.colors.warning;
|
||||
|
||||
return (
|
||||
<Animated.View entering={SlideInRight.delay(index * 50)}>
|
||||
<Pressable style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.customerInfo}>
|
||||
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{item.customerName}</Text>
|
||||
<Text style={[styles.category, { color: theme.colors.textMuted }]}>{item.category} Loan</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${statusColor}15` }]}>
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.replace('_', ' ').toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.cardBody}>
|
||||
<View>
|
||||
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Amount</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.amount)}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Tenure</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{item.tenureMonths} mos</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Applied On</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatDate(item.appliedDate)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
newBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
gap: 4,
|
||||
},
|
||||
newBtnText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 16,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
customerInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
customerName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
category: {
|
||||
fontSize: 12,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginVertical: 12,
|
||||
},
|
||||
cardBody: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
marginBottom: 4,
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import type { AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
|
||||
import { useGetSmsLogsQuery } from '../services/agentApi';
|
||||
import { agentActions } from '../store/agentSlice';
|
||||
import { selectSmsLogs } from '../store/agentSelectors';
|
||||
import { formatDateTime } from '../utils/agentFormatters';
|
||||
|
||||
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
|
||||
import { AgentEmptyState } from '../components/AgentEmptyState';
|
||||
|
||||
type Props = NativeStackScreenProps<AgentProfileStackParamList, 'SmsHistory'>;
|
||||
|
||||
export default function SmsHistoryScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const logs = useAppSelector(selectSmsLogs);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useGetSmsLogsQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) dispatch(agentActions.setSmsLogs(data));
|
||||
}, [data, dispatch]);
|
||||
|
||||
return (
|
||||
<ScreenShell title="SMS Logs" scrollable={false}>
|
||||
<View style={styles.container}>
|
||||
{isLoading && logs.length === 0 ? (
|
||||
<AgentSkeletonLoader type="list" />
|
||||
) : (
|
||||
<FlatList
|
||||
data={logs}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
|
||||
ListEmptyComponent={() => (
|
||||
<AgentEmptyState
|
||||
title="No SMS Logs"
|
||||
description="There is no record of SMS messages sent."
|
||||
icon="chatbubble-outline"
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item, index }) => {
|
||||
const isDelivered = item.status === 'delivered';
|
||||
const isFailed = item.status === 'failed';
|
||||
|
||||
const statusColor = isDelivered ? theme.colors.success : isFailed ? theme.colors.danger : theme.colors.warning;
|
||||
const statusIcon = isDelivered ? 'checkmark-done' : isFailed ? 'close-circle' : 'time';
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Ionicons name="chatbubble-ellipses" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.phone, { color: theme.colors.textPrimary }]}>{item.customerPhone}</Text>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<Ionicons name={statusIcon} size={14} color={statusColor} />
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<Text style={[styles.message, { color: theme.colors.textSecondary }]}>{item.message}</Text>
|
||||
<Text style={[styles.date, { color: theme.colors.textMuted }]}>{formatDateTime(item.sentAt)}</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
phone: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginBottom: 12,
|
||||
},
|
||||
message: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 8,
|
||||
},
|
||||
date: {
|
||||
fontSize: 11,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import type {
|
||||
AgentDashboardStats,
|
||||
AgentCustomer,
|
||||
CollectionEntry,
|
||||
CommissionSummary,
|
||||
AgentLoanRequest,
|
||||
AgentRoute,
|
||||
SmsLog,
|
||||
CollectionReceipt,
|
||||
} from '../types/agentTypes';
|
||||
import {
|
||||
MOCK_DASHBOARD_STATS,
|
||||
MOCK_CUSTOMERS,
|
||||
MOCK_TODAY_COLLECTIONS,
|
||||
MOCK_COMMISSION_SUMMARY,
|
||||
MOCK_LOAN_REQUESTS,
|
||||
MOCK_ROUTE,
|
||||
MOCK_SMS_LOGS,
|
||||
} from '../utils/agentMockData';
|
||||
import { generateReceiptNumber } from '../utils/agentFormatters';
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const agentApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
// ── Dashboard ──────────────────────────────────────────
|
||||
getAgentDashboard: builder.query<AgentDashboardStats, void>({
|
||||
queryFn: async () => {
|
||||
await delay(600);
|
||||
return { data: MOCK_DASHBOARD_STATS };
|
||||
},
|
||||
providesTags: ['AgentDashboard'],
|
||||
}),
|
||||
|
||||
// ── Customers ──────────────────────────────────────────
|
||||
getAgentCustomers: builder.query<AgentCustomer[], void>({
|
||||
queryFn: async () => {
|
||||
await delay(500);
|
||||
return { data: MOCK_CUSTOMERS };
|
||||
},
|
||||
providesTags: ['AgentCustomers'],
|
||||
}),
|
||||
|
||||
addAgentCustomer: builder.mutation<AgentCustomer, Partial<AgentCustomer>>({
|
||||
queryFn: async (customer) => {
|
||||
await delay(400);
|
||||
const newCustomer: AgentCustomer = {
|
||||
id: `c${Date.now()}`,
|
||||
name: customer.name ?? '',
|
||||
phone: customer.phone ?? '',
|
||||
email: customer.email,
|
||||
address: customer.address ?? '',
|
||||
city: customer.city ?? '',
|
||||
state: customer.state ?? '',
|
||||
pincode: customer.pincode ?? '',
|
||||
status: 'active',
|
||||
kyc: customer.kyc ?? {
|
||||
aadhaarNumber: '',
|
||||
panNumber: '',
|
||||
aadhaarVerified: false,
|
||||
panVerified: false,
|
||||
status: 'pending',
|
||||
},
|
||||
accounts: [],
|
||||
dueAmount: 0,
|
||||
totalCollected: 0,
|
||||
joinedDate: new Date().toISOString(),
|
||||
};
|
||||
return { data: newCustomer };
|
||||
},
|
||||
invalidatesTags: ['AgentCustomers', 'AgentDashboard'],
|
||||
}),
|
||||
|
||||
updateAgentCustomer: builder.mutation<AgentCustomer, AgentCustomer>({
|
||||
queryFn: async (customer) => {
|
||||
await delay(300);
|
||||
return { data: customer };
|
||||
},
|
||||
invalidatesTags: ['AgentCustomers'],
|
||||
}),
|
||||
|
||||
deleteAgentCustomer: builder.mutation<{ id: string }, string>({
|
||||
queryFn: async (id) => {
|
||||
await delay(300);
|
||||
return { data: { id } };
|
||||
},
|
||||
invalidatesTags: ['AgentCustomers', 'AgentDashboard'],
|
||||
}),
|
||||
|
||||
// ── Collections ────────────────────────────────────────
|
||||
getTodayCollections: builder.query<CollectionEntry[], void>({
|
||||
queryFn: async () => {
|
||||
await delay(500);
|
||||
return { data: MOCK_TODAY_COLLECTIONS };
|
||||
},
|
||||
providesTags: ['AgentCollections'],
|
||||
}),
|
||||
|
||||
submitCollection: builder.mutation<CollectionReceipt, { entryId: string; amount: number; mode: string }>({
|
||||
queryFn: async (params) => {
|
||||
await delay(500);
|
||||
const receipt: CollectionReceipt = {
|
||||
id: `rcp-${Date.now()}`,
|
||||
collectionId: params.entryId,
|
||||
customerName: 'Customer',
|
||||
customerPhone: '9876543000',
|
||||
accountNumber: 'CBS00000000',
|
||||
amount: params.amount,
|
||||
date: new Date().toISOString(),
|
||||
mode: params.mode as any,
|
||||
agentName: 'Rajesh Kumar',
|
||||
agentCode: 'AGT-2024-0147',
|
||||
referenceNumber: generateReceiptNumber(),
|
||||
};
|
||||
return { data: receipt };
|
||||
},
|
||||
invalidatesTags: ['AgentCollections', 'AgentDashboard'],
|
||||
}),
|
||||
|
||||
getCollectionHistory: builder.query<CollectionEntry[], { from?: string; to?: string }>({
|
||||
queryFn: async () => {
|
||||
await delay(400);
|
||||
const history = MOCK_TODAY_COLLECTIONS.map(e => ({ ...e, collectedAt: '2026-05-17T10:00:00Z' }));
|
||||
return { data: history };
|
||||
},
|
||||
}),
|
||||
|
||||
// ── Commission ─────────────────────────────────────────
|
||||
getCommissionSummary: builder.query<CommissionSummary, void>({
|
||||
queryFn: async () => {
|
||||
await delay(500);
|
||||
return { data: MOCK_COMMISSION_SUMMARY };
|
||||
},
|
||||
providesTags: ['AgentCommission'],
|
||||
}),
|
||||
|
||||
// ── Loan Requests ──────────────────────────────────────
|
||||
getAgentLoanRequests: builder.query<AgentLoanRequest[], void>({
|
||||
queryFn: async () => {
|
||||
await delay(500);
|
||||
return { data: MOCK_LOAN_REQUESTS };
|
||||
},
|
||||
providesTags: ['AgentLoans'],
|
||||
}),
|
||||
|
||||
submitAgentLoanRequest: builder.mutation<AgentLoanRequest, Partial<AgentLoanRequest>>({
|
||||
queryFn: async (req) => {
|
||||
await delay(600);
|
||||
const newRequest: AgentLoanRequest = {
|
||||
id: `lr-${Date.now()}`,
|
||||
customerId: req.customerId ?? '',
|
||||
customerName: req.customerName ?? '',
|
||||
customerPhone: req.customerPhone ?? '',
|
||||
category: req.category ?? 'Personal',
|
||||
amount: req.amount ?? 0,
|
||||
tenureMonths: req.tenureMonths ?? 12,
|
||||
purpose: req.purpose ?? '',
|
||||
monthlyIncome: req.monthlyIncome ?? 0,
|
||||
status: 'submitted',
|
||||
appliedDate: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
documents: req.documents ?? [],
|
||||
timeline: [{ stage: 'Submitted', timestamp: new Date().toISOString(), active: true }],
|
||||
};
|
||||
return { data: newRequest };
|
||||
},
|
||||
invalidatesTags: ['AgentLoans', 'AgentDashboard'],
|
||||
}),
|
||||
|
||||
// ── Route ──────────────────────────────────────────────
|
||||
getAgentRoute: builder.query<AgentRoute, void>({
|
||||
queryFn: async () => {
|
||||
await delay(400);
|
||||
return { data: MOCK_ROUTE };
|
||||
},
|
||||
}),
|
||||
|
||||
// ── SMS ────────────────────────────────────────────────
|
||||
getSmsLogs: builder.query<SmsLog[], void>({
|
||||
queryFn: async () => {
|
||||
await delay(400);
|
||||
return { data: MOCK_SMS_LOGS };
|
||||
},
|
||||
providesTags: ['AgentSms'],
|
||||
}),
|
||||
|
||||
sendSms: builder.mutation<SmsLog, { customerId: string; type: string; message: string }>({
|
||||
queryFn: async (params) => {
|
||||
await delay(500);
|
||||
const log: SmsLog = {
|
||||
id: `sms-${Date.now()}`,
|
||||
customerId: params.customerId,
|
||||
customerName: 'Customer',
|
||||
customerPhone: '9876543000',
|
||||
type: params.type as any,
|
||||
message: params.message,
|
||||
status: 'sent',
|
||||
sentAt: new Date().toISOString(),
|
||||
retryCount: 0,
|
||||
};
|
||||
return { data: log };
|
||||
},
|
||||
invalidatesTags: ['AgentSms'],
|
||||
}),
|
||||
|
||||
// ── Account Opening ────────────────────────────────────
|
||||
submitAccountOpening: builder.mutation<{ accountId: string; accountNumber: string }, any>({
|
||||
queryFn: async () => {
|
||||
await delay(800);
|
||||
return {
|
||||
data: {
|
||||
accountId: `acc-${Date.now()}`,
|
||||
accountNumber: `CBS${Date.now().toString().slice(-8)}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
invalidatesTags: ['AgentCustomers', 'AgentDashboard'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetAgentDashboardQuery,
|
||||
useGetAgentCustomersQuery,
|
||||
useAddAgentCustomerMutation,
|
||||
useUpdateAgentCustomerMutation,
|
||||
useDeleteAgentCustomerMutation,
|
||||
useGetTodayCollectionsQuery,
|
||||
useSubmitCollectionMutation,
|
||||
useGetCollectionHistoryQuery,
|
||||
useGetCommissionSummaryQuery,
|
||||
useGetAgentLoanRequestsQuery,
|
||||
useSubmitAgentLoanRequestMutation,
|
||||
useGetAgentRouteQuery,
|
||||
useGetSmsLogsQuery,
|
||||
useSendSmsMutation,
|
||||
useSubmitAccountOpeningMutation,
|
||||
} = agentApi;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '../../../app/store';
|
||||
import type { CustomerFilter } from '../types/agentTypes';
|
||||
|
||||
const selectAgent = (state: RootState) => state.agent;
|
||||
|
||||
export const selectDashboardStats = createSelector(selectAgent, a => a.dashboardStats);
|
||||
export const selectIsRefreshing = createSelector(selectAgent, a => a.isRefreshing);
|
||||
|
||||
// Customers
|
||||
export const selectAllCustomers = createSelector(selectAgent, a => a.customers);
|
||||
export const selectCustomerFilter = createSelector(selectAgent, a => a.customerFilter);
|
||||
export const selectCustomerSearch = createSelector(selectAgent, a => a.customerSearch);
|
||||
|
||||
export const selectFilteredCustomers = createSelector(
|
||||
[selectAllCustomers, selectCustomerFilter, selectCustomerSearch],
|
||||
(customers, filter, search) => {
|
||||
let filtered = customers;
|
||||
|
||||
if (filter !== 'all') {
|
||||
filtered = filtered.filter(c => {
|
||||
if (filter === 'paid') return c.dueAmount === 0;
|
||||
if (filter === 'pending') return c.dueAmount > 0 && c.status !== 'overdue';
|
||||
if (filter === 'overdue') return c.status === 'overdue';
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
c => c.name.toLowerCase().includes(q) || c.phone.includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
);
|
||||
|
||||
export const selectCustomerById = (customerId: string) =>
|
||||
createSelector(selectAllCustomers, customers => customers.find(c => c.id === customerId) ?? null);
|
||||
|
||||
// Collections
|
||||
export const selectTodayCollections = createSelector(selectAgent, a => a.todayCollections);
|
||||
export const selectCollectionHistory = createSelector(selectAgent, a => a.collectionHistory);
|
||||
export const selectOfflineQueue = createSelector(selectAgent, a => a.offlineQueue);
|
||||
|
||||
export const selectTodayCollectionSummary = createSelector(selectTodayCollections, entries => {
|
||||
const totalCollected = entries.reduce((sum, e) => sum + e.collectedAmount, 0);
|
||||
const totalPending = entries.reduce((sum, e) => sum + e.pendingAmount, 0);
|
||||
const collectedCount = entries.filter(e => e.status === 'collected').length;
|
||||
const pendingCount = entries.filter(e => e.status === 'pending').length;
|
||||
return { totalCollected, totalPending, collectedCount, pendingCount, total: entries.length };
|
||||
});
|
||||
|
||||
// Commission
|
||||
export const selectCommissionSummary = createSelector(selectAgent, a => a.commissionSummary);
|
||||
|
||||
// Loan Requests
|
||||
export const selectLoanRequests = createSelector(selectAgent, a => a.loanRequests);
|
||||
export const selectPendingLoanRequests = createSelector(selectLoanRequests, lr =>
|
||||
lr.filter(l => l.status === 'submitted' || l.status === 'under_review'),
|
||||
);
|
||||
|
||||
// Route
|
||||
export const selectRoute = createSelector(selectAgent, a => a.route);
|
||||
|
||||
// SMS
|
||||
export const selectSmsLogs = createSelector(selectAgent, a => a.smsLogs);
|
||||
|
||||
// Account Opening
|
||||
export const selectAccountDraft = createSelector(selectAgent, a => a.accountOpeningDraft);
|
||||
export const selectAccountStep = createSelector(selectAgent, a => a.accountOpeningStep);
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import type {
|
||||
AgentCustomer,
|
||||
CollectionEntry,
|
||||
CustomerFilter,
|
||||
AgentDashboardStats,
|
||||
CommissionSummary,
|
||||
AgentLoanRequest,
|
||||
AgentRoute,
|
||||
SmsLog,
|
||||
AccountOpeningDraft,
|
||||
AccountOpeningStep,
|
||||
NewAccountType,
|
||||
} from '../types/agentTypes';
|
||||
|
||||
interface AgentState {
|
||||
dashboardStats: AgentDashboardStats | null;
|
||||
customers: AgentCustomer[];
|
||||
customerFilter: CustomerFilter;
|
||||
customerSearch: string;
|
||||
todayCollections: CollectionEntry[];
|
||||
collectionHistory: CollectionEntry[];
|
||||
offlineQueue: CollectionEntry[];
|
||||
commissionSummary: CommissionSummary | null;
|
||||
loanRequests: AgentLoanRequest[];
|
||||
route: AgentRoute | null;
|
||||
smsLogs: SmsLog[];
|
||||
accountOpeningDraft: AccountOpeningDraft;
|
||||
accountOpeningStep: AccountOpeningStep;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
const emptyAccountDraft: AccountOpeningDraft = {
|
||||
accountType: null,
|
||||
customerName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
pincode: '',
|
||||
dateOfBirth: '',
|
||||
gender: '',
|
||||
aadhaarNumber: '',
|
||||
panNumber: '',
|
||||
nomineeName: '',
|
||||
nomineeRelation: '',
|
||||
initialDeposit: '',
|
||||
monthlyInstallment: '',
|
||||
};
|
||||
|
||||
const initialState: AgentState = {
|
||||
dashboardStats: null,
|
||||
customers: [],
|
||||
customerFilter: 'all',
|
||||
customerSearch: '',
|
||||
todayCollections: [],
|
||||
collectionHistory: [],
|
||||
offlineQueue: [],
|
||||
commissionSummary: null,
|
||||
loanRequests: [],
|
||||
route: null,
|
||||
smsLogs: [],
|
||||
accountOpeningDraft: emptyAccountDraft,
|
||||
accountOpeningStep: 'type',
|
||||
isRefreshing: false,
|
||||
};
|
||||
|
||||
const agentSlice = createSlice({
|
||||
name: 'agent',
|
||||
initialState,
|
||||
reducers: {
|
||||
setDashboardStats(state, action: PayloadAction<AgentDashboardStats>) {
|
||||
state.dashboardStats = action.payload;
|
||||
},
|
||||
setCustomers(state, action: PayloadAction<AgentCustomer[]>) {
|
||||
// Merge: keep any locally-added customers not present in the API response
|
||||
const incomingIds = new Set(action.payload.map(c => c.id));
|
||||
const localOnly = state.customers.filter(c => !incomingIds.has(c.id));
|
||||
state.customers = [...localOnly, ...action.payload];
|
||||
},
|
||||
addCustomer(state, action: PayloadAction<AgentCustomer>) {
|
||||
state.customers.unshift(action.payload);
|
||||
},
|
||||
updateCustomer(state, action: PayloadAction<AgentCustomer>) {
|
||||
const idx = state.customers.findIndex(c => c.id === action.payload.id);
|
||||
if (idx !== -1) state.customers[idx] = action.payload;
|
||||
},
|
||||
removeCustomer(state, action: PayloadAction<string>) {
|
||||
state.customers = state.customers.filter(c => c.id !== action.payload);
|
||||
},
|
||||
setCustomerFilter(state, action: PayloadAction<CustomerFilter>) {
|
||||
state.customerFilter = action.payload;
|
||||
},
|
||||
setCustomerSearch(state, action: PayloadAction<string>) {
|
||||
state.customerSearch = action.payload;
|
||||
},
|
||||
setTodayCollections(state, action: PayloadAction<CollectionEntry[]>) {
|
||||
state.todayCollections = action.payload;
|
||||
},
|
||||
updateCollectionEntry(state, action: PayloadAction<CollectionEntry>) {
|
||||
const idx = state.todayCollections.findIndex(c => c.id === action.payload.id);
|
||||
if (idx !== -1) state.todayCollections[idx] = action.payload;
|
||||
},
|
||||
addToOfflineQueue(state, action: PayloadAction<CollectionEntry>) {
|
||||
state.offlineQueue.push(action.payload);
|
||||
},
|
||||
clearOfflineQueue(state) {
|
||||
state.offlineQueue = [];
|
||||
},
|
||||
setCollectionHistory(state, action: PayloadAction<CollectionEntry[]>) {
|
||||
state.collectionHistory = action.payload;
|
||||
},
|
||||
setCommissionSummary(state, action: PayloadAction<CommissionSummary>) {
|
||||
state.commissionSummary = action.payload;
|
||||
},
|
||||
setLoanRequests(state, action: PayloadAction<AgentLoanRequest[]>) {
|
||||
state.loanRequests = action.payload;
|
||||
},
|
||||
addLoanRequest(state, action: PayloadAction<AgentLoanRequest>) {
|
||||
state.loanRequests.unshift(action.payload);
|
||||
},
|
||||
setRoute(state, action: PayloadAction<AgentRoute>) {
|
||||
state.route = action.payload;
|
||||
},
|
||||
updateRoutePointStatus(state, action: PayloadAction<{ pointId: string; status: 'visited' | 'skipped' }>) {
|
||||
if (!state.route) return;
|
||||
const point = state.route.points.find(p => p.id === action.payload.pointId);
|
||||
if (point) {
|
||||
point.visitStatus = action.payload.status;
|
||||
point.visitedAt = new Date().toISOString();
|
||||
const total = state.route.points.length;
|
||||
const done = state.route.points.filter(p => p.visitStatus !== 'pending').length;
|
||||
state.route.completionPercent = Math.round((done / total) * 100);
|
||||
}
|
||||
},
|
||||
setSmsLogs(state, action: PayloadAction<SmsLog[]>) {
|
||||
state.smsLogs = action.payload;
|
||||
},
|
||||
addSmsLog(state, action: PayloadAction<SmsLog>) {
|
||||
state.smsLogs.unshift(action.payload);
|
||||
},
|
||||
setAccountOpeningStep(state, action: PayloadAction<AccountOpeningStep>) {
|
||||
state.accountOpeningStep = action.payload;
|
||||
},
|
||||
updateAccountDraftField(state, action: PayloadAction<{ field: keyof AccountOpeningDraft; value: string }>) {
|
||||
(state.accountOpeningDraft as any)[action.payload.field] = action.payload.value;
|
||||
},
|
||||
setAccountType(state, action: PayloadAction<NewAccountType>) {
|
||||
state.accountOpeningDraft.accountType = action.payload;
|
||||
},
|
||||
resetAccountDraft(state) {
|
||||
state.accountOpeningDraft = emptyAccountDraft;
|
||||
state.accountOpeningStep = 'type';
|
||||
},
|
||||
setRefreshing(state, action: PayloadAction<boolean>) {
|
||||
state.isRefreshing = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const agentActions = agentSlice.actions;
|
||||
export const agentReducer = agentSlice.reducer;
|
||||
@@ -0,0 +1,262 @@
|
||||
// ─── Agent Profile ───────────────────────────────────────────────────────────
|
||||
export interface AgentProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
agentCode: string;
|
||||
branch: string;
|
||||
region: string;
|
||||
joinedDate: string;
|
||||
commissionRate: number; // percentage
|
||||
biometricEnabled: boolean;
|
||||
language: 'en' | 'hi' | 'bn';
|
||||
}
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||
export interface AgentDashboardStats {
|
||||
totalCustomers: number;
|
||||
paidCustomers: number;
|
||||
pendingCustomers: number;
|
||||
todayCollection: number;
|
||||
weeklyCollection: number;
|
||||
monthlyCollection: number;
|
||||
totalCollectionTillDate: number;
|
||||
commissionEarned: number;
|
||||
lastCommissionPaidDate: string;
|
||||
possibleCollection: number;
|
||||
actualCollection: number;
|
||||
commissionLoss: number;
|
||||
pendingAmount: number;
|
||||
pendingLoanRequests: number;
|
||||
approvedLoanRequests: number;
|
||||
todayVisits: number;
|
||||
completedVisits: number;
|
||||
weeklyCollectionTrend: number[];
|
||||
monthlyCollectionTrend: number[];
|
||||
}
|
||||
|
||||
// ─── Customer ────────────────────────────────────────────────────────────────
|
||||
export type CustomerStatus = 'active' | 'inactive' | 'overdue';
|
||||
export type CustomerFilter = 'all' | 'paid' | 'pending' | 'overdue';
|
||||
export type KycStatus = 'verified' | 'pending' | 'rejected';
|
||||
|
||||
export interface CustomerKyc {
|
||||
aadhaarNumber: string;
|
||||
panNumber: string;
|
||||
aadhaarVerified: boolean;
|
||||
panVerified: boolean;
|
||||
aadhaarDocUrl?: string;
|
||||
panDocUrl?: string;
|
||||
photoUrl?: string;
|
||||
status: KycStatus;
|
||||
}
|
||||
|
||||
export interface CustomerAccount {
|
||||
id: string;
|
||||
type: 'DDS' | 'RD' | 'Savings';
|
||||
accountNumber: string;
|
||||
balance: number;
|
||||
emiAmount: number;
|
||||
totalPaid: number;
|
||||
totalDue: number;
|
||||
startDate: string;
|
||||
maturityDate?: string;
|
||||
status: 'active' | 'closed' | 'matured';
|
||||
}
|
||||
|
||||
export interface AgentCustomer {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
pincode: string;
|
||||
photoUrl?: string;
|
||||
status: CustomerStatus;
|
||||
kyc: CustomerKyc;
|
||||
accounts: CustomerAccount[];
|
||||
dueAmount: number;
|
||||
lastPaymentDate?: string;
|
||||
lastPaymentAmount?: number;
|
||||
totalCollected: number;
|
||||
joinedDate: string;
|
||||
}
|
||||
|
||||
// ─── Collection ──────────────────────────────────────────────────────────────
|
||||
export type CollectionStatus = 'collected' | 'pending' | 'partial' | 'skipped';
|
||||
export type CollectionMode = 'cash' | 'upi' | 'bank_transfer';
|
||||
|
||||
export interface CollectionEntry {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
accountId: string;
|
||||
accountType: string;
|
||||
emiAmount: number;
|
||||
collectedAmount: number;
|
||||
pendingAmount: number;
|
||||
status: CollectionStatus;
|
||||
mode: CollectionMode;
|
||||
collectedAt?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
remarks?: string;
|
||||
receiptId?: string;
|
||||
synced: boolean;
|
||||
}
|
||||
|
||||
export interface CollectionReceipt {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
accountNumber: string;
|
||||
amount: number;
|
||||
date: string;
|
||||
mode: CollectionMode;
|
||||
agentName: string;
|
||||
agentCode: string;
|
||||
referenceNumber: string;
|
||||
}
|
||||
|
||||
export interface CollectionDaySummary {
|
||||
date: string;
|
||||
totalCollected: number;
|
||||
totalPending: number;
|
||||
entriesCount: number;
|
||||
collectedCount: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
// ─── Commission ──────────────────────────────────────────────────────────────
|
||||
export interface CommissionRecord {
|
||||
id: string;
|
||||
month: string;
|
||||
year: number;
|
||||
possibleCollection: number;
|
||||
actualCollection: number;
|
||||
commissionRate: number;
|
||||
possibleCommission: number;
|
||||
actualCommission: number;
|
||||
loss: number;
|
||||
paidDate?: string;
|
||||
status: 'paid' | 'pending' | 'processing';
|
||||
}
|
||||
|
||||
export interface CommissionSummary {
|
||||
totalEarned: number;
|
||||
lastPaidAmount: number;
|
||||
lastPaidDate: string;
|
||||
currentMonthExpected: number;
|
||||
currentMonthActual: number;
|
||||
currentMonthLoss: number;
|
||||
commissionRate: number;
|
||||
history: CommissionRecord[];
|
||||
}
|
||||
|
||||
// ─── Loan Request ────────────────────────────────────────────────────────────
|
||||
export type LoanRequestStatus = 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected' | 'disbursed';
|
||||
export type AgentLoanCategory = 'Personal' | 'Gold' | 'Vehicle' | 'Housing' | 'Agriculture' | 'Business';
|
||||
|
||||
export interface AgentLoanRequest {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
category: AgentLoanCategory;
|
||||
amount: number;
|
||||
tenureMonths: number;
|
||||
purpose: string;
|
||||
monthlyIncome: number;
|
||||
status: LoanRequestStatus;
|
||||
appliedDate: string;
|
||||
lastUpdated: string;
|
||||
documents: LoanRequestDocument[];
|
||||
remarks?: string;
|
||||
rejectionReason?: string;
|
||||
approvedAmount?: number;
|
||||
timeline: { stage: string; timestamp: string; active: boolean }[];
|
||||
}
|
||||
|
||||
export interface LoanRequestDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'aadhaar' | 'pan' | 'income_proof' | 'address_proof' | 'photo' | 'other';
|
||||
status: 'uploaded' | 'verified' | 'rejected';
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// ─── Account Opening ─────────────────────────────────────────────────────────
|
||||
export type NewAccountType = 'DDS' | 'RD' | 'Savings';
|
||||
export type AccountOpeningStep = 'type' | 'personal' | 'kyc' | 'deposit' | 'review';
|
||||
|
||||
export interface AccountOpeningDraft {
|
||||
accountType: NewAccountType | null;
|
||||
customerName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
pincode: string;
|
||||
dateOfBirth: string;
|
||||
gender: string;
|
||||
aadhaarNumber: string;
|
||||
panNumber: string;
|
||||
nomineeName: string;
|
||||
nomineeRelation: string;
|
||||
initialDeposit: string;
|
||||
monthlyInstallment: string;
|
||||
photoUrl?: string;
|
||||
aadhaarDocUrl?: string;
|
||||
panDocUrl?: string;
|
||||
}
|
||||
|
||||
// ─── Route ───────────────────────────────────────────────────────────────────
|
||||
export type VisitStatus = 'pending' | 'visited' | 'skipped';
|
||||
|
||||
export interface RoutePoint {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
dueAmount: number;
|
||||
visitStatus: VisitStatus;
|
||||
visitedAt?: string;
|
||||
distance?: string;
|
||||
estimatedTime?: string;
|
||||
}
|
||||
|
||||
export interface AgentRoute {
|
||||
id: string;
|
||||
date: string;
|
||||
points: RoutePoint[];
|
||||
totalDistance: string;
|
||||
estimatedDuration: string;
|
||||
completionPercent: number;
|
||||
}
|
||||
|
||||
// ─── SMS ─────────────────────────────────────────────────────────────────────
|
||||
export type SmsType = 'balance' | 'payment_success' | 'due_reminder' | 'loan_status' | 'account_created';
|
||||
export type SmsStatus = 'sent' | 'failed' | 'pending' | 'delivered';
|
||||
|
||||
export interface SmsLog {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
type: SmsType;
|
||||
message: string;
|
||||
status: SmsStatus;
|
||||
sentAt: string;
|
||||
deliveredAt?: string;
|
||||
retryCount: number;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export const formatAgentCurrency = (value: number): string => {
|
||||
if (value >= 10000000) {
|
||||
return `₹${(value / 10000000).toFixed(2)} Cr`;
|
||||
}
|
||||
if (value >= 100000) {
|
||||
return `₹${(value / 100000).toFixed(2)} L`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `₹${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return `₹${value.toLocaleString('en-IN')}`;
|
||||
};
|
||||
|
||||
export const formatFullCurrency = (value: number): string =>
|
||||
`₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
|
||||
export const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
export const formatTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
export const formatDateTime = (dateString: string): string =>
|
||||
`${formatDate(dateString)}, ${formatTime(dateString)}`;
|
||||
|
||||
export const maskPhone = (phone: string): string => {
|
||||
if (phone.length < 10) return phone;
|
||||
return `${phone.slice(0, 2)}****${phone.slice(-4)}`;
|
||||
};
|
||||
|
||||
export const maskAadhaar = (aadhaar: string): string => {
|
||||
const digits = aadhaar.replace(/\D/g, '');
|
||||
if (digits.length < 12) return aadhaar;
|
||||
return `XXXX XXXX ${digits.slice(-4)}`;
|
||||
};
|
||||
|
||||
export const formatPercentage = (value: number, decimals = 1): string =>
|
||||
`${value.toFixed(decimals)}%`;
|
||||
|
||||
export const getGreeting = (): string => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
export const generateReceiptNumber = (): string => {
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `RCP-${dateStr}-${random}`;
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
AgentProfile,
|
||||
AgentDashboardStats,
|
||||
AgentCustomer,
|
||||
CollectionEntry,
|
||||
CommissionSummary,
|
||||
CommissionRecord,
|
||||
AgentLoanRequest,
|
||||
AgentRoute,
|
||||
RoutePoint,
|
||||
SmsLog,
|
||||
} from '../types/agentTypes';
|
||||
|
||||
export const MOCK_AGENT_PROFILE: AgentProfile = {
|
||||
id: 'agent-001',
|
||||
name: 'Rajesh Kumar',
|
||||
phone: '9876543210',
|
||||
email: 'rajesh.kumar@cbsbank.in',
|
||||
agentCode: 'AGT-2024-0147',
|
||||
branch: 'Kolkata Main Branch',
|
||||
region: 'West Bengal',
|
||||
joinedDate: '2023-03-15',
|
||||
commissionRate: 2.5,
|
||||
biometricEnabled: true,
|
||||
language: 'en',
|
||||
};
|
||||
|
||||
export const MOCK_DASHBOARD_STATS: AgentDashboardStats = {
|
||||
totalCustomers: 248,
|
||||
paidCustomers: 189,
|
||||
pendingCustomers: 59,
|
||||
todayCollection: 34500,
|
||||
weeklyCollection: 187500,
|
||||
monthlyCollection: 756000,
|
||||
totalCollectionTillDate: 8945000,
|
||||
commissionEarned: 18900,
|
||||
lastCommissionPaidDate: '2026-04-30',
|
||||
possibleCollection: 875000,
|
||||
actualCollection: 756000,
|
||||
commissionLoss: 2975,
|
||||
pendingAmount: 119000,
|
||||
pendingLoanRequests: 7,
|
||||
approvedLoanRequests: 23,
|
||||
todayVisits: 18,
|
||||
completedVisits: 11,
|
||||
weeklyCollectionTrend: [28500, 31200, 25800, 34500, 29000, 37800, 34500],
|
||||
monthlyCollectionTrend: [680000, 720000, 695000, 756000],
|
||||
};
|
||||
|
||||
const makeCustomer = (
|
||||
id: string, name: string, phone: string, status: 'active' | 'inactive' | 'overdue',
|
||||
dueAmount: number, totalCollected: number, lastPayment?: number,
|
||||
): AgentCustomer => ({
|
||||
id,
|
||||
name,
|
||||
phone,
|
||||
email: `${name.toLowerCase().replace(/\s/g, '.')}@email.com`,
|
||||
address: '123, Main Road',
|
||||
city: 'Kolkata',
|
||||
state: 'West Bengal',
|
||||
pincode: '700001',
|
||||
status,
|
||||
kyc: {
|
||||
aadhaarNumber: '123456789012',
|
||||
panNumber: 'ABCDE1234F',
|
||||
aadhaarVerified: true,
|
||||
panVerified: status !== 'overdue',
|
||||
status: status === 'overdue' ? 'pending' : 'verified',
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: `acc-${id}`,
|
||||
type: 'DDS',
|
||||
accountNumber: `CBS${id.padStart(8, '0')}`,
|
||||
balance: totalCollected,
|
||||
emiAmount: 500,
|
||||
totalPaid: totalCollected,
|
||||
totalDue: dueAmount,
|
||||
startDate: '2025-01-01',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
dueAmount,
|
||||
lastPaymentDate: lastPayment ? '2026-05-17' : undefined,
|
||||
lastPaymentAmount: lastPayment,
|
||||
totalCollected,
|
||||
joinedDate: '2025-01-15',
|
||||
});
|
||||
|
||||
export const MOCK_CUSTOMERS: AgentCustomer[] = [
|
||||
makeCustomer('c001', 'Amit Sharma', '9876543001', 'active', 0, 15000, 500),
|
||||
makeCustomer('c002', 'Priya Devi', '9876543002', 'active', 500, 12000, 500),
|
||||
makeCustomer('c003', 'Ravi Prasad', '9876543003', 'overdue', 2500, 8500),
|
||||
makeCustomer('c004', 'Sunita Kumari', '9876543004', 'active', 0, 18000, 500),
|
||||
makeCustomer('c005', 'Manoj Singh', '9876543005', 'overdue', 1500, 6000),
|
||||
makeCustomer('c006', 'Deepa Gupta', '9876543006', 'active', 500, 14500, 500),
|
||||
makeCustomer('c007', 'Arun Verma', '9876543007', 'active', 0, 16000, 500),
|
||||
makeCustomer('c008', 'Kavita Rani', '9876543008', 'inactive', 3000, 3000),
|
||||
makeCustomer('c009', 'Suresh Yadav', '9876543009', 'active', 0, 20000, 500),
|
||||
makeCustomer('c010', 'Meena Devi', '9876543010', 'overdue', 2000, 7000),
|
||||
makeCustomer('c011', 'Vikas Jha', '9876543011', 'active', 500, 11500, 500),
|
||||
makeCustomer('c012', 'Anita Patel', '9876543012', 'active', 0, 19000, 500),
|
||||
];
|
||||
|
||||
const makeCollectionEntry = (
|
||||
id: string, customerId: string, customerName: string, phone: string,
|
||||
emiAmount: number, collected: number, status: 'collected' | 'pending' | 'partial',
|
||||
): CollectionEntry => ({
|
||||
id,
|
||||
customerId,
|
||||
customerName,
|
||||
customerPhone: phone,
|
||||
accountId: `acc-${customerId}`,
|
||||
accountType: 'DDS',
|
||||
emiAmount,
|
||||
collectedAmount: collected,
|
||||
pendingAmount: Math.max(0, emiAmount - collected),
|
||||
status,
|
||||
mode: 'cash',
|
||||
collectedAt: status !== 'pending' ? new Date().toISOString() : undefined,
|
||||
latitude: 22.5726 + Math.random() * 0.01,
|
||||
longitude: 88.3639 + Math.random() * 0.01,
|
||||
synced: true,
|
||||
});
|
||||
|
||||
export const MOCK_TODAY_COLLECTIONS: CollectionEntry[] = [
|
||||
makeCollectionEntry('col-001', 'c001', 'Amit Sharma', '9876543001', 500, 500, 'collected'),
|
||||
makeCollectionEntry('col-002', 'c002', 'Priya Devi', '9876543002', 500, 500, 'collected'),
|
||||
makeCollectionEntry('col-003', 'c003', 'Ravi Prasad', '9876543003', 500, 0, 'pending'),
|
||||
makeCollectionEntry('col-004', 'c004', 'Sunita Kumari', '9876543004', 500, 500, 'collected'),
|
||||
makeCollectionEntry('col-005', 'c005', 'Manoj Singh', '9876543005', 500, 200, 'partial'),
|
||||
makeCollectionEntry('col-006', 'c006', 'Deepa Gupta', '9876543006', 500, 0, 'pending'),
|
||||
makeCollectionEntry('col-007', 'c007', 'Arun Verma', '9876543007', 500, 500, 'collected'),
|
||||
makeCollectionEntry('col-008', 'c009', 'Suresh Yadav', '9876543009', 500, 0, 'pending'),
|
||||
];
|
||||
|
||||
export const MOCK_COMMISSION_SUMMARY: CommissionSummary = {
|
||||
totalEarned: 18900,
|
||||
lastPaidAmount: 4750,
|
||||
lastPaidDate: '2026-04-30',
|
||||
currentMonthExpected: 21875,
|
||||
currentMonthActual: 18900,
|
||||
currentMonthLoss: 2975,
|
||||
commissionRate: 2.5,
|
||||
history: [
|
||||
{ id: 'com-01', month: 'January', year: 2026, possibleCollection: 800000, actualCollection: 750000, commissionRate: 2.5, possibleCommission: 20000, actualCommission: 18750, loss: 1250, paidDate: '2026-01-31', status: 'paid' },
|
||||
{ id: 'com-02', month: 'February', year: 2026, possibleCollection: 820000, actualCollection: 790000, commissionRate: 2.5, possibleCommission: 20500, actualCommission: 19750, loss: 750, paidDate: '2026-02-28', status: 'paid' },
|
||||
{ id: 'com-03', month: 'March', year: 2026, possibleCollection: 850000, actualCollection: 810000, commissionRate: 2.5, possibleCommission: 21250, actualCommission: 20250, loss: 1000, paidDate: '2026-03-31', status: 'paid' },
|
||||
{ id: 'com-04', month: 'April', year: 2026, possibleCollection: 870000, actualCollection: 760000, commissionRate: 2.5, possibleCommission: 21750, actualCommission: 19000, loss: 2750, paidDate: '2026-04-30', status: 'paid' },
|
||||
{ id: 'com-05', month: 'May', year: 2026, possibleCollection: 875000, actualCollection: 756000, commissionRate: 2.5, possibleCommission: 21875, actualCommission: 18900, loss: 2975, status: 'pending' },
|
||||
] as CommissionRecord[],
|
||||
};
|
||||
|
||||
export const MOCK_LOAN_REQUESTS: AgentLoanRequest[] = [
|
||||
{
|
||||
id: 'lr-001', customerId: 'c001', customerName: 'Amit Sharma', customerPhone: '9876543001',
|
||||
category: 'Personal', amount: 200000, tenureMonths: 24, purpose: 'Home renovation',
|
||||
monthlyIncome: 35000, status: 'approved', appliedDate: '2026-04-20', lastUpdated: '2026-05-10',
|
||||
documents: [
|
||||
{ id: 'd1', name: 'Aadhaar Card', type: 'aadhaar', status: 'verified', uploadedAt: '2026-04-20' },
|
||||
{ id: 'd2', name: 'PAN Card', type: 'pan', status: 'verified', uploadedAt: '2026-04-20' },
|
||||
],
|
||||
approvedAmount: 180000,
|
||||
timeline: [
|
||||
{ stage: 'Submitted', timestamp: '2026-04-20T10:00:00Z', active: false },
|
||||
{ stage: 'Under Review', timestamp: '2026-04-25T14:00:00Z', active: false },
|
||||
{ stage: 'Approved', timestamp: '2026-05-10T11:00:00Z', active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lr-002', customerId: 'c003', customerName: 'Ravi Prasad', customerPhone: '9876543003',
|
||||
category: 'Gold', amount: 100000, tenureMonths: 12, purpose: 'Business expansion',
|
||||
monthlyIncome: 28000, status: 'under_review', appliedDate: '2026-05-12', lastUpdated: '2026-05-15',
|
||||
documents: [
|
||||
{ id: 'd3', name: 'Aadhaar Card', type: 'aadhaar', status: 'uploaded', uploadedAt: '2026-05-12' },
|
||||
],
|
||||
timeline: [
|
||||
{ stage: 'Submitted', timestamp: '2026-05-12T09:00:00Z', active: false },
|
||||
{ stage: 'Under Review', timestamp: '2026-05-15T10:00:00Z', active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lr-003', customerId: 'c005', customerName: 'Manoj Singh', customerPhone: '9876543005',
|
||||
category: 'Vehicle', amount: 350000, tenureMonths: 48, purpose: 'Two-wheeler purchase',
|
||||
monthlyIncome: 22000, status: 'submitted', appliedDate: '2026-05-17', lastUpdated: '2026-05-17',
|
||||
documents: [],
|
||||
timeline: [
|
||||
{ stage: 'Submitted', timestamp: '2026-05-17T16:00:00Z', active: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_ROUTE: AgentRoute = {
|
||||
id: 'route-today',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
totalDistance: '12.5 km',
|
||||
estimatedDuration: '2h 30m',
|
||||
completionPercent: 61,
|
||||
points: MOCK_CUSTOMERS.slice(0, 8).map((c, i): RoutePoint => ({
|
||||
id: `rp-${i}`,
|
||||
customerId: c.id,
|
||||
customerName: c.name,
|
||||
customerPhone: c.phone,
|
||||
address: `${c.address}, ${c.city}`,
|
||||
latitude: 22.5726 + i * 0.002,
|
||||
longitude: 88.3639 + i * 0.003,
|
||||
dueAmount: c.dueAmount,
|
||||
visitStatus: i < 5 ? 'visited' : 'pending',
|
||||
visitedAt: i < 5 ? new Date(Date.now() - (5 - i) * 3600000).toISOString() : undefined,
|
||||
distance: `${(1.2 + i * 0.3).toFixed(1)} km`,
|
||||
estimatedTime: `${8 + i * 3} min`,
|
||||
})),
|
||||
};
|
||||
|
||||
export const MOCK_SMS_LOGS: SmsLog[] = [
|
||||
{ id: 'sms-001', customerId: 'c001', customerName: 'Amit Sharma', customerPhone: '9876543001', type: 'payment_success', message: 'Dear Amit, your DDS payment of ₹500 has been received. Ref: RCP-20260518-0001. Thank you.', status: 'delivered', sentAt: '2026-05-18T10:30:00Z', deliveredAt: '2026-05-18T10:30:05Z', retryCount: 0 },
|
||||
{ id: 'sms-002', customerId: 'c003', customerName: 'Ravi Prasad', customerPhone: '9876543003', type: 'due_reminder', message: 'Dear Ravi, your DDS installment of ₹500 is overdue. Please pay at the earliest. Contact your agent.', status: 'sent', sentAt: '2026-05-18T09:00:00Z', retryCount: 0 },
|
||||
{ id: 'sms-003', customerId: 'c005', customerName: 'Manoj Singh', customerPhone: '9876543005', type: 'loan_status', message: 'Dear Manoj, your vehicle loan application (₹3,50,000) has been submitted. Ref: LR-003.', status: 'failed', sentAt: '2026-05-17T16:30:00Z', retryCount: 2 },
|
||||
{ id: 'sms-004', customerId: 'c002', customerName: 'Priya Devi', customerPhone: '9876543002', type: 'balance', message: 'Dear Priya, your DDS account balance is ₹12,000. Next installment due: 19-May-2026.', status: 'delivered', sentAt: '2026-05-17T08:00:00Z', deliveredAt: '2026-05-17T08:00:03Z', retryCount: 0 },
|
||||
];
|
||||
@@ -0,0 +1,134 @@
|
||||
export const validatePhone = (phone: string): string | null => {
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (!digits) return 'Phone number is required';
|
||||
if (digits.length !== 10) return 'Enter a valid 10-digit phone number';
|
||||
if (!/^[6-9]/.test(digits)) return 'Phone must start with 6, 7, 8, or 9';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateAadhaar = (aadhaar: string): string | null => {
|
||||
const digits = aadhaar.replace(/\D/g, '');
|
||||
if (!digits) return 'Aadhaar number is required';
|
||||
if (digits.length !== 12) return 'Enter a valid 12-digit Aadhaar number';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validatePan = (pan: string): string | null => {
|
||||
if (!pan.trim()) return 'PAN number is required';
|
||||
const panRegex = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
||||
if (!panRegex.test(pan.toUpperCase())) return 'Enter a valid PAN (e.g. ABCDE1234F)';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateEmail = (email: string): string | null => {
|
||||
if (!email.trim()) return null; // optional
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) return 'Enter a valid email address';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateRequired = (value: string, fieldName: string): string | null => {
|
||||
if (!value.trim()) return `${fieldName} is required`;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateAmount = (amount: string, min = 1): string | null => {
|
||||
const num = Number(amount);
|
||||
if (!amount.trim() || isNaN(num)) return 'Enter a valid amount';
|
||||
if (num < min) return `Amount must be at least ₹${min}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validatePincode = (pincode: string): string | null => {
|
||||
const digits = pincode.replace(/\D/g, '');
|
||||
if (!digits) return 'Pincode is required';
|
||||
if (digits.length !== 6) return 'Enter a valid 6-digit pincode';
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
export const validateCustomerForm = (data: {
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
pincode: string;
|
||||
aadhaarNumber: string;
|
||||
panNumber: string;
|
||||
email?: string;
|
||||
}): ValidationResult => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
const nameErr = validateRequired(data.name, 'Name');
|
||||
if (nameErr) errors.name = nameErr;
|
||||
|
||||
const phoneErr = validatePhone(data.phone);
|
||||
if (phoneErr) errors.phone = phoneErr;
|
||||
|
||||
const addressErr = validateRequired(data.address, 'Address');
|
||||
if (addressErr) errors.address = addressErr;
|
||||
|
||||
const cityErr = validateRequired(data.city, 'City');
|
||||
if (cityErr) errors.city = cityErr;
|
||||
|
||||
const stateErr = validateRequired(data.state, 'State');
|
||||
if (stateErr) errors.state = stateErr;
|
||||
|
||||
const pincodeErr = validatePincode(data.pincode);
|
||||
if (pincodeErr) errors.pincode = pincodeErr;
|
||||
|
||||
const aadhaarErr = validateAadhaar(data.aadhaarNumber);
|
||||
if (aadhaarErr) errors.aadhaarNumber = aadhaarErr;
|
||||
|
||||
const panErr = validatePan(data.panNumber);
|
||||
if (panErr) errors.panNumber = panErr;
|
||||
|
||||
if (data.email) {
|
||||
const emailErr = validateEmail(data.email);
|
||||
if (emailErr) errors.email = emailErr;
|
||||
}
|
||||
|
||||
return { isValid: Object.keys(errors).length === 0, errors };
|
||||
};
|
||||
|
||||
export const validateCollectionAmount = (collected: string, emi: number): string | null => {
|
||||
const num = Number(collected);
|
||||
if (!collected.trim() || isNaN(num)) return 'Enter a valid amount';
|
||||
if (num <= 0) return 'Amount must be greater than 0';
|
||||
if (num > emi * 3) return 'Amount exceeds 3x EMI limit';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateLoanRequest = (data: {
|
||||
customerName: string;
|
||||
amount: string;
|
||||
tenureMonths: string;
|
||||
purpose: string;
|
||||
monthlyIncome: string;
|
||||
category: string | null;
|
||||
}): ValidationResult => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!data.category) errors.category = 'Select a loan category';
|
||||
if (!data.customerName.trim()) errors.customerName = 'Select a customer';
|
||||
|
||||
const amountErr = validateAmount(data.amount, 5000);
|
||||
if (amountErr) errors.amount = amountErr;
|
||||
|
||||
const tenureNum = Number(data.tenureMonths);
|
||||
if (!data.tenureMonths || isNaN(tenureNum) || tenureNum < 3 || tenureNum > 360) {
|
||||
errors.tenureMonths = 'Tenure must be between 3 and 360 months';
|
||||
}
|
||||
|
||||
if (!data.purpose.trim()) errors.purpose = 'Enter the loan purpose';
|
||||
|
||||
const incomeErr = validateAmount(data.monthlyIncome, 1000);
|
||||
if (incomeErr) errors.monthlyIncome = incomeErr;
|
||||
|
||||
return { isValid: Object.keys(errors).length === 0, errors };
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
Easing,
|
||||
interpolate
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
const Orb = ({ size, color, delay, duration, startPos }: {
|
||||
size: number;
|
||||
color: string;
|
||||
delay: number;
|
||||
duration: number;
|
||||
startPos: { x: number; y: number };
|
||||
}) => {
|
||||
const tx = useSharedValue(0);
|
||||
const ty = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
tx.value = withRepeat(
|
||||
withTiming(1, { duration, easing: Easing.inOut(Easing.sin) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
ty.value = withRepeat(
|
||||
withTiming(1, { duration: duration * 1.2, easing: Easing.inOut(Easing.sin) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: interpolate(tx.value, [0, 1], [-20, 20]) },
|
||||
{ translateY: interpolate(ty.value, [0, 1], [-30, 30]) },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.orb,
|
||||
animatedStyle,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color,
|
||||
left: startPos.x,
|
||||
top: startPos.y,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BackgroundOrbs = () => {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[StyleSheet.absoluteFill, styles.container]}>
|
||||
<Orb
|
||||
size={width * 0.8}
|
||||
color={theme.colors.primary}
|
||||
delay={0}
|
||||
duration={8000}
|
||||
startPos={{ x: -50, y: height * 0.1 }}
|
||||
/>
|
||||
<Orb
|
||||
size={width * 0.6}
|
||||
color="#3B82F6"
|
||||
delay={1000}
|
||||
duration={10000}
|
||||
startPos={{ x: width * 0.4, y: height * 0.5 }}
|
||||
/>
|
||||
<Orb
|
||||
size={width * 0.5}
|
||||
color="#6366F1"
|
||||
delay={2000}
|
||||
duration={12000}
|
||||
startPos={{ x: width * 0.1, y: height * 0.7 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#0A0F1E',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
orb: {
|
||||
position: 'absolute',
|
||||
opacity: 0.15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, Text, View, Pressable, Platform } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
Easing,
|
||||
interpolate
|
||||
} from 'react-native-reanimated';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface BiometricNodeProps {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const BiometricNode = ({ onPress }: BiometricNodeProps) => {
|
||||
const theme = useTheme();
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
scale.value = withRepeat(
|
||||
withTiming(1.05, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable onPress={onPress} style={styles.pressable}>
|
||||
<Animated.View style={[styles.node, { backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder }, animatedStyle]}>
|
||||
<Ionicons name="finger-print-outline" size={32} color={theme.colors.primary} />
|
||||
</Animated.View>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Use Biometrics</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
pressable: {
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
node: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 2,
|
||||
},
|
||||
}),
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
withDelay,
|
||||
FadeInDown,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
const CBSNexoraLogo = () => {
|
||||
return (
|
||||
<View style={styles.logoGrid}>
|
||||
<View style={styles.logoRow}>
|
||||
<View style={[styles.logoSquare, { backgroundColor: '#1E40AF' }]} />
|
||||
<View style={[styles.logoSquare, { backgroundColor: '#3B82F6' }]} />
|
||||
</View>
|
||||
<View style={styles.logoRow}>
|
||||
<View style={[styles.logoSquare, { backgroundColor: '#10B981' }]} />
|
||||
<View style={[styles.logoSquare, { backgroundColor: '#CBD5E1' }]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const LoginHeroComponent = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(800).springify()}
|
||||
style={styles.logoWrapper}
|
||||
>
|
||||
<CBSNexoraLogo />
|
||||
<Text style={[styles.brandName, { color: theme.colors.textPrimary }]}>
|
||||
CBS<Text style={{ color: theme.colors.primary }}>Nexora</Text>
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(200).duration(800).springify()} style={styles.textContainer}>
|
||||
<Text style={[styles.welcome, { color: theme.colors.textPrimary }]}>Secure Banking</Text>
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
Enter your credentials to access your global accounts.
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 32,
|
||||
gap: 24,
|
||||
},
|
||||
logoWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
logoGrid: {
|
||||
gap: 3,
|
||||
},
|
||||
logoRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 3,
|
||||
},
|
||||
logoSquare: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 3,
|
||||
},
|
||||
brandName: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
textContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
welcome: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
fontWeight: '400',
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const LoginHero = memo(LoginHeroComponent);
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
|
||||
export function LoginHero() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.logoFrame, { backgroundColor: `${theme.colors.primary}10` }]}>
|
||||
<Ionicons name="shield-checkmark" size={28} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.heading, { color: theme.colors.textPrimary }]}>BankCBS</Text>
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>Banking made effortless for your everyday money management.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 22,
|
||||
gap: 12,
|
||||
},
|
||||
logoFrame: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heading: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
textAlign: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
maxWidth: 320,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import Animated, { FadeIn, FadeOut, Layout } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { haptics } from '../../../services/haptics/haptics';
|
||||
import type { LoginRole } from '../../../types/app';
|
||||
|
||||
interface LoginRoleTabsProps {
|
||||
activeRole: LoginRole;
|
||||
onRoleChange: (role: LoginRole) => void;
|
||||
}
|
||||
|
||||
const ROLES: LoginRole[] = ['User', 'Employee', 'Agent'];
|
||||
|
||||
const ActiveTabBackground = () => (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
layout={Layout.springify().damping(15)}
|
||||
style={StyleSheet.absoluteFill}
|
||||
>
|
||||
<Svg width="100%" height="100%" viewBox="0 0 110 36" preserveAspectRatio="none">
|
||||
<Path d="M 0,36 L 0,12 Q 0,0 12,0 L 80,0 Q 88,0 92,6 L 110,36 Z" fill="#143e6c" />
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
export const LoginRoleTabs: React.FC<LoginRoleTabsProps> = ({ activeRole, onRoleChange }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{ROLES.map((role, index) => {
|
||||
const isActive = activeRole === role;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={role}
|
||||
onPress={() => {
|
||||
if (!isActive) {
|
||||
haptics.selection();
|
||||
onRoleChange(role);
|
||||
}
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.tab,
|
||||
index > 0 && { marginLeft: isActive ? -10 : 0 },
|
||||
isActive && { zIndex: 10 },
|
||||
pressed && !isActive && { opacity: 0.6 },
|
||||
pressed && isActive && { transform: [{ scale: 0.98 }] }
|
||||
]}
|
||||
>
|
||||
{isActive && <ActiveTabBackground />}
|
||||
|
||||
<Animated.Text
|
||||
layout={Layout.springify().damping(15)}
|
||||
style={[
|
||||
styles.tabText,
|
||||
isActive ? styles.activeText : [styles.inactiveText, { color: theme.colors.textSecondary }],
|
||||
]}
|
||||
>
|
||||
{role}
|
||||
</Animated.Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: -1,
|
||||
zIndex: 10,
|
||||
marginLeft: 0,
|
||||
},
|
||||
tab: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 38,
|
||||
width: 110,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
zIndex: 2,
|
||||
transform: [{ translateX: -4 }],
|
||||
},
|
||||
activeText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
inactiveText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
Easing,
|
||||
withDelay,
|
||||
withSequence,
|
||||
} from 'react-native-reanimated';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
const Node = ({ x, y, delay, color }: { x: number; y: number; delay: number; color: string }) => {
|
||||
const opacity = useSharedValue(0.1);
|
||||
const scale = useSharedValue(0.8);
|
||||
|
||||
React.useEffect(() => {
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withDelay(delay, withTiming(0.4, { duration: 1500, easing: Easing.inOut(Easing.sin) })),
|
||||
withTiming(0.1, { duration: 1500, easing: Easing.inOut(Easing.sin) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withDelay(delay, withTiming(1.2, { duration: 1500, easing: Easing.inOut(Easing.sin) })),
|
||||
withTiming(0.8, { duration: 1500, easing: Easing.inOut(Easing.sin) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}, [delay]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.node,
|
||||
{
|
||||
left: x,
|
||||
top: y,
|
||||
backgroundColor: color,
|
||||
shadowColor: color,
|
||||
},
|
||||
animatedStyle
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const NeuralGrid = () => {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const theme = useTheme();
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
const items = [];
|
||||
const cols = 8;
|
||||
const rows = 16;
|
||||
for (let i = 0; i < cols; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
items.push({
|
||||
id: `${i}-${j}`,
|
||||
x: (width / (cols - 1)) * i,
|
||||
y: (height / (rows - 1)) * j,
|
||||
delay: Math.random() * 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [width, height]);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<LinearGradient
|
||||
colors={[theme.colors.backgroundElevated, theme.colors.background]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* Radial Background Glows */}
|
||||
<View style={[styles.glow, {
|
||||
backgroundColor: theme.colors.cyberCyan,
|
||||
opacity: 0.05,
|
||||
left: -width * 0.3,
|
||||
top: height * 0.1,
|
||||
width: width * 1.5,
|
||||
height: width * 1.5
|
||||
}]} />
|
||||
|
||||
<View style={[styles.glow, {
|
||||
backgroundColor: theme.colors.cyberPurple,
|
||||
opacity: 0.03,
|
||||
right: -width * 0.4,
|
||||
bottom: -height * 0.2,
|
||||
width: width * 1.5,
|
||||
height: width * 1.5
|
||||
}]} />
|
||||
|
||||
{/* Grid Lines */}
|
||||
<View style={styles.gridContainer}>
|
||||
{nodes.map((node) => (
|
||||
<Node key={node.id} x={node.x} y={node.y} delay={node.delay} color={theme.colors.primary} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ScanLine height={height} width={width} color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ScanLine = ({ width, height, color }: { width: number; height: number; color: string }) => {
|
||||
const ty = useSharedValue(-100);
|
||||
|
||||
React.useEffect(() => {
|
||||
ty.value = withRepeat(
|
||||
withTiming(height + 100, { duration: 6000, easing: Easing.linear }),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, [height]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: ty.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.scanLine,
|
||||
{
|
||||
width,
|
||||
backgroundColor: color,
|
||||
shadowColor: color,
|
||||
},
|
||||
animatedStyle
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gridContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
opacity: 0.4,
|
||||
},
|
||||
node: {
|
||||
position: 'absolute',
|
||||
width: 2,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
glow: {
|
||||
position: 'absolute',
|
||||
borderRadius: 9999,
|
||||
},
|
||||
scanLine: {
|
||||
position: 'absolute',
|
||||
height: 1,
|
||||
opacity: 0.15,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 10,
|
||||
elevation: 10,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { biometrics } from '../../../services/security/biometrics';
|
||||
import { authActions } from '../store/authSlice';
|
||||
|
||||
export const useBiometricPrompt = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return useCallback(async () => {
|
||||
const available = await biometrics.isAvailable();
|
||||
|
||||
if (!available) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await biometrics.prompt();
|
||||
|
||||
if (result.success) {
|
||||
dispatch(authActions.setAuthMethod('biometric'));
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}, [dispatch]);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { secureStorage } from '../../../services/storage/secureStorage';
|
||||
import { authActions } from '../store/authSlice';
|
||||
import { useLoginMutation } from '../services/authApi';
|
||||
import { isValidPhone } from '../utils/authValidation';
|
||||
import { LoginHero } from '../components/LoginHero';
|
||||
import { NeuralGrid } from '../components/NeuralGrid';
|
||||
import { BiometricNode } from '../components/BiometricNode';
|
||||
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
|
||||
import { haptics } from '../../../services/haptics/haptics';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
import type { LoginRole } from '../../../types/app';
|
||||
import { LoginRoleTabs } from '../components/LoginRoleTabs';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Login'>>;
|
||||
|
||||
export default function LoginScreen(props: Readonly<Props>) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const promptBiometric = useBiometricPrompt();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [activeRole, setActiveRole] = useState<LoginRole>('User');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [login, loginState] = useLoginMutation();
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
haptics.selection();
|
||||
toast('Redirecting to secure reset channel...', 'info');
|
||||
};
|
||||
|
||||
const handleSignUp = () => {
|
||||
haptics.selection();
|
||||
toast('Initializing onboarding sequence...', 'info');
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
try {
|
||||
const tokens = {
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
role: activeRole,
|
||||
};
|
||||
await secureStorage.saveSession(tokens);
|
||||
dispatch(authActions.authenticate(tokens));
|
||||
haptics.success();
|
||||
toast('Neural bridge established', 'success');
|
||||
return;
|
||||
} catch {
|
||||
haptics.error();
|
||||
toast('Access Denied', 'error', 'Authorization failed. Retry handshake.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBiometricLogin = async () => {
|
||||
haptics.selection();
|
||||
const success = await promptBiometric();
|
||||
|
||||
if (!success) {
|
||||
toast('Biometric scan aborted', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = {
|
||||
accessToken: 'biometric-access-token',
|
||||
refreshToken: 'biometric-refresh-token',
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
|
||||
role: activeRole,
|
||||
};
|
||||
|
||||
await secureStorage.saveSession(tokens);
|
||||
dispatch(authActions.authenticate(tokens));
|
||||
haptics.success();
|
||||
toast('Synapse link verified', 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.flex}>
|
||||
<ScreenShell scrollable={true} contentContainerStyle={styles.shellContainer}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.heroWrapper}>
|
||||
<LoginHero />
|
||||
</View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(400).duration(800).springify()}>
|
||||
<LoginRoleTabs activeRole={activeRole} onRoleChange={setActiveRole} />
|
||||
<Card
|
||||
style={[
|
||||
styles.formCard,
|
||||
activeRole === 'User' ? { borderTopLeftRadius: 0 } : {}
|
||||
]}
|
||||
>
|
||||
<View style={styles.inputGap}>
|
||||
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>EMAIL OR PHONE</Text>
|
||||
<Input
|
||||
keyboardType="default"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
placeholder="name@example.com"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGap}>
|
||||
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>PASSWORD</Text>
|
||||
<Input
|
||||
secureTextEntry={!showPassword}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="••••••••"
|
||||
rightAccessory={
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)} hitSlop={12}>
|
||||
<Ionicons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={theme.colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.forgotPasswordRow}>
|
||||
<Pressable onPress={handleForgotPassword} hitSlop={12}>
|
||||
<Text style={[styles.linkText, { color: theme.colors.primary }]}>Forgot password?</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Sign In"
|
||||
loading={loginState.isLoading}
|
||||
onPress={handleContinue}
|
||||
style={styles.mainButton}
|
||||
/>
|
||||
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<Text style={[styles.dividerText, { color: theme.colors.textMuted }]}>OR SIGN IN WITH</Text>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
</View>
|
||||
|
||||
<BiometricNode onPress={handleBiometricLogin} />
|
||||
</Card>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(600).duration(800)}
|
||||
style={styles.bottomLinks}
|
||||
>
|
||||
<Text style={[styles.signUpText, { color: theme.colors.textSecondary }]}>
|
||||
New to BankCBS?{' '}
|
||||
<Text style={[styles.signUpLink, { color: theme.colors.primary }]} onPress={handleSignUp}>Create account</Text>
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
shellContainer: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 40,
|
||||
},
|
||||
heroWrapper: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
formCard: {
|
||||
gap: 20,
|
||||
padding: 24,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#FFFFFF',
|
||||
// We'll rely on the Card component's internal variant/shadow or override
|
||||
},
|
||||
inputGap: {
|
||||
gap: 8,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
forgotPasswordRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: -8,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
mainButton: {
|
||||
height: 54,
|
||||
borderRadius: 12,
|
||||
},
|
||||
dividerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
marginVertical: 4,
|
||||
},
|
||||
divider: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
},
|
||||
dividerText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
bottomLinks: {
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 32,
|
||||
},
|
||||
signUpText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
signUpLink: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { AuthTokens, UserProfile } from '../../../types/app';
|
||||
import { demoUser } from '../../../constants/mockData';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { resolveWithMock } from '../../../services/api/serviceHelpers';
|
||||
|
||||
interface LoginRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
challengeId: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface VerifyOtpRequest {
|
||||
challengeId: string;
|
||||
otp: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface VerifyOtpResponse {
|
||||
verified: true;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface SetupMpinRequest {
|
||||
phone: string;
|
||||
mpin: string;
|
||||
}
|
||||
|
||||
interface SetupMpinResponse {
|
||||
tokens: AuthTokens;
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
const createDemoTokens = (): AuthTokens => ({
|
||||
accessToken: 'demo-access-token',
|
||||
refreshToken: 'demo-refresh-token',
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
|
||||
});
|
||||
|
||||
export const authApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
login: builder.mutation<LoginResponse, LoginRequest>({
|
||||
queryFn: ({ phone }) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
challengeId: 'otp_challenge_001',
|
||||
phone,
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post<LoginResponse>(endpoints.auth.login, { phone });
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
verifyOtp: builder.mutation<VerifyOtpResponse, VerifyOtpRequest>({
|
||||
queryFn: ({ phone }) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
verified: true,
|
||||
phone,
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post<VerifyOtpResponse>(endpoints.auth.verifyOtp, {
|
||||
phone,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
setupMpin: builder.mutation<SetupMpinResponse, SetupMpinRequest>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: () => ({
|
||||
tokens: createDemoTokens(),
|
||||
user: demoUser,
|
||||
}),
|
||||
request: async () => {
|
||||
const response = await apiClient.post<SetupMpinResponse>(endpoints.auth.setupMpin);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['Auth', 'User'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useLoginMutation,
|
||||
useVerifyOtpMutation,
|
||||
useSetupMpinMutation,
|
||||
} = authApi;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RootState } from '../../../app/store';
|
||||
|
||||
export const selectAuthState = (state: RootState) => state.auth;
|
||||
export const selectIsAuthenticated = (state: RootState) => state.auth.isAuthenticated;
|
||||
export const selectAuthHydrating = (state: RootState) => state.auth.isHydrating;
|
||||
export const selectPendingPhone = (state: RootState) => state.auth.pendingPhone;
|
||||
export const selectChallengeId = (state: RootState) => state.auth.challengeId;
|
||||
export const selectAuthRole = (state: RootState) => state.auth.role;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { AuthTokens } from '../../../types/app';
|
||||
import { bootstrapAuthSession, logoutSession } from './authThunk';
|
||||
|
||||
import type { LoginRole } from '../../../types/app';
|
||||
|
||||
interface AuthState {
|
||||
isHydrating: boolean;
|
||||
isAuthenticated: boolean;
|
||||
pendingPhone: string;
|
||||
challengeId: string | null;
|
||||
tokens: AuthTokens | null;
|
||||
authMethod: 'otp' | 'biometric' | null;
|
||||
role: LoginRole;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
isHydrating: true,
|
||||
isAuthenticated: false,
|
||||
pendingPhone: '',
|
||||
challengeId: null,
|
||||
tokens: null,
|
||||
authMethod: null,
|
||||
role: 'User',
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
setPendingPhone(state, action: PayloadAction<string>) {
|
||||
state.pendingPhone = action.payload;
|
||||
},
|
||||
setChallengeId(state, action: PayloadAction<string>) {
|
||||
state.challengeId = action.payload;
|
||||
},
|
||||
authenticate(state, action: PayloadAction<AuthTokens>) {
|
||||
state.tokens = action.payload;
|
||||
state.isAuthenticated = true;
|
||||
state.isHydrating = false;
|
||||
state.role = action.payload.role || 'User';
|
||||
},
|
||||
setAuthMethod(state, action: PayloadAction<'otp' | 'biometric'>) {
|
||||
state.authMethod = action.payload;
|
||||
},
|
||||
resetAuthFlow(state) {
|
||||
state.challengeId = null;
|
||||
state.pendingPhone = '';
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(bootstrapAuthSession.pending, (state) => {
|
||||
state.isHydrating = true;
|
||||
})
|
||||
.addCase(bootstrapAuthSession.fulfilled, (state, action) => {
|
||||
state.tokens = action.payload;
|
||||
state.isAuthenticated = Boolean(action.payload);
|
||||
state.isHydrating = false;
|
||||
state.role = action.payload?.role || 'User';
|
||||
})
|
||||
.addCase(bootstrapAuthSession.rejected, (state) => {
|
||||
state.isHydrating = false;
|
||||
state.isAuthenticated = false;
|
||||
state.role = 'User';
|
||||
})
|
||||
.addCase(logoutSession.fulfilled, (state) => {
|
||||
state.tokens = null;
|
||||
state.isAuthenticated = false;
|
||||
state.pendingPhone = '';
|
||||
state.challengeId = null;
|
||||
state.authMethod = null;
|
||||
state.role = 'User';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const authActions = authSlice.actions;
|
||||
export const authReducer = authSlice.reducer;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { secureStorage } from '../../../services/storage/secureStorage';
|
||||
|
||||
export const bootstrapAuthSession = createAsyncThunk('auth/bootstrapSession', async () => {
|
||||
const tokens = await secureStorage.getSession();
|
||||
return tokens;
|
||||
});
|
||||
|
||||
export const logoutSession = createAsyncThunk('auth/logoutSession', async () => {
|
||||
await secureStorage.clearSession();
|
||||
return true;
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export type { AuthTokens, UserProfile } from '../../../types/app';
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
isValidMpin,
|
||||
isValidOtp,
|
||||
isValidPhone,
|
||||
} from '../../../utils/validation';
|
||||
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { AwarenessArticle } from '../types';
|
||||
|
||||
interface AwarenessCardProps {
|
||||
article: AwarenessArticle;
|
||||
isBookmarked: boolean;
|
||||
onToggleBookmark: () => void;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const AwarenessCard = ({
|
||||
article,
|
||||
isBookmarked,
|
||||
onToggleBookmark,
|
||||
onPress,
|
||||
}: AwarenessCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name={article.icon as any} size={22} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.headerInfo}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.category, { color: theme.colors.primary }]}>
|
||||
{article.category}
|
||||
</Text>
|
||||
{article.isUrgent && (
|
||||
<View style={[styles.urgentBadge, { backgroundColor: `${theme.colors.error}15` }]}>
|
||||
<Text style={[styles.urgentText, { color: theme.colors.error }]}>URGENT</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{article.title}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable hitSlop={10} onPress={onToggleBookmark} style={styles.bookmarkBtn}>
|
||||
<Ionicons
|
||||
name={isBookmarked ? 'bookmark' : 'bookmark-outline'}
|
||||
size={22}
|
||||
color={isBookmarked ? theme.colors.primary : theme.colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]} numberOfLines={2}>
|
||||
{article.description}
|
||||
</Text>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.tags}>
|
||||
{article.tags.slice(0, 2).map((tag) => (
|
||||
<View key={tag} style={[styles.tag, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.tagText, { color: theme.colors.textSecondary }]}>#{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.readMore}>
|
||||
<Text style={[styles.readMoreText, { color: theme.colors.primary }]}>Read More</Text>
|
||||
<Ionicons name="chevron-forward" size={14} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
gap: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconFrame: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerInfo: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
category: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
urgentBadge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
urgentText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '900',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
bookmarkBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
description: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 4,
|
||||
},
|
||||
tags: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
readMore: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
readMoreText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { AwarenessArticle } from '../types';
|
||||
|
||||
export const awarenessArticles: AwarenessArticle[] = [
|
||||
{
|
||||
id: 'art_001',
|
||||
title: 'Recognizing UPI QR Code Scams',
|
||||
description: 'Learn how scammers use fake QR codes to steal money from your account.',
|
||||
content: `UPI QR codes are meant for making payments, not receiving them. Scammers often send QR codes to victims claiming they will receive money by scanning it.
|
||||
|
||||
Key Safety Tips:
|
||||
1. Scanning a QR code always means money will be deducted from YOUR account.
|
||||
2. You NEVER need to scan a QR code or enter your UPI PIN to RECEIVE money.
|
||||
3. If someone asks you to scan a QR code to "collect prize money," it's a scam.
|
||||
4. Always verify the merchant name displayed in the UPI app before confirming a payment.`,
|
||||
category: 'NPCI',
|
||||
tags: ['UPI', 'Scam'],
|
||||
icon: 'qr-code-outline',
|
||||
publishDate: '2026-04-10',
|
||||
isUrgent: true,
|
||||
},
|
||||
{
|
||||
id: 'art_002',
|
||||
title: 'Protecting Your OTP',
|
||||
description: 'Why you should never share your One Time Password with anyone.',
|
||||
content: `One Time Passwords (OTPs) are the final barrier protecting your transactions. No bank official will ever ask for your OTP over the phone or via email.
|
||||
|
||||
Safe Practices:
|
||||
- Never share OTP, CVV, or PIN with anyone, including bank staff.
|
||||
- Ensure your mobile number is updated with the bank to receive alerts.
|
||||
- Do not click on links in SMS that ask for your OTP.
|
||||
- Use biometric authentication where available for added security.`,
|
||||
category: 'RBI',
|
||||
tags: ['OTP', 'Scam'],
|
||||
icon: 'shield-checkmark-outline',
|
||||
publishDate: '2026-04-08',
|
||||
},
|
||||
{
|
||||
id: 'art_003',
|
||||
title: 'Fake Call/Vishing Alerts',
|
||||
description: 'How to handle calls from individuals pretending to be bank employees.',
|
||||
content: `Vishing (voice phishing) is a common method where scammers call you pretending to be from your bank's KYC or security department.
|
||||
|
||||
How to identify Vishing:
|
||||
1. Sense of Urgency: They claim your account will be blocked immediately.
|
||||
2. Asking for Sensitive Info: They ask for PIN, OTP, or passwords.
|
||||
3. Call from unknown mobile numbers: Banks usually call from official numbers.
|
||||
|
||||
What to do:
|
||||
- Hang up immediately if you feel suspicious.
|
||||
- Call your bank's official customer care number to verify.
|
||||
- Report the number on the Cyber Crime portal.`,
|
||||
category: 'Fraud Alert',
|
||||
tags: ['Phishing', 'Scam'],
|
||||
icon: 'call-outline',
|
||||
publishDate: '2026-04-12',
|
||||
isUrgent: true,
|
||||
},
|
||||
{
|
||||
id: 'art_004',
|
||||
title: 'Safe Online Banking Habits',
|
||||
description: 'Essential practices for secure net banking and mobile app usage.',
|
||||
content: `Securing your digital footprint is vital in modern banking. Follow these RBI-recommended practices.
|
||||
|
||||
Checklist:
|
||||
- Use strong, unique passwords for banking apps.
|
||||
- Enable Two-Factor Authentication (2FA).
|
||||
- Avoid using public Wi-Fi for banking transactions.
|
||||
- Always log out after your session.
|
||||
- Keep your banking app updated to the latest version.`,
|
||||
category: 'Best Practices',
|
||||
tags: ['Net Banking', 'Scam'],
|
||||
icon: 'globe-outline',
|
||||
publishDate: '2026-04-05',
|
||||
},
|
||||
{
|
||||
id: 'art_005',
|
||||
title: 'NPCI: UPI Safety Shield',
|
||||
description: 'NPCI’s official guide to safe digital payments.',
|
||||
content: `NPCI encourages users to be "UPI Chalak" (UPI Smart).
|
||||
|
||||
Security Features:
|
||||
- PIN Entry: Required only for sending money or checking balance.
|
||||
- Spam Filter: UPI apps now highlight suspicious payment requests.
|
||||
- Transaction Limits: Set daily limits for UPI transactions in your bank app.
|
||||
- Block & Report: Use the "Report" feature for fraudulent IDs.`,
|
||||
category: 'NPCI',
|
||||
tags: ['UPI'],
|
||||
icon: 'flash-outline',
|
||||
publishDate: '2026-04-01',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { awarenessArticles } from '../constants/mockData';
|
||||
import { AwarenessArticle, AwarenessFilter } from '../types';
|
||||
|
||||
const BOOKMARKS_KEY = '@awareness_bookmarks';
|
||||
|
||||
export const useAwareness = () => {
|
||||
const [articles] = useState<AwarenessArticle[]>(awarenessArticles);
|
||||
const [bookmarks, setBookmarks] = useState<string[]>([]);
|
||||
const [filter, setFilter] = useState<AwarenessFilter>({
|
||||
category: 'All',
|
||||
tag: 'All',
|
||||
searchQuery: '',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load bookmarks on mount
|
||||
useEffect(() => {
|
||||
const loadBookmarks = async () => {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(BOOKMARKS_KEY);
|
||||
if (saved) {
|
||||
setBookmarks(JSON.parse(saved));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadBookmarks();
|
||||
}, []);
|
||||
|
||||
// Save bookmarks when changed
|
||||
const toggleBookmark = useCallback(async (id: string) => {
|
||||
try {
|
||||
const newBookmarks = bookmarks.includes(id)
|
||||
? bookmarks.filter((b) => b !== id)
|
||||
: [...bookmarks, id];
|
||||
|
||||
setBookmarks(newBookmarks);
|
||||
await AsyncStorage.setItem(BOOKMARKS_KEY, JSON.stringify(newBookmarks));
|
||||
} catch (error) {
|
||||
console.error('Failed to update bookmarks', error);
|
||||
}
|
||||
}, [bookmarks]);
|
||||
|
||||
const filteredArticles = useMemo(() => {
|
||||
return articles.filter((article) => {
|
||||
const matchesCategory = filter.category === 'All' || article.category === filter.category;
|
||||
const matchesTag = filter.tag === 'All' || article.tags.includes(filter.tag as any);
|
||||
const matchesSearch =
|
||||
article.title.toLowerCase().includes(filter.searchQuery.toLowerCase()) ||
|
||||
article.description.toLowerCase().includes(filter.searchQuery.toLowerCase()) ||
|
||||
article.content.toLowerCase().includes(filter.searchQuery.toLowerCase());
|
||||
|
||||
return matchesCategory && matchesTag && matchesSearch;
|
||||
});
|
||||
}, [articles, filter]);
|
||||
|
||||
const urgentArticles = useMemo(() => {
|
||||
return articles.filter((a) => a.isUrgent);
|
||||
}, [articles]);
|
||||
|
||||
const bookmarkedArticles = useMemo(() => {
|
||||
return articles.filter((a) => bookmarks.includes(a.id));
|
||||
}, [articles, bookmarks]);
|
||||
|
||||
return {
|
||||
articles: filteredArticles,
|
||||
urgentArticles,
|
||||
bookmarkedArticles,
|
||||
bookmarks,
|
||||
toggleBookmark,
|
||||
isLoading,
|
||||
filter,
|
||||
setFilter,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import React from 'react';
|
||||
import { Share, StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Card, ScreenShell } from '../../../components';
|
||||
import { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAwareness } from '../hooks/useAwareness';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'AwarenessDetail'>;
|
||||
|
||||
export default function AwarenessDetailScreen({ route, navigation }: Props) {
|
||||
const { articleId } = route.params;
|
||||
const theme = useTheme();
|
||||
const { articles, bookmarks, toggleBookmark } = useAwareness();
|
||||
|
||||
const article = articles.find((a) => a.id === articleId);
|
||||
const isBookmarked = bookmarks.includes(articleId);
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<ScreenShell title="Not Found">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={{ color: theme.colors.textPrimary }}>Article not found.</Text>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
await Share.share({
|
||||
message: `${article.title}\n\n${article.description}\n\nStay safe with BankCBS!`,
|
||||
title: article.title,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sharing article', error);
|
||||
}
|
||||
};
|
||||
|
||||
const rightContent = (
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable onPress={handleShare} style={[styles.actionBtn, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="share-social-outline" size={20} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => toggleBookmark(articleId)}
|
||||
style={[styles.actionBtn, { backgroundColor: isBookmarked ? `${theme.colors.primary}15` : theme.colors.surfaceMuted }]}
|
||||
>
|
||||
<Ionicons
|
||||
name={isBookmarked ? 'bookmark' : 'bookmark-outline'}
|
||||
size={20}
|
||||
color={isBookmarked ? theme.colors.primary : theme.colors.textPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Article"
|
||||
subtitle={article.category}
|
||||
rightContent={rightContent}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.metaRow}>
|
||||
<View style={[styles.categoryBadge, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Text style={[styles.categoryText, { color: theme.colors.primary }]}>{article.category}</Text>
|
||||
</View>
|
||||
<Text style={[styles.dateText, { color: theme.colors.textMuted }]}>{article.publishDate}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{article.title}</Text>
|
||||
|
||||
<View style={styles.tagsRow}>
|
||||
{article.tags.map(tag => (
|
||||
<View key={tag} style={[styles.tag, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.tagText, { color: theme.colors.textSecondary }]}>#{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Card variant="glass" style={styles.introCard}>
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
|
||||
{article.description}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<View style={styles.contentSection}>
|
||||
<Text style={[styles.contentText, { color: theme.colors.textPrimary }]}>
|
||||
{article.content}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.safetyCta}>
|
||||
<View style={[styles.ctaIcon, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<Ionicons name="shield-checkmark-outline" size={24} color={theme.colors.success} />
|
||||
</View>
|
||||
<View style={styles.ctaContent}>
|
||||
<Text style={[styles.ctaTitle, { color: theme.colors.textPrimary }]}>Stay Protected</Text>
|
||||
<Text style={[styles.ctaSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
BankCBS never asks for your OTP, PIN, or CVV. Report any suspicious activity immediately.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footerActions}>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ServiceRequestsHub')}
|
||||
style={[styles.reportBtn, { borderColor: theme.colors.cardBorder }]}
|
||||
>
|
||||
<Ionicons name="megaphone-outline" size={18} color={theme.colors.error} />
|
||||
<Text style={[styles.reportText, { color: theme.colors.error }]}>Report Fraudulent Activity</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ height: 20 }} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
actionBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
categoryBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.5,
|
||||
lineHeight: 30,
|
||||
marginBottom: 12,
|
||||
},
|
||||
tagsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 10,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
introCard: {
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
description: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contentSection: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
contentText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 26,
|
||||
},
|
||||
safetyCta: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.05)',
|
||||
padding: 20,
|
||||
borderRadius: 20,
|
||||
gap: 16,
|
||||
marginBottom: 32,
|
||||
},
|
||||
ctaIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ctaContent: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
ctaTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
ctaSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
footerActions: {
|
||||
gap: 12,
|
||||
},
|
||||
reportBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
reportText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import { FlatList, ScrollView, StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Input, ScreenShell } from '../../../components';
|
||||
import { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { AwarenessCard } from '../components/AwarenessCard';
|
||||
import { useAwareness } from '../hooks/useAwareness';
|
||||
import { AwarenessCategory } from '../types';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'AwarenessList'>;
|
||||
|
||||
const CATEGORIES: (AwarenessCategory | 'All')[] = ['All', 'RBI', 'NPCI', 'Fraud Alert', 'Best Practices'];
|
||||
|
||||
export default function AwarenessListScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { articles, filter, setFilter, bookmarks, toggleBookmark, urgentArticles } = useAwareness();
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setFilter({ ...filter, searchQuery: query });
|
||||
};
|
||||
|
||||
const handleCategoryPress = (category: AwarenessCategory | 'All') => {
|
||||
setFilter({ ...filter, category });
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Security aware" subtitle="Learn to protect your digital banking">
|
||||
<View style={styles.searchContainer}>
|
||||
<Input
|
||||
placeholder="Search safety tips, fraud alerts..."
|
||||
value={filter.searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
leftAccessory={<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.filterWrapper}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterContainer}>
|
||||
{CATEGORIES.map((cat) => {
|
||||
const isActive = filter.category === cat;
|
||||
return (
|
||||
<Pressable
|
||||
key={cat}
|
||||
onPress={() => handleCategoryPress(cat)}
|
||||
style={[
|
||||
styles.filterChip,
|
||||
{
|
||||
backgroundColor: isActive ? theme.colors.primary : theme.colors.surfaceMuted,
|
||||
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterText,
|
||||
{ color: isActive ? theme.colors.white : theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{cat}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={articles}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<AwarenessCard
|
||||
article={item}
|
||||
isBookmarked={bookmarks.includes(item.id)}
|
||||
onToggleBookmark={() => toggleBookmark(item.id)}
|
||||
onPress={() => navigation.navigate('AwarenessDetail', { articleId: item.id })}
|
||||
/>
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{urgentArticles.length > 0 && filter.category === 'All' && filter.searchQuery === '' && (
|
||||
<View style={styles.urgentSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Ionicons name="alert-circle" size={18} color={theme.colors.error} />
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Critical Security Alerts
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.urgentCarousel}>
|
||||
{urgentArticles.map((article) => (
|
||||
<Pressable
|
||||
key={article.id}
|
||||
onPress={() => navigation.navigate('AwarenessDetail', { articleId: article.id })}
|
||||
style={[styles.urgentCard, { backgroundColor: `${theme.colors.error}08`, borderColor: `${theme.colors.error}20` }]}
|
||||
>
|
||||
<View style={[styles.urgentIcon, { backgroundColor: `${theme.colors.error}15` }]}>
|
||||
<Ionicons name={article.icon as any} size={20} color={theme.colors.error} />
|
||||
</View>
|
||||
<Text style={[styles.urgentCardTitle, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{article.title}
|
||||
</Text>
|
||||
<Text style={[styles.urgentCardDesc, { color: theme.colors.textSecondary }]} numberOfLines={1}>
|
||||
{article.description}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
{filter.category === 'All' ? 'Latest Awareness Articles' : `${filter.category} Guidelines`}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="search-outline" size={48} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No results found</Text>
|
||||
<Text style={[styles.emptySubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Try adjusting your search or filters to find what you're looking for.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
scrollEnabled={false} // Since it's inside ScreenShell's ScrollView
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
searchContainer: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
filterWrapper: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: 16,
|
||||
gap: 10,
|
||||
},
|
||||
filterChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
},
|
||||
filterText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
urgentSection: {
|
||||
marginBottom: 24,
|
||||
gap: 12,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
urgentCarousel: {
|
||||
gap: 12,
|
||||
},
|
||||
urgentCard: {
|
||||
width: 260,
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
},
|
||||
urgentIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
urgentCardTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
urgentCardDesc: {
|
||||
fontSize: 12,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
gap: 12,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export type AwarenessCategory = 'RBI' | 'NPCI' | 'Fraud Alert' | 'Best Practices';
|
||||
export type AwarenessTag = 'UPI' | 'Cards' | 'Net Banking' | 'OTP' | 'Phishing' | 'Scam';
|
||||
|
||||
export interface AwarenessArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
category: AwarenessCategory;
|
||||
tags: AwarenessTag[];
|
||||
icon: string;
|
||||
imageUrl?: string;
|
||||
publishDate: string;
|
||||
isUrgent?: boolean;
|
||||
}
|
||||
|
||||
export type AwarenessFilter = {
|
||||
category: AwarenessCategory | 'All';
|
||||
tag: AwarenessTag | 'All';
|
||||
searchQuery: string;
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedScrollHandler,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import type { BankCard } from '../../../types/app';
|
||||
import { CardItem } from './CardItem';
|
||||
|
||||
interface CardCarouselProps {
|
||||
cards: BankCard[];
|
||||
selectedIndex: number;
|
||||
onSnapToCard: (cardId: string) => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
const CARD_WIDTH = Math.min(screenWidth - 72, 340);
|
||||
const CARD_GAP = 18;
|
||||
const ITEM_SIZE = CARD_WIDTH + CARD_GAP;
|
||||
const SIDE_INSET = Math.max((screenWidth - CARD_WIDTH) / 2, 20);
|
||||
|
||||
const CardCarouselComponent = ({
|
||||
cards,
|
||||
selectedIndex,
|
||||
onSnapToCard,
|
||||
}: CardCarouselProps) => {
|
||||
const scrollX = useSharedValue(selectedIndex * ITEM_SIZE);
|
||||
|
||||
const initialScrollIndex = useMemo(
|
||||
() => (cards.length ? Math.min(selectedIndex, cards.length - 1) : 0),
|
||||
[cards.length, selectedIndex],
|
||||
);
|
||||
|
||||
const handleScroll = useAnimatedScrollHandler((event) => {
|
||||
scrollX.value = event.contentOffset.x;
|
||||
});
|
||||
|
||||
const handleMomentumScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!cards.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = Math.round(event.nativeEvent.contentOffset.x / ITEM_SIZE);
|
||||
const card = cards[Math.min(Math.max(nextIndex, 0), cards.length - 1)];
|
||||
|
||||
if (card) {
|
||||
onSnapToCard(card.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Animated.FlatList
|
||||
data={cards}
|
||||
horizontal
|
||||
bounces={false}
|
||||
initialScrollIndex={initialScrollIndex}
|
||||
decelerationRate="fast"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={ITEM_SIZE}
|
||||
snapToAlignment="start"
|
||||
disableIntervalMomentum
|
||||
onScroll={handleScroll}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={(item) => item.id}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: ITEM_SIZE,
|
||||
offset: ITEM_SIZE * index,
|
||||
index,
|
||||
})}
|
||||
renderItem={({ item, index }) => (
|
||||
<View style={styles.itemWrap}>
|
||||
<CardItem
|
||||
card={item}
|
||||
index={index}
|
||||
scrollX={scrollX}
|
||||
cardWidth={CARD_WIDTH}
|
||||
itemSize={ITEM_SIZE}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
<View style={styles.pagination}>
|
||||
{cards.map((card, index) => (
|
||||
<View
|
||||
key={card.id}
|
||||
style={[
|
||||
styles.dot,
|
||||
index === selectedIndex ? styles.dotActive : styles.dotInactive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
marginHorizontal: -20,
|
||||
minHeight: 246,
|
||||
gap: 14,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: SIDE_INSET,
|
||||
},
|
||||
itemWrap: {
|
||||
width: ITEM_SIZE,
|
||||
},
|
||||
pagination: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
dotActive: {
|
||||
width: 24,
|
||||
backgroundColor: '#124577',
|
||||
},
|
||||
dotInactive: {
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.14)',
|
||||
},
|
||||
});
|
||||
|
||||
export const CardCarousel = memo(CardCarouselComponent);
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface CardControlToggleProps {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const CardControlToggleComponent = ({
|
||||
label,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: CardControlToggleProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onToggle}
|
||||
style={[
|
||||
styles.row,
|
||||
{
|
||||
borderBottomColor: theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>{label}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.track,
|
||||
{
|
||||
borderRadius: theme.radius.pill,
|
||||
backgroundColor: enabled ? theme.colors.accent : theme.colors.surfaceMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.thumb,
|
||||
enabled ? styles.thumbEnd : styles.thumbStart,
|
||||
{
|
||||
backgroundColor: theme.colors.white,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
track: {
|
||||
width: 52,
|
||||
padding: 4,
|
||||
},
|
||||
thumb: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
},
|
||||
thumbStart: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
thumbEnd: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
});
|
||||
|
||||
export const CardControlToggle = memo(CardControlToggleComponent);
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { CardPhysicalDelivery } from '../../../types/app';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import {
|
||||
getCardDeliveryProgressIndex,
|
||||
getCardDeliveryStatusLabel,
|
||||
} from '../utils/cardCopy';
|
||||
|
||||
interface CardDeliveryStatusProps {
|
||||
delivery: CardPhysicalDelivery;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
type DeliveryStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const CardDeliveryStatusComponent = ({
|
||||
delivery,
|
||||
compact = false,
|
||||
}: CardDeliveryStatusProps) => {
|
||||
const theme = useTheme();
|
||||
const statusLabel = getCardDeliveryStatusLabel(delivery);
|
||||
const progressIndex = getCardDeliveryProgressIndex(delivery);
|
||||
|
||||
const steps = useMemo<DeliveryStep[]>(
|
||||
() =>
|
||||
delivery.mode === 'home'
|
||||
? [
|
||||
{ id: 'processing', label: 'Requested' },
|
||||
{ id: 'personalizing', label: 'Prepared' },
|
||||
{ id: 'shipped', label: 'In transit' },
|
||||
{ id: 'delivered', label: 'Delivered' },
|
||||
]
|
||||
: [
|
||||
{ id: 'processing', label: 'Requested' },
|
||||
{ id: 'personalizing', label: 'Prepared' },
|
||||
{ id: 'ready-for-pickup', label: 'At branch' },
|
||||
{ id: 'delivered', label: 'Collected' },
|
||||
],
|
||||
[delivery.mode],
|
||||
);
|
||||
|
||||
const accentColor =
|
||||
delivery.status === 'delivered'
|
||||
? theme.colors.accentStrong
|
||||
: delivery.status === 'ready-for-pickup'
|
||||
? theme.colors.warning
|
||||
: theme.colors.primary;
|
||||
const accentBackground =
|
||||
delivery.status === 'delivered'
|
||||
? `${theme.colors.accent}12`
|
||||
: delivery.status === 'ready-for-pickup'
|
||||
? `${theme.colors.warning}14`
|
||||
: `${theme.colors.primary}12`;
|
||||
const modeLabel = delivery.mode === 'home' ? 'Home delivery' : 'Branch pickup';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerCopy}>
|
||||
<Text style={[styles.eyebrow, { color: theme.colors.textSecondary }]}>
|
||||
Physical card delivery
|
||||
</Text>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{statusLabel}</Text>
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
{delivery.status === 'delivered'
|
||||
? `Completed on ${formatDate(delivery.lastUpdatedAt)}`
|
||||
: `Expected by ${formatDate(delivery.estimatedDate)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.badge, { backgroundColor: accentBackground }]}>
|
||||
<Ionicons name="cube-outline" size={14} color={accentColor} />
|
||||
<Text style={[styles.badgeText, { color: accentColor }]}>{modeLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Tracking ID
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{delivery.trackingId}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Destination
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={compact ? 1 : 2}
|
||||
style={[styles.metricValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{delivery.destinationLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.timelineRow}>
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < progressIndex;
|
||||
const isCurrent = index === progressIndex;
|
||||
const activeColor = isCompleted || isCurrent ? accentColor : theme.colors.cardBorder;
|
||||
const timelineDotStyle = {
|
||||
backgroundColor: isCompleted || isCurrent ? activeColor : 'transparent',
|
||||
borderColor: activeColor,
|
||||
};
|
||||
const timelineLineStyle = {
|
||||
backgroundColor: index < progressIndex ? accentColor : theme.colors.cardBorder,
|
||||
};
|
||||
|
||||
return (
|
||||
<View key={step.id} style={styles.timelineItem}>
|
||||
<View style={styles.timelineTopRow}>
|
||||
<View style={[styles.timelineDot, timelineDotStyle]}>
|
||||
{isCompleted ? (
|
||||
<Ionicons name="checkmark" size={10} color={theme.colors.white} />
|
||||
) : null}
|
||||
</View>
|
||||
{index !== steps.length - 1 ? (
|
||||
<View style={[styles.timelineLine, timelineLineStyle]} />
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.timelineLabel,
|
||||
{
|
||||
color:
|
||||
isCompleted || isCurrent
|
||||
? theme.colors.textPrimary
|
||||
: theme.colors.textMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{!compact ? (
|
||||
<View style={[styles.footerCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View style={styles.footerRow}>
|
||||
<Text style={[styles.footerLabel, { color: theme.colors.textSecondary }]}>
|
||||
Requested
|
||||
</Text>
|
||||
<Text style={[styles.footerValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(delivery.requestedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.footerRow}>
|
||||
<Text style={[styles.footerLabel, { color: theme.colors.textSecondary }]}>
|
||||
Last updated
|
||||
</Text>
|
||||
<Text style={[styles.footerValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(delivery.lastUpdatedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 14,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
headerCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 17,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
metricTile: {
|
||||
flex: 1,
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
timelineRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
timelineItem: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
timelineTopRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
timelineDot: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
timelineLine: {
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
timelineLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
lineHeight: 15,
|
||||
},
|
||||
footerCard: {
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 10,
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
footerLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footerValue: {
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
},
|
||||
});
|
||||
|
||||
export const CardDeliveryStatus = memo(CardDeliveryStatusComponent);
|
||||
@@ -0,0 +1,301 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import type { BankCard } from '../../../types/app';
|
||||
import { getCardStatusLabel } from '../utils/cardCopy';
|
||||
|
||||
interface CardItemProps {
|
||||
card: BankCard;
|
||||
index: number;
|
||||
scrollX: SharedValue<number>;
|
||||
cardWidth: number;
|
||||
itemSize: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const CardItemComponent = ({
|
||||
card,
|
||||
index,
|
||||
scrollX,
|
||||
cardWidth,
|
||||
itemSize,
|
||||
height = 220,
|
||||
}: CardItemProps) => {
|
||||
const flipProgress = useSharedValue(0);
|
||||
const statusLabel = getCardStatusLabel(card);
|
||||
const statusColor =
|
||||
card.status === 'blocked'
|
||||
? '#FFB3BC'
|
||||
: card.controls.frozen
|
||||
? '#FFE08C'
|
||||
: '#C8FACC';
|
||||
|
||||
const toggleFlip = () => {
|
||||
flipProgress.value = withTiming(flipProgress.value === 0 ? 1 : 0, { duration: 420 });
|
||||
};
|
||||
|
||||
const carouselStyle = useAnimatedStyle(() => {
|
||||
const inputRange = [(index - 1) * itemSize, index * itemSize, (index + 1) * itemSize];
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(scrollX.value, inputRange, [0.92, 1, 0.92], Extrapolation.CLAMP),
|
||||
},
|
||||
{
|
||||
translateY: interpolate(scrollX.value, inputRange, [12, 0, 12], Extrapolation.CLAMP),
|
||||
},
|
||||
],
|
||||
opacity: interpolate(scrollX.value, inputRange, [0.72, 1, 0.72], Extrapolation.CLAMP),
|
||||
};
|
||||
});
|
||||
|
||||
const frontStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ perspective: 1200 },
|
||||
{ rotateY: `${interpolate(flipProgress.value, [0, 1], [0, 180])}deg` },
|
||||
],
|
||||
}));
|
||||
|
||||
const backStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ perspective: 1200 },
|
||||
{ rotateY: `${interpolate(flipProgress.value, [0, 1], [180, 360])}deg` },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.frame, carouselStyle, { width: cardWidth, height }]}>
|
||||
<Pressable style={styles.flex} onPress={toggleFlip}>
|
||||
<Animated.View style={[styles.face, frontStyle]}>
|
||||
<LinearGradient colors={card.themeColors} style={styles.gradient}>
|
||||
<View style={styles.headerRow}>
|
||||
<View>
|
||||
<Text style={styles.brandName}>CBS Bank</Text>
|
||||
<Text style={styles.cardMeta}>{card.type.toUpperCase()} CARD</Text>
|
||||
</View>
|
||||
<View style={styles.statusBadge}>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||
<Text style={styles.statusBadgeText}>{statusLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.centerBlock}>
|
||||
<Text style={styles.cardName}>{card.name}</Text>
|
||||
<Text style={styles.cardNumber}>{card.maskedNumber}</Text>
|
||||
<Text style={styles.holderName}>{card.cardholderName}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footerRow}>
|
||||
<View>
|
||||
<Text style={styles.footerLabel}>VALID THRU</Text>
|
||||
<Text style={styles.footerValue}>{card.expiry}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.footerLabel}>NETWORK</Text>
|
||||
<Text style={styles.footerValue}>{card.network}</Text>
|
||||
</View>
|
||||
<View style={styles.flipHint}>
|
||||
<Ionicons name="sync-outline" size={14} color="rgba(255,255,255,0.85)" />
|
||||
<Text style={styles.flipText}>Flip</Text>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.face, styles.backFace, backStyle]}>
|
||||
<LinearGradient
|
||||
colors={[card.themeColors[2], card.themeColors[1], card.themeColors[0]]}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<View style={styles.magneticStrip} />
|
||||
<View style={styles.backSection}>
|
||||
<Text style={styles.backLabel}>PRIMARY LINKED ACCOUNT</Text>
|
||||
<Text style={styles.backValue}>{card.primaryLinkedAccountId}</Text>
|
||||
</View>
|
||||
<View style={styles.backGrid}>
|
||||
<View style={styles.backCell}>
|
||||
<Text style={styles.backLabel}>DAILY LIMIT</Text>
|
||||
<Text style={styles.backValue}>Rs {card.limits.daily.toLocaleString('en-IN')}</Text>
|
||||
</View>
|
||||
<View style={styles.backCell}>
|
||||
<Text style={styles.backLabel}>LINKED ACCOUNTS</Text>
|
||||
<Text style={styles.backValue}>{card.linkedAccountIds.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.backFooter}>
|
||||
<Text style={styles.backSupport}>24x7 card care · 1800 100 2244</Text>
|
||||
<Text style={styles.backSupport}>Tap card again to return</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
frame: {
|
||||
overflow: 'visible',
|
||||
},
|
||||
face: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backfaceVisibility: 'hidden',
|
||||
},
|
||||
backFace: {
|
||||
zIndex: 2,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
borderRadius: 28,
|
||||
padding: 22,
|
||||
justifyContent: 'space-between',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
brandName: {
|
||||
color: 'white',
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
cardMeta: {
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.1,
|
||||
marginTop: 2,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.14)',
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusBadgeText: {
|
||||
color: 'white',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
centerBlock: {
|
||||
gap: 10,
|
||||
},
|
||||
cardName: {
|
||||
color: 'rgba(255,255,255,0.82)',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
cardNumber: {
|
||||
color: 'white',
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
holderName: {
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
gap: 12,
|
||||
},
|
||||
footerLabel: {
|
||||
color: 'rgba(255,255,255,0.68)',
|
||||
fontSize: 9,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
footerValue: {
|
||||
color: 'white',
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
marginTop: 4,
|
||||
},
|
||||
flipHint: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
flipText: {
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
magneticStrip: {
|
||||
height: 42,
|
||||
marginHorizontal: -22,
|
||||
backgroundColor: 'rgba(7, 19, 33, 0.8)',
|
||||
},
|
||||
backSection: {
|
||||
gap: 6,
|
||||
},
|
||||
backGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
backCell: {
|
||||
flex: 1,
|
||||
gap: 6,
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
},
|
||||
backLabel: {
|
||||
color: 'rgba(255,255,255,0.68)',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.9,
|
||||
},
|
||||
backValue: {
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
backFooter: {
|
||||
gap: 6,
|
||||
},
|
||||
backSupport: {
|
||||
color: 'rgba(255,255,255,0.82)',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export const CardItem = memo(CardItemComponent);
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
|
||||
import { Card } from '../../../components';
|
||||
import { SkeletonBlock } from '../../../components/Loader';
|
||||
|
||||
const CardsSkeletonComponent = () => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Card style={styles.heroCard}>
|
||||
<SkeletonBlock height={22} width="34%" />
|
||||
<SkeletonBlock height={36} width="68%" />
|
||||
<SkeletonBlock height={18} width="48%" />
|
||||
<View style={styles.bottomRow}>
|
||||
<SkeletonBlock height={14} width={70} />
|
||||
<SkeletonBlock height={14} width={90} />
|
||||
<SkeletonBlock height={14} width={60} />
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<View style={styles.metricRow}>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<Card key={item} style={styles.metricCard}>
|
||||
<SkeletonBlock height={18} width="42%" />
|
||||
<SkeletonBlock height={28} width="70%" />
|
||||
<SkeletonBlock height={14} width="55%" />
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Card style={styles.sectionCard}>
|
||||
<SkeletonBlock height={18} width="28%" />
|
||||
<SkeletonBlock height={14} width="64%" />
|
||||
<SkeletonBlock height={76} width="100%" />
|
||||
<SkeletonBlock height={76} width="100%" />
|
||||
</Card>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 18,
|
||||
},
|
||||
heroCard: {
|
||||
padding: 22,
|
||||
borderRadius: 28,
|
||||
gap: 14,
|
||||
},
|
||||
bottomRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 8,
|
||||
},
|
||||
metricRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
metricCard: {
|
||||
flex: 1,
|
||||
gap: 12,
|
||||
},
|
||||
sectionCard: {
|
||||
gap: 14,
|
||||
},
|
||||
});
|
||||
|
||||
export const CardsSkeleton = memo(CardsSkeletonComponent);
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeOutUp,
|
||||
Layout,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface ExpandableSectionProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
defaultExpanded?: boolean;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ExpandableSectionComponent = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
defaultExpanded = false,
|
||||
badge,
|
||||
children,
|
||||
}: ExpandableSectionProps) => {
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<Animated.View layout={Layout.springify().damping(25)}>
|
||||
<Card style={styles.card}>
|
||||
<Pressable style={styles.header} onPress={() => setExpanded((value) => !value)}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconWrap,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
]}
|
||||
>
|
||||
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{badge ? (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: theme.colors.textSecondary }]}>
|
||||
{badge}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Ionicons
|
||||
name={expanded ? 'chevron-up-outline' : 'chevron-down-outline'}
|
||||
size={18}
|
||||
color={theme.colors.textSecondary}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{expanded ? (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(220)}
|
||||
exiting={FadeOutUp.duration(180)}
|
||||
layout={Layout.springify().damping(25)}
|
||||
style={styles.body}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</Card>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: 20,
|
||||
gap: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
},
|
||||
badge: {
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
body: {
|
||||
gap: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export const ExpandableSection = memo(ExpandableSectionComponent);
|
||||
@@ -0,0 +1,331 @@
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
LayoutChangeEvent,
|
||||
PanResponder,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface LimitSliderProps {
|
||||
label: string;
|
||||
helperText?: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
formatValue?: (value: number) => string;
|
||||
onInteractionStart?: () => void;
|
||||
onInteractionEnd?: () => void;
|
||||
}
|
||||
|
||||
const THUMB_SIZE = 24;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const valueToProgress = (value: number, min: number, max: number) =>
|
||||
max === min ? 0 : (value - min) / (max - min);
|
||||
|
||||
const progressToValue = (
|
||||
nextProgress: number,
|
||||
min: number,
|
||||
max: number,
|
||||
step: number,
|
||||
) => {
|
||||
const steppedValue =
|
||||
min + Math.round((clamp(nextProgress, 0, 1) * (max - min)) / step) * step;
|
||||
|
||||
return clamp(steppedValue, min, max);
|
||||
};
|
||||
|
||||
const LimitSliderComponent = ({
|
||||
label,
|
||||
helperText,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1000,
|
||||
onChange,
|
||||
formatValue = (nextValue) => `Rs ${nextValue.toLocaleString('en-IN')}`,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
}: LimitSliderProps) => {
|
||||
const theme = useTheme();
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const trackWidthRef = useRef(0);
|
||||
const trackWidth = useSharedValue(0);
|
||||
const progress = useSharedValue(valueToProgress(value, min, max));
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayValue(value);
|
||||
progress.value = withTiming(valueToProgress(value, min, max), {
|
||||
duration: 160,
|
||||
});
|
||||
}, [max, min, progress, value]);
|
||||
|
||||
const commitValue = useCallback(
|
||||
(nextValue: number) => {
|
||||
onChange(clamp(nextValue, min, max));
|
||||
},
|
||||
[max, min, onChange],
|
||||
);
|
||||
|
||||
const onTrackLayout = (event: LayoutChangeEvent) => {
|
||||
trackWidthRef.current = event.nativeEvent.layout.width;
|
||||
trackWidth.value = event.nativeEvent.layout.width;
|
||||
};
|
||||
|
||||
const updateFromLocationX = useCallback(
|
||||
(
|
||||
locationX: number,
|
||||
{ animate = false, commit = false }: { animate?: boolean; commit?: boolean } = {},
|
||||
) => {
|
||||
const usableWidth = Math.max(trackWidthRef.current - THUMB_SIZE, 1);
|
||||
const nextProgress = clamp((locationX - THUMB_SIZE / 2) / usableWidth, 0, 1);
|
||||
const nextValue = progressToValue(nextProgress, min, max, step);
|
||||
|
||||
progress.value = animate ? withTiming(nextProgress, { duration: 120 }) : nextProgress;
|
||||
setDisplayValue(nextValue);
|
||||
|
||||
if (commit) {
|
||||
commitValue(nextValue);
|
||||
}
|
||||
},
|
||||
[commitValue, max, min, progress, step],
|
||||
);
|
||||
|
||||
const handleStepChange = useCallback(
|
||||
(direction: -1 | 1) => {
|
||||
const nextValue = clamp(value + direction * step, min, max);
|
||||
setDisplayValue(nextValue);
|
||||
progress.value = withTiming(valueToProgress(nextValue, min, max), {
|
||||
duration: 120,
|
||||
});
|
||||
commitValue(nextValue);
|
||||
},
|
||||
[commitValue, max, min, progress, step, value],
|
||||
);
|
||||
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onMoveShouldSetPanResponderCapture: (_, gestureState) =>
|
||||
Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 4,
|
||||
onMoveShouldSetPanResponder: (_, gestureState) =>
|
||||
Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 4,
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
onPanResponderGrant: (event) => {
|
||||
onInteractionStart?.();
|
||||
updateFromLocationX(event.nativeEvent.locationX);
|
||||
},
|
||||
onPanResponderMove: (event) => {
|
||||
updateFromLocationX(event.nativeEvent.locationX);
|
||||
},
|
||||
onPanResponderRelease: (event) => {
|
||||
updateFromLocationX(event.nativeEvent.locationX, { commit: true });
|
||||
onInteractionEnd?.();
|
||||
},
|
||||
onPanResponderTerminate: (event) => {
|
||||
updateFromLocationX(event.nativeEvent.locationX, { commit: true });
|
||||
onInteractionEnd?.();
|
||||
},
|
||||
}),
|
||||
[onInteractionEnd, onInteractionStart, updateFromLocationX],
|
||||
);
|
||||
|
||||
const decreaseDisabled = value <= min;
|
||||
const increaseDisabled = value >= max;
|
||||
const decreaseButtonStyle = {
|
||||
backgroundColor: decreaseDisabled ? theme.colors.surfaceMuted : `${theme.colors.primary}12`,
|
||||
opacity: decreaseDisabled ? 0.6 : 1,
|
||||
};
|
||||
const increaseButtonStyle = {
|
||||
backgroundColor: increaseDisabled ? theme.colors.surfaceMuted : `${theme.colors.primary}12`,
|
||||
opacity: increaseDisabled ? 0.6 : 1,
|
||||
};
|
||||
|
||||
const fillStyle = useAnimatedStyle(() => ({
|
||||
width: THUMB_SIZE / 2 + progress.value * Math.max(trackWidth.value - THUMB_SIZE, 0),
|
||||
}));
|
||||
|
||||
const thumbStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateX: progress.value * Math.max(trackWidth.value - THUMB_SIZE, 0),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>{label}</Text>
|
||||
{helperText ? (
|
||||
<Text style={[styles.helperText, { color: theme.colors.textSecondary }]}>
|
||||
{helperText}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.value, { color: theme.colors.primary }]}>
|
||||
{formatValue(displayValue)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.sliderRow}>
|
||||
<Pressable
|
||||
onPress={() => handleStepChange(-1)}
|
||||
onPressIn={onInteractionStart}
|
||||
onPressOut={onInteractionEnd}
|
||||
disabled={decreaseDisabled}
|
||||
style={[styles.stepButton, decreaseButtonStyle]}
|
||||
>
|
||||
<Ionicons name="remove-outline" size={18} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.trackColumn}>
|
||||
<Pressable
|
||||
onPress={(event) =>
|
||||
updateFromLocationX(event.nativeEvent.locationX, { animate: true, commit: true })
|
||||
}
|
||||
onPressIn={onInteractionStart}
|
||||
onPressOut={onInteractionEnd}
|
||||
>
|
||||
<View style={styles.trackFrame} onLayout={onTrackLayout} {...panResponder.panHandlers}>
|
||||
<View
|
||||
style={[
|
||||
styles.trackBackground,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.trackFill,
|
||||
fillStyle,
|
||||
{ backgroundColor: theme.colors.primary },
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.thumb,
|
||||
thumbStyle,
|
||||
{
|
||||
borderColor: `${theme.colors.primary}30`,
|
||||
backgroundColor: theme.colors.white,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.scale}>
|
||||
<Text style={[styles.scaleText, { color: theme.colors.textMuted }]}>
|
||||
{formatValue(min)}
|
||||
</Text>
|
||||
<Text style={[styles.scaleText, { color: theme.colors.textMuted }]}>
|
||||
{formatValue(max)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => handleStepChange(1)}
|
||||
onPressIn={onInteractionStart}
|
||||
onPressOut={onInteractionEnd}
|
||||
disabled={increaseDisabled}
|
||||
style={[styles.stepButton, increaseButtonStyle]}
|
||||
>
|
||||
<Ionicons name="add-outline" size={18} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
value: {
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
},
|
||||
sliderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
trackColumn: {
|
||||
flex: 1,
|
||||
gap: 10,
|
||||
},
|
||||
stepButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
trackFrame: {
|
||||
height: THUMB_SIZE,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
trackBackground: {
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
},
|
||||
trackFill: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 7,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
},
|
||||
thumb: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: THUMB_SIZE,
|
||||
height: THUMB_SIZE,
|
||||
borderRadius: THUMB_SIZE / 2,
|
||||
borderWidth: 4,
|
||||
},
|
||||
scale: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
scaleText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export const LimitSlider = memo(LimitSliderComponent);
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
interpolateColor,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ToggleSwitchComponent = ({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
}: ToggleSwitchProps) => {
|
||||
const theme = useTheme();
|
||||
const progress = useSharedValue(value ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withTiming(value ? 1 : 0, { duration: 180 });
|
||||
}, [progress, value]);
|
||||
|
||||
const trackStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[theme.colors.surfaceMuted, theme.colors.primary],
|
||||
),
|
||||
}));
|
||||
|
||||
const thumbStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: withTiming(progress.value * 22, { duration: 180 }) }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={() => onValueChange(!value)}
|
||||
style={[
|
||||
styles.row,
|
||||
disabled && styles.rowDisabled,
|
||||
{
|
||||
borderBottomColor: theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>{label}</Text>
|
||||
{description ? (
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.track,
|
||||
trackStyle,
|
||||
{ borderRadius: theme.radius.pill },
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.thumb,
|
||||
thumbStyle,
|
||||
{ backgroundColor: theme.colors.white },
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
gap: 16,
|
||||
},
|
||||
rowDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
track: {
|
||||
width: 52,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
thumb: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
},
|
||||
});
|
||||
|
||||
export const ToggleSwitch = memo(ToggleSwitchComponent);
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { CardTransaction } from '../../../types/app';
|
||||
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
|
||||
|
||||
interface TransactionItemProps {
|
||||
item: CardTransaction;
|
||||
showDivider?: boolean;
|
||||
}
|
||||
|
||||
const TransactionItemComponent = ({
|
||||
item,
|
||||
showDivider = true,
|
||||
}: TransactionItemProps) => {
|
||||
const theme = useTheme();
|
||||
const isCredit = item.direction === 'credit';
|
||||
|
||||
const statusTone =
|
||||
item.status === 'completed'
|
||||
? theme.colors.success
|
||||
: item.status === 'pending'
|
||||
? theme.colors.warning
|
||||
: theme.colors.danger;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.row,
|
||||
showDivider && styles.rowDivider,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.iconFrame,
|
||||
{
|
||||
backgroundColor: isCredit ? `${theme.colors.success}12` : `${theme.colors.primary}12`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={isCredit ? 'arrow-down-outline' : 'arrow-up-outline'}
|
||||
size={18}
|
||||
color={isCredit ? theme.colors.success : theme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.copy}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={[styles.merchant, { color: theme.colors.textPrimary }]}>{item.merchant}</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.amount,
|
||||
{ color: isCredit ? theme.colors.success : theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{isCredit ? '+' : '-'}
|
||||
{formatCurrency(item.amount)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.rowBottom}>
|
||||
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>{item.category}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.metaDot,
|
||||
{ backgroundColor: theme.colors.textMuted },
|
||||
]}
|
||||
/>
|
||||
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>
|
||||
{formatDate(item.occurredAt)} · {formatTime(item.occurredAt)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statusPill, { backgroundColor: `${statusTone}12` }]}>
|
||||
<Text style={[styles.statusText, { color: statusTone }]}>
|
||||
{item.status.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
rowDivider: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(15, 23, 42, 0.08)',
|
||||
},
|
||||
iconFrame: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
rowTop: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
merchant: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
amount: {
|
||||
fontSize: 15,
|
||||
fontWeight: '900',
|
||||
textAlign: 'right',
|
||||
},
|
||||
rowBottom: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
metaDot: {
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
statusPill: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
export const TransactionItem = memo(TransactionItemComponent);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { selectSelectedCard } from '../store/cardsSelectors';
|
||||
import { cardsActions } from '../store/cardsSlice';
|
||||
|
||||
export const useCardControls = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const selectedCard = useAppSelector(selectSelectedCard);
|
||||
|
||||
const updateControls = useCallback(
|
||||
(changes: Partial<NonNullable<typeof selectedCard>['controls']>) => {
|
||||
if (!selectedCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(cardsActions.updateCardControls({ cardId: selectedCard.id, changes }));
|
||||
},
|
||||
[dispatch, selectedCard],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedCard,
|
||||
updateControls,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { CardTransaction, TransactionDirection } from '../../../types/app';
|
||||
|
||||
export type TransactionDateFilter = '7d' | '30d' | '90d' | 'all';
|
||||
export type TransactionAmountFilter = 'all' | 'under5k' | '5kto10k' | 'above10k';
|
||||
export type TransactionTypeFilter = 'all' | TransactionDirection;
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export const useCardTransactionFilters = (transactions: CardTransaction[]) => {
|
||||
const [dateFilter, setDateFilter] = useState<TransactionDateFilter>('30d');
|
||||
const [amountFilter, setAmountFilter] = useState<TransactionAmountFilter>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<TransactionTypeFilter>('all');
|
||||
|
||||
const filteredTransactions = useMemo(() => {
|
||||
const orderedTransactions = [...transactions].sort(
|
||||
(left, right) =>
|
||||
new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(),
|
||||
);
|
||||
|
||||
const referenceTime =
|
||||
orderedTransactions.length > 0 && orderedTransactions[0]
|
||||
? new Date(orderedTransactions[0].occurredAt).getTime()
|
||||
: Date.now();
|
||||
|
||||
return orderedTransactions.filter((transaction) => {
|
||||
const transactionTime = new Date(transaction.occurredAt).getTime();
|
||||
const transactionAmount = transaction.amount;
|
||||
|
||||
const passesDate =
|
||||
dateFilter === 'all'
|
||||
? true
|
||||
: referenceTime - transactionTime <=
|
||||
(dateFilter === '7d' ? 7 : dateFilter === '30d' ? 30 : 90) * DAY_MS;
|
||||
|
||||
const passesAmount =
|
||||
amountFilter === 'all'
|
||||
? true
|
||||
: amountFilter === 'under5k'
|
||||
? transactionAmount < 5000
|
||||
: amountFilter === '5kto10k'
|
||||
? transactionAmount >= 5000 && transactionAmount <= 10000
|
||||
: transactionAmount > 10000;
|
||||
|
||||
const passesType = typeFilter === 'all' ? true : transaction.direction === typeFilter;
|
||||
|
||||
return passesDate && passesAmount && passesType;
|
||||
});
|
||||
}, [amountFilter, dateFilter, transactions, typeFilter]);
|
||||
|
||||
return {
|
||||
dateFilter,
|
||||
amountFilter,
|
||||
typeFilter,
|
||||
setDateFilter,
|
||||
setAmountFilter,
|
||||
setTypeFilter,
|
||||
filteredTransactions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,627 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { CardsStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
|
||||
|
||||
type Props = NativeStackScreenProps<CardsStackParamList, 'ApplyCreditCard'>;
|
||||
|
||||
const CREDIT_PRODUCTS = [
|
||||
{
|
||||
id: 'infinite_rewards',
|
||||
title: 'Infinite Rewards',
|
||||
subtitle: 'Premium travel, dining, and lifestyle perks',
|
||||
annualFee: 1499,
|
||||
joiningBenefit: '5,000 welcome points',
|
||||
estimatedLimit: 350000,
|
||||
benefits: ['Lounge access', 'Dining cashback', 'Reward multipliers'],
|
||||
},
|
||||
{
|
||||
id: 'cashback_plus',
|
||||
title: 'Cashback Plus',
|
||||
subtitle: 'Simple everyday savings on online and POS spends',
|
||||
annualFee: 499,
|
||||
joiningBenefit: 'Amazon voucher worth Rs 1,000',
|
||||
estimatedLimit: 180000,
|
||||
benefits: ['Online cashback', 'EMI offers', 'Fuel surcharge waiver'],
|
||||
},
|
||||
{
|
||||
id: 'travel_signature',
|
||||
title: 'Travel Signature',
|
||||
subtitle: 'Built for frequent flyers and hotel-heavy lifestyles',
|
||||
annualFee: 1999,
|
||||
joiningBenefit: 'Domestic flight voucher',
|
||||
estimatedLimit: 500000,
|
||||
benefits: ['Air miles boost', 'Travel insurance', 'Hotel privileges'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const EMPLOYMENT_TYPES = [
|
||||
{ id: 'salaried', label: 'Salaried' },
|
||||
{ id: 'self-employed', label: 'Self-employed' },
|
||||
] as const;
|
||||
|
||||
type CreditProductId = (typeof CREDIT_PRODUCTS)[number]['id'];
|
||||
|
||||
export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const accounts = useAppSelector((state) => state.account.accounts);
|
||||
const profile = useAppSelector((state) => state.user.profile);
|
||||
|
||||
const [selectedProductId, setSelectedProductId] = useState<CreditProductId>(
|
||||
CREDIT_PRODUCTS[0].id,
|
||||
);
|
||||
const [selectedRepaymentAccountId, setSelectedRepaymentAccountId] =
|
||||
useState(accounts[0]?.id ?? '');
|
||||
const [employmentType, setEmploymentType] =
|
||||
useState<(typeof EMPLOYMENT_TYPES)[number]['id']>('salaried');
|
||||
const [annualIncome, setAnnualIncome] = useState('');
|
||||
const [employerName, setEmployerName] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [consentAccepted, setConsentAccepted] = useState(false);
|
||||
|
||||
const selectedProduct = useMemo(
|
||||
() => CREDIT_PRODUCTS.find((product) => product.id === selectedProductId) ?? CREDIT_PRODUCTS[0],
|
||||
[selectedProductId],
|
||||
);
|
||||
|
||||
const selectedRepaymentAccount = useMemo(
|
||||
() =>
|
||||
accounts.find((account) => account.id === selectedRepaymentAccountId) ?? accounts[0],
|
||||
[selectedRepaymentAccountId, accounts],
|
||||
);
|
||||
|
||||
const handleApply = () => {
|
||||
const parsedIncome = Number(annualIncome.replace(/[^0-9]/g, ''));
|
||||
|
||||
if (!parsedIncome || parsedIncome < 300000) {
|
||||
toast(
|
||||
'Income too low',
|
||||
'error',
|
||||
'Enter an annual income of at least Rs 3,00,000 to continue.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!employerName.trim()) {
|
||||
toast('Employer required', 'error', 'Enter your employer or business name.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!city.trim()) {
|
||||
toast('City required', 'error', 'Enter your current city to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedRepaymentAccount) {
|
||||
toast('Account required', 'error', 'Select a repayment account for your credit card.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!consentAccepted) {
|
||||
toast(
|
||||
'Consent required',
|
||||
'error',
|
||||
'Accept the bureau and KYC consent to submit your application.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast(
|
||||
'Application submitted',
|
||||
'success',
|
||||
`Your ${selectedProduct.title} credit card application is under review.`,
|
||||
);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Apply credit card"
|
||||
subtitle="Check your eligibility and submit a new credit card request"
|
||||
>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.heroIcon,
|
||||
{ backgroundColor: `${theme.colors.warning}14` },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={22} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
Pre-qualified journey
|
||||
</Text>
|
||||
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Use your CBS relationship to fast-track the card application experience.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.heroStats}>
|
||||
<View
|
||||
style={[
|
||||
styles.heroStat,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
|
||||
Customer ID
|
||||
</Text>
|
||||
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
|
||||
{profile?.customerId || ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.heroStat,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
|
||||
Est. limit
|
||||
</Text>
|
||||
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(selectedProduct.estimatedLimit)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Choose your credit card
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Pick a credit card that matches how you spend and travel.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productList}>
|
||||
{CREDIT_PRODUCTS.map((product) => {
|
||||
const selected = product.id === selectedProductId;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={product.id}
|
||||
onPress={() => setSelectedProductId(product.id)}
|
||||
style={[
|
||||
styles.productCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.productHeader}>
|
||||
<View style={styles.productCopy}>
|
||||
<Text style={[styles.productTitle, { color: theme.colors.textPrimary }]}>
|
||||
{product.title}
|
||||
</Text>
|
||||
<Text style={[styles.productSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{product.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={theme.colors.primary} />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.productMetaRow}>
|
||||
<Text style={[styles.productMeta, { color: theme.colors.textPrimary }]}>
|
||||
Annual fee {formatCurrency(product.annualFee)}
|
||||
</Text>
|
||||
<Text style={[styles.productMeta, { color: theme.colors.accentStrong }]}>
|
||||
{product.joiningBenefit}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.benefitList}>
|
||||
{product.benefits.map((benefit) => (
|
||||
<View key={benefit} style={styles.benefitItem}>
|
||||
<Ionicons name="checkmark-outline" size={16} color={theme.colors.accentStrong} />
|
||||
<Text style={[styles.benefitText, { color: theme.colors.textSecondary }]}>
|
||||
{benefit}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Eligibility details
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
These details are used to estimate the right limit and product fit.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.segmentRow}>
|
||||
{EMPLOYMENT_TYPES.map((option) => {
|
||||
const selected = option.id === employmentType;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onPress={() => setEmploymentType(option.id)}
|
||||
style={[
|
||||
styles.segmentButton,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.segmentText,
|
||||
{ color: selected ? theme.colors.primary : theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Annual income"
|
||||
keyboardType="number-pad"
|
||||
value={annualIncome}
|
||||
onChangeText={setAnnualIncome}
|
||||
placeholder="e.g. 1200000"
|
||||
/>
|
||||
<Input
|
||||
label={employmentType === 'salaried' ? 'Employer name' : 'Business name'}
|
||||
value={employerName}
|
||||
onChangeText={setEmployerName}
|
||||
placeholder="Enter company or business name"
|
||||
/>
|
||||
<Input
|
||||
label="Current city"
|
||||
value={city}
|
||||
onChangeText={setCity}
|
||||
placeholder="Enter current city"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Repayment account
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Choose the CBS account you want to link for repayments and autopay.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productList}>
|
||||
{accounts.map((account) => {
|
||||
const selected = account.id === selectedRepaymentAccountId;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={account.id}
|
||||
onPress={() => setSelectedRepaymentAccountId(account.id)}
|
||||
style={[
|
||||
styles.accountCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.accent}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.accent}40` : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.productHeader}>
|
||||
<View style={styles.productCopy}>
|
||||
<Text style={[styles.productTitle, { color: theme.colors.textPrimary }]}>
|
||||
{account.type} account
|
||||
</Text>
|
||||
<Text style={[styles.productSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{account.maskedNumber}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={theme.colors.accentStrong} />
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.productMeta, { color: theme.colors.textPrimary }]}>
|
||||
Available balance {formatCurrency(account.availableBalance)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Final consent
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
We will use your submitted details for bureau checks, KYC, and underwriting review.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setConsentAccepted((current) => !current)}
|
||||
style={[
|
||||
styles.consentRow,
|
||||
{ backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.consentCheck,
|
||||
{
|
||||
backgroundColor: consentAccepted ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: consentAccepted ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{consentAccepted ? (
|
||||
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.consentText, { color: theme.colors.textPrimary }]}>
|
||||
I authorize CBS Bank to review my bureau, KYC, and income details for this credit card
|
||||
application.
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.summaryCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Application summary
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
A quick review of the selected product before submission.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Product
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{selectedProduct.title}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated limit
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(selectedProduct.estimatedLimit)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Annual fee
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(selectedProduct.annualFee)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Repayment account
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{selectedRepaymentAccount?.maskedNumber ?? 'Not selected'}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Submit credit card application"
|
||||
onPress={handleApply}
|
||||
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<View style={{ height: 60 }} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
gap: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 18,
|
||||
},
|
||||
heroRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
},
|
||||
heroIcon: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
},
|
||||
heroStats: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
heroStat: {
|
||||
flex: 1,
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
heroStatLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroStatValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
formCard: {
|
||||
gap: 14,
|
||||
},
|
||||
sectionHeader: {
|
||||
gap: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
},
|
||||
productList: {
|
||||
gap: 12,
|
||||
},
|
||||
productCard: {
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
},
|
||||
accountCard: {
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
},
|
||||
productHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
productCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
productTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
productSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
productMetaRow: {
|
||||
gap: 4,
|
||||
},
|
||||
productMeta: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
benefitList: {
|
||||
gap: 8,
|
||||
},
|
||||
benefitItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
benefitText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
segmentRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
segmentButton: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
segmentText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
consentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
borderWidth: 1,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
},
|
||||
consentCheck: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 1,
|
||||
},
|
||||
consentText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
},
|
||||
summaryCard: {
|
||||
gap: 14,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
summaryValue: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, EmptyState, ScreenShell } from '../../../components';
|
||||
import { selectAllAccounts } from '../../home/store/accountSelectors';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { CardsStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
|
||||
import { CardCarousel } from '../components/CardCarousel';
|
||||
import { CardDeliveryStatus } from '../components/CardDeliveryStatus';
|
||||
import { CardsSkeleton } from '../components/CardsSkeleton';
|
||||
import { TransactionItem } from '../components/TransactionItem';
|
||||
import {
|
||||
selectCards,
|
||||
selectCardsStatus,
|
||||
selectSelectedCard,
|
||||
selectSelectedCardIndex,
|
||||
} from '../store/cardsSelectors';
|
||||
import { cardsActions } from '../store/cardsSlice';
|
||||
|
||||
type Props = NativeStackScreenProps<CardsStackParamList, 'CardsOverview'>;
|
||||
|
||||
type CardServiceHubProps = {
|
||||
onRequestDebitCard: () => void;
|
||||
onApplyCreditCard: () => void;
|
||||
};
|
||||
|
||||
const CardServiceHub = ({
|
||||
onRequestDebitCard,
|
||||
onApplyCreditCard,
|
||||
}: CardServiceHubProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card style={styles.serviceHubCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Card services
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Need a new card? Request a physical debit card or start a fresh credit card application.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.serviceList}>
|
||||
<View
|
||||
style={[
|
||||
styles.servicePanel,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<View style={styles.servicePanelHeader}>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceIconWrap,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="card-outline" size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.serviceCopy}>
|
||||
<Text style={[styles.serviceEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
Debit card
|
||||
</Text>
|
||||
<Text style={[styles.serviceTitle, { color: theme.colors.textPrimary }]}>
|
||||
Request physical card
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.serviceDescription, { color: theme.colors.textSecondary }]}>
|
||||
Order a new physical debit card linked to your savings or salary account.
|
||||
</Text>
|
||||
<View style={styles.serviceTagRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.accent}12` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.accentStrong }]}>
|
||||
RuPay
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.primary }]}>Visa</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.warning}16` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.warning }]}>
|
||||
Mastercard
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
title="Request debit card"
|
||||
variant="secondary"
|
||||
onPress={onRequestDebitCard}
|
||||
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.servicePanel,
|
||||
{ backgroundColor: `${theme.colors.primary}08` },
|
||||
]}
|
||||
>
|
||||
<View style={styles.servicePanelHeader}>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceIconWrap,
|
||||
{ backgroundColor: `${theme.colors.warning}16` },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={18} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.serviceCopy}>
|
||||
<Text style={[styles.serviceEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
Credit card
|
||||
</Text>
|
||||
<Text style={[styles.serviceTitle, { color: theme.colors.textPrimary }]}>
|
||||
Apply for a new card
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.serviceDescription, { color: theme.colors.textSecondary }]}>
|
||||
Compare premium variants, check eligibility, and submit your credit card application.
|
||||
</Text>
|
||||
<View style={styles.serviceTagRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.primary }]}>
|
||||
Rewards
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.accent}12` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.accentStrong }]}>
|
||||
Cashback
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.warning}16` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.warning }]}>
|
||||
Travel
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
title="Apply credit card"
|
||||
onPress={onApplyCreditCard}
|
||||
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const cards = useAppSelector(selectCards);
|
||||
const cardsStatus = useAppSelector(selectCardsStatus);
|
||||
const selectedCard = useAppSelector(selectSelectedCard);
|
||||
const selectedIndex = useAppSelector(selectSelectedCardIndex);
|
||||
const accounts = useAppSelector((state) => state.account.accounts);
|
||||
|
||||
const selectedTransactions = useMemo(
|
||||
() =>
|
||||
selectedCard
|
||||
? [...selectedCard.transactions].sort(
|
||||
(left, right) =>
|
||||
new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(),
|
||||
)
|
||||
: [],
|
||||
[selectedCard],
|
||||
);
|
||||
|
||||
const previewTransactions = selectedTransactions.slice(0, 3);
|
||||
const budgetUsage =
|
||||
selectedCard && selectedCard.spendInsight.budget > 0
|
||||
? selectedCard.spendInsight.totalSpent / selectedCard.spendInsight.budget
|
||||
: 0;
|
||||
|
||||
const topCategories = selectedCard?.spendInsight.categories.slice(0, 4) ?? [];
|
||||
const maxCategorySpend = Math.max(...topCategories.map((item) => item.amount), 1);
|
||||
const primaryLinkedAccount = useMemo(
|
||||
() =>
|
||||
selectedCard
|
||||
? accounts.find((account) => account.id === selectedCard.primaryLinkedAccountId) ?? null
|
||||
: null,
|
||||
[selectedCard, accounts],
|
||||
);
|
||||
|
||||
const handleRequestDebitCard = () => navigation.navigate('RequestPhysicalCard');
|
||||
const handleApplyCreditCard = () => navigation.navigate('ApplyCreditCard');
|
||||
|
||||
const handleToggleFreeze = () => {
|
||||
if (!selectedCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
cardsActions.updateCardControls({
|
||||
cardId: selectedCard.id,
|
||||
changes: { frozen: !selectedCard.controls.frozen },
|
||||
}),
|
||||
);
|
||||
|
||||
toast(
|
||||
selectedCard.controls.frozen ? 'Card resumed' : 'Card frozen',
|
||||
'success',
|
||||
selectedCard.controls.frozen
|
||||
? 'Transactions are available again.'
|
||||
: 'Transactions are temporarily paused for this card.',
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleBlock = () => {
|
||||
if (!selectedCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStatus = selectedCard.status === 'blocked' ? 'active' : 'blocked';
|
||||
dispatch(cardsActions.setCardStatus({ cardId: selectedCard.id, status: nextStatus }));
|
||||
toast(
|
||||
nextStatus === 'blocked' ? 'Card blocked' : 'Card unblocked',
|
||||
nextStatus === 'blocked' ? 'error' : 'success',
|
||||
nextStatus === 'blocked'
|
||||
? 'This card is now blocked for all new transactions.'
|
||||
: 'This card is active again.',
|
||||
);
|
||||
};
|
||||
|
||||
if (cardsStatus === 'loading') {
|
||||
return (
|
||||
<ScreenShell title="Cards" subtitle="Premium controls, insights, and secure card tools">
|
||||
<CardsSkeleton />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cards.length || !selectedCard) {
|
||||
return (
|
||||
<ScreenShell title="Cards" subtitle="Premium controls, insights, and secure card tools">
|
||||
<View style={styles.emptyStateWrap}>
|
||||
<EmptyState
|
||||
title="No cards yet"
|
||||
description="Request a physical debit card or start a new credit card application from one place."
|
||||
actionLabel="Request debit card"
|
||||
onAction={handleRequestDebitCard}
|
||||
/>
|
||||
<CardServiceHub
|
||||
onRequestDebitCard={handleRequestDebitCard}
|
||||
onApplyCreditCard={handleApplyCreditCard}
|
||||
/>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Cards"
|
||||
subtitle="Premium controls, insights, and secure card tools"
|
||||
scrollable={false}
|
||||
>
|
||||
<View style={styles.flex}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.heroCopy}>
|
||||
<View>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
Selected wallet card
|
||||
</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
{selectedCard.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.heroBadge,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={14} color={theme.colors.primary} />
|
||||
<Text style={[styles.heroBadgeText, { color: theme.colors.primary }]}>
|
||||
{selectedCard.isDefault ? 'Default card' : 'Secondary card'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<CardCarousel
|
||||
cards={cards}
|
||||
selectedIndex={selectedIndex}
|
||||
onSnapToCard={(cardId) => dispatch(cardsActions.setSelectedCard(cardId))}
|
||||
/>
|
||||
|
||||
{selectedCard.physicalCardDelivery ? (
|
||||
<Card style={styles.deliveryCard}>
|
||||
<CardDeliveryStatus delivery={selectedCard.physicalCardDelivery} compact />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<View style={styles.metricGrid}>
|
||||
<Card style={styles.metricCard}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
{selectedCard.type === 'Credit' ? 'Available limit' : 'Linked account'}
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{selectedCard.type === 'Credit'
|
||||
? formatCompactCurrency(selectedCard.availableLimit)
|
||||
: primaryLinkedAccount?.maskedNumber ?? 'Not linked'}
|
||||
</Text>
|
||||
<Text style={[styles.metricSubtext, { color: theme.colors.textMuted }]}>
|
||||
{selectedCard.type === 'Credit'
|
||||
? `Network ${selectedCard.network}`
|
||||
: primaryLinkedAccount
|
||||
? `${primaryLinkedAccount.type} account`
|
||||
: `Network ${selectedCard.network}`}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.metricCard}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Spend today
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(selectedCard.spendToday)}
|
||||
</Text>
|
||||
<Text style={[styles.metricSubtext, { color: theme.colors.textMuted }]}>
|
||||
{selectedCard.controls.frozen ? 'Temporarily frozen' : 'Live spend tracking'}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={[styles.metricCard, styles.metricCardWide]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Rewards balance
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{selectedCard.rewards.points.toLocaleString('en-IN')} pts
|
||||
</Text>
|
||||
<Text style={[styles.metricSubtext, { color: theme.colors.textMuted }]}>
|
||||
Redeem value {formatCurrency(selectedCard.rewards.redeemValue)}
|
||||
</Text>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
<Card style={styles.actionsCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Quick controls
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Manage your selected card instantly
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionsGrid}>
|
||||
<Button
|
||||
title="View details"
|
||||
fullWidth={false}
|
||||
style={styles.actionButton}
|
||||
onPress={() => navigation.navigate('CardDetails', { cardId: selectedCard.id })}
|
||||
leftAccessory={
|
||||
<Ionicons name="card-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
title={selectedCard.controls.frozen ? 'Unfreeze' : 'Freeze'}
|
||||
variant="secondary"
|
||||
fullWidth={false}
|
||||
style={styles.actionButton}
|
||||
onPress={handleToggleFreeze}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
name={selectedCard.controls.frozen ? 'play-circle-outline' : 'snow-outline'}
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
title={selectedCard.status === 'blocked' ? 'Unblock' : 'Block card'}
|
||||
variant={selectedCard.status === 'blocked' ? 'secondary' : 'danger'}
|
||||
fullWidth={false}
|
||||
style={styles.actionButton}
|
||||
onPress={handleToggleBlock}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
name={selectedCard.status === 'blocked' ? 'lock-open-outline' : 'lock-closed-outline'}
|
||||
size={18}
|
||||
color={
|
||||
selectedCard.status === 'blocked' ? theme.colors.textPrimary : theme.colors.white
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<CardServiceHub
|
||||
onRequestDebitCard={handleRequestDebitCard}
|
||||
onApplyCreditCard={handleApplyCreditCard}
|
||||
/>
|
||||
|
||||
<Card style={styles.insightCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Spending pulse
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{selectedCard.spendInsight.monthLabel} budget usage
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressWrap}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressTrack,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: `${Math.min(budgetUsage * 100, 100)}%`,
|
||||
backgroundColor:
|
||||
budgetUsage > 0.85 ? theme.colors.warning : theme.colors.accent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.progressText, { color: theme.colors.textSecondary }]}>
|
||||
{formatCurrency(selectedCard.spendInsight.totalSpent)} of{' '}
|
||||
{formatCurrency(selectedCard.spendInsight.budget)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.categoryChart}>
|
||||
{topCategories.map((category) => (
|
||||
<View key={category.id} style={styles.categoryColumn}>
|
||||
<View
|
||||
style={[
|
||||
styles.categoryBar,
|
||||
{
|
||||
height: 28 + (category.amount / maxCategorySpend) * 56,
|
||||
backgroundColor: category.color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text style={[styles.categoryLabel, { color: theme.colors.textSecondary }]}>
|
||||
{category.label}
|
||||
</Text>
|
||||
<Text style={[styles.categoryAmount, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(category.amount)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.transactionsCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Recent card transactions
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.navigate('CardDetails', { cardId: selectedCard.id })}>
|
||||
<Text style={[styles.linkText, { color: theme.colors.primary }]}>See all</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{previewTransactions.length ? (
|
||||
previewTransactions.map((transaction, index) => (
|
||||
<TransactionItem
|
||||
key={transaction.id}
|
||||
item={transaction}
|
||||
showDivider={index !== previewTransactions.length - 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No card transactions"
|
||||
description="Your recent card activity will appear here once you start spending."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card style={styles.rewardsCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Rewards and offers
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Two live perks on this card
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.offerList}>
|
||||
{selectedCard.offers.slice(0, 2).map((offer) => (
|
||||
<View
|
||||
key={offer.id}
|
||||
style={[
|
||||
styles.offerCard,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.offerMerchant, { color: theme.colors.primary }]}>
|
||||
{offer.merchant}
|
||||
</Text>
|
||||
<Text style={[styles.offerTitle, { color: theme.colors.textPrimary }]}>
|
||||
{offer.title}
|
||||
</Text>
|
||||
<Text style={[styles.offerSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{offer.subtitle}
|
||||
</Text>
|
||||
<Text style={[styles.offerCashback, { color: theme.colors.accentStrong }]}>
|
||||
{offer.cashback}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<View style={styles.footerSpace} />
|
||||
</ScrollView>
|
||||
|
||||
<Pressable
|
||||
onPress={handleRequestDebitCard}
|
||||
style={[
|
||||
styles.fab,
|
||||
{ backgroundColor: theme.colors.primary },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="add-outline" size={24} color={theme.colors.white} />
|
||||
<Text style={styles.fabLabel}>Request Card</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.bottomInset} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 120,
|
||||
},
|
||||
emptyStateWrap: {
|
||||
gap: 20,
|
||||
},
|
||||
heroCopy: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroEyebrow: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
heroBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
heroBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
metricGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
deliveryCard: {
|
||||
gap: 14,
|
||||
},
|
||||
metricCard: {
|
||||
width: '48%',
|
||||
gap: 8,
|
||||
},
|
||||
metricCardWide: {
|
||||
width: '100%',
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.9,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 22,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
metricSubtext: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actionsCard: {
|
||||
gap: 16,
|
||||
},
|
||||
serviceHubCard: {
|
||||
gap: 16,
|
||||
},
|
||||
sectionHeader: {
|
||||
gap: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
width: '48%',
|
||||
},
|
||||
serviceList: {
|
||||
gap: 12,
|
||||
},
|
||||
servicePanel: {
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
servicePanelHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
serviceIconWrap: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
serviceCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
serviceEyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
serviceTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
serviceDescription: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
serviceTagRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
serviceTag: {
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
serviceTagText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
insightCard: {
|
||||
gap: 18,
|
||||
},
|
||||
progressWrap: {
|
||||
gap: 10,
|
||||
},
|
||||
progressTrack: {
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
categoryChart: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
categoryColumn: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
categoryBar: {
|
||||
width: '100%',
|
||||
borderRadius: 16,
|
||||
},
|
||||
categoryLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
categoryAmount: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
},
|
||||
transactionsCard: {
|
||||
gap: 4,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
rewardsCard: {
|
||||
gap: 16,
|
||||
},
|
||||
offerList: {
|
||||
gap: 12,
|
||||
},
|
||||
offerCard: {
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
gap: 6,
|
||||
},
|
||||
offerMerchant: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.9,
|
||||
},
|
||||
offerTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
offerSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
offerCashback: {
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
},
|
||||
footerSpace: {
|
||||
height: 24,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
bottom: 22,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 18,
|
||||
elevation: 10,
|
||||
},
|
||||
fabLabel: {
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
bottomInset: {
|
||||
height: 60,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { CardsStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { CardNetwork } from '../../../types/app';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { selectCards } from '../store/cardsSelectors';
|
||||
import { cardsActions } from '../store/cardsSlice';
|
||||
import { createMockPhysicalCard } from '../utils/cardCopy';
|
||||
|
||||
type Props = NativeStackScreenProps<CardsStackParamList, 'RequestPhysicalCard'>;
|
||||
|
||||
const NETWORK_OPTIONS: Array<{
|
||||
id: CardNetwork;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
}> = [
|
||||
{
|
||||
id: 'RuPay',
|
||||
title: 'RuPay',
|
||||
subtitle: 'Great for domestic everyday payments',
|
||||
icon: 'shield-checkmark-outline',
|
||||
},
|
||||
{
|
||||
id: 'VISA',
|
||||
title: 'Visa',
|
||||
subtitle: 'Wider acceptance for online and travel use',
|
||||
icon: 'globe-outline',
|
||||
},
|
||||
{
|
||||
id: 'Mastercard',
|
||||
title: 'Mastercard',
|
||||
subtitle: 'Balanced rewards and global acceptance',
|
||||
icon: 'flash-outline',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const DELIVERY_OPTIONS = [
|
||||
{
|
||||
id: 'home',
|
||||
title: 'Home delivery',
|
||||
subtitle: 'Courier to your registered or alternate address',
|
||||
icon: 'home-outline',
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
title: 'Branch pickup',
|
||||
subtitle: 'Collect it from your home branch once ready',
|
||||
icon: 'business-outline',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const cards = useAppSelector(selectCards);
|
||||
const accounts = useAppSelector((state) => state.account.accounts);
|
||||
const profile = useAppSelector((state) => state.user.profile);
|
||||
|
||||
const [selectedAccountId, setSelectedAccountId] = useState(accounts[0]?.id ?? '');
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<CardNetwork>('RuPay');
|
||||
const [deliveryMode, setDeliveryMode] =
|
||||
useState<(typeof DELIVERY_OPTIONS)[number]['id']>('home');
|
||||
const [nameOnCard, setNameOnCard] = useState(`${profile?.firstName || ''} ${profile?.lastName || ''}`);
|
||||
const [deliveryAddress, setDeliveryAddress] = useState('');
|
||||
const [landmark, setLandmark] = useState('');
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => accounts.find((account) => account.id === selectedAccountId) ?? accounts[0],
|
||||
[selectedAccountId, accounts],
|
||||
);
|
||||
|
||||
const issuanceFee = selectedAccount?.type === 'Salary' ? 0 : 199;
|
||||
const deliveryEta = deliveryMode === 'home' ? '3-5 business days' : 'Ready in 2 business days';
|
||||
const estimatedDeliveryDate = useMemo(
|
||||
() =>
|
||||
new Date(
|
||||
Date.now() + (deliveryMode === 'home' ? 4 : 2) * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
[deliveryMode],
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedAccount) {
|
||||
toast('Unable to place request', 'error', 'Select a linked account first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nameOnCard.trim()) {
|
||||
toast('Name required', 'error', 'Enter the cardholder name to print on the card.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deliveryMode === 'home' && !deliveryAddress.trim()) {
|
||||
toast('Address required', 'error', 'Add a delivery address for doorstep dispatch.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCard = createMockPhysicalCard({
|
||||
index: cards.length + 1,
|
||||
accountId: selectedAccount.id,
|
||||
cardholderName: nameOnCard.trim(),
|
||||
type: 'Debit',
|
||||
network: selectedNetwork,
|
||||
name: `${selectedNetwork} Debit`,
|
||||
deliveryMode,
|
||||
destinationLabel:
|
||||
deliveryMode === 'home'
|
||||
? [deliveryAddress.trim(), landmark.trim()].filter(Boolean).join(', ')
|
||||
: selectedAccount.branch,
|
||||
});
|
||||
|
||||
dispatch(cardsActions.addCard(newCard));
|
||||
toast(
|
||||
'Physical card requested',
|
||||
'success',
|
||||
deliveryMode === 'home'
|
||||
? 'Your debit card request was submitted and doorstep delivery has been scheduled.'
|
||||
: 'Your debit card request was submitted for branch pickup.',
|
||||
);
|
||||
navigation.replace('CardDetails', { cardId: newCard.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Request physical card"
|
||||
subtitle="Order a new debit card for your linked bank account"
|
||||
>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroTopRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.heroIcon,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="card-outline" size={22} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
Debit card request
|
||||
</Text>
|
||||
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Choose the account, network, and delivery mode for your new physical card.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.heroStats}>
|
||||
<View
|
||||
style={[
|
||||
styles.heroStat,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
|
||||
Issuance fee
|
||||
</Text>
|
||||
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
|
||||
{issuanceFee === 0 ? 'Free' : formatCurrency(issuanceFee)}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.heroStat,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
|
||||
Delivery ETA
|
||||
</Text>
|
||||
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
|
||||
{deliveryEta}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Linked account
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
This account will be used for debit-card settlements and charges.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.choiceList}>
|
||||
{accounts.map((account) => {
|
||||
const selected = account.id === selectedAccountId;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={account.id}
|
||||
onPress={() => setSelectedAccountId(account.id)}
|
||||
style={[
|
||||
styles.choiceCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.choiceHeader}>
|
||||
<View>
|
||||
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
|
||||
{account.type} account
|
||||
</Text>
|
||||
<Text style={[styles.choiceSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{account.maskedNumber} · {account.branch}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.choiceValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(account.availableBalance)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Card network
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Pick the network that fits how you spend.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.choiceList}>
|
||||
{NETWORK_OPTIONS.map((option) => {
|
||||
const selected = option.id === selectedNetwork;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onPress={() => setSelectedNetwork(option.id)}
|
||||
style={[
|
||||
styles.choiceCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.accent}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.accent}40` : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.choiceHeader}>
|
||||
<View style={styles.networkTitleRow}>
|
||||
<Ionicons name={option.icon as any} size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
|
||||
{option.title}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={20}
|
||||
color={theme.colors.accentStrong}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.choiceSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{option.subtitle}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Delivery details
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Decide how you want to receive your new physical debit card.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.choiceList}>
|
||||
{DELIVERY_OPTIONS.map((option) => {
|
||||
const selected = option.id === deliveryMode;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onPress={() => setDeliveryMode(option.id)}
|
||||
style={[
|
||||
styles.choiceCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.choiceHeader}>
|
||||
<View style={styles.networkTitleRow}>
|
||||
<Ionicons name={option.icon as any} size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
|
||||
{option.title}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.choiceSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{option.subtitle}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Name on card"
|
||||
value={nameOnCard}
|
||||
onChangeText={setNameOnCard}
|
||||
placeholder="Enter cardholder name"
|
||||
/>
|
||||
{deliveryMode === 'home' ? (
|
||||
<>
|
||||
<Input
|
||||
label="Delivery address"
|
||||
value={deliveryAddress}
|
||||
onChangeText={setDeliveryAddress}
|
||||
placeholder="Flat / street / area"
|
||||
/>
|
||||
<Input
|
||||
label="Landmark (optional)"
|
||||
value={landmark}
|
||||
onChangeText={setLandmark}
|
||||
placeholder="Nearby landmark"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.pickupCard,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="business-outline" size={18} color={theme.colors.primary} />
|
||||
<View style={styles.pickupCopy}>
|
||||
<Text style={[styles.pickupTitle, { color: theme.colors.textPrimary }]}>
|
||||
Pickup branch
|
||||
</Text>
|
||||
<Text style={[styles.pickupSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{selectedAccount?.branch ?? 'Home branch'} will hold your card after dispatch.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card style={styles.summaryCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Request summary
|
||||
</Text>
|
||||
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Final review before we place the request.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Card type
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
Physical debit card
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Linked account
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{selectedAccount?.maskedNumber ?? 'Not selected'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Network
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{selectedNetwork}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Fulfilment
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{deliveryEta}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Delivery status
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
Request received
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated by
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(estimatedDeliveryDate)}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Request physical debit card"
|
||||
onPress={handleSubmit}
|
||||
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<View style={styles.bottomInset} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
gap: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 18,
|
||||
},
|
||||
heroTopRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
},
|
||||
heroIcon: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
},
|
||||
heroStats: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
heroStat: {
|
||||
flex: 1,
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
heroStatLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroStatValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
formCard: {
|
||||
gap: 14,
|
||||
},
|
||||
sectionHeader: {
|
||||
gap: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
},
|
||||
choiceList: {
|
||||
gap: 12,
|
||||
},
|
||||
choiceCard: {
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
},
|
||||
choiceHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
choiceTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
choiceSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
choiceValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
networkTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
pickupCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
},
|
||||
pickupCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
pickupTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
pickupSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
summaryCard: {
|
||||
gap: 14,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
summaryValue: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
bottomInset: {
|
||||
height: 60,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
BankCard,
|
||||
CardControlState,
|
||||
CardLimitConfig,
|
||||
CardNotificationSettings,
|
||||
CardStatus,
|
||||
CardStatement,
|
||||
VirtualCard,
|
||||
} from '../../../types/app';
|
||||
import { demoCards } from '../../../constants/mockData';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { resolveWithMock } from '../../../services/api/serviceHelpers';
|
||||
|
||||
interface UpdateCardControlsRequest {
|
||||
cardId: string;
|
||||
controls: Partial<CardControlState>;
|
||||
}
|
||||
|
||||
interface UpdateCardStatusRequest {
|
||||
cardId: string;
|
||||
status: CardStatus;
|
||||
}
|
||||
|
||||
interface UpdateCardLimitsRequest {
|
||||
cardId: string;
|
||||
limits: Partial<CardLimitConfig>;
|
||||
}
|
||||
|
||||
interface UpdateCardNotificationsRequest {
|
||||
cardId: string;
|
||||
notifications: Partial<CardNotificationSettings>;
|
||||
}
|
||||
|
||||
interface GenerateVirtualCardRequest {
|
||||
cardId: string;
|
||||
virtualCard: VirtualCard;
|
||||
}
|
||||
|
||||
const findDemoCard = (cardId: string) => demoCards.find((card) => card.id === cardId) ?? demoCards[0]!;
|
||||
|
||||
export const cardsApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getCards: builder.query<BankCard[], void>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: demoCards,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<BankCard[]>(endpoints.cards.overview);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['Cards'],
|
||||
}),
|
||||
getCardDetail: builder.query<BankCard, string>({
|
||||
queryFn: (cardId) =>
|
||||
resolveWithMock({
|
||||
mockData: findDemoCard(cardId),
|
||||
request: async () => {
|
||||
const response = await apiClient.get<BankCard>(endpoints.cards.detail);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['Cards'],
|
||||
}),
|
||||
updateCardControls: builder.mutation<BankCard, UpdateCardControlsRequest>({
|
||||
queryFn: ({ cardId, controls }) => {
|
||||
const fallbackCard = findDemoCard(cardId);
|
||||
|
||||
return resolveWithMock({
|
||||
mockData: () => ({
|
||||
...fallbackCard,
|
||||
controls: {
|
||||
...fallbackCard.controls,
|
||||
...controls,
|
||||
},
|
||||
}),
|
||||
request: async () => {
|
||||
const response = await apiClient.post<BankCard>(endpoints.cards.controls);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
},
|
||||
invalidatesTags: ['Cards'],
|
||||
}),
|
||||
updateCardStatus: builder.mutation<BankCard, UpdateCardStatusRequest>({
|
||||
queryFn: ({ cardId, status }) =>
|
||||
resolveWithMock({
|
||||
mockData: () => ({
|
||||
...findDemoCard(cardId),
|
||||
status,
|
||||
}),
|
||||
request: async () => {
|
||||
const response = await apiClient.post<BankCard>(endpoints.cards.status);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['Cards'],
|
||||
}),
|
||||
updateCardLimits: builder.mutation<BankCard, UpdateCardLimitsRequest>({
|
||||
queryFn: ({ cardId, limits }) =>
|
||||
resolveWithMock({
|
||||
mockData: () => ({
|
||||
...findDemoCard(cardId),
|
||||
limits: {
|
||||
...findDemoCard(cardId).limits,
|
||||
...limits,
|
||||
},
|
||||
}),
|
||||
request: async () => {
|
||||
const response = await apiClient.post<BankCard>(endpoints.cards.limits);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['Cards'],
|
||||
}),
|
||||
updateCardNotifications: builder.mutation<BankCard, UpdateCardNotificationsRequest>({
|
||||
queryFn: ({ cardId, notifications }) =>
|
||||
resolveWithMock({
|
||||
mockData: () => ({
|
||||
...findDemoCard(cardId),
|
||||
notifications: {
|
||||
...findDemoCard(cardId).notifications,
|
||||
...notifications,
|
||||
},
|
||||
}),
|
||||
request: async () => {
|
||||
const response = await apiClient.post<BankCard>(endpoints.cards.notifications);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['Cards'],
|
||||
}),
|
||||
generateVirtualCard: builder.mutation<BankCard, GenerateVirtualCardRequest>({
|
||||
queryFn: ({ cardId, virtualCard }) =>
|
||||
resolveWithMock({
|
||||
mockData: () => ({
|
||||
...findDemoCard(cardId),
|
||||
virtualCard,
|
||||
}),
|
||||
request: async () => {
|
||||
const response = await apiClient.post<BankCard>(endpoints.cards.virtualCard);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['Cards'],
|
||||
}),
|
||||
deleteVirtualCard: builder.mutation<BankCard, string>({
|
||||
queryFn: (cardId) =>
|
||||
resolveWithMock({
|
||||
mockData: () => ({
|
||||
...findDemoCard(cardId),
|
||||
virtualCard: null,
|
||||
}),
|
||||
request: async () => {
|
||||
const response = await apiClient.post<BankCard>(endpoints.cards.virtualCard);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['Cards'],
|
||||
}),
|
||||
getCardStatements: builder.query<CardStatement[], string>({
|
||||
queryFn: (cardId) =>
|
||||
resolveWithMock({
|
||||
mockData: findDemoCard(cardId).statements,
|
||||
request: async () => {
|
||||
const response = await apiClient.get<CardStatement[]>(endpoints.cards.statements);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
providesTags: ['Cards'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetCardsQuery,
|
||||
useGetCardDetailQuery,
|
||||
useUpdateCardControlsMutation,
|
||||
useUpdateCardStatusMutation,
|
||||
useUpdateCardLimitsMutation,
|
||||
useUpdateCardNotificationsMutation,
|
||||
useGenerateVirtualCardMutation,
|
||||
useDeleteVirtualCardMutation,
|
||||
useGetCardStatementsQuery,
|
||||
} = cardsApi;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import type { RootState } from '../../../app/store';
|
||||
|
||||
export const selectCardsState = (state: RootState) => state.cards;
|
||||
export const selectCards = (state: RootState) => state.cards.cards;
|
||||
export const selectCardsStatus = (state: RootState) => state.cards.status;
|
||||
|
||||
export const selectSelectedCard = createSelector([selectCardsState], (cardsState) => {
|
||||
if (!cardsState.cards.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
cardsState.cards.find((card) => card.id === cardsState.selectedCardId) ?? cardsState.cards[0]
|
||||
);
|
||||
});
|
||||
|
||||
export const selectSelectedCardIndex = createSelector([selectCardsState], (cardsState) => {
|
||||
if (!cardsState.cards.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cardIndex = cardsState.cards.findIndex((card) => card.id === cardsState.selectedCardId);
|
||||
|
||||
return cardIndex >= 0 ? cardIndex : 0;
|
||||
});
|
||||
|
||||
export const selectCardById = (state: RootState, cardId: string) =>
|
||||
state.cards.cards.find((card) => card.id === cardId) ?? null;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type {
|
||||
AsyncStatus,
|
||||
BankCard,
|
||||
CardControlState,
|
||||
CardLimitConfig,
|
||||
CardNotificationSettings,
|
||||
CardStatus,
|
||||
VirtualCard,
|
||||
} from '../../../types/app';
|
||||
import { seedCards } from './cardsThunk';
|
||||
|
||||
interface CardsState {
|
||||
status: AsyncStatus;
|
||||
cards: BankCard[];
|
||||
selectedCardId: string | null;
|
||||
}
|
||||
|
||||
const initialState: CardsState = {
|
||||
status: 'idle',
|
||||
cards: [],
|
||||
selectedCardId: null,
|
||||
};
|
||||
|
||||
const findCard = (state: CardsState, cardId: string) =>
|
||||
state.cards.find((card) => card.id === cardId);
|
||||
|
||||
const cardsSlice = createSlice({
|
||||
name: 'cards',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedCard(state, action: PayloadAction<string>) {
|
||||
state.selectedCardId = action.payload;
|
||||
},
|
||||
updateCardControls(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; changes: Partial<CardControlState> }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.controls = {
|
||||
...card.controls,
|
||||
...action.payload.changes,
|
||||
};
|
||||
},
|
||||
setCardStatus(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; status: CardStatus }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.status = action.payload.status;
|
||||
},
|
||||
updateCardLimits(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; changes: Partial<CardLimitConfig> }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.limits = {
|
||||
...card.limits,
|
||||
...action.payload.changes,
|
||||
};
|
||||
},
|
||||
toggleCardNotification(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; key: keyof CardNotificationSettings }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.notifications[action.payload.key] = !card.notifications[action.payload.key];
|
||||
},
|
||||
renameCard(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; name: string }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.name = action.payload.name;
|
||||
},
|
||||
setDefaultCard(state, action: PayloadAction<{ cardId: string }>) {
|
||||
state.cards.forEach((card) => {
|
||||
card.isDefault = card.id === action.payload.cardId;
|
||||
});
|
||||
state.selectedCardId = action.payload.cardId;
|
||||
},
|
||||
setPrimaryLinkedAccount(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; accountId: string }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card || !card.linkedAccountIds.includes(action.payload.accountId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.primaryLinkedAccountId = action.payload.accountId;
|
||||
},
|
||||
setVirtualCard(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; virtualCard: VirtualCard }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.virtualCard = action.payload.virtualCard;
|
||||
},
|
||||
updateVirtualCardLimit(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; spendingLimit: number }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card?.virtualCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.virtualCard.spendingLimit = action.payload.spendingLimit;
|
||||
},
|
||||
deleteVirtualCard(state, action: PayloadAction<{ cardId: string }>) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.virtualCard = null;
|
||||
},
|
||||
redeemRewards(
|
||||
state,
|
||||
action: PayloadAction<{ cardId: string; points: number }>,
|
||||
) {
|
||||
const card = findCard(state, action.payload.cardId);
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPoints = Math.max(card.rewards.points - action.payload.points, 0);
|
||||
card.rewards.points = nextPoints;
|
||||
card.rewards.redeemValue = Math.round(nextPoints * 0.25);
|
||||
},
|
||||
addCard(state, action: PayloadAction<BankCard>) {
|
||||
state.cards = [action.payload, ...state.cards];
|
||||
state.selectedCardId = action.payload.id;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(seedCards.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
})
|
||||
.addCase(seedCards.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.cards = action.payload;
|
||||
state.selectedCardId = action.payload[0]?.id ?? null;
|
||||
})
|
||||
.addCase(seedCards.rejected, (state) => {
|
||||
state.status = 'failed';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const cardsActions = cardsSlice.actions;
|
||||
export const cardsReducer = cardsSlice.reducer;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { demoCards } from '../../../constants/mockData';
|
||||
import type { BankCard } from '../../../types/app';
|
||||
import { sleep } from '../../../utils/async';
|
||||
|
||||
export const seedCards = createAsyncThunk<BankCard[]>('cards/seedCards', async () => {
|
||||
await sleep(120);
|
||||
return demoCards;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
BankCard,
|
||||
CardControlState,
|
||||
CardLimitConfig,
|
||||
CardNotificationSettings,
|
||||
CardOffer,
|
||||
CardSpendInsight,
|
||||
CardStatement,
|
||||
CardTransaction,
|
||||
VirtualCard,
|
||||
} from '../../../types/app';
|
||||
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
BankCard,
|
||||
CardControlState,
|
||||
CardDeliveryMode,
|
||||
CardDeliveryStage,
|
||||
CardKind,
|
||||
CardNetwork,
|
||||
CardPhysicalDelivery,
|
||||
VirtualCard,
|
||||
} from '../../../types/app';
|
||||
|
||||
const buildMaskedNumber = (fullNumber: string) => `**** ${fullNumber.slice(-4)}`;
|
||||
|
||||
export const summariseCardControls = (controls: CardControlState) =>
|
||||
controls.frozen ? 'Card frozen' : 'Card active';
|
||||
|
||||
export const getCardStatusLabel = (card: BankCard) => {
|
||||
if (card.status === 'blocked') {
|
||||
return 'Blocked';
|
||||
}
|
||||
|
||||
if (card.controls.frozen) {
|
||||
return 'Frozen';
|
||||
}
|
||||
|
||||
return 'Active';
|
||||
};
|
||||
|
||||
export const createPhysicalCardDeliveryPayload = ({
|
||||
mode,
|
||||
destinationLabel,
|
||||
status = 'processing',
|
||||
requestedAt = new Date().toISOString(),
|
||||
}: {
|
||||
mode: CardDeliveryMode;
|
||||
destinationLabel: string;
|
||||
status?: CardDeliveryStage;
|
||||
requestedAt?: string;
|
||||
}): CardPhysicalDelivery => {
|
||||
const requestedDate = new Date(requestedAt);
|
||||
const estimatedDate = new Date(
|
||||
requestedDate.getTime() + (mode === 'home' ? 4 : 2) * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
return {
|
||||
mode,
|
||||
status,
|
||||
trackingId: `CBS${Date.now().toString().slice(-8)}`,
|
||||
requestedAt,
|
||||
lastUpdatedAt: requestedAt,
|
||||
estimatedDate,
|
||||
destinationLabel,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCardDeliveryStatusLabel = (delivery: CardPhysicalDelivery) => {
|
||||
switch (delivery.status) {
|
||||
case 'processing':
|
||||
return 'Request received';
|
||||
case 'personalizing':
|
||||
return 'Card being prepared';
|
||||
case 'shipped':
|
||||
return delivery.mode === 'home' ? 'Shipped' : 'Sent to branch';
|
||||
case 'ready-for-pickup':
|
||||
return 'Ready for pickup';
|
||||
case 'delivered':
|
||||
return delivery.mode === 'home' ? 'Delivered' : 'Collected';
|
||||
default:
|
||||
return 'In progress';
|
||||
}
|
||||
};
|
||||
|
||||
export const getCardDeliveryProgressIndex = (delivery: CardPhysicalDelivery) => {
|
||||
switch (delivery.status) {
|
||||
case 'processing':
|
||||
return 0;
|
||||
case 'personalizing':
|
||||
return 1;
|
||||
case 'shipped':
|
||||
case 'ready-for-pickup':
|
||||
return 2;
|
||||
case 'delivered':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const createVirtualCardPayload = (
|
||||
cardId: string,
|
||||
spendingLimit: number,
|
||||
): VirtualCard => {
|
||||
const seed = Date.now().toString();
|
||||
const fullNumber = `4111 ${seed.slice(-4)} ${seed.slice(-8, -4)} ${seed.slice(-12, -8) || '1188'}`;
|
||||
|
||||
return {
|
||||
id: `virtual_${cardId}_${seed}`,
|
||||
maskedNumber: buildMaskedNumber(fullNumber.replace(/\s/g, '')),
|
||||
fullNumber,
|
||||
expiry: '12/28',
|
||||
cvv: seed.slice(-3),
|
||||
spendingLimit,
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockPhysicalCard = ({
|
||||
index,
|
||||
accountId,
|
||||
cardholderName,
|
||||
type = index % 2 === 0 ? 'Credit' : 'Debit',
|
||||
network = index % 2 === 0 ? 'RuPay' : 'VISA',
|
||||
name,
|
||||
deliveryMode,
|
||||
destinationLabel,
|
||||
deliveryStatus = 'processing',
|
||||
physicalCardDelivery,
|
||||
}: {
|
||||
index: number;
|
||||
accountId: string;
|
||||
cardholderName: string;
|
||||
type?: CardKind;
|
||||
network?: CardNetwork;
|
||||
name?: string;
|
||||
deliveryMode?: CardDeliveryMode;
|
||||
destinationLabel?: string;
|
||||
deliveryStatus?: CardDeliveryStage;
|
||||
physicalCardDelivery?: CardPhysicalDelivery | null;
|
||||
}): BankCard => {
|
||||
const serial = `${4721 + index}${8654 + index}${1021 + index}${7300 + index}`.slice(0, 16);
|
||||
const fullNumber = `${serial.slice(0, 4)} ${serial.slice(4, 8)} ${serial.slice(8, 12)} ${serial.slice(12, 16)}`;
|
||||
const resolvedType = type;
|
||||
const resolvedNetwork = network;
|
||||
const resolvedName =
|
||||
name ?? `${resolvedType === 'Credit' ? 'Platinum Credit' : 'Signature Debit'} ${index + 1}`;
|
||||
const resolvedPhysicalCardDelivery =
|
||||
physicalCardDelivery ??
|
||||
(deliveryMode && destinationLabel
|
||||
? createPhysicalCardDeliveryPayload({
|
||||
mode: deliveryMode,
|
||||
destinationLabel,
|
||||
status: deliveryStatus,
|
||||
})
|
||||
: null);
|
||||
|
||||
return {
|
||||
id: `card_added_${Date.now()}_${index}`,
|
||||
name: resolvedName,
|
||||
cardholderName,
|
||||
type: resolvedType,
|
||||
maskedNumber: buildMaskedNumber(serial),
|
||||
expiry: '11/31',
|
||||
network: resolvedNetwork,
|
||||
status: 'active',
|
||||
availableLimit: resolvedType === 'Credit' ? 180000 : 125000,
|
||||
spendToday: 0,
|
||||
linkedAccountIds: [accountId],
|
||||
primaryLinkedAccountId: accountId,
|
||||
isDefault: false,
|
||||
themeColors:
|
||||
resolvedType === 'Credit'
|
||||
? ['#0F172A', '#1E3A8A', '#38BDF8']
|
||||
: resolvedNetwork === 'RuPay'
|
||||
? ['#0B3B2E', '#0F766E', '#35D6A3']
|
||||
: ['#311847', '#7C3AED', '#EC4899'],
|
||||
controls: {
|
||||
online: true,
|
||||
atm: true,
|
||||
contactless: true,
|
||||
international: false,
|
||||
frozen: false,
|
||||
},
|
||||
limits: {
|
||||
daily: 100000,
|
||||
atmWithdrawal: 25000,
|
||||
pos: 50000,
|
||||
},
|
||||
notifications: {
|
||||
transactionAlerts: true,
|
||||
suspiciousActivity: true,
|
||||
internationalUsage: false,
|
||||
},
|
||||
secureDetails: {
|
||||
fullNumber,
|
||||
cvv: '517',
|
||||
billingCycle:
|
||||
resolvedType === 'Credit' ? '15th of every month' : 'Real-time debit settlements',
|
||||
outstandingBalance: 0,
|
||||
minimumDue: 0,
|
||||
dueDate: new Date().toISOString(),
|
||||
},
|
||||
rewards: {
|
||||
points: 2500,
|
||||
tier: 'Silver',
|
||||
redeemValue: 625,
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
id: `offer_added_${index}`,
|
||||
title: 'Welcome cashback',
|
||||
subtitle: 'Use your new card within 30 days to unlock a joining reward.',
|
||||
cashback: 'Up to Rs 500',
|
||||
merchant: 'CBS Bank',
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
|
||||
},
|
||||
],
|
||||
statements: [],
|
||||
spendInsight: {
|
||||
monthLabel: 'This month',
|
||||
budget: 50000,
|
||||
totalSpent: 0,
|
||||
categories: [
|
||||
{ id: `food_${index}`, label: 'Food', amount: 0, color: '#35D6A3' },
|
||||
{ id: `travel_${index}`, label: 'Travel', amount: 0, color: '#59C6FF' },
|
||||
{ id: `shopping_${index}`, label: 'Shopping', amount: 0, color: '#FF7C8A' },
|
||||
],
|
||||
},
|
||||
transactions: [],
|
||||
virtualCard: null,
|
||||
physicalCardDelivery: resolvedPhysicalCardDelivery,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { DepositAccount } from '../../../types/app';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { getDaysUntilMaturity, isDepositNearMaturity } from '../utils/depositCalculators';
|
||||
import { DepositStatusPill } from './DepositStatusPill';
|
||||
|
||||
interface DepositAccountCardProps {
|
||||
deposit: DepositAccount;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const DepositAccountCardComponent = ({
|
||||
deposit,
|
||||
onPress,
|
||||
}: DepositAccountCardProps) => {
|
||||
const theme = useTheme();
|
||||
const nearMaturity = isDepositNearMaturity(deposit);
|
||||
const daysUntilMaturity = getDaysUntilMaturity(deposit.maturityDate);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.pressable,
|
||||
{
|
||||
opacity: pressed ? 0.94 : 1,
|
||||
transform: [{ scale: pressed ? 0.99 : 1 }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerCopy}>
|
||||
<Text style={[styles.eyebrow, { color: theme.colors.textSecondary }]}>
|
||||
{deposit.type}
|
||||
</Text>
|
||||
<Text style={[styles.number, { color: theme.colors.textPrimary }]}>
|
||||
{deposit.maskedNumber}
|
||||
</Text>
|
||||
</View>
|
||||
<DepositStatusPill status={deposit.status} nearMaturity={nearMaturity} />
|
||||
</View>
|
||||
|
||||
<View style={styles.amountRow}>
|
||||
<View>
|
||||
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>
|
||||
{deposit.kind === 'fd' ? 'Deposit amount' : 'Monthly installment'}
|
||||
</Text>
|
||||
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(
|
||||
deposit.kind === 'fd'
|
||||
? deposit.principalAmount
|
||||
: deposit.monthlyInstallment ?? deposit.principalAmount,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.ratePill,
|
||||
{ backgroundColor: `${theme.colors.accent}12` },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="trending-up-outline" size={14} color={theme.colors.accentStrong} />
|
||||
<Text style={[styles.rateText, { color: theme.colors.accentStrong }]}>
|
||||
{deposit.interestRate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metricsGrid}>
|
||||
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Start date
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(deposit.startDate)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Maturity date
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(deposit.maturityDate)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Maturity amount
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(deposit.maturityAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Tracking
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.metricValue,
|
||||
{
|
||||
color:
|
||||
deposit.status === 'closed'
|
||||
? theme.colors.textSecondary
|
||||
: nearMaturity
|
||||
? theme.colors.warning
|
||||
: theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{deposit.status === 'closed'
|
||||
? 'Closed'
|
||||
: daysUntilMaturity <= 0
|
||||
? 'Ready'
|
||||
: `${daysUntilMaturity} days left`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressable: {
|
||||
width: '100%',
|
||||
},
|
||||
card: {
|
||||
gap: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
headerCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
number: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
amountRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
amountLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
amountValue: {
|
||||
marginTop: 4,
|
||||
fontSize: 22,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
ratePill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
rateText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
},
|
||||
metricsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
metricTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
lineHeight: 19,
|
||||
},
|
||||
});
|
||||
|
||||
export const DepositAccountCard = memo(DepositAccountCardComponent);
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface DepositOptionChipProps {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const DepositOptionChipComponent = ({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
}: DepositOptionChipProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
transform: [{ scale: pressed ? 0.98 : 1 }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{ color: selected ? theme.colors.primary : theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export const DepositOptionChip = memo(DepositOptionChipComponent);
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface DepositRatePillProps {
|
||||
rate: number;
|
||||
}
|
||||
|
||||
const DepositRatePillComponent = ({ rate }: DepositRatePillProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.pill,
|
||||
{
|
||||
backgroundColor: 'rgba(53, 214, 163, 0.12)',
|
||||
borderColor: theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.pill,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.text, { color: theme.colors.accent }]}>Up to {rate}% p.a.</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
alignSelf: 'flex-start',
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export const DepositRatePill = memo(DepositRatePillComponent);
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { DepositStatus } from '../../../types/app';
|
||||
|
||||
interface DepositStatusPillProps {
|
||||
status: DepositStatus;
|
||||
nearMaturity?: boolean;
|
||||
}
|
||||
|
||||
const DepositStatusPillComponent = ({
|
||||
status,
|
||||
nearMaturity = false,
|
||||
}: DepositStatusPillProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const tone = nearMaturity
|
||||
? {
|
||||
backgroundColor: `${theme.colors.warning}16`,
|
||||
color: theme.colors.warning,
|
||||
label: 'MATURING',
|
||||
}
|
||||
: status === 'closed'
|
||||
? {
|
||||
backgroundColor: `${theme.colors.textMuted}18`,
|
||||
color: theme.colors.textSecondary,
|
||||
label: 'CLOSED',
|
||||
}
|
||||
: status === 'matured'
|
||||
? {
|
||||
backgroundColor: `${theme.colors.accent}16`,
|
||||
color: theme.colors.accentStrong,
|
||||
label: 'MATURED',
|
||||
}
|
||||
: {
|
||||
backgroundColor: `${theme.colors.primary}12`,
|
||||
color: theme.colors.primary,
|
||||
label: 'ACTIVE',
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.pill, { backgroundColor: tone.backgroundColor }]}>
|
||||
<Text style={[styles.label, { color: tone.color }]}>{tone.label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
label: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
export const DepositStatusPill = memo(DepositStatusPillComponent);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { selectDepositDraft } from '../store/depositSelectors';
|
||||
import { depositActions } from '../store/depositSlice';
|
||||
|
||||
export const useDepositPlanner = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const draft = useAppSelector(selectDepositDraft);
|
||||
|
||||
return {
|
||||
draft,
|
||||
setProduct: useCallback(
|
||||
(productId: string) => {
|
||||
dispatch(depositActions.setDepositProduct(productId));
|
||||
},
|
||||
[dispatch],
|
||||
),
|
||||
setAmount: useCallback(
|
||||
(amount: string) => {
|
||||
dispatch(depositActions.setDepositAmount(amount));
|
||||
},
|
||||
[dispatch],
|
||||
),
|
||||
setMonthlyInstallment: useCallback(
|
||||
(monthlyInstallment: string) => {
|
||||
dispatch(depositActions.setMonthlyInstallment(monthlyInstallment));
|
||||
},
|
||||
[dispatch],
|
||||
),
|
||||
setTenure: useCallback(
|
||||
(tenure: number) => {
|
||||
dispatch(depositActions.setDepositTenure(tenure));
|
||||
},
|
||||
[dispatch],
|
||||
),
|
||||
setInterestPayoutType: useCallback(
|
||||
(interestPayoutType: 'cumulative' | 'non-cumulative') => {
|
||||
dispatch(depositActions.setInterestPayoutType(interestPayoutType));
|
||||
},
|
||||
[dispatch],
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePullToRefresh } from '../../../hooks/usePullToRefresh';
|
||||
import {
|
||||
useGetFixedDepositsQuery,
|
||||
useGetRecurringDepositsQuery,
|
||||
} from '../services/depositApi';
|
||||
|
||||
export const useDepositPortfolio = () => {
|
||||
const fixedDepositsQuery = useGetFixedDepositsQuery();
|
||||
const recurringDepositsQuery = useGetRecurringDepositsQuery();
|
||||
|
||||
const deposits = useMemo(
|
||||
() =>
|
||||
[...(fixedDepositsQuery.data ?? []), ...(recurringDepositsQuery.data ?? [])].sort(
|
||||
(left, right) =>
|
||||
new Date(right.startDate).getTime() - new Date(left.startDate).getTime(),
|
||||
),
|
||||
[fixedDepositsQuery.data, recurringDepositsQuery.data],
|
||||
);
|
||||
|
||||
const { refreshing, onRefresh } = usePullToRefresh(async () => {
|
||||
await Promise.all([
|
||||
fixedDepositsQuery.refetch(),
|
||||
recurringDepositsQuery.refetch(),
|
||||
]);
|
||||
});
|
||||
|
||||
return {
|
||||
deposits,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
isLoading: fixedDepositsQuery.isLoading || recurringDepositsQuery.isLoading,
|
||||
isFetching: fixedDepositsQuery.isFetching || recurringDepositsQuery.isFetching,
|
||||
isError: fixedDepositsQuery.isError || recurringDepositsQuery.isError,
|
||||
error: fixedDepositsQuery.error ?? recurringDepositsQuery.error ?? null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, { useEffect, 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 { useAppSelector } from '../../../app/hooks';
|
||||
import { Card, Input, ScreenShell } from '../../../components';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { DepositOptionChip } from '../components/DepositOptionChip';
|
||||
import {
|
||||
selectFixedDepositProduct,
|
||||
selectRecurringDepositProduct,
|
||||
} from '../store/depositSelectors';
|
||||
import {
|
||||
calculateFixedDepositMaturity,
|
||||
calculateRecurringDepositMaturity,
|
||||
} from '../utils/depositCalculators';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'DepositCalculator'>,
|
||||
BottomTabScreenProps<MainTabParamList, 'Services'>
|
||||
>;
|
||||
|
||||
type CalculatorMode = 'fd' | 'rd';
|
||||
type TenureUnit = 'months' | 'years';
|
||||
|
||||
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
|
||||
|
||||
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d.]/g, ''));
|
||||
|
||||
export default function DepositCalculatorScreen({ route }: Props) {
|
||||
const theme = useTheme();
|
||||
const fixedProduct = useAppSelector(selectFixedDepositProduct);
|
||||
const recurringProduct = useAppSelector(selectRecurringDepositProduct);
|
||||
|
||||
const [mode, setMode] = useState<CalculatorMode>(route.params?.initialKind ?? 'fd');
|
||||
const [amount, setAmount] = useState('100000');
|
||||
const [monthlyInstallment, setMonthlyInstallment] = useState('5000');
|
||||
const [rate, setRate] = useState(String(fixedProduct?.rate ?? 7.1));
|
||||
const [tenureValue, setTenureValue] = useState('12');
|
||||
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
|
||||
|
||||
useEffect(() => {
|
||||
if (route.params?.initialKind) {
|
||||
setMode(route.params.initialKind);
|
||||
}
|
||||
}, [route.params?.initialKind]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextRate =
|
||||
mode === 'fd' ? fixedProduct?.rate ?? 7.1 : recurringProduct?.rate ?? 6.8;
|
||||
|
||||
setRate(String(nextRate));
|
||||
}, [fixedProduct?.rate, mode, recurringProduct?.rate]);
|
||||
|
||||
const tenureMonths = useMemo(() => {
|
||||
const numericValue = toPositiveNumber(tenureValue);
|
||||
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
|
||||
}, [tenureUnit, tenureValue]);
|
||||
|
||||
const amountNumber = useMemo(() => toPositiveNumber(amount), [amount]);
|
||||
const monthlyInstallmentNumber = useMemo(
|
||||
() => toPositiveNumber(monthlyInstallment),
|
||||
[monthlyInstallment],
|
||||
);
|
||||
const rateNumber = useMemo(() => Number(rate), [rate]);
|
||||
|
||||
const maturityAmount = useMemo(
|
||||
() =>
|
||||
mode === 'fd'
|
||||
? calculateFixedDepositMaturity(amountNumber, rateNumber, tenureMonths, 'cumulative')
|
||||
: calculateRecurringDepositMaturity(
|
||||
monthlyInstallmentNumber,
|
||||
rateNumber,
|
||||
tenureMonths,
|
||||
),
|
||||
[amountNumber, mode, monthlyInstallmentNumber, rateNumber, tenureMonths],
|
||||
);
|
||||
|
||||
const totalInvestment =
|
||||
mode === 'fd' ? amountNumber : monthlyInstallmentNumber * tenureMonths;
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Deposit calculator"
|
||||
subtitle="Compare projected returns for fixed and recurring deposits"
|
||||
scrollable={false}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
|
||||
Calculator type
|
||||
</Text>
|
||||
<View style={styles.optionRow}>
|
||||
<DepositOptionChip
|
||||
label="Fixed deposit"
|
||||
selected={mode === 'fd'}
|
||||
onPress={() => setMode('fd')}
|
||||
/>
|
||||
<DepositOptionChip
|
||||
label="Recurring deposit"
|
||||
selected={mode === 'rd'}
|
||||
onPress={() => setMode('rd')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{mode === 'fd' ? (
|
||||
<Input
|
||||
label="Principal amount"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
keyboardType="number-pad"
|
||||
placeholder="100000"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
label="Monthly deposit"
|
||||
value={monthlyInstallment}
|
||||
onChangeText={setMonthlyInstallment}
|
||||
keyboardType="number-pad"
|
||||
placeholder="5000"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Interest rate (%)"
|
||||
value={rate}
|
||||
onChangeText={setRate}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="7.1"
|
||||
/>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
|
||||
Tenure
|
||||
</Text>
|
||||
<Input
|
||||
value={tenureValue}
|
||||
onChangeText={setTenureValue}
|
||||
keyboardType="number-pad"
|
||||
placeholder="12"
|
||||
/>
|
||||
<View style={styles.optionRow}>
|
||||
{TENURE_UNITS.map((unit) => (
|
||||
<DepositOptionChip
|
||||
key={unit}
|
||||
label={unit === 'months' ? 'Months' : 'Years'}
|
||||
selected={tenureUnit === unit}
|
||||
onPress={() => setTenureUnit(unit)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.resultCard}>
|
||||
<View style={styles.resultHeader}>
|
||||
<View style={[styles.resultIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="calculator-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.resultCopy}>
|
||||
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]}>
|
||||
Estimated maturity
|
||||
</Text>
|
||||
<Text style={[styles.resultSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Based on the rate and tenure entered above
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.resultValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.round(maturityAmount))}
|
||||
</Text>
|
||||
|
||||
<View style={styles.resultGrid}>
|
||||
<View style={[styles.resultTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total contribution
|
||||
</Text>
|
||||
<Text style={[styles.resultTileValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(totalInvestment || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.resultTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated gains
|
||||
</Text>
|
||||
<Text style={[styles.resultTileValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.max(Math.round(maturityAmount) - totalInvestment, 0))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
keyboardAvoider: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
formCard: {
|
||||
gap: 18,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 10,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
resultCard: {
|
||||
gap: 18,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
resultIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
resultCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
resultSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
resultValue: {
|
||||
fontSize: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.8,
|
||||
},
|
||||
resultGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
resultTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
resultLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
resultTileValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
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 type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } 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';
|
||||
|
||||
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>
|
||||
|
||||
<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}>
|
||||
<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>
|
||||
<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>
|
||||
</View>
|
||||
<Button
|
||||
title="Calculator"
|
||||
variant="secondary"
|
||||
style={styles.actionButton}
|
||||
onPress={handleOpenCalculator}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
name="calculator-outline"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{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="Open a new fixed or recurring deposit to start building your savings ladder."
|
||||
actionLabel="Open fixed deposit"
|
||||
onAction={handleOpenFixedDeposit}
|
||||
/>
|
||||
) : 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,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '900',
|
||||
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,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroHighlightValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
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,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
heroBadgeLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
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,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroMetricValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.4,
|
||||
},
|
||||
heroMetricCaption: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actionCard: {
|
||||
gap: 16,
|
||||
},
|
||||
sectionHeader: {
|
||||
gap: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
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,
|
||||
fontWeight: '800',
|
||||
},
|
||||
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,
|
||||
fontWeight: '800',
|
||||
},
|
||||
watchMeta: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
watchDays: {
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
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,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.4,
|
||||
},
|
||||
portfolioCountLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
filterWrap: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: 60,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
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 { useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { DepositInterestPayoutType } from '../../../types/app';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { DepositOptionChip } from '../components/DepositOptionChip';
|
||||
import { useCreateFixedDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
|
||||
import { selectFixedDepositProduct } from '../store/depositSelectors';
|
||||
import { calculateFixedDepositMaturity } from '../utils/depositCalculators';
|
||||
import { validateFixedDepositInput } from '../utils/depositValidators';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'OpenFixedDeposit'>,
|
||||
BottomTabScreenProps<MainTabParamList, 'Services'>
|
||||
>;
|
||||
|
||||
type TenureUnit = 'months' | 'years';
|
||||
|
||||
const PAYOUT_OPTIONS: Array<{
|
||||
id: DepositInterestPayoutType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'cumulative', label: 'Cumulative' },
|
||||
{ id: 'non-cumulative', label: 'Non-cumulative' },
|
||||
];
|
||||
|
||||
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
|
||||
|
||||
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d]/g, ''));
|
||||
|
||||
export default function OpenFixedDepositScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
useGetDepositProductsQuery();
|
||||
const product = useAppSelector(selectFixedDepositProduct);
|
||||
const [createFixedDeposit, createState] = useCreateFixedDepositMutation();
|
||||
|
||||
const [amount, setAmount] = useState('');
|
||||
const [tenureValue, setTenureValue] = useState(
|
||||
String(product?.defaultTenureMonths ?? 12),
|
||||
);
|
||||
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
|
||||
const [payoutType, setPayoutType] =
|
||||
useState<DepositInterestPayoutType>('cumulative');
|
||||
const [errors, setErrors] = useState<{
|
||||
amount?: string;
|
||||
tenureMonths?: string;
|
||||
interestPayoutType?: string;
|
||||
}>({});
|
||||
|
||||
const tenureMonths = useMemo(() => {
|
||||
const numericValue = toPositiveNumber(tenureValue);
|
||||
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
|
||||
}, [tenureUnit, tenureValue]);
|
||||
|
||||
const amountNumber = useMemo(() => toPositiveNumber(amount), [amount]);
|
||||
|
||||
const maturityAmount = useMemo(() => {
|
||||
if (!product) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return calculateFixedDepositMaturity(
|
||||
amountNumber,
|
||||
product.rate,
|
||||
tenureMonths,
|
||||
payoutType,
|
||||
);
|
||||
}, [amountNumber, payoutType, product, tenureMonths]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!product) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateFixedDepositInput(
|
||||
{
|
||||
amount: amountNumber,
|
||||
tenureMonths,
|
||||
interestPayoutType: payoutType,
|
||||
},
|
||||
product,
|
||||
);
|
||||
|
||||
setErrors(validation);
|
||||
|
||||
if (Object.keys(validation).length) {
|
||||
toast('Check deposit inputs', 'error', 'Please correct the highlighted fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deposit = await createFixedDeposit({
|
||||
amount: amountNumber,
|
||||
tenureMonths,
|
||||
interestPayoutType: payoutType,
|
||||
}).unwrap();
|
||||
|
||||
toast('Fixed deposit created', 'success', 'Your new FD is now active.');
|
||||
navigation.replace('DepositDetails', {
|
||||
depositId: deposit.id,
|
||||
kind: deposit.kind,
|
||||
});
|
||||
} catch {
|
||||
toast('Unable to open fixed deposit', 'error', 'Please try again shortly.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<ScreenShell title="Open fixed deposit" subtitle="Loading fixed deposit product">
|
||||
<Card>
|
||||
<Text style={[styles.placeholderText, { color: theme.colors.textSecondary }]}>
|
||||
Loading product details...
|
||||
</Text>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Open fixed deposit"
|
||||
subtitle="Create a new FD using your existing deposit product settings"
|
||||
scrollable={false}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroHeader}>
|
||||
<View>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
{product.name}
|
||||
</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
{product.rate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.accent}12` }]}>
|
||||
<Ionicons
|
||||
name="shield-checkmark-outline"
|
||||
size={16}
|
||||
color={theme.colors.accentStrong}
|
||||
/>
|
||||
<Text style={[styles.heroPillText, { color: theme.colors.accentStrong }]}>
|
||||
Min {formatCurrency(product.minimumAmount ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.heroCaption, { color: theme.colors.textSecondary }]}>
|
||||
Maximum amount {formatCurrency(product.maximumAmount ?? 0)} · Tenure between{' '}
|
||||
{product.minimumTenureMonths} and {product.maximumTenureMonths} months
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<Input
|
||||
label="Deposit amount"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
keyboardType="number-pad"
|
||||
placeholder="100000"
|
||||
error={errors.amount}
|
||||
/>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>Tenure</Text>
|
||||
<Input
|
||||
value={tenureValue}
|
||||
onChangeText={setTenureValue}
|
||||
keyboardType="number-pad"
|
||||
placeholder={String(product.defaultTenureMonths)}
|
||||
error={errors.tenureMonths}
|
||||
/>
|
||||
<View style={styles.optionRow}>
|
||||
{TENURE_UNITS.map((unit) => (
|
||||
<DepositOptionChip
|
||||
key={unit}
|
||||
label={unit === 'months' ? 'Months' : 'Years'}
|
||||
selected={tenureUnit === unit}
|
||||
onPress={() => setTenureUnit(unit)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
|
||||
Interest payout
|
||||
</Text>
|
||||
<View style={styles.optionRow}>
|
||||
{PAYOUT_OPTIONS.map((option) => (
|
||||
<DepositOptionChip
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
selected={payoutType === option.id}
|
||||
onPress={() => setPayoutType(option.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{errors.interestPayoutType ? (
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>
|
||||
{errors.interestPayoutType}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.previewCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Maturity preview
|
||||
</Text>
|
||||
<View style={styles.previewGrid}>
|
||||
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Principal
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(amountNumber || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated maturity
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.round(maturityAmount))}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.previewTile,
|
||||
styles.previewTileWide,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated interest earned
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.max(Math.round(maturityAmount) - amountNumber, 0))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Create fixed deposit"
|
||||
onPress={handleSubmit}
|
||||
loading={createState.isLoading}
|
||||
leftAccessory={
|
||||
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={{ height: 50 }} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 10,
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 24,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
heroPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
heroPillText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
},
|
||||
heroCaption: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
formCard: {
|
||||
gap: 18,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 10,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
previewCard: {
|
||||
gap: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
previewGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
previewTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
previewTileWide: {
|
||||
width: '100%',
|
||||
},
|
||||
previewLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
previewValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '900',
|
||||
lineHeight: 22,
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
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 { useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { DepositOptionChip } from '../components/DepositOptionChip';
|
||||
import { useCreateRecurringDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
|
||||
import { selectRecurringDepositProduct } from '../store/depositSelectors';
|
||||
import { calculateRecurringDepositMaturity } from '../utils/depositCalculators';
|
||||
import { validateRecurringDepositInput } from '../utils/depositValidators';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'OpenRecurringDeposit'>,
|
||||
BottomTabScreenProps<MainTabParamList, 'Services'>
|
||||
>;
|
||||
|
||||
type TenureUnit = 'months' | 'years';
|
||||
|
||||
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
|
||||
|
||||
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d]/g, ''));
|
||||
|
||||
export default function OpenRecurringDepositScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
useGetDepositProductsQuery();
|
||||
const product = useAppSelector(selectRecurringDepositProduct);
|
||||
const [createRecurringDeposit, createState] = useCreateRecurringDepositMutation();
|
||||
|
||||
const [monthlyInstallment, setMonthlyInstallment] = useState('');
|
||||
const [tenureValue, setTenureValue] = useState(
|
||||
String(product?.defaultTenureMonths ?? 24),
|
||||
);
|
||||
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
|
||||
const [errors, setErrors] = useState<{
|
||||
monthlyInstallment?: string;
|
||||
tenureMonths?: string;
|
||||
}>({});
|
||||
|
||||
const installmentNumber = useMemo(
|
||||
() => toPositiveNumber(monthlyInstallment),
|
||||
[monthlyInstallment],
|
||||
);
|
||||
const tenureMonths = useMemo(() => {
|
||||
const numericValue = toPositiveNumber(tenureValue);
|
||||
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
|
||||
}, [tenureUnit, tenureValue]);
|
||||
|
||||
const totalInvestment = installmentNumber * tenureMonths;
|
||||
const maturityAmount = useMemo(() => {
|
||||
if (!product) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return calculateRecurringDepositMaturity(
|
||||
installmentNumber,
|
||||
product.rate,
|
||||
tenureMonths,
|
||||
);
|
||||
}, [installmentNumber, product, tenureMonths]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!product) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateRecurringDepositInput(
|
||||
{
|
||||
monthlyInstallment: installmentNumber,
|
||||
tenureMonths,
|
||||
},
|
||||
product,
|
||||
);
|
||||
|
||||
setErrors(validation);
|
||||
|
||||
if (Object.keys(validation).length) {
|
||||
toast('Check recurring deposit inputs', 'error', 'Please correct the highlighted fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deposit = await createRecurringDeposit({
|
||||
monthlyInstallment: installmentNumber,
|
||||
tenureMonths,
|
||||
}).unwrap();
|
||||
|
||||
toast('Recurring deposit created', 'success', 'Your new RD is now active.');
|
||||
navigation.replace('DepositDetails', {
|
||||
depositId: deposit.id,
|
||||
kind: deposit.kind,
|
||||
});
|
||||
} catch {
|
||||
toast('Unable to open recurring deposit', 'error', 'Please try again shortly.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<ScreenShell title="Open recurring deposit" subtitle="Loading recurring deposit product">
|
||||
<Card>
|
||||
<Text style={[styles.placeholderText, { color: theme.colors.textSecondary }]}>
|
||||
Loading product details...
|
||||
</Text>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Open recurring deposit"
|
||||
subtitle="Set a monthly installment and grow savings automatically"
|
||||
scrollable={false}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroHeader}>
|
||||
<View>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
{product.name}
|
||||
</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
{product.rate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="repeat-outline" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.heroPillText, { color: theme.colors.primary }]}>
|
||||
Min {formatCurrency(product.minimumInstallment ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.heroCaption, { color: theme.colors.textSecondary }]}>
|
||||
Maximum installment {formatCurrency(product.maximumInstallment ?? 0)} · Tenure
|
||||
between {product.minimumTenureMonths} and {product.maximumTenureMonths} months
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.formCard}>
|
||||
<Input
|
||||
label="Monthly installment"
|
||||
value={monthlyInstallment}
|
||||
onChangeText={setMonthlyInstallment}
|
||||
keyboardType="number-pad"
|
||||
placeholder="5000"
|
||||
error={errors.monthlyInstallment}
|
||||
/>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>Tenure</Text>
|
||||
<Input
|
||||
value={tenureValue}
|
||||
onChangeText={setTenureValue}
|
||||
keyboardType="number-pad"
|
||||
placeholder={String(product.defaultTenureMonths)}
|
||||
error={errors.tenureMonths}
|
||||
/>
|
||||
<View style={styles.optionRow}>
|
||||
{TENURE_UNITS.map((unit) => (
|
||||
<DepositOptionChip
|
||||
key={unit}
|
||||
label={unit === 'months' ? 'Months' : 'Years'}
|
||||
selected={tenureUnit === unit}
|
||||
onPress={() => setTenureUnit(unit)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.previewCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Growth preview
|
||||
</Text>
|
||||
<View style={styles.previewGrid}>
|
||||
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total investment
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(totalInvestment || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated maturity
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.round(maturityAmount))}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.previewTile,
|
||||
styles.previewTileWide,
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
|
||||
Estimated gains
|
||||
</Text>
|
||||
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(Math.max(Math.round(maturityAmount) - totalInvestment, 0))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Create recurring deposit"
|
||||
onPress={handleSubmit}
|
||||
loading={createState.isLoading}
|
||||
leftAccessory={
|
||||
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={{ height: 50 }} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 10,
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 24,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
heroPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
heroPillText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
},
|
||||
heroCaption: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
formCard: {
|
||||
gap: 18,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 10,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
previewCard: {
|
||||
gap: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
previewGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
previewTile: {
|
||||
width: '48%',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
},
|
||||
previewTileWide: {
|
||||
width: '100%',
|
||||
},
|
||||
previewLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
previewValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '900',
|
||||
lineHeight: 22,
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { demoDepositAccounts, demoDepositProducts } from '../../../constants/mockData';
|
||||
import { appConfig } from '../../../config/appConfig';
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { toApiError } from '../../../services/api/serviceHelpers';
|
||||
import type {
|
||||
DepositAccount,
|
||||
DepositInterestPayoutType,
|
||||
DepositKind,
|
||||
DepositProduct,
|
||||
} from '../../../types/app';
|
||||
import { sleep } from '../../../utils/async';
|
||||
import {
|
||||
calculateFixedDepositMaturity,
|
||||
calculatePrematureClosureAmount,
|
||||
calculateRecurringDepositMaturity,
|
||||
getDepositCanonicalStatus,
|
||||
} from '../utils/depositCalculators';
|
||||
|
||||
interface DepositDetailArgs {
|
||||
depositId: string;
|
||||
kind: DepositKind;
|
||||
}
|
||||
|
||||
interface CreateFixedDepositRequest {
|
||||
amount: number;
|
||||
tenureMonths: number;
|
||||
interestPayoutType: DepositInterestPayoutType;
|
||||
}
|
||||
|
||||
interface CreateRecurringDepositRequest {
|
||||
monthlyInstallment: number;
|
||||
tenureMonths: number;
|
||||
}
|
||||
|
||||
interface CloseDepositRequest {
|
||||
depositId: string;
|
||||
kind: DepositKind;
|
||||
}
|
||||
|
||||
const cloneDeposit = (deposit: DepositAccount): DepositAccount => ({
|
||||
...deposit,
|
||||
nominee: deposit.nominee ? { ...deposit.nominee } : undefined,
|
||||
transactions: deposit.transactions.map((transaction) => ({ ...transaction })),
|
||||
});
|
||||
|
||||
let mockDeposits: DepositAccount[] = demoDepositAccounts.map(cloneDeposit);
|
||||
|
||||
const getProductByKind = (kind: DepositKind) =>
|
||||
demoDepositProducts.find((product) => product.kind === kind) ?? null;
|
||||
|
||||
const listMockDeposits = (kind: DepositKind) =>
|
||||
mockDeposits
|
||||
.filter((deposit) => deposit.kind === kind)
|
||||
.map((deposit) => ({
|
||||
...cloneDeposit(deposit),
|
||||
status: getDepositCanonicalStatus(deposit),
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.startDate).getTime() - new Date(left.startDate).getTime(),
|
||||
);
|
||||
|
||||
const findMockDeposit = ({ depositId, kind }: DepositDetailArgs) => {
|
||||
const deposit =
|
||||
mockDeposits.find((item) => item.id === depositId && item.kind === kind) ?? null;
|
||||
|
||||
return deposit
|
||||
? {
|
||||
...cloneDeposit(deposit),
|
||||
status: getDepositCanonicalStatus(deposit),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
const createMaskedDepositNumber = (kind: DepositKind) =>
|
||||
`${kind.toUpperCase()} XXXX ${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
|
||||
const createFixedDepositMock = ({
|
||||
amount,
|
||||
tenureMonths,
|
||||
interestPayoutType,
|
||||
}: CreateFixedDepositRequest) => {
|
||||
const product = getProductByKind('fd');
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Fixed deposit product unavailable.');
|
||||
}
|
||||
|
||||
const maturityAmount = Math.round(
|
||||
calculateFixedDepositMaturity(amount, product.rate, tenureMonths, interestPayoutType),
|
||||
);
|
||||
const nextDeposit: DepositAccount = {
|
||||
id: `fd_${Date.now()}`,
|
||||
productId: product.id,
|
||||
kind: 'fd',
|
||||
type: 'Fixed Deposit',
|
||||
maskedNumber: createMaskedDepositNumber('fd'),
|
||||
principalAmount: amount,
|
||||
totalInvestment: amount,
|
||||
interestRate: product.rate,
|
||||
tenureMonths,
|
||||
startDate: new Date().toISOString(),
|
||||
maturityDate: new Date(
|
||||
new Date().setMonth(new Date().getMonth() + tenureMonths),
|
||||
).toISOString(),
|
||||
maturityAmount,
|
||||
interestEarned: Math.max(maturityAmount - amount, 0),
|
||||
payoutLabel:
|
||||
interestPayoutType === 'cumulative' ? 'At maturity' : 'Quarterly payout',
|
||||
interestPayoutType,
|
||||
status: 'active',
|
||||
settlementAccountMasked: 'XXXX 9312',
|
||||
nominee: {
|
||||
name: 'Megha Sharma',
|
||||
relationship: 'Spouse',
|
||||
},
|
||||
canWithdrawPrematurely: true,
|
||||
prematurePenaltyRate: 1,
|
||||
transactions: [
|
||||
{
|
||||
id: `dep_txn_${Date.now()}_create`,
|
||||
title: 'FD booking',
|
||||
category: 'Deposit creation',
|
||||
amount,
|
||||
direction: 'debit',
|
||||
status: 'completed',
|
||||
occurredAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockDeposits = [nextDeposit, ...mockDeposits];
|
||||
|
||||
return cloneDeposit(nextDeposit);
|
||||
};
|
||||
|
||||
const createRecurringDepositMock = ({
|
||||
monthlyInstallment,
|
||||
tenureMonths,
|
||||
}: CreateRecurringDepositRequest) => {
|
||||
const product = getProductByKind('rd');
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Recurring deposit product unavailable.');
|
||||
}
|
||||
|
||||
const totalInvestment = monthlyInstallment * tenureMonths;
|
||||
const maturityAmount = Math.round(
|
||||
calculateRecurringDepositMaturity(monthlyInstallment, product.rate, tenureMonths),
|
||||
);
|
||||
const nextDeposit: DepositAccount = {
|
||||
id: `rd_${Date.now()}`,
|
||||
productId: product.id,
|
||||
kind: 'rd',
|
||||
type: 'Recurring Deposit',
|
||||
maskedNumber: createMaskedDepositNumber('rd'),
|
||||
principalAmount: monthlyInstallment,
|
||||
monthlyInstallment,
|
||||
totalInvestment,
|
||||
interestRate: product.rate,
|
||||
tenureMonths,
|
||||
startDate: new Date().toISOString(),
|
||||
maturityDate: new Date(
|
||||
new Date().setMonth(new Date().getMonth() + tenureMonths),
|
||||
).toISOString(),
|
||||
maturityAmount,
|
||||
interestEarned: Math.max(maturityAmount - totalInvestment, 0),
|
||||
payoutLabel: 'Monthly deposit',
|
||||
status: 'active',
|
||||
settlementAccountMasked: 'XXXX 9312',
|
||||
nominee: {
|
||||
name: 'Megha Sharma',
|
||||
relationship: 'Spouse',
|
||||
},
|
||||
canWithdrawPrematurely: true,
|
||||
prematurePenaltyRate: 0.75,
|
||||
transactions: [
|
||||
{
|
||||
id: `dep_txn_${Date.now()}_create`,
|
||||
title: 'RD started',
|
||||
category: 'Deposit creation',
|
||||
amount: monthlyInstallment,
|
||||
direction: 'debit',
|
||||
status: 'completed',
|
||||
occurredAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockDeposits = [nextDeposit, ...mockDeposits];
|
||||
|
||||
return cloneDeposit(nextDeposit);
|
||||
};
|
||||
|
||||
const closeMockDeposit = ({ depositId, kind }: CloseDepositRequest) => {
|
||||
const depositIndex = mockDeposits.findIndex(
|
||||
(deposit) => deposit.id === depositId && deposit.kind === kind,
|
||||
);
|
||||
|
||||
if (depositIndex < 0) {
|
||||
throw new Error('Deposit not found.');
|
||||
}
|
||||
|
||||
const existingDeposit = mockDeposits[depositIndex]!;
|
||||
const closure = calculatePrematureClosureAmount(existingDeposit);
|
||||
const canonicalStatus = getDepositCanonicalStatus(existingDeposit);
|
||||
|
||||
const closedDeposit: DepositAccount = {
|
||||
...existingDeposit,
|
||||
status: 'closed',
|
||||
maturityAmount: Math.round(closure.netAmount),
|
||||
interestEarned:
|
||||
kind === 'fd'
|
||||
? Math.max(Math.round(closure.netAmount) - existingDeposit.principalAmount, 0)
|
||||
: Math.max(Math.round(closure.netAmount) - existingDeposit.totalInvestment, 0),
|
||||
transactions: [
|
||||
{
|
||||
id: `dep_txn_${Date.now()}_close`,
|
||||
title:
|
||||
canonicalStatus === 'matured'
|
||||
? 'Maturity proceeds credited'
|
||||
: 'Premature closure credited',
|
||||
category: 'Deposit closure',
|
||||
amount: Math.round(closure.netAmount),
|
||||
direction: 'credit',
|
||||
status: 'completed',
|
||||
occurredAt: new Date().toISOString(),
|
||||
},
|
||||
...existingDeposit.transactions,
|
||||
],
|
||||
};
|
||||
|
||||
mockDeposits = mockDeposits.map((deposit, index) =>
|
||||
index === depositIndex ? closedDeposit : deposit,
|
||||
);
|
||||
|
||||
return cloneDeposit(closedDeposit);
|
||||
};
|
||||
|
||||
const handleMock = async <T>(factory: () => T) => {
|
||||
await sleep(280);
|
||||
return { data: factory() };
|
||||
};
|
||||
|
||||
const handleRequest = async <T>(request: () => Promise<T>) => {
|
||||
try {
|
||||
return { data: await request() };
|
||||
} catch (error) {
|
||||
return { error: toApiError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export const depositApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getDepositProducts: builder.query<DepositProduct[], void>({
|
||||
queryFn: async () =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => demoDepositProducts.map((product) => ({ ...product })))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<DepositProduct[]>(endpoints.deposits.products);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Deposits'],
|
||||
}),
|
||||
getFixedDeposits: builder.query<DepositAccount[], void>({
|
||||
queryFn: async () =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => listMockDeposits('fd'))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<DepositAccount[]>(
|
||||
endpoints.deposits.fixed.list,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Deposits'],
|
||||
}),
|
||||
getRecurringDeposits: builder.query<DepositAccount[], void>({
|
||||
queryFn: async () =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => listMockDeposits('rd'))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<DepositAccount[]>(
|
||||
endpoints.deposits.recurring.list,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Deposits'],
|
||||
}),
|
||||
getDepositDetail: builder.query<DepositAccount, DepositDetailArgs>({
|
||||
queryFn: async (args) => {
|
||||
if (appConfig.features.useMockServer) {
|
||||
await sleep(240);
|
||||
const deposit = findMockDeposit(args);
|
||||
|
||||
return deposit
|
||||
? { data: deposit }
|
||||
: {
|
||||
error: {
|
||||
status: 404,
|
||||
message: 'Deposit not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return handleRequest(async () => {
|
||||
const response = await apiClient.get<DepositAccount>(
|
||||
args.kind === 'fd'
|
||||
? endpoints.deposits.fixed.detail(args.depositId)
|
||||
: endpoints.deposits.recurring.detail(args.depositId),
|
||||
);
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
providesTags: ['Deposits'],
|
||||
}),
|
||||
createFixedDeposit: builder.mutation<DepositAccount, CreateFixedDepositRequest>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => createFixedDepositMock(payload))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<DepositAccount>(
|
||||
endpoints.deposits.fixed.create,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Deposits'],
|
||||
}),
|
||||
createRecurringDeposit: builder.mutation<DepositAccount, CreateRecurringDepositRequest>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => createRecurringDepositMock(payload))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<DepositAccount>(
|
||||
endpoints.deposits.recurring.create,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Deposits'],
|
||||
}),
|
||||
closeDeposit: builder.mutation<DepositAccount, CloseDepositRequest>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => closeMockDeposit(payload))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<DepositAccount>(
|
||||
payload.kind === 'fd'
|
||||
? endpoints.deposits.fixed.close
|
||||
: endpoints.deposits.recurring.close,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Deposits'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useCloseDepositMutation,
|
||||
useCreateFixedDepositMutation,
|
||||
useCreateRecurringDepositMutation,
|
||||
useGetDepositDetailQuery,
|
||||
useGetDepositProductsQuery,
|
||||
useGetFixedDepositsQuery,
|
||||
useGetRecurringDepositsQuery,
|
||||
} = depositApi;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import type { RootState } from '../../../app/store';
|
||||
|
||||
export const selectDepositState = (state: RootState) => state.deposit;
|
||||
export const selectDepositDraft = (state: RootState) => state.deposit.draft;
|
||||
export const selectDepositProducts = (state: RootState) => state.deposit.products;
|
||||
export const selectSelectedDepositId = (state: RootState) => state.deposit.selectedDepositId;
|
||||
|
||||
export const selectSelectedDepositProduct = createSelector(
|
||||
[selectDepositState],
|
||||
(depositState) =>
|
||||
depositState.products.find((product) => product.id === depositState.draft.productId) ?? null,
|
||||
);
|
||||
|
||||
export const selectFixedDepositProduct = createSelector([selectDepositProducts], (products) =>
|
||||
products.find((product) => product.kind === 'fd') ?? null,
|
||||
);
|
||||
|
||||
export const selectRecurringDepositProduct = createSelector([selectDepositProducts], (products) =>
|
||||
products.find((product) => product.kind === 'rd') ?? null,
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { AsyncStatus, DepositDraft, DepositProduct } from '../../../types/app';
|
||||
import { seedDepositProducts } from './depositThunk';
|
||||
|
||||
interface DepositState {
|
||||
status: AsyncStatus;
|
||||
products: DepositProduct[];
|
||||
draft: DepositDraft;
|
||||
receiptId: string | null;
|
||||
selectedDepositId: string | null;
|
||||
}
|
||||
|
||||
const initialState: DepositState = {
|
||||
status: 'idle',
|
||||
products: [],
|
||||
draft: {
|
||||
amount: '',
|
||||
monthlyInstallment: '',
|
||||
tenureMonths: 12,
|
||||
interestPayoutType: 'cumulative',
|
||||
},
|
||||
receiptId: null,
|
||||
selectedDepositId: null,
|
||||
};
|
||||
|
||||
const depositSlice = createSlice({
|
||||
name: 'deposit',
|
||||
initialState,
|
||||
reducers: {
|
||||
setDepositProduct(state, action: PayloadAction<string>) {
|
||||
state.draft.productId = action.payload;
|
||||
},
|
||||
setDepositAmount(state, action: PayloadAction<string>) {
|
||||
state.draft.amount = action.payload;
|
||||
},
|
||||
setMonthlyInstallment(state, action: PayloadAction<string>) {
|
||||
state.draft.monthlyInstallment = action.payload;
|
||||
},
|
||||
setDepositTenure(state, action: PayloadAction<number>) {
|
||||
state.draft.tenureMonths = action.payload;
|
||||
},
|
||||
setInterestPayoutType(state, action: PayloadAction<'cumulative' | 'non-cumulative'>) {
|
||||
state.draft.interestPayoutType = action.payload;
|
||||
},
|
||||
setDepositReceipt(state, action: PayloadAction<string>) {
|
||||
state.receiptId = action.payload;
|
||||
},
|
||||
setSelectedDeposit(state, action: PayloadAction<string | null>) {
|
||||
state.selectedDepositId = action.payload;
|
||||
},
|
||||
resetDepositFlow(state) {
|
||||
state.draft = {
|
||||
amount: '',
|
||||
monthlyInstallment: '',
|
||||
tenureMonths: 12,
|
||||
interestPayoutType: 'cumulative',
|
||||
};
|
||||
state.receiptId = null;
|
||||
state.selectedDepositId = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(seedDepositProducts.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
})
|
||||
.addCase(seedDepositProducts.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.products = action.payload;
|
||||
})
|
||||
.addCase(seedDepositProducts.rejected, (state) => {
|
||||
state.status = 'failed';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const depositActions = depositSlice.actions;
|
||||
export const depositReducer = depositSlice.reducer;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { demoDepositProducts } from '../../../constants/mockData';
|
||||
import type { DepositProduct } from '../../../types/app';
|
||||
import { sleep } from '../../../utils/async';
|
||||
|
||||
export const seedDepositProducts = createAsyncThunk<DepositProduct[]>(
|
||||
'deposit/seedProducts',
|
||||
async () => {
|
||||
await sleep(160);
|
||||
return demoDepositProducts;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
DepositAccount,
|
||||
DepositDraft,
|
||||
DepositInterestPayoutType,
|
||||
DepositKind,
|
||||
DepositProduct,
|
||||
DepositStatus,
|
||||
DepositTransaction,
|
||||
DepositType,
|
||||
} from '../../../types/app';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { demoDepositProducts } from '../../../constants/mockData';
|
||||
import {
|
||||
calculateFixedDepositMaturity,
|
||||
calculateRecurringDepositMaturity,
|
||||
getDepositCanonicalStatus,
|
||||
isDepositNearMaturity,
|
||||
} from './depositCalculators';
|
||||
import {
|
||||
validateFixedDepositInput,
|
||||
validateRecurringDepositInput,
|
||||
} from './depositValidators';
|
||||
|
||||
describe('deposit calculators', () => {
|
||||
it('gives higher maturity for cumulative FD than non-cumulative FD', () => {
|
||||
const cumulative = calculateFixedDepositMaturity(100000, 7.1, 12, 'cumulative');
|
||||
const nonCumulative = calculateFixedDepositMaturity(
|
||||
100000,
|
||||
7.1,
|
||||
12,
|
||||
'non-cumulative',
|
||||
);
|
||||
|
||||
expect(cumulative).toBeGreaterThan(nonCumulative);
|
||||
expect(nonCumulative).toBeGreaterThan(100000);
|
||||
});
|
||||
|
||||
it('gives maturity above total contributions for RD', () => {
|
||||
const maturity = calculateRecurringDepositMaturity(5000, 6.8, 24);
|
||||
|
||||
expect(maturity).toBeGreaterThan(5000 * 24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deposit validators', () => {
|
||||
const fixedProduct = demoDepositProducts.find((product) => product.kind === 'fd')!;
|
||||
const recurringProduct = demoDepositProducts.find((product) => product.kind === 'rd')!;
|
||||
|
||||
it('rejects fixed deposits below minimum amount', () => {
|
||||
const errors = validateFixedDepositInput(
|
||||
{
|
||||
amount: (fixedProduct.minimumAmount ?? 0) - 1,
|
||||
tenureMonths: fixedProduct.defaultTenureMonths,
|
||||
interestPayoutType: 'cumulative',
|
||||
},
|
||||
fixedProduct,
|
||||
);
|
||||
|
||||
expect(errors.amount).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects recurring deposits below minimum installment', () => {
|
||||
const errors = validateRecurringDepositInput(
|
||||
{
|
||||
monthlyInstallment: (recurringProduct.minimumInstallment ?? 0) - 1,
|
||||
tenureMonths: recurringProduct.defaultTenureMonths,
|
||||
},
|
||||
recurringProduct,
|
||||
);
|
||||
|
||||
expect(errors.monthlyInstallment).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deposit lifecycle helpers', () => {
|
||||
it('marks deposits with past maturity dates as matured', () => {
|
||||
const status = getDepositCanonicalStatus(
|
||||
{
|
||||
status: 'active',
|
||||
maturityDate: '2026-04-05T00:00:00.000Z',
|
||||
},
|
||||
new Date('2026-04-09T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
expect(status).toBe('matured');
|
||||
});
|
||||
|
||||
it('identifies deposits maturing within 7 days', () => {
|
||||
const nearMaturity = isDepositNearMaturity(
|
||||
{
|
||||
status: 'active',
|
||||
maturityDate: '2026-04-14T00:00:00.000Z',
|
||||
},
|
||||
new Date('2026-04-09T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
expect(nearMaturity).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
DepositAccount,
|
||||
DepositInterestPayoutType,
|
||||
DepositStatus,
|
||||
} from '../../../types/app';
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
export const calculateFixedDepositMaturity = (
|
||||
principal: number,
|
||||
annualRate: number,
|
||||
tenureMonths: number,
|
||||
payoutType: DepositInterestPayoutType = 'cumulative',
|
||||
) => {
|
||||
if (principal <= 0 || annualRate <= 0 || tenureMonths <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const years = tenureMonths / 12;
|
||||
|
||||
if (payoutType === 'non-cumulative') {
|
||||
return principal + principal * (annualRate / 100) * years;
|
||||
}
|
||||
|
||||
const quarterlyRate = annualRate / 100 / 4;
|
||||
const periods = years * 4;
|
||||
|
||||
return principal * Math.pow(1 + quarterlyRate, periods);
|
||||
};
|
||||
|
||||
export const calculateRecurringDepositMaturity = (
|
||||
monthlyInstallment: number,
|
||||
annualRate: number,
|
||||
tenureMonths: number,
|
||||
) => {
|
||||
if (monthlyInstallment <= 0 || annualRate <= 0 || tenureMonths <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const monthlyRate = annualRate / 100 / 12;
|
||||
let maturityAmount = 0;
|
||||
|
||||
for (let month = 0; month < tenureMonths; month += 1) {
|
||||
maturityAmount += monthlyInstallment * Math.pow(1 + monthlyRate, tenureMonths - month);
|
||||
}
|
||||
|
||||
return maturityAmount;
|
||||
};
|
||||
|
||||
export const getDepositCanonicalStatus = (
|
||||
deposit: Pick<DepositAccount, 'status' | 'maturityDate'>,
|
||||
now = new Date(),
|
||||
): DepositStatus => {
|
||||
if (deposit.status === 'closed') {
|
||||
return 'closed';
|
||||
}
|
||||
|
||||
return new Date(deposit.maturityDate).getTime() <= now.getTime() ? 'matured' : 'active';
|
||||
};
|
||||
|
||||
export const getDaysUntilMaturity = (maturityDate: string, now = new Date()) => {
|
||||
const diff = new Date(maturityDate).getTime() - now.getTime();
|
||||
|
||||
return Math.ceil(diff / MS_PER_DAY);
|
||||
};
|
||||
|
||||
export const isDepositNearMaturity = (
|
||||
deposit: Pick<DepositAccount, 'status' | 'maturityDate'>,
|
||||
now = new Date(),
|
||||
) => {
|
||||
const daysUntilMaturity = getDaysUntilMaturity(deposit.maturityDate, now);
|
||||
|
||||
return getDepositCanonicalStatus(deposit, now) === 'active' && daysUntilMaturity <= 7;
|
||||
};
|
||||
|
||||
export const getDepositProgress = (
|
||||
startDate: string,
|
||||
maturityDate: string,
|
||||
now = new Date(),
|
||||
) => {
|
||||
const start = new Date(startDate).getTime();
|
||||
const maturity = new Date(maturityDate).getTime();
|
||||
const current = now.getTime();
|
||||
|
||||
if (maturity <= start) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return clamp((current - start) / (maturity - start), 0, 1);
|
||||
};
|
||||
|
||||
export const calculatePrematureClosureAmount = (
|
||||
deposit: Pick<
|
||||
DepositAccount,
|
||||
'kind' | 'principalAmount' | 'monthlyInstallment' | 'interestRate' | 'startDate' | 'maturityDate' | 'maturityAmount' | 'prematurePenaltyRate' | 'status'
|
||||
>,
|
||||
now = new Date(),
|
||||
) => {
|
||||
const canonicalStatus = getDepositCanonicalStatus(deposit, now);
|
||||
|
||||
if (canonicalStatus === 'closed') {
|
||||
return {
|
||||
penaltyAmount: 0,
|
||||
netAmount: deposit.maturityAmount,
|
||||
};
|
||||
}
|
||||
|
||||
if (canonicalStatus === 'matured') {
|
||||
return {
|
||||
penaltyAmount: 0,
|
||||
netAmount: deposit.maturityAmount,
|
||||
};
|
||||
}
|
||||
|
||||
const elapsedMonths = Math.max(
|
||||
1,
|
||||
Math.round((now.getTime() - new Date(deposit.startDate).getTime()) / (MS_PER_DAY * 30)),
|
||||
);
|
||||
|
||||
const currentValue =
|
||||
deposit.kind === 'fd'
|
||||
? calculateFixedDepositMaturity(
|
||||
deposit.principalAmount,
|
||||
deposit.interestRate,
|
||||
elapsedMonths,
|
||||
'non-cumulative',
|
||||
)
|
||||
: calculateRecurringDepositMaturity(
|
||||
deposit.monthlyInstallment ?? 0,
|
||||
deposit.interestRate,
|
||||
elapsedMonths,
|
||||
);
|
||||
const investedBase =
|
||||
deposit.kind === 'fd'
|
||||
? deposit.principalAmount
|
||||
: (deposit.monthlyInstallment ?? 0) * elapsedMonths;
|
||||
|
||||
const penaltyAmount = currentValue * ((deposit.prematurePenaltyRate ?? 0) / 100);
|
||||
|
||||
return {
|
||||
penaltyAmount,
|
||||
netAmount: Math.max(currentValue - penaltyAmount, investedBase * 0.9),
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user