This commit is contained in:
shafi54 2026-01-25 02:05:09 +05:30
parent 3e02f5ebd0
commit 3ec9cf8795
11 changed files with 4235 additions and 183 deletions

View file

@ -1,56 +1,68 @@
import React, { useState } from 'react';
import { View, ScrollView, 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 React, { useState } from "react";
import {
View,
ScrollView,
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 dayjs from "dayjs";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from 'expo-router';
import { useRouter } from "expo-router";
import VendorSnippetForm from '../../../components/VendorSnippetForm';
import SnippetOrdersView from '../../../components/SnippetOrdersView';
import { SnippetMenu } from '../../../components/SnippetMenu';
interface VendorSnippet {
id: number;
snippetCode: string;
slotId: number;
productIds: number[];
validTill: string | null;
createdAt: string;
accessUrl: string;
slot?: {
id: number;
deliveryTime: string;
freezeTime: string;
isActive: boolean;
deliverySequence?: unknown;
};
}
import VendorSnippetForm from "../../../components/VendorSnippetForm";
import SnippetOrdersView from "../../../components/SnippetOrdersView";
import { SnippetMenu } from "../../../components/SnippetMenu";
import { ProductListDialog } from "../../../components/ProductListDialog";
import {
VendorSnippet,
VendorSnippetProduct,
VendorSnippetForm as VendorSnippetFormType,
} from "../../../types/vendor-snippets";
const SnippetItem = ({
snippet,
onEdit,
onDelete,
onViewOrders,
index
onViewProducts,
index,
}: {
snippet: VendorSnippet;
onEdit: (snippet: VendorSnippet) => void;
onDelete: (id: number) => void;
onViewOrders: (snippetCode: string) => void;
onViewProducts: (products: VendorSnippetProduct[]) => void;
index: number;
}) => {
const isExpired = snippet.validTill && dayjs(snippet.validTill).isBefore(dayjs());
const isExpired =
snippet.validTill && dayjs(snippet.validTill).isBefore(dayjs());
const handleCopyLink = async () => {
try {
if (!snippet.accessUrl) {
Alert.alert("Error", "No link available");
return;
}
await Share.share({
message: snippet.accessUrl,
});
} 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 */}
<View style={tw`flex-row justify-between items-start mb-6`}>
<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`}>
<MaterialIcons name="fingerprint" size={24} color={theme.colors.brand600} />
<View
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 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
</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}
</MyText>
</View>
</View>
<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 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'}
<View
style={[
tw`px-3 py-1.5 rounded-full flex-row items-center mr-2`,
{ 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>
</View>
<SnippetMenu
@ -90,23 +128,56 @@ const SnippetItem = ({
</View>
{/* Middle: Delivery Banner */}
<View 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`}>
<View
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" />
</View>
<View style={tw`ml-4 flex-1`}>
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
{snippet.slot?.deliveryTime ? dayjs(snippet.slot.deliveryTime).format('ddd, MMM DD') : 'Schedule Pending'}
</MyText>
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
Time: {snippet.slot?.deliveryTime ? dayjs(snippet.slot.deliveryTime).format('hh:mm A') : 'N/A'}
</MyText>
{snippet.isPermanent ? (
<>
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
Permanent
</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>
{snippet.validTill && (
<View style={tw`items-end`}>
<MyText style={tw`text-slate-400 text-[9px] font-bold uppercase`}>Expires</MyText>
<MyText style={[tw`text-xs font-black`, { color: isExpired ? '#E11D48' : '#64748B' }]}>
{dayjs(snippet.validTill).format('MMM DD')}
<MyText style={tw`text-slate-400 text-[9px] font-bold uppercase`}>
Expires
</MyText>
<MyText
style={[
tw`text-xs font-black`,
{ color: isExpired ? "#E11D48" : "#64748B" },
]}
>
{dayjs(snippet.validTill).format("MMM DD")}
</MyText>
</View>
)}
@ -115,13 +186,28 @@ const SnippetItem = ({
{/* Stats & Actions */}
<View style={tw`flex-row items-center justify-between`}>
<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" />
<MyText style={tw`text-xs font-bold text-slate-500 ml-1.5`}>{snippet.productIds.length} Items</MyText>
</View>
<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>
<MyText style={tw`text-xs font-bold text-slate-500 ml-1.5`}>
{snippet.productIds.length} Items
</MyText>
<MaterialIcons name="chevron-right" size={14} color="#94A3B8" />
</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>
</View>
@ -131,7 +217,11 @@ const SnippetItem = ({
activeOpacity={0.7}
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" />
</MyTouchableOpacity>
</View>
@ -142,7 +232,12 @@ const SnippetItem = ({
export default function VendorSnippets() {
// 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 updateSnippet = trpc.admin.vendorSnippets.update.useMutation();
@ -151,41 +246,64 @@ export default function VendorSnippets() {
// const updateSnippet = useUpdateVendorSnippet();
// const deleteSnippet = useDeleteVendorSnippet();
const router = useRouter();
const router = useRouter();
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<VendorSnippet | null>(null);
const [showOrdersView, setShowOrdersView] = useState(false);
const [ordersData, setOrdersData] = useState<any>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingSnippet, setEditingSnippet] =
useState<VendorSnippetFormType | null>(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(() => {
refetch();
});
const handleCreate = () => {
setShowCreateForm(true);
setEditingSnippet(null);
};
const handleCreate = () => {
setShowCreateForm(true);
setEditingSnippet(null);
};
const handleViewProducts = (products: VendorSnippetProduct[]) => {
setSelectedProducts(products);
setShowProductList(true);
};
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);
};
const handleDelete = (id: number) => {
deleteSnippet.mutate({ id }, {
onSuccess: () => {
refetch();
}
});
deleteSnippet.mutate(
{ id },
{
onSuccess: () => {
refetch();
},
},
);
};
const handleViewOrders = async (snippetCode: string) => {
try {
const result = await trpcClient.admin.vendorSnippets.getOrdersBySnippet.query({ snippetCode });
const result =
await trpcClient.admin.vendorSnippets.getOrdersBySnippet.query({
snippetCode,
});
if (result.success) {
setOrdersData({
orders: result.data,
@ -193,10 +311,10 @@ export default function VendorSnippets() {
});
setShowOrdersView(true);
} else {
Alert.alert('Error', 'Failed to fetch orders');
Alert.alert("Error", "Failed to fetch orders");
}
} 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 (
<AppContainer>
<View style={tw`flex-1 bg-white relative`}>
<ScrollView
style={tw`flex-1`}
contentContainerStyle={tw` pt-2 pb-32`}
showsVerticalScrollIndicator={false}
>
{snippets && snippets.length === 0 ? (
<View style={tw`flex-1 justify-center items-center py-20`}>
<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" />
<>
<AppContainer>
<View style={tw`flex-1 bg-white h-full`}>
<ScrollView
style={tw`flex-1`}
contentContainerStyle={tw` pt-2 pb-32`}
showsVerticalScrollIndicator={false}
>
{snippets && snippets.length === 0 ? (
<View style={tw`flex-1 justify-center items-center py-20`}>
<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>
<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>
) : (
snippets?.map((snippet, index) => (
<React.Fragment key={snippet.id}>
<SnippetItem
) : (
snippets?.map((snippet, index) => (
<React.Fragment key={snippet.id}>
<SnippetItem
snippet={snippet}
index={index}
onEdit={handleEdit}
onDelete={handleDelete}
onViewOrders={handleViewOrders}
onViewProducts={handleViewProducts}
/>
{index < snippets.length - 1 && (
<View style={tw`h-px bg-slate-200 w-full`} />
)}
</React.Fragment>
))
)}
</ScrollView>
{index < snippets.length - 1 && (
<View style={tw`h-px bg-slate-200 w-full`} />
)}
</React.Fragment>
))
)}
</ScrollView>
{/* Global Floating Action Button */}
<MyTouchableOpacity
onPress={handleCreate}
activeOpacity={0.95}
style={tw`absolute bottom-8 right-6 shadow-2xl z-50`}
{/* Global Floating Action Button - Fixed Position */}
</View>
</AppContainer>
<MyTouchableOpacity
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
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`}
>
<MaterialIcons name="add" size={32} color="white" />
</LinearGradient>
</MyTouchableOpacity>
</View>
</AppContainer>
<MaterialIcons name="add" size={32} color="white" />
</LinearGradient>
</MyTouchableOpacity>
<ProductListDialog
open={showProductList}
onClose={() => setShowProductList(false)}
products={selectedProducts || []}
/>
</>
);
}

View 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>
);
};

View file

@ -3,6 +3,7 @@ import { View, TouchableOpacity, Alert, Clipboard } from 'react-native';
import { Entypo } from '@expo/vector-icons';
import { MyText, tw, BottomDialog } from 'common-ui';
import { SuccessToast } from '../services/toaster';
import { VendorSnippet } from '../types/vendor-snippets';
export interface SnippetMenuOption {
title: string;
@ -12,12 +13,8 @@ export interface SnippetMenuOption {
}
export interface SnippetMenuProps {
snippet: {
id: number;
snippetCode: string;
accessUrl: string;
};
onEdit: (snippet: any) => void;
snippet: VendorSnippet;
onEdit: (snippet: VendorSnippet) => void;
onDelete: (id: number) => void;
onViewOrders: (snippetCode: string) => void;
triggerStyle?: any;
@ -72,6 +69,10 @@ export const SnippetMenu: React.FC<SnippetMenuProps> = ({
const handleCopyUrl = async () => {
setIsOpen(false);
if (!snippet.accessUrl) {
Alert.alert('Error', 'No URL available to copy');
return;
}
await Clipboard.setString(snippet.accessUrl);
SuccessToast('URL copied to clipboard');
};

View file

@ -4,27 +4,20 @@ import { useFormik } from 'formik';
import { MyText, tw, DatePicker, MyTextInput } from 'common-ui';
import BottomDropdown from 'common-ui/src/components/bottom-dropdown';
import { trpc } from '../src/trpc-client';
interface VendorSnippet {
id: number;
snippetCode: string;
slotId: number;
productIds: number[];
validTill: string | null;
createdAt: string;
}
import { VendorSnippetForm as VendorSnippetFormType, VendorSnippet } from '../types/vendor-snippets';
interface VendorSnippetFormProps {
snippet?: VendorSnippet | null;
snippet?: VendorSnippetFormType | null;
onClose: () => void;
onSuccess: () => void;
showIsPermanentCheckbox?: boolean;
}
const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
snippet,
onClose,
onSuccess,
showIsPermanentCheckbox = false,
}) => {
// Fetch slots and products
@ -40,6 +33,7 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
initialValues: {
snippetCode: snippet?.snippetCode || '',
slotId: snippet?.slotId?.toString() || '',
isPermanent: snippet?.isPermanent || false,
productIds: snippet?.productIds?.map(id => id.toString()) || [],
validTill: snippet?.validTill ? new Date(snippet.validTill) : null,
},
@ -50,7 +44,14 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
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';
}
@ -62,11 +63,14 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
},
onSubmit: async (values) => {
try {
console.log({values})
const submitData = {
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)),
validTill: values.validTill?.toISOString(),
validTill: values.validTill ? values.validTill.toISOString() : undefined,
};
if (isEditing && snippet) {
@ -93,7 +97,7 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
if (!isEditing && !formik.values.snippetCode) {
const timestamp = Date.now();
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
@ -146,21 +150,44 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
)}
</View>
{/* Slot Selection */}
{/* Is Permanent Checkbox */}
<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>
)}
<TouchableOpacity
onPress={() => formik.setFieldValue('isPermanent', !formik.values.isPermanent)}
style={tw`flex-row items-center mb-2`}
>
<View style={[
tw`w-6 h-6 border-2 rounded-md mr-3 justify-center items-center`,
formik.values.isPermanent ? tw`bg-blue-500 border-blue-500` : tw`border-gray-300`
]}>
{formik.values.isPermanent && (
<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>
{/* 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 */}
<View>
<MyText style={tw`text-gray-700 font-medium mb-2`}>Products</MyText>

View 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;
}

View 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;

File diff suppressed because it is too large Load diff

View file

@ -470,6 +470,13 @@
"when": 1768710657343,
"tag": "0066_gorgeous_karnak",
"breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1769280779210,
"tag": "0067_messy_earthquake",
"breakpoints": true
}
]
}

View file

@ -194,7 +194,8 @@ export const deliverySlotInfo = mf.table('delivery_slot_info', {
export const vendorSnippets = mf.table('vendor_snippets', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
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(),
validTill: timestamp('valid_till'),
createdAt: timestamp('created_at').notNull().defaultNow(),

View file

@ -2,14 +2,15 @@ import { router, publicProcedure, protectedProcedure } from '../trpc-index';
import { z } from 'zod';
import { db } from '../../db/db_index';
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';
const createSnippetSchema = z.object({
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"),
validTill: z.string().optional(),
isPermanent: z.boolean().default(false)
});
const updateSnippetSchema = z.object({
@ -17,6 +18,7 @@ const updateSnippetSchema = z.object({
updates: createSnippetSchema.partial().extend({
snippetCode: z.string().min(1).optional(),
productIds: z.array(z.number().int().positive()).optional(),
isPermanent: z.boolean().default(false)
}),
});
@ -24,7 +26,7 @@ export const vendorSnippetsRouter = router({
create: protectedProcedure
.input(createSnippetSchema)
.mutation(async ({ input, ctx }) => {
const { snippetCode, slotId, productIds, validTill } = input;
const { snippetCode, slotId, productIds, validTill, isPermanent } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
@ -33,11 +35,13 @@ export const vendorSnippetsRouter = router({
}
// Validate slot exists
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
if (!slot) {
throw new Error("Invalid slot ID");
if(slotId) {
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
if (!slot) {
throw new Error("Invalid slot ID");
}
}
// Validate products exist
@ -60,6 +64,7 @@ export const vendorSnippetsRouter = router({
snippetCode,
slotId,
productIds,
isPermanent,
validTill: validTill ? new Date(validTill) : undefined,
}).returning();
@ -71,17 +76,29 @@ export const vendorSnippetsRouter = router({
console.log('from the vendor snipptes methods')
try {
const result = await db.query.vendorSnippets.findMany({
with: {
slot: true,
},
orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)],
});
return result.map(snippet => ({
...snippet,
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`
}));
const snippetsWithProducts = await Promise.all(
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) {
console.log(e)
@ -112,6 +129,7 @@ export const vendorSnippetsRouter = router({
.input(updateSnippetSchema)
.mutation(async ({ input }) => {
const { id, updates } = input;
console.log({updates})
// Check if snippet exists
const existingSnippet = await db.query.vendorSnippets.findFirst({
@ -208,7 +226,7 @@ export const vendorSnippetsRouter = router({
// Query orders that match the snippet criteria
const matchingOrders = await db.query.orders.findMany({
where: and(
eq(orders.slotId, snippet.slotId),
eq(orders.slotId, snippet.slotId!),
// We'll filter by products in the application logic
),
with: {
@ -277,6 +295,7 @@ export const vendorSnippetsRouter = router({
productIds: snippet.productIds,
validTill: snippet.validTill?.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
.input(z.object({
orderItemId: z.number().int().positive("Valid order item ID required"),

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useMemo, useState, useEffect } from 'react'
import { useSearch } from '@tanstack/react-router'
import { trpc } from '../trpc/client'
import { cn } from '@/lib/utils'
@ -20,12 +20,48 @@ interface VendorOrder {
}>
}
interface DeliverySlot {
id: number
deliveryTime: string
freezeTime: string
deliverySequence: any
}
export function VendorOrderListRoute() {
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 })
: { 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()
@ -52,8 +88,6 @@ export function VendorOrderListRoute() {
}
}
const orders = data?.success ? data.data : []
const productSummary = useMemo(() => {
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>}
</p>
</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
variant="outline"
onClick={() => {
@ -109,11 +162,11 @@ export function VendorOrderListRoute() {
</Button>
</div>
<div className="mt-6">
{isLoading ? (
<div className="rounded-xl border border-slate-100 bg-slate-50 p-6 text-sm text-slate-600">
Loading orders
</div>
<div className="mt-6">
{isLoadingCurrent ? (
<div className="rounded-xl border border-slate-100 bg-slate-50 p-6 text-sm text-slate-600">
Loading orders
</div>
) : error ? (
<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'}
@ -204,7 +257,7 @@ export function VendorOrderListRoute() {
)}
</div>
{isFetching && !isLoading ? (
{isFetching && !isLoadingCurrent ? (
<p className="mt-4 text-xs text-slate-500">Refreshing</p>
) : null}
</div>