Add pagination to admin user, vendor, and ban lists
Introduces pagination controls and server-side paginated fetching for blocked users, users, and vendors in the admin dashboard. Improves error handling in server API responses and validates order ID in OrderDetailsModal. Updates git-info.json with latest commit metadata.
This commit is contained in:
@@ -33,6 +33,15 @@ export default function AdminBanPage() {
|
|||||||
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
|
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
} | null>(null);
|
||||||
const [banData, setBanData] = useState({
|
const [banData, setBanData] = useState({
|
||||||
telegramUserId: "",
|
telegramUserId: "",
|
||||||
reason: "",
|
reason: "",
|
||||||
@@ -41,13 +50,25 @@ export default function AdminBanPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBlockedUsers();
|
fetchBlockedUsers();
|
||||||
}, []);
|
}, [page]);
|
||||||
|
|
||||||
const fetchBlockedUsers = async () => {
|
const fetchBlockedUsers = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchClient<BlockedUser[]>("/admin/blocked-users");
|
const data = await fetchClient<{
|
||||||
setBlockedUsers(data);
|
success: boolean;
|
||||||
|
blockedUsers: BlockedUser[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
};
|
||||||
|
}>(`/admin/blocked-users?page=${page}&limit=25`);
|
||||||
|
setBlockedUsers(data.blockedUsers);
|
||||||
|
setPagination(data.pagination);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch blocked users:", error);
|
console.error("Failed to fetch blocked users:", error);
|
||||||
toast({
|
toast({
|
||||||
@@ -395,6 +416,31 @@ export default function AdminBanPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between px-6 pb-6">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={!pagination.hasPrevPage}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={!pagination.hasNextPage}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ interface TelegramUser {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PaginationResponse {
|
||||||
|
success: boolean;
|
||||||
|
users: TelegramUser[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number): string {
|
function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-GB', {
|
return new Intl.NumberFormat('en-GB', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -37,16 +50,19 @@ export default function AdminUsersPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [users, setUsers] = useState<TelegramUser[]>([]);
|
const [users, setUsers] = useState<TelegramUser[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, []);
|
}, [page]);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchClient<TelegramUser[]>("/admin/users");
|
const data = await fetchClient<PaginationResponse>(`/admin/users?page=${page}&limit=25`);
|
||||||
setUsers(data);
|
setUsers(data.users);
|
||||||
|
setPagination(data.pagination);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to fetch users:", error);
|
console.error("Failed to fetch users:", error);
|
||||||
toast({
|
toast({
|
||||||
@@ -300,6 +316,31 @@ export default function AdminUsersPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing page {pagination.page} of {pagination.totalPages} ({pagination.total} total users)
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={!pagination.hasPrevPage}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={!pagination.hasNextPage}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,10 +157,21 @@ export default function OrderDetailsModal({ orderId, open, onOpenChange }: Order
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
// Validate orderId before making request
|
||||||
|
if (!orderId || orderId === 'undefined' || orderId === 'null') {
|
||||||
|
throw new Error('Order ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure orderId is a valid number or string
|
||||||
|
const orderIdStr = String(orderId).trim();
|
||||||
|
if (!orderIdStr || orderIdStr === 'undefined' || orderIdStr === 'null') {
|
||||||
|
throw new Error('Invalid order ID');
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch full order details from admin endpoint
|
// Fetch full order details from admin endpoint
|
||||||
// Use /admin/orders/:orderId (fetchClient will add /api prefix and backend URL)
|
// Use /admin/orders/:orderId (fetchClient will add /api prefix and backend URL)
|
||||||
console.log(`Fetching order details for order #${orderId}`);
|
console.log(`Fetching order details for order #${orderIdStr}`);
|
||||||
const orderData = await fetchClient<OrderDetails>(`/admin/orders/${orderId}`);
|
const orderData = await fetchClient<OrderDetails>(`/admin/orders/${orderIdStr}`);
|
||||||
|
|
||||||
console.log("Order data received:", orderData);
|
console.log("Order data received:", orderData);
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,37 @@ interface Vendor {
|
|||||||
lastLogin?: string;
|
lastLogin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PaginationResponse {
|
||||||
|
success: boolean;
|
||||||
|
vendors: Vendor[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function VendorsCard() {
|
export default function VendorsCard() {
|
||||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [resetTokens, setResetTokens] = useState<Record<string, string>>({});
|
const [resetTokens, setResetTokens] = useState<Record<string, string>>({});
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchClient<Vendor[]>("/admin/vendors");
|
setLoading(true);
|
||||||
if (mounted) setVendors(data);
|
const data = await fetchClient<PaginationResponse>(`/admin/vendors?page=${page}&limit=10`);
|
||||||
|
if (mounted) {
|
||||||
|
setVendors(data.vendors);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (mounted) setError(e?.message || "Failed to load vendors");
|
if (mounted) setError(e?.message || "Failed to load vendors");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -29,7 +48,7 @@ export default function VendorsCard() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, []);
|
}, [page]);
|
||||||
|
|
||||||
async function generateResetToken(vendorId: string) {
|
async function generateResetToken(vendorId: string) {
|
||||||
try {
|
try {
|
||||||
@@ -55,8 +74,9 @@ export default function VendorsCard() {
|
|||||||
) : vendors.length === 0 ? (
|
) : vendors.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground mt-3">No vendors found</p>
|
<p className="text-sm text-muted-foreground mt-3">No vendors found</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 space-y-2 max-h-64 overflow-y-auto">
|
<>
|
||||||
{vendors.map((vendor) => (
|
<div className="mt-3 space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{vendors.map((vendor) => (
|
||||||
<div key={vendor._id} className="rounded border border-border/50 p-3 text-sm">
|
<div key={vendor._id} className="rounded border border-border/50 p-3 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -88,7 +108,31 @@ export default function VendorsCard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={!pagination.hasPrevPage}
|
||||||
|
className="px-2 py-1 rounded border border-border hover:bg-muted/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={!pagination.hasNextPage}
|
||||||
|
className="px-2 py-1 rounded border border-border hover:bg-muted/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,8 +86,26 @@ export async function fetchServer<T = unknown>(
|
|||||||
|
|
||||||
// Handle other errors
|
// Handle other errors
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({}));
|
let errorData;
|
||||||
const errorMessage = errorData.message || errorData.error || `Request failed: ${res.status} ${res.statusText}`;
|
try {
|
||||||
|
errorData = await res.json();
|
||||||
|
} catch {
|
||||||
|
errorData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new error format: { success: false, error: { message: "...", code: "..." } }
|
||||||
|
// or old format: { error: "...", message: "..." }
|
||||||
|
let errorMessage: string;
|
||||||
|
if (errorData.error?.message) {
|
||||||
|
errorMessage = errorData.error.message;
|
||||||
|
} else if (typeof errorData.error === 'string') {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else {
|
||||||
|
errorMessage = `Request failed: ${res.status} ${res.statusText}`;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +114,24 @@ export async function fetchServer<T = unknown>(
|
|||||||
return {} as T;
|
return {} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await res.json();
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
return data;
|
||||||
|
} catch (parseError) {
|
||||||
|
// If JSON parsing fails, throw a proper error
|
||||||
|
throw new Error(`Failed to parse response from ${endpoint}: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Server request to ${endpoint} failed:`, error);
|
console.error(`Server request to ${endpoint} failed:`, error);
|
||||||
throw error;
|
// Ensure we always throw an Error instance, not an object
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
} else if (typeof error === 'string') {
|
||||||
|
throw new Error(error);
|
||||||
|
} else {
|
||||||
|
const errorStr = error && typeof error === 'object' ? JSON.stringify(error) : String(error);
|
||||||
|
throw new Error(`Request failed: ${errorStr}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "96638f9",
|
"commitHash": "0062aa2",
|
||||||
"buildTime": "2025-12-31T05:14:19.565Z"
|
"buildTime": "2025-12-31T05:39:04.712Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user