feat(quota): retire standalone Plans screen, unified into pool wizard (Phase C2)

Remove plans/ route and sidebar entry; PoolWizard Step 2 now covers plan-dimensions inline.
This commit is contained in:
diegosouzapw
2026-05-30 22:15:27 -03:00
parent 62de5a83b8
commit 2300db6cc5
5 changed files with 64 additions and 427 deletions

View File

@@ -1,389 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { knownProviders, getKnownPlan } from "@/lib/quota/planRegistry";
import type { QuotaDimension, QuotaUnit, QuotaWindow } from "@/lib/quota/dimensions";
// ────────────────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────────────────
interface Connection {
id: string;
provider: string;
name?: string;
displayName?: string;
email?: string;
}
interface ProviderPlanOverride {
connectionId: string;
provider: string;
dimensions: QuotaDimension[];
source: "auto" | "manual";
}
// ────────────────────────────────────────────────────────────────────────────
// Constants
// ────────────────────────────────────────────────────────────────────────────
const UNIT_OPTIONS: QuotaUnit[] = ["percent", "requests", "tokens", "usd"];
const WINDOW_OPTIONS: QuotaWindow[] = ["5h", "hourly", "daily", "weekly", "monthly"];
// ────────────────────────────────────────────────────────────────────────────
// Component
// ────────────────────────────────────────────────────────────────────────────
export default function ProviderPlanConfigClient() {
const t = useTranslations("quotaPlans");
const [connections, setConnections] = useState<Connection[]>([]);
const [selectedConnectionId, setSelectedConnectionId] = useState("");
const [overrides, setOverrides] = useState<Record<string, ProviderPlanOverride>>({});
const [editDimensions, setEditDimensions] = useState<QuotaDimension[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [reverting, setReverting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// ── Load connections and existing overrides ───────────────────────────────
useEffect(() => {
setLoading(true);
Promise.all([
fetch("/api/providers/client")
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch("/api/quota/plans")
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
])
.then(([connsData, plansData]) => {
const conns: Connection[] = Array.isArray(connsData?.connections)
? connsData.connections
: [];
setConnections(conns);
if (Array.isArray(plansData)) {
const map: Record<string, ProviderPlanOverride> = {};
for (const p of plansData as ProviderPlanOverride[]) {
if (p.connectionId) map[p.connectionId] = p;
}
setOverrides(map);
}
})
.catch(() => {
setError("Failed to load data");
})
.finally(() => setLoading(false));
}, []);
// ── Derived: selected connection and plan info ────────────────────────────
const selectedConn = connections.find((c) => c.id === selectedConnectionId);
const selectedProvider = selectedConn?.provider || "";
const existingOverride = selectedConnectionId ? overrides[selectedConnectionId] : undefined;
const catalogPlan = selectedProvider ? getKnownPlan(selectedProvider) : null;
const detectedSource = existingOverride?.source || (catalogPlan ? "auto" : null);
const connLabel = (c: Connection) =>
`${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`;
// ── When connection changes, populate edit dimensions ─────────────────────
useEffect(() => {
if (!selectedConnectionId) {
setEditDimensions([]);
return;
}
// Priority: manual override > catalog
if (existingOverride && existingOverride.source === "manual") {
setEditDimensions(existingOverride.dimensions);
} else if (catalogPlan) {
setEditDimensions([...catalogPlan.dimensions]);
} else {
setEditDimensions([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedConnectionId]);
// ── Dimension editors ─────────────────────────────────────────────────────
const addDimension = () => {
setEditDimensions((prev) => [...prev, { unit: "percent", window: "daily", limit: 100 }]);
};
const removeDimension = (i: number) => {
setEditDimensions((prev) => prev.filter((_, idx) => idx !== i));
};
const updateDimension = (i: number, patch: Partial<QuotaDimension>) => {
setEditDimensions((prev) => prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)));
};
// ── Save override ─────────────────────────────────────────────────────────
const handleSaveOverride = useCallback(async () => {
if (!selectedConnectionId) return;
setSaving(true);
setError(null);
setSuccessMsg(null);
try {
const res = await fetch(`/api/quota/plans/${selectedConnectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dimensions: editDimensions }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Refresh overrides
const data = (await res.json()) as ProviderPlanOverride;
setOverrides((prev) => ({ ...prev, [selectedConnectionId]: data }));
setSuccessMsg(t("saveOverrideButton") + " — saved");
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}, [selectedConnectionId, editDimensions, t]);
// ── Revert to catalog ─────────────────────────────────────────────────────
const handleRevertToCatalog = useCallback(async () => {
if (!selectedConnectionId) return;
setReverting(true);
setError(null);
setSuccessMsg(null);
try {
const res = await fetch(`/api/quota/plans/${selectedConnectionId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setOverrides((prev) => {
const next = { ...prev };
delete next[selectedConnectionId];
return next;
});
// Reset edit dims to catalog
if (catalogPlan) setEditDimensions([...catalogPlan.dimensions]);
else setEditDimensions([]);
setSuccessMsg(t("revertToCatalogButton") + " — reverted");
} catch (err) {
setError(err instanceof Error ? err.message : "Revert failed");
} finally {
setReverting(false);
}
}, [selectedConnectionId, catalogPlan, t]);
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col gap-4">
{/* Header */}
<div>
<h1 className="text-xl font-bold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[24px] text-primary">fact_check</span>
{t("title")}
</h1>
<p className="text-sm text-text-muted mt-0.5">{t("description")}</p>
</div>
{loading ? (
<div className="text-text-muted text-sm py-10 text-center animate-pulse">Loading</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-4">
{/* Left: connection selector */}
<div className="flex flex-col gap-3">
<div>
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
{t("providerLabel")}
</label>
<select
value={selectedConnectionId}
onChange={(e) => setSelectedConnectionId(e.target.value)}
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
>
<option value=""> {t("providerLabel")} </option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{connLabel(c)}
</option>
))}
</select>
</div>
{/* Catalog known plans */}
<div className="rounded-lg border border-border/40 bg-bg-subtle/20 p-3">
<div className="text-[10px] uppercase tracking-wide font-bold text-text-muted mb-2">
{t("catalogTitle")}
</div>
<p className="text-[11px] text-text-muted mb-2">{t("catalogDescription")}</p>
<div className="space-y-1.5">
{knownProviders().map((prov) => {
const plan = getKnownPlan(prov);
if (!plan) return null;
return (
<div
key={prov}
className="flex items-start gap-2 text-[11px] rounded-md bg-bg-subtle/30 px-2 py-1.5"
>
<div className="w-4 h-4 mt-0.5 rounded-sm overflow-hidden shrink-0">
<ProviderIcon providerId={prov} size={16} type="color" />
</div>
<div className="min-w-0">
<div className="font-semibold text-text-main capitalize">{prov}</div>
{plan.dimensions.map((d, i) => (
<div key={i} className="text-text-muted">
{d.unit}/{d.window}: {d.limit}
</div>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Right: plan config */}
{selectedConnectionId ? (
<div className="flex flex-col gap-3">
{/* Status badge */}
<div className="flex items-center gap-2 text-xs">
{selectedProvider && (
<div className="w-5 h-5 rounded-sm overflow-hidden">
<ProviderIcon providerId={selectedProvider} size={20} type="color" />
</div>
)}
<span className="font-semibold text-text-main">{connLabel(selectedConn!)}</span>
{detectedSource === "auto" && (
<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-400 text-[10px] font-bold">
{t("detectedPlanLabel")} (auto)
</span>
)}
{detectedSource === "manual" && (
<span className="px-2 py-0.5 rounded bg-blue-500/10 text-blue-400 text-[10px] font-bold">
{t("manualPlanLabel")}
</span>
)}
{!detectedSource && (
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-400 text-[10px] font-bold">
{t("unconfiguredLabel")}
</span>
)}
</div>
{/* Dimensions editor */}
<div className="rounded-lg border border-border/40 bg-bg-subtle/10 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] uppercase tracking-wide font-bold text-text-muted">
{t("dimensionLabel")}
</span>
<button
type="button"
onClick={addDimension}
className="text-[11px] text-primary hover:underline cursor-pointer flex items-center gap-1"
>
<span className="material-symbols-outlined text-[14px]">add</span>
{t("addDimension")}
</button>
</div>
{editDimensions.length === 0 && (
<div className="text-[11px] text-text-muted italic py-3 text-center">
{t("unconfiguredLabel")} {t("addDimension")}
</div>
)}
<div className="space-y-2">
{editDimensions.map((dim, i) => (
<div key={i} className="grid items-center gap-2" style={{ gridTemplateColumns: "1fr 1fr 90px 24px" }}>
<select
value={dim.unit}
onChange={(e) => updateDimension(i, { unit: e.target.value as QuotaUnit })}
className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs"
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>
{t(`unitOptions.${u}`)}
</option>
))}
</select>
<select
value={dim.window}
onChange={(e) => updateDimension(i, { window: e.target.value as QuotaWindow })}
className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs"
>
{WINDOW_OPTIONS.map((w) => (
<option key={w} value={w}>
{t(`windowOptions.${w}`)}
</option>
))}
</select>
<input
type="number"
min={0}
value={dim.limit}
onChange={(e) => updateDimension(i, { limit: Number(e.target.value) })}
placeholder={t("limitLabel")}
className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs tabular-nums text-right"
/>
<button
type="button"
onClick={() => removeDimension(i)}
className="p-0.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
</div>
</div>
{/* Error / success */}
{error && (
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
)}
{successMsg && (
<p className="text-[11px] text-emerald-400 bg-emerald-500/10 px-3 py-2 rounded">
{successMsg}
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="primary"
size="sm"
onClick={handleSaveOverride}
disabled={saving || editDimensions.length === 0}
>
{saving ? "Saving…" : t("saveOverrideButton")}
</Button>
{existingOverride && existingOverride.source === "manual" && (
<Button
variant="secondary"
size="sm"
onClick={handleRevertToCatalog}
disabled={reverting}
>
{reverting ? "Reverting…" : t("revertToCatalogButton")}
</Button>
)}
</div>
</div>
) : (
<div className="flex items-center justify-center py-16 text-text-muted text-sm">
{t("unknownProviderNotice")}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,7 +0,0 @@
import ProviderPlanConfigClient from "./ProviderPlanConfigClient";
export const dynamic = "force-dynamic";
export default function PlansPage() {
return <ProviderPlanConfigClient />;
}

View File

@@ -47,7 +47,6 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"costs-pricing",
"costs-budget",
"costs-quota-share",
"costs-quota-plans",
// Monitoring > Audit
"audit",
"audit-mcp",
@@ -462,13 +461,6 @@ const COSTS_ITEMS: readonly SidebarItemDefinition[] = [
subtitleKey: "costsQuotaShareSubtitle",
icon: "pie_chart",
},
{
id: "costs-quota-plans",
href: "/dashboard/costs/quota-share/plans",
i18nKey: "costsQuotaPlans",
subtitleKey: "costsQuotaPlansSubtitle",
icon: "fact_check",
},
];
const AUDIT_GROUP: SidebarItemGroup = {

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
test('sidebarVisibility.ts no longer contains "costs-quota-plans"', () => {
assert.ok(
!(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]).includes("costs-quota-plans"),
'"costs-quota-plans" must have been removed from HIDEABLE_SIDEBAR_ITEM_IDS (Plans screen retired)'
);
});
test("retired Plans route file no longer exists", () => {
const routePath = path.resolve(
__dirname,
"../../src/app/(dashboard)/dashboard/costs/quota-share/plans/page.tsx"
);
assert.strictEqual(
fs.existsSync(routePath),
false,
`Route file should not exist (Plans screen retired): ${routePath}`
);
});
test("retired ProviderPlanConfigClient file no longer exists", () => {
const clientPath = path.resolve(
__dirname,
"../../src/app/(dashboard)/dashboard/costs/quota-share/plans/ProviderPlanConfigClient.tsx"
);
assert.strictEqual(
fs.existsSync(clientPath),
false,
`ProviderPlanConfigClient should not exist (Plans screen retired): ${clientPath}`
);
});
test("costs section no longer includes costs-quota-plans nav item", () => {
const section = sidebarVisibility.SIDEBAR_SECTIONS.find((s) => s.id === "costs");
assert.ok(section, 'expected "costs" section to exist');
const items = sidebarVisibility.getSectionItems(section);
const ids = items.map((i) => i.id);
assert.ok(
!ids.includes("costs-quota-plans"),
'"costs-quota-plans" nav item must be absent from the costs section (Plans screen retired)'
);
});

View File

@@ -1,3 +1,8 @@
/**
* Phase C2 — Plans screen retired (unified into PoolWizard Step 2).
* These tests verify the sidebar entry is gone and the costs section
* still has the correct remaining items.
*/
import test from "node:test";
import assert from "node:assert/strict";
@@ -9,36 +14,21 @@ function sectionItems(sectionId: string) {
return sidebarVisibility.getSectionItems(section);
}
test("HIDEABLE_SIDEBAR_ITEM_IDS contains costs-quota-plans", () => {
test("HIDEABLE_SIDEBAR_ITEM_IDS does NOT contain costs-quota-plans (retired)", () => {
assert.ok(
(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]).includes("costs-quota-plans"),
"costs-quota-plans must be in HIDEABLE_SIDEBAR_ITEM_IDS"
!(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]).includes("costs-quota-plans"),
"costs-quota-plans must have been removed from HIDEABLE_SIDEBAR_ITEM_IDS"
);
});
test("HIDEABLE_SIDEBAR_ITEM_IDS: costs-quota-plans appears after costs-quota-share", () => {
const ids = sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[];
const qsIdx = ids.indexOf("costs-quota-share");
const qpIdx = ids.indexOf("costs-quota-plans");
assert.ok(qsIdx !== -1, "costs-quota-share must exist");
assert.ok(qpIdx !== -1, "costs-quota-plans must exist");
assert.ok(qpIdx > qsIdx, "costs-quota-plans must come after costs-quota-share");
});
test("costs section has 5 items including costs-quota-plans at end", () => {
test("costs section does not include costs-quota-plans item (retired)", () => {
const items = sectionItems("costs");
const ids = items.map((i) => i.id);
assert.ok(ids.includes("costs-quota-plans"), "costs section must include costs-quota-plans");
assert.strictEqual(ids[ids.length - 1], "costs-quota-plans", "costs-quota-plans must be last");
assert.strictEqual(ids.length, 5, "costs section must have exactly 5 items");
assert.ok(!ids.includes("costs-quota-plans"), "costs section must NOT include costs-quota-plans");
});
test("costs-quota-plans has correct href and icon", () => {
test("costs section still contains costs-quota-share", () => {
const items = sectionItems("costs");
const item = items.find((i) => i.id === "costs-quota-plans");
assert.ok(item, "costs-quota-plans item must exist");
assert.strictEqual(item.href, "/dashboard/costs/quota-share/plans");
assert.strictEqual(item.icon, "fact_check");
assert.strictEqual(item.i18nKey, "costsQuotaPlans");
assert.strictEqual(item.subtitleKey, "costsQuotaPlansSubtitle");
const ids = items.map((i) => i.id);
assert.ok(ids.includes("costs-quota-share"), "costs section must still include costs-quota-share");
});