Compare commits
2 commits
648a0a5d28
...
1b62c6add4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b62c6add4 | ||
|
|
588cbe00f3 |
9 changed files with 14 additions and 1211 deletions
4
.commandcode/taste/taste.md
Normal file
4
.commandcode/taste/taste.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Taste (Continuously Learned by [CommandCode][cmd])
|
||||||
|
|
||||||
|
[cmd]: https://commandcode.ai/
|
||||||
|
|
||||||
|
|
@ -1,801 +0,0 @@
|
||||||
import React, { useState , useEffect } from 'react';
|
|
||||||
import { View, TouchableOpacity, Alert, TextInput, ActivityIndicator, Linking } from 'react-native';
|
|
||||||
import { AppContainer, MyText, tw, MyFlatList, BottomDialog, BottomDropdown, Checkbox, theme, MyTextInput } from 'common-ui';
|
|
||||||
import { trpc } from '@/src/trpc-client';
|
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import { Entypo } from '@expo/vector-icons';
|
|
||||||
import CancelOrderDialog from '@/components/CancelOrderDialog';
|
|
||||||
import { OrderOptionsMenu } from '@/components/OrderOptionsMenu';
|
|
||||||
import * as Location from 'expo-location';
|
|
||||||
|
|
||||||
const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => {
|
|
||||||
const [notesText, setNotesText] = useState(existingNotes || '');
|
|
||||||
const updateNotesMutation = trpc.admin.order.updateNotes.useMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={tw`p-4`}>
|
|
||||||
<MyText style={tw`text-lg font-bold mb-4`}>Admin Notes</MyText>
|
|
||||||
<TextInput
|
|
||||||
style={tw`border border-gray-300 rounded p-3 h-24 text-base`}
|
|
||||||
multiline
|
|
||||||
value={notesText}
|
|
||||||
onChangeText={setNotesText}
|
|
||||||
placeholder="Enter admin notes..."
|
|
||||||
/>
|
|
||||||
<View style={tw`flex-row mt-4`}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`flex-1 bg-blue-500 p-3 rounded ml-2`}
|
|
||||||
onPress={() => {
|
|
||||||
updateNotesMutation.mutate(
|
|
||||||
{ orderId: parseInt(orderId), adminNotes: notesText },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
onClose();
|
|
||||||
Alert.alert('Success', 'Notes updated successfully');
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to update notes');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MyText style={tw`text-center text-white font-semibold`}>Save</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
interface OrderType {
|
|
||||||
id: number;
|
|
||||||
orderId: string;
|
|
||||||
readableId: number;
|
|
||||||
customerName: string | null;
|
|
||||||
customerMobile?: string | null;
|
|
||||||
address: string;
|
|
||||||
addressId: number;
|
|
||||||
latitude: number | null;
|
|
||||||
longitude: number | null;
|
|
||||||
totalAmount: number;
|
|
||||||
deliveryCharge: number;
|
|
||||||
items: {
|
|
||||||
id?: number;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
amount: number;
|
|
||||||
unit: string;
|
|
||||||
isPackaged?: boolean;
|
|
||||||
isPackageVerified?: boolean;
|
|
||||||
productSize: number;
|
|
||||||
}[];
|
|
||||||
createdAt: string;
|
|
||||||
deliveryTime: string | null;
|
|
||||||
status: 'pending' | 'delivered' | 'cancelled';
|
|
||||||
isPackaged: boolean;
|
|
||||||
isDelivered: boolean;
|
|
||||||
isCod: boolean;
|
|
||||||
isFlashDelivery: boolean;
|
|
||||||
couponCode?: string;
|
|
||||||
couponDescription?: string;
|
|
||||||
discountAmount?: number;
|
|
||||||
adminNotes?: string | null;
|
|
||||||
userNotes?: string | null;
|
|
||||||
userNegativityScore?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
|
||||||
const id = order.orderId;
|
|
||||||
const router = useRouter();
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const [itemsDialogOpen, setItemsDialogOpen] = useState(false);
|
|
||||||
const [notesDialogOpen, setNotesDialogOpen] = useState(false);
|
|
||||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
|
||||||
const [userNotesDialogOpen, setUserNotesDialogOpen] = useState(false);
|
|
||||||
const [adminNotesDialogOpen, setAdminNotesDialogOpen] = useState(false);
|
|
||||||
const [updatingItems, setUpdatingItems] = useState<Set<number>>(new Set());
|
|
||||||
|
|
||||||
const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation();
|
|
||||||
const updateDeliveredMutation = trpc.admin.order.updateDelivered.useMutation();
|
|
||||||
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
|
|
||||||
|
|
||||||
const handleOrderPress = () => {
|
|
||||||
router.push(`/manage-orders/order-details/${order.orderId}` as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMenuOption = () => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
router.push(`/manage-orders/order-details/${order.orderId}` as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkPackaged = (isPackaged: boolean) => {
|
|
||||||
updatePackagedMutation.mutate(
|
|
||||||
{ orderId: order.orderId.toString(), isPackaged },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkDelivered = (isDelivered: boolean) => {
|
|
||||||
updateDeliveredMutation.mutate(
|
|
||||||
{ orderId: order.orderId.toString(), isDelivered },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleItemPackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => {
|
|
||||||
setUpdatingItems(prev => new Set(prev).add(itemId));
|
|
||||||
|
|
||||||
updateItemPackagingMutation.mutate(
|
|
||||||
{ orderItemId: itemId, [field]: value },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setUpdatingItems(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(itemId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
setUpdatingItems(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(itemId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
Alert.alert("Error", error.message || "Failed to update packaging status");
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`bg-white mx-4 mb-4 rounded-xl shadow-sm border border-gray-100 overflow-hidden`}
|
|
||||||
onPress={handleOrderPress}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
{/* Header Section */}
|
|
||||||
<View style={tw`p-4 border-b border-gray-100 bg-gray-50/50`}>
|
|
||||||
<View style={tw`flex-row justify-between items-start`}>
|
|
||||||
<View style={tw`flex-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' : (order.userNegativityScore && order.userNegativityScore > 0 ? 'text-yellow-600' : 'text-gray-900')}`}>
|
|
||||||
{order.customerName || order.customerMobile || 'Unknown Customer'}
|
|
||||||
</MyText>
|
|
||||||
<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>
|
|
||||||
</View>
|
|
||||||
{order.isFlashDelivery && (
|
|
||||||
<View style={tw`bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200 flex-row items-center`}>
|
|
||||||
<MaterialIcons name="bolt" size={12} color="#D97706" />
|
|
||||||
<MyText style={tw`text-xs font-bold text-amber-700 ml-1`}>FLASH</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="access-time" size={12} color="#6B7280" />
|
|
||||||
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
|
||||||
{dayjs(order.createdAt).format('MMM D, h:mm A')}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setMenuOpen(true)}
|
|
||||||
style={tw`p-2 -mr-2 -mt-2 rounded-full`}
|
|
||||||
>
|
|
||||||
<Entypo name="dots-three-vertical" size={16} color="#9CA3AF" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<View style={tw`p-4`}>
|
|
||||||
{/* Status Badges */}
|
|
||||||
<View style={tw`flex-row flex-wrap gap-4 mb-4`}>
|
|
||||||
{/* <View style={tw`px-2.5 py-1 rounded-full ${getStatusColor(order.status)}`}>
|
|
||||||
<MyText style={tw`text-xs font-semibold capitalize`}>{order.status}</MyText>
|
|
||||||
</View> */}
|
|
||||||
{/* {order.isCod && (
|
|
||||||
<View style={tw`px-2.5 py-1 rounded-full bg-blue-50 border border-blue-100`}>
|
|
||||||
<MyText style={tw`text-xs font-semibold text-blue-700`}>COD</MyText>
|
|
||||||
</View>
|
|
||||||
)} */}
|
|
||||||
<View style={tw`flex-row items-center gap-1`}>
|
|
||||||
<MyText style={tw`text-sm font-semibold text-gray-600`}>Packaged</MyText>
|
|
||||||
<Checkbox
|
|
||||||
checked={order.isPackaged}
|
|
||||||
// onPress={() => handleMarkPackaged(!order.isPackaged)}
|
|
||||||
onPress={() => {}}
|
|
||||||
size={18}
|
|
||||||
fillColor={theme.colors.gray500}
|
|
||||||
checkColor="#FFFFFF"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row items-center gap-1`}>
|
|
||||||
<MyText style={tw`text-xs font-semibold text-gray-600`}>Delivered</MyText>
|
|
||||||
<Checkbox
|
|
||||||
checked={order.isDelivered}
|
|
||||||
onPress={() => handleMarkDelivered(!order.isDelivered)}
|
|
||||||
size={18}
|
|
||||||
fillColor="#10B981"
|
|
||||||
checkColor="#FFFFFF"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{order.status === 'cancelled' && (
|
|
||||||
<View style={tw`border border-red-500 px-2 py-0.5 rounded-full`}>
|
|
||||||
<MyText style={tw`text-xs font-bold text-red-600`}>CANCELLED</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Delivery Info */}
|
|
||||||
<View style={tw`flex-row items-start mb-4 bg-blue-50/50 p-3 rounded-lg`}>
|
|
||||||
<MaterialIcons name="location-pin" size={18} color="#3B82F6" style={tw`mt-0.5`} />
|
|
||||||
<View style={tw`ml-2 flex-1`}>
|
|
||||||
<MyText style={tw`text-xs font-bold text-blue-800 mb-0.5 uppercase tracking-wide`}>Delivery Address</MyText>
|
|
||||||
<MyText style={tw`text-sm text-gray-700 leading-5`} numberOfLines={2}>
|
|
||||||
{order.address}
|
|
||||||
</MyText>
|
|
||||||
<View style={tw`flex-row items-center mt-2`}>
|
|
||||||
<MaterialIcons name="event" size={14} color="#6B7280" />
|
|
||||||
<MyText style={tw`text-xs text-gray-600 ml-1`}>
|
|
||||||
{order.isFlashDelivery ? "1 Hr Delivery:" : "Slot:"} {order.isFlashDelivery ? dayjs(order.createdAt).add(30, 'minutes').format('MMM D, h:mm A') : order.deliveryTime ? dayjs(order.deliveryTime).format("ddd, MMM D • h:mm A") : 'Not scheduled'}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
{order.isFlashDelivery && (
|
|
||||||
<View style={tw`flex-row items-center mt-1 bg-amber-50 px-2 py-1 rounded`}>
|
|
||||||
<MaterialIcons name="bolt" size={12} color="#D97706" />
|
|
||||||
<MyText style={tw`text-xs text-amber-700 ml-1 font-medium`}>
|
|
||||||
1 Hour Delivery • High Priority
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Items Summary & Total */}
|
|
||||||
<View style={tw`mb-4`}>
|
|
||||||
<View style={tw`mb-2`}>
|
|
||||||
<View style={tw`flex-row justify-between items-center`}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setItemsDialogOpen(true)}
|
|
||||||
style={tw`flex-row items-center py-2 px-3 bg-blue-50 rounded-lg flex-1 mr-3`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="shopping-cart" size={16} color="#3B82F6" />
|
|
||||||
<MyText style={tw`text-sm font-medium text-blue-700 ml-2`}>
|
|
||||||
{order.items.length} {order.items.length === 1 ? 'item' : 'items'}
|
|
||||||
</MyText>
|
|
||||||
{order.isFlashDelivery && (
|
|
||||||
<View style={tw`ml-2 bg-amber-100 px-1.5 py-0.5 rounded-full`}>
|
|
||||||
<MyText style={tw`text-xs font-bold text-amber-700`}>⚡</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MyText style={tw`text-base font-semibold text-gray-900 mr-2`}>Total:</MyText>
|
|
||||||
<MyText style={tw`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-gray-900'}`}>₹{order.totalAmount}</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Coupons */}
|
|
||||||
{order.couponCode && (
|
|
||||||
<View style={tw`mb-4`}>
|
|
||||||
<MyText style={tw`text-xs font-bold text-gray-500 mb-2 uppercase tracking-wide`}>Applied Coupons</MyText>
|
|
||||||
<View style={tw`bg-pink-50 border border-pink-200 rounded-lg p-3`}>
|
|
||||||
<MyText style={tw`text-sm text-pink-800 font-medium mb-1`}>
|
|
||||||
{order.couponCode}
|
|
||||||
</MyText>
|
|
||||||
{order.couponDescription && (
|
|
||||||
<MyText style={tw`text-xs text-pink-600 mb-2`}>
|
|
||||||
{order.couponDescription}
|
|
||||||
</MyText>
|
|
||||||
)}
|
|
||||||
{order.discountAmount && (
|
|
||||||
<MyText style={tw`text-sm font-bold text-pink-800`}>
|
|
||||||
Discount: ₹{order.discountAmount}
|
|
||||||
</MyText>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes Section */}
|
|
||||||
<View style={tw`flex-row gap-2`}>
|
|
||||||
{order.userNotes && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`flex-row items-center p-3 bg-amber-50 rounded-lg flex-1`}
|
|
||||||
onPress={() => setUserNotesDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="note" size={18} color="#D97706" />
|
|
||||||
<MyText style={tw`text-amber-800 font-medium ml-2`}>
|
|
||||||
User Notes
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{order.adminNotes && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={tw`flex-row items-center p-3 bg-blue-50 rounded-lg flex-1`}
|
|
||||||
onPress={() => setNotesDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="admin-panel-settings" size={18} color="#2563EB" />
|
|
||||||
<MyText style={tw`text-blue-800 font-medium ml-2`}>
|
|
||||||
Admin Notes
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Footer / Delivery Charge */}
|
|
||||||
{order.deliveryCharge > 0 && (
|
|
||||||
<View style={tw`pt-3 border-t border-gray-100`}>
|
|
||||||
<View style={tw`flex-row justify-between items-center`}>
|
|
||||||
<MyText style={tw`text-sm text-gray-500`}>Delivery Charge</MyText>
|
|
||||||
<MyText style={tw`text-sm text-gray-900`}>₹{order.deliveryCharge}</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<OrderOptionsMenu
|
|
||||||
open={menuOpen}
|
|
||||||
onClose={() => setMenuOpen(false)}
|
|
||||||
order={{
|
|
||||||
id: order.id,
|
|
||||||
readableId: order.readableId,
|
|
||||||
isPackaged: order.isPackaged,
|
|
||||||
isDelivered: order.isDelivered,
|
|
||||||
isFlashDelivery: order.isFlashDelivery,
|
|
||||||
address: order.address,
|
|
||||||
addressId: order.addressId,
|
|
||||||
adminNotes: order.adminNotes,
|
|
||||||
userNotes: order.userNotes,
|
|
||||||
latitude: order.latitude,
|
|
||||||
longitude: order.longitude,
|
|
||||||
status: order.status,
|
|
||||||
}}
|
|
||||||
onViewDetails={handleMenuOption}
|
|
||||||
onTogglePackaged={() => handleMarkPackaged(!order.isPackaged)}
|
|
||||||
onToggleDelivered={() => handleMarkDelivered(!order.isDelivered)}
|
|
||||||
onOpenAdminNotes={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
setNotesDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onCancelOrder={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
setCancelDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onAttachLocation={() => refetch()}
|
|
||||||
onWhatsApp={() => {}}
|
|
||||||
onDial={() => {}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BottomDialog open={itemsDialogOpen} onClose={() => setItemsDialogOpen(false)}>
|
|
||||||
<View style={tw`py-6`}>
|
|
||||||
<View style={tw`flex-row items-center justify-between mb-4`}>
|
|
||||||
<MyText style={tw`text-lg font-bold text-gray-800`}>
|
|
||||||
Order Items
|
|
||||||
</MyText>
|
|
||||||
{order.isFlashDelivery && (
|
|
||||||
<View style={tw`bg-amber-100 px-2 py-1 rounded-full border border-amber-200 flex-row items-center`}>
|
|
||||||
<MaterialIcons name="bolt" size={14} color="#D97706" />
|
|
||||||
<MyText style={tw`text-xs font-bold text-amber-700 ml-1`}>FLASH</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<MyText style={tw`text-sm text-gray-600 mb-6`}>
|
|
||||||
Total: ₹{order.totalAmount}
|
|
||||||
</MyText>
|
|
||||||
{order.items.map((item, idx) => (
|
|
||||||
<View key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}>
|
|
||||||
<MyText style={tw`text-xs font-bold text-gray-600`}>{item.quantity * item.productSize } {item.unit}</MyText>
|
|
||||||
</View>
|
|
||||||
<MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}
|
|
||||||
</MyText>
|
|
||||||
{item.isPackaged !== undefined && item.isPackageVerified !== undefined && (
|
|
||||||
<>
|
|
||||||
<View style={tw`flex-row items-center gap-1 mr-3`}>
|
|
||||||
<MyText style={tw`text-sm font-medium text-gray-600`}>pkg</MyText>
|
|
||||||
<Checkbox
|
|
||||||
checked={item.isPackaged}
|
|
||||||
onPress={() => handleItemPackagingToggle(item.id!, 'isPackaged', !item.isPackaged)}
|
|
||||||
size={18}
|
|
||||||
fillColor={updatingItems.has(item.id!) ? "#F59E0B" : "#10B981"}
|
|
||||||
checkColor="#FFFFFF"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row items-center gap-1`}>
|
|
||||||
<MyText style={tw`text-sm font-medium text-gray-600`}>verf</MyText>
|
|
||||||
<Checkbox
|
|
||||||
checked={item.isPackageVerified}
|
|
||||||
onPress={() => handleItemPackagingToggle(item.id!, 'isPackageVerified', !item.isPackageVerified)}
|
|
||||||
size={18}
|
|
||||||
fillColor={updatingItems.has(item.id!) ? "#F59E0B" : "#10B981"}
|
|
||||||
checkColor="#FFFFFF"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{updatingItems.has(item.id!) && (
|
|
||||||
<ActivityIndicator size="small" color="#F59E0B" style={tw`ml-1`} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</BottomDialog>
|
|
||||||
|
|
||||||
<BottomDialog open={notesDialogOpen} onClose={() => setNotesDialogOpen(false)}>
|
|
||||||
<AdminNotesForm orderId={order.orderId} existingNotes={order.adminNotes} onClose={() => setNotesDialogOpen(false)} refetch={refetch} />
|
|
||||||
</BottomDialog>
|
|
||||||
|
|
||||||
<CancelOrderDialog
|
|
||||||
orderId={order.id}
|
|
||||||
open={cancelDialogOpen}
|
|
||||||
onClose={() => setCancelDialogOpen(false)}
|
|
||||||
onSuccess={refetch}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BottomDialog open={userNotesDialogOpen} onClose={() => setUserNotesDialogOpen(false)}>
|
|
||||||
<View style={tw`p-6`}>
|
|
||||||
<MyText style={tw`text-lg font-bold text-gray-800 mb-4`}>
|
|
||||||
User Notes
|
|
||||||
</MyText>
|
|
||||||
<View style={tw`bg-amber-50 p-4 rounded-lg border border-amber-200`}>
|
|
||||||
<MyText style={tw`text-sm text-amber-900 leading-5`}>
|
|
||||||
{order.userNotes}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomDialog>
|
|
||||||
|
|
||||||
<BottomDialog open={adminNotesDialogOpen} onClose={() => setAdminNotesDialogOpen(false)}>
|
|
||||||
<View style={tw`p-6`}>
|
|
||||||
<MyText style={tw`text-lg font-bold text-gray-800 mb-4`}>
|
|
||||||
Admin Notes
|
|
||||||
</MyText>
|
|
||||||
<View style={tw`bg-blue-50 p-4 rounded-lg border border-blue-200`}>
|
|
||||||
<MyText style={tw`text-sm text-blue-900 leading-5`}>
|
|
||||||
{order.adminNotes}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Orders() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { filter } = useLocalSearchParams<{ filter?: string }>();
|
|
||||||
const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
|
|
||||||
const [selectedSlotType, setSelectedSlotType] = useState<'slot' | 'flash' | null>(null);
|
|
||||||
const [packagedFilter, setPackagedFilter] = useState<'all' | 'packaged' | 'not_packaged'>('all');
|
|
||||||
const [packagedChecked, setPackagedChecked] = useState(false);
|
|
||||||
const [notPackagedChecked, setNotPackagedChecked] = useState(false);
|
|
||||||
const [deliveredFilter, setDeliveredFilter] = useState<'all' | 'delivered' | 'not_delivered'>('all');
|
|
||||||
const [deliveredChecked, setDeliveredChecked] = useState(false);
|
|
||||||
const [notDeliveredChecked, setNotDeliveredChecked] = useState(false);
|
|
||||||
const [cancellationFilter, setCancellationFilter] = useState<'all' | 'cancelled' | 'not_cancelled'>('all');
|
|
||||||
const [cancelledChecked, setCancelledChecked] = useState(false);
|
|
||||||
const [notCancelledChecked, setNotCancelledChecked] = useState(false);
|
|
||||||
const [flashDeliveryFilter, setFlashDeliveryFilter] = useState<'all' | 'flash' | 'regular'>('all');
|
|
||||||
const [flashChecked, setFlashChecked] = useState(false);
|
|
||||||
const [regularChecked, setRegularChecked] = useState(false);
|
|
||||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
// Handle initial filter from URL params
|
|
||||||
useEffect(() => {
|
|
||||||
if (filter === 'flash') {
|
|
||||||
setSelectedSlotType('flash');
|
|
||||||
setFlashDeliveryFilter('flash');
|
|
||||||
setFlashChecked(true);
|
|
||||||
setRegularChecked(false);
|
|
||||||
}
|
|
||||||
}, [filter]);
|
|
||||||
const { data: slotsData } = trpc.admin.slots.getAll.useQuery();
|
|
||||||
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch } = trpc.admin.order.getAll.useInfiniteQuery(
|
|
||||||
{
|
|
||||||
limit: 20,
|
|
||||||
slotId: selectedSlotType === 'slot' ? selectedSlot : null,
|
|
||||||
packagedFilter,
|
|
||||||
deliveredFilter,
|
|
||||||
cancellationFilter,
|
|
||||||
flashDeliveryFilter: selectedSlotType === 'flash' ? 'flash' : flashDeliveryFilter
|
|
||||||
},
|
|
||||||
{
|
|
||||||
getNextPageParam: (lastPage) => lastPage?.nextCursor,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const orders = data?.pages.flatMap(page => page?.orders) || [];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
|
||||||
<ActivityIndicator size="large" color="#3B82F6" />
|
|
||||||
<MyText style={tw`text-gray-600 mt-4`}>Loading orders...</MyText>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slotOptions = [
|
|
||||||
{ label: '⚡ Flash Deliveries', value: 'flash' },
|
|
||||||
...(slotsData?.slots?.map(slot => ({
|
|
||||||
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
|
|
||||||
value: slot.id.toString(),
|
|
||||||
})) || [])
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MyFlatList
|
|
||||||
data={orders}
|
|
||||||
keyExtractor={(item) => item!.orderId}
|
|
||||||
renderItem={({ item }) => item ? <OrderItem order={item} refetch={refetch} /> : null}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onEndReachedThreshold={0.5}
|
|
||||||
onRefresh={() => refetch()}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<>
|
|
||||||
<View style={tw`flex-row justify-between items-center p-4 bg-white`}>
|
|
||||||
<View style={tw`flex-1 mr-4`}>
|
|
||||||
<BottomDropdown
|
|
||||||
label="Select Slot"
|
|
||||||
options={slotOptions}
|
|
||||||
value={selectedSlotType === 'flash' ? 'flash' : (selectedSlot?.toString() || '')}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
if (val === 'flash') {
|
|
||||||
setSelectedSlotType('flash');
|
|
||||||
setSelectedSlot(null);
|
|
||||||
setFlashDeliveryFilter('flash');
|
|
||||||
// Reset other filters when switching to flash
|
|
||||||
setPackagedFilter('all');
|
|
||||||
setPackagedChecked(false);
|
|
||||||
setNotPackagedChecked(false);
|
|
||||||
setDeliveredFilter('all');
|
|
||||||
setDeliveredChecked(false);
|
|
||||||
setNotDeliveredChecked(false);
|
|
||||||
setCancellationFilter('all');
|
|
||||||
setCancelledChecked(false);
|
|
||||||
setNotCancelledChecked(false);
|
|
||||||
} else {
|
|
||||||
setSelectedSlotType('slot');
|
|
||||||
setSelectedSlot(val ? Number(val) : null);
|
|
||||||
setFlashDeliveryFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="All slots"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setFilterDialogOpen(true)}
|
|
||||||
style={tw`p-2`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="filter-list" size={24} color="#6b7280" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{!isLoading && selectedSlotType && (
|
|
||||||
<View style={tw`bg-gray-50 p-3 border-b border-gray-200`}>
|
|
||||||
<MyText style={tw`text-center text-gray-600`}>
|
|
||||||
{selectedSlotType === 'flash'
|
|
||||||
? `${orders.length} Flash delivery orders`
|
|
||||||
: `${orders.length} Orders in slot`
|
|
||||||
}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
ListFooterComponent={
|
|
||||||
isFetchingNextPage ? (
|
|
||||||
<View style={tw`py-4 items-center flex-row justify-center`}>
|
|
||||||
<ActivityIndicator size="small" color="#3B82F6" />
|
|
||||||
<MyText style={tw`text-gray-600 ml-2`}>Loading more...</MyText>
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BottomDialog open={filterDialogOpen} onClose={() => setFilterDialogOpen(false)}>
|
|
||||||
<AppContainer>
|
|
||||||
<View style={tw`mt-4`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold mb-2`}>Packaged Status</MyText>
|
|
||||||
<View style={tw`flex-row items-center mb-2`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={packagedChecked}
|
|
||||||
onPress={() => {
|
|
||||||
const newValue = !packagedChecked;
|
|
||||||
setPackagedChecked(newValue);
|
|
||||||
if (newValue && notPackagedChecked) {
|
|
||||||
setPackagedFilter('all');
|
|
||||||
} else if (newValue) {
|
|
||||||
setPackagedFilter('packaged');
|
|
||||||
} else if (notPackagedChecked) {
|
|
||||||
setPackagedFilter('not_packaged');
|
|
||||||
} else {
|
|
||||||
setPackagedFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`ml-2`}>Packaged</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={notPackagedChecked}
|
|
||||||
onPress={() => {
|
|
||||||
const newValue = !notPackagedChecked;
|
|
||||||
setNotPackagedChecked(newValue);
|
|
||||||
if (packagedChecked && newValue) {
|
|
||||||
setPackagedFilter('all');
|
|
||||||
} else if (newValue) {
|
|
||||||
setPackagedFilter('not_packaged');
|
|
||||||
} else if (packagedChecked) {
|
|
||||||
setPackagedFilter('packaged');
|
|
||||||
} else {
|
|
||||||
setPackagedFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`ml-2`}>Not Packaged</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={tw`mt-6`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold mb-2`}>Delivered Status</MyText>
|
|
||||||
<View style={tw`flex-row items-center mb-2`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={deliveredChecked}
|
|
||||||
onPress={() => {
|
|
||||||
const newValue = !deliveredChecked;
|
|
||||||
setDeliveredChecked(newValue);
|
|
||||||
if (newValue && notDeliveredChecked) {
|
|
||||||
setDeliveredFilter('all');
|
|
||||||
} else if (newValue) {
|
|
||||||
setDeliveredFilter('delivered');
|
|
||||||
} else if (notDeliveredChecked) {
|
|
||||||
setDeliveredFilter('not_delivered');
|
|
||||||
} else {
|
|
||||||
setDeliveredFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`ml-2`}>Delivered</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={notDeliveredChecked}
|
|
||||||
onPress={() => {
|
|
||||||
const newValue = !notDeliveredChecked;
|
|
||||||
setNotDeliveredChecked(newValue);
|
|
||||||
if (deliveredChecked && newValue) {
|
|
||||||
setDeliveredFilter('all');
|
|
||||||
} else if (newValue) {
|
|
||||||
setDeliveredFilter('not_delivered');
|
|
||||||
} else if (deliveredChecked) {
|
|
||||||
setDeliveredFilter('delivered');
|
|
||||||
} else {
|
|
||||||
setDeliveredFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`ml-2`}>Not Delivered</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={tw`mt-6`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold mb-2`}>Cancellation Status</MyText>
|
|
||||||
<View style={tw`flex-row items-center mb-2`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={cancelledChecked}
|
|
||||||
onPress={() => {
|
|
||||||
const newValue = !cancelledChecked;
|
|
||||||
setCancelledChecked(newValue);
|
|
||||||
if (newValue && notCancelledChecked) {
|
|
||||||
setCancellationFilter('all');
|
|
||||||
} else if (newValue) {
|
|
||||||
setCancellationFilter('cancelled');
|
|
||||||
} else if (notCancelledChecked) {
|
|
||||||
setCancellationFilter('not_cancelled');
|
|
||||||
} else {
|
|
||||||
setCancellationFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`ml-2`}>Cancelled</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={notCancelledChecked}
|
|
||||||
onPress={() => {
|
|
||||||
const newValue = !notCancelledChecked;
|
|
||||||
setNotCancelledChecked(newValue);
|
|
||||||
if (cancelledChecked && newValue) {
|
|
||||||
setCancellationFilter('all');
|
|
||||||
} else if (newValue) {
|
|
||||||
setCancellationFilter('not_cancelled');
|
|
||||||
} else if (cancelledChecked) {
|
|
||||||
setCancellationFilter('cancelled');
|
|
||||||
} else {
|
|
||||||
setCancellationFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`ml-2`}>Not Cancelled</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={tw`mt-6`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold mb-2`}>Delivery Type</MyText>
|
|
||||||
<View style={tw`flex-row items-center mb-2`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={flashChecked}
|
|
||||||
onPress={() => {
|
|
||||||
const newValue = !flashChecked;
|
|
||||||
setFlashChecked(newValue);
|
|
||||||
if (newValue && regularChecked) {
|
|
||||||
setFlashDeliveryFilter('all');
|
|
||||||
} else if (newValue) {
|
|
||||||
setFlashDeliveryFilter('flash');
|
|
||||||
} else if (regularChecked) {
|
|
||||||
setFlashDeliveryFilter('regular');
|
|
||||||
} else {
|
|
||||||
setFlashDeliveryFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`ml-2`}>⚡ 1 Hr Delivery</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={regularChecked}
|
|
||||||
onPress={() => {
|
|
||||||
const newValue = !regularChecked;
|
|
||||||
setRegularChecked(newValue);
|
|
||||||
if (flashChecked && newValue) {
|
|
||||||
setFlashDeliveryFilter('all');
|
|
||||||
} else if (newValue) {
|
|
||||||
setFlashDeliveryFilter('regular');
|
|
||||||
} else if (flashChecked) {
|
|
||||||
setFlashDeliveryFilter('flash');
|
|
||||||
} else {
|
|
||||||
setFlashDeliveryFilter('all');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`ml-2`}>Regular Delivery</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
</BottomDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Alert } from 'react-native';
|
|
||||||
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui';
|
|
||||||
import ProductForm from '@/src/components/ProductForm';
|
|
||||||
import { trpc } from '@/src/trpc-client';
|
|
||||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
|
||||||
|
|
||||||
export default function AddProduct() {
|
|
||||||
const createProduct = trpc.admin.product.createProduct.useMutation();
|
|
||||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
const { upload, isUploading } = useUploadToObjectStorage();
|
|
||||||
|
|
||||||
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => {
|
|
||||||
try {
|
|
||||||
let uploadUrls: string[] = [];
|
|
||||||
|
|
||||||
if (images.length > 0) {
|
|
||||||
const blobs = await Promise.all(
|
|
||||||
images.map(async (img) => {
|
|
||||||
const response = await fetch(img.url);
|
|
||||||
const blob = await response.blob();
|
|
||||||
return { blob, mimeType: img.mimeType || 'image/jpeg' };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await upload({ images: blobs, contextString: 'product_info' });
|
|
||||||
uploadUrls = result.presignedUrls;
|
|
||||||
}
|
|
||||||
|
|
||||||
await createProduct.mutateAsync({
|
|
||||||
name: values.name,
|
|
||||||
shortDescription: values.shortDescription,
|
|
||||||
longDescription: values.longDescription,
|
|
||||||
unitId: parseInt(values.unitId),
|
|
||||||
storeId: parseInt(values.storeId),
|
|
||||||
price: parseFloat(values.price),
|
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
|
||||||
incrementStep: 1,
|
|
||||||
productQuantity: values.productQuantity || 1,
|
|
||||||
isSuspended: values.isSuspended || false,
|
|
||||||
isFlashAvailable: values.isFlashAvailable || false,
|
|
||||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
|
||||||
uploadUrls,
|
|
||||||
tagIds: values.tagIds || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await refetchProducts();
|
|
||||||
Alert.alert('Success', 'Product created successfully!');
|
|
||||||
} catch (error: any) {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to create product');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialValues = {
|
|
||||||
name: '',
|
|
||||||
shortDescription: '',
|
|
||||||
longDescription: '',
|
|
||||||
unitId: 0,
|
|
||||||
price: '',
|
|
||||||
storeId: 1,
|
|
||||||
marketPrice: '',
|
|
||||||
deals: [{ quantity: '', price: '', validTill: new Date() }],
|
|
||||||
tagIds: [],
|
|
||||||
isSuspended: false,
|
|
||||||
isFlashAvailable: false,
|
|
||||||
flashPrice: '',
|
|
||||||
productQuantity: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppContainer>
|
|
||||||
<ProductForm
|
|
||||||
mode="create"
|
|
||||||
initialValues={initialValues}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
isLoading={createProduct.isPending || isUploading}
|
|
||||||
/>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react';
|
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
|
||||||
import { Formik, FieldArray } from 'formik';
|
|
||||||
import * as Yup from 'yup';
|
|
||||||
import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import { trpc } from '../trpc-client';
|
|
||||||
|
|
||||||
interface ProductFormData {
|
|
||||||
name: string;
|
|
||||||
shortDescription: string;
|
|
||||||
longDescription: string;
|
|
||||||
unitId: number;
|
|
||||||
storeId: number;
|
|
||||||
price: string;
|
|
||||||
marketPrice: string;
|
|
||||||
isSuspended: boolean;
|
|
||||||
isFlashAvailable: boolean;
|
|
||||||
flashPrice: string;
|
|
||||||
deals: Deal[];
|
|
||||||
tagIds: number[];
|
|
||||||
productQuantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Deal {
|
|
||||||
quantity: string;
|
|
||||||
price: string;
|
|
||||||
validTill: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductFormRef {
|
|
||||||
clearImages: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductFormProps {
|
|
||||||
mode: 'create' | 'edit';
|
|
||||||
initialValues: ProductFormData;
|
|
||||||
onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
existingImages?: ImageUploaderNeoItem[];
|
|
||||||
existingImageKeys?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const unitOptions = [
|
|
||||||
{ label: 'Kg', value: 1 },
|
|
||||||
{ label: 'Litre', value: 2 },
|
|
||||||
{ label: 'Dozen', value: 3 },
|
|
||||||
{ label: 'Unit Piece', value: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|
||||||
mode,
|
|
||||||
initialValues,
|
|
||||||
onSubmit,
|
|
||||||
isLoading,
|
|
||||||
existingImages:existingImagesRaw,
|
|
||||||
existingImageKeys = [],
|
|
||||||
}, ref) => {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [images, setImages] = useState<ImageUploaderNeoItem[]>([]);
|
|
||||||
|
|
||||||
const existingImages = existingImagesRaw || []
|
|
||||||
// Sync images state when existingImages prop changes (e.g., when async query data arrives)
|
|
||||||
useEffect(() => {
|
|
||||||
setImages(existingImages);
|
|
||||||
}, [existingImagesRaw]);
|
|
||||||
|
|
||||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
|
||||||
const storeOptions = storesData?.stores.map(store => ({
|
|
||||||
label: store.name,
|
|
||||||
value: store.id,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const { data: tagsData } = trpc.admin.product.getProductTags.useQuery();
|
|
||||||
const tagOptions = tagsData?.tags.map(tag => ({
|
|
||||||
label: tag.tagName,
|
|
||||||
value: tag.id.toString(),
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
// Build signed URL -> S3 key mapping for existing images
|
|
||||||
const signedUrlToKey = useMemo(() => {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
existingImages.forEach((img, i) => {
|
|
||||||
if (existingImageKeys[i]) {
|
|
||||||
map[img.imgUrl] = existingImageKeys[i];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [existingImages, existingImageKeys]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Formik
|
|
||||||
initialValues={initialValues}
|
|
||||||
onSubmit={(values) => {
|
|
||||||
// New images have mimeType set, existing images have mimeType === null
|
|
||||||
const newImages = images.filter(img => img.mimeType !== null);
|
|
||||||
const deletedImageKeys = existingImages
|
|
||||||
.filter(existing => !images.some(current => current.imgUrl === existing.imgUrl))
|
|
||||||
.map(deleted => signedUrlToKey[deleted.imgUrl])
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
onSubmit(
|
|
||||||
values,
|
|
||||||
newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })),
|
|
||||||
deletedImageKeys,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
enableReinitialize
|
|
||||||
>
|
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
|
||||||
const clearForm = useCallback(() => {
|
|
||||||
setImages([]);
|
|
||||||
resetForm();
|
|
||||||
}, [resetForm]);
|
|
||||||
|
|
||||||
useFocusCallback(clearForm);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
clearImages: clearForm,
|
|
||||||
}), [clearForm]);
|
|
||||||
|
|
||||||
const submit = () => handleSubmit();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Product Name"
|
|
||||||
placeholder="Enter product name"
|
|
||||||
value={values.name}
|
|
||||||
onChangeText={handleChange('name')}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Short Description"
|
|
||||||
placeholder="Enter short description"
|
|
||||||
multiline
|
|
||||||
numberOfLines={2}
|
|
||||||
value={values.shortDescription}
|
|
||||||
onChangeText={handleChange('shortDescription')}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Long Description"
|
|
||||||
placeholder="Enter detailed description"
|
|
||||||
multiline
|
|
||||||
numberOfLines={4}
|
|
||||||
value={values.longDescription}
|
|
||||||
onChangeText={handleChange('longDescription')}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImageUploaderNeo
|
|
||||||
images={images}
|
|
||||||
onImageAdd={(payloads) => setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])}
|
|
||||||
onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))}
|
|
||||||
allowMultiple={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BottomDropdown
|
|
||||||
topLabel='Unit'
|
|
||||||
label="Unit"
|
|
||||||
value={values.unitId}
|
|
||||||
options={unitOptions}
|
|
||||||
onValueChange={(value) => setFieldValue('unitId', value)}
|
|
||||||
placeholder="Select unit"
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Product Quantity"
|
|
||||||
placeholder="Enter product quantity"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={values.productQuantity.toString()}
|
|
||||||
onChangeText={(text) => setFieldValue('productQuantity', text)}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
<BottomDropdown
|
|
||||||
topLabel="Store"
|
|
||||||
label="Store"
|
|
||||||
value={values.storeId}
|
|
||||||
options={storeOptions}
|
|
||||||
onValueChange={(value) => setFieldValue('storeId', value)}
|
|
||||||
placeholder="Select store"
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
<BottomDropdown
|
|
||||||
topLabel="Tags"
|
|
||||||
label="Tags"
|
|
||||||
value={values.tagIds.map(id => id.toString())}
|
|
||||||
options={tagOptions}
|
|
||||||
onValueChange={(value) => setFieldValue('tagIds', (value as string[]).map(id => parseInt(id)))}
|
|
||||||
multiple={true}
|
|
||||||
placeholder="Select tags"
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Unit Price"
|
|
||||||
placeholder="Enter unit price"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={values.price}
|
|
||||||
onChangeText={handleChange('price')}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Market Price (Optional)"
|
|
||||||
placeholder="Enter market price"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={values.marketPrice}
|
|
||||||
onChangeText={handleChange('marketPrice')}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={tw`flex-row items-center mb-4`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={values.isSuspended}
|
|
||||||
onPress={() => setFieldValue('isSuspended', !values.isSuspended)}
|
|
||||||
style={tw`mr-3`}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`text-gray-700 font-medium`}>Suspend Product</MyText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={tw`flex-row items-center mb-4`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={values.isFlashAvailable}
|
|
||||||
onPress={() => {
|
|
||||||
setFieldValue('isFlashAvailable', !values.isFlashAvailable);
|
|
||||||
if (values.isFlashAvailable) setFieldValue('flashPrice', '');
|
|
||||||
}}
|
|
||||||
style={tw`mr-3`}
|
|
||||||
/>
|
|
||||||
<MyText style={tw`text-gray-700 font-medium`}>Flash Available</MyText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{values.isFlashAvailable && (
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Flash Price"
|
|
||||||
placeholder="Enter flash price"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={values.flashPrice}
|
|
||||||
onChangeText={handleChange('flashPrice')}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={submit}
|
|
||||||
disabled={isLoading}
|
|
||||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
|
||||||
>
|
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
|
||||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Formik>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ProductForm.displayName = 'ProductForm';
|
|
||||||
|
|
||||||
export default ProductForm;
|
|
||||||
|
|
@ -73,7 +73,6 @@ export {
|
||||||
updateSkuDeals,
|
updateSkuDeals,
|
||||||
replaceProductTags,
|
replaceProductTags,
|
||||||
mergeSkus,
|
mergeSkus,
|
||||||
toggleSkuOutOfStock,
|
|
||||||
updateSlotProducts,
|
updateSlotProducts,
|
||||||
getSlotProductIds,
|
getSlotProductIds,
|
||||||
getSlotsProductIds,
|
getSlotsProductIds,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
getAllProducts as getAllProductsInDb,
|
getAllProducts as getAllProductsInDb,
|
||||||
getProductById as getProductByIdInDb,
|
getProductById as getProductByIdInDb,
|
||||||
deleteProduct as deleteProductInDb,
|
deleteProduct as deleteProductInDb,
|
||||||
toggleProductOutOfStock as toggleProductOutOfStockInDb,
|
|
||||||
updateSlotProducts as updateSlotProductsInDb,
|
updateSlotProducts as updateSlotProductsInDb,
|
||||||
getSlotProductIds as getSlotProductIdsInDb,
|
getSlotProductIds as getSlotProductIdsInDb,
|
||||||
getSlotsProductIds as getSlotsProductIdsInDb,
|
getSlotsProductIds as getSlotsProductIdsInDb,
|
||||||
|
|
@ -21,11 +20,9 @@ import {
|
||||||
checkProductExistsByName,
|
checkProductExistsByName,
|
||||||
checkUnitExists,
|
checkUnitExists,
|
||||||
createProduct as createProductInDb,
|
createProduct as createProductInDb,
|
||||||
createSpecialDealsForProduct,
|
|
||||||
replaceProductTags,
|
replaceProductTags,
|
||||||
getProductImagesById,
|
getProductImagesById,
|
||||||
updateProduct as updateProductInDb,
|
updateProduct as updateProductInDb,
|
||||||
updateProductDeals,
|
|
||||||
checkProductTagExistsByName,
|
checkProductTagExistsByName,
|
||||||
createProductTag as createProductTagInDb,
|
createProductTag as createProductTagInDb,
|
||||||
updateProductTag as updateProductTagInDb,
|
updateProductTag as updateProductTagInDb,
|
||||||
|
|
@ -44,7 +41,6 @@ import type {
|
||||||
AdminProductListResponse,
|
AdminProductListResponse,
|
||||||
AdminProductResponse,
|
AdminProductResponse,
|
||||||
AdminDeleteProductResult,
|
AdminDeleteProductResult,
|
||||||
AdminToggleOutOfStockResult,
|
|
||||||
AdminUpdateSlotProductsResult,
|
AdminUpdateSlotProductsResult,
|
||||||
AdminSlotProductIdsResult,
|
AdminSlotProductIdsResult,
|
||||||
AdminSlotsProductIdsResult,
|
AdminSlotsProductIdsResult,
|
||||||
|
|
@ -187,46 +183,6 @@ export const productRouter = router({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
toggleOutOfStock: protectedProcedure
|
|
||||||
.input(z.object({
|
|
||||||
id: z.number(),
|
|
||||||
}))
|
|
||||||
.mutation(async ({ input }): Promise<AdminToggleOutOfStockResult> => {
|
|
||||||
const { id } = input;
|
|
||||||
|
|
||||||
const updatedProduct = await toggleProductOutOfStockInDb(id)
|
|
||||||
|
|
||||||
/*
|
|
||||||
// Old implementation - direct DB queries:
|
|
||||||
const product = await db.query.productInfo.findFirst({
|
|
||||||
where: eq(productInfo.id, id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
throw new ApiError("Product not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updatedProduct] = await db
|
|
||||||
.update(productInfo)
|
|
||||||
.set({
|
|
||||||
isOutOfStock: !product.isOutOfStock,
|
|
||||||
})
|
|
||||||
.where(eq(productInfo.id, id))
|
|
||||||
.returning();
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (!updatedProduct) {
|
|
||||||
throw new ApiError('Product not found', 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
await scheduleStoreInitialization()
|
|
||||||
|
|
||||||
return {
|
|
||||||
product: updatedProduct,
|
|
||||||
message: `Product marked as ${updatedProduct.isOutOfStock ? 'out of stock' : 'in stock'}`,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
createProduct: protectedProcedure
|
createProduct: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
|
@ -386,16 +342,16 @@ export const productRouter = router({
|
||||||
updateSlotProducts: protectedProcedure
|
updateSlotProducts: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
slotId: z.string(),
|
slotId: z.string(),
|
||||||
productIds: z.array(z.string()),
|
skuIds: z.array(z.string()),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }): Promise<AdminUpdateSlotProductsResult> => {
|
.mutation(async ({ input }): Promise<AdminUpdateSlotProductsResult> => {
|
||||||
const { slotId, productIds } = input;
|
const { slotId, skuIds } = input;
|
||||||
|
|
||||||
if (!Array.isArray(productIds)) {
|
if (!Array.isArray(skuIds)) {
|
||||||
throw new ApiError("productIds must be an array", 400);
|
throw new ApiError("skuIds must be an array", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateSlotProductsInDb(slotId, productIds)
|
const result = await updateSlotProductsInDb(slotId, skuIds)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
// Old implementation - direct DB queries:
|
// Old implementation - direct DB queries:
|
||||||
|
|
@ -460,7 +416,7 @@ export const productRouter = router({
|
||||||
.query(async ({ input }): Promise<AdminSlotProductIdsResult> => {
|
.query(async ({ input }): Promise<AdminSlotProductIdsResult> => {
|
||||||
const { slotId } = input;
|
const { slotId } = input;
|
||||||
|
|
||||||
const productIds = await getSlotProductIdsInDb(slotId)
|
const skuIds = await getSlotProductIdsInDb(slotId)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
// Old implementation - direct DB queries:
|
// Old implementation - direct DB queries:
|
||||||
|
|
@ -474,12 +430,7 @@ export const productRouter = router({
|
||||||
const productIds = associations.map(assoc => assoc.productId);
|
const productIds = associations.map(assoc => assoc.productId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
productIds,
|
skuIds,
|
||||||
};
|
|
||||||
*/
|
|
||||||
|
|
||||||
return {
|
|
||||||
productIds,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,6 @@ export {
|
||||||
updateSkuDeals,
|
updateSkuDeals,
|
||||||
replaceProductTags,
|
replaceProductTags,
|
||||||
mergeSkus,
|
mergeSkus,
|
||||||
toggleSkuOutOfStock,
|
|
||||||
updateSlotProducts,
|
updateSlotProducts,
|
||||||
getSlotProductIds,
|
getSlotProductIds,
|
||||||
getSlotsProductIds,
|
getSlotsProductIds,
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,6 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSkuOutOfStock(id: number): Promise<AdminSku | null> {
|
|
||||||
const product = await db.query.productSkus.findFirst({
|
const product = await db.query.productSkus.findFirst({
|
||||||
where: eq(productSkus.id, id),
|
where: eq(productSkus.id, id),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -466,7 +466,8 @@ export interface AdminSpecialDeal {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProductWithDetails extends AdminProduct {
|
export interface AdminProductWithDetails extends AdminProduct {
|
||||||
unit: AdminUnit;
|
store: Store | null;
|
||||||
|
skus: AdminSku[];
|
||||||
deals: AdminSpecialDeal[];
|
deals: AdminSpecialDeal[];
|
||||||
tags: AdminProductTagInfo[];
|
tags: AdminProductTagInfo[];
|
||||||
}
|
}
|
||||||
|
|
@ -484,11 +485,6 @@ export interface AdminDeleteProductResult {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminToggleOutOfStockResult {
|
|
||||||
product: AdminProduct;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUpdateSlotProductsResult {
|
export interface AdminUpdateSlotProductsResult {
|
||||||
message: string;
|
message: string;
|
||||||
added: number;
|
added: number;
|
||||||
|
|
@ -496,7 +492,7 @@ export interface AdminUpdateSlotProductsResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSlotProductIdsResult {
|
export interface AdminSlotProductIdsResult {
|
||||||
productIds: number[];
|
skuIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminSlotsProductIdsResult = Record<number, number[]>;
|
export type AdminSlotsProductIdsResult = Record<number, number[]>;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue