feat(followup): página + lista de fluxos de follow-up [onda 6]

Task 6.1: /app/ai/followups (server component, gate manager+) + FlowsList/
NewFlowDialog client, hook hooks/followup/useFollowupFlows.ts (padrão useAgent.ts
+ apiClient). Badge de status (draft/active/disabled) via variants nativos do
Badge Sage (neutral/success/warning — tokens, não Tailwind cru). Empty state
com FlowArrow. Nav item "Follow-ups" no Sidebar reusando permissão
ai.agents.view. Prova Playwright headed (tests/e2e/followup-builder.spec.ts,
2/2): manager cria fluxo → aparece na lista com badge "Rascunho"; viewer é
barrado (403) antes mesmo do botão existir.

Contagem de enrollments vivos por fluxo deferida (GET list não retorna;
adicionar como subquery correlacionada quando 6.2/6.3 precisarem).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143T3gYRomX8kFxK3KhJK1U
This commit is contained in:
Rafael Melgaço
2026-07-22 11:37:37 -03:00
co-authored by Claude Fable 5
parent bea944fb1c
commit fcd00687e6
8 changed files with 388 additions and 1 deletions
@@ -0,0 +1,22 @@
import { Badge } from "@/components/ui/badge";
import type { FollowupFlowStatus } from "@/hooks/followup/useFollowupFlows";
const LABEL: Record<FollowupFlowStatus, string> = {
draft: "Rascunho",
active: "Ativo",
disabled: "Desativado",
};
const VARIANT: Record<FollowupFlowStatus, "neutral" | "success" | "warning"> = {
draft: "neutral",
active: "success",
disabled: "warning",
};
export function FlowStatusBadge({ status }: { status: FollowupFlowStatus }) {
return (
<Badge variant={VARIANT[status]} aria-label={`status: ${LABEL[status]}`}>
{LABEL[status]}
</Badge>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { FlowArrow, Plus } from "@/lib/ui/icons";
import { useFollowupFlows, type FollowupFlowPointerRow } from "@/hooks/followup/useFollowupFlows";
import { FlowStatusBadge } from "./FlowStatusBadge";
import { NewFlowDialog } from "./NewFlowDialog";
interface Props {
initialData: FollowupFlowPointerRow[];
canWrite: boolean;
}
function formatUpdatedAt(iso: string): string {
return new Date(iso).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function FlowsList({ initialData, canWrite }: Props) {
const { data } = useFollowupFlows({ initialData });
const [dialogOpen, setDialogOpen] = useState(false);
const flows = data ?? [];
const newFlowButton = (
<Button onClick={() => setDialogOpen(true)}>
<Plus size={14} aria-hidden className="mr-2" /> Novo fluxo
</Button>
);
if (flows.length === 0) {
return (
<>
<Card className="flex flex-col items-center gap-3 p-10 text-center">
<FlowArrow size={36} aria-hidden className="text-text-muted" />
<h2 className="font-medium">Nenhum fluxo de follow-up ainda</h2>
<p className="max-w-sm text-sm text-text-muted">
Follow-ups reengajam contatos automaticamente após silêncio, mudança de
etapa ou fim de conversa — sem depender de alguém lembrar de mandar mensagem.
</p>
{canWrite && <div className="mt-1">{newFlowButton}</div>}
</Card>
{canWrite && <NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />}
</>
);
}
return (
<div className="flex flex-col gap-4">
{canWrite && (
<div className="flex justify-end">{newFlowButton}</div>
)}
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{flows.map((flow) => (
<li key={flow.id}>
<Link href={`/app/ai/followups/${flow.id}`} className="block h-full">
<Card className="flex h-full flex-col gap-3 p-4 transition-colors hover:border-accent-400">
<div className="flex items-start justify-between gap-2">
<h3 className="min-w-0 flex-1 truncate font-medium" title={flow.name}>
{flow.name}
</h3>
<FlowStatusBadge status={flow.status} />
</div>
<dl className="grid grid-cols-2 gap-2 pt-1 text-xs">
<div>
<dt className="text-text-muted">Versão</dt>
<dd className="font-mono">{flow.active_version_id ? "publicada" : "—"}</dd>
</div>
<div>
<dt className="text-text-muted">Handoff</dt>
<dd className="font-mono">{flow.handoff_policy}</dd>
</div>
</dl>
<p className="mt-auto pt-2 text-xs text-text-muted">
Atualizado em {formatUpdatedAt(flow.updated_at)}
</p>
</Card>
</Link>
</li>
))}
</ul>
{canWrite && <NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />}
</div>
);
}
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCreateFollowupFlow } from "@/hooks/followup/useFollowupFlows";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function NewFlowDialog({ open, onOpenChange }: Props) {
const [name, setName] = useState("");
const create = useCreateFollowupFlow();
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
create.mutate(name.trim(), {
onSuccess: () => {
setName("");
onOpenChange(false);
},
});
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) setName("");
onOpenChange(next);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo fluxo de follow-up</DialogTitle>
<DialogDescription>
Nasce como rascunho. Você monta as etapas no editor visual em seguida.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="flow-name">Nome</Label>
<Input
id="flow-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: Recuperação de carrinho abandonado"
maxLength={80}
required
autoFocus
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={create.isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={create.isPending || name.trim().length === 0}>
{create.isPending ? "Criando…" : "Criar fluxo"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { ROLE_RANK } from "@/lib/auth/types";
import { createClient } from "@/lib/supabase/server";
import type { FollowupFlowPointerRow } from "@/hooks/followup/useFollowupFlows";
import { FlowsList } from "./_components/FlowsList";
export const dynamic = "force-dynamic";
const FLOW_COLUMNS = "id, name, status, active_version_id, handoff_policy, updated_at";
export default async function FollowupFlowsPage() {
const user = await requireAuth();
const activeOrg = await resolveActiveOrg(user);
if (!activeOrg) redirect("/app");
if (ROLE_RANK[activeOrg.role] < ROLE_RANK.manager) {
redirect("/403");
}
const supabase = await createClient();
const { data } = await supabase
.from("followup_flow_pointers")
.select(FLOW_COLUMNS)
.eq("organization_id", activeOrg.orgId)
.order("updated_at", { ascending: false });
const flows = (data ?? []) as unknown as FollowupFlowPointerRow[];
const canWrite = ROLE_RANK[activeOrg.role] >= ROLE_RANK.manager;
return (
<div className="flex h-full flex-col gap-6 p-6">
<header className="flex flex-wrap items-end justify-between gap-2">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Follow-ups</h1>
<p className="text-sm text-text-muted">
Fluxos automáticos de reengajamento — silêncio, mudança de etapa ou fim
de conversa disparam mensagens sem intervenção manual.
</p>
</div>
</header>
<FlowsList initialData={flows} canWrite={canWrite} />
</div>
);
}
+2 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTransition } from "react";
import { Kanban, Users, UsersThree, Gear, CaretDoubleLeft, CaretDoubleRight, Inbox, ScalesSimple, Robot, PlugsConnected, ChartBar, WebhooksLogo } from "@/lib/ui/icons";
import { Kanban, Users, UsersThree, Gear, CaretDoubleLeft, CaretDoubleRight, Inbox, ScalesSimple, Robot, PlugsConnected, ChartBar, WebhooksLogo, FlowArrow } from "@/lib/ui/icons";
import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { toggleSidebar } from "@/app/actions/shell/toggleSidebar";
@@ -26,6 +26,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/app/metrics", label: "Desempenho", icon: ChartBar },
{ href: "/app/lgpd/requests", label: "LGPD", icon: ScalesSimple, permission: "lgpd.execute_redact" },
{ href: "/app/ai/agents", label: "Agentes IA", icon: Robot, permission: "ai.agents.view" },
{ href: "/app/ai/followups", label: "Follow-ups", icon: FlowArrow, permission: "ai.agents.view" },
{ href: "/app/webhooks", label: "Webhooks", icon: WebhooksLogo, permission: "webhooks.manage" },
{ href: "/app/settings", label: "Configurações", icon: Gear },
];
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
export type FollowupFlowStatus = "draft" | "active" | "disabled";
export interface FollowupFlowPointerRow {
id: string;
name: string;
status: FollowupFlowStatus;
active_version_id: string | null;
handoff_policy: string;
updated_at: string;
}
interface ListResponse {
data: FollowupFlowPointerRow[];
}
interface SingleResponse {
data: FollowupFlowPointerRow;
}
export const followupFlowsListQueryKey = ["followup", "flows", "list"] as const;
export function useFollowupFlows(opts?: { initialData?: FollowupFlowPointerRow[] }) {
return useQuery({
queryKey: followupFlowsListQueryKey,
queryFn: async () => {
try {
const res = await apiClient.get<ListResponse>("/api/v1/ai/followup-flows");
return res.data;
} catch (err) {
showApiError(err);
throw err;
}
},
initialData: opts?.initialData,
});
}
export function useCreateFollowupFlow() {
const qc = useQueryClient();
return useMutation({
mutationKey: ["followup", "flows", "create"],
mutationFn: async (name: string) => {
const res = await apiClient.post<SingleResponse>("/api/v1/ai/followup-flows", { name });
return res.data;
},
onSuccess: (created) => {
qc.setQueryData<FollowupFlowPointerRow[]>(followupFlowsListQueryKey, (prev) =>
prev ? [created, ...prev] : [created],
);
toast.success("Fluxo criado.");
},
onError: (err) => {
showApiError(err);
},
});
}
+1
View File
@@ -22,6 +22,7 @@ export {
House,
// admin platform
Buildings,
FlowArrow,
ChatsCircle,
ClipboardText,
Scales,
+82
View File
@@ -0,0 +1,82 @@
/**
* Task 6.1 — página + lista de fluxos de follow-up.
*
* Critério de aceite (1): logado como MANAGER, navega pra /app/ai/followups →
* clica "Novo fluxo" → digita um nome → o fluxo aparece na lista com badge
* "Rascunho". Task 6.2 estende este spec com o editor visual (grafo).
*
* Sem endpoint DELETE em followup-flows (decisão deliberada da Onda 3+ —
* fluxos não se apagam, só se desativam). Cada run usa um nome com timestamp
* único, então não colide entre execuções; os drafts de teste se acumulam no
* banco e exigem um sweep manual periódico (fora do escopo desta task).
*/
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { test, expect, type Page } from "@playwright/test";
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
interface Creds {
password: string;
users: Record<string, { email: string }>;
}
function loadCreds(): Creds {
const needsSeed = (): boolean => {
if (!fs.existsSync(CREDS_PATH)) return true;
const c = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
return !c.users?.manager;
};
if (needsSeed()) {
execFileSync("npx", ["tsx", "scripts/seed-e2e-credentials.ts"], { stdio: "inherit" });
}
return JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
}
const creds = loadCreds();
async function login(page: Page, email: string): Promise<void> {
await page.goto("/login");
await page.locator("#email").fill(email);
await page.locator("#password").fill(creds.password);
await page.getByRole("button", { name: /entrar/i }).click();
await page.waitForURL(/\/app\//);
}
test.describe("followup flows — lista + criação (Task 6.1)", () => {
test("manager cria um fluxo e ele aparece na lista com badge Rascunho", async ({ page }) => {
await login(page, creds.users.manager!.email);
await page.goto("/app/ai/followups");
await expect(page.getByRole("heading", { name: "Follow-ups" })).toBeVisible();
await page.screenshot({ path: "test-results/followup-6.1-01-list.png", fullPage: true });
const flowName = `E2E Follow-up ${Date.now()}`;
await page.getByRole("button", { name: "Novo fluxo" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Novo fluxo de follow-up")).toBeVisible();
await page.screenshot({ path: "test-results/followup-6.1-02-dialog-open.png", fullPage: true });
const nameInput = dialog.getByLabel("Nome");
await expect(nameInput).toBeFocused();
await nameInput.fill(flowName);
await page.screenshot({ path: "test-results/followup-6.1-03-name-typed.png", fullPage: true });
await dialog.getByRole("button", { name: "Criar fluxo" }).click();
await expect(dialog).not.toBeVisible();
const card = page.locator("li", { hasText: flowName });
await expect(card).toBeVisible();
await expect(card.getByText("Rascunho", { exact: true })).toBeVisible();
await page.screenshot({ path: "test-results/followup-6.1-04-flow-in-list.png", fullPage: true });
});
test("viewer não vê o botão de criar fluxo (RBAC)", async ({ page }) => {
await login(page, creds.users.viewer!.email);
await page.goto("/app/ai/followups");
await page.waitForURL(/\/403/);
});
});