322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { View, Text, StyleSheet, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity, Modal, TextInput, ToastAndroid } 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';
|
|
import { BASE_URL } from '../../utils/ApiUrls';
|
|
|
|
// --- CONSTANTS ---
|
|
const API_BASE_URL = 'http://192.168.1.5:9060/api/payments';
|
|
|
|
// --- TYPE DEFINITIONS ---
|
|
interface ApiResponse<T> {
|
|
status: 'success' | 'error';
|
|
data: T;
|
|
message?: string;
|
|
statusCode: number;
|
|
}
|
|
|
|
interface Participant {
|
|
userId: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
mobile: string;
|
|
contributionAmount: number;
|
|
paidAmount: number;
|
|
payAmount?: number;
|
|
status: 'SUCCESS' | 'PENDING';
|
|
lateFee: number;
|
|
lateFeeWaived: boolean;
|
|
}
|
|
|
|
interface PaymentDetails {
|
|
groupId: string;
|
|
groupName: string;
|
|
currentCycle: number;
|
|
contributionAmount: number;
|
|
totalDueWithLateFee: number;
|
|
participants: Participant[];
|
|
}
|
|
|
|
type PaymentStackParamList = { Payments: { groupId: string } };
|
|
type PaymentsRouteProp = RouteProp<PaymentStackParamList, 'Payments'>;
|
|
|
|
// The component is a valid React Functional Component, which is required for using hooks.
|
|
const Payments: React.FC = () => {
|
|
// --- HOOKS ---
|
|
// All hooks are called at the top level, which is correct.
|
|
const route = useRoute<PaymentsRouteProp>();
|
|
const { groupId } = route.params;
|
|
|
|
const [paymentDetails, setPaymentDetails] = useState<PaymentDetails | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [isGroupActive, setIsGroupActive] = useState(true);
|
|
const [isModalVisible, setModalVisible] = useState(false);
|
|
const [currentParticipant, setCurrentParticipant] = useState<Participant | null>(null);
|
|
const [waiverAmount, setWaiverAmount] = useState('');
|
|
const [cycleData, setCycleData] = useState<any>();
|
|
|
|
// --- FUNCTIONS ---
|
|
// These are regular functions and do not call hooks inside them, which is correct.
|
|
const getCycleData = async () => {
|
|
try {
|
|
const response = await apiCall(`${API_BASE_URL}/groups/${groupId}/cycle-dashboard`, 'GET') as ApiResponse<any>;
|
|
if (response && response.statusCode === 200) {
|
|
setCycleData(response.data);
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Failed to fetch cycle data:", error.message);
|
|
}
|
|
};
|
|
|
|
const fetchPaymentDetails = useCallback(async () => {
|
|
if (!refreshing) setLoading(true);
|
|
setIsGroupActive(true);
|
|
|
|
try {
|
|
const response = await apiCall(`${BASE_URL}payments/current/payment/details/${groupId}`, 'GET') as ApiResponse<PaymentDetails>;
|
|
|
|
if (response && response.statusCode === 200) {
|
|
setPaymentDetails(response.data);
|
|
} else if (response && response.statusCode === 400) {
|
|
setIsGroupActive(false);
|
|
setPaymentDetails(null);
|
|
} else {
|
|
setIsGroupActive(false);
|
|
}
|
|
} catch (error: any) {
|
|
console.log("payment error: ", error);
|
|
setIsGroupActive(false);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, [groupId, refreshing]);
|
|
|
|
// --- EFFECT HOOK ---
|
|
// useEffect is correctly used to call functions when the component mounts or dependencies change.
|
|
useEffect(() => {
|
|
getCycleData();
|
|
fetchPaymentDetails();
|
|
}, [fetchPaymentDetails]);
|
|
|
|
const onRefresh = useCallback(() => {
|
|
setRefreshing(true);
|
|
getCycleData();
|
|
fetchPaymentDetails();
|
|
}, [fetchPaymentDetails]);
|
|
|
|
const openWaiverDialog = (participant: Participant) => {
|
|
setCurrentParticipant(participant);
|
|
setWaiverAmount(participant.lateFee.toString());
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleWaiveFee = async () => {
|
|
if (!currentParticipant || !paymentDetails) return;
|
|
|
|
const amountToWaive = parseFloat(waiverAmount);
|
|
|
|
if (isNaN(amountToWaive) || amountToWaive < 0) {
|
|
ToastAndroid.show("Please enter a valid waiver amount.", ToastAndroid.SHORT);
|
|
return;
|
|
}
|
|
|
|
if (amountToWaive > currentParticipant.lateFee) {
|
|
ToastAndroid.show(`Waiver cannot exceed the current late fee of ₹${currentParticipant.lateFee}.`, ToastAndroid.SHORT);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const url = `${BASE_URL}groups/${groupId}/cycles/${paymentDetails.currentCycle}/waive/${currentParticipant.userId}?amountToWaive=${amountToWaive}`;
|
|
const response = await apiCall(url, 'PUT', {}) as any;
|
|
|
|
if (response && response.statusCode === 200) {
|
|
ToastAndroid.show(`₹${amountToWaive} has been waived successfully.`, ToastAndroid.SHORT);
|
|
setModalVisible(false);
|
|
onRefresh();
|
|
} else {
|
|
throw new Error(response?.message || "Failed to apply waiver.");
|
|
}
|
|
} catch (error: any) {
|
|
ToastAndroid.show(error.message || "Failed to apply waiver.", ToastAndroid.SHORT);
|
|
console.error("Waive Fee Error:", error);
|
|
}
|
|
};
|
|
|
|
// --- RENDER LOGIC ---
|
|
const renderParticipant = (participant: Participant) => {
|
|
const isPending = participant.status === 'PENDING';
|
|
const canWaive = isPending && participant.lateFee > 0 && !participant.lateFeeWaived;
|
|
|
|
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 ? colors.warning : colors.success }]}>
|
|
{participant.status}
|
|
</Text>
|
|
<Text style={styles.amountText}>
|
|
{isPending ? `₹${participant.payAmount || 0}` : `₹${participant.paidAmount}`}
|
|
</Text>
|
|
{isPending && participant.lateFee > 0 && (
|
|
<Text style={styles.lateFeeText}>Late Fee: ₹{participant.lateFee}</Text>
|
|
)}
|
|
{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>
|
|
);
|
|
};
|
|
|
|
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>
|
|
|
|
{loading && !refreshing ? (
|
|
<ActivityIndicator style={{marginTop: 50}} size="large" color={colors.primary} />
|
|
) : !isGroupActive ? (
|
|
<View style={styles.fullScreenMessage}>
|
|
<Ionicons name="warning-outline" size={40} color={colors.black} />
|
|
<Text style={styles.fullScreenMessageText}>
|
|
This group is not currently active.
|
|
</Text>
|
|
<TouchableOpacity onPress={onRefresh} style={styles.refreshButton}>
|
|
<Text style={styles.refreshButtonText}>Tap to Refresh</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : (
|
|
<ScrollView
|
|
style={styles.container}
|
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
|
>
|
|
{paymentDetails && (
|
|
<>
|
|
<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({
|
|
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.black },
|
|
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' },
|
|
fullScreenMessage: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: '#f4f6f8',
|
|
padding: 20,
|
|
},
|
|
fullScreenMessageText: {
|
|
fontSize: 18,
|
|
color: '#333',
|
|
textAlign: 'center',
|
|
marginTop: 15,
|
|
fontWeight: 'bold',
|
|
},
|
|
refreshButton: {
|
|
marginTop: 20,
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 10,
|
|
borderRadius: 8,
|
|
},
|
|
refreshButtonText: {
|
|
color: colors.black,
|
|
fontWeight: 'bold',
|
|
},
|
|
});
|
|
|
|
export default Payments;
|