diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx index d61c8814c5..97e2e3ba63 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/shared/components"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; @@ -21,6 +21,12 @@ import EditAllocationsModal from "./components/EditAllocationsModal"; // Local types (display layer only) // ──────────────────────────────────────────────────────────────────────────── +interface QuotaGroup { + id: string; + name: string; + createdAt: string; +} + interface Connection { id: string; provider: string; @@ -134,6 +140,13 @@ export default function QuotaSharePageClient() { const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); + // ── Group state ─────────────────────────────────────────────────────────── + const [groups, setGroups] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState("group-demo"); + const [newGroupInput, setNewGroupInput] = useState(""); + const [showNewGroupInput, setShowNewGroupInput] = useState(false); + const [renaming, setRenaming] = useState(false); + // ── Fetch side data once on mount ───────────────────────────────────────── useMemo(() => { @@ -178,6 +191,66 @@ export default function QuotaSharePageClient() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // ── Fetch groups ────────────────────────────────────────────────────────── + + const fetchGroups = useCallback(async () => { + try { + const res = await fetch("/api/quota/groups"); + if (res.ok) { + const data = (await res.json()) as { groups: QuotaGroup[] }; + setGroups(Array.isArray(data.groups) ? data.groups : []); + } + } catch { + // fail open — groups list not critical + } + }, []); + + useEffect(() => { + void fetchGroups(); + }, [fetchGroups]); + + // ── Group actions ───────────────────────────────────────────────────────── + + const handleCreateGroup = useCallback(async () => { + const name = newGroupInput.trim(); + if (!name) return; + try { + const res = await fetch("/api/quota/groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (res.ok) { + const data = (await res.json()) as { group: QuotaGroup }; + await fetchGroups(); + setSelectedGroupId(data.group.id); + } + } catch { + // fail open + } + setNewGroupInput(""); + setShowNewGroupInput(false); + }, [newGroupInput, fetchGroups]); + + const handleRenameGroup = useCallback(async () => { + const name = prompt(t("groupNamePrompt"), groups.find((g) => g.id === selectedGroupId)?.name ?? ""); + if (!name?.trim()) return; + setRenaming(true); + try { + const res = await fetch(`/api/quota/groups/${selectedGroupId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + if (res.ok) { + await fetchGroups(); + } + } catch { + // fail open + } + setRenaming(false); + }, [selectedGroupId, groups, fetchGroups, t]); + // ── Derived ────────────────────────────────────────────────────────────── const keyLabels = useMemo(() => { @@ -213,6 +286,12 @@ export default function QuotaSharePageClient() { [pools, aggregate] ); + // Pools filtered by selected group + const filteredPools = useMemo( + () => pools.filter((p) => (p as unknown as { groupId?: string }).groupId === selectedGroupId || (!( p as unknown as { groupId?: string }).groupId && selectedGroupId === "group-demo")), + [pools, selectedGroupId] + ); + // ── Mutations ───────────────────────────────────────────────────────────── const handleSaveAllocations = useCallback( @@ -258,6 +337,76 @@ export default function QuotaSharePageClient() { + {/* Group bar */} +
+ + {t("groupLabel")} + + + {showNewGroupInput ? ( +
+ setNewGroupInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateGroup(); + if (e.key === "Escape") { setShowNewGroupInput(false); setNewGroupInput(""); } + }} + placeholder={t("groupNamePrompt")} + autoFocus + className="px-2 py-1 rounded border border-border bg-bg-base text-sm w-36" + /> + + +
+ ) : ( + + )} + {selectedGroupId !== "group-demo" && ( + + )} +
+ {/* Concept card */} @@ -293,21 +442,45 @@ export default function QuotaSharePageClient() { ) : ( -
- {pools.map((pool) => ( - setEditing(pool)} - onRemove={() => void handleRemovePool(pool.id)} - /> - ))} -
+ <> + {/* Group heading */} + {groups.find((g) => g.id === selectedGroupId) && ( +
+ folder + + {groups.find((g) => g.id === selectedGroupId)?.name} + + + ({filteredPools.length}) + +
+ )} + {filteredPools.length === 0 ? ( +
+

{t("emptyDescription")}

+ +
+ ) : ( +
+ {filteredPools.map((pool) => ( + setEditing(pool)} + onRemove={() => void handleRemovePool(pool.id)} + /> + ))} +
+ )} + )} {/* Modals */} @@ -319,6 +492,8 @@ export default function QuotaSharePageClient() { apiKeys={apiKeys} plans={plans} existingPoolConnectionIds={new Set(pools.map((p) => p.connectionId))} + groups={groups} + selectedGroupId={selectedGroupId} /> {editing && ( diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx index 027ef0e3b5..ebe1a03028 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx @@ -113,6 +113,12 @@ export default function EditAllocationsModal({ {t("pool")}: {emailsVisible ? pool.name : maskEmailLikeValue(pool.name)} + {/* Group allocation note */} +

+ info + {t("groupAllocationNote")} +

+ {drafts.length === 0 ? (
{t("noKeysAdded")} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx index 31c902dc03..4c1b450ee3 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx @@ -48,6 +48,12 @@ interface PlanInfo { source: "auto" | "manual"; } +interface QuotaGroup { + id: string; + name: string; + createdAt: string; +} + export interface PoolWizardProps { open: boolean; onClose: () => void; @@ -56,6 +62,8 @@ export interface PoolWizardProps { apiKeys: ApiKey[]; plans: Record; existingPoolConnectionIds: Set; + groups?: QuotaGroup[]; + selectedGroupId?: string; } // ──────────────────────────────────────────────────────────────────────────── @@ -159,6 +167,8 @@ export default function PoolWizard({ apiKeys, plans, existingPoolConnectionIds, + groups = [], + selectedGroupId: initialGroupId = "group-demo", }: PoolWizardProps) { const t = useTranslations("quotaShare"); const tPlans = useTranslations("quotaPlans"); @@ -181,6 +191,9 @@ export default function PoolWizard({ const [allocations, setAllocations] = useState([]); const [exclusive, setExclusive] = useState(false); + // ── Group selection ─────────────────────────────────────────────────────── + const [groupId, setGroupId] = useState(initialGroupId); + // ── Saving ──────────────────────────────────────────────────────────────── const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -252,8 +265,9 @@ export default function PoolWizard({ setExclusive(false); setError(null); setSaving(false); + setGroupId(initialGroupId); } - }, [open]); + }, [open, initialGroupId]); // ── Step 2 — dimension editors ──────────────────────────────────────────── @@ -349,7 +363,8 @@ export default function PoolWizard({ [previewByProvider] ); - const effectivePoolName = poolName.trim() || (selectedConn ? connLabel(selectedConn) : ""); + // Default pool name uses provider slug — NOT the raw connection label/email. + const effectivePoolName = poolName.trim() || (selectedConn ? selectedConn.provider : ""); // ── Save sequence ───────────────────────────────────────────────────────── @@ -368,6 +383,7 @@ export default function PoolWizard({ connectionIds, name: effectivePoolName, allocations: [], + groupId, }), }); if (!createRes.ok) { @@ -514,12 +530,32 @@ export default function PoolWizard({ type="text" value={poolName} onChange={(e) => setPoolName(e.target.value)} - placeholder={selectedConn ? connLabel(selectedConn) : t("wizardPoolNamePlaceholder")} + placeholder={selectedConn ? selectedConn.provider : t("wizardPoolNamePlaceholder")} className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm" />
)} + {/* Group picker */} + {connectionIds.length > 0 && groups.length > 0 && ( +
+ + +
+ )} + {/* Default policy */} {connectionIds.length > 0 && (
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 383f45eb9a..13e8e3658d 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7898,7 +7898,14 @@ "accountQuotaTitle": "Account quota", "accountQuotaNone": "—", "logTitle": "Usage log", - "logEmpty": "No usage yet" + "logEmpty": "No usage yet", + "groupLabel": "Group", + "newGroup": "New group", + "renameGroup": "Rename group", + "groupSelectHint": "Filter pools by group", + "groupAllocationNote": "Allocations apply to all pools in this group via the shared quota layer.", + "groupNamePrompt": "Enter group name", + "wizardGroupLabel": "Group" }, "plugins": { "title": "Plugins", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 5c6111051f..77580dc264 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5408,7 +5408,14 @@ "accountQuotaTitle": "Cota da conta", "accountQuotaNone": "—", "logTitle": "Log de uso", - "logEmpty": "Sem uso ainda" + "logEmpty": "Sem uso ainda", + "groupLabel": "Grupo", + "newGroup": "Novo grupo", + "renameGroup": "Renomear grupo", + "groupSelectHint": "Filtrar pools por grupo", + "groupAllocationNote": "As alocações se aplicam a todos os pools deste grupo através da camada de cota compartilhada.", + "groupNamePrompt": "Digite o nome do grupo", + "wizardGroupLabel": "Grupo" }, "requestLogger": { "recording": "Recording", diff --git a/src/shared/schemas/quota.ts b/src/shared/schemas/quota.ts index 3abad23eeb..518d80f883 100644 --- a/src/shared/schemas/quota.ts +++ b/src/shared/schemas/quota.ts @@ -17,6 +17,7 @@ export const PoolCreateSchema = z connectionIds: z.array(z.string().min(1)).min(1).optional(), name: z.string().min(1).max(120), allocations: z.array(PoolAllocationSchema).default([]), + groupId: z.string().optional(), }) .refine( (data) => { diff --git a/tests/unit/quota-groups-ui.test.ts b/tests/unit/quota-groups-ui.test.ts new file mode 100644 index 0000000000..4b2e0d2d7b --- /dev/null +++ b/tests/unit/quota-groups-ui.test.ts @@ -0,0 +1,297 @@ +/** + * tests/unit/quota-groups-ui.test.ts + * + * Task B9 — source-level assertions for the group selector UI, + * group-aware PoolWizard, and EditAllocationsModal group note. + * + * Pattern mirrors: + * tests/unit/quota-pool-log-route.test.ts (source-scan + i18n parity) + * tests/unit/quota-pool-wizard.test.ts (source-scan structural) + * + * Node.js native test runner — no DOM setup required. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const PAGE_CLIENT_PATH = join( + ROOT, + "src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx" +); + +const WIZARD_PATH = join( + ROOT, + "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx" +); + +const MODAL_PATH = join( + ROOT, + "src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx" +); + +const EN_PATH = join(ROOT, "src/i18n/messages/en.json"); +const PT_PATH = join(ROOT, "src/i18n/messages/pt-BR.json"); + +const pageSrc = readFileSync(PAGE_CLIENT_PATH, "utf8"); +const wizardSrc = readFileSync(WIZARD_PATH, "utf8"); +const modalSrc = readFileSync(MODAL_PATH, "utf8"); + +// ── QuotaSharePageClient: group fetch ───────────────────────────────────────── + +test("QuotaSharePageClient: fetches /api/quota/groups", () => { + assert.ok( + pageSrc.includes("/api/quota/groups"), + "QuotaSharePageClient must fetch /api/quota/groups" + ); +}); + +test("QuotaSharePageClient: has a fetch for GET /api/quota/groups (list)", () => { + // The fetchGroups function does a plain GET fetch + const fetchGroupsIdx = pageSrc.indexOf("fetchGroups"); + assert.ok(fetchGroupsIdx >= 0, "fetchGroups function must exist"); + // The function body must call fetch('/api/quota/groups') + const afterFetch = pageSrc.slice(fetchGroupsIdx); + assert.ok( + afterFetch.includes('"/api/quota/groups"') || afterFetch.includes("'/api/quota/groups'"), + "fetchGroups must call fetch with /api/quota/groups" + ); +}); + +test("QuotaSharePageClient: has a POST to /api/quota/groups for group creation", () => { + // handleCreateGroup does POST /api/quota/groups + const createGroupIdx = pageSrc.indexOf("handleCreateGroup"); + assert.ok(createGroupIdx >= 0, "handleCreateGroup must be defined"); + const afterCreate = pageSrc.slice(createGroupIdx, createGroupIdx + 800); + assert.ok( + afterCreate.includes('method: "POST"') || afterCreate.includes("method: 'POST'"), + "handleCreateGroup must use POST" + ); + assert.ok( + afterCreate.includes("/api/quota/groups"), + "handleCreateGroup must POST to /api/quota/groups" + ); +}); + +test("QuotaSharePageClient: has a PATCH to /api/quota/groups/[id] for rename", () => { + // handleRenameGroup does PATCH /api/quota/groups/${selectedGroupId} + const renameIdx = pageSrc.indexOf("handleRenameGroup"); + assert.ok(renameIdx >= 0, "handleRenameGroup must be defined"); + const afterRename = pageSrc.slice(renameIdx, renameIdx + 800); + assert.ok( + afterRename.includes('method: "PATCH"') || afterRename.includes("method: 'PATCH'"), + "handleRenameGroup must use PATCH" + ); + assert.ok( + afterRename.includes("/api/quota/groups/"), + "handleRenameGroup must PATCH /api/quota/groups/[id]" + ); +}); + +// ── QuotaSharePageClient: group select in header ────────────────────────────── + +test("QuotaSharePageClient: renders a group with value bound to selectedGroupId + assert.ok( + pageSrc.includes("selectedGroupId"), + "QuotaSharePageClient must track selectedGroupId state" + ); + assert.ok( + pageSrc.includes(" for groups" + ); +}); + +test("QuotaSharePageClient: group bar renders 'New group' action", () => { + assert.ok( + pageSrc.includes("newGroup") || pageSrc.includes("showNewGroupInput"), + "QuotaSharePageClient must render a New group action (uses newGroup i18n key or showNewGroupInput state)" + ); +}); + +test("QuotaSharePageClient: group bar renders 'Rename group' action", () => { + assert.ok( + pageSrc.includes("renameGroup"), + "QuotaSharePageClient must render a Rename group action using renameGroup i18n key" + ); +}); + +// ── QuotaSharePageClient: pool list filtered by group ──────────────────────── + +test("QuotaSharePageClient: filters pool list by selectedGroupId (filteredPools)", () => { + assert.ok( + pageSrc.includes("filteredPools"), + "QuotaSharePageClient must derive filteredPools from the group selection" + ); +}); + +test("QuotaSharePageClient: renders group heading above pool grid", () => { + // The group heading shows the group name and pool count + assert.ok( + pageSrc.includes("filteredPools.length"), + "QuotaSharePageClient must show filteredPools count in the group heading" + ); +}); + +// ── QuotaSharePageClient: passes groups/selectedGroupId to PoolWizard ───────── + +test("QuotaSharePageClient: passes groups prop to PoolWizard", () => { + assert.ok( + pageSrc.includes("groups={groups}"), + "QuotaSharePageClient must pass groups={groups} to PoolWizard" + ); +}); + +test("QuotaSharePageClient: passes selectedGroupId prop to PoolWizard", () => { + assert.ok( + pageSrc.includes("selectedGroupId={selectedGroupId}"), + "QuotaSharePageClient must pass selectedGroupId={selectedGroupId} to PoolWizard" + ); +}); + +// ── PoolWizard: groupId in POST body ────────────────────────────────────────── + +test("PoolWizard: includes groupId in the POST /api/quota/pools body", () => { + // The POST body JSON must include groupId + assert.ok( + wizardSrc.includes("groupId"), + "PoolWizard must include groupId in the create POST body" + ); + // Find the POST body JSON.stringify call + const postIdx = wizardSrc.indexOf('method: "POST"'); + assert.ok(postIdx >= 0, "POST method call must exist in PoolWizard"); + const postSection = wizardSrc.slice(postIdx, postIdx + 400); + assert.ok( + postSection.includes("groupId"), + "groupId must be in the POST body JSON.stringify" + ); +}); + +// ── PoolWizard: group picker UI in step 1 ──────────────────────────────────── + +test("PoolWizard: accepts groups prop in PoolWizardProps", () => { + assert.ok( + wizardSrc.includes("groups?:") || wizardSrc.includes("groups ?: ") || wizardSrc.includes("groups?: "), + "PoolWizardProps must include optional groups prop" + ); +}); + +test("PoolWizard: renders a group picker