This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
@@ -14,6 +16,7 @@ interface RequestSummary {
|
||||
status: string;
|
||||
adminNotes?: string;
|
||||
tenantName?: string;
|
||||
dismissedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -36,21 +39,42 @@ interface SingleRequestState {
|
||||
tenant: TenantSummary | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
/**
|
||||
* Whether the viewer can act on this request — cancel a pending one,
|
||||
* dismiss a rejected one, etc. True for owner + platform; false for
|
||||
* `user`-role customers (who shouldn't see in-flight requests at all,
|
||||
* but defence in depth — `canSeeInflightRequests` already gates the
|
||||
* dashboard side).
|
||||
*/
|
||||
canAct: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProvisioningStatus
|
||||
*
|
||||
* Polls /api/onboarding?id=<requestId> every 5s until the request reaches
|
||||
* a terminal state. Slice 3: takes a `requestId` prop so multiple of these
|
||||
* can render on the same dashboard for different in-flight requests.
|
||||
* a terminal state. Slice 3: takes a `requestId` prop so multiple of
|
||||
* these can render on the same dashboard for different in-flight
|
||||
* requests.
|
||||
*
|
||||
* The pre-Slice-3 version polled /api/onboarding with no params and
|
||||
* assumed one-request-per-org — that endpoint shape is gone now.
|
||||
* Slice 7 / Bug 6 + 13:
|
||||
* - pending → cancel + edit buttons
|
||||
* - rejected → admin notes block + dismiss button
|
||||
* - cancelled → small acknowledgement card + dismiss button
|
||||
* - terminal Ready/Active states unchanged
|
||||
*/
|
||||
export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
export function ProvisioningStatus({ requestId, canAct }: Props) {
|
||||
const t = useTranslations("onboarding");
|
||||
const tCommon = useTranslations("common");
|
||||
const f = useFormatter();
|
||||
const router = useRouter();
|
||||
|
||||
const [data, setData] = useState<SingleRequestState | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [actionPending, setActionPending] = useState(false);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
@@ -67,11 +91,11 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
poll();
|
||||
|
||||
const status = data?.request?.status;
|
||||
const phase = data?.tenant?.phase;
|
||||
const terminal =
|
||||
status === "rejected" ||
|
||||
status === "cancelled" ||
|
||||
status === "active" ||
|
||||
phase === "Ready" ||
|
||||
phase === "Running";
|
||||
@@ -82,7 +106,54 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
return () => clearInterval(interval);
|
||||
}, [poll, data?.request?.status, data?.tenant?.phase]);
|
||||
|
||||
if (error) {
|
||||
const handleCancel = async () => {
|
||||
setActionPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/onboarding/${encodeURIComponent(requestId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || t("cancelFailed"));
|
||||
}
|
||||
setConfirmCancel(false);
|
||||
// Re-poll so the card transitions to "cancelled" state without a
|
||||
// full route refresh — the dashboard's surrounding tenant cards
|
||||
// are unaffected.
|
||||
await poll();
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setActionPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
setActionPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/onboarding/${encodeURIComponent(requestId)}/dismiss`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || t("dismissFailed"));
|
||||
}
|
||||
// Server-rendered list query (`listActiveTenantRequestsByOrgId`)
|
||||
// filters out dismissed rows — refresh to drop this card.
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setActionPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-xs text-red-400">{error}</div>
|
||||
@@ -107,7 +178,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
data.request.tenantName ||
|
||||
data.request.agentName;
|
||||
|
||||
// Pending admin approval
|
||||
// ─── Pending: awaiting admin approval ───────────────────────────────
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<Card className="animate-in">
|
||||
@@ -131,7 +202,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
{t("pendingTitle")}
|
||||
</h2>
|
||||
{label && (
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto">
|
||||
{t("pendingDescription")}
|
||||
@@ -150,12 +223,76 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bug 6 — owner-only edit + cancel actions while still
|
||||
pending. Once admin acts, both buttons disappear (the
|
||||
status branch changes). */}
|
||||
{canAct && (
|
||||
<div className="flex justify-center gap-2 mt-5">
|
||||
<Link
|
||||
href={`/dashboard/edit/${encodeURIComponent(requestId)}`}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("editRequest")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancel(true)}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
{t("cancelRequest")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 mt-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmCancel && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setConfirmCancel(false);
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmRequestTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-5">
|
||||
{t("cancelConfirmRequestDescription")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancel(false)}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionPending
|
||||
? tCommon("loading")
|
||||
: t("cancelRequestConfirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Rejected
|
||||
// ─── Rejected: admin declined ───────────────────────────────────────
|
||||
if (status === "rejected") {
|
||||
return (
|
||||
<Card className="animate-in">
|
||||
@@ -179,22 +316,94 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
{t("rejectedTitle")}
|
||||
</h2>
|
||||
{label && (
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto">
|
||||
{t("rejectedDescription")}
|
||||
</p>
|
||||
{data.request.adminNotes && (
|
||||
<p className="text-xs text-text-muted mt-3 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
|
||||
{data.request.adminNotes}
|
||||
</p>
|
||||
<div className="text-left text-xs text-text-secondary mt-4 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
|
||||
<div className="font-semibold uppercase tracking-wider text-text-muted text-[10px] mb-1.5">
|
||||
{t("rejectionReason")}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap">
|
||||
{data.request.adminNotes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Bug 13: dismiss removes this card from the dashboard but
|
||||
keeps the row in the DB for audit. The customer can also
|
||||
just resubmit via the wizard — both paths are valid. */}
|
||||
{canAct && (
|
||||
<div className="flex justify-center mt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={actionPending}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionPending ? tCommon("loading") : t("dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Provisioning in progress (status approved/provisioning, optionally with tenant phase < Ready)
|
||||
// ─── Cancelled: customer cancelled before admin acted (Bug 6) ──────
|
||||
if (status === "cancelled") {
|
||||
return (
|
||||
<Card className="animate-in">
|
||||
<div className="text-center py-6">
|
||||
<div className="h-14 w-14 rounded-xl bg-text-muted/15 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="h-7 w-7 text-text-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelledTitle")}
|
||||
</h2>
|
||||
{label && (
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto">
|
||||
{t("cancelledDescription")}
|
||||
</p>
|
||||
{canAct && (
|
||||
<div className="flex justify-center mt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={actionPending}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionPending ? tCommon("loading") : t("dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Provisioning: approved, operator working ──────────────────────
|
||||
if (
|
||||
status === "approved" ||
|
||||
status === "provisioning" ||
|
||||
@@ -213,7 +422,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
{t("provisioningTitle")}
|
||||
</h2>
|
||||
{label && (
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t("provisioningDescription")}
|
||||
@@ -249,7 +460,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Active / Ready
|
||||
// ─── Active / Ready ─────────────────────────────────────────────────
|
||||
if (status === "active") {
|
||||
return (
|
||||
<Card className="animate-in">
|
||||
@@ -273,7 +484,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
|
||||
{t("readyTitle")}
|
||||
</h2>
|
||||
{label && (
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
|
||||
<p className="text-xs font-mono text-text-secondary mb-2">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
|
||||
{t("readyDescription")}
|
||||
|
||||
Reference in New Issue
Block a user