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.
140 lines
5.0 KiB
TypeScript
140 lines
5.0 KiB
TypeScript
"use client";
|
|
import { useEffect, useState } from "react";
|
|
import { fetchClient } from "@/lib/api-client";
|
|
|
|
interface Vendor {
|
|
_id: string;
|
|
username: string;
|
|
isAdmin: boolean;
|
|
createdAt: 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() {
|
|
const [vendors, setVendors] = useState<Vendor[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [resetTokens, setResetTokens] = useState<Record<string, string>>({});
|
|
const [page, setPage] = useState(1);
|
|
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await fetchClient<PaginationResponse>(`/admin/vendors?page=${page}&limit=10`);
|
|
if (mounted) {
|
|
setVendors(data.vendors);
|
|
setPagination(data.pagination);
|
|
}
|
|
} catch (e: any) {
|
|
if (mounted) setError(e?.message || "Failed to load vendors");
|
|
} finally {
|
|
if (mounted) setLoading(false);
|
|
}
|
|
})();
|
|
return () => { mounted = false; };
|
|
}, [page]);
|
|
|
|
async function generateResetToken(vendorId: string) {
|
|
try {
|
|
const res = await fetchClient<{ token: string; expiresAt: string }>("/admin/password-reset-token", {
|
|
method: "POST",
|
|
body: { vendorId }
|
|
});
|
|
setResetTokens(prev => ({ ...prev, [vendorId]: res.token }));
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to generate reset token");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
|
|
<h2 className="font-medium">Vendors</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">Manage vendor accounts and access</p>
|
|
|
|
{loading ? (
|
|
<p className="text-sm text-muted-foreground mt-3">Loading...</p>
|
|
) : error ? (
|
|
<p className="text-sm text-muted-foreground mt-3">{error}</p>
|
|
) : vendors.length === 0 ? (
|
|
<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 key={vendor._id} className="rounded border border-border/50 p-3 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<div className="font-medium">{vendor.username}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Created: {new Date(vendor.createdAt).toLocaleDateString()}
|
|
{vendor.lastLogin && ` · Last login: ${new Date(vendor.lastLogin).toLocaleDateString()}`}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{vendor.isAdmin && (
|
|
<span className="text-xs px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400">
|
|
Admin
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => generateResetToken(vendor._id)}
|
|
className="text-xs px-2 py-1 rounded border border-border hover:bg-muted/40"
|
|
>
|
|
Reset Password
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{resetTokens[vendor._id] && (
|
|
<div className="mt-2 p-2 rounded bg-muted/40 text-xs">
|
|
<div className="font-mono break-all">{resetTokens[vendor._id]}</div>
|
|
<div className="text-muted-foreground mt-1">Copy this token to share with the vendor</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>
|
|
);
|
|
}
|