mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-15 04:49:58 +08:00
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:
co-authored by
Claude Fable 5
parent
bea944fb1c
commit
fcd00687e6
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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 },
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export {
|
||||
House,
|
||||
// admin platform
|
||||
Buildings,
|
||||
FlowArrow,
|
||||
ChatsCircle,
|
||||
ClipboardText,
|
||||
Scales,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user