From a4fb2fe269b9362be16635f666ae744b9ce0b233 Mon Sep 17 00:00:00 2001 From: Abin Toppo Date: Wed, 1 Oct 2025 18:52:51 +0530 Subject: [PATCH] work done --- src/components/common/Header.tsx | 1 + src/screens/home/user-home/CycleDashboard.tsx | 317 ++++++++++++ src/screens/home/user-home/GroupDetails.tsx | 483 +++++++++++------- src/screens/management/ManagementScreen.tsx | 3 +- 4 files changed, 611 insertions(+), 193 deletions(-) create mode 100644 src/screens/home/user-home/CycleDashboard.tsx diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index c4ad6cf..5c3a0e9 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -46,6 +46,7 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "space-between", paddingVertical: 12, + paddingHorizontal:12 }, headerTitle: { fontSize: 18, diff --git a/src/screens/home/user-home/CycleDashboard.tsx b/src/screens/home/user-home/CycleDashboard.tsx new file mode 100644 index 0000000..0f4c8e9 --- /dev/null +++ b/src/screens/home/user-home/CycleDashboard.tsx @@ -0,0 +1,317 @@ +import React, { useState, useCallback } from 'react'; +import { + StyleSheet, + Text, + View, + FlatList, + TouchableOpacity, + LayoutAnimation, + UIManager, + Platform, +} from 'react-native'; +import { Ionicons } from "@react-native-vector-icons/ionicons";import colors from "../../../components/colors"; // Assuming a colors file + +// Enable LayoutAnimation for Android +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +// --- TypeScript Interfaces for Type Safety --- + +interface Contribution { + transactionId: string; + userName: string; + baseAmount: number; + lateFee: number; + waivedAmount: number; + totalAmount: number; + status: 'SUCCESS' | 'PENDING' | 'FAILED'; + paidDate: [number, number, number]; +} + +interface Withdrawal { + transactionId: string; + userName: string; + baseAmount: number; + totalAmount: number; + status: 'SUCCESS' | 'PENDING' | 'FAILED'; + paidDate: [number, number, number]; +} + +interface Cycle { + cycleNumber: number; + cycleStart: [number, number, number]; + cycleEnd: [number, number, number]; + expectedContribution: number; + contributions: Contribution[]; + withdrawal: Withdrawal | null; + totalParticipants: number; + totalCollected: number; + collectionPercentage: number; + paidParticipants: number; + pendingParticipants: number; +} + +interface CycleDashboardData { + groupId: string; + groupName: string; + totalCycles: number; + currentCycle: number; + contributionAmount: number; + cycleType: 'MONTHLY' | 'WEEKLY' | 'DAILY'; + status: 'COMPLETED' | 'ACTIVE' | 'PENDING'; + cycles: Cycle[]; + totalParticipants: number; + totalCollected: number; +} + + +// --- Helper Functions --- + +const formatDate = (dateArray: [number, number, number]): string => { + if (!dateArray || dateArray.length < 3) return 'N/A'; + const [year, month, day] = dateArray; + return new Date(year, month - 1, day).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +}; + +const formatCurrency = (amount: number): string => { + return new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: 'INR', + minimumFractionDigits: 0, + }).format(amount); +}; + + +// --- Child Components --- + +const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: boolean, onToggle: () => void }) => { + return ( + + + + Cycle {item.cycleNumber} + + {formatDate(item.cycleStart)} - {formatDate(item.cycleEnd)} + + + + + + {isExpanded && ( + + {/* Withdrawal Info */} + {item.withdrawal && ( + + Withdrawal + + + + {item.withdrawal.userName} + {formatCurrency(item.withdrawal.totalAmount)} + + {formatDate(item.withdrawal.paidDate)} + + + )} + + {/* Contributions List */} + + Contributions ({item.paidParticipants}/{item.totalParticipants}) + {item.contributions.map(contribution => ( + + + + {contribution.userName} + {formatDate(contribution.paidDate)} + + {formatCurrency(contribution.totalAmount)} + + ))} + + + )} + + ); +}; + + +// --- Main Dashboard Component --- + +const CycleDashboard = ({ data }: { data: CycleDashboardData }) => { + const [expandedCycle, setExpandedCycle] = useState(data.currentCycle); + + const handleToggle = (cycleNumber: number) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setExpandedCycle(expandedCycle === cycleNumber ? null : cycleNumber); + }; + + const renderHeader = () => ( + + {data.groupName} + + + Total Collected + {formatCurrency(data.totalCollected)} + + + Status + {data.status} + + + Cycles + {data.currentCycle}/{data.totalCycles} + + + + ); + + return ( + item.cycleNumber.toString()} + renderItem={({ item }) => ( + handleToggle(item.cycleNumber)} + /> + )} + ListHeaderComponent={renderHeader} + contentContainerStyle={styles.container} + /> + ); +}; + +export default CycleDashboard; + + +// --- Styles --- + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 15, + paddingBottom: 20, + }, + // Header + headerContainer: { + marginBottom: 20, + }, + groupName: { + fontSize: 24, + fontWeight: 'bold', + color: '#1e293b', + marginBottom: 15, + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + summaryBox: { + alignItems: 'center', + }, + summaryLabel: { + fontSize: 14, + color: '#64748b', + marginBottom: 4, + }, + summaryValue: { + fontSize: 16, + fontWeight: '600', + color: '#0f172a', + }, + // Cycle Item Card + cycleCard: { + backgroundColor: '#fff', + borderRadius: 12, + marginBottom: 15, + elevation: 2, + shadowColor: '#000', + shadowOpacity: 0.08, + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 8, + overflow: 'hidden', + }, + cycleHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + }, + cycleTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1e293b', + }, + cycleDateRange: { + fontSize: 13, + color: '#64748b', + marginTop: 2, + }, + cycleDetails: { + paddingHorizontal: 16, + paddingBottom: 16, + borderTopWidth: 1, + borderTopColor: '#f1f5f9', + }, + section: { + marginTop: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#334155', + marginBottom: 12, + }, + // Withdrawal Card + withdrawalCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.pastelPink, // Example color + padding: 12, + borderRadius: 8, + }, + withdrawalInfo: { + flex: 1, + marginLeft: 12, + }, + withdrawalUser: { + fontSize: 15, + fontWeight: 'bold', + color: colors.Home + }, + withdrawalAmount: { + fontSize: 14, + color: colors.Home + }, + withdrawalDate: { + fontSize: 12, + color: '#475569' + }, + // Contribution Item + contributionItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: '#f1f5f9', + }, + contributorName: { + fontSize: 15, + color: '#1e293b', + }, + contributionDate: { + fontSize: 12, + color: '#64748b', + }, + contributionAmount: { + fontSize: 15, + fontWeight: '500', + color: '#1e293b', + }, +}); + diff --git a/src/screens/home/user-home/GroupDetails.tsx b/src/screens/home/user-home/GroupDetails.tsx index e17e430..f93a12c 100644 --- a/src/screens/home/user-home/GroupDetails.tsx +++ b/src/screens/home/user-home/GroupDetails.tsx @@ -1,15 +1,15 @@ -import React, { useRef, useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { StyleSheet, Text, View, TouchableOpacity, - FlatList, - Animated, ToastAndroid, - Alert, + RefreshControl, + ScrollView, + Modal, } from "react-native"; -import { useRoute, RouteProp } from "@react-navigation/native"; +import { useRoute } from "@react-navigation/native"; import AppView from "../../../components/AppView"; import colors from "../../../components/colors"; import { Ionicons } from "@react-native-vector-icons/ionicons"; @@ -17,30 +17,62 @@ import Header from "../../../components/common/Header"; import { apiCall } from "../../../utils/ApiHelper"; import { BASE_URL, CONTRIBUTE, GROUP_ALL_MEMBER } from "../../../utils/ApiUrls"; import ContributionScreen from "../../ContributionScreen"; +import CycleDashboard from "./CycleDashboard"; -// Define type for a member -interface Member { - id: string; - name: string; - role: "Organizer" | "Member"; - contribution: number; - data: any -} +const CustomDropdown = ({ items, selectedValue, onValueChange, placeholder = "Select an item" }:any) => { + const [open, setOpen] = useState(false); + const selectedLabel = items.find((item:any) => item.value === selectedValue)?.label || placeholder; -// Define route params type + return ( + + setOpen(true)} style={styles.dropdownHeader}> + {selectedLabel} + + + setOpen(false)} + > + setOpen(false)}> + + + {items.map(item => ( + { + onValueChange(item.value); + setOpen(false); + }} + style={[ + styles.dropdownItem, + item.value === selectedValue && styles.selectedItem + ]} + > + {item.label} + + ))} + + + + + + ); +}; const GroupDetails = () => { const route = useRoute(); - const { id, groupName, description, contributionAmount, currentParticipants } = route.params; - - const [memberData, setMemberData] = useState([]); + const [memberData, setMemberData] = useState([]); const [loading, setLoading] = useState(false); - const [openModal, setOpenModal] = useState(false) - const [payable, setPayable] = useState() as any + const [openModal, setOpenModal] = useState(false); + const [payable, setPayable] = useState(); + const [refreshing, setRefreshing] = useState(false); + const [cycleDashboardData, setCycleDashboardData] = useState(); + const [selectedCycleNumber, setSelectedCycleNumber] = useState(null); + const [cycleItems, setCycleItems] = useState([]); - console.log("route param ggggggggggg : ", route?.params) - // Group object const group = { id: route.params.item?.id, name: route.params.item?.groupName, @@ -51,224 +83,291 @@ const GroupDetails = () => { members: memberData, }; - const progress = Math.min(group.collected / group.goal, 1); + const getCycleDashboard = async () => { + try { + const response = await apiCall(`${BASE_URL}payments/groups/${group.id}/cycle-dashboard`, "GET") as any; + const responseData = response.data; + setCycleDashboardData(responseData); - // Animation refs - const animatedValues = useRef([]); - - // useEffect(() => { - // animatedValues.current = group.members.map(() => new Animated.Value(0)); - // }, [group.members]); - - // useEffect(() => { - // const animations = group.members.map((_, i) => - // Animated.timing(animatedValues.current[i], { - // toValue: 1, - // duration: 400, - // delay: i * 100, - // useNativeDriver: true, - // }) - // ); - // Animated.stagger(100, animations).start(); - // }, [group.members]); - - // Fetch group members - useEffect(() => { - const handleGroupDetails = async () => { - try { - setLoading(true); - const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${route?.params?.item?.id}`, "GET") as any; - console.log("Group details response:", res); - setMemberData(res.data); - ToastAndroid.show("Group details fetched successfully!", ToastAndroid.SHORT); - } catch (error: any) { - console.error("Error fetching group details:", error); - ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT); - } finally { - setLoading(false); + if (responseData?.cycles) { + const items = responseData.cycles.map(cycle => ({ + label: `Cycle ${cycle.cycleNumber}`, + value: cycle.cycleNumber + })); + setCycleItems(items); + if (responseData.currentCycle) { + setSelectedCycleNumber(responseData.currentCycle); + } } - }; + } catch (error) { + ToastAndroid.show("Failed to fetch cycle data", ToastAndroid.SHORT); + } + }; - handleGroupDetails(); - }, [id]); + const fetchGroupDetails = async () => { + try { + const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${group.id}`, "GET") as any; + console.log("Group Members Response: ", res); + setMemberData(res.data); + } catch (error: any) { + ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT); + } + }; const handleContribution = async () => { try { - const res = await apiCall(`${CONTRIBUTE}${group.id}/calculate-contribution`, 'GET') as any - setPayable(res.data) - console.log("contribution res : ", res) + const res = await apiCall(`${CONTRIBUTE}${group.id}/calculate-contribution`, 'GET') as any; + setPayable(res.data); } catch (error) { - console.log("contribution error : ", error) + console.log("Error in handleContribution: ", error); } - } + }; - // useEffect(()=>{ - // if(group?.id) - // handleContribution() - // },[]) + const fetchAllData = useCallback(async () => { + setLoading(true); + await Promise.all([ + fetchGroupDetails(), + handleContribution(), + getCycleDashboard() + ]); + setLoading(false); + }, [group.id]); + + useEffect(() => { + if (group.id) { + fetchAllData(); + } + }, [group.id, fetchAllData]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await fetchAllData(); + setRefreshing(false); + }, [fetchAllData]); const confirmPayment = async () => { try { - const res = await apiCall(`${BASE_URL}payments/contribute/${group?.id}?amount=${payable?.totalPayable}`, 'POST') as any - console.log("payment response : ", res) - if (res.status) { - ToastAndroid.show('Payment done!', ToastAndroid.SHORT) - } - if (res.status === 400) { - ToastAndroid.show(res?.data?.message || 'Payment done!', ToastAndroid.SHORT) - + const res = await apiCall(`${BASE_URL}payments/contribute/${group?.id}?amount=${payable?.totalPayable}`, 'POST') as any; + if (res.status === 200 || res.status === 201) { + ToastAndroid.show('Payment done!', ToastAndroid.SHORT); + onRefresh(); + } else { + ToastAndroid.show(res?.data?.message || 'Payment failed!', ToastAndroid.SHORT); } } catch (error) { - console.log("payment error : ", error) + ToastAndroid.show("An error occurred during payment.", ToastAndroid.SHORT); } + }; - - } - const renderMember = ({ item, index }: { item: any; index: number }) => ( - - - - - - {item.firstName} {item.lastName} {item.role === "Organizer" ? "(Organizer)" : "(Member)"} - - Mobile: {item.mobile} - - - - ); + const selectedCycleData = cycleDashboardData?.cycles.find(c => c.cycleNumber === selectedCycleNumber); return ( - -
- {group.description} + + + } + contentContainerStyle={styles.scrollContent} + > +
+ {group.description} - {/* Progress Bar */} - - - - + + + {group.members.length ?? 0} + Members + + + ₹{group.collected} + Contribution + + + ₹{group.goal} + Total Amount + - ₹{group.collected} / ₹{group.goal} - - {/* Stats */} - - - {group.members.length ?? []} - Members + {cycleItems.length > 0 && ( + + )} + + {selectedCycleData && cycleDashboardData && ( + + )} + + + Members + + {group.status} + - - ₹{group.collected} - Collected - - - ₹{group.goal} - Goal - - - {/* Members List */} - - Members - {group.status} - + {memberData.map((member:any) => ( + + + + + {member.firstName} {member.role === "Organizer" ? "(Organizer)" : ""} + + + + ))} - item.id} - renderItem={renderMember} - contentContainerStyle={styles.membersListContainer} - /> - - {/* Actions */} - { - group.status === 'ACTIVE' && - - - { - setOpenModal(true) - handleContribution() - }} - style={[styles.button, { backgroundColor: route.params?.color ? route.params?.color : colors.Home }]}> + {/* Button at the bottom inside scroll (no absolute positioning) */} + {group.status === 'ACTIVE' && !payable?.hasPaid && ( + setOpenModal(true)} style={styles.button}> Contribute - - } - { - openModal && setOpenModal(!openModal)} onConfirm={() => { - confirmPayment() - setOpenModal(false) - }} /> - } + )} + + + {openModal && ( + setOpenModal(false)} + onConfirm={() => { + confirmPayment(); + setOpenModal(false); + }} + /> + )} ); }; export default GroupDetails; -// Styles remain mostly unchanged const styles = StyleSheet.create({ + dropdownWrapper: { + marginHorizontal: 15, + marginBottom: 20, + }, + dropdownHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 12, + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + backgroundColor: '#fff', + }, + modalOverlay: { + flex: 1, + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.4)', + padding: 20, + }, + dropdownListContainer: { + backgroundColor: '#fff', + borderRadius: 8, + padding: 10, + maxHeight: 300, + }, + dropdownItem: { + paddingVertical: 12, + paddingHorizontal: 10, + }, + selectedItem: { + backgroundColor: colors.pastelOrange, + borderRadius: 6, + }, + itemText: { + fontSize: 15, + color: colors.black, + }, description: { fontSize: 14, color: "#475569", marginBottom: 20, lineHeight: 20, + paddingHorizontal: 15, }, - progressContainer: { marginBottom: 24 }, - progressBar: { + statsRow: { flexDirection: "row", - height: 8, - borderRadius: 4, - backgroundColor: "#e2e8f0", - overflow: "hidden", - marginBottom: 6, + justifyContent: "space-around", + marginBottom: 20, + paddingHorizontal: 15, }, - amount: { fontSize: 13, color: "#64748b" }, - statsRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 20 }, statBox: { + alignItems: 'center', flex: 1, - backgroundColor: "#fff", - marginHorizontal: 4, - padding: 16, - borderRadius: 12, - alignItems: "center", - shadowColor: "#000", - shadowOpacity: 0.05, - shadowOffset: { width: 0, height: 2 }, - shadowRadius: 6, + }, + statValue: { + fontSize: 18, + fontWeight: '700', + color: '#1e293b', + }, + statLabel: { + fontSize: 12, + color: '#64748b', + marginTop: 4, + }, + membersHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: 'center', + paddingHorizontal: 15, + marginTop: 20, + marginBottom: 10, + }, + sectionTitle: { + fontSize: 18, + fontWeight: "600", + color: "#0f172a", + }, + statusBadge: { + backgroundColor: colors.pastelOrange, + borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 6, elevation: 2, }, - statValue: { fontSize: 18, fontWeight: "700", color: "#1e293b" }, - statLabel: { fontSize: 12, color: "#64748b", marginTop: 4 }, - sectionTitle: { fontSize: 16, fontWeight: "600", color: "#0f172a", marginBottom: 12 }, - membersListContainer: { paddingBottom: 20 }, + statusText: { + fontSize: 14, + fontWeight: '600', + color: colors.Home, + }, memberCard: { - backgroundColor: "#fff", - padding: 16, + flexDirection: "row", + alignItems: 'center', + backgroundColor: '#fff', + padding: 12, borderRadius: 12, marginBottom: 10, - borderLeftWidth: 5, + elevation: 2, + marginHorizontal: 15, + }, + memberInfo: { + marginLeft: 10, + }, + memberName: { + fontSize: 15, + fontWeight: "600", + color: "#1e293b", + }, + button: { + backgroundColor: colors.pastelOrange, + marginHorizontal: 15, + marginTop: 20, + marginBottom: 30, + elevation: 5, + paddingVertical: 14, + borderRadius: 10, + alignItems: "center", + }, + buttonText: { + fontSize: 16, + fontWeight: "600", + color: "#000", + }, + scrollContent: { + paddingBottom: 50, }, - memberName: { fontSize: 15, fontWeight: "600", color: "#1e293b", marginBottom: 4 }, - memberDetail: { fontSize: 13, color: "#475569" }, - actions: { marginTop: 16 }, - button: { backgroundColor: colors.Home, paddingVertical: 14, borderRadius: 10, alignItems: "center", marginBottom: 12 }, - buttonText: { fontSize: 15, fontWeight: "600", color: "#fff" }, }); diff --git a/src/screens/management/ManagementScreen.tsx b/src/screens/management/ManagementScreen.tsx index d394f08..e5634de 100644 --- a/src/screens/management/ManagementScreen.tsx +++ b/src/screens/management/ManagementScreen.tsx @@ -127,7 +127,8 @@ const ManagementScreen: FC = () => { case "My Groups": return } keyExtractor={(item) => item.id} ListEmptyComponent={You haven't created any groups yet.} onRefresh={fetchData} refreshing={loading} contentContainerStyle={{ paddingBottom: 100 }} />; case "Joined Groups": - return handleViewGroup(item)} />} keyExtractor={(item) => item.id} ListEmptyComponent={You haven't joined any groups yet.} onRefresh={fetchData} refreshing={loading} contentContainerStyle={{ paddingBottom: 100 }} />; + return { + handleViewGroup(item)}} />} keyExtractor={(item) => item.id} ListEmptyComponent={You haven't joined any groups yet.} onRefresh={fetchData} refreshing={loading} contentContainerStyle={{ paddingBottom: 100 }} />; case "Requests": return } keyExtractor={(item) => item.requestId} ListEmptyComponent={No pending join requests.} onRefresh={fetchData} refreshing={loading} contentContainerStyle={{ paddingBottom: 100 }} />; case "Your Requests":