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 { 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`}>
|
||||
{snippet.isPermanent ? (
|
||||
<>
|
||||
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
|
||||
{snippet.slot?.deliveryTime ? dayjs(snippet.slot.deliveryTime).format('ddd, MMM DD') : 'Schedule Pending'}
|
||||
Permanent
|
||||
</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'}
|
||||
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();
|
||||
|
|
@ -154,10 +249,12 @@ export default function VendorSnippets() {
|
|||
const router = useRouter();
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<VendorSnippet | null>(null);
|
||||
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);
|
||||
|
||||
|
|
@ -170,22 +267,43 @@ export default function VendorSnippets() {
|
|||
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 }, {
|
||||
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,8 +369,9 @@ export default function VendorSnippets() {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 bg-white relative`}>
|
||||
<View style={tw`flex-1 bg-white h-full`}>
|
||||
<ScrollView
|
||||
style={tw`flex-1`}
|
||||
contentContainerStyle={tw` pt-2 pb-32`}
|
||||
|
|
@ -260,12 +379,21 @@ export default function VendorSnippets() {
|
|||
>
|
||||
{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`}>
|
||||
<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
|
||||
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>
|
||||
) : (
|
||||
|
|
@ -277,6 +405,7 @@ export default function VendorSnippets() {
|
|||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onViewOrders={handleViewOrders}
|
||||
onViewProducts={handleViewProducts}
|
||||
/>
|
||||
{index < snippets.length - 1 && (
|
||||
<View style={tw`h-px bg-slate-200 w-full`} />
|
||||
|
|
@ -286,14 +415,16 @@ export default function VendorSnippets() {
|
|||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Global Floating Action Button */}
|
||||
{/* 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']}
|
||||
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`}
|
||||
|
|
@ -301,7 +432,11 @@ export default function VendorSnippets() {
|
|||
<MaterialIcons name="add" size={32} color="white" />
|
||||
</LinearGradient>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
</AppContainer>
|
||||
<ProductListDialog
|
||||
open={showProductList}
|
||||
onClose={() => setShowProductList(false)}
|
||||
products={selectedProducts || []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 { 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');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,7 +150,29 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
|||
)}
|
||||
</View>
|
||||
|
||||
{/* Slot Selection */}
|
||||
{/* Is Permanent Checkbox */}
|
||||
<View>
|
||||
<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
|
||||
|
|
@ -160,6 +186,7 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
|||
<MyText style={tw`text-red-500 text-sm mt-1`}>{formik.errors.slotId}</MyText>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Product Selection */}
|
||||
<View>
|
||||
|
|
|
|||
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,
|
||||
"tag": "0066_gorgeous_karnak",
|
||||
"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', {
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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,12 +35,14 @@ export const vendorSnippetsRouter = router({
|
|||
}
|
||||
|
||||
// Validate slot exists
|
||||
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
|
||||
const products = await db.query.productInfo.findMany({
|
||||
|
|
@ -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 => ({
|
||||
|
||||
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}`
|
||||
}));
|
||||
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"),
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
@ -110,7 +163,7 @@ export function VendorOrderListRoute() {
|
|||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
{isLoadingCurrent ? (
|
||||
<div className="rounded-xl border border-slate-100 bg-slate-50 p-6 text-sm text-slate-600">
|
||||
Loading orders…
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue