This commit is contained in:
shafi54 2026-02-21 16:13:23 +05:30
parent 10d13408d3
commit b2a35176dd
8 changed files with 264 additions and 161 deletions

View file

@ -1,25 +1,58 @@
import React, { useState } from "react"; import React, { useState, useCallback, useMemo } from "react";
import { View, Text, TouchableOpacity, Alert } from "react-native"; import { View, TouchableOpacity, Alert, ActivityIndicator } from "react-native";
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, usePagination, ImageViewerURI } from "common-ui"; import { MaterialIcons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, ImageViewerURI } from "common-ui";
import { trpc } from "@/src/trpc-client"; import { trpc } from "@/src/trpc-client";
export default function Complaints() { export default function Complaints() {
const { currentPage, pageSize, PaginationComponent } = usePagination(5); // 5 complaints per page for testing const router = useRouter();
const { data, isLoading, error, refetch } = trpc.admin.complaint.getAll.useQuery({ const [dialogOpen, setDialogOpen] = useState(false);
page: currentPage, const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
limit: pageSize,
}); const {
const resolveComplaint = trpc.admin.complaint.resolve.useMutation(); data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = trpc.admin.complaint.getAll.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
useMarkDataFetchers(() => { useMarkDataFetchers(() => {
refetch(); refetch();
}); });
const [dialogOpen, setDialogOpen] = useState(false); const resolveComplaint = trpc.admin.complaint.resolve.useMutation();
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
const complaints = data?.complaints || []; const complaints = useMemo(() => {
const totalCount = data?.totalCount || 0; const allComplaints = data?.pages.flatMap((page) => page.complaints) || [];
return allComplaints.filter(
(complaint, index, self) =>
index === self.findIndex((c) => c.id === complaint.id)
);
}, [data]);
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleUserPress = useCallback((userId: number) => {
router.push(`/(drawer)/user-management/${userId}`);
}, [router]);
const handleOrderPress = useCallback((orderId: number) => {
router.push(`/(drawer)/manage-orders/order-details/${orderId}`);
}, [router]);
const handleMarkResolved = (id: number) => { const handleMarkResolved = (id: number) => {
setSelectedComplaintId(id); setSelectedComplaintId(id);
@ -52,35 +85,72 @@ export default function Complaints() {
if (isLoading) { if (isLoading) {
return ( return (
<View style={tw`flex-1 justify-center items-center`}> <View style={tw`flex-1 justify-center items-center bg-gray-50`}>
<MyText style={tw`text-gray-600`}>Loading complaints...</MyText> <ActivityIndicator size="large" color="#3B82F6" />
<MyText style={tw`text-gray-500 mt-4`}>Loading complaints...</MyText>
</View> </View>
); );
} }
if (error) { if (isError) {
return ( return (
<View style={tw`flex-1 justify-center items-center`}> <View style={tw`flex-1 justify-center items-center bg-gray-50 p-6`}>
<MyText style={tw`text-red-600`}>Error loading complaints</MyText> <MaterialIcons name="error-outline" size={48} color="#EF4444" />
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
<MyText style={tw`text-gray-500 text-center mt-2 mb-6`}>
{error?.message || "Failed to load complaints"}
</MyText>
<TouchableOpacity
onPress={() => refetch()}
style={tw`bg-blue-600 px-6 py-3 rounded-full`}
>
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
</TouchableOpacity>
</View> </View>
); );
} }
return ( return (
<View style={tw`flex-1`}> <View style={tw`flex-1 bg-gray-50`}>
<MyFlatList <MyFlatList
style={tw`flex-1 bg-white`} style={tw`flex-1`}
contentContainerStyle={tw`px-4 pb-6`} contentContainerStyle={tw`px-4 py-4`}
data={complaints} data={complaints}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg`}> <View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-sm border border-gray-100`}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Complaint #{item.id}</MyText> <View style={tw`flex-row justify-between items-start mb-2`}>
<MyText style={tw`text-base mb-2 text-gray-700`}>{item.text}</MyText> <MyText style={tw`text-lg font-bold text-gray-900`}>
Complaint #{item.id}
</MyText>
<View
style={tw`px-2.5 py-1 rounded-full ${
item.status === "resolved"
? "bg-green-100 border border-green-200"
: "bg-amber-100 border border-amber-200"
}`}
>
<MyText
style={tw`text-xs font-semibold ${
item.status === "resolved" ? "text-green-700" : "text-amber-700"
}`}
>
{item.status === "resolved" ? "Resolved" : "Pending"}
</MyText>
</View>
</View>
<MyText style={tw`text-base text-gray-700 mb-3 leading-5`}>
{item.text}
</MyText>
{item.images && item.images.length > 0 && ( {item.images && item.images.length > 0 && (
<View style={tw`mt-3 mb-3`}> <View style={tw`mb-3`}>
<MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>Attached Images:</MyText> <MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>
Attached Images:
</MyText>
<View style={tw`flex-row flex-wrap gap-2`}> <View style={tw`flex-row flex-wrap gap-2`}>
{item.images.map((imageUri: string, index: number) => ( {item.images.map((imageUri: string, index: number) => (
<ImageViewerURI <ImageViewerURI
@ -93,53 +163,64 @@ export default function Complaints() {
</View> </View>
)} )}
<View style={tw`flex-row items-center mb-2`}> <View style={tw`flex-row items-center gap-2 mb-3`}>
<MaterialIcons name="person" size={14} color="#6B7280" />
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() => item.userId && handleUserPress(item.userId)}
Alert.alert("User Page", "User page coming soon")
}
> >
<MyText style={tw`text-sm text-blue-600 underline`}> <MyText style={tw`text-sm text-blue-600 underline`}>
{item.userName} {item.userName || item.userMobile || "Unknown User"}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
<MyText style={tw`text-sm text-gray-600 mx-2`}>|</MyText>
{item.orderId && ( {item.orderId && (
<>
<MyText style={tw`text-sm text-gray-400`}>|</MyText>
<MaterialIcons name="shopping-bag" size={14} color="#6B7280" />
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() => item.orderId && handleOrderPress(item.orderId)}
Alert.alert("Order Page", "Order page coming soon")
}
> >
<MyText style={tw`text-sm text-blue-600 underline`}> <MyText style={tw`text-sm text-blue-600 underline`}>
Order #{item.orderId} Order #{item.orderId}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</>
)} )}
</View> </View>
<MyText
style={tw`text-sm ${
item.status === "resolved" ? "text-green-600" : "text-red-600"
}`}
>
Status: {item.status}
</MyText>
{item.status === "pending" && ( {item.status === "pending" && (
<TouchableOpacity <TouchableOpacity
onPress={() => handleMarkResolved(item.id)} onPress={() => handleMarkResolved(item.id)}
style={tw`mt-2 bg-blue-500 p-3 rounded-lg shadow-md`} style={tw`bg-blue-500 py-3 rounded-xl items-center shadow-sm mt-2`}
> >
<MyText style={tw`text-white text-center font-semibold`}>Mark as Resolved</MyText> <MyText style={tw`text-white font-semibold`}>
Resolve Complaint
</MyText>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
)} )}
ListEmptyComponent={ ListEmptyComponent={
<View style={tw`flex-1 justify-center items-center py-10`}> <View style={tw`flex-1 justify-center items-center py-20`}>
<MyText style={tw`text-gray-500 text-center`}>No complaints found</MyText> <View style={tw`bg-white p-6 rounded-full shadow-sm mb-4`}>
<MaterialIcons name="inbox" size={48} color="#D1D5DB" />
</View>
<MyText style={tw`text-gray-900 text-lg font-bold`}>
No complaints
</MyText>
<MyText style={tw`text-gray-500 text-center mt-2`}>
All complaints will appear here
</MyText>
</View> </View>
} }
ListFooterComponent={
isFetchingNextPage ? (
<View style={tw`py-4 items-center flex-row justify-center`}>
<ActivityIndicator size="small" color="#3B82F6" />
<MyText style={tw`text-gray-500 ml-2`}>Loading more...</MyText>
</View>
) : null
}
/> />
<PaginationComponent totalCount={totalCount} />
<ConfirmationDialog <ConfirmationDialog
open={dialogOpen} open={dialogOpen}
positiveAction={handleConfirmResolve} positiveAction={handleConfirmResolve}
@ -148,10 +229,11 @@ export default function Complaints() {
setDialogOpen(false); setDialogOpen(false);
setSelectedComplaintId(null); setSelectedComplaintId(null);
}} }}
title="Mark as Resolved" title="Resolve Complaint"
message="Add admin notes for this resolution:" message="Add admin notes for this resolution:"
confirmText="Resolve" confirmText="Resolve"
cancelText="Cancel" cancelText="Cancel"
isLoading={resolveComplaint.isPending}
/> />
</View> </View>
); );

View file

@ -267,6 +267,23 @@ export default function OrderDetails() {
</View> </View>
</View> </View>
{/* Cancellation Reason */}
{order.status === "cancelled" && order.cancelReason && (
<View
style={tw`bg-red-50 p-5 rounded-2xl border border-red-100 mb-4`}
>
<View style={tw`flex-row items-center mb-2`}>
<MaterialIcons name="cancel" size={18} color="#DC2626" />
<MyText style={tw`text-sm font-bold text-red-800 ml-2`}>
Cancellation Reason
</MyText>
</View>
<MyText style={tw`text-sm text-red-900 leading-5`}>
{order.cancelReason}
</MyText>
</View>
)}
{/* Order Progress (Simplified Timeline) */} {/* Order Progress (Simplified Timeline) */}
{order.status !== "cancelled" && ( {order.status !== "cancelled" && (
<View <View

View file

@ -57,6 +57,9 @@ interface OrderType {
readableId: number; readableId: number;
customerName: string | null; customerName: string | null;
address: string; address: string;
addressId: number;
latitude: number | null;
longitude: number | null;
totalAmount: number; totalAmount: number;
deliveryCharge: number; deliveryCharge: number;
items: { items: {
@ -359,11 +362,11 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
isDelivered: order.isDelivered, isDelivered: order.isDelivered,
isFlashDelivery: order.isFlashDelivery, isFlashDelivery: order.isFlashDelivery,
address: order.address, address: order.address,
addressId: 0, addressId: order.addressId,
adminNotes: order.adminNotes, adminNotes: order.adminNotes,
userNotes: order.userNotes, userNotes: order.userNotes,
latitude: null, latitude: order.latitude,
longitude: null, longitude: order.longitude,
status: order.status, status: order.status,
}} }}
onViewDetails={handleMenuOption} onViewDetails={handleMenuOption}
@ -377,7 +380,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
setMenuOpen(false); setMenuOpen(false);
setCancelDialogOpen(true); setCancelDialogOpen(true);
}} }}
onAttachLocation={() => {}} onAttachLocation={() => refetch()}
onWhatsApp={() => {}} onWhatsApp={() => {}}
onDial={() => {}} onDial={() => {}}
/> />

File diff suppressed because one or more lines are too long

View file

@ -2,22 +2,23 @@ import { router, protectedProcedure } from '../trpc-index';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '../../db/db_index'; import { db } from '../../db/db_index';
import { complaints, users } from '../../db/schema'; import { complaints, users } from '../../db/schema';
import { eq, desc } from 'drizzle-orm'; import { eq, desc, lt, and } from 'drizzle-orm';
import { generateSignedUrlsFromS3Urls } from '../../lib/s3-client'; import { generateSignedUrlsFromS3Urls } from '../../lib/s3-client';
export const complaintRouter = router({ export const complaintRouter = router({
getAll: protectedProcedure getAll: protectedProcedure
.input(z.object({ .input(z.object({
page: z.number().optional().default(1), cursor: z.number().optional(),
limit: z.number().optional().default(10), limit: z.number().default(20),
})) }))
.query(async ({ input }) => { .query(async ({ input }) => {
const page = input.page; const { cursor, limit } = input;
const limit = input.limit;
const offset = (page - 1) * limit;
const [complaintsData, totalCountResult] = await Promise.all([ let whereCondition = cursor
db ? lt(complaints.id, cursor)
: undefined;
const complaintsData = await db
.select({ .select({
id: complaints.id, id: complaints.id,
complaintBody: complaints.complaintBody, complaintBody: complaints.complaintBody,
@ -26,23 +27,20 @@ export const complaintRouter = router({
isResolved: complaints.isResolved, isResolved: complaints.isResolved,
createdAt: complaints.createdAt, createdAt: complaints.createdAt,
userName: users.name, userName: users.name,
userMobile: users.mobile,
images: complaints.images, images: complaints.images,
}) })
.from(complaints) .from(complaints)
.leftJoin(users, eq(complaints.userId, users.id)) .leftJoin(users, eq(complaints.userId, users.id))
.orderBy(desc(complaints.createdAt)) .where(whereCondition)
.limit(limit) .orderBy(desc(complaints.id))
.offset(offset), .limit(limit + 1);
db
.select({ count: db.$count(complaints) })
.from(complaints),
]);
const totalCount = totalCountResult[0].count; const hasMore = complaintsData.length > limit;
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
// Generate signed URLs for images
const complaintsWithSignedImages = await Promise.all( const complaintsWithSignedImages = await Promise.all(
complaintsData.map(async (c) => { complaintsToReturn.map(async (c) => {
const signedImages = c.images const signedImages = c.images
? await generateSignedUrlsFromS3Urls(c.images as string[]) ? await generateSignedUrlsFromS3Urls(c.images as string[])
: []; : [];
@ -52,6 +50,7 @@ export const complaintRouter = router({
text: c.complaintBody, text: c.complaintBody,
userId: c.userId, userId: c.userId,
userName: c.userName, userName: c.userName,
userMobile: c.userMobile,
orderId: c.orderId, orderId: c.orderId,
status: c.isResolved ? 'resolved' : 'pending', status: c.isResolved ? 'resolved' : 'pending',
createdAt: c.createdAt, createdAt: c.createdAt,
@ -62,7 +61,9 @@ export const complaintRouter = router({
return { return {
complaints: complaintsWithSignedImages, complaints: complaintsWithSignedImages,
totalCount, nextCursor: hasMore
? complaintsToReturn[complaintsToReturn.length - 1].id
: undefined,
}; };
}), }),

View file

@ -52,16 +52,7 @@ const HealthTestWrapper: React.FC<HealthTestWrapperProps> = ({ children }) => {
} }
}, [versionFromBackend]); }, [versionFromBackend]);
if (isLoading) { if (error) {
return (
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
<ActivityIndicator size="large" color={theme.colors.brand500} />
<MyText style={tw`text-gray-500 mt-4 font-medium`}>Checking service status...</MyText>
</View>
);
}
if (error || data?.status !== "ok") {
return ( return (
<View style={tw`flex-1 justify-center items-center bg-gray-50 p-6`}> <View style={tw`flex-1 justify-center items-center bg-gray-50 p-6`}>
<View style={tw`w-16 h-16 bg-red-100 rounded-full items-center justify-center mb-4`}> <View style={tw`w-16 h-16 bg-red-100 rounded-full items-center justify-center mb-4`}>

View file

@ -63,8 +63,8 @@ const isDevMode = Constants.executionEnvironment !== "standalone";
// const BASE_API_URL = API_URL; // const BASE_API_URL = API_URL;
// const BASE_API_URL = 'http://10.0.2.2:4000'; // const BASE_API_URL = 'http://10.0.2.2:4000';
// const BASE_API_URL = 'http://192.168.100.101:4000'; // const BASE_API_URL = 'http://192.168.100.101:4000';
// const BASE_API_URL = 'http://192.168.1.3:4000'; const BASE_API_URL = 'http://192.168.1.6:4000';
let BASE_API_URL = "https://mf.freshyo.in"; // let BASE_API_URL = "https://mf.freshyo.in";
// let BASE_API_URL = 'http://192.168.100.104:4000'; // let BASE_API_URL = 'http://192.168.100.104:4000';
// let BASE_API_URL = 'http://192.168.29.176:4000'; // let BASE_API_URL = 'http://192.168.29.176:4000';

View file

@ -200,6 +200,7 @@ interface ConfirmationDialogProps {
message?: string; message?: string;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
isLoading?: boolean;
} }
export const ConfirmationDialog: React.FC<ConfirmationDialogProps> = (props) => { export const ConfirmationDialog: React.FC<ConfirmationDialogProps> = (props) => {
@ -211,7 +212,8 @@ export const ConfirmationDialog: React.FC<ConfirmationDialogProps> = (props) =>
title = "Are you sure?", title = "Are you sure?",
message = "Do you really want to proceed with this action?", message = "Do you really want to proceed with this action?",
confirmText = "Confirm", confirmText = "Confirm",
cancelText = "Cancel" cancelText = "Cancel",
isLoading = false,
} = props; } = props;
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
@ -253,8 +255,15 @@ export const ConfirmationDialog: React.FC<ConfirmationDialogProps> = (props) =>
)} )}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: commentNeeded ? 16 : 0 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: commentNeeded ? 16 : 0 }}>
<MyButton textContent={cancelText} onPress={handleCancel} fillColor="gray1" textColor="white1" /> <MyButton textContent={cancelText} onPress={handleCancel} fillColor="gray1" textColor="white1" disabled={isLoading} />
<MyButton textContent={confirmText} style={{ flexShrink: 0 }} onPress={handleConfirm} fillColor="red1" textColor="white1" /> <MyButton
textContent={isLoading ? "Processing..." : confirmText}
style={{ flexShrink: 0 }}
onPress={handleConfirm}
fillColor="red1"
textColor="white1"
disabled={isLoading}
/>
</View> </View>
</View> </View>
</BottomDialog> </BottomDialog>