enh
This commit is contained in:
parent
3e02f5ebd0
commit
3ec9cf8795
11 changed files with 4235 additions and 183 deletions
|
|
@ -1,56 +1,68 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { View, ScrollView, TouchableOpacity, Alert, Dimensions, Share } from 'react-native';
|
import {
|
||||||
import { theme, AppContainer, MyText, tw, useManualRefresh, useMarkDataFetchers, MyTouchableOpacity } from 'common-ui';
|
View,
|
||||||
import { trpc, trpcClient } from '../../../src/trpc-client';
|
ScrollView,
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
Dimensions,
|
||||||
|
Share,
|
||||||
|
} from "react-native";
|
||||||
|
import {
|
||||||
|
theme,
|
||||||
|
AppContainer,
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
useManualRefresh,
|
||||||
|
useMarkDataFetchers,
|
||||||
|
MyTouchableOpacity,
|
||||||
|
} from "common-ui";
|
||||||
|
import { trpc, trpcClient } from "../../../src/trpc-client";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
import VendorSnippetForm from '../../../components/VendorSnippetForm';
|
import VendorSnippetForm from "../../../components/VendorSnippetForm";
|
||||||
import SnippetOrdersView from '../../../components/SnippetOrdersView';
|
import SnippetOrdersView from "../../../components/SnippetOrdersView";
|
||||||
import { SnippetMenu } from '../../../components/SnippetMenu';
|
import { SnippetMenu } from "../../../components/SnippetMenu";
|
||||||
|
import { ProductListDialog } from "../../../components/ProductListDialog";
|
||||||
interface VendorSnippet {
|
import {
|
||||||
id: number;
|
VendorSnippet,
|
||||||
snippetCode: string;
|
VendorSnippetProduct,
|
||||||
slotId: number;
|
VendorSnippetForm as VendorSnippetFormType,
|
||||||
productIds: number[];
|
} from "../../../types/vendor-snippets";
|
||||||
validTill: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
accessUrl: string;
|
|
||||||
slot?: {
|
|
||||||
id: number;
|
|
||||||
deliveryTime: string;
|
|
||||||
freezeTime: string;
|
|
||||||
isActive: boolean;
|
|
||||||
deliverySequence?: unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const SnippetItem = ({
|
const SnippetItem = ({
|
||||||
snippet,
|
snippet,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onViewOrders,
|
onViewOrders,
|
||||||
index
|
onViewProducts,
|
||||||
|
index,
|
||||||
}: {
|
}: {
|
||||||
snippet: VendorSnippet;
|
snippet: VendorSnippet;
|
||||||
onEdit: (snippet: VendorSnippet) => void;
|
onEdit: (snippet: VendorSnippet) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onViewOrders: (snippetCode: string) => void;
|
onViewOrders: (snippetCode: string) => void;
|
||||||
|
onViewProducts: (products: VendorSnippetProduct[]) => void;
|
||||||
index: number;
|
index: number;
|
||||||
}) => {
|
}) => {
|
||||||
const isExpired = snippet.validTill && dayjs(snippet.validTill).isBefore(dayjs());
|
|
||||||
|
const isExpired =
|
||||||
|
snippet.validTill && dayjs(snippet.validTill).isBefore(dayjs());
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!snippet.accessUrl) {
|
||||||
|
Alert.alert("Error", "No link available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Share.share({
|
await Share.share({
|
||||||
message: snippet.accessUrl,
|
message: snippet.accessUrl,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert('Error', 'Failed to share link');
|
Alert.alert("Error", "Failed to share link");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -60,24 +72,50 @@ const SnippetItem = ({
|
||||||
{/* Top Header: ID & Status */}
|
{/* Top Header: ID & Status */}
|
||||||
<View style={tw`flex-row justify-between items-start mb-6`}>
|
<View style={tw`flex-row justify-between items-start mb-6`}>
|
||||||
<View style={tw`flex-row items-center flex-1`}>
|
<View style={tw`flex-row items-center flex-1`}>
|
||||||
<View style={tw`w-12 h-12 rounded-2xl bg-brand50 items-center justify-center mr-4`}>
|
<View
|
||||||
<MaterialIcons name="fingerprint" size={24} color={theme.colors.brand600} />
|
style={tw`w-12 h-12 rounded-2xl bg-brand50 items-center justify-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="fingerprint"
|
||||||
|
size={24}
|
||||||
|
color={theme.colors.brand600}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={tw`flex-1`}>
|
<View style={tw`flex-1`}>
|
||||||
<MyText style={tw`text-slate-400 text-[10px] font-black uppercase tracking-widest`}>
|
<MyText
|
||||||
|
style={tw`text-slate-400 text-[10px] font-black uppercase tracking-widest`}
|
||||||
|
>
|
||||||
Identifier
|
Identifier
|
||||||
</MyText>
|
</MyText>
|
||||||
<MyText style={tw`text-xl font-black text-slate-900`} numberOfLines={1}>
|
<MyText
|
||||||
|
style={tw`text-xl font-black text-slate-900`}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{snippet.snippetCode}
|
{snippet.snippetCode}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<View style={[tw`px-3 py-1.5 rounded-full flex-row items-center mr-2`, { backgroundColor: isExpired ? '#FFF1F2' : '#F0FDF4' }]}>
|
<View
|
||||||
<View style={[tw`w-1.5 h-1.5 rounded-full mr-2`, { backgroundColor: isExpired ? '#E11D48' : '#10B981' }]} />
|
style={[
|
||||||
<MyText style={[tw`text-[10px] font-black uppercase tracking-tighter`, { color: isExpired ? '#E11D48' : '#10B981' }]}>
|
tw`px-3 py-1.5 rounded-full flex-row items-center mr-2`,
|
||||||
{isExpired ? 'Expired' : 'Active'}
|
{ backgroundColor: isExpired ? "#FFF1F2" : "#F0FDF4" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
tw`w-1.5 h-1.5 rounded-full mr-2`,
|
||||||
|
{ backgroundColor: isExpired ? "#E11D48" : "#10B981" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<MyText
|
||||||
|
style={[
|
||||||
|
tw`text-[10px] font-black uppercase tracking-tighter`,
|
||||||
|
{ color: isExpired ? "#E11D48" : "#10B981" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isExpired ? "Expired" : "Active"}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
<SnippetMenu
|
<SnippetMenu
|
||||||
|
|
@ -90,23 +128,56 @@ const SnippetItem = ({
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Middle: Delivery Banner */}
|
{/* Middle: Delivery Banner */}
|
||||||
<View style={tw`bg-slate-50 rounded-3xl p-4 flex-row items-center mb-6 border border-slate-100`}>
|
<View
|
||||||
<View style={tw`bg-white w-10 h-10 rounded-2xl items-center justify-center shadow-sm`}>
|
style={tw`bg-slate-50 rounded-3xl p-4 flex-row items-center mb-6 border border-slate-100`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={tw`bg-white w-10 h-10 rounded-2xl items-center justify-center shadow-sm`}
|
||||||
|
>
|
||||||
<MaterialIcons name="event-available" size={20} color="#64748B" />
|
<MaterialIcons name="event-available" size={20} color="#64748B" />
|
||||||
</View>
|
</View>
|
||||||
<View style={tw`ml-4 flex-1`}>
|
<View style={tw`ml-4 flex-1`}>
|
||||||
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
|
{snippet.isPermanent ? (
|
||||||
{snippet.slot?.deliveryTime ? dayjs(snippet.slot.deliveryTime).format('ddd, MMM DD') : 'Schedule Pending'}
|
<>
|
||||||
</MyText>
|
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
|
||||||
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
|
Permanent
|
||||||
Time: {snippet.slot?.deliveryTime ? dayjs(snippet.slot.deliveryTime).format('hh:mm A') : 'N/A'}
|
</MyText>
|
||||||
</MyText>
|
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
|
||||||
|
Always Active
|
||||||
|
</MyText>
|
||||||
|
</>
|
||||||
|
) : snippet.slot?.deliveryTime ? (
|
||||||
|
<>
|
||||||
|
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
|
||||||
|
{dayjs(snippet.slot.deliveryTime).format("ddd, MMM DD")}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
|
||||||
|
Time: {dayjs(snippet.slot.deliveryTime).format("hh:mm A")}
|
||||||
|
</MyText>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
|
||||||
|
Schedule Pending
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
|
||||||
|
N/A
|
||||||
|
</MyText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{snippet.validTill && (
|
{snippet.validTill && (
|
||||||
<View style={tw`items-end`}>
|
<View style={tw`items-end`}>
|
||||||
<MyText style={tw`text-slate-400 text-[9px] font-bold uppercase`}>Expires</MyText>
|
<MyText style={tw`text-slate-400 text-[9px] font-bold uppercase`}>
|
||||||
<MyText style={[tw`text-xs font-black`, { color: isExpired ? '#E11D48' : '#64748B' }]}>
|
Expires
|
||||||
{dayjs(snippet.validTill).format('MMM DD')}
|
</MyText>
|
||||||
|
<MyText
|
||||||
|
style={[
|
||||||
|
tw`text-xs font-black`,
|
||||||
|
{ color: isExpired ? "#E11D48" : "#64748B" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{dayjs(snippet.validTill).format("MMM DD")}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -115,13 +186,28 @@ const SnippetItem = ({
|
||||||
{/* Stats & Actions */}
|
{/* Stats & Actions */}
|
||||||
<View style={tw`flex-row items-center justify-between`}>
|
<View style={tw`flex-row items-center justify-between`}>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<View style={tw`flex-row items-center mr-4`}>
|
<MyTouchableOpacity
|
||||||
|
onPress={() => onViewProducts(snippet.products)}
|
||||||
|
style={tw`flex-row items-center mr-4`}
|
||||||
|
>
|
||||||
<MaterialIcons name="shopping-bag" size={14} color="#94A3B8" />
|
<MaterialIcons name="shopping-bag" size={14} color="#94A3B8" />
|
||||||
<MyText style={tw`text-xs font-bold text-slate-500 ml-1.5`}>{snippet.productIds.length} Items</MyText>
|
<MyText style={tw`text-xs font-bold text-slate-500 ml-1.5`}>
|
||||||
</View>
|
{snippet.productIds.length} Items
|
||||||
<MyTouchableOpacity onPress={handleCopyLink} style={tw`flex-row items-center`}>
|
</MyText>
|
||||||
<MaterialIcons name="link" size={16} color={theme.colors.brand500} />
|
<MaterialIcons name="chevron-right" size={14} color="#94A3B8" />
|
||||||
<MyText style={tw`text-xs font-black text-brand600 ml-1`}>Share</MyText>
|
</MyTouchableOpacity>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={handleCopyLink}
|
||||||
|
style={tw`flex-row items-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="link"
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.brand500}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`text-xs font-black text-brand600 ml-1`}>
|
||||||
|
Share
|
||||||
|
</MyText>
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -131,7 +217,11 @@ const SnippetItem = ({
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
style={tw`flex-row items-center`}
|
style={tw`flex-row items-center`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-xs font-black text-slate-900 uppercase tracking-widest`}>View Slot</MyText>
|
<MyText
|
||||||
|
style={tw`text-xs font-black text-slate-900 uppercase tracking-widest`}
|
||||||
|
>
|
||||||
|
View Slot
|
||||||
|
</MyText>
|
||||||
<MaterialIcons name="chevron-right" size={16} color="#000" />
|
<MaterialIcons name="chevron-right" size={16} color="#000" />
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -142,7 +232,12 @@ const SnippetItem = ({
|
||||||
|
|
||||||
export default function VendorSnippets() {
|
export default function VendorSnippets() {
|
||||||
// const { data: snippets, isLoading, error, refetch } = useVendorSnippets();
|
// const { data: snippets, isLoading, error, refetch } = useVendorSnippets();
|
||||||
const { data: snippets, isLoading, error, refetch } = trpc.admin.vendorSnippets.getAll.useQuery();
|
const {
|
||||||
|
data: snippets,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = trpc.admin.vendorSnippets.getAll.useQuery();
|
||||||
|
|
||||||
const createSnippet = trpc.admin.vendorSnippets.create.useMutation();
|
const createSnippet = trpc.admin.vendorSnippets.create.useMutation();
|
||||||
const updateSnippet = trpc.admin.vendorSnippets.update.useMutation();
|
const updateSnippet = trpc.admin.vendorSnippets.update.useMutation();
|
||||||
|
|
@ -151,41 +246,64 @@ export default function VendorSnippets() {
|
||||||
// const updateSnippet = useUpdateVendorSnippet();
|
// const updateSnippet = useUpdateVendorSnippet();
|
||||||
// const deleteSnippet = useDeleteVendorSnippet();
|
// const deleteSnippet = useDeleteVendorSnippet();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [editingSnippet, setEditingSnippet] = useState<VendorSnippet | null>(null);
|
const [editingSnippet, setEditingSnippet] =
|
||||||
const [showOrdersView, setShowOrdersView] = useState(false);
|
useState<VendorSnippetFormType | null>(null);
|
||||||
const [ordersData, setOrdersData] = useState<any>(null);
|
const [showOrdersView, setShowOrdersView] = useState(false);
|
||||||
|
const [ordersData, setOrdersData] = useState<any>(null);
|
||||||
|
const [showProductList, setShowProductList] = useState(false);
|
||||||
|
const [selectedProducts, setSelectedProducts] = useState<VendorSnippetProduct[] | null>(null);
|
||||||
|
|
||||||
|
useManualRefresh(refetch);
|
||||||
|
|
||||||
useManualRefresh(refetch);
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
useMarkDataFetchers(() => {
|
const handleCreate = () => {
|
||||||
refetch();
|
setShowCreateForm(true);
|
||||||
});
|
setEditingSnippet(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleViewProducts = (products: VendorSnippetProduct[]) => {
|
||||||
setShowCreateForm(true);
|
setSelectedProducts(products);
|
||||||
setEditingSnippet(null);
|
setShowProductList(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (snippet: VendorSnippet) => {
|
const handleEdit = (snippet: VendorSnippet) => {
|
||||||
setEditingSnippet(snippet);
|
// Convert to match the form's expected type
|
||||||
|
const formSnippet: VendorSnippetFormType = {
|
||||||
|
id: snippet.id,
|
||||||
|
snippetCode: snippet.snippetCode,
|
||||||
|
slotId: snippet.slotId || 0, // Convert null to number for form
|
||||||
|
isPermanent: snippet.isPermanent,
|
||||||
|
productIds: snippet.productIds,
|
||||||
|
validTill: snippet.validTill,
|
||||||
|
createdAt: snippet.createdAt,
|
||||||
|
};
|
||||||
|
setEditingSnippet(formSnippet);
|
||||||
setShowCreateForm(true);
|
setShowCreateForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDelete = (id: number) => {
|
||||||
deleteSnippet.mutate({ id }, {
|
deleteSnippet.mutate(
|
||||||
onSuccess: () => {
|
{ id },
|
||||||
refetch();
|
{
|
||||||
}
|
onSuccess: () => {
|
||||||
});
|
refetch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewOrders = async (snippetCode: string) => {
|
const handleViewOrders = async (snippetCode: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await trpcClient.admin.vendorSnippets.getOrdersBySnippet.query({ snippetCode });
|
const result =
|
||||||
|
await trpcClient.admin.vendorSnippets.getOrdersBySnippet.query({
|
||||||
|
snippetCode,
|
||||||
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setOrdersData({
|
setOrdersData({
|
||||||
orders: result.data,
|
orders: result.data,
|
||||||
|
|
@ -193,10 +311,10 @@ export default function VendorSnippets() {
|
||||||
});
|
});
|
||||||
setShowOrdersView(true);
|
setShowOrdersView(true);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Error', 'Failed to fetch orders');
|
Alert.alert("Error", "Failed to fetch orders");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Alert.alert('Error', error.message || 'Failed to fetch orders');
|
Alert.alert("Error", error.message || "Failed to fetch orders");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -251,57 +369,74 @@ export default function VendorSnippets() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<>
|
||||||
<View style={tw`flex-1 bg-white relative`}>
|
<AppContainer>
|
||||||
<ScrollView
|
<View style={tw`flex-1 bg-white h-full`}>
|
||||||
style={tw`flex-1`}
|
<ScrollView
|
||||||
contentContainerStyle={tw` pt-2 pb-32`}
|
style={tw`flex-1`}
|
||||||
showsVerticalScrollIndicator={false}
|
contentContainerStyle={tw` pt-2 pb-32`}
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{snippets && snippets.length === 0 ? (
|
>
|
||||||
<View style={tw`flex-1 justify-center items-center py-20`}>
|
{snippets && snippets.length === 0 ? (
|
||||||
<View style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}>
|
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||||
<Ionicons name="code-working" size={48} color="#94A3B8" />
|
<View
|
||||||
|
style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}
|
||||||
|
>
|
||||||
|
<Ionicons name="code-working" size={48} color="#94A3B8" />
|
||||||
|
</View>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-slate-900 text-xl font-black tracking-tight`}
|
||||||
|
>
|
||||||
|
No Snippets Yet
|
||||||
|
</MyText>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-slate-500 text-center mt-2 font-medium px-8`}
|
||||||
|
>
|
||||||
|
Start by creating your first vendor identifier using the
|
||||||
|
button below.
|
||||||
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
<MyText style={tw`text-slate-900 text-xl font-black tracking-tight`}>No Snippets Yet</MyText>
|
) : (
|
||||||
<MyText style={tw`text-slate-500 text-center mt-2 font-medium px-8`}>
|
snippets?.map((snippet, index) => (
|
||||||
Start by creating your first vendor identifier using the button below.
|
<React.Fragment key={snippet.id}>
|
||||||
</MyText>
|
<SnippetItem
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
snippets?.map((snippet, index) => (
|
|
||||||
<React.Fragment key={snippet.id}>
|
|
||||||
<SnippetItem
|
|
||||||
snippet={snippet}
|
snippet={snippet}
|
||||||
index={index}
|
index={index}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onViewOrders={handleViewOrders}
|
onViewOrders={handleViewOrders}
|
||||||
|
onViewProducts={handleViewProducts}
|
||||||
/>
|
/>
|
||||||
{index < snippets.length - 1 && (
|
{index < snippets.length - 1 && (
|
||||||
<View style={tw`h-px bg-slate-200 w-full`} />
|
<View style={tw`h-px bg-slate-200 w-full`} />
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Global Floating Action Button */}
|
{/* Global Floating Action Button - Fixed Position */}
|
||||||
<MyTouchableOpacity
|
</View>
|
||||||
onPress={handleCreate}
|
</AppContainer>
|
||||||
activeOpacity={0.95}
|
<MyTouchableOpacity
|
||||||
style={tw`absolute bottom-8 right-6 shadow-2xl z-50`}
|
onPress={handleCreate}
|
||||||
|
activeOpacity={0.95}
|
||||||
|
style={tw`absolute bottom-8 right-6 shadow-2xl z-50`}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["#1570EF", "#194185"]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-brand300`}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<MaterialIcons name="add" size={32} color="white" />
|
||||||
colors={['#1570EF', '#194185']}
|
</LinearGradient>
|
||||||
start={{ x: 0, y: 0 }}
|
</MyTouchableOpacity>
|
||||||
end={{ x: 1, y: 1 }}
|
<ProductListDialog
|
||||||
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-brand300`}
|
open={showProductList}
|
||||||
>
|
onClose={() => setShowProductList(false)}
|
||||||
<MaterialIcons name="add" size={32} color="white" />
|
products={selectedProducts || []}
|
||||||
</LinearGradient>
|
/>
|
||||||
</MyTouchableOpacity>
|
</>
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
apps/admin-ui/components/ProductListDialog.tsx
Normal file
45
apps/admin-ui/components/ProductListDialog.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, ScrollView } from 'react-native';
|
||||||
|
import { BottomDialog, MyText, tw } from 'common-ui';
|
||||||
|
|
||||||
|
interface VendorSnippetProduct {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductListDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
products: VendorSnippetProduct[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductListDialog: React.FC<ProductListDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
products,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<BottomDialog open={open} onClose={onClose}>
|
||||||
|
<View style={tw`pt-4 pb-8`}>
|
||||||
|
<MyText style={tw`text-lg font-bold mb-4 text-slate-900`}>
|
||||||
|
Products ({products.length})
|
||||||
|
</MyText>
|
||||||
|
<ScrollView style={{ maxHeight: 400 }}>
|
||||||
|
{products.map((product, index) => (
|
||||||
|
<View
|
||||||
|
key={product.id}
|
||||||
|
style={[
|
||||||
|
tw`py-3 border-b border-slate-100`,
|
||||||
|
index === products.length - 1 && tw`border-b-0`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-sm text-slate-800 font-medium`}>
|
||||||
|
{product.name}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@ import { View, TouchableOpacity, Alert, Clipboard } from 'react-native';
|
||||||
import { Entypo } from '@expo/vector-icons';
|
import { Entypo } from '@expo/vector-icons';
|
||||||
import { MyText, tw, BottomDialog } from 'common-ui';
|
import { MyText, tw, BottomDialog } from 'common-ui';
|
||||||
import { SuccessToast } from '../services/toaster';
|
import { SuccessToast } from '../services/toaster';
|
||||||
|
import { VendorSnippet } from '../types/vendor-snippets';
|
||||||
|
|
||||||
export interface SnippetMenuOption {
|
export interface SnippetMenuOption {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -12,12 +13,8 @@ export interface SnippetMenuOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnippetMenuProps {
|
export interface SnippetMenuProps {
|
||||||
snippet: {
|
snippet: VendorSnippet;
|
||||||
id: number;
|
onEdit: (snippet: VendorSnippet) => void;
|
||||||
snippetCode: string;
|
|
||||||
accessUrl: string;
|
|
||||||
};
|
|
||||||
onEdit: (snippet: any) => void;
|
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onViewOrders: (snippetCode: string) => void;
|
onViewOrders: (snippetCode: string) => void;
|
||||||
triggerStyle?: any;
|
triggerStyle?: any;
|
||||||
|
|
@ -72,6 +69,10 @@ export const SnippetMenu: React.FC<SnippetMenuProps> = ({
|
||||||
|
|
||||||
const handleCopyUrl = async () => {
|
const handleCopyUrl = async () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
if (!snippet.accessUrl) {
|
||||||
|
Alert.alert('Error', 'No URL available to copy');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Clipboard.setString(snippet.accessUrl);
|
await Clipboard.setString(snippet.accessUrl);
|
||||||
SuccessToast('URL copied to clipboard');
|
SuccessToast('URL copied to clipboard');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,20 @@ import { useFormik } from 'formik';
|
||||||
import { MyText, tw, DatePicker, MyTextInput } from 'common-ui';
|
import { MyText, tw, DatePicker, MyTextInput } from 'common-ui';
|
||||||
import BottomDropdown from 'common-ui/src/components/bottom-dropdown';
|
import BottomDropdown from 'common-ui/src/components/bottom-dropdown';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
|
import { VendorSnippetForm as VendorSnippetFormType, VendorSnippet } from '../types/vendor-snippets';
|
||||||
|
|
||||||
interface VendorSnippet {
|
|
||||||
id: number;
|
|
||||||
snippetCode: string;
|
|
||||||
slotId: number;
|
|
||||||
productIds: number[];
|
|
||||||
validTill: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VendorSnippetFormProps {
|
interface VendorSnippetFormProps {
|
||||||
snippet?: VendorSnippet | null;
|
snippet?: VendorSnippetFormType | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
|
showIsPermanentCheckbox?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
||||||
snippet,
|
snippet,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
showIsPermanentCheckbox = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
// Fetch slots and products
|
// Fetch slots and products
|
||||||
|
|
@ -40,6 +33,7 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
snippetCode: snippet?.snippetCode || '',
|
snippetCode: snippet?.snippetCode || '',
|
||||||
slotId: snippet?.slotId?.toString() || '',
|
slotId: snippet?.slotId?.toString() || '',
|
||||||
|
isPermanent: snippet?.isPermanent || false,
|
||||||
productIds: snippet?.productIds?.map(id => id.toString()) || [],
|
productIds: snippet?.productIds?.map(id => id.toString()) || [],
|
||||||
validTill: snippet?.validTill ? new Date(snippet.validTill) : null,
|
validTill: snippet?.validTill ? new Date(snippet.validTill) : null,
|
||||||
},
|
},
|
||||||
|
|
@ -50,7 +44,14 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
||||||
errors.snippetCode = 'Snippet code is required';
|
errors.snippetCode = 'Snippet code is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!values.slotId) {
|
// Validate snippet code only contains alphanumeric characters and underscore
|
||||||
|
const snippetCodeRegex = /^[a-zA-Z0-9_]+$/;
|
||||||
|
if (values.snippetCode && !snippetCodeRegex.test(values.snippetCode)) {
|
||||||
|
errors.snippetCode = 'Snippet code can only contain letters, numbers, and underscores';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only require slotId if isPermanent is false
|
||||||
|
if (!values.isPermanent && !values.slotId) {
|
||||||
errors.slotId = 'Slot selection is required';
|
errors.slotId = 'Slot selection is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,11 +63,14 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
||||||
},
|
},
|
||||||
onSubmit: async (values) => {
|
onSubmit: async (values) => {
|
||||||
try {
|
try {
|
||||||
|
console.log({values})
|
||||||
|
|
||||||
const submitData = {
|
const submitData = {
|
||||||
snippetCode: values.snippetCode,
|
snippetCode: values.snippetCode,
|
||||||
slotId: parseInt(values.slotId),
|
slotId: values.isPermanent ? undefined : parseInt(values.slotId || '0'),
|
||||||
|
isPermanent: values.isPermanent,
|
||||||
productIds: values.productIds.map(id => parseInt(id)),
|
productIds: values.productIds.map(id => parseInt(id)),
|
||||||
validTill: values.validTill?.toISOString(),
|
validTill: values.validTill ? values.validTill.toISOString() : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditing && snippet) {
|
if (isEditing && snippet) {
|
||||||
|
|
@ -93,7 +97,7 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
||||||
if (!isEditing && !formik.values.snippetCode) {
|
if (!isEditing && !formik.values.snippetCode) {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
formik.setFieldValue('snippetCode', `VS_${timestamp}_${random}`.toUpperCase());
|
formik.setFieldValue('snippetCode', `VS_${timestamp}_${random}`);
|
||||||
}
|
}
|
||||||
}, [isEditing]); // Removed formik.values.snippetCode from deps
|
}, [isEditing]); // Removed formik.values.snippetCode from deps
|
||||||
|
|
||||||
|
|
@ -146,21 +150,44 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Slot Selection */}
|
{/* Is Permanent Checkbox */}
|
||||||
<View>
|
<View>
|
||||||
<MyText style={tw`text-gray-700 font-medium mb-2`}>Delivery Slot</MyText>
|
<TouchableOpacity
|
||||||
<BottomDropdown
|
onPress={() => formik.setFieldValue('isPermanent', !formik.values.isPermanent)}
|
||||||
label="Select Slot"
|
style={tw`flex-row items-center mb-2`}
|
||||||
value={formik.values.slotId}
|
>
|
||||||
options={slotOptions}
|
<View style={[
|
||||||
onValueChange={(value) => formik.setFieldValue('slotId', value)}
|
tw`w-6 h-6 border-2 rounded-md mr-3 justify-center items-center`,
|
||||||
placeholder="Choose a delivery slot"
|
formik.values.isPermanent ? tw`bg-blue-500 border-blue-500` : tw`border-gray-300`
|
||||||
/>
|
]}>
|
||||||
{formik.errors.slotId && formik.touched.slotId && (
|
{formik.values.isPermanent && (
|
||||||
<MyText style={tw`text-red-500 text-sm mt-1`}>{formik.errors.slotId}</MyText>
|
<MyText style={tw`text-white text-center text-lg`}>✓</MyText>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-gray-700 font-medium`}>Is Permanent?</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<MyText style={tw`text-sm text-gray-500`}>
|
||||||
|
Check if this snippet is permanent and not tied to a specific delivery slot
|
||||||
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Slot Selection - Only show if not permanent */}
|
||||||
|
{!formik.values.isPermanent && (
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-gray-700 font-medium mb-2`}>Delivery Slot</MyText>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Select Slot"
|
||||||
|
value={formik.values.slotId}
|
||||||
|
options={slotOptions}
|
||||||
|
onValueChange={(value) => formik.setFieldValue('slotId', value)}
|
||||||
|
placeholder="Choose a delivery slot"
|
||||||
|
/>
|
||||||
|
{formik.errors.slotId && formik.touched.slotId && (
|
||||||
|
<MyText style={tw`text-red-500 text-sm mt-1`}>{formik.errors.slotId}</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Product Selection */}
|
{/* Product Selection */}
|
||||||
<View>
|
<View>
|
||||||
<MyText style={tw`text-gray-700 font-medium mb-2`}>Products</MyText>
|
<MyText style={tw`text-gray-700 font-medium mb-2`}>Products</MyText>
|
||||||
|
|
|
||||||
34
apps/admin-ui/types/vendor-snippets.ts
Normal file
34
apps/admin-ui/types/vendor-snippets.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
export interface VendorSnippetProduct {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorSnippet {
|
||||||
|
id: number;
|
||||||
|
snippetCode: string;
|
||||||
|
slotId: number | null;
|
||||||
|
isPermanent: boolean;
|
||||||
|
productIds: number[];
|
||||||
|
products: VendorSnippetProduct[];
|
||||||
|
validTill: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
accessUrl?: string;
|
||||||
|
slot?: {
|
||||||
|
id: number;
|
||||||
|
deliveryTime: string;
|
||||||
|
freezeTime: string;
|
||||||
|
isActive: boolean;
|
||||||
|
deliverySequence?: unknown;
|
||||||
|
isFlash?: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorSnippetForm {
|
||||||
|
id: number;
|
||||||
|
snippetCode: string;
|
||||||
|
slotId: number;
|
||||||
|
isPermanent: boolean;
|
||||||
|
productIds: number[];
|
||||||
|
validTill: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
2
apps/backend/drizzle/0067_messy_earthquake.sql
Normal file
2
apps/backend/drizzle/0067_messy_earthquake.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "mf"."vendor_snippets" ALTER COLUMN "slot_id" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "mf"."vendor_snippets" ADD COLUMN "is_permanent" boolean DEFAULT false NOT NULL;
|
||||||
3600
apps/backend/drizzle/meta/0067_snapshot.json
Normal file
3600
apps/backend/drizzle/meta/0067_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -470,6 +470,13 @@
|
||||||
"when": 1768710657343,
|
"when": 1768710657343,
|
||||||
"tag": "0066_gorgeous_karnak",
|
"tag": "0066_gorgeous_karnak",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 67,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769280779210,
|
||||||
|
"tag": "0067_messy_earthquake",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +194,8 @@ export const deliverySlotInfo = mf.table('delivery_slot_info', {
|
||||||
export const vendorSnippets = mf.table('vendor_snippets', {
|
export const vendorSnippets = mf.table('vendor_snippets', {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
snippetCode: varchar('snippet_code', { length: 255 }).notNull().unique(),
|
snippetCode: varchar('snippet_code', { length: 255 }).notNull().unique(),
|
||||||
slotId: integer('slot_id').notNull().references(() => deliverySlotInfo.id),
|
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
|
||||||
|
isPermanent: boolean('is_permanent').notNull().default(false),
|
||||||
productIds: integer('product_ids').array().notNull(),
|
productIds: integer('product_ids').array().notNull(),
|
||||||
validTill: timestamp('valid_till'),
|
validTill: timestamp('valid_till'),
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@ import { router, publicProcedure, protectedProcedure } from '../trpc-index';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db } from '../../db/db_index';
|
import { db } from '../../db/db_index';
|
||||||
import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users } from '../../db/schema';
|
import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users } from '../../db/schema';
|
||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray, isNotNull, gt, sql, asc } from 'drizzle-orm';
|
||||||
import { appUrl } from '../../lib/env-exporter';
|
import { appUrl } from '../../lib/env-exporter';
|
||||||
|
|
||||||
const createSnippetSchema = z.object({
|
const createSnippetSchema = z.object({
|
||||||
snippetCode: z.string().min(1, "Snippet code is required"),
|
snippetCode: z.string().min(1, "Snippet code is required"),
|
||||||
slotId: z.number().int().positive("Valid slot ID is required"),
|
slotId: z.number().optional(),
|
||||||
productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"),
|
productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"),
|
||||||
validTill: z.string().optional(),
|
validTill: z.string().optional(),
|
||||||
|
isPermanent: z.boolean().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateSnippetSchema = z.object({
|
const updateSnippetSchema = z.object({
|
||||||
|
|
@ -17,6 +18,7 @@ const updateSnippetSchema = z.object({
|
||||||
updates: createSnippetSchema.partial().extend({
|
updates: createSnippetSchema.partial().extend({
|
||||||
snippetCode: z.string().min(1).optional(),
|
snippetCode: z.string().min(1).optional(),
|
||||||
productIds: z.array(z.number().int().positive()).optional(),
|
productIds: z.array(z.number().int().positive()).optional(),
|
||||||
|
isPermanent: z.boolean().default(false)
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -24,20 +26,22 @@ export const vendorSnippetsRouter = router({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(createSnippetSchema)
|
.input(createSnippetSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { snippetCode, slotId, productIds, validTill } = input;
|
const { snippetCode, slotId, productIds, validTill, isPermanent } = input;
|
||||||
|
|
||||||
// Get staff user ID from auth middleware
|
// Get staff user ID from auth middleware
|
||||||
const staffUserId = ctx.staffUser?.id;
|
const staffUserId = ctx.staffUser?.id;
|
||||||
if (!staffUserId) {
|
if (!staffUserId) {
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate slot exists
|
// Validate slot exists
|
||||||
const slot = await db.query.deliverySlotInfo.findFirst({
|
if(slotId) {
|
||||||
where: eq(deliverySlotInfo.id, slotId),
|
const slot = await db.query.deliverySlotInfo.findFirst({
|
||||||
});
|
where: eq(deliverySlotInfo.id, slotId),
|
||||||
if (!slot) {
|
});
|
||||||
throw new Error("Invalid slot ID");
|
if (!slot) {
|
||||||
|
throw new Error("Invalid slot ID");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate products exist
|
// Validate products exist
|
||||||
|
|
@ -60,6 +64,7 @@ export const vendorSnippetsRouter = router({
|
||||||
snippetCode,
|
snippetCode,
|
||||||
slotId,
|
slotId,
|
||||||
productIds,
|
productIds,
|
||||||
|
isPermanent,
|
||||||
validTill: validTill ? new Date(validTill) : undefined,
|
validTill: validTill ? new Date(validTill) : undefined,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
|
|
@ -71,17 +76,29 @@ export const vendorSnippetsRouter = router({
|
||||||
console.log('from the vendor snipptes methods')
|
console.log('from the vendor snipptes methods')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const result = await db.query.vendorSnippets.findMany({
|
const result = await db.query.vendorSnippets.findMany({
|
||||||
with: {
|
with: {
|
||||||
slot: true,
|
slot: true,
|
||||||
},
|
},
|
||||||
orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)],
|
orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)],
|
||||||
});
|
});
|
||||||
return result.map(snippet => ({
|
|
||||||
...snippet,
|
const snippetsWithProducts = await Promise.all(
|
||||||
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`
|
result.map(async (snippet) => {
|
||||||
}));
|
const products = await db.query.productInfo.findMany({
|
||||||
|
where: inArray(productInfo.id, snippet.productIds),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...snippet,
|
||||||
|
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`,
|
||||||
|
products: products.map(p => ({ id: p.id, name: p.name })),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return snippetsWithProducts;
|
||||||
}
|
}
|
||||||
catch(e) {
|
catch(e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
|
|
@ -112,7 +129,8 @@ export const vendorSnippetsRouter = router({
|
||||||
.input(updateSnippetSchema)
|
.input(updateSnippetSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { id, updates } = input;
|
const { id, updates } = input;
|
||||||
|
console.log({updates})
|
||||||
|
|
||||||
// Check if snippet exists
|
// Check if snippet exists
|
||||||
const existingSnippet = await db.query.vendorSnippets.findFirst({
|
const existingSnippet = await db.query.vendorSnippets.findFirst({
|
||||||
where: eq(vendorSnippets.id, id),
|
where: eq(vendorSnippets.id, id),
|
||||||
|
|
@ -208,7 +226,7 @@ export const vendorSnippetsRouter = router({
|
||||||
// Query orders that match the snippet criteria
|
// Query orders that match the snippet criteria
|
||||||
const matchingOrders = await db.query.orders.findMany({
|
const matchingOrders = await db.query.orders.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(orders.slotId, snippet.slotId),
|
eq(orders.slotId, snippet.slotId!),
|
||||||
// We'll filter by products in the application logic
|
// We'll filter by products in the application logic
|
||||||
),
|
),
|
||||||
with: {
|
with: {
|
||||||
|
|
@ -277,6 +295,7 @@ export const vendorSnippetsRouter = router({
|
||||||
productIds: snippet.productIds,
|
productIds: snippet.productIds,
|
||||||
validTill: snippet.validTill?.toISOString(),
|
validTill: snippet.validTill?.toISOString(),
|
||||||
createdAt: snippet.createdAt.toISOString(),
|
createdAt: snippet.createdAt.toISOString(),
|
||||||
|
isPermanent: snippet.isPermanent,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
@ -312,6 +331,134 @@ export const vendorSnippetsRouter = router({
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getUpcomingSlots: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const slots = await db.query.deliverySlotInfo.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(deliverySlotInfo.isActive, true),
|
||||||
|
gt(deliverySlotInfo.deliveryTime, now)
|
||||||
|
),
|
||||||
|
orderBy: asc(deliverySlotInfo.deliveryTime),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: slots.map(slot => ({
|
||||||
|
id: slot.id,
|
||||||
|
deliveryTime: slot.deliveryTime.toISOString(),
|
||||||
|
freezeTime: slot.freezeTime.toISOString(),
|
||||||
|
deliverySequence: slot.deliverySequence,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getOrdersBySnippetAndSlot: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
snippetCode: z.string().min(1, "Snippet code is required"),
|
||||||
|
slotId: z.number().int().positive("Valid slot ID is required"),
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { snippetCode, slotId } = input;
|
||||||
|
|
||||||
|
// Find the snippet
|
||||||
|
const snippet = await db.query.vendorSnippets.findFirst({
|
||||||
|
where: eq(vendorSnippets.snippetCode, snippetCode),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!snippet) {
|
||||||
|
throw new Error("Vendor snippet not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the slot
|
||||||
|
const slot = await db.query.deliverySlotInfo.findFirst({
|
||||||
|
where: eq(deliverySlotInfo.id, slotId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!slot) {
|
||||||
|
throw new Error("Slot not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query orders that match the slot and snippet criteria
|
||||||
|
const matchingOrders = await db.query.orders.findMany({
|
||||||
|
where: eq(orders.slotId, slotId),
|
||||||
|
with: {
|
||||||
|
orderItems: {
|
||||||
|
with: {
|
||||||
|
product: {
|
||||||
|
with: {
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: true,
|
||||||
|
slot: true,
|
||||||
|
},
|
||||||
|
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter orders that contain at least one of the snippet's products
|
||||||
|
const filteredOrders = matchingOrders.filter(order => {
|
||||||
|
const orderProductIds = order.orderItems.map(item => item.productId);
|
||||||
|
return snippet.productIds.some(productId => orderProductIds.includes(productId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format the response
|
||||||
|
const formattedOrders = filteredOrders.map(order => {
|
||||||
|
// Filter orderItems to only include products attached to the snippet
|
||||||
|
const attachedOrderItems = order.orderItems.filter(item =>
|
||||||
|
snippet.productIds.includes(item.productId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const products = attachedOrderItems.map(item => ({
|
||||||
|
orderItemId: item.id,
|
||||||
|
productId: item.productId,
|
||||||
|
productName: item.product.name,
|
||||||
|
quantity: parseFloat(item.quantity),
|
||||||
|
price: parseFloat(item.price.toString()),
|
||||||
|
unit: item.product.unit?.shortNotation || 'unit',
|
||||||
|
subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity),
|
||||||
|
is_packaged: item.is_packaged,
|
||||||
|
is_package_verified: item.is_package_verified,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderId: `ORD${order.readableId.toString().padStart(3, '0')}`,
|
||||||
|
orderDate: order.createdAt.toISOString(),
|
||||||
|
customerName: order.user.name,
|
||||||
|
totalAmount: order.totalAmount,
|
||||||
|
slotInfo: order.slot ? {
|
||||||
|
time: order.slot.deliveryTime.toISOString(),
|
||||||
|
sequence: order.slot.deliverySequence,
|
||||||
|
} : null,
|
||||||
|
products,
|
||||||
|
matchedProducts: snippet.productIds,
|
||||||
|
snippetCode: snippet.snippetCode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: formattedOrders,
|
||||||
|
snippet: {
|
||||||
|
id: snippet.id,
|
||||||
|
snippetCode: snippet.snippetCode,
|
||||||
|
slotId: snippet.slotId,
|
||||||
|
productIds: snippet.productIds,
|
||||||
|
validTill: snippet.validTill?.toISOString(),
|
||||||
|
createdAt: snippet.createdAt.toISOString(),
|
||||||
|
isPermanent: snippet.isPermanent,
|
||||||
|
},
|
||||||
|
selectedSlot: {
|
||||||
|
id: slot.id,
|
||||||
|
deliveryTime: slot.deliveryTime.toISOString(),
|
||||||
|
freezeTime: slot.freezeTime.toISOString(),
|
||||||
|
deliverySequence: slot.deliverySequence,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
updateOrderItemPackaging: publicProcedure
|
updateOrderItemPackaging: publicProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
orderItemId: z.number().int().positive("Valid order item ID required"),
|
orderItemId: z.number().int().positive("Valid order item ID required"),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState, useEffect } from 'react'
|
||||||
import { useSearch } from '@tanstack/react-router'
|
import { useSearch } from '@tanstack/react-router'
|
||||||
import { trpc } from '../trpc/client'
|
import { trpc } from '../trpc/client'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
@ -20,12 +20,48 @@ interface VendorOrder {
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeliverySlot {
|
||||||
|
id: number
|
||||||
|
deliveryTime: string
|
||||||
|
freezeTime: string
|
||||||
|
deliverySequence: any
|
||||||
|
}
|
||||||
|
|
||||||
export function VendorOrderListRoute() {
|
export function VendorOrderListRoute() {
|
||||||
const { id } = useSearch({ from: '/vendor-order-list' })
|
const { id } = useSearch({ from: '/vendor-order-list' })
|
||||||
|
|
||||||
const { data, error, isLoading, isFetching, refetch } = id
|
// Fetch snippet info
|
||||||
|
const { data: snippetInfo, isLoading: isLoadingSnippet } = id
|
||||||
? trpc.admin.vendorSnippets.getOrdersBySnippet.useQuery({ snippetCode: id })
|
? trpc.admin.vendorSnippets.getOrdersBySnippet.useQuery({ snippetCode: id })
|
||||||
: { data: null, error: null, isLoading: false, isFetching: false, refetch: () => {} }
|
: { data: null, isLoading: false }
|
||||||
|
const snippet = snippetInfo?.snippet
|
||||||
|
const isPermanent = snippet?.isPermanent
|
||||||
|
|
||||||
|
const { data: upcomingSlots } = trpc.admin.vendorSnippets.getUpcomingSlots.useQuery(undefined, { enabled: !!id })
|
||||||
|
|
||||||
|
// State for selected slot
|
||||||
|
const [selectedSlotId, setSelectedSlotId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Auto-select first slot when snippets are loaded and isPermanent is true
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPermanent && upcomingSlots?.data && upcomingSlots.data.length > 0 && !selectedSlotId) {
|
||||||
|
setSelectedSlotId(upcomingSlots.data[0].id)
|
||||||
|
}
|
||||||
|
}, [isPermanent, upcomingSlots, selectedSlotId])
|
||||||
|
|
||||||
|
// Fetch orders based on mode
|
||||||
|
const { data: slotOrdersData, error, isLoading: isLoadingOrders, isFetching, refetch } = trpc.admin.vendorSnippets.getOrdersBySnippetAndSlot.useQuery(
|
||||||
|
{ snippetCode: id!, slotId: selectedSlotId! },
|
||||||
|
{ enabled: !!id && !!selectedSlotId && isPermanent }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: regularOrders } = trpc.admin.vendorSnippets.getOrdersBySnippet.useQuery(
|
||||||
|
{ snippetCode: id! },
|
||||||
|
{ enabled: !!id && !isPermanent }
|
||||||
|
)
|
||||||
|
|
||||||
|
const orders = slotOrdersData?.data || regularOrders?.data || []
|
||||||
|
const isLoadingCurrent = isPermanent ? isLoadingOrders : isLoadingSnippet
|
||||||
|
|
||||||
const updatePackagingMutation = trpc.admin.vendorSnippets.updateOrderItemPackaging.useMutation()
|
const updatePackagingMutation = trpc.admin.vendorSnippets.updateOrderItemPackaging.useMutation()
|
||||||
|
|
||||||
|
|
@ -52,8 +88,6 @@ export function VendorOrderListRoute() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const orders = data?.success ? data.data : []
|
|
||||||
|
|
||||||
|
|
||||||
const productSummary = useMemo(() => {
|
const productSummary = useMemo(() => {
|
||||||
const summary: Record<string, { quantity: number; unit: string }> = {};
|
const summary: Record<string, { quantity: number; unit: string }> = {};
|
||||||
|
|
@ -98,6 +132,25 @@ export function VendorOrderListRoute() {
|
||||||
{id && <span className="block mt-1 text-xs">Snippet: {id}</span>}
|
{id && <span className="block mt-1 text-xs">Snippet: {id}</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{isPermanent && upcomingSlots?.data && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="slot-select" className="text-sm font-medium text-slate-700">
|
||||||
|
Select Slot:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="slot-select"
|
||||||
|
value={selectedSlotId || ''}
|
||||||
|
onChange={(e) => setSelectedSlotId(Number(e.target.value))}
|
||||||
|
className="base-select px-3 py-2 text-sm border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{upcomingSlots.data.map((slot) => (
|
||||||
|
<option key={slot.id} value={slot.id}>
|
||||||
|
{new Date(slot.deliveryTime).toLocaleDateString()} {new Date(slot.deliveryTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -109,11 +162,11 @@ export function VendorOrderListRoute() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{isLoading ? (
|
{isLoadingCurrent ? (
|
||||||
<div className="rounded-xl border border-slate-100 bg-slate-50 p-6 text-sm text-slate-600">
|
<div className="rounded-xl border border-slate-100 bg-slate-50 p-6 text-sm text-slate-600">
|
||||||
Loading orders…
|
Loading orders…
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||||
{error.message ?? 'Unable to load vendor orders right now'}
|
{error.message ?? 'Unable to load vendor orders right now'}
|
||||||
|
|
@ -204,7 +257,7 @@ export function VendorOrderListRoute() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFetching && !isLoading ? (
|
{isFetching && !isLoadingCurrent ? (
|
||||||
<p className="mt-4 text-xs text-slate-500">Refreshing…</p>
|
<p className="mt-4 text-xs text-slate-500">Refreshing…</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue