diff --git a/app/mobile/app/admin/dashboard.tsx b/app/mobile/app/admin/dashboard.tsx new file mode 100644 index 00000000..b9fea64f --- /dev/null +++ b/app/mobile/app/admin/dashboard.tsx @@ -0,0 +1,807 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, Alert, SafeAreaView, Modal, TextInput, ScrollView } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { + getAdminReports, + getReportedUsers, + getAllUsers, + getAdminStatistics, + updateReportStatus, + banUser, + unbanUser, + adminDeleteTask, + Report, + ReportedUser, + UserListItem, + AdminStatistics, + ReportStatus, + ReportType +} from '../../lib/api'; + +export default function AdminDashboard() { + const { colors } = useTheme(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState<'reports' | 'users' | 'all-users' | 'statistics'>('reports'); + const [reportEntityFilter, setReportEntityFilter] = useState<'all' | 'task' | 'user'>('all'); + + const [reports, setReports] = useState([]); + const [users, setUsers] = useState([]); + const [allUsers, setAllUsers] = useState([]); + const [statistics, setStatistics] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // Search and filter for All Users tab + const [userSearch, setUserSearch] = useState(''); + const [userFilter, setUserFilter] = useState<'all' | 'active' | 'banned'>('all'); + + // Action Modal State + const [selectedReport, setSelectedReport] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedRegularUser, setSelectedRegularUser] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [actionLoading, setActionLoading] = useState(false); + const [adminNote, setAdminNote] = useState(''); + const [banReason, setBanReason] = useState(''); + const [unbanReason, setUnbanReason] = useState(''); + + interface ExtendedReport extends Report { + entityType: 'task' | 'user'; + } + + const fetchReports = async () => { + setLoading(true); + try { + const response = await getAdminReports(reportEntityFilter); + const taskReports = response.data.task_reports.reports.map(r => ({ ...r, entityType: 'task' as const })); + const userReports = response.data.user_reports.reports.map(r => ({ ...r, entityType: 'user' as const })); + + const all = [...taskReports, ...userReports].sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + setReports(all); + } catch (error) { + Alert.alert('Error', 'Failed to fetch reports'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + const fetchData = useCallback(async () => { + setLoading(true); + try { + if (activeTab === 'reports') { + await fetchReports(); + } else if (activeTab === 'users') { + const response = await getReportedUsers(); + setUsers(response.data.users); + } else if (activeTab === 'all-users') { + const response = await getAllUsers(1, 50, userSearch, userFilter); + setAllUsers(response.data.users); + } else if (activeTab === 'statistics') { + const response = await getAdminStatistics(); + setStatistics(response.data); + } + } catch (error) { + console.error('Error fetching admin data:', error); + Alert.alert('Error', 'Failed to load data.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [activeTab, reportEntityFilter, userSearch, userFilter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const onRefresh = () => { + setRefreshing(true); + fetchData(); + }; + + const handleStatusUpdate = async (report: ExtendedReport, newStatus: ReportStatus) => { + try { + await updateReportStatus(report.id, report.entityType, newStatus, adminNote); + Alert.alert('Success', 'Report status updated'); + setModalVisible(false); + fetchReports(); + } catch (error) { + Alert.alert('Error', 'Failed to update status'); + } + }; + + const handleBanUser = async () => { + if (!selectedUser || !banReason.trim()) { + Alert.alert('Error', 'Please provide a reason for banning.'); + return; + } + + Alert.alert('Confirm Ban', `Are you sure you want to ban ${selectedUser.username}?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Ban User', + style: 'destructive', + onPress: async () => { + try { + await banUser(selectedUser.user_id, banReason); + Alert.alert('Success', 'User has been banned.'); + setModalVisible(false); + fetchData(); + } catch (error) { + Alert.alert('Error', 'Failed to ban user.'); + } + } + } + ]); + }; + + const handleUnbanUser = async () => { + if (!selectedUser || !unbanReason.trim()) { + Alert.alert('Error', 'Please provide a reason for unbanning.'); + return; + } + + Alert.alert('Confirm Unban', `Are you sure you want to unban ${selectedUser.username}?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Unban User', + style: 'default', + onPress: async () => { + try { + await unbanUser(selectedUser.user_id, unbanReason); + Alert.alert('Success', 'User has been unbanned.'); + setModalVisible(false); + fetchData(); + } catch (error) { + Alert.alert('Error', 'Failed to unban user.'); + } + } + } + ]); + }; + + const renderReportItem = ({ item }: { item: ExtendedReport }) => ( + { + setSelectedReport(item); + setAdminNote(item.admin_notes || ''); + setModalVisible(true); + }} + > + + + {item.entityType.toUpperCase()} + + {new Date(item.created_at).toLocaleDateString()} + + + + {(item.report_type || item.type || 'Unknown').replace(/_/g, ' ')} + {item.description} + + + + Status: + + {item.status} + + + + ); + + const renderUserItem = ({ item }: { item: ReportedUser }) => ( + { + setSelectedUser(item); + setBanReason(''); + setUnbanReason(''); + setModalVisible(true); + }} + > + + {item.username} + + {item.is_active ? 'ACTIVE' : 'BANNED'} + + + + {item.email} + + + + {item.report_count} + Total Reports + + + {item.user_report_count} + User Reports + + + {item.task_report_count} + Task Reports + + + + + + {item.last_reported_at ? new Date(item.last_reported_at).toLocaleDateString() : 'N/A'} + + Last Reported + + + + ); + + const renderAllUserItem = ({ item }: { item: UserListItem }) => ( + { + setSelectedRegularUser(item); + setModalVisible(true); + }} + > + + {item.username} + + {item.is_active ? 'ACTIVE' : 'BANNED'} + + + + {item.email} + {item.name} {item.surname} + + + + {item.rating.toFixed(1)} + Rating + + + {item.completed_task_count} + Tasks + + + + {new Date(item.date_joined).toLocaleDateString()} + + Joined + + + + ); + + const renderStatisticsView = () => { + if (!statistics) return null; + + return ( + + + Users + {statistics.total_users} + + Active: {statistics.active_users} + Inactive: {statistics.inactive_users} + + + + + Tasks + {statistics.total_tasks} + + Posted: {statistics.tasks_by_status.POSTED} + Assigned: {statistics.tasks_by_status.ASSIGNED} + Completed: {statistics.tasks_by_status.COMPLETED} + + + + + Reports + {statistics.total_reports} + + Pending: {statistics.reports_by_status.PENDING} + Under Review: {statistics.reports_by_status.UNDER_REVIEW} + Resolved: {statistics.reports_by_status.RESOLVED} + Dismissed: {statistics.reports_by_status.DISMISSED} + + + + + Recent Activity + {statistics.reports_last_7_days} + Reports in last 7 days + + + ); + }; + + return ( + + + router.back()} style={styles.backButton}> + + + Admin Dashboard + + + + setActiveTab('reports')} + > + Reports + + setActiveTab('users')} + > + Reported + + setActiveTab('all-users')} + > + All Users + + setActiveTab('statistics')} + > + Stats + + + + {activeTab === 'all-users' && ( + + fetchData()} + /> + + {(['all', 'active', 'banned'] as const).map(filter => ( + setUserFilter(filter)} + > + + {filter.charAt(0).toUpperCase() + filter.slice(1)} + + + ))} + + + )} + + {activeTab === 'reports' && ( + + {(['all', 'task', 'user'] as const).map(filter => ( + setReportEntityFilter(filter)} + > + + {filter.charAt(0).toUpperCase() + filter.slice(1)} + + + ))} + + )} + + {loading && !refreshing ? ( + + ) : activeTab === 'statistics' ? ( + renderStatisticsView() + ) : ( + item.id?.toString() || item.user_id?.toString()} + contentContainerStyle={styles.listContent} + refreshing={refreshing} + onRefresh={onRefresh} + ListEmptyComponent={ + No items found. + } + /> + )} + + {/* Detail/Action Modal */} + + + + + + {selectedReport ? 'Manage Report' : selectedUser ? 'Manage User' : 'User Details'} + + { + setModalVisible(false); + setSelectedReport(null); + setSelectedUser(null); + setSelectedRegularUser(null); + }}> + + + + + + {selectedReport && ( + <> + Description: + {selectedReport.description} + + Admin Notes: + + + Update Status: + + handleStatusUpdate(selectedReport as ExtendedReport, ReportStatus.DISMISSED)} + > + Dismiss + + handleStatusUpdate(selectedReport as ExtendedReport, ReportStatus.RESOLVED)} + > + Resolve + + + + )} + + {selectedUser && ( + <> + User: + {selectedUser.username} ({selectedUser.email}) + + Report Count: + {selectedUser.report_count} + + {selectedUser.is_active ? ( + <> + Ban Reason: + + + Ban User + + + ) : ( + <> + User is banned. + Unban Reason: + + + Unban User + + + )} + + )} + + {selectedRegularUser && ( + <> + Username: + {selectedRegularUser.username} + + Email: + {selectedRegularUser.email} + + Name: + {selectedRegularUser.name} {selectedRegularUser.surname} + + Location: + {selectedRegularUser.location || 'Not specified'} + + Rating: + {selectedRegularUser.rating.toFixed(1)} / 5.0 + + Completed Tasks: + {selectedRegularUser.completed_task_count} + + Joined: + {new Date(selectedRegularUser.date_joined).toLocaleDateString()} + + Status: + + {selectedRegularUser.is_active ? 'ACTIVE' : 'BANNED'} + + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#ccc', + }, + backButton: { + marginRight: 16, + }, + headerTitle: { + fontSize: 20, + fontWeight: 'bold', + }, + tabs: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#ccc', + }, + tab: { + flex: 1, + paddingVertical: 12, + alignItems: 'center', + }, + tabText: { + fontSize: 14, + fontWeight: '600', + }, + searchBar: { + padding: 12, + }, + searchInput: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + marginBottom: 12, + }, + filterBar: { + flexDirection: 'row', + padding: 12, + gap: 8, + }, + filterChips: { + flexDirection: 'row', + gap: 8, + }, + filterChip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + }, + filterChipText: { + fontSize: 14, + fontWeight: '600', + }, + loader: { + marginTop: 20, + }, + listContent: { + padding: 16, + }, + emptyText: { + textAlign: 'center', + marginTop: 20, + fontSize: 16, + }, + card: { + borderRadius: 12, + borderWidth: 1, + padding: 16, + marginBottom: 12, + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + badge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + }, + badgeText: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, + date: { + fontSize: 12, + opacity: 0.7, + }, + reason: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + }, + description: { + fontSize: 14, + marginBottom: 8, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + }, + statusLabel: { + fontSize: 14, + marginRight: 4, + }, + statusValue: { + fontSize: 14, + fontWeight: '600', + }, + username: { + fontSize: 18, + fontWeight: 'bold', + }, + email: { + fontSize: 14, + marginBottom: 8, + opacity: 0.8, + }, + statsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + stat: { + alignItems: 'center', + }, + statValue: { + fontSize: 18, + fontWeight: 'bold', + }, + statLabel: { + fontSize: 12, + opacity: 0.7, + }, + statsContainer: { + flex: 1, + }, + statsContent: { + padding: 16, + }, + statCard: { + borderRadius: 12, + borderWidth: 1, + padding: 20, + marginBottom: 16, + }, + statCardTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 12, + }, + statCardValue: { + fontSize: 36, + fontWeight: 'bold', + marginBottom: 16, + }, + statCardRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + statCardColumn: { + gap: 8, + }, + statCardLabel: { + fontSize: 14, + opacity: 0.8, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + height: '80%', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 20, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + }, + modalBody: { + paddingBottom: 40, + }, + label: { + fontSize: 16, + fontWeight: '600', + marginBottom: 8, + }, + value: { + fontSize: 16, + marginBottom: 16, + }, + input: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + minHeight: 80, + textAlignVertical: 'top', + marginBottom: 16, + }, + actionButtons: { + flexDirection: 'row', + gap: 12, + }, + actionButton: { + flex: 1, + padding: 12, + borderRadius: 8, + alignItems: 'center', + }, + actionButtonText: { + color: 'white', + fontWeight: 'bold', + fontSize: 16, + }, + banButton: { + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 8, + }, + bannedText: { + fontSize: 16, + fontWeight: 'bold', + textAlign: 'center', + }, +}); diff --git a/app/mobile/app/profile.tsx b/app/mobile/app/profile.tsx index 1e101847..25916b19 100644 --- a/app/mobile/app/profile.tsx +++ b/app/mobile/app/profile.tsx @@ -10,6 +10,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import RequestCard from '../components/ui/RequestCard'; import { Ionicons } from '@expo/vector-icons'; import type { ThemeTokens } from '../constants/Colors'; +import { ReportModal } from '../components/ui/ReportModal'; export default function ProfileScreen() { const { colors } = useTheme(); @@ -33,9 +34,25 @@ export default function ProfileScreen() { const [reviews, setReviews] = useState([]); const [reviewsLoading, setReviewsLoading] = useState(false); const [reviewsError, setReviewsError] = useState(null); + const [reportModalVisible, setReportModalVisible] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); const [selectedTab, setSelectedTab] = useState<'volunteer' | 'requester'>(initialTab); + // Check admin status from AsyncStorage + useEffect(() => { + const checkAdminStatus = async () => { + try { + const adminStatus = await AsyncStorage.getItem('isAdmin'); + setIsAdmin(adminStatus === 'true'); + } catch (error) { + console.error('Error checking admin status:', error); + setIsAdmin(false); + } + }; + checkAdminStatus(); + }, []); + useEffect(() => { if (!targetUserId) { if (!user && !viewedUserId) { @@ -73,22 +90,22 @@ export default function ProfileScreen() { const parsedProfile: UserProfile = JSON.parse(profileDataFromStorage); console.log('[ProfileScreen] Parsed AsyncStorage profile:', JSON.stringify(parsedProfile)); if (parsedProfile.id === user.id) { - console.log('[ProfileScreen] Using profile from AsyncStorage.'); - setProfile(parsedProfile); + console.log('[ProfileScreen] Using profile from AsyncStorage.'); + setProfile(parsedProfile); } else { - console.log('[ProfileScreen] AsyncStorage profile ID mismatch. Fetching from API.'); - const fetchedProfile = await getUserProfile(user.id); - console.log('[ProfileScreen] API response for own user (after mismatch, direct):', JSON.stringify(fetchedProfile)); - if (fetchedProfile?.id) { - setProfile(fetchedProfile); - await AsyncStorage.setItem('userProfile', JSON.stringify(fetchedProfile)); - console.log('[ProfileScreen] Set profile from API (after mismatch) and updated AsyncStorage.'); - } else { - setProfile(null); - await AsyncStorage.removeItem('userProfile'); - setError('Failed to load your profile.'); - console.log('[ProfileScreen] Failed to load own profile from API (after mismatch).'); - } + console.log('[ProfileScreen] AsyncStorage profile ID mismatch. Fetching from API.'); + const fetchedProfile = await getUserProfile(user.id); + console.log('[ProfileScreen] API response for own user (after mismatch, direct):', JSON.stringify(fetchedProfile)); + if (fetchedProfile?.id) { + setProfile(fetchedProfile); + await AsyncStorage.setItem('userProfile', JSON.stringify(fetchedProfile)); + console.log('[ProfileScreen] Set profile from API (after mismatch) and updated AsyncStorage.'); + } else { + setProfile(null); + await AsyncStorage.removeItem('userProfile'); + setError('Failed to load your profile.'); + console.log('[ProfileScreen] Failed to load own profile from API (after mismatch).'); + } } } else { console.log('[ProfileScreen] No profile in AsyncStorage. Fetching from API for own user.'); @@ -106,9 +123,9 @@ export default function ProfileScreen() { } } } else { - setError('Cannot determine which profile to load.'); - console.log('[ProfileScreen] Cannot determine target user ID.'); - setProfile(null); + setError('Cannot determine which profile to load.'); + console.log('[ProfileScreen] Cannot determine target user ID.'); + setProfile(null); } } catch (err: any) { console.error('[ProfileScreen] CATCH BLOCK ERROR in loadProfileForTargetUser:', err); @@ -129,7 +146,7 @@ export default function ProfileScreen() { setUserVolunteers([]); return; } - + // Fetch tasks getTasks() .then((res) => { @@ -139,7 +156,7 @@ export default function ProfileScreen() { Alert.alert('Error', 'Could not load task lists.'); setAllTasks([]); }); - + // Fetch user's volunteer records if viewing own profile or if user is logged in if (user && (targetUserId === user.id || !viewedUserId)) { listVolunteers() @@ -172,12 +189,12 @@ export default function ProfileScreen() { // Helper function to check if a task is assigned to the target user const isTaskAssignedToUser = (task: Task): boolean => { if (!targetUserId) return false; - + // Check single assignee field if (task.assignee && task.assignee.id === targetUserId) { return true; } - + // Check if user has an ACCEPTED volunteer record for this task if (user && targetUserId === user.id) { const volunteerRecord = userVolunteers.find(v => { @@ -186,7 +203,7 @@ export default function ProfileScreen() { }); return volunteerRecord !== undefined; } - + return false; }; @@ -251,14 +268,17 @@ export default function ProfileScreen() { useEffect(() => { if (!profile?.id) { - setReviews([]); - return; + setReviews([]); + return; } setReviewsLoading(true); setReviewsError(null); getUserReviews(profile.id) .then(res => setReviews(res.data.reviews || [])) - .catch(() => setReviewsError('Failed to load reviews')) + .catch((error) => { + console.error('Error fetching reviews:', error); + setReviewsError('Failed to load reviews'); + }) .finally(() => setReviewsLoading(false)); }, [profile?.id]); @@ -278,38 +298,38 @@ export default function ProfileScreen() { } if (loading) { - return ; + return ; } if (error && !profile) { return ( - - {error} - { - const targetUserIdForRetry = viewedUserId || user?.id; - if (targetUserIdForRetry) { - setLoading(true); - setError(null); - getUserProfile(targetUserIdForRetry) - .then(fetchedProfile => { - setProfile(fetchedProfile); - if (!viewedUserId && fetchedProfile?.id) { - AsyncStorage.setItem('userProfile', JSON.stringify(fetchedProfile)); - } - }) - .catch(() => setError(viewedUserId? 'Failed to load user profile.' : 'Failed to load your profile.')) - .finally(() => setLoading(false)); - } else { - Alert.alert("Error", "Cannot retry: User ID is not available."); - } - }} - > - Retry - - - ); + + {error} + { + const targetUserIdForRetry = viewedUserId || user?.id; + if (targetUserIdForRetry) { + setLoading(true); + setError(null); + getUserProfile(targetUserIdForRetry) + .then(fetchedProfile => { + setProfile(fetchedProfile); + if (!viewedUserId && fetchedProfile?.id) { + AsyncStorage.setItem('userProfile', JSON.stringify(fetchedProfile)); + } + }) + .catch(() => setError(viewedUserId ? 'Failed to load user profile.' : 'Failed to load your profile.')) + .finally(() => setLoading(false)); + } else { + Alert.alert("Error", "Cannot retry: User ID is not available."); + } + }} + > + Retry + + + ); } if (!profile) { @@ -346,7 +366,7 @@ export default function ProfileScreen() { @@ -370,21 +390,30 @@ export default function ProfileScreen() { /> - {isOwnProfile && ( - <> - router.push('/notifications')} style={{ marginLeft: 12 }}> - - - router.push('/settings')} style={{ marginLeft: 12 }}> - - - + {isOwnProfile ? ( + <> + {isAdmin && ( + router.push('/admin/dashboard')} style={{ marginLeft: 12 }}> + + + )} + router.push('/notifications')} style={{ marginLeft: 12 }}> + + + router.push('/settings')} style={{ marginLeft: 12 }}> + + + + ) : ( + setReportModalVisible(true)} style={{ marginLeft: 12 }}> + + )} setSelectedTab('volunteer')} > @@ -392,7 +421,7 @@ export default function ProfileScreen() { setSelectedTab('requester')} > @@ -435,29 +464,40 @@ export default function ProfileScreen() { )} - - - - Reviews for {profile ? profile.name : 'User'} - - {reviewsLoading && } - {reviewsError && {reviewsError}} - {!reviewsLoading && !reviewsError && reviews.length === 0 && ( - No reviews yet for this user. - )} - {!reviewsLoading && !reviewsError && reviews.map((review) => ( - - ))} + + + + Reviews for {profile ? profile.name : 'User'} + + {reviewsLoading && } + {reviewsError && {reviewsError}} + {!reviewsLoading && !reviewsError && reviews.length === 0 && ( + No reviews yet for this user. + )} + {!reviewsLoading && !reviewsError && reviews.map((review) => ( + + ))} + + {profile && ( + setReportModalVisible(false)} + targetId={profile.id} + targetType="user" + targetName={displayName} + /> + )} + ); } @@ -543,4 +583,4 @@ const styles = StyleSheet.create({ fontSize: 15, marginVertical: 16, } -}); +}); diff --git a/app/mobile/app/r-request-details.tsx b/app/mobile/app/r-request-details.tsx index f32280e3..fd4b4a8c 100644 --- a/app/mobile/app/r-request-details.tsx +++ b/app/mobile/app/r-request-details.tsx @@ -25,6 +25,7 @@ import { CategoryPicker } from '../components/forms/CategoryPicker'; import { DeadlinePicker } from '../components/forms/DeadlinePicker'; import { AddressFields } from '../components/forms/AddressFields'; import { AddressFieldsValue, emptyAddress, parseAddressString, formatAddress } from '../utils/address'; +import { ReportModal } from '../components/ui/ReportModal'; export default function RequestDetails() { const params = useLocalSearchParams(); @@ -43,6 +44,7 @@ export default function RequestDetails() { const [cancellingTask, setCancellingTask] = useState(false); const [modalVisible, setModalVisible] = useState(false); + const [reportModalVisible, setReportModalVisible] = useState(false); const [isEdit, setIsEdit] = useState(false); const [rating, setRating] = useState(0); const [reviewText, setReviewText] = useState(''); @@ -75,8 +77,8 @@ export default function RequestDetails() { (property === 'Text' ? themeColors.text : property === 'Background' - ? themeColors.labelDefaultBackground - : themeColors.labelDefaultBorder || themeColors.border) + ? themeColors.labelDefaultBackground + : themeColors.labelDefaultBorder || themeColors.border) ); }; @@ -175,12 +177,12 @@ export default function RequestDetails() { } setCurrentVolunteerIndex(0); const currentVolunteer = assignedVolunteers[0]; - + // Check if review already exists for this volunteer const existingReview = existingReviews.find( (review) => review.reviewee.id === currentVolunteer.user.id && review.reviewer.id === user?.id ); - + if (existingReview) { setRating(existingReview.score); setReviewText(existingReview.comment); @@ -188,7 +190,7 @@ export default function RequestDetails() { setRating(0); setReviewText(''); } - + setModalVisible(true); setIsEdit(false); }; @@ -201,8 +203,8 @@ export default function RequestDetails() { const hasReviewedAllVolunteers = (): boolean => { if (assignedVolunteers.length === 0) return false; - return assignedVolunteers.every((volunteer) => - existingReviews.some((review) => + return assignedVolunteers.every((volunteer) => + existingReviews.some((review) => review.reviewee.id === volunteer.user.id && review.reviewer.id === user?.id ) ); @@ -286,12 +288,12 @@ export default function RequestDetails() { const nextIndex = currentVolunteerIndex + 1; setCurrentVolunteerIndex(nextIndex); const nextVolunteer = assignedVolunteers[nextIndex]; - + // Load existing review for next volunteer if it exists const nextReview = updatedReviews.find( (review) => review.reviewee.id === nextVolunteer.user.id && review.reviewer.id === user?.id ); - + if (nextReview) { setRating(nextReview.score); setReviewText(nextReview.comment); @@ -299,7 +301,7 @@ export default function RequestDetails() { setRating(0); setReviewText(''); } - + Alert.alert('Success', `Review submitted for ${currentVolunteer.user.name}!`); } else { // All volunteers reviewed @@ -340,8 +342,8 @@ export default function RequestDetails() { return; } - if (!addressFields.city.trim() || !addressFields.district.trim()) { - Alert.alert('Validation Error', 'Please select a city and district for the address.'); + if (!addressFields.city.trim() || !addressFields.state.trim()) { + Alert.alert('Validation Error', 'Please select a city and state for the address.'); return; } @@ -405,12 +407,12 @@ export default function RequestDetails() { style: 'destructive', onPress: async () => { if (!id || !request) return; - + setCompletingTask(true); try { const response = await completeTask(id); Alert.alert('Success', response.message || 'Request marked as completed successfully!'); - + // Refresh task data to get updated status await fetchTaskData(); } catch (err: any) { @@ -439,12 +441,12 @@ export default function RequestDetails() { style: 'destructive', onPress: async () => { if (!id || !request) return; - + setCancellingTask(true); try { const response = await cancelTask(id); Alert.alert('Success', response.message || 'Request deleted successfully!'); - + // Navigate back to feed router.back(); } catch (err: any) { @@ -582,36 +584,36 @@ export default function RequestDetails() { const firstPhoto = photos[0]; const photoUrl = firstPhoto.photo_url || firstPhoto.url || firstPhoto.image || ''; console.log(photoUrl); - const absoluteUrl = photoUrl.startsWith('http') - ? photoUrl + const absoluteUrl = photoUrl.startsWith('http') + ? photoUrl : `${BACKEND_BASE_URL}${photoUrl}`; return ( - ); })()} - + {/* Show remaining photos as thumbnails if there are more */} {photos.length > 1 && ( - {photos.slice(1).map((photo) => { const photoUrl = photo.photo_url || photo.url || photo.image || ''; - const absoluteUrl = photoUrl.startsWith('http') - ? photoUrl + const absoluteUrl = photoUrl.startsWith('http') + ? photoUrl : `${BACKEND_BASE_URL}${photoUrl}`; - + return ( - @@ -625,7 +627,7 @@ export default function RequestDetails() { ) : ( )} - + Requester @@ -726,17 +728,26 @@ export default function RequestDetails() { )} {!isCreator && ( - - router.push({ - pathname: '/select-volunteer', - params: { id }, - }) - } - > - Volunteer for this Request - + <> + + router.push({ + pathname: '/select-volunteer', + params: { id }, + }) + } + > + Volunteer for this Request + + + setReportModalVisible(true)} + > + Report Request + + )} {isCreator && isCompleted && ( @@ -756,7 +767,7 @@ export default function RequestDetails() { onPress={handleOpenReviewModal} > - {hasReviewedAllVolunteers() + {hasReviewedAllVolunteers() ? `Edit Rate & Review ${numAssigned === 1 ? 'Volunteer' : 'Volunteers'}` : `Rate & Review ${numAssigned === 1 ? 'Volunteer' : 'Volunteers'}` } @@ -766,20 +777,30 @@ export default function RequestDetails() { )} + {request && ( + setReportModalVisible(false)} + targetId={request.id} + targetType="task" + targetName={request.title} + /> + )} + - {isEdit - ? 'Edit Request' - : assignedVolunteers.length > 0 + {isEdit + ? 'Edit Request' + : assignedVolunteers.length > 0 ? (() => { - const currentVolunteer = assignedVolunteers[currentVolunteerIndex]; - const existingReview = getExistingReviewForVolunteer(currentVolunteer?.user?.id); - return existingReview - ? `Edit Rate & Review ${currentVolunteer?.user?.name || 'Volunteer'}` - : `Rate & Review ${currentVolunteer?.user?.name || 'Volunteer'}`; - })() + const currentVolunteer = assignedVolunteers[currentVolunteerIndex]; + const existingReview = getExistingReviewForVolunteer(currentVolunteer?.user?.id); + return existingReview + ? `Edit Rate & Review ${currentVolunteer?.user?.name || 'Volunteer'}` + : `Rate & Review ${currentVolunteer?.user?.name || 'Volunteer'}`; + })() : 'Rate Request' } diff --git a/app/mobile/app/signin.tsx b/app/mobile/app/signin.tsx index 278558ad..f041756c 100644 --- a/app/mobile/app/signin.tsx +++ b/app/mobile/app/signin.tsx @@ -17,7 +17,7 @@ import { ScrollView, Platform } from 'react-native'; -import { login } from '../lib/api'; +import { login, checkIsAdmin } from '../lib/api'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAuth } from '../lib/auth'; @@ -42,13 +42,19 @@ export default function SignIn() { console.log('Attempting login with:', { email }); const response = await login(email, password); console.log('Login successful:', response); - + // The login function already fetches and stores the user profile // Just set the user in auth context and navigate if (response.data?.user_id) { await setUser({ id: response.data.user_id, email }); + + // Check if user is admin + console.log('Checking admin status...'); + const isAdmin = await checkIsAdmin(); + console.log('Admin status:', isAdmin); + await AsyncStorage.setItem('isAdmin', JSON.stringify(isAdmin)); } - + // Navigate to feed router.replace('/feed'); } catch (error: any) { @@ -79,9 +85,10 @@ export default function SignIn() { if (router.canGoBack()) { router.back(); } else { - router.replace('/');} + router.replace('/'); } - }> + } + }> Back @@ -186,7 +193,7 @@ const styles = StyleSheet.create({ header: { marginBottom: 20, textAlign: 'auto' }, - title: { + title: { fontSize: 32, fontWeight: '700', }, @@ -203,12 +210,12 @@ const styles = StyleSheet.create({ marginBottom: 16, paddingBottom: 4, }, - input: { + input: { flex: 1, marginLeft: 8, height: 40, }, - rememberWrapper: { + rememberWrapper: { flexDirection: 'row', alignItems: 'center', marginBottom: 24, @@ -226,19 +233,19 @@ const styles = StyleSheet.create({ fontWeight: 'bold', fontSize: 16, }, - forgotText: { + forgotText: { textAlign: 'center', fontSize: 14, marginBottom: 144, }, - signupPrompt: { + signupPrompt: { flexDirection: 'row', justifyContent: 'center', }, - promptText: { + promptText: { fontSize: 14, }, - promptLink: { + promptLink: { fontSize: 14, fontWeight: '500', }, diff --git a/app/mobile/app/v-request-details.tsx b/app/mobile/app/v-request-details.tsx index 296c2547..a369aad1 100644 --- a/app/mobile/app/v-request-details.tsx +++ b/app/mobile/app/v-request-details.tsx @@ -20,6 +20,7 @@ import { getTaskDetails, listVolunteers, type Task, type Volunteer, volunteerFor import { useAuth } from '../lib/auth'; import AsyncStorage from '@react-native-async-storage/async-storage'; import type { ThemeTokens } from '../constants/Colors'; +import { ReportModal } from '../components/ui/ReportModal'; export default function RequestDetailsVolunteer() { const params = useLocalSearchParams(); @@ -45,6 +46,7 @@ export default function RequestDetailsVolunteer() { const [volunteerRecord, setVolunteerRecord] = useState<{ id: number; status?: string } | null>(null); const [photos, setPhotos] = useState([]); const [photosLoading, setPhotosLoading] = useState(false); + const [reportModalVisible, setReportModalVisible] = useState(false); const storageKey = id && user?.id ? `volunteer-record-${id}-${user.id}` : null; const legacyStorageKey = id ? `volunteer-record-${id}` : null; const volunteerRecordRef = useRef<{ id: number; status?: string } | null>(null); @@ -69,8 +71,8 @@ export default function RequestDetailsVolunteer() { (property === 'Text' ? themeColors.text : property === 'Background' - ? 'transparent' - : themeColors.border) + ? 'transparent' + : themeColors.border) ); }; @@ -89,7 +91,7 @@ export default function RequestDetailsVolunteer() { const isCreatorView = user?.id && details.creator?.id === user.id; const currentRecord = volunteerRecordRef.current; - const taskVolunteers = await listVolunteers( {task:id,limit: 100 }); + const taskVolunteers = await listVolunteers({ task: id, limit: 100 }); const volunteers = taskVolunteers.filter((vol) => { const taskField = typeof (vol as any).task === 'number' ? (vol as any).task : (vol.task as any)?.id; @@ -118,7 +120,7 @@ export default function RequestDetailsVolunteer() { console.warn('Failed to persist volunteer state:', storageError); }); if (legacyStorageKey) { - AsyncStorage.removeItem(legacyStorageKey).catch(() => {}); + AsyncStorage.removeItem(legacyStorageKey).catch(() => { }); } } return updated; @@ -134,14 +136,14 @@ export default function RequestDetailsVolunteer() { console.warn('Failed to persist volunteer state:', storageError); }); if (legacyStorageKey) { - AsyncStorage.removeItem(legacyStorageKey).catch(() => {}); + AsyncStorage.removeItem(legacyStorageKey).catch(() => { }); } } } else { setHasVolunteered(false); setVolunteerRecord(null); if (storageKey) { - AsyncStorage.removeItem(storageKey).catch(() => {}); + AsyncStorage.removeItem(storageKey).catch(() => { }); } } @@ -152,7 +154,7 @@ export default function RequestDetailsVolunteer() { if (reviewsResponse.status === 'success') { setExistingReviews(reviewsResponse.data.reviews || []); } - + // Build list of reviewable participants (creator + other volunteers, excluding self) const participants: UserProfile[] = []; if (details.creator && details.creator.id !== user.id) { @@ -197,70 +199,70 @@ export default function RequestDetailsVolunteer() { } }, [id, user?.id, storageKey]); -useEffect(() => { - fetchRequestDetails(); -}, [fetchRequestDetails]); - -useFocusEffect( - useCallback(() => { + useEffect(() => { fetchRequestDetails(); - }, [fetchRequestDetails]) -); + }, [fetchRequestDetails]); -useEffect(() => { - let isMounted = true; - const hydrateVolunteerState = async () => { - if (!storageKey) { - if (isMounted) { - setVolunteerRecord(null); - setHasVolunteered(false); - } - if (legacyStorageKey) { - AsyncStorage.removeItem(legacyStorageKey).catch(() => {}); - } - return; - } - try { - let value = await AsyncStorage.getItem(storageKey); - if (!value && legacyStorageKey) { - value = await AsyncStorage.getItem(legacyStorageKey); - if (value) { - await AsyncStorage.setItem(storageKey, value); - await AsyncStorage.removeItem(legacyStorageKey); + useFocusEffect( + useCallback(() => { + fetchRequestDetails(); + }, [fetchRequestDetails]) + ); + + useEffect(() => { + let isMounted = true; + const hydrateVolunteerState = async () => { + if (!storageKey) { + if (isMounted) { + setVolunteerRecord(null); + setHasVolunteered(false); + } + if (legacyStorageKey) { + AsyncStorage.removeItem(legacyStorageKey).catch(() => { }); } - } - if (!isMounted) { return; } - if (value) { - try { - const parsed = JSON.parse(value); - setVolunteerRecord(parsed); - setHasVolunteered(isActiveVolunteerStatus(parsed.status)); - } catch (parseError) { - console.warn('Failed to parse stored volunteer record:', parseError); + try { + let value = await AsyncStorage.getItem(storageKey); + if (!value && legacyStorageKey) { + value = await AsyncStorage.getItem(legacyStorageKey); + if (value) { + await AsyncStorage.setItem(storageKey, value); + await AsyncStorage.removeItem(legacyStorageKey); + } + } + if (!isMounted) { + return; + } + if (value) { + try { + const parsed = JSON.parse(value); + setVolunteerRecord(parsed); + setHasVolunteered(isActiveVolunteerStatus(parsed.status)); + } catch (parseError) { + console.warn('Failed to parse stored volunteer record:', parseError); + setVolunteerRecord(null); + setHasVolunteered(false); + } + } else { + setVolunteerRecord(null); + setHasVolunteered(false); + } + } catch (storageError) { + console.warn('Failed to read volunteer state from storage:', storageError); + if (isMounted) { setVolunteerRecord(null); setHasVolunteered(false); } - } else { - setVolunteerRecord(null); - setHasVolunteered(false); - } - } catch (storageError) { - console.warn('Failed to read volunteer state from storage:', storageError); - if (isMounted) { - setVolunteerRecord(null); - setHasVolunteered(false); } - } - }; + }; - hydrateVolunteerState(); + hydrateVolunteerState(); - return () => { - isMounted = false; - }; -}, [storageKey]); + return () => { + isMounted = false; + }; + }, [storageKey]); const handleStarPress = (star: number) => setRating(star); @@ -273,8 +275,8 @@ useEffect(() => { const hasReviewedAllParticipants = (): boolean => { if (reviewableParticipants.length === 0) return false; - return reviewableParticipants.every((participant) => - existingReviews.some((review) => + return reviewableParticipants.every((participant) => + existingReviews.some((review) => review.reviewee.id === participant.id && review.reviewer.id === user?.id ) ); @@ -287,10 +289,10 @@ useEffect(() => { } setCurrentReviewIndex(0); const currentParticipant = reviewableParticipants[0]; - + // Check if review already exists for this participant const existingReview = getExistingReviewForParticipant(currentParticipant.id); - + if (existingReview) { setRating(existingReview.score); setReviewText(existingReview.comment); @@ -344,12 +346,12 @@ useEffect(() => { const nextIndex = currentReviewIndex + 1; setCurrentReviewIndex(nextIndex); const nextParticipant = reviewableParticipants[nextIndex]; - + // Load existing review for next participant if it exists const nextReview = updatedReviews.find( (review) => review.reviewee.id === nextParticipant.id && review.reviewer.id === user?.id ); - + if (nextReview) { setRating(nextReview.score); setReviewText(nextReview.comment); @@ -357,7 +359,7 @@ useEffect(() => { setRating(0); setReviewText(''); } - + Alert.alert('Success', `Review submitted for ${currentParticipant.name}!`); } else { // All participants reviewed @@ -409,7 +411,7 @@ useEffect(() => { console.warn('Failed to persist volunteer state:', storageError); }); if (legacyStorageKey) { - AsyncStorage.removeItem(legacyStorageKey).catch(() => {}); + AsyncStorage.removeItem(legacyStorageKey).catch(() => { }); } } } @@ -444,9 +446,9 @@ useEffect(() => { setVolunteerRecord(updatedRecord); volunteerRecordRef.current = updatedRecord; if (storageKey) { - AsyncStorage.setItem(storageKey, JSON.stringify(updatedRecord)).catch(() => {}); + AsyncStorage.setItem(storageKey, JSON.stringify(updatedRecord)).catch(() => { }); if (legacyStorageKey) { - AsyncStorage.removeItem(legacyStorageKey).catch(() => {}); + AsyncStorage.removeItem(legacyStorageKey).catch(() => { }); } } await fetchRequestDetails(); @@ -505,24 +507,24 @@ useEffect(() => { const volunteerStatusMessage = !isCreatorView && (userAssigned || ['pending', 'accepted', 'rejected', 'withdrawn'].includes(volunteerStatusLabel ?? '')) ? (() => { - if (userAssigned || volunteerStatusLabel === 'accepted') { - console.log("userAssigned", userAssigned); - console.log("volunteerStatusLabel", volunteerStatusLabel); - console.log(request); - console.log(user); - return 'You have been assigned to this request.'; - } - if (volunteerStatusLabel === 'rejected') { - return 'Your volunteer request was declined.'; - } - if (volunteerStatusLabel === 'withdrawn') { - return 'You withdrew your volunteer request. Contact the requester if you wish to volunteer again.'; - } - if (volunteerStatusLabel === 'pending') { - return 'Your volunteer request is pending approval.'; - } - return 'You have volunteered for this request.'; - })() + if (userAssigned || volunteerStatusLabel === 'accepted') { + console.log("userAssigned", userAssigned); + console.log("volunteerStatusLabel", volunteerStatusLabel); + console.log(request); + console.log(user); + return 'You have been assigned to this request.'; + } + if (volunteerStatusLabel === 'rejected') { + return 'Your volunteer request was declined.'; + } + if (volunteerStatusLabel === 'withdrawn') { + return 'You withdrew your volunteer request. Contact the requester if you wish to volunteer again.'; + } + if (volunteerStatusLabel === 'pending') { + return 'Your volunteer request is pending approval.'; + } + return 'You have volunteered for this request.'; + })() : null; const showWithdrawButton = @@ -579,18 +581,18 @@ useEffect(() => { {(() => { const firstPhoto = photos[0]; const photoUrl = firstPhoto.photo_url || firstPhoto.url || firstPhoto.image || ''; - const absoluteUrl = photoUrl.startsWith('http') - ? photoUrl + const absoluteUrl = photoUrl.startsWith('http') + ? photoUrl : `${BACKEND_BASE_URL}${photoUrl}`; return ( - ); })()} - + {/* Show remaining photos as thumbnails if there are more */} {photos.length > 1 && ( @@ -601,14 +603,14 @@ useEffect(() => { > {photos.slice(1).map((photo) => { const photoUrl = photo.photo_url || photo.url || photo.image || ''; - const absoluteUrl = photoUrl.startsWith('http') - ? photoUrl + const absoluteUrl = photoUrl.startsWith('http') + ? photoUrl : `${BACKEND_BASE_URL}${photoUrl}`; - + return ( - @@ -620,7 +622,7 @@ useEffect(() => { )} )} - + { }} > - {hasReviewedAllParticipants() + {hasReviewedAllParticipants() ? `Edit Rate & Review ${reviewableParticipants.length === 1 ? 'Participant' : 'Participants'}` : `Rate & Review ${reviewableParticipants.length === 1 ? 'Participant' : 'Participants'}` } @@ -742,20 +744,29 @@ useEffect(() => { View Requester Profile )} + + {!isCreator && ( + setReportModalVisible(true)} + > + Report Request + + )} - {reviewableParticipants.length > 0 + {reviewableParticipants.length > 0 ? (() => { - const currentParticipant = reviewableParticipants[currentReviewIndex]; - const existingReview = getExistingReviewForParticipant(currentParticipant?.id); - return existingReview - ? `Edit Rate & Review ${currentParticipant?.name || 'Participant'}` - : `Rate & Review ${currentParticipant?.name || 'Participant'}`; - })() + const currentParticipant = reviewableParticipants[currentReviewIndex]; + const existingReview = getExistingReviewForParticipant(currentParticipant?.id); + return existingReview + ? `Edit Rate & Review ${currentParticipant?.name || 'Participant'}` + : `Rate & Review ${currentParticipant?.name || 'Participant'}`; + })() : 'Rate & Review' } @@ -765,10 +776,10 @@ useEffect(() => { )} { + + setReportModalVisible(false)} + targetType="task" + targetId={request.id} + targetName={request.title} + /> ); } diff --git a/app/mobile/components/ui/ReportModal.tsx b/app/mobile/components/ui/ReportModal.tsx new file mode 100644 index 00000000..1cc283bc --- /dev/null +++ b/app/mobile/components/ui/ReportModal.tsx @@ -0,0 +1,228 @@ +import React, { useState } from 'react'; +import { View, Text, Modal, StyleSheet, TouchableOpacity, TextInput, ActivityIndicator, Alert, ScrollView } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTheme } from '@react-navigation/native'; +import { ReportType, createTaskReport, createUserReport } from '../../lib/api'; + +interface ReportModalProps { + visible: boolean; + onClose: () => void; + targetId: number; + targetType: 'task' | 'user'; + targetName?: string; // Name of the task or user being reported +} + +export function ReportModal({ visible, onClose, targetId, targetType, targetName }: ReportModalProps) { + const { colors } = useTheme(); + const [selectedType, setSelectedType] = useState(null); + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(false); + + const reportTypes = Object.values(ReportType); + + const handleSubmit = async () => { + if (!selectedType) { + Alert.alert('Error', 'Please select a reason for reporting.'); + return; + } + if (!description.trim()) { + Alert.alert('Error', 'Please provide a description.'); + return; + } + + setLoading(true); + try { + if (targetType === 'task') { + await createTaskReport(targetId, selectedType, description); + } else { + await createUserReport(targetId, selectedType, description); + } + Alert.alert('Success', 'Report submitted successfully.'); + onClose(); + // Reset form + setSelectedType(null); + setDescription(''); + } catch (error) { + Alert.alert('Error', 'Failed to submit report. Please try again.'); + } finally { + setLoading(false); + } + }; + + const getReadableType = (type: string) => { + return type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase()); + }; + + return ( + + + + + + Report {targetType === 'task' ? 'Task' : 'User'} + + + + + + + + Why are you reporting {targetName ? `"${targetName}"` : 'this'}? + + + + Reason + + {reportTypes.map((type) => ( + setSelectedType(type)} + > + + {getReadableType(type)} + + + ))} + + + Description + + + + + + Cancel + + + + {loading ? ( + + ) : ( + Submit Report + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + container: { + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 20, + height: '80%', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + }, + subtitle: { + fontSize: 16, + marginBottom: 20, + opacity: 0.8, + }, + content: { + flex: 1, + }, + label: { + fontSize: 16, + fontWeight: '600', + marginBottom: 10, + marginTop: 10, + }, + typeContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + typeButton: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + marginBottom: 8, + }, + typeText: { + fontSize: 14, + }, + input: { + borderWidth: 1, + borderRadius: 12, + padding: 12, + height: 100, + textAlignVertical: 'top', + }, + footer: { + flexDirection: 'row', + gap: 12, + marginTop: 20, + paddingBottom: 20, // Add some padding for safe area if needed + }, + cancelButton: { + flex: 1, + padding: 16, + borderRadius: 12, + borderWidth: 1, + alignItems: 'center', + }, + cancelText: { + fontSize: 16, + fontWeight: '600', + }, + submitButton: { + flex: 1, + padding: 16, + borderRadius: 12, + alignItems: 'center', + }, + submitText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/app/mobile/lib/api.ts b/app/mobile/lib/api.ts index 65b72caa..df454932 100644 --- a/app/mobile/lib/api.ts +++ b/app/mobile/lib/api.ts @@ -31,7 +31,7 @@ const port = Constants.expoConfig?.extra?.apiPort ?? '8000'; // Find your LAN IP with: ipconfig getifaddr en0 // use the first one returned by the command // do not forget to also change the export const API_BASE_URL constant below. -const LOCAL_LAN_IP = '192.168.4.23'; // Change this to your LAN IP if needed +const LOCAL_LAN_IP = '192.168.4.41'; // Change this to your LAN IP if needed const API_HOST = Platform.select({ web: 'localhost', // Web uses localhost @@ -87,6 +87,7 @@ export interface UserProfile { completed_task_count: number; is_active: boolean; photo?: string; + is_staff?: boolean; } export interface UserProfileResponse { @@ -196,15 +197,15 @@ export interface NotificationsListResponse { // Add MarkReadResponse (can be generic if backend sends consistent success/data structure) export interface MarkReadResponse { - status: string; - message: string; - data?: Notification; // mark_as_read returns the updated notification + status: string; + message: string; + data?: Notification; // mark_as_read returns the updated notification } export interface MarkAllReadResponse { - status: string; - message: string; - // data is not typically returned for mark_all_as_read, just a success message + status: string; + message: string; + // data is not typically returned for mark_all_as_read, just a success message } // Add Volunteer Interface @@ -230,7 +231,7 @@ export interface GetTaskApplicantsResponse { export interface UpdateVolunteerStatusResponse { status: string; message: string; - data: Volunteer; + data: Volunteer; } export interface UpdateTaskPayload { @@ -302,11 +303,11 @@ api.interceptors.response.use( baseURL: error.config?.baseURL, fullURL: `${error.config?.baseURL}${error.config?.url}`, platform: Platform.OS, - suggestion: Platform.OS === 'android' + suggestion: Platform.OS === 'android' ? 'Make sure backend is running and use 10.0.2.2 for Android emulator' : Platform.OS === 'ios' - ? 'Try using your LAN IP address instead of localhost for iOS simulator' - : 'Check if backend is accessible from your device' + ? 'Try using your LAN IP address instead of localhost for iOS simulator' + : 'Check if backend is accessible from your device' }); } return Promise.reject(error); @@ -337,18 +338,18 @@ export const register = async ( ): Promise => { try { console.log('Sending registration request to:', `${API_BASE_URL}/auth/register/`); - + // Split full name into name and surname const nameParts = fullName.trim().split(' '); const name = nameParts[0]; const surname = nameParts.slice(1).join(' ') || ''; // Use first name as surname if no surname provided - + // Validate phone number format (10-15 digits, optional + prefix) const phoneRegex = /^\+?[0-9]{10,15}$/; if (!phoneRegex.test(phone)) { throw new Error('Phone number must be 10-15 digits with an optional + prefix'); } - + // Validate password requirements const passwordValidation = { length: password.length >= 8, @@ -357,7 +358,7 @@ export const register = async ( number: /[0-9]/.test(password), special: /[!@#$%^&*(),.?":{}|<>]/.test(password) }; - + if (!Object.values(passwordValidation).every(Boolean)) { const missing = []; if (!passwordValidation.length) missing.push('at least 8 characters'); @@ -367,7 +368,7 @@ export const register = async ( if (!passwordValidation.special) missing.push('a special character'); throw new Error(`Password must contain ${missing.join(', ')}`); } - + // Match backend's expected field order exactly const requestData = { name, @@ -378,7 +379,7 @@ export const register = async ( password, confirm_password: password }; - + console.log('Registration request data:', requestData); const response = await api.post('/auth/register/', requestData); console.log('Registration response:', response.data); @@ -392,11 +393,11 @@ export const register = async ( status: error.response?.status, headers: error.response?.headers }); - + // Check for specific validation errors from backend if (error.response?.data?.data) { const errors = error.response.data.data; - + // Password validation errors if (errors.password) { const passwordErrors = errors.password; @@ -406,7 +407,7 @@ export const register = async ( throw new Error(passwordErrors); } } - + // Phone number validation errors if (errors.phone_number) { const phoneErrors = errors.phone_number; @@ -416,7 +417,7 @@ export const register = async ( throw new Error(phoneErrors); } } - + // Username validation errors if (errors.username) { const usernameErrors = errors.username; @@ -426,7 +427,7 @@ export const register = async ( throw new Error(usernameErrors); } } - + // Email validation errors if (errors.email) { const emailErrors = errors.email; @@ -436,7 +437,7 @@ export const register = async ( throw new Error(emailErrors); } } - + // Name validation errors if (errors.name) { const nameErrors = errors.name; @@ -446,7 +447,7 @@ export const register = async ( throw new Error(nameErrors); } } - + // Surname validation errors if (errors.surname) { const surnameErrors = errors.surname; @@ -472,11 +473,11 @@ export const login = async (email: string, password: string): Promise => { try { console.log('Fetching user profile for (expecting direct object):', userId); // Expect UserProfile directly as response.data - const response = await api.get(`/users/${userId}/`); + const response = await api.get(`/users/${userId}/`); console.log('User profile response (direct object expected):', response.data); return response.data; // response.data should now be UserProfile } catch (error) { @@ -679,7 +680,7 @@ export const getPopularTasks = async (limit: number = 6): Promise => { params: { limit }, }); console.log('Popular tasks response:', response.data); - + // Handle different response formats if (response.data?.data && Array.isArray(response.data.data)) { return response.data.data as Task[]; @@ -800,13 +801,13 @@ export const createTask = async (taskData: { console.log('Creating task:', taskData); const response = await api.post('/tasks/', taskData); console.log('Create task response:', response.data); - + // Backend returns { status, message, data: Task } // Extract the actual task from the nested structure if (response.data && typeof response.data === 'object' && 'data' in response.data) { return (response.data as any).data as Task; } - + // Fallback: if response is directly a Task return response.data as Task; } catch (error) { @@ -927,7 +928,7 @@ export const createReview = async (data: CreateReviewRequest): Promise 0) { - errMessage = Array.isArray(validationErrors[0]) - ? validationErrors[0][0] + errMessage = Array.isArray(validationErrors[0]) + ? validationErrors[0][0] : String(validationErrors[0]); } } - + throw new Error(errMessage); } const errMessage = (error as Error).message || 'An unexpected error occurred while trying to create review.'; @@ -1061,7 +1062,7 @@ export const getTaskApplicants = async (taskId: number, status: string = 'PENDIN } }); console.log(`Get task applicants for ${taskId} (status: ${status}) response:`, response.data); - return response.data; + return response.data; } catch (error) { if (error instanceof AxiosError) { console.error(`Get task ${taskId} applicants error details:`, { @@ -1257,7 +1258,7 @@ export const getTaskPhotos = async (taskId: number): Promise status: error.response?.status, headers: error.response?.headers, }); - + // If 404, it likely means no photos exist for this task yet - return empty array if (error.response?.status === 404) { console.log(`No photos found for task ${taskId}, returning empty array`); @@ -1281,10 +1282,10 @@ export const uploadTaskPhoto = async ( try { // Create FormData for multipart upload const formData = new FormData(); - + // Extract file extension from fileName or URI const fileExtension = fileName.split('.').pop()?.toLowerCase() || 'jpg'; - + // Determine MIME type based on file extension let mimeType = 'image/jpeg'; if (fileExtension === 'png') { @@ -1294,7 +1295,7 @@ export const uploadTaskPhoto = async ( } else if (fileExtension === 'webp') { mimeType = 'image/webp'; } - + // Append the photo file to FormData // On React Native, we need to use a specific format for file uploads formData.append('photo', { @@ -1335,4 +1336,380 @@ export const uploadTaskPhoto = async ( } }; +// --- Report & Admin API --- + +export enum ReportType { + SPAM = 'SPAM', + INAPPROPRIATE_CONTENT = 'INAPPROPRIATE_CONTENT', + HARASSMENT = 'HARASSMENT', + FRAUD = 'FRAUD', + FAKE_REQUEST = 'FAKE_REQUEST', + NO_SHOW = 'NO_SHOW', + SAFETY_CONCERN = 'SAFETY_CONCERN', + OTHER = 'OTHER', +} + +export enum ReportStatus { + PENDING = 'PENDING', + UNDER_REVIEW = 'UNDER_REVIEW', + RESOLVED = 'RESOLVED', + DISMISSED = 'DISMISSED', +} + +export interface Report { + id: number; + reporter: string; // username + reported_item_id: number; // task_id or user_id + type: ReportType; + description: string; + status: ReportStatus; + created_at: string; + updated_at: string; + admin_notes?: string; + reviewed_by?: string; +} + +export interface ReportStatistics { + total_task_reports: number; + pending_task_reports: number; + total_user_reports: number; + pending_user_reports: number; +} + +export interface ReportListResponse { + status: string; + data: { + task_reports: { + reports: Report[]; + pagination: PaginationInfo; + }; + user_reports: { + reports: Report[]; + pagination: PaginationInfo; + }; + statistics: ReportStatistics; + }; +} + +export interface ReportedUser { + user_id: number; + username: string; + email: string; + is_active: boolean; + report_count: number; + user_report_count: number; // Number of direct user reports + task_report_count: number; // Number of reports on user's tasks + last_reported_at: string; +} + +export interface ReportedUsersResponse { + status: string; + data: { + users: ReportedUser[]; + pagination: PaginationInfo; + }; +} + +export interface AdminUserDetails { + user_id: number; + username: string; + email: string; + name: string; + surname: string; + phone_number: string; + location: string; + rating: number; + completed_task_count: number; + status: string; + user_reports: Report[]; + user_reports_count: number; + task_reports_count: number; + flagged_tasks: { + task_id: number; + task_title: string; + created_at: string; + report_type: ReportType; + report_description: string; + }[]; +} + +export interface AdminUserDetailsResponse { + status: string; + data: AdminUserDetails; +} + +export interface BanUserResponse { + status: string; + message: string; + data: { + user_id: number; + username: string; + new_status: string; + banned_at: string; + reason: string; + }; +} + +export interface DeleteTaskResponse { + status: string; + message: string; + data: { + task_id: number; + title: string; + creator_id: number; + creator_username: string; + reason: string; + }; +} + +export interface UpdateReportStatusResponse { + status: string; + message: string; + data: { + id: number; + status: ReportStatus; + reviewed_by_username: string; + admin_notes: string; + updated_at: string; + }; +} + +// --- Admin API Functions --- + +export const getAdminReports = async ( + type: 'task' | 'user' | 'all' = 'all', + status?: ReportStatus, + page: number = 1, + limit: number = 20 +): Promise => { + try { + const params: any = { type, page, limit }; + if (status) params.status = status; + + const response = await api.get('/admin/reports/', { params }); + return response.data; + } catch (error) { + console.error('Get admin reports error:', error); + throw error; + } +}; + +export const updateReportStatus = async ( + reportId: number, + reportType: 'task' | 'user', + status: ReportStatus, + adminNotes?: string +): Promise => { + try { + const endpoint = reportType === 'task' + ? `/task-reports/${reportId}/update-status/` + : `/user-reports/${reportId}/update-status/`; + + const response = await api.patch(endpoint, { + status, + admin_notes: adminNotes + }); + return response.data; + } catch (error) { + console.error(`Update ${reportType} report status error:`, error); + throw error; + } +}; + +export const getReportedUsers = async (page: number = 1, limit: number = 20): Promise => { + try { + const response = await api.get('/admin/reported-users/', { + params: { page, limit } + }); + return response.data; + } catch (error) { + console.error('Get reported users error:', error); + throw error; + } +}; + +export const getAdminUserDetails = async (userId: number): Promise => { + try { + const response = await api.get(`/admin/users/${userId}/`); + return response.data; + } catch (error) { + console.error(`Get admin user details for ${userId} error:`, error); + throw error; + } +}; + +export const banUser = async (userId: number, reason: string): Promise => { + try { + const response = await api.post(`/admin/users/${userId}/ban/`, { reason }); + return response.data; + } catch (error) { + console.error(`Ban user ${userId} error:`, error); + throw error; + } +}; + +export const adminDeleteTask = async (taskId: number, reason: string): Promise => { + try { + const response = await api.delete(`/admin/tasks/${taskId}/delete/`, { + data: { reason } // DELETE with body + }); + return response.data; + } catch (error) { + console.error(`Admin delete task ${taskId} error:`, error); + throw error; + } +}; + +// --- New Admin Functions --- + +export interface UserListItem { + id: number; + username: string; + email: string; + name: string; + surname: string; + rating: number; + completed_task_count: number; + is_active: boolean; + date_joined: string; + location: string; +} + +export interface UserListResponse { + status: string; + data: { + users: UserListItem[]; + pagination: PaginationInfo; + }; +} + +export const getAllUsers = async (page: number = 1, limit: number = 20, search?: string, filter?: 'all' | 'active' | 'banned'): Promise => { + try { + const params: any = { page, limit }; + if (search) params.search = search; + if (filter && filter !== 'all') params.is_active = filter === 'active'; + + const response = await api.get('/admin/users/', { params }); + return response.data; + } catch (error) { + console.error('Get all users error:', error); + throw error; + } +}; + +export interface AdminStatistics { + total_users: number; + active_users: number; + inactive_users: number; + total_tasks: number; + tasks_by_status: { + POSTED: number; + ASSIGNED: number; + COMPLETED: number; + CANCELLED: number; + }; + total_reports: number; + reports_by_status: { + PENDING: number; + UNDER_REVIEW: number; + RESOLVED: number; + DISMISSED: number; + }; + reports_last_7_days: number; +} + +export interface AdminStatisticsResponse { + status: string; + data: AdminStatistics; +} + +export const getAdminStatistics = async (): Promise => { + try { + const response = await api.get('/admin/statistics/'); + return response.data; + } catch (error) { + console.error('Get admin statistics error:', error); + throw error; + } +}; + +export interface UnbanUserResponse { + status: string; + message: string; + data: { + user_id: number; + username: string; + new_status: string; + unbanned_at: string; + reason: string; + }; +} + +export const unbanUser = async (userId: number, reason: string): Promise => { + try { + const response = await api.post(`/admin/users/${userId}/unban/`, { reason }); + return response.data; + } catch (error) { + console.error(`Unban user ${userId} error:`, error); + throw error; + } +}; + + +// --- User Reporting Functions --- + +export const createTaskReport = async (taskId: number, type: ReportType, description: string): Promise => { + try { + const response = await api.post('/task-reports/', { + task_id: taskId, + report_type: type, + description + }); + return response.data; + } catch (error) { + console.error(`Create task report error:`, error); + throw error; + } +}; + +export const createUserReport = async (userId: number, type: ReportType, description: string): Promise => { + try { + const response = await api.post('/user-reports/', { + reported_user_id: userId, + report_type: type, + description + }); + return response.data; + } catch (error) { + console.error(`Create user report error:`, error); + throw error; + } +}; + +// --- Admin Status Check --- + +/** + * Check if the current user is an admin by attempting to access an admin endpoint. + * Returns true if user has admin privileges, false otherwise. + */ +export const checkIsAdmin = async (): Promise => { + try { + // Try to access admin reports endpoint with minimal data + const response = await api.get('/admin/reports/', { + params: { type: 'all', page: 1, limit: 1 } + }); + return response.status === 200; + } catch (error: any) { + if (error.response?.status === 403) { + // Forbidden - user is not an admin + console.log('User is not an admin (403 Forbidden)'); + return false; + } + // Other errors (network, etc) - assume not admin + console.log('Admin check failed:', error.message); + return false; + } +}; + export default api; + diff --git a/app/mobile/lib/auth.tsx b/app/mobile/lib/auth.tsx index 525aa3a6..4000d7da 100644 --- a/app/mobile/lib/auth.tsx +++ b/app/mobile/lib/auth.tsx @@ -47,7 +47,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const handleLogout = async () => { await handleSetUser(null); // This will clear user and userProfile from storage await AsyncStorage.removeItem('token'); // Clear authentication token - + await AsyncStorage.removeItem('isAdmin'); // Clear admin status + // Optional: Clear all volunteer state keys try { const allKeys = await AsyncStorage.getAllKeys(); @@ -58,7 +59,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch (error) { console.error('Error clearing volunteer state:', error); } - + // Navigation will be handled by the component calling logout or a root navigator effect // For example, by using router.replace('/signin') or a similar mechanism. };