diff --git a/TODO.md b/TODO.md index 4c4fd13..c9f7e30 100644 --- a/TODO.md +++ b/TODO.md @@ -1449,6 +1449,40 @@ systemd (fecha um risco documentado desde a PHASE 01) (nginx, pendência original da PHASE 01) — o acesso de teste continua direto na porta 3001 da VM +## PHASE 39 — Frontend: Discador > Leads (tela dedicada) +(agente.md secao 67-68, 170) +- [x] `/app/discador/leads` — seletor de campanha (o backend já era + `GET /campaigns/:campaignId/leads`, escopado por campanha desde a + PHASE 15, sem endpoint "todos os leads de todas as campanhas" — não + faz sentido ter um, `Lead.campaignId` é obrigatório), busca por + telefone/nome client-side, adicionar lead individual (mesmo `POST` + já usado pelo wizard), remover com confirmação de 2 cliques. A + prévia de 20 leads dentro do detalhe da campanha (PHASE 26) já + apontava pra cá ("a lista completa vive em Discador > Leads") — só + faltava a tela existir. +- [x] `LEAD_STATUS_LABELS` novo (16 valores do enum) — badge própria, sem + reusar `StatusBadge` (mesma cautela de gênero já aplicada em + `AgentStateBadge`/`TenantStatusBadge`: `READY`/`FAILED`/`COMPLETED` + colidiriam com rótulos de campanha que não fazem sentido pra um + lead individual). +- [x] Testado ponta a ponta contra a API real: criada uma campanha de + teste (`Campanha Teste Leads`, DRAFT) só pra ter um `campaignId` + válido, 3 leads adicionados (2 via API, 1 via UI), busca por nome + filtrando corretamente pra 1 resultado, remoção confirmada (3 → 2 + leads, `DELETE` retornando 204). Smoke test de regressão nas 19 + telas do tenant + platform, todas 200 — desta vez sem nenhum + timeout de navegação no meio do caminho (rotas já compiladas de + passadas anteriores, consistente com o achado da PHASE 38 sobre + compile-on-first-visit do modo dev). +- [ ] "Importações" e "Callbacks" continuam "em breve" — Importações é + CSV em lote, que já existe dentro do wizard/detalhe da campanha + (PHASE 26), uma tela separada só faria sentido com histórico de + importações persistido (não existe hoje, cada import é só um + resumo devolvido na hora, não uma linha salva); Callbacks depende + de `Lead.status = CALLBACK` + `nextAttemptAt`, que o + `PredictiveDialerEngine` já popula (PHASE 16) mas sem tela nenhuma + pra visualizar/gerenciar ainda + --- ## Riscos conhecidos diff --git a/apps/frontend/.impeccable/review/leads-added-desktop.png b/apps/frontend/.impeccable/review/leads-added-desktop.png new file mode 100644 index 0000000..f857a96 Binary files /dev/null and b/apps/frontend/.impeccable/review/leads-added-desktop.png differ diff --git a/apps/frontend/.impeccable/review/leads-empty-desktop.png b/apps/frontend/.impeccable/review/leads-empty-desktop.png new file mode 100644 index 0000000..ba8e766 Binary files /dev/null and b/apps/frontend/.impeccable/review/leads-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/leads-search-desktop.png b/apps/frontend/.impeccable/review/leads-search-desktop.png new file mode 100644 index 0000000..b866efd Binary files /dev/null and b/apps/frontend/.impeccable/review/leads-search-desktop.png differ diff --git a/apps/frontend/.impeccable/review/leads-with-data-desktop.png b/apps/frontend/.impeccable/review/leads-with-data-desktop.png new file mode 100644 index 0000000..7ce0ef1 Binary files /dev/null and b/apps/frontend/.impeccable/review/leads-with-data-desktop.png differ diff --git a/apps/frontend/src/app/app/discador/leads/actions.ts b/apps/frontend/src/app/app/discador/leads/actions.ts new file mode 100644 index 0000000..3c86733 --- /dev/null +++ b/apps/frontend/src/app/app/discador/leads/actions.ts @@ -0,0 +1,49 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreateLeadInput { + campaignId: string; + phone: string; + name?: string; +} + +export async function createLead(input: CreateLeadInput): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/campaigns/${input.campaignId}/leads`, session.accessToken, { + method: "POST", + body: JSON.stringify({ phone: input.phone, name: input.name }), + }); + revalidatePath("/app/discador/leads"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function deleteLead(campaignId: string, leadId: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/campaigns/${campaignId}/leads/${leadId}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/discador/leads"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/discador/leads/leads-view.tsx b/apps/frontend/src/app/app/discador/leads/leads-view.tsx new file mode 100644 index 0000000..e54f0e6 --- /dev/null +++ b/apps/frontend/src/app/app/discador/leads/leads-view.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Phone, Plus, Search, Trash2, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, Select, FieldLabel } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatDateTime } from "@/lib/format"; +import { LEAD_STATUS_LABELS, type Campaign, type Lead } from "@/lib/campaign-types"; +import { createLead, deleteLead } from "./actions"; + +export function LeadsView({ + campaigns, + selectedCampaignId, + leads, +}: { + campaigns: Campaign[]; + selectedCampaignId: string | null; + leads: Lead[]; +}) { + const router = useRouter(); + const [showForm, setShowForm] = useState(false); + const [query, setQuery] = useState(""); + + const rows = useMemo(() => { + const q = query.trim(); + if (!q) return leads; + return leads.filter((l) => l.phoneNormalized.includes(q) || (l.name ?? "").toLowerCase().includes(q.toLowerCase())); + }, [leads, query]); + + return ( +
+
+
+

Leads

+

+ Lista completa de leads por campanha (agente.md secao 67-68) — a prévia dentro do detalhe da campanha + mostra só os 20 primeiros; aqui dá pra ver, buscar e remover qualquer um. Importação em lote (CSV) + continua em Discador > Campanhas > detalhe. +

+
+ {selectedCampaignId && ( + + )} +
+ + + Campanha + + + + {!selectedCampaignId ? ( + + + + ) : ( + <> + {showForm && setShowForm(false)} />} + + + +
+
+ + setQuery(e.target.value)} placeholder="Buscar telefone ou nome…" className="pl-8" aria-label="Buscar lead" /> +
+
+ {rows.length === 0 ? ( + 0 ? "Nenhum lead bate com essa busca" : "Nenhum lead nesta campanha ainda"} + description={leads.length > 0 ? "Tente outro termo." : "Adicione um lead ou importe um CSV no detalhe da campanha."} + /> + ) : ( + + + + + + + + + + + + + {rows.map((lead) => ( + + ))} + +
NomeTelefoneStatusTentativasÚltima tentativa + Ações +
+ )} +
+ + )} +
+ ); +} + +function NewLeadForm({ campaignId, onDone }: { campaignId: string; onDone: () => void }) { + const router = useRouter(); + const [phone, setPhone] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!phone.trim()) { + setError("Informe um telefone."); + return; + } + startTransition(async () => { + const result = await createLead({ campaignId, phone: phone.trim(), name: name.trim() || undefined }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + router.refresh(); + }); + } + + return ( + +
+
+
+ Telefone + setPhone(e.target.value)} placeholder="11987654321" className="font-mono" disabled={pending} /> +
+
+ Nome (opcional) + setName(e.target.value)} disabled={pending} /> +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function LeadRow({ lead, campaignId }: { lead: Lead; campaignId: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onDelete() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteLead(campaignId, lead.id); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( + + {lead.name ?? "—"} + + + + {lead.phoneNormalized} + + + + {LEAD_STATUS_LABELS[lead.status] ?? lead.status} + + {lead.attemptCount} + {lead.lastAttemptAt ? formatDateTime(lead.lastAttemptAt) : "—"} + +
+ {error && {error}} + +
+ + + ); +} diff --git a/apps/frontend/src/app/app/discador/leads/page.tsx b/apps/frontend/src/app/app/discador/leads/page.tsx new file mode 100644 index 0000000..1194c7f --- /dev/null +++ b/apps/frontend/src/app/app/discador/leads/page.tsx @@ -0,0 +1,14 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Campaign, Lead } from "@/lib/campaign-types"; +import { LeadsView } from "./leads-view"; + +export default async function LeadsPage({ searchParams }: { searchParams: Promise<{ campaignId?: string }> }) { + const { campaignId } = await searchParams; + const session = await requireSession(); + + const campaigns = await apiFetch("/campaigns", session.accessToken); + const leads = campaignId ? await apiFetch(`/campaigns/${campaignId}/leads`, session.accessToken) : []; + + return ; +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 8e0ad09..92723ef 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -37,7 +37,11 @@ export const TENANT_NAV: NavSection[] = [ href: "/app/discador/campanhas", description: "Discador preditivo — campanhas deste tenant", }, - { label: "Leads" }, + { + label: "Leads", + href: "/app/discador/leads", + description: "Leads de uma campanha — buscar, adicionar, remover", + }, { label: "Importações" }, { label: "Callbacks" }, { diff --git a/apps/frontend/src/lib/campaign-types.ts b/apps/frontend/src/lib/campaign-types.ts index 3b34ab8..ddfb63b 100644 --- a/apps/frontend/src/lib/campaign-types.ts +++ b/apps/frontend/src/lib/campaign-types.ts @@ -73,3 +73,36 @@ export const DAYS_OF_WEEK_LABELS: Record = { 6: "Sáb", 7: "Dom", }; + +export interface Lead { + id: string; + campaignId: string; + name: string | null; + phoneOriginal: string; + phoneNormalized: string; + status: string; + attemptCount: number; + lastAttemptAt: string | null; + nextAttemptAt: string | null; + lastResult: string | null; + createdAt: string; +} + +export const LEAD_STATUS_LABELS: Record = { + NEW: "Novo", + READY: "Pronto", + RESERVED: "Reservado", + ORIGINATING: "Discando", + RINGING: "Chamando", + ANSWERED: "Atendeu", + QUEUEING: "Na fila", + CONNECTED_AGENT: "Com agente", + BUSY: "Ocupado", + NO_ANSWER: "Não atendeu", + FAILED: "Falhou", + VOICEMAIL: "Caixa postal", + CALLBACK: "Callback", + COMPLETED: "Concluído", + DO_NOT_CALL: "Não ligar", + MAX_ATTEMPTS: "Máx. tentativas", +};