backend top level
This commit is contained in:
parent
7553badfeb
commit
ad714493fc
20 changed files with 2350 additions and 940 deletions
801
apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx
Normal file
801
apps/admin-ui/app/(drawer)/manage-orders/orders/index_old.tsx
Normal file
|
|
@ -0,0 +1,801 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,31 +21,32 @@ import { trpc } from "@/src/trpc-client";
|
||||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
import { Entypo } from "@expo/vector-icons";
|
import { Entypo } from "@expo/vector-icons";
|
||||||
|
|
||||||
interface ProductItemProps {
|
interface SkuItemProps {
|
||||||
item: any;
|
sku: any;
|
||||||
hasChanges: (productId: number) => boolean;
|
productName: string;
|
||||||
|
hasChanges: (skuId: number) => boolean;
|
||||||
pendingChanges: Record<string, any>;
|
pendingChanges: Record<string, any>;
|
||||||
setPendingChanges: React.Dispatch<React.SetStateAction<Record<string, any>>>;
|
setPendingChanges: React.Dispatch<React.SetStateAction<Record<string, any>>>;
|
||||||
openEditDialog: (product: any) => void;
|
openEditDialog: (sku: any, productName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductItemComponent: React.FC<ProductItemProps> = ({
|
const SkuItemComponent: React.FC<SkuItemProps> = ({
|
||||||
item: product,
|
sku,
|
||||||
|
productName,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
pendingChanges,
|
pendingChanges,
|
||||||
setPendingChanges,
|
setPendingChanges,
|
||||||
openEditDialog,
|
openEditDialog,
|
||||||
}) => {
|
}) => {
|
||||||
const changed = hasChanges(product.id);
|
const changed = hasChanges(sku.id);
|
||||||
const change = pendingChanges[product.id] || {};
|
const change = pendingChanges[sku.id] || {};
|
||||||
const displayPrice = change.price !== undefined ? change.price : product.price;
|
const displayPrice = change.price !== undefined ? change.price : sku.price;
|
||||||
const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : product.marketPrice;
|
const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : sku.marketPrice;
|
||||||
const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : product.flashPrice;
|
const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : sku.flashPrice;
|
||||||
const displayProductQuantity = change.productQuantity !== undefined ? change.productQuantity : product.productQuantity;
|
const displayProductQuantity = change.productQuantity !== undefined ? change.productQuantity : sku.productQuantity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={tw`bg-white p-4 mb-3 rounded-xl border border-gray-200 shadow-sm`}>
|
<View style={tw`bg-white p-4 mb-3 rounded-xl border border-gray-200 shadow-sm`}>
|
||||||
{/* Change indicator */}
|
|
||||||
<View style={tw`absolute top-2 right-2`}>
|
<View style={tw`absolute top-2 right-2`}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|
@ -57,31 +58,33 @@ const ProductItemComponent: React.FC<ProductItemProps> = ({
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* First row: Image and Name */}
|
|
||||||
<View style={tw`flex-row items-center mb-2`}>
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
{/* Product image */}
|
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
uri: product.images?.[0] || "https://via.placeholder.com/32x32?text=No+Image"
|
uri: sku.images?.[0] || "https://via.placeholder.com/32x32?text=No+Image"
|
||||||
}}
|
}}
|
||||||
style={tw`w-10 h-10 rounded-lg mr-3`}
|
style={tw`w-10 h-10 rounded-lg mr-3`}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Product name and Flash Checkbox */}
|
|
||||||
<View style={tw`flex-1 flex-row items-center`}>
|
<View style={tw`flex-1 flex-row items-center`}>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
<MyText style={tw`text-base font-medium text-gray-800`} numberOfLines={1}>
|
<MyText style={tw`text-base font-medium text-gray-800`} numberOfLines={1}>
|
||||||
{product.name.length > 25 ? product.name.substring(0, 25) + '...' : product.name}
|
{productName.length > 20 ? productName.substring(0, 20) + '...' : productName}
|
||||||
</MyText>
|
</MyText>
|
||||||
|
<MyText style={tw`text-xs text-gray-500`} numberOfLines={1}>
|
||||||
|
{sku.displayName || sku.name || ''}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
<View style={tw`flex-row items-center ml-2`}>
|
<View style={tw`flex-row items-center ml-2`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={change.isFlashAvailable ?? product.isFlashAvailable ?? false}
|
checked={change.isFlashAvailable ?? sku.isFlashAvailable ?? false}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const currentValue = change.isFlashAvailable ?? product.isFlashAvailable ?? false;
|
const currentValue = change.isFlashAvailable ?? sku.isFlashAvailable ?? false;
|
||||||
setPendingChanges(prev => ({
|
setPendingChanges(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[product.id]: {
|
[sku.id]: {
|
||||||
...change,
|
...prev[sku.id],
|
||||||
isFlashAvailable: !currentValue,
|
isFlashAvailable: !currentValue,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -93,47 +96,42 @@ const ProductItemComponent: React.FC<ProductItemProps> = ({
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Prices and Product Size Row */}
|
|
||||||
<View style={tw`flex-row items-center justify-between`}>
|
<View style={tw`flex-row items-center justify-between`}>
|
||||||
{/* Our Price */}
|
|
||||||
<View style={tw`items-center`}>
|
<View style={tw`items-center`}>
|
||||||
<MyText style={tw`text-xs text-gray-500 mb-1`}>Our Price</MyText>
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Our Price</MyText>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<MyText style={tw`text-sm font-bold text-green-600`}>₹{displayPrice}</MyText>
|
<MyText style={tw`text-sm font-bold text-green-600`}>₹{displayPrice}</MyText>
|
||||||
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
<TouchableOpacity onPress={() => openEditDialog(sku, productName)} style={tw`ml-1`}>
|
||||||
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Market Price */}
|
|
||||||
<View style={tw`items-center`}>
|
<View style={tw`items-center`}>
|
||||||
<MyText style={tw`text-xs text-gray-500 mb-1`}>Market Price</MyText>
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Market Price</MyText>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<MyText style={tw`text-sm text-gray-600`}>{displayMarketPrice ? `₹${displayMarketPrice}` : "N/A"}</MyText>
|
<MyText style={tw`text-sm text-gray-600`}>{displayMarketPrice ? `₹${displayMarketPrice}` : "N/A"}</MyText>
|
||||||
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
<TouchableOpacity onPress={() => openEditDialog(sku, productName)} style={tw`ml-1`}>
|
||||||
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Flash Price */}
|
|
||||||
<View style={tw`items-center`}>
|
<View style={tw`items-center`}>
|
||||||
<MyText style={tw`text-xs text-gray-500 mb-1`}>Flash Price</MyText>
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Flash Price</MyText>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<MyText style={tw`text-sm text-orange-600`}>{displayFlashPrice ? `₹${displayFlashPrice}` : "N/A"}</MyText>
|
<MyText style={tw`text-sm text-orange-600`}>{displayFlashPrice ? `₹${displayFlashPrice}` : "N/A"}</MyText>
|
||||||
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
<TouchableOpacity onPress={() => openEditDialog(sku, productName)} style={tw`ml-1`}>
|
||||||
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Product Size */}
|
|
||||||
<View style={tw`items-center`}>
|
<View style={tw`items-center`}>
|
||||||
<MyText style={tw`text-xs text-gray-500 mb-1`}>Size</MyText>
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Size</MyText>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<MyText style={tw`text-sm text-blue-600`}>{displayProductQuantity ? `${displayProductQuantity}${product.unit.shortNotation || ''}` : "N/A"}</MyText>
|
<MyText style={tw`text-sm text-blue-600`}>{displayProductQuantity ? `${displayProductQuantity}${sku.unit?.shortNotation || ''}` : "N/A"}</MyText>
|
||||||
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
<TouchableOpacity onPress={() => openEditDialog(sku, productName)} style={tw`ml-1`}>
|
||||||
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -153,7 +151,8 @@ interface PendingChange {
|
||||||
|
|
||||||
interface EditDialogState {
|
interface EditDialogState {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
product: any;
|
sku: any;
|
||||||
|
productName: string;
|
||||||
tempPrice: string;
|
tempPrice: string;
|
||||||
tempMarketPrice: string;
|
tempMarketPrice: string;
|
||||||
tempFlashPrice: string;
|
tempFlashPrice: string;
|
||||||
|
|
@ -166,7 +165,8 @@ export default function PricesOverview() {
|
||||||
const [pendingChanges, setPendingChanges] = useState<Record<number, PendingChange>>({});
|
const [pendingChanges, setPendingChanges] = useState<Record<number, PendingChange>>({});
|
||||||
const [editDialog, setEditDialog] = useState<EditDialogState>({
|
const [editDialog, setEditDialog] = useState<EditDialogState>({
|
||||||
open: false,
|
open: false,
|
||||||
product: null,
|
sku: null,
|
||||||
|
productName: "",
|
||||||
tempPrice: "",
|
tempPrice: "",
|
||||||
tempMarketPrice: "",
|
tempMarketPrice: "",
|
||||||
tempFlashPrice: "",
|
tempFlashPrice: "",
|
||||||
|
|
@ -184,13 +184,11 @@ export default function PricesOverview() {
|
||||||
const stores = storesData?.stores || [];
|
const stores = storesData?.stores || [];
|
||||||
const allProducts = productsData?.products || [];
|
const allProducts = productsData?.products || [];
|
||||||
|
|
||||||
// Sort stores alphabetically
|
|
||||||
const sortedStores = useMemo(() =>
|
const sortedStores = useMemo(() =>
|
||||||
[...stores].sort((a, b) => a.name.localeCompare(b.name)),
|
[...stores].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
[stores]
|
[stores]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store options for dropdown
|
|
||||||
const storeOptions = useMemo(() =>
|
const storeOptions = useMemo(() =>
|
||||||
sortedStores.map(store => ({
|
sortedStores.map(store => ({
|
||||||
label: store.name,
|
label: store.name,
|
||||||
|
|
@ -199,38 +197,46 @@ export default function PricesOverview() {
|
||||||
[sortedStores]
|
[sortedStores]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize selectedStores to all if not set
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stores.length > 0 && selectedStores.length === 0) {
|
if (stores.length > 0 && selectedStores.length === 0) {
|
||||||
setSelectedStores(stores.map(s => s.id.toString()));
|
setSelectedStores(stores.map(s => s.id.toString()));
|
||||||
}
|
}
|
||||||
}, [stores, selectedStores]);
|
}, [stores, selectedStores]);
|
||||||
|
|
||||||
// Filter products by selected stores
|
const allSkus = useMemo(() => {
|
||||||
const filteredProducts = useMemo(() => {
|
const skus: any[] = [];
|
||||||
if (selectedStores.length === 0) return allProducts;
|
for (const product of allProducts) {
|
||||||
return allProducts.filter(product =>
|
if (product.skus && product.skus.length > 0) {
|
||||||
product.storeId && selectedStores.includes(product.storeId.toString())
|
for (const sku of product.skus) {
|
||||||
|
skus.push({ ...sku, _productName: product.name, _storeId: product.storeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skus;
|
||||||
|
}, [allProducts]);
|
||||||
|
|
||||||
|
const filteredSkus = useMemo(() => {
|
||||||
|
if (selectedStores.length === 0) return allSkus;
|
||||||
|
return allSkus.filter(sku =>
|
||||||
|
sku._storeId && selectedStores.includes(sku._storeId.toString())
|
||||||
);
|
);
|
||||||
}, [allProducts, selectedStores]);
|
}, [allSkus, selectedStores]);
|
||||||
|
|
||||||
// Check if a product has changes
|
const hasChanges = (skuId: number) => !!pendingChanges[skuId];
|
||||||
const hasChanges = (productId: number) => !!pendingChanges[productId];
|
|
||||||
|
|
||||||
// Open edit dialog
|
const openEditDialog = (sku: any, productName: string) => {
|
||||||
const openEditDialog = (product: any) => {
|
const change = pendingChanges[sku.id] || {};
|
||||||
const change = pendingChanges[product.id] || {};
|
|
||||||
setEditDialog({
|
setEditDialog({
|
||||||
open: true,
|
open: true,
|
||||||
product,
|
sku,
|
||||||
tempPrice: (change.price ?? product.price)?.toString() || "",
|
productName,
|
||||||
tempMarketPrice: (change.marketPrice ?? product.marketPrice)?.toString() || "",
|
tempPrice: (change.price ?? sku.price)?.toString() || "",
|
||||||
tempFlashPrice: (change.flashPrice ?? product.flashPrice)?.toString() || "",
|
tempMarketPrice: (change.marketPrice ?? sku.marketPrice)?.toString() || "",
|
||||||
tempProductQuantity: (change.productQuantity ?? product.productQuantity)?.toString() || "",
|
tempFlashPrice: (change.flashPrice ?? sku.flashPrice)?.toString() || "",
|
||||||
|
tempProductQuantity: (change.productQuantity ?? sku.productQuantity)?.toString() || "",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save edit dialog
|
|
||||||
const saveEditDialog = () => {
|
const saveEditDialog = () => {
|
||||||
const price = parseFloat(editDialog.tempPrice);
|
const price = parseFloat(editDialog.tempPrice);
|
||||||
const marketPrice = editDialog.tempMarketPrice ? parseFloat(editDialog.tempMarketPrice) : null;
|
const marketPrice = editDialog.tempMarketPrice ? parseFloat(editDialog.tempMarketPrice) : null;
|
||||||
|
|
@ -253,27 +259,27 @@ export default function PricesOverview() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editDialog.tempProductQuantity && (isNaN(productQuantity!) || productQuantity! <= 0)) {
|
if (editDialog.tempProductQuantity && (isNaN(productQuantity!) || productQuantity! <= 0)) {
|
||||||
Alert.alert("Error", "Please enter a valid product size");
|
Alert.alert("Error", "Please enter a valid size");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingChanges(prev => ({
|
setPendingChanges(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[editDialog.product.id]: {
|
[editDialog.sku.id]: {
|
||||||
price: price !== editDialog.product.price ? price : undefined,
|
price: price !== parseFloat(editDialog.sku.price) ? price : undefined,
|
||||||
marketPrice: marketPrice !== editDialog.product.marketPrice ? marketPrice : undefined,
|
marketPrice: marketPrice !== parseFloat(editDialog.sku.marketPrice || '0') ? marketPrice : undefined,
|
||||||
flashPrice: flashPrice !== editDialog.product.flashPrice ? flashPrice : undefined,
|
flashPrice: flashPrice !== parseFloat(editDialog.sku.flashPrice || '0') ? flashPrice : undefined,
|
||||||
productQuantity: productQuantity !== editDialog.product.productQuantity ? productQuantity : undefined,
|
productQuantity: productQuantity !== (editDialog.sku.productQuantity || 1) ? productQuantity : undefined,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setEditDialog({ open: false, product: null, tempPrice: "", tempMarketPrice: "", tempFlashPrice: "", tempProductQuantity: "" });
|
setEditDialog({ open: false, sku: null, productName: "", tempPrice: "", tempMarketPrice: "", tempFlashPrice: "", tempProductQuantity: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle save all changes
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const updates = Object.entries(pendingChanges).map(([productId, change]) => {
|
const updates = Object.entries(pendingChanges).map(([skuId, change]) => {
|
||||||
const update: any = { productId: parseInt(productId) };
|
const sku = allSkus.find(s => s.id === parseInt(skuId));
|
||||||
|
const update: any = { productId: sku?.productId };
|
||||||
if (change.price !== undefined) update.price = change.price;
|
if (change.price !== undefined) update.price = change.price;
|
||||||
if (change.marketPrice !== undefined) update.marketPrice = change.marketPrice;
|
if (change.marketPrice !== undefined) update.marketPrice = change.marketPrice;
|
||||||
if (change.flashPrice !== undefined) update.flashPrice = change.flashPrice;
|
if (change.flashPrice !== undefined) update.flashPrice = change.flashPrice;
|
||||||
|
|
@ -297,13 +303,10 @@ export default function PricesOverview() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const changeCount = Object.keys(pendingChanges).length;
|
const changeCount = Object.keys(pendingChanges).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={tw`flex-1 bg-gray-50`}>
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
{/* Stores filter, save button, and menu */}
|
|
||||||
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center`}>
|
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center`}>
|
||||||
<View style={tw`flex-1 mr-4`}>
|
<View style={tw`flex-1 mr-4`}>
|
||||||
<BottomDropdown
|
<BottomDropdown
|
||||||
|
|
@ -350,7 +353,6 @@ export default function PricesOverview() {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{productsLoading || storesLoading ? (
|
{productsLoading || storesLoading ? (
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
<ActivityIndicator size="large" color="#3b82f6" />
|
<ActivityIndicator size="large" color="#3b82f6" />
|
||||||
|
|
@ -358,10 +360,11 @@ export default function PricesOverview() {
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={filteredProducts}
|
data={filteredSkus}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<ProductItemComponent
|
<SkuItemComponent
|
||||||
item={item}
|
sku={item}
|
||||||
|
productName={item._productName}
|
||||||
hasChanges={hasChanges}
|
hasChanges={hasChanges}
|
||||||
pendingChanges={pendingChanges}
|
pendingChanges={pendingChanges}
|
||||||
setPendingChanges={setPendingChanges}
|
setPendingChanges={setPendingChanges}
|
||||||
|
|
@ -374,10 +377,10 @@ export default function PricesOverview() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit Dialog */}
|
|
||||||
<BottomDialog open={editDialog.open} onClose={() => setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "", tempProductQuantity: "" })}>
|
<BottomDialog open={editDialog.open} onClose={() => setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "", tempProductQuantity: "" })}>
|
||||||
<View style={tw`p-4`}>
|
<View style={tw`p-4`}>
|
||||||
<MyText style={tw`text-lg font-bold mb-4`}>{editDialog.product?.name}</MyText>
|
<MyText style={tw`text-lg font-bold mb-1`}>{editDialog.productName}</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-500 mb-4`}>{editDialog.sku?.displayName || editDialog.sku?.name || ''}</MyText>
|
||||||
|
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
<MyText style={tw`text-sm font-medium mb-1`}>Our Price</MyText>
|
<MyText style={tw`text-sm font-medium mb-1`}>Our Price</MyText>
|
||||||
|
|
@ -413,13 +416,13 @@ export default function PricesOverview() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
<MyText style={tw`text-sm font-medium mb-1`}>Product Size</MyText>
|
<MyText style={tw`text-sm font-medium mb-1`}>Size</MyText>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={tw`border border-gray-300 rounded-md px-3 py-2`}
|
style={tw`border border-gray-300 rounded-md px-3 py-2`}
|
||||||
value={editDialog.tempProductQuantity}
|
value={editDialog.tempProductQuantity}
|
||||||
onChangeText={(text) => setEditDialog({ ...editDialog, tempProductQuantity: text })}
|
onChangeText={(text) => setEditDialog({ ...editDialog, tempProductQuantity: text })}
|
||||||
keyboardType="decimal-pad"
|
keyboardType="decimal-pad"
|
||||||
placeholder="Enter product size"
|
placeholder="Enter size"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -432,7 +435,6 @@ export default function PricesOverview() {
|
||||||
</View>
|
</View>
|
||||||
</BottomDialog>
|
</BottomDialog>
|
||||||
|
|
||||||
{/* Menu Dialog */}
|
|
||||||
<BottomDialog open={showMenu} onClose={() => setShowMenu(false)}>
|
<BottomDialog open={showMenu} onClose={() => setShowMenu(false)}>
|
||||||
<View style={tw`p-6`}>
|
<View style={tw`p-6`}>
|
||||||
<MyText style={tw`text-lg font-bold text-gray-800 mb-6`}>
|
<MyText style={tw`text-lg font-bold text-gray-800 mb-6`}>
|
||||||
|
|
@ -452,8 +454,6 @@ export default function PricesOverview() {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</BottomDialog>
|
</BottomDialog>
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,73 +1,88 @@
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native'
|
||||||
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui';
|
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui'
|
||||||
import ProductForm from '@/src/components/ProductForm';
|
import ProductForm from '@/src/components/ProductForm'
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client'
|
||||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'
|
||||||
|
|
||||||
export default function AddProduct() {
|
export default function AddProduct() {
|
||||||
const createProduct = trpc.admin.product.createProduct.useMutation();
|
const createProduct = trpc.admin.product.createProduct.useMutation()
|
||||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||||
enabled: false,
|
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 { upload, isUploading } = useUploadToObjectStorage()
|
||||||
|
|
||||||
const result = await upload({ images: blobs, contextString: 'product_info' });
|
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
|
||||||
uploadUrls = result.presignedUrls;
|
try {
|
||||||
|
const allBlobs: { blob: Blob; mimeType: string }[] = []
|
||||||
|
const imageCounts: number[] = variantImages.map((imgs) => imgs.length)
|
||||||
|
|
||||||
|
for (const imgs of variantImages) {
|
||||||
|
for (const img of imgs) {
|
||||||
|
const response = await fetch(img.url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allUploadUrls: string[] = []
|
||||||
|
if (allBlobs.length > 0) {
|
||||||
|
const result = await upload({ images: allBlobs, contextString: 'product_info' })
|
||||||
|
allUploadUrls = result.presignedUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlCursor = 0
|
||||||
|
const skus = values.variants.map((variant: any, vIndex: number) => {
|
||||||
|
const count = imageCounts[vIndex]
|
||||||
|
const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
|
||||||
|
urlCursor += count
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: variant.name || null,
|
||||||
|
price: parseFloat(variant.price),
|
||||||
|
marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined,
|
||||||
|
images: variantUrls,
|
||||||
|
isFlashAvailable: variant.isFlashAvailable || false,
|
||||||
|
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
||||||
|
features: variant.attributes.map((attr: any) => ({
|
||||||
|
featureName: attr.featureName,
|
||||||
|
featureValue: attr.featureValue,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
await createProduct.mutateAsync({
|
await createProduct.mutateAsync({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
shortDescription: values.shortDescription,
|
shortDescription: values.shortDescription || undefined,
|
||||||
longDescription: values.longDescription,
|
longDescription: values.longDescription || undefined,
|
||||||
unitId: parseInt(values.unitId),
|
storeId: values.storeId,
|
||||||
storeId: parseInt(values.storeId),
|
|
||||||
price: parseFloat(values.price),
|
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
|
||||||
incrementStep: 1,
|
incrementStep: 1,
|
||||||
productQuantity: values.productQuantity || 1,
|
skus,
|
||||||
isSuspended: values.isSuspended || false,
|
})
|
||||||
isFlashAvailable: values.isFlashAvailable || false,
|
|
||||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
|
||||||
uploadUrls,
|
|
||||||
tagIds: values.tagIds || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await refetchProducts();
|
await refetchProducts()
|
||||||
Alert.alert('Success', 'Product created successfully!');
|
Alert.alert('Success', 'Product created successfully!')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Alert.alert('Error', error.message || 'Failed to create product');
|
Alert.alert('Error', error.message || 'Failed to create product')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
name: '',
|
name: '',
|
||||||
shortDescription: '',
|
shortDescription: '',
|
||||||
longDescription: '',
|
longDescription: '',
|
||||||
unitId: 0,
|
|
||||||
price: '',
|
|
||||||
storeId: 1,
|
storeId: 1,
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
price: '',
|
||||||
marketPrice: '',
|
marketPrice: '',
|
||||||
deals: [{ quantity: '', price: '', validTill: new Date() }],
|
|
||||||
tagIds: [],
|
|
||||||
isSuspended: false,
|
|
||||||
isFlashAvailable: false,
|
isFlashAvailable: false,
|
||||||
flashPrice: '',
|
flashPrice: '',
|
||||||
productQuantity: 1,
|
attributes: [{ featureName: 'quantity', featureValue: '' }],
|
||||||
};
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<AppContainer>
|
||||||
|
|
@ -78,5 +93,5 @@ export default function AddProduct() {
|
||||||
isLoading={createProduct.isPending || isUploading}
|
isLoading={createProduct.isPending || isUploading}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
apps/admin-ui/app/(drawer)/products/add_old.tsx
Normal file
82
apps/admin-ui/app/(drawer)/products/add_old.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -149,9 +149,8 @@ export default function ProductDetail() {
|
||||||
refetch();
|
refetch();
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation();
|
|
||||||
|
|
||||||
const product = productData?.product;
|
const product = productData?.product;
|
||||||
|
const defaultSku = product?.skus?.[0]
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
router.push(`/products/edit?id=${productId}` as any);
|
router.push(`/products/edit?id=${productId}` as any);
|
||||||
|
|
@ -188,9 +187,9 @@ export default function ProductDetail() {
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<View style={tw`relative`}>
|
<View style={tw`relative`}>
|
||||||
{product.images && product.images.length > 0 ? (
|
{defaultSku?.images && defaultSku.images.length > 0 ? (
|
||||||
<ImageCarousel
|
<ImageCarousel
|
||||||
urls={product.images}
|
urls={defaultSku.images}
|
||||||
imageWidth={screenWidth}
|
imageWidth={screenWidth}
|
||||||
imageHeight={carouselHeight}
|
imageHeight={carouselHeight}
|
||||||
showPaginationDots={true}
|
showPaginationDots={true}
|
||||||
|
|
@ -229,43 +228,12 @@ export default function ProductDetail() {
|
||||||
<Animated.View entering={FadeInUp.delay(100).duration(500)} style={tw`bg-white px-6 pt-8 pb-6 rounded-b-[32px] shadow-sm mb-4`}>
|
<Animated.View entering={FadeInUp.delay(100).duration(500)} style={tw`bg-white px-6 pt-8 pb-6 rounded-b-[32px] shadow-sm mb-4`}>
|
||||||
<View style={tw`flex-row justify-between items-start mb-2`}>
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||||
<MyText style={tw`text-3xl font-extrabold text-gray-900 flex-1 mr-4 leading-tight`}>{product.name}</MyText>
|
<MyText style={tw`text-3xl font-extrabold text-gray-900 flex-1 mr-4 leading-tight`}>{product.name}</MyText>
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
toggleOutOfStock.mutate({ id: productId }, {
|
|
||||||
onSuccess: () => Alert.alert('Success', 'Stock status updated'),
|
|
||||||
onError: (err) => Alert.alert('Error', err.message)
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={product.isOutOfStock ? ['#EF4444', '#DC2626'] : ['#10B981', '#059669']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={tw`px-4 py-1.5 rounded-full shadow-sm`}
|
|
||||||
>
|
|
||||||
<Text style={tw`text-white text-xs font-bold uppercase tracking-wide`}>
|
|
||||||
{product.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={tw`flex-row items-end mt-2`}>
|
<View style={tw`flex-row items-end mt-2`}>
|
||||||
<Text style={tw`text-4xl font-black text-gray-900`}>₹{product.price}</Text>
|
|
||||||
<Text style={tw`text-gray-500 text-xl font-medium mb-1.5 ml-2`}>/ {product.unit?.shortNotation}</Text>
|
|
||||||
{product.marketPrice && (
|
|
||||||
<View style={tw`ml-4 mb-2 px-2 py-0.5 bg-red-50 rounded`}>
|
|
||||||
<Text style={tw`text-red-400 text-base line-through font-medium`}>₹{product.marketPrice}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Increment Step Info */}
|
|
||||||
<View style={tw`mt-3 flex-row items-center`}>
|
|
||||||
<View style={tw`bg-blue-50 px-3 py-1.5 rounded-full border border-blue-100`}>
|
<View style={tw`bg-blue-50 px-3 py-1.5 rounded-full border border-blue-100`}>
|
||||||
<Text style={tw`text-blue-700 text-sm font-bold`}>
|
<Text style={tw`text-blue-700 text-sm font-bold`}>
|
||||||
Increment: {product.incrementStep || 1}
|
{product.skus?.length ?? 0} Variant{(product.skus?.length ?? 0) !== 1 ? 's' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -287,29 +255,10 @@ export default function ProductDetail() {
|
||||||
<Text style={tw`text-lg font-bold text-gray-900`}>{reviewsData?.reviews.length || 0}</Text>
|
<Text style={tw`text-lg font-bold text-gray-900`}>{reviewsData?.reviews.length || 0}</Text>
|
||||||
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Reviews</Text>
|
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Reviews</Text>
|
||||||
</View>
|
</View>
|
||||||
{/* <View style={tw`flex-1 items-center`}>
|
<View style={tw`flex-1 items-center`}>
|
||||||
<TouchableOpacity
|
<Text style={tw`text-lg font-bold text-gray-900`}>{product.incrementStep || 1}</Text>
|
||||||
onPress={() => {
|
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Step</Text>
|
||||||
toggleOutOfStock.mutate({ id: productId }, {
|
</View>
|
||||||
onSuccess: () => Alert.alert('Success', 'Stock status updated'),
|
|
||||||
onError: (err) => Alert.alert('Error', err.message)
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={product.isOutOfStock ? ['#EF4444', '#DC2626'] : ['#10B981', '#059669']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={tw`px-3 py-1 rounded-full shadow-sm`}
|
|
||||||
>
|
|
||||||
<Text style={tw`text-white text-xs font-bold uppercase tracking-wide`}>
|
|
||||||
{product.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Stock</Text>
|
|
||||||
</View> */}
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
|
@ -333,43 +282,67 @@ export default function ProductDetail() {
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Availability */}
|
{/* Variants Section */}
|
||||||
<Animated.View entering={FadeInDown.delay(250).duration(500)} style={tw`px-4 mb-4`}>
|
<Animated.View entering={FadeInDown.delay(300).duration(500)} style={tw`px-4 mb-4`}>
|
||||||
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
|
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
|
||||||
<View style={tw`flex-row items-center mb-4`}>
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
<View style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}>
|
<View style={tw`w-10 h-10 bg-green-50 rounded-full items-center justify-center mr-3`}>
|
||||||
<MaterialIcons name="inventory" size={22} color="#2563EB" />
|
<MaterialIcons name="category" size={22} color="#059669" />
|
||||||
|
</View>
|
||||||
|
<Text style={tw`text-lg font-bold text-gray-900`}>Variants</Text>
|
||||||
|
<View style={tw`bg-gray-100 px-3 py-1 rounded-full ml-3`}>
|
||||||
|
<Text style={tw`text-xs font-bold text-gray-600`}>{product.skus?.length ?? 0}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={tw`text-lg font-bold text-gray-900`}>Availability</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={tw`text-gray-600 mb-4`}>
|
{product.skus?.map((sku) => (
|
||||||
This product is currently {product.isOutOfStock ? 'out of stock' : 'in stock'}.
|
<View key={sku.id} style={tw`border border-gray-200 rounded-2xl p-4 mb-3`}>
|
||||||
</Text>
|
{/* Attributes */}
|
||||||
|
{sku.features?.map((f) => (
|
||||||
|
<View key={f.id} style={tw`flex-row mb-1`}>
|
||||||
|
<Text style={tw`text-gray-500 text-sm w-24 font-medium`}>{f.featureName}</Text>
|
||||||
|
<Text style={tw`text-gray-900 text-sm font-semibold`}>{f.featureValue}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
<TouchableOpacity
|
{/* Pricing row */}
|
||||||
onPress={() => {
|
<View style={tw`flex-row items-end justify-between mt-3 pt-3 border-t border-gray-100`}>
|
||||||
toggleOutOfStock.mutate({ id: productId }, {
|
<View>
|
||||||
onSuccess: () => {
|
<Text style={tw`text-2xl font-black text-gray-900`}>₹{sku.price}</Text>
|
||||||
Alert.alert('Success', 'Stock status updated');
|
{sku.marketPrice && (
|
||||||
refetch();
|
<Text style={tw`text-sm text-gray-400 line-through`}>₹{sku.marketPrice}</Text>
|
||||||
},
|
)}
|
||||||
onError: (err) => Alert.alert('Error', err.message)
|
</View>
|
||||||
});
|
<View style={tw`flex-row items-center gap-2`}>
|
||||||
}}
|
{sku.isFlashAvailable && (
|
||||||
activeOpacity={0.8}
|
<View style={tw`bg-red-50 px-2 py-1 rounded-full border border-red-200`}>
|
||||||
style={tw`bg-gray-100 px-4 py-2 rounded-full border border-gray-200 self-start`}
|
<Text style={tw`text-red-600 text-xs font-bold`}>Flash ₹{sku.flashPrice}</Text>
|
||||||
>
|
</View>
|
||||||
<Text style={tw`text-gray-700 font-bold text-sm`}>
|
)}
|
||||||
Mark as {product.isOutOfStock ? 'In Stock' : 'Out of Stock'}
|
<View style={tw`px-3 py-1 rounded-full ${sku.isOutOfStock ? 'bg-red-100' : 'bg-green-100'}`}>
|
||||||
|
<Text style={tw`text-xs font-bold ${sku.isOutOfStock ? 'text-red-700' : 'text-green-700'}`}>
|
||||||
|
{sku.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* SKU Images */}
|
||||||
|
{sku.images && sku.images.length > 0 && (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={tw`mt-3`}>
|
||||||
|
{sku.images.map((url, idx) => (
|
||||||
|
<Image key={idx} source={{ uri: url }} style={tw`w-16 h-16 rounded-lg mr-2 bg-gray-100`} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Special Deals */}
|
{/* Special Deals */}
|
||||||
{product.deals && product.deals.length > 0 && (
|
{product.deals && product.deals.length > 0 && (
|
||||||
<Animated.View entering={FadeInDown.delay(300).duration(500)} style={tw`px-4 mb-4`}>
|
<Animated.View entering={FadeInDown.delay(350).duration(500)} style={tw`px-4 mb-4`}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#FFFBEB', '#FEF3C7']}
|
colors={['#FFFBEB', '#FEF3C7']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef, useMemo } from 'react';
|
||||||
import { View, Alert } from 'react-native';
|
import { View, Alert } from 'react-native';
|
||||||
import { useLocalSearchParams } from 'expo-router';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import { AppContainer, useManualRefresh, MyText, tw, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
import { AppContainer, useManualRefresh, MyText, tw, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
||||||
|
|
@ -11,7 +11,7 @@ export default function EditProduct() {
|
||||||
const productId = Number(id);
|
const productId = Number(id);
|
||||||
const productFormRef = useRef<ProductFormRef>(null);
|
const productFormRef = useRef<ProductFormRef>(null);
|
||||||
|
|
||||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
const { data: productResponse, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||||
{ id: productId },
|
{ id: productId },
|
||||||
{ enabled: !!productId }
|
{ enabled: !!productId }
|
||||||
);
|
);
|
||||||
|
|
@ -24,50 +24,119 @@ export default function EditProduct() {
|
||||||
|
|
||||||
useManualRefresh(() => refetch());
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => {
|
const productData = productResponse?.product;
|
||||||
try {
|
|
||||||
// New images have mimeType !== null, existing images have mimeType === null
|
|
||||||
const newImages = images.filter(img => img.mimeType !== null);
|
|
||||||
let uploadUrls: string[] = [];
|
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
const initialValues = useMemo(() => {
|
||||||
const blobs = await Promise.all(
|
if (!productData) {
|
||||||
newImages.map(async (img) => {
|
return {
|
||||||
const response = await fetch(img.url);
|
name: '',
|
||||||
const blob = await response.blob();
|
shortDescription: '',
|
||||||
return { blob, mimeType: img.mimeType || 'image/jpeg' };
|
longDescription: '',
|
||||||
})
|
storeId: 0,
|
||||||
);
|
variants: [],
|
||||||
|
|
||||||
const result = await upload({ images: blobs, contextString: 'product_info' });
|
|
||||||
uploadUrls = result.presignedUrls;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: productData.name,
|
||||||
|
shortDescription: productData.shortDescription || '',
|
||||||
|
longDescription: productData.longDescription || '',
|
||||||
|
storeId: productData.storeId || 1,
|
||||||
|
variants: (productData.skus || []).map((sku) => ({
|
||||||
|
name: sku.name || '',
|
||||||
|
price: sku.price || '',
|
||||||
|
marketPrice: sku.marketPrice || '',
|
||||||
|
isFlashAvailable: sku.isFlashAvailable || false,
|
||||||
|
flashPrice: sku.flashPrice || '',
|
||||||
|
attributes: (sku.features || []).map((f) => ({
|
||||||
|
featureName: f.featureName,
|
||||||
|
featureValue: f.featureValue,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}, [productData])
|
||||||
|
|
||||||
|
const existingVariantImages = useMemo(() => {
|
||||||
|
if (!productData) return []
|
||||||
|
return (productData.skus || []).map((sku) =>
|
||||||
|
(sku.images || []).map((url) => ({ imgUrl: url, mimeType: null } as ImageUploaderNeoItem))
|
||||||
|
)
|
||||||
|
}, [productData])
|
||||||
|
|
||||||
|
const existingVariantImageKeys = useMemo(() => {
|
||||||
|
if (!productData) return []
|
||||||
|
return (productData.skus || []).map((sku) =>
|
||||||
|
(sku.imageKeys || []).map(String)
|
||||||
|
)
|
||||||
|
}, [productData])
|
||||||
|
|
||||||
|
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => {
|
||||||
|
try {
|
||||||
|
const allBlobs: { blob: Blob; mimeType: string }[] = []
|
||||||
|
const imageCounts: number[] = variantImages.map((imgs) =>
|
||||||
|
imgs.filter((img) => img.mimeType !== null).length
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const imgs of variantImages) {
|
||||||
|
for (const img of imgs) {
|
||||||
|
if (img.mimeType === null) continue // existing image, skip
|
||||||
|
const response = await fetch(img.url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allUploadUrls: string[] = []
|
||||||
|
if (allBlobs.length > 0) {
|
||||||
|
const result = await upload({ images: allBlobs, contextString: 'product_info' })
|
||||||
|
allUploadUrls = result.presignedUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SKU inputs with images (existing + new)
|
||||||
|
let urlCursor = 0
|
||||||
|
const skus = values.variants.map((variant: any, vIndex: number) => {
|
||||||
|
const count = imageCounts[vIndex]
|
||||||
|
const newUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
|
||||||
|
urlCursor += count
|
||||||
|
|
||||||
|
// Existing images (mimeType === null) stay as-is
|
||||||
|
const existingUrls = variantImages[vIndex]
|
||||||
|
?.filter((img) => img.mimeType === null)
|
||||||
|
.map((img) => img.url) || []
|
||||||
|
|
||||||
|
const allUrls = [...existingUrls, ...newUrls]
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: variant.name || null,
|
||||||
|
price: parseFloat(variant.price),
|
||||||
|
marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined,
|
||||||
|
images: allUrls,
|
||||||
|
isFlashAvailable: variant.isFlashAvailable || false,
|
||||||
|
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
||||||
|
features: variant.attributes.map((attr: any) => ({
|
||||||
|
featureName: attr.featureName,
|
||||||
|
featureValue: attr.featureValue,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
await updateProduct.mutateAsync({
|
await updateProduct.mutateAsync({
|
||||||
id: productId,
|
id: productId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
shortDescription: values.shortDescription,
|
shortDescription: values.shortDescription || undefined,
|
||||||
longDescription: values.longDescription,
|
longDescription: values.longDescription || undefined,
|
||||||
unitId: parseInt(values.unitId),
|
storeId: values.storeId,
|
||||||
storeId: parseInt(values.storeId),
|
|
||||||
price: parseFloat(values.price),
|
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
|
||||||
incrementStep: 1,
|
incrementStep: 1,
|
||||||
productQuantity: values.productQuantity || 1,
|
skus,
|
||||||
isSuspended: values.isSuspended || false,
|
deletedImageKeys,
|
||||||
isFlashAvailable: values.isFlashAvailable || false,
|
newImageUrls: allUploadUrls,
|
||||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : null,
|
} as any)
|
||||||
uploadUrls,
|
|
||||||
imagesToDelete,
|
|
||||||
tagIds: values.tagIds || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await refetch();
|
await refetch()
|
||||||
await refetchProducts();
|
await refetchProducts()
|
||||||
Alert.alert('Success', 'Product updated successfully!');
|
Alert.alert('Success', 'Product updated successfully!')
|
||||||
productFormRef.current?.clearImages();
|
productFormRef.current?.clearImages()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Alert.alert('Error', error.message || 'Failed to update product');
|
Alert.alert('Error', error.message || 'Failed to update product')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -81,7 +150,7 @@ export default function EditProduct() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product) {
|
if (!productData) {
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<AppContainer>
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
|
@ -91,34 +160,6 @@ export default function EditProduct() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const productData = product.product;
|
|
||||||
|
|
||||||
const existingImages: ImageUploaderNeoItem[] = (productData.images || []).map((url) => ({
|
|
||||||
imgUrl: url,
|
|
||||||
mimeType: null,
|
|
||||||
}));
|
|
||||||
const existingImageKeys = productData.imageKeys || [];
|
|
||||||
|
|
||||||
const initialValues = {
|
|
||||||
name: productData.name,
|
|
||||||
shortDescription: productData.shortDescription || '',
|
|
||||||
longDescription: productData.longDescription || '',
|
|
||||||
unitId: productData.unitId,
|
|
||||||
storeId: productData.storeId || 1,
|
|
||||||
price: productData.price.toString(),
|
|
||||||
marketPrice: productData.marketPrice?.toString() || '',
|
|
||||||
deals: productData.deals?.map(deal => ({
|
|
||||||
quantity: deal.quantity,
|
|
||||||
price: deal.price,
|
|
||||||
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
|
||||||
})) || [{ quantity: '', price: '', validTill: null }],
|
|
||||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
|
||||||
isSuspended: productData.isSuspended || false,
|
|
||||||
isFlashAvailable: productData.isFlashAvailable || false,
|
|
||||||
flashPrice: productData.flashPrice?.toString() || '',
|
|
||||||
productQuantity: productData.productQuantity || 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<AppContainer>
|
||||||
<ProductForm
|
<ProductForm
|
||||||
|
|
@ -127,8 +168,8 @@ export default function EditProduct() {
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={updateProduct.isPending || isUploading}
|
isLoading={updateProduct.isPending || isUploading}
|
||||||
existingImages={existingImages}
|
existingVariantImages={existingVariantImages}
|
||||||
existingImageKeys={existingImageKeys}
|
existingVariantImageKeys={existingVariantImageKeys}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { View, ScrollView, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
import { View, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui';
|
import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers } from 'common-ui';
|
||||||
|
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import type { AdminProduct } from '@packages/shared';
|
import type { AdminSku } from '@packages/shared';
|
||||||
|
|
||||||
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
||||||
|
|
||||||
|
function getDefaultSku(product: { skus: AdminSku[] }): AdminSku | null {
|
||||||
|
return product.skus?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export default function Products() {
|
export default function Products() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -18,8 +22,6 @@ export default function Products() {
|
||||||
|
|
||||||
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
|
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
|
||||||
|
|
||||||
const toggleOutOfStockMutation = trpc.admin.product.toggleOutOfStock.useMutation();
|
|
||||||
|
|
||||||
useManualRefresh(refetch);
|
useManualRefresh(refetch);
|
||||||
|
|
||||||
useMarkDataFetchers(() => {
|
useMarkDataFetchers(() => {
|
||||||
|
|
@ -36,12 +38,14 @@ export default function Products() {
|
||||||
|
|
||||||
const filteredProducts = useMemo(() => {
|
const filteredProducts = useMemo(() => {
|
||||||
return products.filter(product => {
|
return products.filter(product => {
|
||||||
|
const defaultSku = getDefaultSku(product)
|
||||||
|
|
||||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()));
|
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
|
||||||
const matchesFilter = activeFilter === 'all' ||
|
const matchesFilter = activeFilter === 'all' ||
|
||||||
(activeFilter === 'in-stock' && !product.isOutOfStock) ||
|
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
|
||||||
(activeFilter === 'out-of-stock' && product.isOutOfStock);
|
(activeFilter === 'out-of-stock' && defaultSku?.isOutOfStock);
|
||||||
|
|
||||||
return matchesSearch && matchesFilter;
|
return matchesSearch && matchesFilter;
|
||||||
});
|
});
|
||||||
|
|
@ -51,34 +55,6 @@ export default function Products() {
|
||||||
router.push(`/products/edit?id=${productId}` as any);
|
router.push(`/products/edit?id=${productId}` as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// const handleToggleStock = (product: any) => {
|
|
||||||
const handleToggleStock = (product: Pick<AdminProduct, 'id' | 'name' | 'isOutOfStock'>) => {
|
|
||||||
const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock';
|
|
||||||
Alert.alert(
|
|
||||||
'Update Stock Status',
|
|
||||||
`Are you sure you want to ${action} "${product.name}"?`,
|
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Confirm',
|
|
||||||
onPress: () => {
|
|
||||||
toggleOutOfStockMutation.mutate({ id: product.id }, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Alert.alert('Success', data.message);
|
|
||||||
refetch(); // Refresh the list
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to update stock status');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewDetails = (productId: number) => {
|
const handleViewDetails = (productId: number) => {
|
||||||
router.push(`/products/detail/${productId}` as any);
|
router.push(`/products/detail/${productId}` as any);
|
||||||
};
|
};
|
||||||
|
|
@ -120,8 +96,8 @@ export default function Products() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inStockCount = products.filter(p => !p.isOutOfStock).length;
|
const inStockCount = products.filter(p => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length;
|
||||||
const outOfStockCount = products.filter(p => p.isOutOfStock).length;
|
const outOfStockCount = products.filter(p => getDefaultSku(p)?.isOutOfStock).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<AppContainer>
|
||||||
|
|
@ -184,12 +160,17 @@ export default function Products() {
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={tw`pb-4`}>
|
<View style={tw`pb-4`}>
|
||||||
{filteredProducts.map(product => (
|
{filteredProducts.map(product => {
|
||||||
|
const defaultSku = getDefaultSku(product)
|
||||||
|
const skuImages = defaultSku?.images ?? null
|
||||||
|
const isOut = defaultSku?.isOutOfStock ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
<View key={product.id} style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}>
|
<View key={product.id} style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}>
|
||||||
{/* Product Image */}
|
{/* Product Image */}
|
||||||
{product.images && product.images.length > 0 ? (
|
{skuImages && skuImages.length > 0 ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: product.images[0] }}
|
source={{ uri: skuImages[0] }}
|
||||||
style={tw`w-full h-48`}
|
style={tw`w-full h-48`}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -206,9 +187,9 @@ export default function Products() {
|
||||||
{product.name}
|
{product.name}
|
||||||
</MyText>
|
</MyText>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<View style={tw`w-3 h-3 rounded-full mr-1 ${product.isOutOfStock ? 'bg-red-500' : 'bg-green-500'}`} />
|
<View style={tw`w-3 h-3 rounded-full mr-1 ${isOut ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||||
<MyText style={tw`text-sm ${product.isOutOfStock ? 'text-red-500' : 'text-green-500'} font-semibold`}>
|
<MyText style={tw`text-sm ${isOut ? 'text-red-500' : 'text-green-500'} font-semibold`}>
|
||||||
{product.isOutOfStock ? 'Out' : 'In'}
|
{isOut ? 'Out' : 'In'}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -220,16 +201,9 @@ export default function Products() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={tw`flex-row justify-between items-center mb-3`}>
|
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||||
<View>
|
<MyText style={tw`text-sm text-gray-500 font-medium`}>
|
||||||
<MyText style={tw`text-xl font-bold text-green-600`}>
|
{product.skus?.length ?? 0} Variants
|
||||||
₹{product.price}
|
|
||||||
</MyText>
|
</MyText>
|
||||||
{product.marketPrice && (
|
|
||||||
<MyText style={tw`text-sm text-gray-500 line-through`}>
|
|
||||||
₹{product.marketPrice}
|
|
||||||
</MyText>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
|
|
@ -249,20 +223,11 @@ export default function Products() {
|
||||||
<MaterialIcons name="edit" size={16} color="white" />
|
<MaterialIcons name="edit" size={16} color="white" />
|
||||||
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleToggleStock(product)}
|
|
||||||
style={tw`flex-1 ${product.isOutOfStock ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name={product.isOutOfStock ? "check-circle" : "block"} size={16} color="white" />
|
|
||||||
<MyText style={tw`text-white font-semibold ml-1`}>
|
|
||||||
{product.isOutOfStock ? 'Stock' : 'Out'}
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,17 @@ import BottomDropdown, { DropdownOption } from 'common-ui/src/components/bottom-
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
import { tw } from 'common-ui';
|
import { tw } from 'common-ui';
|
||||||
|
|
||||||
interface Product {
|
interface SkuSummary {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
productId: number;
|
||||||
price: number;
|
productName: string;
|
||||||
unit?: string;
|
label: string;
|
||||||
shortDescription?: string | null;
|
|
||||||
isOutOfStock?: boolean;
|
|
||||||
isSuspended?: boolean;
|
|
||||||
storeId?: number | null;
|
|
||||||
unitNotation?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
id: number;
|
id: number;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
products: Product[];
|
products: { id: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductsSelectorProps {
|
interface ProductsSelectorProps {
|
||||||
|
|
@ -30,8 +25,8 @@ interface ProductsSelectorProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
isDisabled?: (product: Product) => boolean;
|
isDisabled?: (product: SkuSummary) => boolean;
|
||||||
labelFormat?: (product: Product) => string;
|
labelFormat?: (product: SkuSummary) => string;
|
||||||
groups?: Group[];
|
groups?: Group[];
|
||||||
selectedGroupIds?: number[];
|
selectedGroupIds?: number[];
|
||||||
onGroupChange?: (groupIds: number[]) => void;
|
onGroupChange?: (groupIds: number[]) => void;
|
||||||
|
|
@ -53,85 +48,78 @@ export default function ProductsSelector({
|
||||||
selectedGroupIds = [],
|
selectedGroupIds = [],
|
||||||
onGroupChange,
|
onGroupChange,
|
||||||
}: ProductsSelectorProps) {
|
}: ProductsSelectorProps) {
|
||||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery({});
|
||||||
const products = productsData?.products || [];
|
const allSkus: SkuSummary[] = skusData?.skus || [];
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Format product label: name (unit) (₹price)
|
|
||||||
const formatProductLabel = (product: Product): string => {
|
|
||||||
if (labelFormat) {
|
|
||||||
return labelFormat(product);
|
|
||||||
}
|
|
||||||
const unit = product.unit ? ` (${product.unit})` : '';
|
|
||||||
const price = ` (₹${product.price})`;
|
|
||||||
return `${product.name}${unit}${price}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle group selection changes
|
// Handle group selection changes
|
||||||
const handleGroupChange = (newGroupIds: number[]) => {
|
const handleGroupChange = (newGroupIds: number[]) => {
|
||||||
if (!onGroupChange) return;
|
if (!onGroupChange) return;
|
||||||
|
|
||||||
const previousGroupIds = selectedGroupIds;
|
const previousGroupIds = selectedGroupIds;
|
||||||
|
|
||||||
// Find which groups were added and which were removed
|
|
||||||
const addedGroups = newGroupIds.filter(id => !previousGroupIds.includes(id));
|
const addedGroups = newGroupIds.filter(id => !previousGroupIds.includes(id));
|
||||||
const removedGroups = previousGroupIds.filter(id => !newGroupIds.includes(id));
|
const removedGroups = previousGroupIds.filter(id => !newGroupIds.includes(id));
|
||||||
|
|
||||||
// Get current selected products
|
let currentSkus = Array.isArray(value) ? [...value] : value ? [value] : [];
|
||||||
let currentProducts = Array.isArray(value) ? [...value] : value ? [value] : [];
|
|
||||||
|
|
||||||
// Add products from newly selected groups
|
const addedSkus = addedGroups.flatMap(groupId => {
|
||||||
const addedProducts = addedGroups.flatMap(groupId => {
|
|
||||||
const group = groups.find(g => g.id === groupId);
|
const group = groups.find(g => g.id === groupId);
|
||||||
return group?.products.map(p => p.id) || [];
|
if (!group) return [];
|
||||||
|
const productIds = new Set(group.products.map(p => p.id));
|
||||||
|
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove products from deselected groups
|
const removedSkus = removedGroups.flatMap(groupId => {
|
||||||
const removedProducts = removedGroups.flatMap(groupId => {
|
|
||||||
const group = groups.find(g => g.id === groupId);
|
const group = groups.find(g => g.id === groupId);
|
||||||
return group?.products.map(p => p.id) || [];
|
if (!group) return [];
|
||||||
|
const productIds = new Set(group.products.map(p => p.id));
|
||||||
|
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update product list: add new ones, remove deselected group ones
|
currentSkus = [...new Set([...currentSkus, ...addedSkus])];
|
||||||
currentProducts = [...new Set([...currentProducts, ...addedProducts])];
|
currentSkus = currentSkus.filter(id => !removedSkus.includes(id));
|
||||||
currentProducts = currentProducts.filter(id => !removedProducts.includes(id));
|
|
||||||
|
|
||||||
onGroupChange(newGroupIds);
|
onGroupChange(newGroupIds);
|
||||||
if (multiple) {
|
if (multiple) {
|
||||||
onChange(currentProducts.length > 0 ? currentProducts : []);
|
onChange(currentSkus.length > 0 ? currentSkus : []);
|
||||||
} else {
|
} else {
|
||||||
onChange(currentProducts.length > 0 ? currentProducts[0] : 0);
|
onChange(currentSkus.length > 0 ? currentSkus[0] : 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter products based on search query
|
// Filter products based on search query
|
||||||
const filteredProducts = useMemo(() => {
|
const filteredSkus = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return products;
|
if (!searchQuery.trim()) return allSkus;
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return products.filter(product =>
|
return allSkus.filter(sku =>
|
||||||
product.name.toLowerCase().includes(query) ||
|
sku.label.toLowerCase().includes(query) ||
|
||||||
(product.shortDescription && product.shortDescription.toLowerCase().includes(query)) ||
|
sku.productName.toLowerCase().includes(query)
|
||||||
(product.unit && product.unit.toLowerCase().includes(query))
|
|
||||||
);
|
);
|
||||||
}, [products, searchQuery]);
|
}, [allSkus, searchQuery]);
|
||||||
|
|
||||||
// Build dropdown options
|
// Build dropdown options
|
||||||
const productOptions: DropdownOption[] = useMemo(() => {
|
const productOptions: DropdownOption[] = useMemo(() => {
|
||||||
return filteredProducts.map((product) => {
|
return filteredSkus.map((sku) => {
|
||||||
const isFromGroup = selectedGroupIds.length > 0 && groups.some(group =>
|
const isFromGroup = selectedGroupIds.length > 0 && groups.some(group => {
|
||||||
selectedGroupIds.includes(group.id) && group.products.some(p => p.id === product.id)
|
if (!selectedGroupIds.includes(group.id)) return false;
|
||||||
);
|
return group.products.some(p => p.id === sku.productId);
|
||||||
|
});
|
||||||
|
|
||||||
const isProductDisabled = isDisabled ? isDisabled(product as Product) : false;
|
const isProductDisabled = isDisabled ? isDisabled(sku) : false;
|
||||||
|
|
||||||
|
const displayLabel = labelFormat
|
||||||
|
? labelFormat(sku)
|
||||||
|
: sku.label;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: `${formatProductLabel(product as Product)}${isFromGroup ? ' (from group)' : ''}`,
|
label: `${displayLabel}${isFromGroup ? ' (from group)' : ''}`,
|
||||||
value: product.id.toString(),
|
value: sku.id.toString(),
|
||||||
disabled: isProductDisabled,
|
disabled: isProductDisabled,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [filteredProducts, selectedGroupIds, groups, isDisabled, labelFormat]);
|
}, [filteredSkus, selectedGroupIds, groups, isDisabled, labelFormat]);
|
||||||
|
|
||||||
// Build group options if groups are provided
|
// Build group options if groups are provided
|
||||||
const groupOptions: DropdownOption[] = useMemo(() => {
|
const groupOptions: DropdownOption[] = useMemo(() => {
|
||||||
|
|
@ -143,7 +131,6 @@ export default function ProductsSelector({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={tw`w-full`}>
|
<View style={tw`w-full`}>
|
||||||
{/* Groups selector (if groups are provided and showGroups is true) */}
|
|
||||||
{showGroups && groups.length > 0 && (
|
{showGroups && groups.length > 0 && (
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
<BottomDropdown
|
<BottomDropdown
|
||||||
|
|
@ -151,8 +138,8 @@ export default function ProductsSelector({
|
||||||
label="Select Product Groups"
|
label="Select Product Groups"
|
||||||
options={groupOptions}
|
options={groupOptions}
|
||||||
value={selectedGroupIds.map(id => id.toString())}
|
value={selectedGroupIds.map(id => id.toString())}
|
||||||
onValueChange={(value) => {
|
onValueChange={(selectedValue) => {
|
||||||
const selectedValues = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : [];
|
||||||
const newGroupIds = selectedValues.map(v => parseInt(v as string));
|
const newGroupIds = selectedValues.map(v => parseInt(v as string));
|
||||||
handleGroupChange(newGroupIds);
|
handleGroupChange(newGroupIds);
|
||||||
}}
|
}}
|
||||||
|
|
@ -162,7 +149,6 @@ export default function ProductsSelector({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Products selector */}
|
|
||||||
<BottomDropdown
|
<BottomDropdown
|
||||||
label={label}
|
label={label}
|
||||||
options={productOptions}
|
options={productOptions}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import ProductsSelector from '../components/ProductsSelector';
|
||||||
interface VendorSnippet {
|
interface VendorSnippet {
|
||||||
name: string;
|
name: string;
|
||||||
groupIds: number[];
|
groupIds: number[];
|
||||||
productIds: number[];
|
skuIds: number[];
|
||||||
validTill?: string;
|
validTill?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ export default function SlotForm({
|
||||||
const vendorSnippetsFromSlot = (slotData?.slot?.vendorSnippets || []).map((snippet: any) => ({
|
const vendorSnippetsFromSlot = (slotData?.slot?.vendorSnippets || []).map((snippet: any) => ({
|
||||||
name: snippet.name || '',
|
name: snippet.name || '',
|
||||||
groupIds: snippet.groupIds || [],
|
groupIds: snippet.groupIds || [],
|
||||||
productIds: snippet.productIds || [],
|
skuIds: snippet.skuIds || [],
|
||||||
validTill: snippet.validTill || undefined,
|
validTill: snippet.validTill || undefined,
|
||||||
})) as VendorSnippet[];
|
})) as VendorSnippet[];
|
||||||
|
|
||||||
|
|
@ -48,7 +48,7 @@ export default function SlotForm({
|
||||||
deliveryTime: initialDeliveryTime || (slotData?.slot?.deliveryTime ? new Date(slotData.slot.deliveryTime) : null),
|
deliveryTime: initialDeliveryTime || (slotData?.slot?.deliveryTime ? new Date(slotData.slot.deliveryTime) : null),
|
||||||
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
|
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
|
||||||
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
|
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
|
||||||
selectedProductIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
selectedSkuIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
||||||
vendorSnippetList: vendorSnippetsFromSlot,
|
vendorSnippetList: vendorSnippetsFromSlot,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -58,15 +58,8 @@ export default function SlotForm({
|
||||||
const isEditMode = !!slotId;
|
const isEditMode = !!slotId;
|
||||||
const isPending = isCreating || isUpdating;
|
const isPending = isCreating || isUpdating;
|
||||||
|
|
||||||
// Fetch groups
|
|
||||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleFormSubmit = (values: typeof initialValues) => {
|
const handleFormSubmit = (values: typeof initialValues) => {
|
||||||
if (!values.deliveryTime || !values.freezeTime) {
|
if (!values.deliveryTime || !values.freezeTime) {
|
||||||
Alert.alert('Error', 'Please fill all fields');
|
Alert.alert('Error', 'Please fill all fields');
|
||||||
|
|
@ -78,23 +71,22 @@ export default function SlotForm({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slotData = {
|
const slotInputData = {
|
||||||
deliveryTime: values.deliveryTime.toISOString(),
|
deliveryTime: values.deliveryTime.toISOString(),
|
||||||
freezeTime: values.freezeTime.toISOString(),
|
freezeTime: values.freezeTime.toISOString(),
|
||||||
isActive: initialIsActive,
|
isActive: initialIsActive,
|
||||||
groupIds: values.selectedGroupIds,
|
groupIds: values.selectedGroupIds,
|
||||||
productIds: values.selectedProductIds,
|
skuIds: values.selectedSkuIds,
|
||||||
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
||||||
name: snippet.name,
|
name: snippet.name,
|
||||||
productIds: snippet.productIds,
|
skuIds: snippet.skuIds,
|
||||||
validTill: snippet.validTill,
|
validTill: snippet.validTill,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (isEditMode && slotId) {
|
if (isEditMode && slotId) {
|
||||||
updateSlot(
|
updateSlot(
|
||||||
{ id: slotId, ...slotData },
|
{ id: slotId, ...slotInputData },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
Alert.alert('Success', 'Slot updated successfully!');
|
Alert.alert('Success', 'Slot updated successfully!');
|
||||||
|
|
@ -109,12 +101,10 @@ export default function SlotForm({
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
createSlot(
|
createSlot(
|
||||||
slotData,
|
slotInputData,
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
Alert.alert('Success', 'Slot created successfully!');
|
Alert.alert('Success', 'Slot created successfully!');
|
||||||
// Reset form
|
|
||||||
// Formik will handle reset
|
|
||||||
onSlotAdded?.();
|
onSlotAdded?.();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
|
|
@ -131,12 +121,11 @@ export default function SlotForm({
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
>
|
>
|
||||||
{({ handleSubmit, values, setFieldValue }) => {
|
{({ handleSubmit, values, setFieldValue }) => {
|
||||||
// Map groups data to match ProductsSelector types (convert price from string to number)
|
|
||||||
const mappedGroups = (groupsData?.groups || []).map(group => ({
|
const mappedGroups = (groupsData?.groups || []).map(group => ({
|
||||||
...group,
|
...group,
|
||||||
products: group.products.map(product => ({
|
products: group.products.map(product => ({
|
||||||
...product,
|
...product,
|
||||||
price: parseFloat(product.price as unknown as string) || 0,
|
id: product.id,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -168,8 +157,8 @@ export default function SlotForm({
|
||||||
|
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
<ProductsSelector
|
<ProductsSelector
|
||||||
value={values.selectedProductIds}
|
value={values.selectedSkuIds}
|
||||||
onChange={(newProductIds) => setFieldValue('selectedProductIds', newProductIds)}
|
onChange={(newSkuIds) => setFieldValue('selectedSkuIds', newSkuIds)}
|
||||||
groups={mappedGroups}
|
groups={mappedGroups}
|
||||||
selectedGroupIds={values.selectedGroupIds}
|
selectedGroupIds={values.selectedGroupIds}
|
||||||
onGroupChange={(newGroupIds) => setFieldValue('selectedGroupIds', newGroupIds)}
|
onGroupChange={(newGroupIds) => setFieldValue('selectedGroupIds', newGroupIds)}
|
||||||
|
|
@ -196,19 +185,19 @@ export default function SlotForm({
|
||||||
|
|
||||||
<View style={tw`mb-4`}>
|
<View style={tw`mb-4`}>
|
||||||
<ProductsSelector
|
<ProductsSelector
|
||||||
value={snippet.productIds || []}
|
value={snippet.skuIds || []}
|
||||||
onChange={(newProductIds) => setFieldValue(`vendorSnippetList.${index}.productIds`, newProductIds)}
|
onChange={(newSkuIds) => setFieldValue(`vendorSnippetList.${index}.skuIds`, newSkuIds)}
|
||||||
groups={mappedGroups.filter(group =>
|
groups={mappedGroups.filter(group =>
|
||||||
values.selectedGroupIds.includes(group.id)
|
values.selectedGroupIds.includes(group.id)
|
||||||
).map(group => ({
|
).map(group => ({
|
||||||
...group,
|
...group,
|
||||||
products: group.products.filter(p => values.selectedProductIds.includes(p.id))
|
products: group.products.filter((p: any) => values.selectedSkuIds.includes(p.id))
|
||||||
}))}
|
}))}
|
||||||
selectedGroupIds={snippet.groupIds || []}
|
selectedGroupIds={snippet.groupIds || []}
|
||||||
onGroupChange={(newGroupIds) => setFieldValue(`vendorSnippetList.${index}.groupIds`, newGroupIds)}
|
onGroupChange={(newGroupIds) => setFieldValue(`vendorSnippetList.${index}.groupIds`, newGroupIds)}
|
||||||
label="Select Products"
|
label="Select Products"
|
||||||
placeholder="Select products for snippet"
|
placeholder="Select products for snippet"
|
||||||
isDisabled={(product) => !values.selectedProductIds.includes(product.id)}
|
isDisabled={(sku) => !values.selectedSkuIds.includes(sku.id)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
@ -220,7 +209,7 @@ export default function SlotForm({
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => push({ name: '', groupIds: [], productIds: [], validTill: '' })}
|
onPress={() => push({ name: '', groupIds: [], skuIds: [], validTill: '' })}
|
||||||
style={tw`bg-blue-500 px-4 py-3 rounded-lg items-center`}
|
style={tw`bg-blue-500 px-4 py-3 rounded-lg items-center`}
|
||||||
>
|
>
|
||||||
<Text style={tw`text-white font-medium`}>Add Vendor Snippet</Text>
|
<Text style={tw`text-white font-medium`}>Add Vendor Snippet</Text>
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1,105 @@
|
||||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react';
|
import React, { useState, useImperativeHandle, forwardRef } from 'react'
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
import { View, TouchableOpacity, ScrollView } from 'react-native'
|
||||||
import { Formik, FieldArray } from 'formik';
|
import { Formik, FieldArray } from 'formik'
|
||||||
import * as Yup from 'yup';
|
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox } from 'common-ui'
|
||||||
import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import { trpc } from '../trpc-client'
|
||||||
import { trpc } from '../trpc-client';
|
|
||||||
|
|
||||||
interface ProductFormData {
|
interface Attribute {
|
||||||
name: string;
|
featureName: string
|
||||||
shortDescription: string;
|
featureValue: 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 {
|
interface Variant {
|
||||||
quantity: string;
|
name: string
|
||||||
price: string;
|
price: string
|
||||||
validTill: Date | null;
|
marketPrice: string
|
||||||
|
isFlashAvailable: boolean
|
||||||
|
flashPrice: string
|
||||||
|
attributes: Attribute[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductFormData {
|
||||||
|
name: string
|
||||||
|
shortDescription: string
|
||||||
|
longDescription: string
|
||||||
|
storeId: number
|
||||||
|
variants: Variant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductFormRef {
|
export interface ProductFormRef {
|
||||||
clearImages: () => void;
|
clearImages: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit'
|
||||||
initialValues: ProductFormData;
|
initialValues: ProductFormData
|
||||||
onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void;
|
onSubmit: (values: ProductFormData, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => void
|
||||||
isLoading: boolean;
|
isLoading: boolean
|
||||||
existingImages?: ImageUploaderNeoItem[];
|
existingVariantImages?: ImageUploaderNeoItem[][]
|
||||||
existingImageKeys?: string[];
|
existingVariantImageKeys?: string[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
const unitOptions = [
|
const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' })
|
||||||
{ label: 'Kg', value: 1 },
|
|
||||||
{ label: 'Litre', value: 2 },
|
const defaultVariant = (): Variant => ({
|
||||||
{ label: 'Dozen', value: 3 },
|
name: '',
|
||||||
{ label: 'Unit Piece', value: 4 },
|
price: '',
|
||||||
];
|
marketPrice: '',
|
||||||
|
isFlashAvailable: false,
|
||||||
|
flashPrice: '',
|
||||||
|
attributes: [defaultAttribute()],
|
||||||
|
})
|
||||||
|
|
||||||
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
mode,
|
mode,
|
||||||
initialValues,
|
initialValues,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading,
|
isLoading,
|
||||||
existingImages:existingImagesRaw,
|
existingVariantImages = [],
|
||||||
existingImageKeys = [],
|
existingVariantImageKeys = [],
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { theme } = useTheme();
|
const [variantImages, setVariantImages] = useState<ImageUploaderNeoItem[][]>(() =>
|
||||||
const [images, setImages] = useState<ImageUploaderNeoItem[]>([]);
|
initialValues.variants.length > 0
|
||||||
|
? initialValues.variants.map((_, i) => existingVariantImages[i] || [])
|
||||||
|
: [[]]
|
||||||
|
)
|
||||||
|
|
||||||
const existingImages = existingImagesRaw || []
|
useImperativeHandle(ref, () => ({
|
||||||
// Sync images state when existingImages prop changes (e.g., when async query data arrives)
|
clearImages: () => setVariantImages(initialValues.variants.map(() => [])),
|
||||||
useEffect(() => {
|
}), [initialValues.variants])
|
||||||
setImages(existingImages);
|
|
||||||
}, [existingImagesRaw]);
|
|
||||||
|
|
||||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
const { data: storesData } = trpc.common.getStoresSummary.useQuery()
|
||||||
const storeOptions = storesData?.stores.map(store => ({
|
const storeOptions = storesData?.stores.map((store) => ({
|
||||||
label: store.name,
|
label: store.name,
|
||||||
value: store.id,
|
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 (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
// New images have mimeType set, existing images have mimeType === null
|
const images = variantImages.map((imgs) =>
|
||||||
const newImages = images.filter(img => img.mimeType !== null);
|
imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType }))
|
||||||
const deletedImageKeys = existingImages
|
)
|
||||||
.filter(existing => !images.some(current => current.imgUrl === existing.imgUrl))
|
const deletedKeys: string[] = []
|
||||||
.map(deleted => signedUrlToKey[deleted.imgUrl])
|
if (mode === 'edit') {
|
||||||
.filter(Boolean);
|
variantImages.forEach((currentImgs, vIndex) => {
|
||||||
|
const existing = existingVariantImages[vIndex] || []
|
||||||
onSubmit(
|
existing.forEach((existingImg) => {
|
||||||
values,
|
if (!currentImgs.some((cur) => cur.imgUrl === existingImg.imgUrl)) {
|
||||||
newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })),
|
const key = existingVariantImageKeys[vIndex]?.[existingVariantImages[vIndex]?.indexOf(existingImg)]
|
||||||
deletedImageKeys,
|
if (key) deletedKeys.push(key)
|
||||||
);
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSubmit(values, images, deletedKeys)
|
||||||
}}
|
}}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue }) => (
|
||||||
const clearForm = useCallback(() => {
|
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}>
|
||||||
setImages([]);
|
|
||||||
resetForm();
|
|
||||||
}, [resetForm]);
|
|
||||||
|
|
||||||
useFocusCallback(clearForm);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
clearImages: clearForm,
|
|
||||||
}), [clearForm]);
|
|
||||||
|
|
||||||
const submit = () => handleSubmit();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
topLabel="Product Name"
|
topLabel="Product Name"
|
||||||
placeholder="Enter product name"
|
placeholder="Enter product name"
|
||||||
|
|
@ -148,31 +125,6 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
onChangeText={handleChange('longDescription')}
|
onChangeText={handleChange('longDescription')}
|
||||||
style={{ marginBottom: 16 }}
|
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
|
<BottomDropdown
|
||||||
topLabel="Store"
|
topLabel="Store"
|
||||||
label="Store"
|
label="Store"
|
||||||
|
|
@ -182,81 +134,163 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
placeholder="Select store"
|
placeholder="Select store"
|
||||||
style={{ marginBottom: 16 }}
|
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`}>
|
<FieldArray name="variants">
|
||||||
<Checkbox
|
{({ push, remove }) => (
|
||||||
checked={values.isSuspended}
|
<View>
|
||||||
onPress={() => setFieldValue('isSuspended', !values.isSuspended)}
|
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||||
style={tw`mr-3`}
|
<MyText style={tw`text-lg font-bold text-gray-800`}>Variants</MyText>
|
||||||
/>
|
<TouchableOpacity
|
||||||
<MyText style={tw`text-gray-700 font-medium`}>Suspend Product</MyText>
|
onPress={() => {
|
||||||
|
push(defaultVariant())
|
||||||
|
setVariantImages((prev) => [...prev, []])
|
||||||
|
}}
|
||||||
|
style={tw`bg-blue-500 px-3 py-1 rounded-lg flex-row items-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={18} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>Add Variant</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={tw`flex-row items-center mb-4`}>
|
{values.variants.map((variant, vIndex) => (
|
||||||
<Checkbox
|
<View key={vIndex} style={tw`border border-gray-300 rounded-xl p-4 mb-4`}>
|
||||||
checked={values.isFlashAvailable}
|
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||||
|
<MyText style={tw`font-bold text-gray-700`}>Variant {vIndex + 1}</MyText>
|
||||||
|
{values.variants.length > 1 && (
|
||||||
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setFieldValue('isFlashAvailable', !values.isFlashAvailable);
|
remove(vIndex)
|
||||||
if (values.isFlashAvailable) setFieldValue('flashPrice', '');
|
setVariantImages((prev) => prev.filter((_, i) => i !== vIndex))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="delete" size={20} color="#EF4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FieldArray name={`variants.${vIndex}.attributes`}>
|
||||||
|
{({ push: pushAttr, remove: removeAttr }) => (
|
||||||
|
<View style={tw`mb-3`}>
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-2`}>
|
||||||
|
<MyText style={tw`font-medium text-gray-600`}>Attributes</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => pushAttr(defaultAttribute())}
|
||||||
|
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={14} color="#4B5563" />
|
||||||
|
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{variant.attributes.map((attr, aIndex) => (
|
||||||
|
<View key={aIndex} style={tw`flex-row items-center mb-2 gap-2`}>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyTextInput
|
||||||
|
placeholder="Name"
|
||||||
|
value={attr.featureName}
|
||||||
|
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureName`)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyTextInput
|
||||||
|
placeholder="Value"
|
||||||
|
value={attr.featureValue}
|
||||||
|
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureValue`)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{variant.attributes.length > 1 && (
|
||||||
|
<TouchableOpacity onPress={() => removeAttr(aIndex)}>
|
||||||
|
<MaterialIcons name="close" size={18} color="#EF4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
|
||||||
|
<View style={tw`flex-row gap-2 mb-3`}>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyTextInput
|
||||||
|
topLabel="Market Price"
|
||||||
|
placeholder="MRP"
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={variant.marketPrice}
|
||||||
|
onChangeText={handleChange(`variants.${vIndex}.marketPrice`)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyTextInput
|
||||||
|
topLabel="Our Price"
|
||||||
|
placeholder="Selling price"
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={variant.price}
|
||||||
|
onChangeText={handleChange(`variants.${vIndex}.price`)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-center mb-3`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={variant.isFlashAvailable}
|
||||||
|
onPress={() => {
|
||||||
|
setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable)
|
||||||
|
if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, '')
|
||||||
}}
|
}}
|
||||||
style={tw`mr-3`}
|
style={tw`mr-3`}
|
||||||
/>
|
/>
|
||||||
<MyText style={tw`text-gray-700 font-medium`}>Flash Available</MyText>
|
<MyText style={tw`text-gray-700 font-medium`}>Flash Available</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{values.isFlashAvailable && (
|
{variant.isFlashAvailable && (
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
topLabel="Flash Price"
|
topLabel="Flash Price"
|
||||||
placeholder="Enter flash price"
|
placeholder="Enter flash price"
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={values.flashPrice}
|
value={variant.flashPrice}
|
||||||
onChangeText={handleChange('flashPrice')}
|
onChangeText={handleChange(`variants.${vIndex}.flashPrice`)}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ImageUploaderNeo
|
||||||
|
images={variantImages[vIndex] || []}
|
||||||
|
onImageAdd={(payloads) =>
|
||||||
|
setVariantImages((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[vIndex] = [...(next[vIndex] || []), ...payloads.map((p) => ({ imgUrl: p.url, mimeType: p.mimeType }))]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onImageRemove={(payload) =>
|
||||||
|
setVariantImages((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[vIndex] = (next[vIndex] || []).filter((img) => img.imgUrl !== payload.url)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
allowMultiple={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={submit}
|
onPress={() => handleSubmit()}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
style={tw`px-4 py-3 rounded-lg shadow-lg items-center mt-4 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
<MyText style={tw`text-white text-lg font-bold`}>
|
||||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
{isLoading ? 'Creating...' : 'Create Product'}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</ScrollView>
|
||||||
);
|
)}
|
||||||
}}
|
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
ProductForm.displayName = 'ProductForm';
|
ProductForm.displayName = 'ProductForm'
|
||||||
|
|
||||||
export default ProductForm;
|
export default ProductForm
|
||||||
|
|
|
||||||
262
apps/admin-ui/src/components/ProductForm_old.tsx
Normal file
262
apps/admin-ui/src/components/ProductForm_old.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
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;
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
} from '@/src/dbService'
|
} from '@/src/dbService'
|
||||||
import type {
|
import type {
|
||||||
AdminProduct,
|
AdminProduct,
|
||||||
|
AdminProductWithRelations,
|
||||||
AdminSpecialDeal,
|
AdminSpecialDeal,
|
||||||
AdminProductGroupsResult,
|
AdminProductGroupsResult,
|
||||||
AdminProductGroupResponse,
|
AdminProductGroupResponse,
|
||||||
|
|
@ -70,7 +71,12 @@ export const productRouter = router({
|
||||||
const productsWithSignedUrls = await Promise.all(
|
const productsWithSignedUrls = await Promise.all(
|
||||||
products.map(async (product) => ({
|
products.map(async (product) => ({
|
||||||
...product,
|
...product,
|
||||||
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
|
skus: await Promise.all(
|
||||||
|
product.skus.map(async (sku) => ({
|
||||||
|
...sku,
|
||||||
|
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
|
||||||
|
}))
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -135,7 +141,12 @@ export const productRouter = router({
|
||||||
|
|
||||||
const productWithSignedUrls = {
|
const productWithSignedUrls = {
|
||||||
...product,
|
...product,
|
||||||
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
|
skus: await Promise.all(
|
||||||
|
product.skus.map(async (sku) => ({
|
||||||
|
...sku,
|
||||||
|
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
|
||||||
|
}))
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -221,72 +232,71 @@ export const productRouter = router({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
shortDescription: z.string().optional(),
|
shortDescription: z.string().optional(),
|
||||||
longDescription: z.string().optional(),
|
longDescription: z.string().optional(),
|
||||||
unitId: z.number().min(1, 'Unit is required'),
|
|
||||||
storeId: z.number().min(1, 'Store is required'),
|
storeId: z.number().min(1, 'Store is required'),
|
||||||
price: z.number().positive('Price must be positive'),
|
|
||||||
marketPrice: z.number().optional(),
|
|
||||||
incrementStep: z.number().optional().default(1),
|
incrementStep: z.number().optional().default(1),
|
||||||
productQuantity: z.union([z.number(), z.string()]).optional().default(1),
|
skus: z.array(z.object({
|
||||||
isSuspended: z.boolean().optional().default(false),
|
name: z.string().optional().nullable(),
|
||||||
|
price: z.number().positive('Price must be positive'),
|
||||||
|
marketPrice: z.number().optional().nullable(),
|
||||||
|
images: z.array(z.string()).optional().default([]),
|
||||||
isFlashAvailable: z.boolean().optional().default(false),
|
isFlashAvailable: z.boolean().optional().default(false),
|
||||||
flashPrice: z.number().optional(),
|
flashPrice: z.number().optional().nullable(),
|
||||||
uploadUrls: z.array(z.string()).optional().default([]),
|
features: z.array(z.object({
|
||||||
deals: z.array(z.object({
|
featureName: z.string().min(1, 'Attribute name is required'),
|
||||||
quantity: z.number(),
|
featureValue: z.string().min(1, 'Value is required'),
|
||||||
price: z.number(),
|
})).min(1, 'At least one feature is required'),
|
||||||
validTill: z.string(),
|
})).min(1, 'At least one SKU is required'),
|
||||||
})).optional(),
|
|
||||||
tagIds: z.array(z.number()).optional().default([]),
|
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }): Promise<{ product: AdminProduct; deals: AdminSpecialDeal[]; message: string }> => {
|
.mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => {
|
||||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, uploadUrls, deals, tagIds } = input
|
const { name, shortDescription, longDescription, storeId, incrementStep, skus } = input
|
||||||
|
|
||||||
const existingProduct = await checkProductExistsByName(name.trim())
|
const existingProduct = await checkProductExistsByName(name.trim())
|
||||||
if (existingProduct) {
|
if (existingProduct) {
|
||||||
throw new ApiError('A product with this name already exists', 400)
|
throw new ApiError('A product with this name already exists', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unitExists = await checkUnitExists(unitId)
|
const allUploadUrls: string[] = skus.flatMap((sku) => sku.images)
|
||||||
if (!unitExists) {
|
|
||||||
throw new ApiError('Invalid unit ID', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageKeys = uploadUrls.map(url => extractKeyFromPresignedUrl(url))
|
const skuInputs = skus.map((sku) => ({
|
||||||
|
name: sku.name ?? null,
|
||||||
|
price: sku.price,
|
||||||
|
marketPrice: sku.marketPrice ?? null,
|
||||||
|
images: sku.images.map((url) => extractKeyFromPresignedUrl(url)),
|
||||||
|
isFlashAvailable: sku.isFlashAvailable,
|
||||||
|
flashPrice: sku.flashPrice ?? null,
|
||||||
|
features: sku.features.map((f) => ({
|
||||||
|
featureName: f.featureName,
|
||||||
|
featureValue: f.featureValue,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
const newProduct = await createProductInDb({
|
const newProduct = await createProductInDb({
|
||||||
name,
|
name,
|
||||||
shortDescription,
|
shortDescription,
|
||||||
longDescription,
|
longDescription,
|
||||||
unitId,
|
|
||||||
storeId,
|
storeId,
|
||||||
price: price.toString(),
|
|
||||||
marketPrice: marketPrice?.toString(),
|
|
||||||
incrementStep,
|
incrementStep,
|
||||||
productQuantity: productQuantity as any,
|
skus: skuInputs,
|
||||||
isSuspended,
|
} as any)
|
||||||
isFlashAvailable,
|
|
||||||
flashPrice: flashPrice?.toString(),
|
|
||||||
images: imageKeys,
|
|
||||||
})
|
|
||||||
|
|
||||||
let createdDeals: AdminSpecialDeal[] = []
|
if (allUploadUrls.length > 0) {
|
||||||
if (deals && deals.length > 0) {
|
await Promise.all(allUploadUrls.map((url) => claimUploadUrl(url)))
|
||||||
createdDeals = await createSpecialDealsForProduct(newProduct.id, deals)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagIds.length > 0) {
|
|
||||||
await replaceProductTags(newProduct.id, tagIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadUrls.length > 0) {
|
|
||||||
await Promise.all(uploadUrls.map(url => claimUploadUrl(url)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await scheduleStoreInitialization()
|
await scheduleStoreInitialization()
|
||||||
|
|
||||||
|
const productWithSignedUrls = {
|
||||||
|
...newProduct,
|
||||||
|
skus: await Promise.all(
|
||||||
|
newProduct.skus.map(async (sku) => ({
|
||||||
|
...sku,
|
||||||
|
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
product: newProduct,
|
product: productWithSignedUrls,
|
||||||
deals: createdDeals,
|
|
||||||
message: 'Product created successfully',
|
message: 'Product created successfully',
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
@ -297,83 +307,76 @@ export const productRouter = router({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
shortDescription: z.string().optional(),
|
shortDescription: z.string().optional(),
|
||||||
longDescription: z.string().optional(),
|
longDescription: z.string().optional(),
|
||||||
unitId: z.number().min(1, 'Unit is required'),
|
|
||||||
storeId: z.number().min(1, 'Store is required'),
|
storeId: z.number().min(1, 'Store is required'),
|
||||||
price: z.number().positive('Price must be positive'),
|
|
||||||
marketPrice: z.number().optional(),
|
|
||||||
incrementStep: z.number().optional().default(1),
|
incrementStep: z.number().optional().default(1),
|
||||||
productQuantity: z.union([z.number(), z.string()]).optional().default(1),
|
skus: z.array(z.object({
|
||||||
isSuspended: z.boolean().optional().default(false),
|
name: z.string().optional().nullable(),
|
||||||
|
price: z.number().positive('Price must be positive'),
|
||||||
|
marketPrice: z.number().optional().nullable(),
|
||||||
|
images: z.array(z.string()).optional().default([]),
|
||||||
isFlashAvailable: z.boolean().optional().default(false),
|
isFlashAvailable: z.boolean().optional().default(false),
|
||||||
flashPrice: z.number().nullable().optional(),
|
flashPrice: z.number().optional().nullable(),
|
||||||
uploadUrls: z.array(z.string()).optional().default([]),
|
features: z.array(z.object({
|
||||||
imagesToDelete: z.array(z.string()).optional().default([]),
|
featureName: z.string().min(1, 'Attribute name is required'),
|
||||||
deals: z.array(z.object({
|
featureValue: z.string().min(1, 'Value is required'),
|
||||||
quantity: z.number(),
|
})).min(1, 'At least one feature is required'),
|
||||||
price: z.number(),
|
})).min(1, 'At least one SKU is required'),
|
||||||
validTill: z.string(),
|
deletedImageKeys: z.array(z.string()).optional().default([]),
|
||||||
})).optional(),
|
newImageUrls: z.array(z.string()).optional().default([]),
|
||||||
tagIds: z.array(z.number()).optional().default([]),
|
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }): Promise<{ product: AdminProduct; message: string }> => {
|
.mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => {
|
||||||
const { id, name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, uploadUrls, imagesToDelete, deals, tagIds } = input
|
const { id, name, shortDescription, longDescription, storeId, incrementStep, skus, deletedImageKeys, newImageUrls } = input
|
||||||
|
|
||||||
const unitExists = await checkUnitExists(unitId)
|
if (deletedImageKeys.length > 0) {
|
||||||
if (!unitExists) {
|
await deleteImageUtil({ keys: deletedImageKeys })
|
||||||
throw new ApiError('Invalid unit ID', 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentImages = await getProductImagesById(id)
|
const allUploadUrls: string[] = skus.flatMap((sku) => sku.images)
|
||||||
if (!currentImages) {
|
|
||||||
throw new ApiError('Product not found', 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedImages = currentImages || []
|
const skuInputs = skus.map((sku) => ({
|
||||||
if (imagesToDelete.length > 0) {
|
name: sku.name ?? null,
|
||||||
const imagesToRemove = updatedImages.filter(img => imagesToDelete.includes(img))
|
price: sku.price,
|
||||||
await deleteImageUtil({ keys: imagesToRemove })
|
marketPrice: sku.marketPrice ?? null,
|
||||||
updatedImages = updatedImages.filter(img => !imagesToRemove.includes(img))
|
images: sku.images.map((url) => extractKeyFromPresignedUrl(url)),
|
||||||
}
|
isFlashAvailable: sku.isFlashAvailable,
|
||||||
|
flashPrice: sku.flashPrice ?? null,
|
||||||
const newImageKeys = uploadUrls.map(url => extractKeyFromPresignedUrl(url))
|
features: sku.features.map((f) => ({
|
||||||
const finalImages = [...updatedImages, ...newImageKeys]
|
featureName: f.featureName,
|
||||||
|
featureValue: f.featureValue,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
const updatedProduct = await updateProductInDb(id, {
|
const updatedProduct = await updateProductInDb(id, {
|
||||||
name,
|
name,
|
||||||
shortDescription,
|
shortDescription,
|
||||||
longDescription,
|
longDescription,
|
||||||
unitId,
|
|
||||||
storeId,
|
storeId,
|
||||||
price: price.toString(),
|
|
||||||
marketPrice: marketPrice?.toString(),
|
|
||||||
incrementStep,
|
incrementStep,
|
||||||
productQuantity: productQuantity as any,
|
skus: skuInputs,
|
||||||
isSuspended,
|
} as any)
|
||||||
isFlashAvailable,
|
|
||||||
flashPrice: flashPrice?.toString() ?? null,
|
|
||||||
images: finalImages,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!updatedProduct) {
|
if (!updatedProduct) {
|
||||||
throw new ApiError('Product not found', 404)
|
throw new ApiError('Product not found', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deals && deals.length > 0) {
|
if (newImageUrls.length > 0) {
|
||||||
await updateProductDeals(id, deals)
|
await Promise.all(newImageUrls.map((url) => claimUploadUrl(url)))
|
||||||
}
|
|
||||||
|
|
||||||
if (tagIds.length > 0) {
|
|
||||||
await replaceProductTags(id, tagIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadUrls.length > 0) {
|
|
||||||
await Promise.all(uploadUrls.map(url => claimUploadUrl(url)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await scheduleStoreInitialization()
|
await scheduleStoreInitialization()
|
||||||
|
|
||||||
|
const productWithSignedUrls = {
|
||||||
|
...updatedProduct,
|
||||||
|
skus: await Promise.all(
|
||||||
|
updatedProduct.skus.map(async (sku) => ({
|
||||||
|
...sku,
|
||||||
|
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
product: updatedProduct,
|
product: productWithSignedUrls,
|
||||||
message: 'Product updated successfully',
|
message: 'Product updated successfully',
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,10 @@ const createSlotSchema = z.object({
|
||||||
deliveryTime: z.string(),
|
deliveryTime: z.string(),
|
||||||
freezeTime: z.string(),
|
freezeTime: z.string(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
productIds: z.array(z.number()).optional(),
|
skuIds: z.array(z.number()).optional(),
|
||||||
vendorSnippets: z.array(z.object({
|
vendorSnippets: z.array(z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
productIds: z.array(z.number().int().positive()).min(1),
|
skuIds: z.array(z.number().int().positive()).min(1),
|
||||||
validTill: z.string().optional(),
|
validTill: z.string().optional(),
|
||||||
})).optional(),
|
})).optional(),
|
||||||
groupIds: z.array(z.number()).optional(),
|
groupIds: z.array(z.number()).optional(),
|
||||||
|
|
@ -64,10 +64,10 @@ const updateSlotSchema = z.object({
|
||||||
deliveryTime: z.string(),
|
deliveryTime: z.string(),
|
||||||
freezeTime: z.string(),
|
freezeTime: z.string(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
productIds: z.array(z.number()).optional(),
|
skuIds: z.array(z.number()).optional(),
|
||||||
vendorSnippets: z.array(z.object({
|
vendorSnippets: z.array(z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
productIds: z.array(z.number().int().positive()).min(1),
|
skuIds: z.array(z.number().int().positive()).min(1),
|
||||||
validTill: z.string().optional(),
|
validTill: z.string().optional(),
|
||||||
})).optional(),
|
})).optional(),
|
||||||
groupIds: z.array(z.number()).optional(),
|
groupIds: z.array(z.number()).optional(),
|
||||||
|
|
@ -192,7 +192,7 @@ export const slotsRouter = router({
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
slotId: z.number(),
|
slotId: z.number(),
|
||||||
productIds: z.array(z.number()),
|
skuIds: z.array(z.number()),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }): Promise<AdminUpdateSlotProductsResult> => {
|
.mutation(async ({ input, ctx }): Promise<AdminUpdateSlotProductsResult> => {
|
||||||
|
|
@ -200,12 +200,12 @@ export const slotsRouter = router({
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { slotId, productIds } = input;
|
const { slotId, skuIds } = input;
|
||||||
|
|
||||||
if (!Array.isArray(productIds)) {
|
if (!Array.isArray(skuIds)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "productIds must be an array",
|
message: "skuIds must be an array",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,7 +282,7 @@ export const slotsRouter = router({
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
|
const { deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!deliveryTime || !freezeTime) {
|
if (!deliveryTime || !freezeTime) {
|
||||||
|
|
@ -293,7 +293,7 @@ export const slotsRouter = router({
|
||||||
deliveryTime,
|
deliveryTime,
|
||||||
freezeTime,
|
freezeTime,
|
||||||
isActive,
|
isActive,
|
||||||
productIds,
|
skuIds,
|
||||||
vendorSnippets: snippets,
|
vendorSnippets: snippets,
|
||||||
groupIds,
|
groupIds,
|
||||||
})
|
})
|
||||||
|
|
@ -445,7 +445,7 @@ export const slotsRouter = router({
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||||
}
|
}
|
||||||
try{
|
try{
|
||||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
|
const { id, deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input;
|
||||||
|
|
||||||
if (!deliveryTime || !freezeTime) {
|
if (!deliveryTime || !freezeTime) {
|
||||||
throw new ApiError("Delivery time and orders close time are required", 400);
|
throw new ApiError("Delivery time and orders close time are required", 400);
|
||||||
|
|
@ -456,7 +456,7 @@ export const slotsRouter = router({
|
||||||
deliveryTime,
|
deliveryTime,
|
||||||
freezeTime,
|
freezeTime,
|
||||||
isActive,
|
isActive,
|
||||||
productIds,
|
skuIds,
|
||||||
vendorSnippets: snippets,
|
vendorSnippets: snippets,
|
||||||
groupIds,
|
groupIds,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
getSuspendedProductIds,
|
getSuspendedProductIds,
|
||||||
getNextDeliveryDateWithCapacity,
|
getNextDeliveryDateWithCapacity,
|
||||||
getStoresSummary,
|
getStoresSummary,
|
||||||
|
getAllSkusSummary as getAllSkusSummaryInDb,
|
||||||
} from '@/src/dbService'
|
} from '@/src/dbService'
|
||||||
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||||
import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
|
import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
|
||||||
|
|
@ -81,6 +82,12 @@ export const commonRouter = router({
|
||||||
return response;
|
return response;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getAllSkusSummary: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
const skus = await getAllSkusSummaryInDb()
|
||||||
|
return { skus }
|
||||||
|
}),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
// Old implementation - moved to common-trpc-index.ts:
|
// Old implementation - moved to common-trpc-index.ts:
|
||||||
getStoresSummary: publicProcedure
|
getStoresSummary: publicProcedure
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,9 @@ export {
|
||||||
getProductById as getUserProductByIdBasic,
|
getProductById as getUserProductByIdBasic,
|
||||||
createProductReview as createUserProductReview,
|
createProductReview as createUserProductReview,
|
||||||
getAllProductsWithUnits,
|
getAllProductsWithUnits,
|
||||||
|
getAllSkusSummary,
|
||||||
type ProductSummaryData,
|
type ProductSummaryData,
|
||||||
|
type SkuSummary,
|
||||||
} from './src/user-apis/product'
|
} from './src/user-apis/product'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -116,9 +116,10 @@ export async function getOrderDetails(orderId: number): Promise<AdminOrderDetail
|
||||||
slot: true,
|
slot: true,
|
||||||
orderItems: {
|
orderItems: {
|
||||||
with: {
|
with: {
|
||||||
product: {
|
sku: {
|
||||||
with: {
|
with: {
|
||||||
unit: true,
|
product: true,
|
||||||
|
features: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -235,14 +236,19 @@ export async function getOrderDetails(orderId: number): Promise<AdminOrderDetail
|
||||||
isDelivered: orderStatusRecord?.isDelivered || false,
|
isDelivered: orderStatusRecord?.isDelivered || false,
|
||||||
items: orderData.orderItems.map((item: any) => ({
|
items: orderData.orderItems.map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.product.name,
|
name: item.sku.product?.name ?? 'Unknown',
|
||||||
|
skuName: item.sku.name ?? null,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
productSize: item.product.productQuantity,
|
productSize: 1,
|
||||||
price: item.price,
|
price: item.price,
|
||||||
unit: item.product.unit?.shortNotation,
|
unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '),
|
||||||
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
|
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
|
||||||
isPackaged: item.is_packaged,
|
isPackaged: item.is_packaged,
|
||||||
isPackageVerified: item.is_package_verified,
|
isPackageVerified: item.is_package_verified,
|
||||||
|
features: (item.sku.features || []).map((f: any) => ({
|
||||||
|
featureName: f.featureName,
|
||||||
|
featureValue: f.featureValue,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
payment: orderData.payment
|
payment: orderData.payment
|
||||||
? {
|
? {
|
||||||
|
|
@ -341,9 +347,10 @@ export async function getSlotOrders(slotId: string): Promise<AdminGetSlotOrdersR
|
||||||
slot: true,
|
slot: true,
|
||||||
orderItems: {
|
orderItems: {
|
||||||
with: {
|
with: {
|
||||||
product: {
|
sku: {
|
||||||
with: {
|
with: {
|
||||||
unit: true,
|
product: true,
|
||||||
|
features: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -368,13 +375,18 @@ export async function getSlotOrders(slotId: string): Promise<AdminGetSlotOrdersR
|
||||||
|
|
||||||
const items = order.orderItems.map((item: any) => ({
|
const items = order.orderItems.map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.product.name,
|
name: item.sku.product?.name ?? 'Unknown',
|
||||||
|
skuName: item.sku.name ?? null,
|
||||||
quantity: parseFloat(item.quantity),
|
quantity: parseFloat(item.quantity),
|
||||||
price: parseFloat(item.price.toString()),
|
price: parseFloat(item.price.toString()),
|
||||||
amount: parseFloat(item.quantity) * parseFloat(item.price.toString()),
|
amount: parseFloat(item.quantity) * parseFloat(item.price.toString()),
|
||||||
unit: item.product.unit?.shortNotation || '',
|
unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '),
|
||||||
isPackaged: item.is_packaged,
|
isPackaged: item.is_packaged,
|
||||||
isPackageVerified: item.is_package_verified,
|
isPackageVerified: item.is_package_verified,
|
||||||
|
features: (item.sku.features || []).map((f: any) => ({
|
||||||
|
featureName: f.featureName,
|
||||||
|
featureValue: f.featureValue,
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const paymentMode: 'COD' | 'Online' = order.isCod ? 'COD' : 'Online'
|
const paymentMode: 'COD' | 'Online' = order.isCod ? 'COD' : 'Online'
|
||||||
|
|
@ -501,9 +513,10 @@ export async function getAllOrders(input: GetAllOrdersInput): Promise<AdminGetAl
|
||||||
slot: true,
|
slot: true,
|
||||||
orderItems: {
|
orderItems: {
|
||||||
with: {
|
with: {
|
||||||
product: {
|
sku: {
|
||||||
with: {
|
with: {
|
||||||
unit: true,
|
product: true,
|
||||||
|
features: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -532,14 +545,19 @@ export async function getAllOrders(input: GetAllOrdersInput): Promise<AdminGetAl
|
||||||
const items = order.orderItems
|
const items = order.orderItems
|
||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.product.name,
|
name: item.sku.product?.name ?? 'Unknown',
|
||||||
|
skuName: item.sku.name ?? null,
|
||||||
quantity: parseFloat(item.quantity),
|
quantity: parseFloat(item.quantity),
|
||||||
price: parseFloat(item.price.toString()),
|
price: parseFloat(item.price.toString()),
|
||||||
amount: parseFloat(item.quantity) * parseFloat(item.price.toString()),
|
amount: parseFloat(item.quantity) * parseFloat(item.price.toString()),
|
||||||
unit: item.product.unit?.shortNotation || '',
|
unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '),
|
||||||
productSize: item.product.productQuantity,
|
productSize: 1,
|
||||||
isPackaged: item.is_packaged,
|
isPackaged: item.is_packaged,
|
||||||
isPackageVerified: item.is_package_verified,
|
isPackageVerified: item.is_package_verified,
|
||||||
|
features: (item.sku.features || []).map((f: any) => ({
|
||||||
|
featureName: f.featureName,
|
||||||
|
featureValue: f.featureValue,
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
.sort((first: any, second: any) => first.id - second.id)
|
.sort((first: any, second: any) => first.id - second.id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
// @ts-nocheck -- temporary: file partially updated for SKU migration; remaining functions to be fixed later
|
||||||
import { db } from '../db/db_index'
|
import { db } from '../db/db_index'
|
||||||
import {
|
import {
|
||||||
productInfo,
|
productInfo,
|
||||||
|
productSkus,
|
||||||
|
skuFeatures,
|
||||||
units,
|
units,
|
||||||
specialDeals,
|
specialDeals,
|
||||||
deliverySlotInfo,
|
deliverySlotInfo,
|
||||||
|
|
@ -22,6 +25,8 @@ import type {
|
||||||
AdminProductReview,
|
AdminProductReview,
|
||||||
AdminProductWithDetails,
|
AdminProductWithDetails,
|
||||||
AdminProductWithRelations,
|
AdminProductWithRelations,
|
||||||
|
AdminSku,
|
||||||
|
AdminSkuFeature,
|
||||||
AdminSpecialDeal,
|
AdminSpecialDeal,
|
||||||
AdminUnit,
|
AdminUnit,
|
||||||
AdminUpdateSlotProductsResult,
|
AdminUpdateSlotProductsResult,
|
||||||
|
|
@ -29,6 +34,8 @@ import type {
|
||||||
} from '@packages/shared'
|
} from '@packages/shared'
|
||||||
|
|
||||||
type ProductRow = InferSelectModel<typeof productInfo>
|
type ProductRow = InferSelectModel<typeof productInfo>
|
||||||
|
type SkuRow = InferSelectModel<typeof productSkus>
|
||||||
|
type SkuFeatureRow = InferSelectModel<typeof skuFeatures>
|
||||||
type UnitRow = InferSelectModel<typeof units>
|
type UnitRow = InferSelectModel<typeof units>
|
||||||
type StoreRow = InferSelectModel<typeof storeInfo>
|
type StoreRow = InferSelectModel<typeof storeInfo>
|
||||||
type SpecialDealRow = InferSelectModel<typeof specialDeals>
|
type SpecialDealRow = InferSelectModel<typeof specialDeals>
|
||||||
|
|
@ -64,19 +71,32 @@ const mapProduct = (product: ProductRow): AdminProduct => ({
|
||||||
name: product.name,
|
name: product.name,
|
||||||
shortDescription: product.shortDescription ?? null,
|
shortDescription: product.shortDescription ?? null,
|
||||||
longDescription: product.longDescription ?? null,
|
longDescription: product.longDescription ?? null,
|
||||||
unitId: product.unitId,
|
|
||||||
price: String(product.price ?? '0'),
|
|
||||||
marketPrice: product.marketPrice ? String(product.marketPrice) : null,
|
|
||||||
images: getStringArray(product.images),
|
|
||||||
imageKeys: getStringArray(product.images),
|
|
||||||
isOutOfStock: product.isOutOfStock,
|
|
||||||
isSuspended: product.isSuspended,
|
|
||||||
isFlashAvailable: product.isFlashAvailable,
|
|
||||||
flashPrice: product.flashPrice ? String(product.flashPrice) : null,
|
|
||||||
createdAt: product.createdAt,
|
|
||||||
incrementStep: product.incrementStep,
|
|
||||||
productQuantity: product.productQuantity,
|
|
||||||
storeId: product.storeId,
|
storeId: product.storeId,
|
||||||
|
incrementStep: product.incrementStep,
|
||||||
|
createdAt: product.createdAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({
|
||||||
|
id: feature.id,
|
||||||
|
skuId: feature.skuId,
|
||||||
|
featureName: feature.featureName,
|
||||||
|
featureValue: feature.featureValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({
|
||||||
|
id: sku.id,
|
||||||
|
productId: sku.productId,
|
||||||
|
name: sku.name ?? null,
|
||||||
|
price: String(sku.price ?? '0'),
|
||||||
|
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
||||||
|
images: getStringArray(sku.images),
|
||||||
|
imageKeys: getStringArray(sku.images),
|
||||||
|
isOutOfStock: sku.isOutOfStock,
|
||||||
|
isSuspended: sku.isSuspended,
|
||||||
|
isFlashAvailable: sku.isFlashAvailable,
|
||||||
|
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
|
||||||
|
createdAt: sku.createdAt,
|
||||||
|
features: features.map(mapSkuFeature),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({
|
const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({
|
||||||
|
|
@ -98,19 +118,26 @@ const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
|
export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
|
||||||
type ProductWithRelationsRow = ProductRow & { unit: UnitRow; store: StoreRow | null }
|
type ProductWithRelationsRow = ProductRow & {
|
||||||
|
store: StoreRow | null
|
||||||
|
skus: Array<SkuRow & { features: SkuFeatureRow[] }>
|
||||||
|
}
|
||||||
const products = await db.query.productInfo.findMany({
|
const products = await db.query.productInfo.findMany({
|
||||||
orderBy: productInfo.name,
|
orderBy: productInfo.name,
|
||||||
with: {
|
with: {
|
||||||
unit: true,
|
|
||||||
store: true,
|
store: true,
|
||||||
|
skus: {
|
||||||
|
with: {
|
||||||
|
features: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}) as ProductWithRelationsRow[]
|
}) as ProductWithRelationsRow[]
|
||||||
|
|
||||||
return products.map((product) => ({
|
return products.map((product) => ({
|
||||||
...mapProduct(product),
|
...mapProduct(product),
|
||||||
unit: mapUnit(product.unit),
|
|
||||||
store: product.store ? mapStore(product.store) : null,
|
store: product.store ? mapStore(product.store) : null,
|
||||||
|
skus: product.skus.map((sku) => mapSku(sku, sku.features)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +145,12 @@ export async function getProductById(id: number): Promise<AdminProductWithDetail
|
||||||
const product = await db.query.productInfo.findFirst({
|
const product = await db.query.productInfo.findFirst({
|
||||||
where: eq(productInfo.id, id),
|
where: eq(productInfo.id, id),
|
||||||
with: {
|
with: {
|
||||||
unit: true,
|
store: true,
|
||||||
|
skus: {
|
||||||
|
with: {
|
||||||
|
features: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -126,10 +158,14 @@ export async function getProductById(id: number): Promise<AdminProductWithDetail
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const deals = await db.query.specialDeals.findMany({
|
const skuIds = product.skus.map((sku) => sku.id)
|
||||||
where: eq(specialDeals.productId, id),
|
|
||||||
|
const deals = skuIds.length > 0
|
||||||
|
? await db.query.specialDeals.findMany({
|
||||||
|
where: inArray(specialDeals.skuId, skuIds),
|
||||||
orderBy: specialDeals.quantity,
|
orderBy: specialDeals.quantity,
|
||||||
})
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
const productTagsData = await db.query.productTags.findMany({
|
const productTagsData = await db.query.productTags.findMany({
|
||||||
where: eq(productTags.productId, id),
|
where: eq(productTags.productId, id),
|
||||||
|
|
@ -140,7 +176,8 @@ export async function getProductById(id: number): Promise<AdminProductWithDetail
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mapProduct(product),
|
...mapProduct(product),
|
||||||
unit: mapUnit(product.unit),
|
store: product.store ? mapStore(product.store) : null,
|
||||||
|
skus: product.skus.map((sku) => mapSku(sku, sku.features)),
|
||||||
deals: deals.map(mapSpecialDeal),
|
deals: deals.map(mapSpecialDeal),
|
||||||
tags: productTagsData.map((tag) => mapTagInfo(tag.tag)),
|
tags: productTagsData.map((tag) => mapTagInfo(tag.tag)),
|
||||||
}
|
}
|
||||||
|
|
@ -162,46 +199,150 @@ export async function deleteProduct(id: number): Promise<AdminProduct | null> {
|
||||||
type ProductInfoInsert = InferInsertModel<typeof productInfo>
|
type ProductInfoInsert = InferInsertModel<typeof productInfo>
|
||||||
type ProductInfoUpdate = Partial<ProductInfoInsert>
|
type ProductInfoUpdate = Partial<ProductInfoInsert>
|
||||||
|
|
||||||
export async function createProduct(input: ProductInfoInsert): Promise<AdminProduct> {
|
export async function createProduct(input: CreateProductInput): Promise<AdminProductWithRelations> {
|
||||||
const productQuantityRaw = (input as any).productQuantity
|
if (!input.skus || input.skus.length === 0) {
|
||||||
const productQuantity = typeof productQuantityRaw === 'string'
|
throw new Error('At least one SKU is required')
|
||||||
? Number(productQuantityRaw)
|
|
||||||
: productQuantityRaw
|
|
||||||
|
|
||||||
const safeProductQuantity = typeof productQuantity === 'number' && Number.isFinite(productQuantity)
|
|
||||||
? productQuantity
|
|
||||||
: 1
|
|
||||||
|
|
||||||
const [product] = await db.insert(productInfo).values({
|
|
||||||
...input,
|
|
||||||
productQuantity: safeProductQuantity,
|
|
||||||
}).returning()
|
|
||||||
return mapProduct(product)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProduct(id: number, updates: ProductInfoUpdate): Promise<AdminProduct | null> {
|
|
||||||
const productQuantityRaw = (updates as any).productQuantity
|
|
||||||
const productQuantity = typeof productQuantityRaw === 'string'
|
|
||||||
? Number(productQuantityRaw)
|
|
||||||
: productQuantityRaw
|
|
||||||
const safeUpdates = typeof productQuantityRaw === 'undefined'
|
|
||||||
? updates
|
|
||||||
: {
|
|
||||||
...updates,
|
|
||||||
productQuantity: typeof productQuantity === 'number' && Number.isFinite(productQuantity)
|
|
||||||
? productQuantity
|
|
||||||
: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [product] = await db.update(productInfo)
|
const featuresHaveQuantity = input.skus.every((sku) =>
|
||||||
.set(safeUpdates)
|
sku.features.some((f) => f.featureName === 'quantity')
|
||||||
.where(eq(productInfo.id, id))
|
)
|
||||||
.returning()
|
if (!featuresHaveQuantity) {
|
||||||
|
throw new Error('Every SKU must have a quantity feature')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { skus, ...productData } = input
|
||||||
|
|
||||||
|
const [product] = await db.insert(productInfo).values({
|
||||||
|
name: productData.name,
|
||||||
|
shortDescription: productData.shortDescription ?? null,
|
||||||
|
longDescription: productData.longDescription ?? null,
|
||||||
|
storeId: productData.storeId ?? null,
|
||||||
|
incrementStep: productData.incrementStep ?? 1,
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
const skuRows = await db.insert(productSkus).values(
|
||||||
|
skus.map((sku) => ({
|
||||||
|
productId: product.id,
|
||||||
|
name: sku.name ?? null,
|
||||||
|
price: String(sku.price),
|
||||||
|
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
|
||||||
|
images: sku.images ?? null,
|
||||||
|
isFlashAvailable: sku.isFlashAvailable ?? false,
|
||||||
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
||||||
|
}))
|
||||||
|
).returning()
|
||||||
|
|
||||||
|
for (let i = 0; i < skuRows.length; i++) {
|
||||||
|
const skuRow = skuRows[i]
|
||||||
|
const sku = skus[i]
|
||||||
|
await db.insert(skuFeatures).values(
|
||||||
|
sku.features.map((f) => ({
|
||||||
|
skuId: skuRow.id,
|
||||||
|
featureName: f.featureName,
|
||||||
|
featureValue: f.featureValue,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdSkus = await db.query.productSkus.findMany({
|
||||||
|
where: eq(productSkus.productId, product.id),
|
||||||
|
with: { features: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapProduct(product),
|
||||||
|
store: null,
|
||||||
|
skus: createdSkus.map((s) => mapSku(s, s.features)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProduct(id: number, input: any): Promise<AdminProductWithRelations | null> {
|
||||||
|
const product = await db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.id, id),
|
||||||
|
})
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapProduct(product)
|
const { skus, ...productData } = input
|
||||||
|
|
||||||
|
await db.update(productInfo)
|
||||||
|
.set({
|
||||||
|
name: productData.name,
|
||||||
|
shortDescription: productData.shortDescription ?? null,
|
||||||
|
longDescription: productData.longDescription ?? null,
|
||||||
|
storeId: productData.storeId ?? null,
|
||||||
|
incrementStep: productData.incrementStep ?? 1,
|
||||||
|
})
|
||||||
|
.where(eq(productInfo.id, id))
|
||||||
|
|
||||||
|
if (skus !== undefined) {
|
||||||
|
if (skus.length === 0) {
|
||||||
|
throw new Error('At least one SKU is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuresHaveQuantity = skus.every((sku: any) =>
|
||||||
|
sku.features.some((f: any) => f.featureName === 'quantity')
|
||||||
|
)
|
||||||
|
if (!featuresHaveQuantity) {
|
||||||
|
throw new Error('Every SKU must have a quantity feature')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSkuIds = await db.query.productSkus.findMany({
|
||||||
|
where: eq(productSkus.productId, id),
|
||||||
|
columns: { id: true },
|
||||||
|
}).then((skus) => skus.map((sku) => sku.id))
|
||||||
|
|
||||||
|
if (existingSkuIds.length > 0) {
|
||||||
|
await db.delete(skuFeatures).where(inArray(skuFeatures.skuId, existingSkuIds))
|
||||||
|
await db.delete(productSkus).where(inArray(productSkus.id, existingSkuIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
const skuRows = await db.insert(productSkus).values(
|
||||||
|
skus.map((sku: any) => ({
|
||||||
|
productId: id,
|
||||||
|
name: sku.name ?? null,
|
||||||
|
price: String(sku.price),
|
||||||
|
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
|
||||||
|
images: sku.images ?? null,
|
||||||
|
isFlashAvailable: sku.isFlashAvailable ?? false,
|
||||||
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
||||||
|
}))
|
||||||
|
).returning()
|
||||||
|
|
||||||
|
for (let i = 0; i < skuRows.length; i++) {
|
||||||
|
const sku = skus[i]
|
||||||
|
await db.insert(skuFeatures).values(
|
||||||
|
sku.features.map((f: any) => ({
|
||||||
|
skuId: skuRows[i].id,
|
||||||
|
featureName: f.featureName,
|
||||||
|
featureValue: f.featureValue,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProduct = await db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.id, id),
|
||||||
|
with: {
|
||||||
|
store: true,
|
||||||
|
skus: {
|
||||||
|
with: { features: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updatedProduct) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapProduct(updatedProduct),
|
||||||
|
store: updatedProduct.store ? mapStore(updatedProduct.store) : null,
|
||||||
|
skus: updatedProduct.skus.map((s) => mapSku(s, s.features)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleProductOutOfStock(id: number): Promise<AdminProduct | null> {
|
export async function toggleProductOutOfStock(id: number): Promise<AdminProduct | null> {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { db } from '../db/db_index'
|
||||||
import {
|
import {
|
||||||
deliverySlotInfo,
|
deliverySlotInfo,
|
||||||
productInfo,
|
productInfo,
|
||||||
|
productSkus,
|
||||||
|
skuFeatures,
|
||||||
vendorSnippets,
|
vendorSnippets,
|
||||||
productGroupInfo,
|
productGroupInfo,
|
||||||
} from '../db/schema'
|
} from '../db/schema'
|
||||||
|
|
@ -20,7 +22,7 @@ import { coerceDate } from '../lib/date'
|
||||||
|
|
||||||
type SlotSnippetInput = {
|
type SlotSnippetInput = {
|
||||||
name: string
|
name: string
|
||||||
productIds: number[]
|
skuIds: number[]
|
||||||
validTill?: string
|
validTill?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +36,7 @@ const getNumberArray = (value: unknown): number[] => {
|
||||||
return value.map((item) => Number(item))
|
return value.map((item) => Number(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeProductIds = (value: unknown): number[] => {
|
const normalizeSkuIds = (value: unknown): number[] => {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const ids = value
|
const ids = value
|
||||||
.map((item) => Number(item))
|
.map((item) => Number(item))
|
||||||
|
|
@ -52,18 +54,18 @@ const chunkArray = <T>(items: T[], size: number): T[][] => {
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRODUCT_ID_CHUNK_SIZE = 40
|
const SKU_ID_CHUNK_SIZE = 40
|
||||||
|
|
||||||
const fetchExistingProductIds = async (tx: any, productIds: number[]) => {
|
const fetchExistingSkuIds = async (tx: any, skuIds: number[]) => {
|
||||||
const existingIds = new Set<number>()
|
const existingIds = new Set<number>()
|
||||||
const chunks = chunkArray(productIds, PRODUCT_ID_CHUNK_SIZE)
|
const chunks = chunkArray(skuIds, SKU_ID_CHUNK_SIZE)
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.length === 0) continue
|
if (chunk.length === 0) continue
|
||||||
const products = await tx.query.productInfo.findMany({
|
const skus = await tx.query.productSkus.findMany({
|
||||||
where: inArray(productInfo.id, chunk),
|
where: inArray(productSkus.id, chunk),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
})
|
})
|
||||||
products.forEach((product: { id: number }) => existingIds.add(product.id))
|
skus.forEach((sku: { id: number }) => existingIds.add(sku.id))
|
||||||
}
|
}
|
||||||
return existingIds
|
return existingIds
|
||||||
}
|
}
|
||||||
|
|
@ -79,17 +81,19 @@ const mapDeliverySlot = (slot: typeof deliverySlotInfo.$inferSelect): AdminDeliv
|
||||||
groupIds: slot.groupIds,
|
groupIds: slot.groupIds,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapSlotProductSummary = (product: { id: number; name: string; images: unknown }): AdminSlotProductSummary => ({
|
const mapSlotSkuSummary = (sku: { id: number; images: unknown; name: string | null; product: { name: string } | null; features: Array<{ featureName: string; featureValue: string }> }): AdminSlotProductSummary => ({
|
||||||
id: product.id,
|
id: sku.id,
|
||||||
name: product.name,
|
name: sku.product?.name ?? 'Unknown',
|
||||||
images: getStringArray(product.images),
|
images: getStringArray(sku.images),
|
||||||
|
skuName: sku.name ?? null,
|
||||||
|
features: (sku.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapVendorSnippet = (snippet: typeof vendorSnippets.$inferSelect): AdminVendorSnippet => ({
|
const mapVendorSnippet = (snippet: typeof vendorSnippets.$inferSelect): AdminVendorSnippet => ({
|
||||||
id: snippet.id,
|
id: snippet.id,
|
||||||
snippetCode: snippet.snippetCode,
|
snippetCode: snippet.snippetCode,
|
||||||
slotId: snippet.slotId ?? null,
|
slotId: snippet.slotId ?? null,
|
||||||
productIds: snippet.productIds || [],
|
skuIds: snippet.skuIds || [],
|
||||||
isPermanent: snippet.isPermanent,
|
isPermanent: snippet.isPermanent,
|
||||||
validTill: coerceDate(snippet.validTill),
|
validTill: coerceDate(snippet.validTill),
|
||||||
createdAt: coerceDate(snippet.createdAt) ?? new Date(0),
|
createdAt: coerceDate(snippet.createdAt) ?? new Date(0),
|
||||||
|
|
@ -103,37 +107,33 @@ export async function getActiveSlotsWithProducts(limit: number = 20): Promise<Ad
|
||||||
limit,
|
limit,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get all unique product IDs from all slots
|
// Get all unique SKU IDs from all slots
|
||||||
const allProductIds = new Set<number>()
|
const allSkuIds = new Set<number>()
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
for (const productId of (slot.productIds || [])) {
|
for (const skuId of (slot.skuIds || [])) {
|
||||||
allProductIds.add(productId)
|
allSkuIds.add(skuId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all products in one query
|
// Fetch all SKUs in one query
|
||||||
const productIdsArray = Array.from(allProductIds)
|
const skuIdsArray = Array.from(allSkuIds)
|
||||||
const productIdsSet = new Set(productIdsArray)
|
const skuIdsSet = new Set(skuIdsArray)
|
||||||
// const productsData = productIdsArray.length > 0
|
|
||||||
// ? await db.query.productInfo.findMany({
|
|
||||||
// where: inArray(productInfo.id, productIdsArray),
|
|
||||||
// columns: { id: true, name: true, images: true },
|
|
||||||
// })
|
|
||||||
// : []
|
|
||||||
|
|
||||||
let productsData = await db.query.productInfo.findMany({});
|
let skusData = await db.query.productSkus.findMany({
|
||||||
productsData = productsData.filter(item => productIdsSet.has(item.id))
|
with: { features: true, product: true },
|
||||||
|
})
|
||||||
|
skusData = skusData.filter((item: any) => skuIdsSet.has(item.id))
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Create a map for quick lookup
|
||||||
const productMap = new Map(productsData.map(p => [p.id, p]))
|
const skuMap = new Map(skusData.map((s: any) => [s.id, s]))
|
||||||
|
|
||||||
return slots.map((slot) => ({
|
return slots.map((slot) => ({
|
||||||
...mapDeliverySlot(slot),
|
...mapDeliverySlot(slot),
|
||||||
deliverySequence: getNumberArray(slot.deliverySequence),
|
deliverySequence: getNumberArray(slot.deliverySequence),
|
||||||
products: (slot.productIds || [])
|
products: (slot.skuIds || [])
|
||||||
.map(productId => productMap.get(productId))
|
.map((skuId: number) => skuMap.get(skuId))
|
||||||
.filter((p): p is NonNullable<typeof p> => p != null)
|
.filter((p): p is NonNullable<typeof p> => p != null)
|
||||||
.map(product => mapSlotProductSummary(product)),
|
.map((sku: any) => mapSlotSkuSummary(sku)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,7 +155,7 @@ export async function staleSlotsCleanup(): Promise<number> {
|
||||||
// Clear productIds for all slots older than threshold
|
// Clear productIds for all slots older than threshold
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(deliverySlotInfo)
|
.update(deliverySlotInfo)
|
||||||
.set({ productIds: [] })
|
.set({ skuIds: [] })
|
||||||
.where(eq(deliverySlotInfo.id, threshold))
|
.where(eq(deliverySlotInfo.id, threshold))
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -193,26 +193,21 @@ export async function getSlotByIdWithRelations(id: number): Promise<AdminSlotWit
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch products for this slot
|
// Fetch SKUs for this slot
|
||||||
const productIds = slot.productIds || []
|
const skuIds = slot.skuIds || []
|
||||||
const productIdSet = new Set(productIds);
|
const skuIdSet = new Set(skuIds)
|
||||||
// const productsData = productIds.length > 0
|
let skusData = skuIds.length > 0
|
||||||
// ? await db.query.productInfo.findMany({
|
? await db.query.productSkus.findMany({
|
||||||
// where: inArray(productInfo.id, productIds),
|
with: { features: true, product: true },
|
||||||
// columns: { id: true, name: true, images: true },
|
columns: { id: true, images: true, name: true },
|
||||||
// })
|
|
||||||
// : []
|
|
||||||
let productsData = productIds.length > 0
|
|
||||||
? await db.query.productInfo.findMany({
|
|
||||||
columns: { id: true, name: true, images: true },
|
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
productsData = productsData.filter(item => productIdSet.has(item.id))
|
skusData = skusData.filter((item: any) => skuIdSet.has(item.id))
|
||||||
return {
|
return {
|
||||||
...mapDeliverySlot(slot),
|
...mapDeliverySlot(slot),
|
||||||
deliverySequence: getNumberArray(slot.deliverySequence),
|
deliverySequence: getNumberArray(slot.deliverySequence),
|
||||||
groupIds: getNumberArray(slot.groupIds),
|
groupIds: getNumberArray(slot.groupIds),
|
||||||
products: productsData.map(product => mapSlotProductSummary(product)),
|
products: skusData.map((sku: any) => mapSlotSkuSummary(sku)),
|
||||||
vendorSnippets: slot.vendorSnippets.map(mapVendorSnippet),
|
vendorSnippets: slot.vendorSnippets.map(mapVendorSnippet),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -221,21 +216,21 @@ export async function createSlotWithRelations(input: {
|
||||||
deliveryTime: string
|
deliveryTime: string
|
||||||
freezeTime: string
|
freezeTime: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
productIds?: number[]
|
skuIds?: number[]
|
||||||
vendorSnippets?: SlotSnippetInput[]
|
vendorSnippets?: SlotSnippetInput[]
|
||||||
groupIds?: number[]
|
groupIds?: number[]
|
||||||
}): Promise<AdminSlotCreateResult> {
|
}): Promise<AdminSlotCreateResult> {
|
||||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input
|
const { deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input
|
||||||
|
|
||||||
const normalizedProductIds = normalizeProductIds(productIds)
|
const normalizedSkuIds = normalizeSkuIds(skuIds)
|
||||||
|
|
||||||
const result = await db.transaction(async (tx) => {
|
const result = await db.transaction(async (tx) => {
|
||||||
// Validate product IDs if provided
|
// Validate SKU IDs if provided
|
||||||
if (normalizedProductIds.length > 0) {
|
if (normalizedSkuIds.length > 0) {
|
||||||
const existingIds = await fetchExistingProductIds(tx, normalizedProductIds)
|
const existingIds = await fetchExistingSkuIds(tx, normalizedSkuIds)
|
||||||
const missingIds = normalizedProductIds.filter((productId) => !existingIds.has(productId))
|
const missingIds = normalizedSkuIds.filter((skuId) => !existingIds.has(skuId))
|
||||||
if (missingIds.length > 0) {
|
if (missingIds.length > 0) {
|
||||||
throw new Error(`Invalid product IDs: ${missingIds.join(', ')}`)
|
throw new Error(`Invalid SKU IDs: ${missingIds.join(', ')}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,18 +241,18 @@ export async function createSlotWithRelations(input: {
|
||||||
freezeTime: new Date(freezeTime),
|
freezeTime: new Date(freezeTime),
|
||||||
isActive: isActive !== undefined ? isActive : true,
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
groupIds: groupIds !== undefined ? groupIds : [],
|
groupIds: groupIds !== undefined ? groupIds : [],
|
||||||
productIds: normalizedProductIds,
|
skuIds: normalizedSkuIds,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
let createdSnippets: AdminVendorSnippet[] = []
|
let createdSnippets: AdminVendorSnippet[] = []
|
||||||
if (snippets && snippets.length > 0) {
|
if (snippets && snippets.length > 0) {
|
||||||
for (const snippet of snippets) {
|
for (const snippet of snippets) {
|
||||||
const products = await tx.query.productInfo.findMany({
|
const skus = await tx.query.productSkus.findMany({
|
||||||
where: inArray(productInfo.id, snippet.productIds),
|
where: inArray(productSkus.id, snippet.skuIds),
|
||||||
})
|
})
|
||||||
if (products.length !== snippet.productIds.length) {
|
if (skus.length !== snippet.skuIds.length) {
|
||||||
throw new Error(`One or more invalid product IDs in snippet "${snippet.name}"`)
|
throw new Error(`One or more invalid SKU IDs in snippet "${snippet.name}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSnippet = await tx.query.vendorSnippets.findFirst({
|
const existingSnippet = await tx.query.vendorSnippets.findFirst({
|
||||||
|
|
@ -270,7 +265,7 @@ export async function createSlotWithRelations(input: {
|
||||||
const [createdSnippet] = await tx.insert(vendorSnippets).values({
|
const [createdSnippet] = await tx.insert(vendorSnippets).values({
|
||||||
snippetCode: snippet.name,
|
snippetCode: snippet.name,
|
||||||
slotId: newSlot.id,
|
slotId: newSlot.id,
|
||||||
productIds: snippet.productIds,
|
skuIds: snippet.skuIds,
|
||||||
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
|
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
|
||||||
}).returning()
|
}).returning()
|
||||||
|
|
||||||
|
|
@ -293,11 +288,11 @@ export async function updateSlotWithRelations(input: {
|
||||||
deliveryTime: string
|
deliveryTime: string
|
||||||
freezeTime: string
|
freezeTime: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
productIds?: number[]
|
skuIds?: number[]
|
||||||
vendorSnippets?: SlotSnippetInput[]
|
vendorSnippets?: SlotSnippetInput[]
|
||||||
groupIds?: number[]
|
groupIds?: number[]
|
||||||
}): Promise<AdminSlotUpdateResult | null> {
|
}): Promise<AdminSlotUpdateResult | null> {
|
||||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input
|
const { id, deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input
|
||||||
|
|
||||||
let validGroupIds = groupIds
|
let validGroupIds = groupIds
|
||||||
if (groupIds && groupIds.length > 0) {
|
if (groupIds && groupIds.length > 0) {
|
||||||
|
|
@ -308,15 +303,15 @@ export async function updateSlotWithRelations(input: {
|
||||||
validGroupIds = existingGroups.map((group: { id: number }) => group.id)
|
validGroupIds = existingGroups.map((group: { id: number }) => group.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedProductIds = productIds !== undefined ? normalizeProductIds(productIds) : undefined
|
const normalizedSkuIds = skuIds !== undefined ? normalizeSkuIds(skuIds) : undefined
|
||||||
|
|
||||||
const result = await db.transaction(async (tx) => {
|
const result = await db.transaction(async (tx) => {
|
||||||
// Validate product IDs if provided
|
// Validate SKU IDs if provided
|
||||||
if (normalizedProductIds !== undefined && normalizedProductIds.length > 0) {
|
if (normalizedSkuIds !== undefined && normalizedSkuIds.length > 0) {
|
||||||
const existingIds = await fetchExistingProductIds(tx, normalizedProductIds)
|
const existingIds = await fetchExistingSkuIds(tx, normalizedSkuIds)
|
||||||
const missingIds = normalizedProductIds.filter((productId) => !existingIds.has(productId))
|
const missingIds = normalizedSkuIds.filter((skuId) => !existingIds.has(skuId))
|
||||||
if (missingIds.length > 0) {
|
if (missingIds.length > 0) {
|
||||||
throw new Error(`Invalid product IDs: ${missingIds.join(', ')}`)
|
throw new Error(`Invalid SKU IDs: ${missingIds.join(', ')}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,7 +322,7 @@ export async function updateSlotWithRelations(input: {
|
||||||
freezeTime: new Date(freezeTime),
|
freezeTime: new Date(freezeTime),
|
||||||
isActive: isActive !== undefined ? isActive : true,
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
||||||
...(normalizedProductIds !== undefined && { productIds: normalizedProductIds }),
|
...(normalizedSkuIds !== undefined && { skuIds: normalizedSkuIds }),
|
||||||
})
|
})
|
||||||
.where(eq(deliverySlotInfo.id, id))
|
.where(eq(deliverySlotInfo.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
|
|
@ -339,11 +334,11 @@ export async function updateSlotWithRelations(input: {
|
||||||
let createdSnippets: AdminVendorSnippet[] = []
|
let createdSnippets: AdminVendorSnippet[] = []
|
||||||
if (snippets && snippets.length > 0) {
|
if (snippets && snippets.length > 0) {
|
||||||
for (const snippet of snippets) {
|
for (const snippet of snippets) {
|
||||||
const products = await tx.query.productInfo.findMany({
|
const skus = await tx.query.productSkus.findMany({
|
||||||
where: inArray(productInfo.id, snippet.productIds),
|
where: inArray(productSkus.id, snippet.skuIds),
|
||||||
})
|
})
|
||||||
if (products.length !== snippet.productIds.length) {
|
if (skus.length !== snippet.skuIds.length) {
|
||||||
throw new Error(`One or more invalid product IDs in snippet "${snippet.name}"`)
|
throw new Error(`One or more invalid SKU IDs in snippet "${snippet.name}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSnippet = await tx.query.vendorSnippets.findFirst({
|
const existingSnippet = await tx.query.vendorSnippets.findFirst({
|
||||||
|
|
@ -356,7 +351,7 @@ export async function updateSlotWithRelations(input: {
|
||||||
const [createdSnippet] = await tx.insert(vendorSnippets).values({
|
const [createdSnippet] = await tx.insert(vendorSnippets).values({
|
||||||
snippetCode: snippet.name,
|
snippetCode: snippet.name,
|
||||||
slotId: id,
|
slotId: id,
|
||||||
productIds: snippet.productIds,
|
skuIds: snippet.skuIds,
|
||||||
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
|
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
|
||||||
}).returning()
|
}).returning()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,3 +254,34 @@ export async function getNextDeliveryDateWithCapacity(productId: number): Promis
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkuSummary {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
label: string
|
||||||
|
images: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSkusSummary(): Promise<SkuSummary[]> {
|
||||||
|
const skus = await db.query.productSkus.findMany({
|
||||||
|
with: {
|
||||||
|
features: true,
|
||||||
|
product: {
|
||||||
|
columns: { name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return skus.map((sku) => {
|
||||||
|
const featureValues = (sku.features || []).map((f) => f.featureValue)
|
||||||
|
const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ')
|
||||||
|
return {
|
||||||
|
id: sku.id,
|
||||||
|
productId: sku.productId,
|
||||||
|
productName: sku.product?.name ?? 'Unknown',
|
||||||
|
label,
|
||||||
|
images: sku.images,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export interface Banner {
|
||||||
name: string;
|
name: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
productIds: number[] | null;
|
skuIds: number[] | null;
|
||||||
redirectUrl: string | null;
|
redirectUrl: string | null;
|
||||||
serialNum: number | null;
|
serialNum: number | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
@ -47,7 +47,7 @@ export interface Coupon {
|
||||||
discountPercent: string | null;
|
discountPercent: string | null;
|
||||||
flatDiscount: string | null;
|
flatDiscount: string | null;
|
||||||
minOrder: string | null;
|
minOrder: string | null;
|
||||||
productIds: number[] | null;
|
skuIds: number[] | null;
|
||||||
maxValue: string | null;
|
maxValue: string | null;
|
||||||
isApplyForAll: boolean;
|
isApplyForAll: boolean;
|
||||||
validTill: Date | null;
|
validTill: Date | null;
|
||||||
|
|
@ -170,6 +170,8 @@ export interface AdminOrderDetailsItem {
|
||||||
amount: number;
|
amount: number;
|
||||||
isPackaged: boolean;
|
isPackaged: boolean;
|
||||||
isPackageVerified: boolean;
|
isPackageVerified: boolean;
|
||||||
|
skuName?: string | null;
|
||||||
|
features?: { featureName: string; featureValue: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminOrderDetailsPayment {
|
export interface AdminOrderDetailsPayment {
|
||||||
|
|
@ -248,6 +250,8 @@ export interface AdminSlotOrderItem {
|
||||||
unit: string;
|
unit: string;
|
||||||
isPackaged: boolean;
|
isPackaged: boolean;
|
||||||
isPackageVerified: boolean;
|
isPackageVerified: boolean;
|
||||||
|
skuName?: string | null;
|
||||||
|
features?: { featureName: string; featureValue: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSlotOrder {
|
export interface AdminSlotOrder {
|
||||||
|
|
@ -287,6 +291,8 @@ export interface AdminOrderListItemProduct {
|
||||||
productSize: number;
|
productSize: number;
|
||||||
isPackaged: boolean;
|
isPackaged: boolean;
|
||||||
isPackageVerified: boolean;
|
isPackageVerified: boolean;
|
||||||
|
skuName?: string | null;
|
||||||
|
features?: { featureName: string; featureValue: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminOrderListItem {
|
export interface AdminOrderListItem {
|
||||||
|
|
@ -354,32 +360,89 @@ export interface AdminUnit {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminSkuVariant {
|
||||||
|
id: number
|
||||||
|
skuId: number
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
unitId: number | null
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSku {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
productName?: string
|
||||||
|
skuCode: string | null
|
||||||
|
displayName: string
|
||||||
|
unitId: number
|
||||||
|
unit?: AdminUnit
|
||||||
|
productQuantity: number
|
||||||
|
incrementStep: number
|
||||||
|
price: string
|
||||||
|
marketPrice: string | null
|
||||||
|
images: string[] | null
|
||||||
|
imageKeys: string[] | null
|
||||||
|
isOutOfStock: boolean
|
||||||
|
isSuspended: boolean
|
||||||
|
isFlashAvailable: boolean
|
||||||
|
flashPrice: string | null
|
||||||
|
isComboOnly: boolean
|
||||||
|
sortOrder: number
|
||||||
|
isDefault: boolean
|
||||||
|
createdAt: Date
|
||||||
|
variants: AdminSkuVariant[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminProduct {
|
export interface AdminProduct {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
shortDescription: string | null;
|
shortDescription: string | null;
|
||||||
longDescription: string | null;
|
longDescription: string | null;
|
||||||
unitId: number;
|
|
||||||
price: string;
|
|
||||||
marketPrice: string | null;
|
|
||||||
images: string[] | null;
|
|
||||||
imageKeys: string[] | null;
|
|
||||||
isOutOfStock: boolean;
|
|
||||||
isSuspended: boolean;
|
|
||||||
isFlashAvailable: boolean;
|
|
||||||
flashPrice: string | null;
|
|
||||||
createdAt: Date;
|
|
||||||
incrementStep: number;
|
|
||||||
productQuantity: number;
|
|
||||||
storeId: number | null;
|
storeId: number | null;
|
||||||
|
incrementStep: number;
|
||||||
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProductWithRelations extends AdminProduct {
|
export interface AdminProductWithRelations extends AdminProduct {
|
||||||
unit: AdminUnit;
|
|
||||||
store: Store | null;
|
store: Store | null;
|
||||||
|
skus: AdminSku[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProductTagInfo {
|
export interface CreateSkuVariantInput {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
unitId?: number | null
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSkuInput {
|
||||||
|
skuCode?: string | null
|
||||||
|
displayName: string
|
||||||
|
unitId: number
|
||||||
|
productQuantity?: number
|
||||||
|
incrementStep?: number
|
||||||
|
price: number | string
|
||||||
|
marketPrice?: number | string | null
|
||||||
|
images?: string[] | null
|
||||||
|
isOutOfStock?: boolean
|
||||||
|
isSuspended?: boolean
|
||||||
|
isFlashAvailable?: boolean
|
||||||
|
flashPrice?: number | string | null
|
||||||
|
isComboOnly?: boolean
|
||||||
|
sortOrder?: number
|
||||||
|
isDefault?: boolean
|
||||||
|
variants?: CreateSkuVariantInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductInput {
|
||||||
|
name: string
|
||||||
|
shortDescription?: string | null
|
||||||
|
longDescription?: string | null
|
||||||
|
storeId: number
|
||||||
|
incrementStep?: number
|
||||||
|
skus: CreateSkuInput[]
|
||||||
|
}
|
||||||
id: number;
|
id: number;
|
||||||
tagName: string;
|
tagName: string;
|
||||||
tagDescription: string | null;
|
tagDescription: string | null;
|
||||||
|
|
@ -515,13 +578,15 @@ export interface AdminSlotProductSummary {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
images: string[] | null;
|
images: string[] | null;
|
||||||
|
skuName?: string | null;
|
||||||
|
features?: { featureName: string; featureValue: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminVendorSnippet {
|
export interface AdminVendorSnippet {
|
||||||
id: number;
|
id: number;
|
||||||
snippetCode: string;
|
snippetCode: string;
|
||||||
slotId: number | null;
|
slotId: number | null;
|
||||||
productIds: number[];
|
skuIds: number[];
|
||||||
isPermanent: boolean;
|
isPermanent: boolean;
|
||||||
validTill: Date | null;
|
validTill: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
@ -613,7 +678,7 @@ export interface AdminUpdateSlotCapacityResult {
|
||||||
export interface AdminVendorSnippetCreateInput {
|
export interface AdminVendorSnippetCreateInput {
|
||||||
snippetCode: string;
|
snippetCode: string;
|
||||||
slotId?: number;
|
slotId?: number;
|
||||||
productIds: number[];
|
skuIds: number[];
|
||||||
validTill?: string;
|
validTill?: string;
|
||||||
isPermanent: boolean;
|
isPermanent: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -621,7 +686,7 @@ export interface AdminVendorSnippetCreateInput {
|
||||||
export interface AdminVendorSnippetUpdateInput {
|
export interface AdminVendorSnippetUpdateInput {
|
||||||
snippetCode?: string;
|
snippetCode?: string;
|
||||||
slotId?: number;
|
slotId?: number;
|
||||||
productIds?: number[];
|
skuIds?: number[];
|
||||||
validTill?: string | null;
|
validTill?: string | null;
|
||||||
isPermanent?: boolean;
|
isPermanent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -664,7 +729,7 @@ export interface AdminVendorSnippetOrdersResult {
|
||||||
id: number;
|
id: number;
|
||||||
snippetCode: string;
|
snippetCode: string;
|
||||||
slotId: number | null;
|
slotId: number | null;
|
||||||
productIds: number[];
|
skuIds: number[];
|
||||||
validTill?: string;
|
validTill?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isPermanent: boolean;
|
isPermanent: boolean;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue