fund distribution

This commit is contained in:
Abin Toppo
2025-09-26 10:16:19 +05:30
parent a303af566e
commit 6778e79dc8
6 changed files with 630 additions and 248 deletions
+2
View File
@@ -19,6 +19,7 @@ import ManageJoinedGroup from "../screens/management/ManageJoinedGroup";
import InviteScreen from "../screens/InviteScreen";
import ManagementTabNavigation from "../screens/management/ManagementTabNavigation";
import TransactionsList from "../screens/transaction/TransactionList";
import RandomSelectUserScreen from "../screens/management/RandomSelectUserScreen";
export type RootStackParamList = {
Splash: undefined;
@@ -91,6 +92,7 @@ const linking = {
<Stack.Screen name="Invite" component={InviteScreen} options={{ headerShown: false }} />
<Stack.Screen name="ManagementTabs" component={ManagementTabNavigation} options={{ headerShown: false }} />
<Stack.Screen name="TransactionList" component={TransactionsList} options={{ headerShown: false }} />
<Stack.Screen name="RandomSelectUserScreen" component={RandomSelectUserScreen} options={{ headerShown: false }} />
</Stack.Navigator>
</NavigationContainer>
);
+49 -23
View File
@@ -21,6 +21,7 @@ import { CompositeScreenProps } from '@react-navigation/native';
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import { ManagementTabParamList } from './ManagementTabNavigation'; // Adjust path
// --- Type Definitions ---
interface Participant {
id: string;
@@ -30,6 +31,7 @@ interface Participant {
role: 'ORGANIZER' | 'USER';
}
export interface GroupDetails {
id: string;
groupName: string;
@@ -53,6 +55,7 @@ export interface GroupDetails {
participants: Participant[];
}
export interface EditGroupPayload {
groupName: string;
description: string;
@@ -66,18 +69,22 @@ export interface EditGroupPayload {
lateFeeValue: number | null;
}
// Correct props for a screen inside a nested Tab Navigator
type EditGroupScreenProps = CompositeScreenProps<
BottomTabScreenProps<ManagementTabParamList, 'EditGroup'>,
NativeStackScreenProps<RootStackParamList>
>;
type EditingCard = 'groupInfo' | 'financialRules' | 'configuration' | null;
// --- Main Screen Component ---
const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
const { groupId } = route.params;
const [editingCard, setEditingCard] = useState<EditingCard>(null);
const [groupDetails, setGroupDetails] = useState<GroupDetails | null>(null);
const [editableData, setEditableData] = useState<EditGroupPayload>({
@@ -99,7 +106,7 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
// ... (All your handler functions: initializeEditableData, fetchGroupDetails, handleCancel, handleSave, etc. remain the same)
const initializeEditableData = (data: GroupDetails) => {
setEditableData({
groupName: data.groupName,
@@ -115,6 +122,7 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
});
};
const fetchGroupDetails = useCallback(async () => {
setLoading(true);
setError(null);
@@ -134,10 +142,12 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
}
}, [groupId]);
useEffect(() => {
fetchGroupDetails();
}, [fetchGroupDetails]);
const handleCancel = () => {
if (groupDetails) {
initializeEditableData(groupDetails);
@@ -145,9 +155,11 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
setEditingCard(null);
};
const handleSave = async () => {
if (!groupDetails) return;
if (editableData.maxParticipants < groupDetails.totalParticipants) {
Alert.alert('Invalid Input', `Max participants cannot be less than the current count (${groupDetails.totalParticipants}).`);
return;
@@ -166,9 +178,11 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
}
};
const handleStartGroup = async () => {
if (!groupDetails) return;
Alert.alert(
"Confirm Start",
"Are you sure you want to start this group? This action cannot be undone.",
@@ -200,6 +214,7 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
try {
const response = await apiCall(`https://exotic-humpback-mistakenly.ngrok-free.app/api/invite/generate?groupId=${groupDetails.id}`, 'GET') as { data?: string };
const inviteLink = response.data;
if (inviteLink) {
@@ -220,12 +235,13 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
}
};
const handleUpdateField = (field: keyof EditGroupPayload, value: any) => {
setEditableData(prev => ({ ...prev, [field]: value }));
};
// ... (renderField and renderCard functions also remain the same)
const renderField = (field: keyof EditGroupPayload, label: string, displayValue: string | number, isEditing: boolean) => {
const renderField = (field: keyof EditGroupPayload, label: string, displayValue: string | number, isEditing: boolean, isEditable: boolean) => {
const commonInputProps = { style: styles.input, placeholderTextColor: "#9CA3AF" };
const getInputForField = () => {
@@ -276,6 +292,7 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
const renderCard = (title: string, fields: Array<{ field: keyof EditGroupPayload, label: string, displayValue: () => string | number }>, cardKey: EditingCard) => {
const isEditingThisCard = editingCard === cardKey;
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
@@ -288,7 +305,7 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
</View>
{fields.map(({ field, label, displayValue }) =>
renderField(field, label, displayValue(), isEditingThisCard)
renderField(field, label, displayValue(), isEditingThisCard, isEditable)
)}
{isEditingThisCard && (
@@ -305,16 +322,18 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
);
}
if (loading) return <View style={styles.centered}><ActivityIndicator size="large" color="#4F46E5" /></View>;
if (error) return <View style={styles.centered}><Text style={styles.errorText}>{error}</Text></View>;
if (!groupDetails) return null;
const isEditable = groupDetails.status === 'PENDING' || groupDetails.status === 'APPROVED';
const isInvitable = groupDetails.status === 'APPROVED';
const canStartGroup = groupDetails.status === 'APPROVED' && groupDetails.totalParticipants >= groupDetails.minParticipants;
return (
// Use AppView as the root container with flex: 1
<AppView style={{ flex: 1, paddingHorizontal: 0 }}>
<View style={styles.headerContainer}>
<Text style={styles.headerTitle}>Manage Group</Text>
@@ -325,6 +344,7 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
)}
</View>
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
@@ -335,20 +355,29 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
{ field: 'description', label: 'Description', displayValue: () => groupDetails.description },
], 'groupInfo')}
{renderCard('Financial Rules', [
{ field: 'contributionAmount', label: 'Contribution', displayValue: () => `₹${groupDetails.contributionAmount.toLocaleString('en-IN')}` },
{ field: 'maxParticipants', label: 'Max Participants', displayValue: () => `${groupDetails.totalParticipants} / ${groupDetails.maxParticipants}` },
{ field: 'minParticipants', label: 'Min Participants', displayValue: () => `${groupDetails.minParticipants}` },
{ field: 'latePaymentCharge', label: 'Late Fee', displayValue: () => `₹${(groupDetails.latePaymentCharge ?? 0).toLocaleString('en-IN')}` },
], 'financialRules')}
{groupDetails.status === 'ACTIVE' ?
renderCard('Financial Rules', [
{ field: 'contributionAmount', label: 'Contribution', displayValue: () => `₹${groupDetails.contributionAmount.toLocaleString('en-IN')}` },
{ field: 'latePaymentCharge', label: 'Late Fee', displayValue: () => `₹${(groupDetails.latePaymentCharge ?? 0).toLocaleString('en-IN')}` },
{ field: 'maxParticipants', label: 'Current Participants', displayValue: () => `${groupDetails.totalParticipants}` },
], 'financialRules')
:
renderCard('Financial Rules', [
{ field: 'contributionAmount', label: 'Contribution', displayValue: () => `₹${groupDetails.contributionAmount.toLocaleString('en-IN')}` },
{ field: 'maxParticipants', label: 'Max Participants', displayValue: () => `${groupDetails.totalParticipants} / ${groupDetails.maxParticipants}` },
{ field: 'minParticipants', label: 'Min Participants', displayValue: () => `${groupDetails.minParticipants}` },
{ field: 'latePaymentCharge', label: 'Late Fee', displayValue: () => `₹${(groupDetails.latePaymentCharge ?? 0).toLocaleString('en-IN')}` },
], 'financialRules')
}
{renderCard('Configuration', [
{ field: 'cycleType', label: 'Payment Cycle', displayValue: () => groupDetails.cycleType },
{ field: 'customCycleDays', label: 'Custom Days', displayValue: () => `${groupDetails.customCycleDays ?? ''} days` },
...(groupDetails.cycleType === 'CUSTOM' ? [{ field: 'customCycleDays' as keyof EditGroupPayload, label: 'Custom Days', displayValue: () => `${groupDetails.customCycleDays} days` }] : []),
{ field: 'visibility', label: 'Visibility', displayValue: () => groupDetails.visibility },
], 'configuration')}
{/* Participants Card ... */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Participants ({groupDetails.totalParticipants})</Text>
{groupDetails.participants.length > 0 ? (
@@ -369,8 +398,7 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
</View>
</ScrollView>
{/* --- FLOATING BUTTON AREA --- */}
{/* This View is a sibling of ScrollView, allowing it to float on top */}
{canStartGroup && (
<View style={styles.floatingButtonContainer}>
<TouchableOpacity
@@ -390,10 +418,9 @@ const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
);
};
// --- Styles ---
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6' },
// Added more paddingBottom to ensure content doesn't hide behind the button AND the tab bar
contentContainer: { padding: 16, paddingTop: 0, paddingBottom: 150 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
errorText: { color: '#EF4444', fontSize: 16, textAlign: 'center' },
@@ -437,15 +464,13 @@ const styles = StyleSheet.create({
cancelButton: { backgroundColor: '#E5E7EB' },
inviteButton: { backgroundColor: colors.primary },
buttonText: { color: '#ffffffff', fontSize: 15, fontWeight: '600' },
// --- FLOATING BUTTON STYLES ---
floatingButtonContainer: {
position: 'absolute',
bottom: 85, // Positioned above the tab bar
bottom: 85,
left: 20,
right: 20,
alignItems: 'center', // Center the button horizontally
zIndex: 1, // Ensure it's on top
alignItems: 'center',
zIndex: 1,
},
activateButton: {
backgroundColor: colors.pastelOrange,
@@ -457,7 +482,7 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
width: '90%', // Adjust width as needed
width: '90%',
alignItems: 'center',
},
activateButtonText: {
@@ -467,4 +492,5 @@ const styles = StyleSheet.create({
},
});
export default EditGroupScreen;
+159 -196
View File
@@ -1,15 +1,15 @@
import React, { useEffect, useState, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { View, Text, StyleSheet, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity, Modal, TextInput } from 'react-native';
import { useRoute, RouteProp } from '@react-navigation/native';
import { Ionicons } from "@react-native-vector-icons/ionicons";
import colors from '../../components/colors';
import AppView from '../../components/AppView';
import { apiCall } from '../../utils/ApiHelper';
// --- TYPE DEFINITIONS ---
// --- CONSTANTS ---
const API_BASE_URL = 'https://exotic-humpback-mistakenly.ngrok-free.app/api/payments';
// Generic API Response Wrapper
// --- TYPE DEFINITIONS ---
interface ApiResponse<T> {
status: 'success' | 'error';
data: T;
@@ -17,69 +17,54 @@ interface ApiResponse<T> {
statusCode: number;
}
// Type for an individual participant in the payment details
interface Participant {
userId: string;
userName: string;
firstName: string;
lastName: string;
mobile: string;
contributionAmount: number;
paidAmount: number;
payAmount?: number;
status: 'SUCCESS' | 'PENDING';
lateFee: number;
daysLate: number;
lateFeeWaived: boolean;
}
// Fully typed payment details based on your JSON response
interface PaymentDetails {
groupId: string;
groupName: string;
currentCycle: number;
cycleType: string;
contributionAmount: number;
totalContributedAmount: number;
lateFeeMode: string;
lateFeeValue: number;
notReceivedAmount: number;
totalLateFee: number;
totalDueWithLateFee: number;
participants: Participant[];
}
// Assuming your navigation stack params look something like this
type PaymentStackParamList = {
Payments: { groupId: string };
};
type PaymentStackParamList = { Payments: { groupId: string } };
type PaymentsRouteProp = RouteProp<PaymentStackParamList, 'Payments'>;
type PaymentsNavigationProp = StackNavigationProp<PaymentStackParamList>;
const Payments: React.FC = () => {
const route = useRoute<PaymentsRouteProp>();
const navigation = useNavigation<PaymentsNavigationProp>();
const { groupId } = route.params;
const [paymentDetails, setPaymentDetails] = useState<PaymentDetails | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// State for the waiver modal
const [isModalVisible, setModalVisible] = useState(false);
const [currentParticipant, setCurrentParticipant] = useState<Participant | null>(null);
const [waiverAmount, setWaiverAmount] = useState('');
const fetchPaymentDetails = useCallback(async () => {
if (!refreshing) setLoading(true);
try {
const response = await apiCall(
`https://exotic-humpback-mistakenly.ngrok-free.app/api/payments/current/payment/details/${groupId}`,
'GET'
) as ApiResponse<PaymentDetails>;
const response = await apiCall(`${API_BASE_URL}/current/payment/details/${groupId}`, 'GET') as ApiResponse<PaymentDetails>;
if (response && response.statusCode === 200) {
setPaymentDetails(response.data);
} else {
Alert.alert("Error", response?.message || "Failed to fetch payment details.");
Alert.alert("Error", response?.message || "Failed to fetch details.");
}
} catch (error: any) {
console.error("Fetch payment details error:", error);
Alert.alert("Error", error.message || "An unexpected error occurred.");
} finally {
setLoading(false);
@@ -91,205 +76,183 @@ const Payments: React.FC = () => {
fetchPaymentDetails();
}, [fetchPaymentDetails]);
const onRefresh = () => {
const onRefresh = useCallback(() => {
setRefreshing(true);
fetchPaymentDetails();
}, [fetchPaymentDetails]);
const openWaiverDialog = (participant: Participant) => {
setCurrentParticipant(participant);
setWaiverAmount('');
setModalVisible(true);
};
// --- UPDATED: handleWaiveFee now uses the correct API endpoint ---
const handleWaiveFee = async () => {
if (!currentParticipant || !paymentDetails) return;
const amountToWaive = parseFloat(waiverAmount);
if (isNaN(amountToWaive) || amountToWaive <= 0) {
Alert.alert("Invalid Input", "Please enter a waiver amount greater than zero.");
return;
}
if (amountToWaive > currentParticipant.lateFee) {
Alert.alert("Invalid Amount", `Waiver cannot exceed the current late fee of ₹${currentParticipant.lateFee}.`);
return;
}
try {
// Construct the exact URL as provided
const url = `${API_BASE_URL}/groups/${groupId}/cycles/${paymentDetails.currentCycle}/waive/${currentParticipant.userId}?amountToWaive=${amountToWaive}`;
const response = await apiCall(url, 'PUT', {}); // Body is empty as amount is in URL
if (response && response.statusCode === 200) {
Alert.alert("Success", `₹${amountToWaive} has been waived successfully.`);
setModalVisible(false);
onRefresh(); // Refresh data to reflect the change
} else {
throw new Error(response?.message || "Failed to apply waiver.");
}
} catch (error: any) {
Alert.alert("API Error", error.message);
}
};
// --- ENHANCED: renderParticipant now shows late fee details ---
const renderParticipant = (participant: Participant) => {
const isPending = participant.status === 'PENDING';
const canWaive = isPending && participant.lateFee > 0;
return (
<View key={participant.userId} style={styles.participantCard}>
<View style={styles.participantInfo}>
<Text style={styles.participantName}>{participant.firstName} {participant.lastName}</Text>
<Text style={styles.participantMobile}>{participant.mobile}</Text>
</View>
<View style={styles.paymentStatus}>
<Text style={[
styles.statusText,
{ color: isPending ? '#e0d626ff' : '#18b107ff' }
]}>
<Text style={[styles.statusText, { color: isPending ? colors.warning : colors.success }]}>
{participant.status}
</Text>
<Text style={styles.amountText}>
{isPending ?`₹${participant.payAmount}`:`₹${participant.paidAmount}`}
{isPending ? `₹${participant.payAmount || 0}` : `₹${participant.paidAmount}`}
</Text>
{/* --- NEW: Conditionally render late payment stats --- */}
{isPending && participant.daysLate > 0 && (
<View style={styles.lateFeeContainer}>
<Text style={styles.lateFeeText}>Late Fee: ₹{participant.lateFee}</Text>
<Text style={styles.lateFeeText}>Days Late: {participant.daysLate}</Text>
</View>
{isPending && participant.lateFee > 0 && (
<Text style={styles.lateFeeText}>Late Fee: ₹{participant.lateFee}</Text>
)}
{participant.lateFee === 0 && participant.lateFeeWaived && <Text style={styles.waivedText}>Fee Waived</Text>}
</View>
<View style={styles.menuContainer}>
{canWaive && (
<TouchableOpacity onPress={() => openWaiverDialog(participant)}>
<Ionicons name="ellipsis-vertical" size={22} color="#6c757d" />
</TouchableOpacity>
)}
</View>
</View>
);
};
if (loading && !refreshing) {
return <ActivityIndicator style={styles.centered} size="large" color={colors.primary} />;
}
if (!paymentDetails) {
return (
<View style={styles.centered}>
<Text>No payment details found.</Text>
<TouchableOpacity onPress={fetchPaymentDetails} style={styles.retryButton}>
<Text style={styles.retryButtonText}>Tap to retry</Text>
</TouchableOpacity>
</View>
);
}
return (
<AppView paddingHorizontal={0}>
<Modal
transparent={true}
visible={isModalVisible}
animationType="fade"
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContainer}>
<Text style={styles.modalTitle}>Waive Late Fee</Text>
<Text style={styles.modalSubtitle}>
For {currentParticipant?.firstName}. Current late fee is ₹{currentParticipant?.lateFee}.
</Text>
<TextInput
style={styles.input}
keyboardType="numeric"
value={waiverAmount}
onChangeText={setWaiverAmount}
placeholder="Enter amount to waive"
/>
<View style={styles.modalButtonContainer}>
<TouchableOpacity style={[styles.modalButton, styles.cancelButton]} onPress={() => setModalVisible(false)}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.modalButton, styles.saveButton]} onPress={handleWaiveFee}>
<Text style={styles.saveButtonText}>Apply Waiver</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
<ScrollView
style={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={[colors.primary]} tintColor={colors.primary} />}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<View style={styles.header}>
<Text style={styles.headerTitle}>{paymentDetails.groupName}</Text>
</View>
<View style={styles.summaryContainer}>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Current Cycle</Text>
<Text style={styles.summaryValue}>{paymentDetails.currentCycle}</Text>
</View>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Contribution</Text>
<Text style={styles.summaryValue}>₹{paymentDetails.contributionAmount}</Text>
</View>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Total Due</Text>
<Text style={[styles.summaryValue, { color: colors.danger }]}>₹{paymentDetails.totalDueWithLateFee}</Text>
</View>
</View>
<Text style={styles.sectionTitle}>Participants</Text>
{paymentDetails.participants.map(renderParticipant)}
{loading && !refreshing ? (
<ActivityIndicator style={{marginTop: 50}} size="large" color={colors.primary} />
) : (
<>
<View style={styles.header}>
<Text style={styles.headerTitle}>{paymentDetails?.groupName}</Text>
</View>
<View style={styles.summaryContainer}>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Cycle</Text>
<Text style={styles.summaryValue}>{paymentDetails?.currentCycle}</Text>
</View>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Contribution</Text>
<Text style={styles.summaryValue}>₹{paymentDetails?.contributionAmount}</Text>
</View>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Total Due</Text>
<Text style={[styles.summaryValue, { color: colors.danger }]}>₹{paymentDetails?.totalDueWithLateFee}</Text>
</View>
</View>
<Text style={styles.sectionTitle}>Participants</Text>
{paymentDetails?.participants.map(renderParticipant)}
</>
)}
</ScrollView>
</AppView>
);
};
const styles = StyleSheet.create({
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f4f6f8',
},
container: {
flex: 1,
backgroundColor: '#f4f6f8',
},
header: {
padding: 20,
paddingBottom: 15,
},
headerTitle: {
color: '#000000',
fontSize: 24,
fontWeight: 'bold',
},
summaryContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
padding: 20,
backgroundColor: '#fff',
marginHorizontal: 15,
borderRadius: 12,
elevation: 2,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 10,
},
summaryBox: {
alignItems: 'center',
},
summaryLabel: {
fontSize: 14,
color: '#6c757d',
marginBottom: 4,
},
summaryValue: {
fontSize: 18,
fontWeight: '700',
color: colors.primary,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
marginLeft: 20,
marginTop: 20,
marginBottom: 10,
color: '#343a40',
},
participantCard: {
backgroundColor: '#fff',
marginHorizontal: 15,
marginBottom: 10,
borderRadius: 10,
padding: 15,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
elevation: 1,
},
participantInfo: {
flex: 1,
},
participantName: {
fontSize: 16,
fontWeight: '600',
color: '#212529',
},
participantMobile: {
fontSize: 14,
color: '#6c757d',
marginTop: 2,
},
paymentStatus: {
alignItems: 'flex-end',
},
statusText: {
fontSize: 14,
fontWeight: 'bold',
textTransform: 'uppercase',
},
amountText: {
fontSize: 14,
color: '#495057',
marginTop: 2,
},
// --- NEW STYLES ---
lateFeeContainer: {
marginTop: 8,
paddingTop: 5,
borderTopWidth: 1,
borderTopColor: '#e9ecef',
alignItems: 'flex-end',
},
lateFeeText: {
fontSize: 13,
color: colors.danger,
fontWeight: '600',
},
retryButton: {
marginTop: 20,
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 8,
backgroundColor: colors.primary,
},
retryButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
container: { flex: 1, backgroundColor: '#f4f6f8' },
header: { padding: 20, paddingBottom: 15 },
headerTitle: { color: '#000000', fontSize: 24, fontWeight: 'bold' },
summaryContainer: { flexDirection: 'row', justifyContent: 'space-around', padding: 20, backgroundColor: '#fff', marginHorizontal: 15, borderRadius: 12, elevation: 2 },
summaryBox: { alignItems: 'center' },
summaryLabel: { fontSize: 14, color: '#6c757d', marginBottom: 4 },
summaryValue: { fontSize: 18, fontWeight: '700', color: colors.primary },
sectionTitle: { fontSize: 20, fontWeight: 'bold', marginLeft: 20, marginTop: 20, marginBottom: 10 },
participantCard: { backgroundColor: '#fff', marginHorizontal: 15, marginBottom: 10, borderRadius: 10, padding: 15, flexDirection: 'row', alignItems: 'center', elevation: 1 },
participantInfo: { flex: 1 },
participantName: { fontSize: 16, fontWeight: '600' },
participantMobile: { fontSize: 14, color: '#6c757d', marginTop: 2 },
paymentStatus: { alignItems: 'flex-end', marginHorizontal: 10 },
statusText: { fontSize: 14, fontWeight: 'bold', textTransform: 'uppercase' },
amountText: { fontSize: 14, color: '#495057', marginTop: 2 },
lateFeeText: { fontSize: 13, color: colors.danger, fontWeight: '600', marginTop: 4 },
waivedText: { fontSize: 13, color: colors.success, fontWeight: '600', marginTop: 4 },
menuContainer: { justifyContent: 'center', alignItems: 'center', minWidth: 25 },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' },
modalContainer: { width: '85%', backgroundColor: 'white', borderRadius: 15, padding: 20, elevation: 10 },
modalTitle: { fontSize: 20, fontWeight: 'bold', color: '#333', marginBottom: 5 },
modalSubtitle: { fontSize: 14, color: '#666', marginBottom: 20 },
input: { width: '100%', borderWidth: 1, borderColor: '#ddd', borderRadius: 8, paddingHorizontal: 15, paddingVertical: 12, fontSize: 16, marginBottom: 20 },
modalButtonContainer: { flexDirection: 'row', justifyContent: 'flex-end' },
modalButton: { paddingVertical: 10, paddingHorizontal: 20, borderRadius: 8, marginLeft: 10 },
cancelButton: { backgroundColor: '#f0f0f0' },
cancelButtonText: { color: '#333', fontWeight: 'bold' },
saveButton: { backgroundColor: colors.primary },
saveButtonText: { color: 'white', fontWeight: 'bold' },
});
export default Payments;
@@ -0,0 +1,248 @@
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Animated, Easing, SafeAreaView, Dimensions, Alert } from 'react-native';
import { Confetti, ConfettiMethods } from 'react-native-fast-confetti';
import AppView from '../../components/AppView';
// --- PROPS & TYPE DEFINITIONS ---
export interface RandomWinnerScreenProps {
participants: Participant[];
excludedParticipantIds: string[];
}
export interface Participant {
id: string;
name: string;
}
// --- CONSTANTS ---
const { width } = Dimensions.get('window');
const ITEM_HEIGHT = 70; // Height of each name item
const ANIMATION_DURATION = 8000; // 8 seconds for suspense
const SHUFFLE_MULTIPLIER = 5; // Repeats the list for a longer scroll effect
const RandomSelectUserScreen: React.FC = () => {
// --- MOCK DATA (Replace with navigation props) ---
const mockProps: RandomWinnerScreenProps = {
participants: [
{ id: '1', name: 'Vinay M.' },
{ id: '2', name: 'Nitesh Y.' },
{ id: '3', name: 'Alice' },
{ id: '4', name: 'Bob' },
{ id: '5', name: 'Charlie' },
{ id: '6', name: 'David' },
{ id: '7', name: 'Eve' },
],
excludedParticipantIds: ['4'], // Exclude Bob
};
// --- END MOCK DATA ---
const { participants, excludedParticipantIds } = mockProps;
const [eligibleParticipants, setEligibleParticipants] = useState<Participant[]>([]);
const [isSelecting, setIsSelecting] = useState(false);
const [winner, setWinner] = useState<Participant | null>(null);
const [animationList, setAnimationList] = useState<Participant[]>([]);
const scrollY = useRef(new Animated.Value(0)).current;
const confettiRef = useRef<ConfettiMethods>(null);
// Filter participants
useEffect(() => {
const eligible = participants.filter(p => !excludedParticipantIds.includes(p.id));
setEligibleParticipants(eligible);
}, [participants, excludedParticipantIds]);
const handleStartSelection = () => {
if (eligibleParticipants.length < 2) {
Alert.alert('Not enough eligible participants.');
return;
}
setIsSelecting(true);
setWinner(null);
scrollY.setValue(0);
// 1. Secretly select winner
const winnerIndex = Math.floor(Math.random() * eligibleParticipants.length);
const selectedWinner = eligibleParticipants[winnerIndex];
// 2. Create long, shuffled list for animation
let listForAnimation: Participant[] = [];
for (let i = 0; i < SHUFFLE_MULTIPLIER; i++) {
const shuffled = [...eligibleParticipants].sort(() => Math.random() - 0.5);
listForAnimation.push(...shuffled);
}
// 3. Place winner at the end
listForAnimation.push(selectedWinner);
setAnimationList(listForAnimation);
// 4. Calculate final scroll position
const finalY = -(listForAnimation.length - 1) * ITEM_HEIGHT;
// 5. Start animation
Animated.timing(scrollY, {
toValue: finalY,
duration: ANIMATION_DURATION,
useNativeDriver: true,
easing: Easing.bezier(0.3, 0, 0.1, 1),
}).start(() => {
setWinner(selectedWinner);
confettiRef.current?.start();
});
};
const handleReset = () => {
setIsSelecting(false);
setWinner(null);
scrollY.setValue(0);
confettiRef.current?.stop();
};
const renderListItem = ({ item, index }: { item: Participant, index: number }) => (
<View style={styles.itemContainer} key={`${item.id}-${index}`}>
<Text style={styles.itemName}>{item.name}</Text>
</View>
);
return (
<AppView>
<Confetti ref={confettiRef} />
<Text style={styles.title}>Select a Winner</Text>
<View style={styles.selectionMachine}>
<View style={styles.winnerPointer} />
{isSelecting || winner ? (
<Animated.View style={{ transform: [{ translateY: scrollY }] }}>
{animationList.map((item, index) => renderListItem({ item, index }))}
</Animated.View>
) : (
<View style={styles.placeholderContainer}>
<Text style={styles.placeholderText}>Press Start to Select</Text>
</View>
)}
</View>
{winner && (
<View style={styles.winnerBanner}>
<Text style={styles.winnerText}>Winner is</Text>
<Text style={styles.winnerName}>{winner.name}!</Text>
</View>
)}
{!isSelecting && !winner && (
<TouchableOpacity style={styles.startButton} onPress={handleStartSelection}>
<Text style={styles.startButtonText}>Start Selection</Text>
</TouchableOpacity>
)}
{winner && (
<TouchableOpacity style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Select Again</Text>
</TouchableOpacity>
)}
</AppView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#1C1C1E',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 40,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#FFFFFF',
textAlign: 'center',
marginBottom: 30,
},
selectionMachine: {
height: ITEM_HEIGHT,
width: '90%',
backgroundColor: '#2C2C2E',
borderRadius: 20,
overflow: 'hidden',
justifyContent: 'center',
},
winnerPointer: {
position: 'absolute',
width: '105%',
height: ITEM_HEIGHT,
borderRadius: 20,
borderWidth: 3,
borderColor: '#FBC02D',
alignSelf: 'center',
zIndex: 1,
},
itemContainer: {
height: ITEM_HEIGHT,
justifyContent: 'center',
alignItems: 'center',
},
itemName: {
fontSize: 36,
fontWeight: 'bold',
color: '#FFF',
},
placeholderContainer: {
height: ITEM_HEIGHT,
justifyContent: 'center',
alignItems: 'center',
},
placeholderText: {
fontSize: 24,
color: '#666',
fontStyle: 'italic',
},
winnerBanner: {
marginTop: 30,
padding: 20,
backgroundColor: '#FBC02D',
borderRadius: 15,
alignItems: 'center',
},
winnerText: {
fontSize: 20,
color: '#000',
},
winnerName: {
fontSize: 40,
fontWeight: 'bold',
color: '#000',
},
startButton: {
backgroundColor: '#007AFF',
paddingVertical: 18,
paddingHorizontal: 40,
borderRadius: 30,
marginTop: 40,
},
startButtonText: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: 'bold',
},
resetButton: {
backgroundColor: '#333',
paddingVertical: 18,
paddingHorizontal: 40,
borderRadius: 30,
marginTop: 40,
},
resetButtonText: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: 'bold',
},
});
export default RandomSelectUserScreen;