Add system status and invitations cards to admin page

Introduces SystemStatusCard and InvitationsListCard components to the admin dashboard for displaying system metrics and active invitations. Refactors InviteVendorCard to generate and display invitation codes, and updates card layouts for consistent sizing. Improves admin page structure and enhances visibility of system and invitation data.
This commit is contained in:
NotII
2025-10-15 17:40:54 +01:00
parent 4fb6d3f740
commit e7c06e4352
6 changed files with 170 additions and 46 deletions

View File

@@ -3,6 +3,8 @@ export const dynamic = "force-dynamic";
import InviteVendorCard from "@/components/admin/InviteVendorCard"; import InviteVendorCard from "@/components/admin/InviteVendorCard";
import BanUserCard from "@/components/admin/BanUserCard"; import BanUserCard from "@/components/admin/BanUserCard";
import RecentOrdersCard from "@/components/admin/RecentOrdersCard"; import RecentOrdersCard from "@/components/admin/RecentOrdersCard";
import SystemStatusCard from "@/components/admin/SystemStatusCard";
import InvitationsListCard from "@/components/admin/InvitationsListCard";
export default function AdminPage() { export default function AdminPage() {
return ( return (
@@ -12,18 +14,10 @@ export default function AdminPage() {
<p className="text-sm text-muted-foreground mt-1">Restricted area. Only admin1 can access.</p> <p className="text-sm text-muted-foreground mt-1">Restricted area. Only admin1 can access.</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 items-stretch">
<a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors"> <SystemStatusCard />
<div className="flex items-start justify-between">
<div>
<h2 className="font-medium">System status</h2>
<p className="text-sm text-muted-foreground mt-1">Uptime, versions, environment</p>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400">OK</span>
</div>
</a>
<a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors"> <a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors h-full min-h-[200px]">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h2 className="font-medium">Logs</h2> <h2 className="font-medium">Logs</h2>
@@ -36,6 +30,7 @@ export default function AdminPage() {
<InviteVendorCard /> <InviteVendorCard />
<BanUserCard /> <BanUserCard />
<RecentOrdersCard /> <RecentOrdersCard />
<InvitationsListCard />
<a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors"> <a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@@ -47,7 +42,7 @@ export default function AdminPage() {
</div> </div>
</a> </a>
<a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors"> <a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors h-full min-h-[200px]">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h2 className="font-medium">Config</h2> <h2 className="font-medium">Config</h2>
@@ -57,7 +52,7 @@ export default function AdminPage() {
</div> </div>
</a> </a>
<a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors"> <a href="#" className="group rounded-lg border border-border/60 bg-background p-4 hover:bg-muted/40 transition-colors h-full min-h-[200px]">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h2 className="font-medium">Payments</h2> <h2 className="font-medium">Payments</h2>

View File

@@ -28,7 +28,7 @@ export default function BanUserCard() {
} }
return ( return (
<div className="rounded-lg border border-border/60 bg-background p-4"> <div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
<h2 className="font-medium">Ban User</h2> <h2 className="font-medium">Ban User</h2>
<p className="text-sm text-muted-foreground mt-1">Block abusive users by Telegram ID</p> <p className="text-sm text-muted-foreground mt-1">Block abusive users by Telegram ID</p>
<form onSubmit={handleBan} className="mt-4 space-y-3"> <form onSubmit={handleBan} className="mt-4 space-y-3">

View File

@@ -0,0 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import { fetchClient } from "@/lib/api-client";
interface Invitation {
_id: string;
code: string;
isUsed: boolean;
createdAt: string;
expiresAt: string;
usedAt?: string | null;
}
export default function InvitationsListCard() {
const [invites, setInvites] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
const data = await fetchClient<Invitation[]>("/admin/invitations");
if (mounted) setInvites(data);
} catch (e: any) {
if (mounted) setError(e?.message || "Failed to load invitations");
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
return (
<div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
<h2 className="font-medium">Invitations</h2>
<p className="text-sm text-muted-foreground mt-1">Active and recent invitations</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>
) : invites.length === 0 ? (
<p className="text-sm text-muted-foreground mt-3">No invitations found</p>
) : (
<div className="mt-3 space-y-2">
{invites.map((inv) => {
const expired = new Date(inv.expiresAt).getTime() < Date.now();
return (
<div key={inv._id} className="rounded border border-border/50 p-3 text-sm flex items-center justify-between">
<div className="space-y-1">
<div>
Code: <span className="font-mono px-1.5 py-0.5 rounded bg-muted">{inv.code}</span>
</div>
<div className="text-xs text-muted-foreground">
Created: {new Date(inv.createdAt).toLocaleString()} · Expires: {new Date(inv.expiresAt).toLocaleString()}
</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded ${inv.isUsed ? 'bg-emerald-500/15 text-emerald-400' : expired ? 'bg-rose-500/15 text-rose-400' : 'bg-amber-500/15 text-amber-400'}`}>
{inv.isUsed ? 'Used' : expired ? 'Expired' : 'Active'}
</span>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -3,23 +3,18 @@ import { useState } from "react";
import { fetchClient } from "@/lib/api-client"; import { fetchClient } from "@/lib/api-client";
export default function InviteVendorCard() { export default function InviteVendorCard() {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [code, setCode] = useState<string | null>(null);
async function handleInvite(e: React.FormEvent) { async function handleInvite() {
e.preventDefault();
setLoading(true); setLoading(true);
setMessage(null); setMessage(null);
setCode(null);
try { try {
await fetchClient("/admin/invitations", { const res = await fetchClient<{ code: string }>("/admin/invitations", { method: "POST" });
method: "POST", setMessage("Invitation created");
body: { username, email } setCode(res.code);
});
setMessage("Invitation sent");
setUsername("");
setEmail("");
} catch (e: any) { } catch (e: any) {
setMessage(e?.message || "Failed to send invitation"); setMessage(e?.message || "Failed to send invitation");
} finally { } finally {
@@ -28,33 +23,24 @@ export default function InviteVendorCard() {
} }
return ( return (
<div className="rounded-lg border border-border/60 bg-background p-4"> <div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
<h2 className="font-medium">Invite Vendor</h2> <h2 className="font-medium">Invite Vendor</h2>
<p className="text-sm text-muted-foreground mt-1">Generate and send an invite</p> <p className="text-sm text-muted-foreground mt-1">Generate a new invitation code</p>
<form onSubmit={handleInvite} className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="Vendor username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="Email (optional)"
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
/>
<button <button
type="submit" onClick={handleInvite}
className="inline-flex items-center rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-60" className="inline-flex items-center rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-60"
disabled={loading} disabled={loading}
> >
{loading ? "Sending..." : "Send Invite"} {loading ? "Generating..." : "Generate Invite Code"}
</button> </button>
{message && <p className="text-xs text-muted-foreground">{message}</p>} {message && <p className="text-xs text-muted-foreground">{message}</p>}
</form> {code && (
<div className="text-sm">
Code: <span className="font-mono px-1.5 py-0.5 rounded bg-muted">{code}</span>
</div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -36,7 +36,7 @@ export default function RecentOrdersCard() {
}, []); }, []);
return ( return (
<div className="rounded-lg border border-border/60 bg-background p-4"> <div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
<h2 className="font-medium">Recent Orders</h2> <h2 className="font-medium">Recent Orders</h2>
<p className="text-sm text-muted-foreground mt-1">Last 10 orders across stores</p> <p className="text-sm text-muted-foreground mt-1">Last 10 orders across stores</p>
{loading ? ( {loading ? (

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { fetchClient } from "@/lib/api-client";
interface Status {
uptimeSeconds: number;
memory: Record<string, number>;
versions: Record<string, string>;
counts: { vendors: number; orders: number; products: number; chats: number };
}
function formatDuration(seconds: number) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}h ${m}m ${s}s`;
}
export default function SystemStatusCard() {
const [data, setData] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await fetchClient<Status>("/admin/system-status");
if (mounted) setData(res);
} catch (e: any) {
if (mounted) setError(e?.message || "Failed to load status");
}
})();
return () => { mounted = false; };
}, []);
return (
<div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
<div className="flex items-start justify-between">
<div>
<h2 className="font-medium">System status</h2>
<p className="text-sm text-muted-foreground mt-1">Uptime, versions, environment</p>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400">OK</span>
</div>
{error && <p className="text-sm text-muted-foreground mt-3">{error}</p>}
{data && (
<div className="mt-3 grid grid-cols-2 gap-3 text-sm">
<div className="space-y-1">
<div className="text-muted-foreground">Uptime</div>
<div>{formatDuration(data.uptimeSeconds)}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">Node</div>
<div>{data.versions?.node}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">Vendors</div>
<div>{data.counts?.vendors}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">Orders</div>
<div>{data.counts?.orders}</div>
</div>
</div>
)}
</div>
);
}