Files
circle-collect/src/screens/management/EditGroupScreen.tsx
T
2025-09-29 11:31:10 +05:30

660 lines
25 KiB
TypeScript

import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
TextInput,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
Share,
Image,
ToastAndroid,
} from 'react-native';
import { RootStackParamList } from '../../navigation/AppNavigator'; // Adjust path
import { apiCall } from '../../utils/ApiHelper'; // Adjust path
import AppView from '../../components/AppView';
import { Picker } from '@react-native-picker/picker';
import colors from '../../components/colors';
import Clipboard from '@react-native-clipboard/clipboard';
import { CompositeScreenProps, useNavigation } from '@react-navigation/native';
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
// Adjust path
import { UPLOAD_GROUP_IMAGE } from '../../utils/ApiUrls';
import { launchImageLibrary } from 'react-native-image-picker';
import *as Keychain from 'react-native-keychain'
import Ionicons from '@react-native-vector-icons/ionicons';
import { CustomButton } from '../../components/common/Custom';
import { StackNavigationProp } from '@react-navigation/stack';
// --- Type Definitions ---
interface Participant {
id: string;
firstName: string;
lastName: string | null;
mobile: string;
role: 'ORGANIZER' | 'USER';
}
export interface GroupDetails {
id: string;
groupName: string;
description: string;
startDate: [number, number, number] | null;
endDate: [number, number, number] | null;
contributionAmount: number;
minParticipants: number;
maxParticipants: number;
portalCharge: number | null;
latePaymentCharge: number | null;
lateFeeMode: 'DAILY' | 'FIXED' | null;
lateFeeValue: number | null;
cycleType: 'QUARTERLY' | 'MONTHLY' | 'WEEKLY' | 'CUSTOM';
customCycleDays: number;
withdrawalMethod: 'RANDOM' | 'SEQUENTIAL';
status: 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'APPROVED';
visibility: 'PUBLIC' | 'PRIVATE';
organizerId: string;
totalParticipants: number;
participants: Participant[];
imageBlob:string
}
export interface EditGroupPayload {
groupName: string;
description: string;
maxParticipants: number;
contributionAmount: number;
customCycleDays: number | null;
cycleType: 'QUARTERLY' | 'MONTHLY' | 'WEEKLY' | 'CUSTOM';
latePaymentCharge: number;
visibility: 'PUBLIC' | 'PRIVATE';
lateFeeMode: 'DAILY' | 'FIXED' | null;
lateFeeValue: number | null;
}
// Correct props for a screen inside a nested Tab Navigator
type EditGroupScreenProps = CompositeScreenProps<
BottomTabScreenProps<RootStackParamList, 'EditGroup'>,
NativeStackScreenProps<RootStackParamList>
>;
type EditingCard = 'groupInfo' | 'financialRules' | 'configuration' | null;
type GroupManagementNavProp = StackNavigationProp<RootStackParamList, 'EditGroup'>;
// --- Main Screen Component ---
const EditGroupScreen: React.FC<EditGroupScreenProps> = ({ route }) => {
const { groupId } = route.params;
const navigation = useNavigation<GroupManagementNavProp>();
const [editingCard, setEditingCard] = useState<EditingCard>(null);
const [groupDetails, setGroupDetails] = useState<GroupDetails | null>(null);
const [editableData, setEditableData] = useState<EditGroupPayload>({
groupName: '',
description: '',
maxParticipants: 0,
contributionAmount: 0,
customCycleDays: null,
cycleType: 'MONTHLY',
latePaymentCharge: 0,
visibility: 'PRIVATE',
lateFeeMode: null,
lateFeeValue: null,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [starting, setStarting] = useState(false);
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paymentStatus, setPaymentStatus] = useState<boolean | null>(null);
const [currentCycle, setCurrentCycle] = useState<number | null>(null);
const [selectedImage, setSelectedImage] = useState<string | null>()
const [imageUpdating, setImageUpdating] = useState<boolean>(false)
const initializeEditableData = (data: GroupDetails) => {
setEditableData({
groupName: data.groupName,
description: data.description,
maxParticipants: data.maxParticipants,
contributionAmount: data.contributionAmount,
customCycleDays: data.customCycleDays,
cycleType: data.cycleType,
latePaymentCharge: data.latePaymentCharge ?? 0,
visibility: data.visibility,
lateFeeMode: data.lateFeeMode ?? null,
lateFeeValue: data.lateFeeValue ?? null,
});
};
const fetchGroupDetails = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiCall(`https://exotic-humpback-mistakenly.ngrok-free.app/api/fund/group/details?groupId=${groupId}`, 'GET') as { status: string; data?: GroupDetails; message?: string };
console.log("groupDetails", response)
if (response.status === 'success' && response.data) {
setGroupDetails(response.data);
initializeEditableData(response.data);
} else {
throw new Error(response.message || 'Failed to fetch group data.');
}
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [groupId]);
const getPaymentStatus = async()=>{
const response = await apiCall(`https://exotic-humpback-mistakenly.ngrok-free.app/api/payments/group/${groupId}/payment/paid`, 'GET') as { status: string; data?: { currentCycle: number ,status: boolean }; message?: string };
console.log("paymentStatus",response)
if (response.status === 'success') {
setPaymentStatus(response.data?.status || false);
setCurrentCycle(response.data?.currentCycle || null);
}
}
useEffect(() => {
fetchGroupDetails();
getPaymentStatus();
}, [fetchGroupDetails]);
const handleCancel = () => {
if (groupDetails) {
initializeEditableData(groupDetails);
}
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;
}
setSaving(true);
try {
await apiCall(`https://exotic-humpback-mistakenly.ngrok-free.app/api/fund/groups/${groupDetails.id}/edit`, 'PUT', editableData);
Alert.alert('Success', 'Group has been updated.');
setEditingCard(null);
await fetchGroupDetails();
} catch (e: any) {
Alert.alert('Save Error', e.message || 'An unexpected error occurred.');
} finally {
setSaving(false);
}
};
const handleDistribute = async () => {
navigation.navigate('RandomSelectUserScreen', { groupId: groupDetails?.id || '', cycleNumber: currentCycle || 0 });
};
const handleStartGroup = async () => {
if (!groupDetails) return;
Alert.alert(
"Confirm Start",
"Are you sure you want to start this group? This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Start",
onPress: async () => {
setStarting(true);
try {
await apiCall(`https://exotic-humpback-mistakenly.ngrok-free.app/api/fund/group/start?groupId=${groupDetails.id}`, 'GET');
Alert.alert('Success', 'Group has been started successfully.');
await fetchGroupDetails();
} catch (e: any) {
Alert.alert('Start Error', e.message || 'An unexpected error occurred.');
} finally {
setStarting(false);
}
},
style: 'destructive'
}
]
);
};
const handleInvite = async () => {
if (!groupDetails) return;
setInviting(true);
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) {
Clipboard.setString(inviteLink);
Alert.alert('Copied!', 'Invitation link copied to clipboard.');
await Share.share({
message: `Join my group on CircleCollect! ${inviteLink}`,
url: inviteLink,
title: 'CircleCollect Group Invitation'
});
} else {
throw new Error('Could not generate invitation link.');
}
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to generate invitation.');
} finally {
setInviting(false);
}
};
const handleUpdateField = (field: keyof EditGroupPayload, value: any) => {
setEditableData(prev => ({ ...prev, [field]: value }));
};
const renderField = (field: keyof EditGroupPayload, label: string, displayValue: string | number, isEditing: boolean, isEditable: boolean) => {
const commonInputProps = { style: styles.input, placeholderTextColor: "#9CA3AF" };
const getInputForField = () => {
switch (field) {
case 'groupName':
return <TextInput {...commonInputProps} value={editableData.groupName} onChangeText={(text) => handleUpdateField('groupName', text)} />;
case 'description':
return <TextInput {...commonInputProps} style={[styles.input, styles.textArea]} value={editableData.description} onChangeText={(text) => handleUpdateField('description', text)} multiline />;
case 'contributionAmount':
return <TextInput {...commonInputProps} value={String(editableData.contributionAmount ?? '')} onChangeText={(text) => handleUpdateField('contributionAmount', text ? Number(text.replace(/[^0-9]/g, '')) : 0)} keyboardType="numeric" />;
case 'maxParticipants':
return <TextInput {...commonInputProps} value={String(editableData.maxParticipants ?? '')} onChangeText={(text) => handleUpdateField('maxParticipants', text ? Number(text.replace(/[^0-9]/g, '')) : 0)} keyboardType="numeric" />;
case 'latePaymentCharge':
return <TextInput {...commonInputProps} value={String(editableData.latePaymentCharge ?? '')} onChangeText={(text) => handleUpdateField('latePaymentCharge', text ? Number(text.replace(/[^0-9]/g, '')) : 0)} keyboardType="numeric" />;
case 'cycleType':
return (
<Picker selectedValue={editableData.cycleType} onValueChange={(itemValue) => handleUpdateField('cycleType', itemValue)} style={styles.picker}>
{['MONTHLY', 'WEEKLY', 'QUARTERLY', 'CUSTOM'].map(c => <Picker.Item key={c} label={c} value={c} />)}
</Picker>
);
case 'customCycleDays':
if (editableData.cycleType !== 'CUSTOM') return null;
return <TextInput {...commonInputProps} value={String(editableData.customCycleDays ?? '')} onChangeText={(text) => handleUpdateField('customCycleDays', text ? Number(text.replace(/[^0-9]/g, '')) : 0)} keyboardType="numeric" />;
case 'visibility':
return (
<Picker selectedValue={editableData.visibility} onValueChange={(itemValue) => handleUpdateField('visibility', itemValue)} style={styles.picker}>
<Picker.Item label="PUBLIC" value="PUBLIC" />
<Picker.Item label="PRIVATE" value="PRIVATE" />
</Picker>
);
default:
return null;
}
};
return (
<View style={styles.fieldContainer}>
<Text style={styles.infoLabel}>{label}</Text>
{isEditing && isEditable ? (
<View style={{ marginTop: 8 }}>{getInputForField()}</View>
) : (
<Text style={styles.infoValue}>{displayValue}</Text>
)}
</View>
);
}
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}>
<Text style={styles.cardTitle}>{title}</Text>
{isEditable && !editingCard && (
<TouchableOpacity onPress={() => setEditingCard(cardKey)} style={styles.editButton}>
<Text style={styles.editButtonText}>✎</Text>
</TouchableOpacity>
)}
</View>
{fields.map(({ field, label, displayValue }) =>
renderField(field, label, displayValue(), isEditingThisCard)
)}
{isEditingThisCard && (
<View style={styles.cardActions}>
<TouchableOpacity style={[styles.button, styles.cancelButton]} onPress={handleCancel} disabled={saving}>
<Text style={[styles.buttonText, { color: '#374151' }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.button, styles.saveButton]} onPress={handleSave} disabled={saving}>
{saving ? <ActivityIndicator color="#FFFFFF" size="small" /> : <Text style={styles.buttonText}>Save</Text>}
</TouchableOpacity>
</View>
)}
</View>
);
}
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;
const pickImage = () => {
launchImageLibrary({ mediaType: "photo", quality: 0.5 }, (response) => {
if (response.didCancel) return;
if (response.errorCode) {
Alert.alert("Error", response.errorMessage || "Something went wrong");
} else if (response.assets && response.assets.length > 0) {
const selectedImage = response.assets[0];
console.log(selectedImage?.uri)
setSelectedImage(selectedImage?.uri)
}
});
};
const handleUpdateImage = async () => {
try {
const credentials = await Keychain.getGenericPassword();
if (!credentials) {
Alert.alert("Error", "You are not logged in");
return;
}
const token = credentials.password;
if (!selectedImage) {
Alert.alert("Error", "Please select an image first");
return;
}
setImageUpdating(true);
// Get file extension and mime type dynamically
const fileExtension = selectedImage.split('.').pop();
const formData = new FormData();
formData.append("file", {
uri: selectedImage,
type: "image/jpeg",
name: `group_${groupId}_profile.${fileExtension || "jpg"}`,
});
const url = `${UPLOAD_GROUP_IMAGE}${groupId}/upload-image`;
console.log("url : ", url)
console.log("formdata : ", formData)
// return
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "multipart/form-data",
"Authorization": `Bearer ${token}`,
},
body: formData,
});
console.log("image response : ", response)
if(response?.status === 200){
fetchGroupDetails()
ToastAndroid.show('Group uploaded successfully', ToastAndroid.SHORT)
}
} catch (error:any) {
console.error("❌ Error updating group image:", error);
Alert.alert("Error", error.message || "Could not update group image");
} finally {
setImageUpdating(false);
}
};
const getProfileImage = () => {
return selectedImage
? selectedImage
: groupDetails?.imageBlob
? `data:image/jpeg;base64,${groupDetails.imageBlob}`
: "https://img.freepik.com/premium-vector/people-group-frame-icon_791764-2649.jpg";
};
// Helper function to get MIME type
return (
<AppView style={{ flex: 1, paddingHorizontal: 0 }}>
<View style={styles.headerContainer}>
<Text style={styles.headerTitle}>Manage Group</Text>
{isInvitable && (
<TouchableOpacity style={[styles.button, styles.inviteButton]} onPress={handleInvite} disabled={inviting}>
{inviting ? <ActivityIndicator color="#FFFFFF" /> : <Text style={styles.buttonText}>Invite</Text>}
</TouchableOpacity>
)}
</View>
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
<View style={[styles.avatarContainer, { flexDirection: 'row', flex: 1, gap: 10 }]}>
<View style={{}}>
<Image
// source={{ uri: groupDetails?.imageBlob ? groupDetails?.imageBlob : selectedImage || "https://img.freepik.com/premium-vector/people-group-frame-icon_791764-2649.jpg" }}
source={{ uri: getProfileImage() }}
style={styles.avatar}
onError={() => console.log("Error loading image")}
/>
<TouchableOpacity style={styles.cameraIcon} onPress={pickImage}>
<Ionicons name="camera" size={18} color="#000" />
</TouchableOpacity>
</View>
<View style={{justifyContent:'center'}}>
<CustomButton
title={imageUpdating ? "Updating..." : "Update Image"}
onPress={handleUpdateImage}
icon={imageUpdating ? undefined : "cloud-upload"}
style={{paddingHorizontal:20}}
buttonBackground={colors.pastelBlue}
/>
</View>
</View>
{renderCard('Group Info', [
{ field: 'groupName', label: 'Group Name', displayValue: () => groupDetails.groupName },
{ field: 'description', label: 'Description', displayValue: () => groupDetails.description },
], 'groupInfo')}
{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 },
...(groupDetails.cycleType === 'CUSTOM' ? [{ field: 'customCycleDays' as keyof EditGroupPayload, label: 'Custom Days', displayValue: () => `${groupDetails.customCycleDays} days` }] : []),
{ field: 'visibility', label: 'Visibility', displayValue: () => groupDetails.visibility },
], 'configuration')}
<View style={styles.card}>
<Text style={styles.cardTitle}>Participants ({groupDetails.totalParticipants})</Text>
{groupDetails.participants.length > 0 ? (
groupDetails.participants.map(p => (
<View key={p.id} style={styles.participantRow}>
<View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={styles.participantName}>{`${p.firstName} ${p.lastName || ''}`.trim()}</Text>
{p.role === 'ORGANIZER' && <View style={styles.organizerBadge}><Text style={styles.organizerBadgeText}>Organizer</Text></View>}
</View>
<Text style={styles.participantMobile}>{p.mobile}</Text>
</View>
</View>
))
) : (
<Text style={styles.noParticipantsText}>No participants yet.</Text>
)}
</View>
</ScrollView>
{canStartGroup && (
<View style={styles.floatingButtonContainer}>
<TouchableOpacity
style={styles.activateButton}
onPress={handleStartGroup}
disabled={starting}
>
{starting ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.activateButtonText}>Activate Group</Text>
)}
</TouchableOpacity>
</View>
)}
{paymentStatus && (
<View style={styles.floatingButtonContainer}>
<TouchableOpacity
style={styles.activateButton}
onPress={handleDistribute}
disabled={starting}
>
{starting ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.activateButtonText}>Distribute</Text>
)}
</TouchableOpacity>
</View>
)}
</AppView>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6' },
contentContainer: { padding: 16, paddingTop: 0, paddingBottom: 150 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
errorText: { color: '#EF4444', fontSize: 16, textAlign: 'center' },
headerContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
avatarContainer: {
position: "relative",
marginVertical: 16,
},
avatar: {
width: 120,
height: 120,
borderRadius: 60,
borderWidth: 4,
borderColor: "#e2e8f0",
},
cameraIcon: {
position: "absolute",
// right: 0,
bottom: 0,
backgroundColor: colors.pastelBlue,
borderRadius: 20,
width: 40,
height: 40,
justifyContent: "center",
alignItems: "center",
borderWidth: 3,
borderColor: "#fff",
elevation: 2,
},
headerTitle: { fontSize: 28, fontWeight: 'bold', color: '#111827' },
card: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 20, marginBottom: 16, shadowColor: '#374151', shadowOpacity: 0.05, shadowRadius: 10 },
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: 10,
marginBottom: 12,
},
cardTitle: { fontSize: 18, fontWeight: 'bold', color: '#1F2937' },
editButton: { padding: 8 },
editButtonText: { color: colors.pastelPink, fontSize: 20, fontWeight: 'bold' },
fieldContainer: { borderBottomWidth: 1, borderBottomColor: '#F3F4F6', paddingBottom: 12, marginBottom: 12 },
infoLabel: { fontSize: 16, color: '#6B7280', marginBottom: 4 },
infoValue: { fontSize: 16, fontWeight: '600', color: '#111827', textTransform: 'capitalize' },
input: { backgroundColor: '#F9FAFB', borderWidth: 1, borderColor: '#D1D5DB', borderRadius: 10, padding: 14, fontSize: 16, color: '#111827' },
textArea: { height: 100, textAlignVertical: 'top' },
picker: { backgroundColor: '#F9FAFB', borderWidth: 1, borderColor: '#D1D5DB', borderRadius: 10 },
participantRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#F3F4F6' },
participantName: { fontSize: 16, fontWeight: '600', color: '#111827' },
participantMobile: { fontSize: 14, color: '#6B7280', marginTop: 4 },
organizerBadge: { backgroundColor: '#FEF3C7', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 12, marginLeft: 10 },
organizerBadgeText: { color: '#92400E', fontSize: 12, fontWeight: '500' },
noParticipantsText: { fontSize: 15, color: '#6B7280', textAlign: 'center', paddingVertical: 20 },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#E5E7EB' },
button: { borderRadius: 10, paddingVertical: 12, paddingHorizontal: 20, marginLeft: 10, minWidth: 80, alignItems: 'center', justifyContent: 'center' },
saveButton: { backgroundColor: colors.pastelOrange },
cancelButton: { backgroundColor: '#E5E7EB' },
inviteButton: { backgroundColor: colors.primary },
buttonText: { color: '#ffffffff', fontSize: 15, fontWeight: '600' },
floatingButtonContainer: {
position: 'absolute',
bottom: 85,
left: 20,
right: 20,
alignItems: 'center',
zIndex: 1,
},
activateButton: {
backgroundColor: colors.pastelOrange,
borderRadius: 14,
paddingVertical: 16,
paddingHorizontal: 32,
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
width: '90%',
alignItems: 'center',
},
activateButtonText: {
color: '#000000ff',
fontSize: 18,
fontWeight: 'bold',
},
});
export default EditGroupScreen;