260 lines
7.5 KiB
TypeScript
260 lines
7.5 KiB
TypeScript
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
|
import {
|
|
View,
|
|
TouchableOpacity,
|
|
Alert,
|
|
RefreshControl,
|
|
ActivityIndicator,
|
|
} from 'react-native';
|
|
import { useRouter } from 'expo-router';
|
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
import {
|
|
AppContainer,
|
|
MyText,
|
|
tw,
|
|
SearchBar,
|
|
MyFlatList,
|
|
} from 'common-ui';
|
|
import { trpc } from '@/src/trpc-client';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
interface User {
|
|
id: number;
|
|
name: string | null;
|
|
mobile: string | null;
|
|
createdAt: string;
|
|
totalOrders: number;
|
|
lastOrderDate: string | null;
|
|
}
|
|
|
|
interface UserItemProps {
|
|
user: User;
|
|
index: number;
|
|
onPress: () => void;
|
|
}
|
|
|
|
const UserItem: React.FC<UserItemProps> = ({ user, index, onPress }) => {
|
|
const displayName = user.name || 'Unnamed User';
|
|
const hasOrders = user.totalOrders > 0;
|
|
|
|
const lastOrderText = user.lastOrderDate
|
|
? formatDistanceToNow(new Date(user.lastOrderDate), { addSuffix: true })
|
|
: 'Never';
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={onPress}
|
|
activeOpacity={0.7}
|
|
style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-3 shadow-sm`}
|
|
>
|
|
<View style={tw`flex-row items-center justify-between`}>
|
|
{/* Left: Index number */}
|
|
<View style={tw`w-8 h-8 rounded-full bg-gray-100 items-center justify-center mr-3`}>
|
|
<MyText style={tw`text-gray-600 text-xs font-bold`}>{index + 1}</MyText>
|
|
</View>
|
|
|
|
{/* Middle: User Info */}
|
|
<View style={tw`flex-1`}>
|
|
{/* Mobile number - primary identifier */}
|
|
<MyText style={tw`text-gray-900 font-bold text-base mb-0.5`}>
|
|
{user.mobile || 'No Mobile'}
|
|
</MyText>
|
|
|
|
{/* Name */}
|
|
<MyText style={tw`text-gray-500 text-sm mb-1`}>
|
|
{displayName}
|
|
</MyText>
|
|
|
|
{/* Registration date */}
|
|
<MyText style={tw`text-gray-400 text-xs`}>
|
|
Registered: {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
|
|
</MyText>
|
|
</View>
|
|
|
|
{/* Right: Order Stats */}
|
|
<View style={tw`items-end`}>
|
|
{/* Total Orders */}
|
|
<View style={tw`flex-row items-center mb-1`}>
|
|
<MaterialIcons name="shopping-bag" size={14} color={hasOrders ? '#10B981' : '#9CA3AF'} />
|
|
<MyText style={tw`${hasOrders ? 'text-green-600' : 'text-gray-400'} font-bold text-sm ml-1`}>
|
|
{user.totalOrders} orders
|
|
</MyText>
|
|
</View>
|
|
|
|
{/* Last Order */}
|
|
<MyText style={tw`text-gray-400 text-xs`}>
|
|
Last: {lastOrderText}
|
|
</MyText>
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
interface ListHeaderProps {
|
|
searchTerm: string;
|
|
onSearchChange: (text: string) => void;
|
|
userCount: number;
|
|
}
|
|
|
|
const ListHeader: React.FC<ListHeaderProps> = ({ searchTerm, onSearchChange, userCount }) => (
|
|
<View>
|
|
{/* Search Bar */}
|
|
<View style={tw`px-4 py-3 bg-white border-b border-gray-100`}>
|
|
<SearchBar
|
|
value={searchTerm}
|
|
onChangeText={onSearchChange}
|
|
placeholder="Search by mobile number..."
|
|
/>
|
|
</View>
|
|
|
|
{/* Stats Summary */}
|
|
<View style={tw`px-4 py-2 bg-gray-50`}>
|
|
<MyText style={tw`text-gray-500 text-sm`}>
|
|
Showing {userCount} users
|
|
</MyText>
|
|
</View>
|
|
</View>
|
|
);
|
|
|
|
const ListFooter: React.FC<{ isFetching: boolean }> = ({ isFetching }) => {
|
|
if (!isFetching) return null;
|
|
return (
|
|
<View style={tw`py-4 items-center`}>
|
|
<ActivityIndicator size="small" color="#3b82f6" />
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default function UserManagement() {
|
|
const router = useRouter();
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
|
|
|
// Infinite scroll query
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isError,
|
|
error,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
refetch,
|
|
} = trpc.admin.user.getAllUsers.useInfiniteQuery(
|
|
{
|
|
limit: 30,
|
|
search: searchTerm || undefined,
|
|
},
|
|
{
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
}
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (data?.pages?.length) {
|
|
setHasLoadedOnce(true);
|
|
}
|
|
}, [data]);
|
|
|
|
// Flatten all pages and remove duplicates
|
|
const users = useMemo(() => {
|
|
const allUsers = data?.pages.flatMap((page) => page.users) || [];
|
|
// Remove duplicates based on user id
|
|
const uniqueUsers = allUsers.filter((user, index, self) =>
|
|
index === self.findIndex((u) => u.id === user.id)
|
|
);
|
|
return uniqueUsers;
|
|
}, [data]);
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
await refetch();
|
|
setRefreshing(false);
|
|
}, [refetch]);
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
if (hasNextPage && !isFetchingNextPage) {
|
|
fetchNextPage();
|
|
}
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
const handleUserPress = useCallback((userId: number) => {
|
|
router.push(`/(drawer)/user-management/${userId}`);
|
|
}, [router]);
|
|
|
|
const renderUserItem = useCallback(({ item, index }: { item: User; index: number }) => {
|
|
return <UserItem user={item} index={index} onPress={() => handleUserPress(item.id)} />;
|
|
}, [handleUserPress]);
|
|
|
|
if (isLoading && !hasLoadedOnce) {
|
|
return (
|
|
<AppContainer>
|
|
<View style={tw`flex-1 justify-center items-center`}>
|
|
<ActivityIndicator size="large" color="#3b82f6" />
|
|
<MyText style={tw`text-gray-500 mt-4`}>Loading users...</MyText>
|
|
</View>
|
|
</AppContainer>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<AppContainer>
|
|
<View style={tw`flex-1 justify-center items-center p-8`}>
|
|
<MaterialIcons name="error-outline" size={64} color="#ef4444" />
|
|
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
|
|
<MyText style={tw`text-gray-500 mt-2 text-center`}>
|
|
{error?.message || 'Failed to load users'}
|
|
</MyText>
|
|
<TouchableOpacity
|
|
onPress={() => refetch()}
|
|
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
|
|
>
|
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</AppContainer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={tw`flex-1 bg-gray-50`}>
|
|
{/* Users List */}
|
|
<MyFlatList
|
|
data={users}
|
|
renderItem={renderUserItem}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
onEndReached={handleLoadMore}
|
|
onEndReachedThreshold={0.5}
|
|
ListHeaderComponent={
|
|
<ListHeader
|
|
searchTerm={searchTerm}
|
|
onSearchChange={setSearchTerm}
|
|
userCount={users.length}
|
|
/>
|
|
}
|
|
ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
|
}
|
|
contentContainerStyle={tw`pb-8`}
|
|
stickyHeaderIndices={[0]}
|
|
ListEmptyComponent={
|
|
<View style={tw`flex-1 justify-center items-center py-12`}>
|
|
<MaterialIcons name="people-outline" size={64} color="#e5e7eb" />
|
|
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
|
|
No users found
|
|
</MyText>
|
|
{searchTerm && (
|
|
<MyText style={tw`text-gray-400 mt-1 text-center`}>
|
|
Try adjusting your search
|
|
</MyText>
|
|
)}
|
|
</View>
|
|
}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|