All the initial admin requests approval flow
This commit is contained in:
508
src/components/admin/admin-panel.tsx
Normal file
508
src/components/admin/admin-panel.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { PiecedTenant, TenantRequest } from "@/types";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import Link from "next/link";
|
||||
|
||||
type Tab = "requests" | "tenants";
|
||||
type RequestFilter = "all" | "pending" | "provisioning" | "approved" | "rejected";
|
||||
|
||||
interface AdminPanelProps {
|
||||
initialTenants: PiecedTenant[];
|
||||
}
|
||||
|
||||
export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
const t = useTranslations("admin");
|
||||
const [tab, setTab] = useState<Tab>("requests");
|
||||
const [requests, setRequests] = useState<TenantRequest[]>([]);
|
||||
const [filter, setFilter] = useState<RequestFilter>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
||||
const [rejectNotes, setRejectNotes] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fetchRequests = useCallback(async () => {
|
||||
try {
|
||||
const url =
|
||||
filter === "all"
|
||||
? "/api/admin/requests"
|
||||
: `/api/admin/requests?status=${filter}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
const data = await res.json();
|
||||
setRequests(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "requests") {
|
||||
setLoading(true);
|
||||
fetchRequests();
|
||||
}
|
||||
}, [tab, filter, fetchRequests]);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Approve failed");
|
||||
}
|
||||
await fetchRequests();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Reject failed");
|
||||
}
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
await fetchRequests();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const FILTERS: RequestFilter[] = [
|
||||
"all",
|
||||
"pending",
|
||||
"provisioning",
|
||||
"approved",
|
||||
"rejected",
|
||||
];
|
||||
|
||||
const pendingCount = requests.filter((r) => r.status === "pending").length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border mb-6">
|
||||
<button
|
||||
onClick={() => setTab("requests")}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||
tab === "requests"
|
||||
? "text-accent"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{t("requests")}
|
||||
{pendingCount > 0 && tab !== "requests" && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-white rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
{tab === "requests" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("tenants")}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||
tab === "tenants"
|
||||
? "text-accent"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{t("allTenants")}
|
||||
<span className="ml-1.5 text-xs text-text-muted tabular-nums">
|
||||
{initialTenants.length}
|
||||
</span>
|
||||
{tab === "tenants" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mb-4 text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
<button
|
||||
onClick={() => setError("")}
|
||||
className="ml-2 text-red-300 hover:text-red-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ───── REQUESTS TAB ───── */}
|
||||
{tab === "requests" && (
|
||||
<>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
filter === f
|
||||
? "bg-accent text-white"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
|
||||
}`}
|
||||
>
|
||||
{t(`filter_${f}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-text-muted text-xs">{t("loadingRequests")}</p>
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<p className="text-text-secondary text-sm">{t("noRequests")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("company")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("contact")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("agentName")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
|
||||
{t("packages")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("status")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("submitted")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map((req) => (
|
||||
<tr
|
||||
key={req.id}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-text-primary text-sm">
|
||||
{req.companyName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-text-primary text-sm">
|
||||
{req.contactName}
|
||||
</div>
|
||||
<div className="text-text-muted text-xs">
|
||||
{req.contactEmail}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-secondary hidden md:table-cell">
|
||||
{req.agentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-secondary font-mono hidden lg:table-cell">
|
||||
{req.packages?.length
|
||||
? req.packages.join(", ")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RequestStatusBadge status={req.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
||||
{new Date(req.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
{req.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApprove(req.id)}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === req.id
|
||||
? "…"
|
||||
: t("approve")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejectModal(req.id)}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("reject")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(req.status === "provisioning" ||
|
||||
req.status === "approved") &&
|
||||
req.tenantName && (
|
||||
<Link
|
||||
href={`/tenants/${req.tenantName}`}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
{t("viewTenant")}
|
||||
</Link>
|
||||
)}
|
||||
{req.status === "rejected" && (
|
||||
<button
|
||||
onClick={() => handleApprove(req.id)}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("reApprove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{req.adminNotes && (
|
||||
<p className="text-[11px] text-text-muted mt-1 max-w-[200px] truncate">
|
||||
{req.adminNotes}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───── TENANTS TAB ───── */}
|
||||
{tab === "tenants" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<SummaryCard
|
||||
label={t("totalTenants")}
|
||||
value={initialTenants.length}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("running")}
|
||||
value={
|
||||
initialTenants.filter((t) => t.status?.phase === "Running")
|
||||
.length
|
||||
}
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("provisioning")}
|
||||
value={
|
||||
initialTenants.filter(
|
||||
(t) =>
|
||||
t.status?.phase === "Provisioning" ||
|
||||
t.status?.phase === "Pending"
|
||||
).length
|
||||
}
|
||||
color="text-amber-400"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("errors")}
|
||||
value={
|
||||
initialTenants.filter((t) => t.status?.phase === "Error").length
|
||||
}
|
||||
color="text-red-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{initialTenants.length === 0 ? (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("name")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("displayName")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("phase")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("packages")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("created")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initialTenants.map((tenant) => (
|
||||
<tr
|
||||
key={tenant.metadata.name}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||
>
|
||||
<td className="px-5 py-3 font-mono text-xs text-accent">
|
||||
{tenant.metadata.name}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-text-primary">
|
||||
{tenant.spec.displayName}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<StatusBadge
|
||||
phase={tenant.status?.phase ?? "Pending"}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-text-secondary font-mono hidden md:table-cell">
|
||||
{tenant.spec.packages?.join(", ") || "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
||||
{tenant.metadata.creationTimestamp
|
||||
? new Date(
|
||||
tenant.metadata.creationTimestamp
|
||||
).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
href={`/tenants/${tenant.metadata.name}`}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
{t("manage")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───── REJECT MODAL ───── */}
|
||||
{rejectModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
||||
{t("rejectTitle")}
|
||||
</h3>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("adminNotesLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectNotes}
|
||||
onChange={(e) => setRejectNotes(e.target.value)}
|
||||
placeholder={t("adminNotesPlaceholder")}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t("cancelAction")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(rejectModal)}
|
||||
disabled={actionLoading === rejectModal}
|
||||
className="px-4 py-2 text-sm font-medium bg-red-500/15 text-red-400 rounded-lg hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestStatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
pending: "bg-blue-400/15 text-blue-400",
|
||||
approved: "bg-emerald-400/15 text-emerald-400",
|
||||
provisioning: "bg-amber-400/15 text-amber-400",
|
||||
active: "bg-emerald-400/15 text-emerald-400",
|
||||
rejected: "bg-red-400/15 text-red-400",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full ${colors[status] || "bg-surface-2 text-text-muted"}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
status === "pending"
|
||||
? "bg-blue-400"
|
||||
: status === "approved" || status === "active"
|
||||
? "bg-emerald-400"
|
||||
: status === "provisioning"
|
||||
? "bg-amber-400 animate-pulse"
|
||||
: status === "rejected"
|
||||
? "bg-red-400"
|
||||
: "bg-text-muted"
|
||||
}`}
|
||||
/>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`font-mono text-2xl font-semibold tabular-nums ${color || "text-text-primary"}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user