feat(frontend): Discador > Leads (tela dedicada)
/app/discador/leads — seletor de campanha, busca por telefone/nome,
adicionar lead individual, remover com confirmação de 2 cliques. O
backend (GET/POST/DELETE /campaigns/:campaignId/leads) já existia desde a
PHASE 15; a prévia de leads no detalhe da campanha já apontava pra esta
tela ("a lista completa vive em Discador > Leads") desde a PHASE 26, só
faltava construir.
LEAD_STATUS_LABELS novo (16 valores) — badge própria, não reusa
StatusBadge (mesma cautela de gênero de AgentStateBadge/TenantStatusBadge:
READY/FAILED/COMPLETED colidiriam com rótulos de campanha).
Testado ponta a ponta: campanha de teste criada, 3 leads adicionados (2
via API, 1 via UI), busca filtrando corretamente, remoção confirmada via
API. Smoke test nas 19 telas do tenant + platform, todas 200.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
34
TODO.md
34
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
|
(nginx, pendência original da PHASE 01) — o acesso de teste
|
||||||
continua direto na porta 3001 da VM
|
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
|
## Riscos conhecidos
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/leads-added-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/leads-added-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/frontend/.impeccable/review/leads-empty-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/leads-empty-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/frontend/.impeccable/review/leads-search-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/leads-search-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/frontend/.impeccable/review/leads-with-data-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/leads-with-data-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
49
apps/frontend/src/app/app/discador/leads/actions.ts
Normal file
49
apps/frontend/src/app/app/discador/leads/actions.ts
Normal file
@@ -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<void>(`/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<void>(`/campaigns/${campaignId}/leads/${leadId}`, session.accessToken, { method: "DELETE" });
|
||||||
|
revalidatePath("/app/discador/leads");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
227
apps/frontend/src/app/app/discador/leads/leads-view.tsx
Normal file
227
apps/frontend/src/app/app/discador/leads/leads-view.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Leads</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedCampaignId && (
|
||||||
|
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||||
|
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||||
|
{showForm ? "Cancelar" : "Novo lead"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel className="p-5">
|
||||||
|
<FieldLabel htmlFor="ld-campaign">Campanha</FieldLabel>
|
||||||
|
<Select
|
||||||
|
id="ld-campaign"
|
||||||
|
value={selectedCampaignId ?? ""}
|
||||||
|
onChange={(e) => router.push(e.target.value ? `/app/discador/leads?campaignId=${e.target.value}` : "/app/discador/leads")}
|
||||||
|
className="max-w-sm"
|
||||||
|
>
|
||||||
|
<option value="">Escolha uma campanha…</option>
|
||||||
|
{campaigns.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{!selectedCampaignId ? (
|
||||||
|
<Panel>
|
||||||
|
<EmptyState title="Escolha uma campanha" description="Selecione uma campanha acima pra ver os leads dela." />
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{showForm && <NewLeadForm campaignId={selectedCampaignId} onDone={() => setShowForm(false)} />}
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title="Leads" description={`${leads.length} lead(s) nesta campanha (até 500 exibidos)`} />
|
||||||
|
<div className="border-b border-border px-5 py-3">
|
||||||
|
<div className="relative w-full max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||||
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Buscar telefone ou nome…" className="pl-8" aria-label="Buscar lead" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={leads.length > 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."}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<TR>
|
||||||
|
<TH>Nome</TH>
|
||||||
|
<TH>Telefone</TH>
|
||||||
|
<TH>Status</TH>
|
||||||
|
<TH>Tentativas</TH>
|
||||||
|
<TH>Última tentativa</TH>
|
||||||
|
<TH>
|
||||||
|
<span className="sr-only">Ações</span>
|
||||||
|
</TH>
|
||||||
|
</TR>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{rows.map((lead) => (
|
||||||
|
<LeadRow key={lead.id} lead={lead} campaignId={selectedCampaignId} />
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewLeadForm({ campaignId, onDone }: { campaignId: string; onDone: () => void }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="ld-phone">Telefone</FieldLabel>
|
||||||
|
<Input id="ld-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11987654321" className="font-mono" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="ld-name">Nome (opcional)</FieldLabel>
|
||||||
|
<Input id="ld-name" value={name} onChange={(e) => setName(e.target.value)} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Adicionando…" : "Adicionar lead"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeadRow({ lead, campaignId }: { lead: Lead; campaignId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<TR>
|
||||||
|
<TD className="text-foreground">{lead.name ?? "—"}</TD>
|
||||||
|
<TD>
|
||||||
|
<span className="flex items-center gap-2 font-mono text-muted-foreground">
|
||||||
|
<Phone className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{lead.phoneNormalized}
|
||||||
|
</span>
|
||||||
|
</TD>
|
||||||
|
<TD>
|
||||||
|
<Pill>{LEAD_STATUS_LABELS[lead.status] ?? lead.status}</Pill>
|
||||||
|
</TD>
|
||||||
|
<TD className="font-mono tabular-nums text-muted-foreground">{lead.attemptCount}</TD>
|
||||||
|
<TD className="text-muted-foreground">{lead.lastAttemptAt ? formatDateTime(lead.lastAttemptAt) : "—"}</TD>
|
||||||
|
<TD>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={confirming ? "destructive" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={pending}
|
||||||
|
aria-label={confirming ? `Confirmar remoção de ${lead.phoneNormalized}` : `Remover ${lead.phoneNormalized}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{confirming ? "Confirmar" : ""}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TD>
|
||||||
|
</TR>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/frontend/src/app/app/discador/leads/page.tsx
Normal file
14
apps/frontend/src/app/app/discador/leads/page.tsx
Normal file
@@ -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<Campaign[]>("/campaigns", session.accessToken);
|
||||||
|
const leads = campaignId ? await apiFetch<Lead[]>(`/campaigns/${campaignId}/leads`, session.accessToken) : [];
|
||||||
|
|
||||||
|
return <LeadsView campaigns={campaigns} selectedCampaignId={campaignId ?? null} leads={leads} />;
|
||||||
|
}
|
||||||
@@ -37,7 +37,11 @@ export const TENANT_NAV: NavSection[] = [
|
|||||||
href: "/app/discador/campanhas",
|
href: "/app/discador/campanhas",
|
||||||
description: "Discador preditivo — campanhas deste tenant",
|
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: "Importações" },
|
||||||
{ label: "Callbacks" },
|
{ label: "Callbacks" },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,3 +73,36 @@ export const DAYS_OF_WEEK_LABELS: Record<number, string> = {
|
|||||||
6: "Sáb",
|
6: "Sáb",
|
||||||
7: "Dom",
|
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<string, string> = {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user