enh
This commit is contained in:
parent
8d702ed2ff
commit
72475f7f71
8 changed files with 385 additions and 231 deletions
|
|
@ -13,194 +13,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
|
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import CancelOrderDialog from "@/components/CancelOrderDialog";
|
import CancelOrderDialog from "@/components/CancelOrderDialog";
|
||||||
|
import { UserIncidentsView } from "@/components/UserIncidentsView";
|
||||||
function UserIncidentDialog({ userId, orderId, open, onClose, onSuccess }: { userId: number; orderId: number; open: boolean; onClose: () => void; onSuccess?: () => void }) {
|
|
||||||
const [adminComment, setAdminComment] = useState('');
|
|
||||||
const [negativityScore, setNegativityScore] = useState('');
|
|
||||||
|
|
||||||
const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
Alert.alert('Success', 'Incident added successfully');
|
|
||||||
setAdminComment('');
|
|
||||||
setNegativityScore('');
|
|
||||||
onClose();
|
|
||||||
onSuccess?.();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to add incident');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAddIncident = () => {
|
|
||||||
const score = negativityScore ? parseInt(negativityScore) : undefined;
|
|
||||||
|
|
||||||
if (!adminComment.trim() && !negativityScore) {
|
|
||||||
Alert.alert('Error', 'Please enter a comment or negativity score');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addIncidentMutation.mutate({
|
|
||||||
userId,
|
|
||||||
orderId,
|
|
||||||
adminComment: adminComment || undefined,
|
|
||||||
negativityScore: score,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomDialog open={open} onClose={onClose}>
|
|
||||||
<View style={tw`p-6`}>
|
|
||||||
<View style={tw`items-center mb-6`}>
|
|
||||||
<View style={tw`w-12 h-12 bg-amber-100 rounded-full items-center justify-center mb-3`}>
|
|
||||||
<MaterialIcons name="warning" size={24} color="#D97706" />
|
|
||||||
</View>
|
|
||||||
<MyText style={tw`text-xl font-bold text-gray-900 text-center`}>
|
|
||||||
Add User Incident
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-500 text-center mt-2 text-sm leading-5`}>
|
|
||||||
Record an incident for this user. This will be visible in their profile.
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Admin Comment"
|
|
||||||
value={adminComment}
|
|
||||||
onChangeText={setAdminComment}
|
|
||||||
placeholder="Enter details about the incident..."
|
|
||||||
multiline
|
|
||||||
style={tw`h-24`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Negativity Score (Optional)"
|
|
||||||
value={negativityScore}
|
|
||||||
onChangeText={setNegativityScore}
|
|
||||||
placeholder="0"
|
|
||||||
keyboardType="numeric"
|
|
||||||
style={tw`mt-4`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={tw`bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 mt-4 flex-row items-start`}>
|
|
||||||
<MaterialIcons name="info-outline" size={20} color="#D97706" style={tw`mt-0.5`} />
|
|
||||||
<MyText style={tw`text-sm text-amber-800 ml-2 flex-1 leading-5`}>
|
|
||||||
Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior).
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={tw`flex-row gap-3`}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`flex-1 bg-amber-500 py-3.5 rounded-xl items-center shadow-sm ${addIncidentMutation.isPending ? 'opacity-50' : ''}`}
|
|
||||||
onPress={handleAddIncident}
|
|
||||||
disabled={addIncidentMutation.isPending}
|
|
||||||
>
|
|
||||||
<MyText style={tw`text-white font-bold`}>
|
|
||||||
{addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'}
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserIncidentsView({ userId, orderId }: { userId: number; orderId: number }) {
|
|
||||||
const [incidentDialogOpen, setIncidentDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: incidentsData, refetch: refetchIncidents } = trpc.admin.user.getUserIncidents.useQuery(
|
|
||||||
{ userId },
|
|
||||||
{ enabled: !!userId }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<View style={tw`bg-amber-50 p-5 rounded-2xl shadow-sm mb-4 border border-amber-100`}>
|
|
||||||
<View style={tw`flex-row items-center justify-between mb-4`}>
|
|
||||||
<MyText style={tw`text-base font-bold text-amber-900`}>
|
|
||||||
User Incidents
|
|
||||||
</MyText>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setIncidentDialogOpen(true)}
|
|
||||||
style={tw`flex-row items-center bg-amber-200 px-3 py-1.5 rounded-lg`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="add" size={16} color="#D97706" />
|
|
||||||
<MyText style={tw`text-xs font-bold text-amber-800 ml-1`}>Add Incident</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{incidentsData?.incidents && incidentsData.incidents.length > 0 ? (
|
|
||||||
<View style={tw`space-y-3`}>
|
|
||||||
{incidentsData.incidents.map((incident: any, index: number) => (
|
|
||||||
<View
|
|
||||||
key={incident.id}
|
|
||||||
style={tw`bg-white p-4 rounded-xl border border-amber-200 ${index === incidentsData.incidents.length - 1 ? 'mb-0' : 'mb-3'}`}
|
|
||||||
>
|
|
||||||
<View style={tw`flex-row justify-between items-start mb-2`}>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="event" size={14} color="#6B7280" />
|
|
||||||
<MyText style={tw`text-xs text-gray-600 ml-1`}>
|
|
||||||
{dayjs(incident.dateAdded).format('MMM DD, YYYY • h:mm A')}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
{incident.negativityScore && (
|
|
||||||
<View style={tw`px-2 py-1 bg-red-100 rounded-md`}>
|
|
||||||
<MyText style={tw`text-xs font-bold text-red-700`}>
|
|
||||||
Score: {incident.negativityScore}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{incident.adminComment && (
|
|
||||||
<View style={tw`mt-2`}>
|
|
||||||
<MyText style={tw`text-sm text-gray-900 leading-5`}>
|
|
||||||
{incident.adminComment}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={tw`flex-row items-center mt-2 pt-2 border-t border-gray-100`}>
|
|
||||||
<MaterialIcons name="person" size={12} color="#9CA3AF" />
|
|
||||||
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
|
||||||
Added by {incident.addedBy}
|
|
||||||
</MyText>
|
|
||||||
{incident.orderId && (
|
|
||||||
<>
|
|
||||||
<MaterialIcons name="shopping-cart" size={12} color="#9CA3AF" style={tw`ml-3`} />
|
|
||||||
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
|
||||||
Order #{incident.orderId}
|
|
||||||
</MyText>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={tw`items-center py-6`}>
|
|
||||||
<MaterialIcons name="check-circle-outline" size={32} color="#D97706" />
|
|
||||||
<MyText style={tw`text-sm text-amber-700 mt-2`}>
|
|
||||||
No incidents recorded for this user
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<UserIncidentDialog
|
|
||||||
orderId={orderId}
|
|
||||||
userId={userId}
|
|
||||||
open={incidentDialogOpen}
|
|
||||||
onClose={() => setIncidentDialogOpen(false)}
|
|
||||||
onSuccess={refetchIncidents}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OrderDetails() {
|
export default function OrderDetails() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ interface OrderType {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
readableId: number;
|
readableId: number;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
customerMobile?: string | null;
|
||||||
address: string;
|
address: string;
|
||||||
addressId: number;
|
addressId: number;
|
||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
|
|
@ -85,6 +86,7 @@ interface OrderType {
|
||||||
discountAmount?: number;
|
discountAmount?: number;
|
||||||
adminNotes?: string | null;
|
adminNotes?: string | null;
|
||||||
userNotes?: string | null;
|
userNotes?: string | null;
|
||||||
|
userNegativityScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
||||||
|
|
@ -171,8 +173,8 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
<View style={tw`flex-row justify-between items-start`}>
|
<View style={tw`flex-row justify-between items-start`}>
|
||||||
<View style={tw`flex-1`}>
|
<View style={tw`flex-1`}>
|
||||||
<View style={tw`flex-row items-center mb-1`}>
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
<MyText style={tw`font-bold text-lg mr-2 ${order.status === 'cancelled' ? 'text-red-600' : 'text-gray-900'}`}>
|
<MyText style={tw`font-bold text-lg mr-2 ${order.status === 'cancelled' ? 'text-red-600' : (order.userNegativityScore && order.userNegativityScore > 0 ? 'text-yellow-600' : 'text-gray-900')}`}>
|
||||||
{order.customerName || 'Unknown Customer'}
|
{order.customerName || order.customerMobile || 'Unknown Customer'}
|
||||||
</MyText>
|
</MyText>
|
||||||
<View style={tw`bg-gray-200 px-2 py-0.5 rounded mr-2`}>
|
<View style={tw`bg-gray-200 px-2 py-0.5 rounded mr-2`}>
|
||||||
<MyText style={tw`text-xs font-medium text-gray-600`}>#{order.readableId}</MyText>
|
<MyText style={tw`text-xs font-medium text-gray-600`}>#{order.readableId}</MyText>
|
||||||
|
|
@ -189,6 +191,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
||||||
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
||||||
{dayjs(order.createdAt).format('MMM D, h:mm A')}
|
{dayjs(order.createdAt).format('MMM D, h:mm A')}
|
||||||
</MyText>
|
</MyText>
|
||||||
|
{order.userNegativityScore && order.userNegativityScore > 0 && (
|
||||||
|
<View style={tw`flex-row items-center ml-2`}>
|
||||||
|
<MaterialIcons name="warning" size={14} color="#CA8A04" />
|
||||||
|
<MyText style={tw`text-xs text-yellow-600 font-semibold ml-1`}>Negative Customer</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { UserIncidentsView } from '@/components/UserIncidentsView';
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -221,6 +222,9 @@ export default function UserDetails() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* User Incidents Section */}
|
||||||
|
<UserIncidentsView userId={userId} orderId={null} />
|
||||||
|
|
||||||
{/* Orders Section */}
|
{/* Orders Section */}
|
||||||
<View style={tw`px-4 pb-8`}>
|
<View style={tw`px-4 pb-8`}>
|
||||||
<View style={tw`flex-row items-center justify-between mb-4`}>
|
<View style={tw`flex-row items-center justify-between mb-4`}>
|
||||||
|
|
|
||||||
206
apps/admin-ui/components/UserIncidentsView.tsx
Normal file
206
apps/admin-ui/components/UserIncidentsView.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
function UserIncidentDialog({
|
||||||
|
userId,
|
||||||
|
orderId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSuccess
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
orderId: number | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void
|
||||||
|
}) {
|
||||||
|
const [adminComment, setAdminComment] = useState('');
|
||||||
|
const [negativityScore, setNegativityScore] = useState('');
|
||||||
|
|
||||||
|
const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert('Success', 'Incident added successfully');
|
||||||
|
setAdminComment('');
|
||||||
|
setNegativityScore('');
|
||||||
|
onClose();
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to add incident');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddIncident = () => {
|
||||||
|
const score = negativityScore ? parseInt(negativityScore) : undefined;
|
||||||
|
|
||||||
|
if (!adminComment.trim() && !negativityScore) {
|
||||||
|
Alert.alert('Error', 'Please enter a comment or negativity score');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addIncidentMutation.mutate({
|
||||||
|
userId,
|
||||||
|
orderId: orderId || undefined,
|
||||||
|
adminComment: adminComment || undefined,
|
||||||
|
negativityScore: score,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomDialog open={open} onClose={onClose}>
|
||||||
|
<View style={tw`p-6`}>
|
||||||
|
<View style={tw`items-center mb-6`}>
|
||||||
|
<View style={tw`w-12 h-12 bg-amber-100 rounded-full items-center justify-center mb-3`}>
|
||||||
|
<MaterialIcons name="warning" size={24} color="#D97706" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900 text-center`}>
|
||||||
|
Add User Incident
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-center mt-2 text-sm leading-5`}>
|
||||||
|
Record an incident for this user. This will be visible in their profile.
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<MyTextInput
|
||||||
|
topLabel="Admin Comment"
|
||||||
|
value={adminComment}
|
||||||
|
onChangeText={setAdminComment}
|
||||||
|
placeholder="Enter details about the incident..."
|
||||||
|
multiline
|
||||||
|
style={tw`h-24`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MyTextInput
|
||||||
|
topLabel="Negativity Score (Optional)"
|
||||||
|
value={negativityScore}
|
||||||
|
onChangeText={setNegativityScore}
|
||||||
|
placeholder="0"
|
||||||
|
keyboardType="numeric"
|
||||||
|
style={tw`mt-4`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={tw`bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 mt-4 flex-row items-start`}>
|
||||||
|
<MaterialIcons name="info-outline" size={20} color="#D97706" style={tw`mt-0.5`} />
|
||||||
|
<MyText style={tw`text-sm text-amber-800 ml-2 flex-1 leading-5`}>
|
||||||
|
Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior).
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row gap-3`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-amber-500 py-3.5 rounded-xl items-center shadow-sm ${addIncidentMutation.isPending ? 'opacity-50' : ''}`}
|
||||||
|
onPress={handleAddIncident}
|
||||||
|
disabled={addIncidentMutation.isPending}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold`}>
|
||||||
|
{addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserIncidentsView({ userId, orderId }: { userId: number; orderId: number | null }) {
|
||||||
|
const [incidentDialogOpen, setIncidentDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: incidentsData, refetch: refetchIncidents } = trpc.admin.user.getUserIncidents.useQuery(
|
||||||
|
{ userId },
|
||||||
|
{ enabled: !!userId }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={tw`bg-amber-50 p-5 rounded-2xl shadow-sm mb-4 border border-amber-100`}>
|
||||||
|
<View style={tw`flex-row items-center justify-between mb-4`}>
|
||||||
|
<MyText style={tw`text-base font-bold text-amber-900`}>
|
||||||
|
User Incidents
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setIncidentDialogOpen(true)}
|
||||||
|
style={tw`flex-row items-center bg-amber-200 px-3 py-1.5 rounded-lg`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={16} color="#D97706" />
|
||||||
|
<MyText style={tw`text-xs font-bold text-amber-800 ml-1`}>Add Incident</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{incidentsData?.incidents && incidentsData.incidents.length > 0 ? (
|
||||||
|
<View style={tw`space-y-3`}>
|
||||||
|
{incidentsData.incidents.map((incident: any, index: number) => (
|
||||||
|
<View
|
||||||
|
key={incident.id}
|
||||||
|
style={tw`bg-white p-4 rounded-xl border border-amber-200 ${index === incidentsData.incidents.length - 1 ? 'mb-0' : 'mb-3'}`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialIcons name="event" size={14} color="#6B7280" />
|
||||||
|
<MyText style={tw`text-xs text-gray-600 ml-1`}>
|
||||||
|
{dayjs(incident.dateAdded).format('MMM DD, YYYY • h:mm A')}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{incident.negativityScore && (
|
||||||
|
<View style={tw`px-2 py-1 bg-red-100 rounded-md`}>
|
||||||
|
<MyText style={tw`text-xs font-bold text-red-700`}>
|
||||||
|
Score: {incident.negativityScore}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{incident.adminComment && (
|
||||||
|
<View style={tw`mt-2`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-900 leading-5`}>
|
||||||
|
{incident.adminComment}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-center mt-2 pt-2 border-t border-gray-100`}>
|
||||||
|
<MaterialIcons name="person" size={12} color="#9CA3AF" />
|
||||||
|
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
||||||
|
Added by {incident.addedBy}
|
||||||
|
</MyText>
|
||||||
|
{incident.orderId && (
|
||||||
|
<>
|
||||||
|
<MaterialIcons name="shopping-cart" size={12} color="#9CA3AF" style={tw`ml-3`} />
|
||||||
|
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
||||||
|
Order #{incident.orderId}
|
||||||
|
</MyText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={tw`items-center py-6`}>
|
||||||
|
<MaterialIcons name="check-circle-outline" size={32} color="#D97706" />
|
||||||
|
<MyText style={tw`text-sm text-amber-700 mt-2`}>
|
||||||
|
No incidents recorded for this user
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<UserIncidentDialog
|
||||||
|
orderId={orderId}
|
||||||
|
userId={userId}
|
||||||
|
open={incidentDialogOpen}
|
||||||
|
onClose={() => setIncidentDialogOpen(false)}
|
||||||
|
onSuccess={refetchIncidents}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import './notif-job';
|
import './notif-job';
|
||||||
import { initializeAllStores } from '../stores/store-initializer';
|
import { initializeAllStores } from '../stores/store-initializer';
|
||||||
|
import { initializeUserNegativityStore } from '../stores/user-negativity-store';
|
||||||
import { startOrderHandler, startCancellationHandler, publishOrder } from './post-order-handler';
|
import { startOrderHandler, startCancellationHandler, publishOrder } from './post-order-handler';
|
||||||
import { deleteOrders } from './delete-orders';
|
import { deleteOrders } from './delete-orders';
|
||||||
|
|
||||||
|
|
@ -10,6 +11,7 @@ import { deleteOrders } from './delete-orders';
|
||||||
* - Const Store (syncs constants from DB to Redis)
|
* - Const Store (syncs constants from DB to Redis)
|
||||||
* - Post Order Handler (Redis Pub/Sub subscriber)
|
* - Post Order Handler (Redis Pub/Sub subscriber)
|
||||||
* - Cancellation Handler (Redis Pub/Sub subscriber for order cancellations)
|
* - Cancellation Handler (Redis Pub/Sub subscriber for order cancellations)
|
||||||
|
* - User Negativity Store (caches user negativity scores in Redis)
|
||||||
* - Other services can be added here in the future
|
* - Other services can be added here in the future
|
||||||
*/
|
*/
|
||||||
export const initFunc = async (): Promise<void> => {
|
export const initFunc = async (): Promise<void> => {
|
||||||
|
|
@ -18,6 +20,7 @@ export const initFunc = async (): Promise<void> => {
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
initializeAllStores(),
|
initializeAllStores(),
|
||||||
|
initializeUserNegativityStore(),
|
||||||
startOrderHandler(),
|
startOrderHandler(),
|
||||||
startCancellationHandler(),
|
startCancellationHandler(),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
118
apps/backend/src/stores/user-negativity-store.ts
Normal file
118
apps/backend/src/stores/user-negativity-store.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import redisClient from 'src/lib/redis-client';
|
||||||
|
import { db } from '../db/db_index';
|
||||||
|
import { userIncidents } from '../db/schema';
|
||||||
|
import { eq, sum } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function initializeUserNegativityStore(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Initializing user negativity store in Redis...');
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select({
|
||||||
|
userId: userIncidents.userId,
|
||||||
|
totalNegativityScore: sum(userIncidents.negativityScore).mapWith(Number),
|
||||||
|
})
|
||||||
|
.from(userIncidents)
|
||||||
|
.groupBy(userIncidents.userId);
|
||||||
|
|
||||||
|
for (const { userId, totalNegativityScore } of results) {
|
||||||
|
await redisClient.set(
|
||||||
|
`user:negativity:${userId}`,
|
||||||
|
totalNegativityScore.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`User negativity store initialized for ${results.length} users`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing user negativity store:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserNegativity(userId: number): Promise<number> {
|
||||||
|
try {
|
||||||
|
const key = `user:negativity:${userId}`;
|
||||||
|
const data = await redisClient.get(key);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseInt(data, 10);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting negativity score for user ${userId}:`, error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllUserNegativityScores(): Promise<Record<number, number>> {
|
||||||
|
try {
|
||||||
|
const keys = await redisClient.KEYS('user:negativity:*');
|
||||||
|
|
||||||
|
if (keys.length === 0) return {};
|
||||||
|
|
||||||
|
const values = await redisClient.MGET(keys);
|
||||||
|
|
||||||
|
const result: Record<number, number> = {};
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
const value = values[i];
|
||||||
|
|
||||||
|
const match = key.match(/user:negativity:(\d+)/);
|
||||||
|
if (match && value) {
|
||||||
|
const userId = parseInt(match[1], 10);
|
||||||
|
result[userId] = parseInt(value, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting all user negativity scores:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMultipleUserNegativityScores(userIds: number[]): Promise<Record<number, number>> {
|
||||||
|
try {
|
||||||
|
if (userIds.length === 0) return {};
|
||||||
|
|
||||||
|
const keys = userIds.map(id => `user:negativity:${id}`);
|
||||||
|
|
||||||
|
const values = await redisClient.MGET(keys);
|
||||||
|
|
||||||
|
const result: Record<number, number> = {};
|
||||||
|
for (let i = 0; i < userIds.length; i++) {
|
||||||
|
const value = values[i];
|
||||||
|
if (value) {
|
||||||
|
result[userIds[i]] = parseInt(value, 10);
|
||||||
|
} else {
|
||||||
|
result[userIds[i]] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting multiple user negativity scores:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recomputeUserNegativityScore(userId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [result] = await db
|
||||||
|
.select({
|
||||||
|
totalNegativityScore: sum(userIncidents.negativityScore).mapWith(Number),
|
||||||
|
})
|
||||||
|
.from(userIncidents)
|
||||||
|
.where(eq(userIncidents.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const totalScore = result?.totalNegativityScore || 0;
|
||||||
|
|
||||||
|
const key = `user:negativity:${userId}`;
|
||||||
|
await redisClient.set(key, totalScore.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error recomputing negativity score for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
sendOrderDeliveredNotification,
|
sendOrderDeliveredNotification,
|
||||||
} from "../../lib/notif-job";
|
} from "../../lib/notif-job";
|
||||||
import { publishCancellation } from "../../lib/post-order-handler";
|
import { publishCancellation } from "../../lib/post-order-handler";
|
||||||
|
import { getMultipleUserNegativityScores } from "../../stores/user-negativity-store";
|
||||||
|
|
||||||
const updateOrderNotesSchema = z.object({
|
const updateOrderNotesSchema = z.object({
|
||||||
orderId: z.number(),
|
orderId: z.number(),
|
||||||
|
|
@ -780,6 +781,9 @@ export const orderRouter = router({
|
||||||
const hasMore = allOrders.length > limit;
|
const hasMore = allOrders.length > limit;
|
||||||
const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders;
|
const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders;
|
||||||
|
|
||||||
|
const userIds = [...new Set(ordersToReturn.map(o => o.userId))];
|
||||||
|
const negativityScores = await getMultipleUserNegativityScores(userIds);
|
||||||
|
|
||||||
const filteredOrders = ordersToReturn.filter((order) => {
|
const filteredOrders = ordersToReturn.filter((order) => {
|
||||||
const statusRecord = order.orderStatus[0];
|
const statusRecord = order.orderStatus[0];
|
||||||
return (
|
return (
|
||||||
|
|
@ -813,35 +817,37 @@ export const orderRouter = router({
|
||||||
.sort((first, second) => first.id - second.id);
|
.sort((first, second) => first.id - second.id);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
return {
|
return {
|
||||||
id: order.id,
|
id: order.id,
|
||||||
orderId: order.id.toString(),
|
orderId: order.id.toString(),
|
||||||
readableId: order.id,
|
readableId: order.id,
|
||||||
customerName: order.user.name,
|
customerName: order.user.name,
|
||||||
address: `${order.address.addressLine1}${
|
customerMobile: order.user.mobile,
|
||||||
order.address.addressLine2
|
address: `${order.address.addressLine1}${
|
||||||
? `, ${order.address.addressLine2}`
|
order.address.addressLine2
|
||||||
: ""
|
? `, ${order.address.addressLine2}`
|
||||||
}, ${order.address.city}, ${order.address.state} - ${
|
: ""
|
||||||
order.address.pincode
|
}, ${order.address.city}, ${order.address.state} - ${
|
||||||
}, Phone: ${order.address.phone}`,
|
order.address.pincode
|
||||||
addressId: order.addressId,
|
}, Phone: ${order.address.phone}`,
|
||||||
latitude: order.address.adminLatitude ?? order.address.latitude,
|
addressId: order.addressId,
|
||||||
longitude: order.address.adminLongitude ?? order.address.longitude,
|
latitude: order.address.adminLatitude ?? order.address.latitude,
|
||||||
totalAmount: parseFloat(order.totalAmount),
|
longitude: order.address.adminLongitude ?? order.address.longitude,
|
||||||
deliveryCharge: parseFloat(order.deliveryCharge || "0"),
|
totalAmount: parseFloat(order.totalAmount),
|
||||||
items,
|
deliveryCharge: parseFloat(order.deliveryCharge || "0"),
|
||||||
createdAt: order.createdAt,
|
items,
|
||||||
// deliveryTime: order.slot ? dayjs.utc(order.slot.deliveryTime).format('ddd, MMM D • h:mm A') : 'Not scheduled',
|
createdAt: order.createdAt,
|
||||||
deliveryTime: order.slot?.deliveryTime.toISOString() || null,
|
// deliveryTime: order.slot ? dayjs.utc(order.slot.deliveryTime).format('ddd, MMM D • h:mm A') : 'Not scheduled',
|
||||||
status,
|
deliveryTime: order.slot?.deliveryTime.toISOString() || null,
|
||||||
isPackaged:
|
status,
|
||||||
order.orderItems.every((item) => item.is_packaged) || false,
|
isPackaged:
|
||||||
isDelivered: statusRecord?.isDelivered || false,
|
order.orderItems.every((item) => item.is_packaged) || false,
|
||||||
isCod: order.isCod,
|
isDelivered: statusRecord?.isDelivered || false,
|
||||||
isFlashDelivery: order.isFlashDelivery,
|
isCod: order.isCod,
|
||||||
userNotes: order.userNotes,
|
isFlashDelivery: order.isFlashDelivery,
|
||||||
adminNotes: order.adminNotes,
|
userNotes: order.userNotes,
|
||||||
};
|
adminNotes: order.adminNotes,
|
||||||
|
userNegativityScore: negativityScores[order.userId] || 0,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { db } from '../../db/db_index';
|
||||||
import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '../../db/schema';
|
import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '../../db/schema';
|
||||||
import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm';
|
import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm';
|
||||||
import { ApiError } from '../../lib/api-error';
|
import { ApiError } from '../../lib/api-error';
|
||||||
import { notificationQueue } from '../../lib/notif-job';
|
import { notificationQueue } from '../../lib/notif-job';
|
||||||
|
import { recomputeUserNegativityScore } from '../../stores/user-negativity-store';
|
||||||
|
|
||||||
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
|
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
|
||||||
// Clean mobile number (remove non-digits)
|
// Clean mobile number (remove non-digits)
|
||||||
|
|
@ -470,21 +471,16 @@ export const userRouter = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const incidentObj = {userId, orderId, adminComment, addedBy: adminUserId, negativityScore}
|
const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore };
|
||||||
|
|
||||||
const [incident] = await db.insert(userIncidents)
|
const [incident] = await db.insert(userIncidents)
|
||||||
.values(
|
.values({
|
||||||
// {
|
...incidentObj,
|
||||||
// userId,
|
})
|
||||||
// orderId,
|
|
||||||
// adminComment,
|
|
||||||
// addedBy: adminUserId,
|
|
||||||
// negativityScore,
|
|
||||||
// }
|
|
||||||
{...incidentObj}
|
|
||||||
)
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
recomputeUserNegativityScore(userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: incident,
|
data: incident,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue