diff --git a/TODO.md b/TODO.md index 0f1424d..a55b822 100644 --- a/TODO.md +++ b/TODO.md @@ -812,7 +812,61 @@ app do Tenant + Dashboard (agente.md secao 162, 169) a PHASE 14, mas a UI não mostra consumo/limite antes de tentar criar — o erro 403 apareceria só depois do submit -## PHASE 26+ — ver `agente.md` seções 140 em diante (resto do Frontend, +## PHASE 26 — Frontend: Discador > Campanhas (agente.md secao 63-86, 170) +- [x] Tela `/app/discador/campanhas`: listagem com busca + filtro de + status + ordenação, `StatusBadge` (já existia desde o design + system da Tarifas, criado exatamente pra status de campanha — + só faltava `WAITING_SCHEDULE`, adicionado). Nomes de fila/tronco + resolvidos client-side (a lista busca `/queues`+`/trunks` junto, + sem precisar mudar o backend). Aviso explícito quando o tenant + ainda não tem fila+tronco (pré-requisito real pra criar + qualquer campanha). +- [x] Wizard de criação em 7 passos, exatamente como a especificação + nomeia (secao 170): Geral → Telefonia → Discagem → Horários → + Gravação e IA → Leads → Revisão. Nada é criado até confirmar na + Revisão (`POST /campaigns` e, se um CSV foi anexado no passo + Leads, `POST /campaigns/:id/leads/import` na mesma ação — a + campanha nunca fica "meio criada" se o import falhar, só reporta + o problema). Upload de CSV lido no browser via `FileReader` + (endpoint espera o CSV cru no body, não multipart). +- [x] Detalhe da campanha: `StatusBadge` + ações de ciclo de vida + (Iniciar/Pausar/Drenar/Parar, cada botão só aparece quando a + transição é válida pro status atual — mesma tabela + `ALLOWED_TRANSITIONS` do backend, duplicada no client só pra + UI, o backend segue sendo a autoridade), pacing ao vivo (`GET + /campaigns/:id/stats`), configuração completa, prévia de leads + (até 20, com import adicional) e remover (zona de risco, só + quando parada). +- [x] **Bug real, achado nesta fase**: `startCampaign`/`pauseCampaign`/ + `drainCampaign`/`stopCampaign` eram `const x = (id) => + transition(...)` — Next.js exige que toda Server Action exportada + seja uma `async function` de verdade, não uma arrow function que + apenas retorna uma Promise (erro de build: "Server Actions must + be async functions"). Corrigido; útil lembrar em qualquer ação + futura que só repassa argumentos pra outra função. +- [x] **Achado sistêmico, não é bug novo**: `@@unique([tenantId, name])` + em `Campaign` não exclui `deletedAt` — não dá pra reusar o nome + de uma campanha apagada, mesma classe de problema já documentada + pra Agent/Extension/Trunk/Queue/PauseReason na PHASE 12. +- [x] Testado ponta a ponta contra a API real com fila+tronco reais + (semeados via API pro tenant Acme): os 7 passos do wizard + preenchidos e revisados (screenshot da Revisão confere cada + valor), campanha criada com CSV de 3 leads importado (`imported: + 3, total: 3` batendo), ciclo de vida completo start→pause→ + drain→stop→delete confirmado via API, e confirmado que uma + campanha iniciada de verdade é pega pelo `PredictiveDialerEngine` + real rodando em Docker (stats deixam de ser null depois de + alguns segundos — `answerProbability`/`pacingFactor` reais + aparecem na tela, não só o placeholder "sem tentativas ainda"). +- [ ] `Discador > Leads` (tela dedicada, fora do wizard/detalhe) e + `Importações` continuam "em breve" — a prévia de leads no + detalhe da campanha cobre o básico, mas não pagina nem edita + leads individualmente +- [ ] O detalhe da campanha não escuta o WebSocket de monitoramento + (secao 161) — `stats`/`callsInFlight` só atualizam ao recarregar + a página, não em tempo real + +## PHASE 27+ — ver `agente.md` seções 140 em diante (resto do Frontend, Security, Tests) --- diff --git a/apps/frontend/.impeccable/review/campaign-detail-running-desktop.png b/apps/frontend/.impeccable/review/campaign-detail-running-desktop.png new file mode 100644 index 0000000..ef45c30 Binary files /dev/null and b/apps/frontend/.impeccable/review/campaign-detail-running-desktop.png differ diff --git a/apps/frontend/.impeccable/review/campanhas-empty-desktop.png b/apps/frontend/.impeccable/review/campanhas-empty-desktop.png new file mode 100644 index 0000000..d600d40 Binary files /dev/null and b/apps/frontend/.impeccable/review/campanhas-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/campanhas-with-data-desktop.png b/apps/frontend/.impeccable/review/campanhas-with-data-desktop.png new file mode 100644 index 0000000..888fcd6 Binary files /dev/null and b/apps/frontend/.impeccable/review/campanhas-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/campanhas-with-data-mobile.png b/apps/frontend/.impeccable/review/campanhas-with-data-mobile.png new file mode 100644 index 0000000..b0fb2aa Binary files /dev/null and b/apps/frontend/.impeccable/review/campanhas-with-data-mobile.png differ diff --git a/apps/frontend/.impeccable/review/wizard-step1-desktop.png b/apps/frontend/.impeccable/review/wizard-step1-desktop.png new file mode 100644 index 0000000..e686e0e Binary files /dev/null and b/apps/frontend/.impeccable/review/wizard-step1-desktop.png differ diff --git a/apps/frontend/.impeccable/review/wizard-step2-desktop.png b/apps/frontend/.impeccable/review/wizard-step2-desktop.png new file mode 100644 index 0000000..0dae83f Binary files /dev/null and b/apps/frontend/.impeccable/review/wizard-step2-desktop.png differ diff --git a/apps/frontend/.impeccable/review/wizard-step4-desktop.png b/apps/frontend/.impeccable/review/wizard-step4-desktop.png new file mode 100644 index 0000000..9b398e8 Binary files /dev/null and b/apps/frontend/.impeccable/review/wizard-step4-desktop.png differ diff --git a/apps/frontend/.impeccable/review/wizard-step6-leads-desktop.png b/apps/frontend/.impeccable/review/wizard-step6-leads-desktop.png new file mode 100644 index 0000000..e0be848 Binary files /dev/null and b/apps/frontend/.impeccable/review/wizard-step6-leads-desktop.png differ diff --git a/apps/frontend/.impeccable/review/wizard-step7-review-desktop.png b/apps/frontend/.impeccable/review/wizard-step7-review-desktop.png new file mode 100644 index 0000000..70767e1 Binary files /dev/null and b/apps/frontend/.impeccable/review/wizard-step7-review-desktop.png differ diff --git a/apps/frontend/src/app/app/discador/campanhas/[id]/delete-action.tsx b/apps/frontend/src/app/app/discador/campanhas/[id]/delete-action.tsx new file mode 100644 index 0000000..3fcd64a --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/[id]/delete-action.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { deleteCampaign } from "../actions"; + +export function DeleteCampaignAction({ campaignId, campaignName }: { campaignId: string; campaignName: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(false); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteCampaign(campaignId); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.push("/app/discador/campanhas"); + router.refresh(); + }); + } + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/app/app/discador/campanhas/[id]/leads-panel.tsx b/apps/frontend/src/app/app/discador/campanhas/[id]/leads-panel.tsx new file mode 100644 index 0000000..4ce154d --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/[id]/leads-panel.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useRef, useState, useTransition, type ChangeEvent } from "react"; +import { useRouter } from "next/navigation"; +import { FileUp, Upload } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Table, THead, TBody, TR, TH, TD, EmptyState } from "@/components/ui/table"; +import { Pill } from "@/components/ui/pill"; +import type { CampaignStatus, LeadImportSummary } from "@/lib/campaign-types"; +import { importLeads } from "../actions"; + +interface Lead { + id: string; + name: string | null; + phoneNormalized: string; + status: string; +} + +const PREVIEW_LIMIT = 20; + +export function LeadsPanel({ campaignId, leads, status }: { campaignId: string; leads: Lead[]; status: CampaignStatus }) { + const router = useRouter(); + const fileInputRef = useRef(null); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [summary, setSummary] = useState(null); + + function onFile(e: ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setError(null); + setSummary(null); + const reader = new FileReader(); + reader.onload = () => { + const csv = String(reader.result ?? ""); + startTransition(async () => { + const result = await importLeads(campaignId, csv); + if (!result.ok) { + setError(result.error); + return; + } + setSummary(result.summary); + router.refresh(); + if (fileInputRef.current) fileInputRef.current.value = ""; + }); + }; + reader.readAsText(file); + } + + return ( + + Leads)`} /> + +
+ + + {error && ( +

+ {error} +

+ )} + + {summary && ( +
+ + {summary.imported} importado(s) de {summary.total} linha(s) + {summary.duplicates > 0 && ` · ${summary.duplicates} duplicado(s)`} + {summary.invalid > 0 && ` · ${summary.invalid} inválido(s)`} + {summary.suppressed > 0 && ` · ${summary.suppressed} na lista de bloqueio`} +
+ )} +
+ + {leads.length === 0 ? ( + + ) : ( + + + + + + + + + + {leads.slice(0, PREVIEW_LIMIT).map((lead) => ( + + + + + + ))} + +
NomeTelefoneStatus
{lead.name || "—"}{lead.phoneNormalized} + {lead.status} +
+ )} + {leads.length > PREVIEW_LIMIT && ( +

+ Mostrando {PREVIEW_LIMIT} de {leads.length} leads. +

+ )} +
+ ); +} diff --git a/apps/frontend/src/app/app/discador/campanhas/[id]/lifecycle-actions.tsx b/apps/frontend/src/app/app/discador/campanhas/[id]/lifecycle-actions.tsx new file mode 100644 index 0000000..076a1b1 --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/[id]/lifecycle-actions.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Pause, Play, Square, Waves } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Panel } from "@/components/ui/panel"; +import type { CampaignStatus } from "@/lib/campaign-types"; +import { startCampaign, pauseCampaign, drainCampaign, stopCampaign } from "../actions"; + +const CAN_START: CampaignStatus[] = ["DRAFT", "READY", "PAUSED", "WAITING_SCHEDULE"]; +const CAN_PAUSE: CampaignStatus[] = ["RUNNING"]; +const CAN_DRAIN: CampaignStatus[] = ["RUNNING", "PAUSED"]; +const CAN_STOP: CampaignStatus[] = ["DRAFT", "READY", "WAITING_SCHEDULE", "RUNNING", "PAUSED", "DRAINING"]; + +export function LifecycleActions({ campaignId, status }: { campaignId: string; status: CampaignStatus }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function run(action: (id: string) => Promise<{ ok: true } | { ok: false; error: string }>) { + setError(null); + startTransition(async () => { + const result = await action(campaignId); + if (!result.ok) { + setError(result.error); + return; + } + router.refresh(); + }); + } + + const noActionAvailable = !CAN_START.includes(status) && !CAN_PAUSE.includes(status) && !CAN_DRAIN.includes(status) && !CAN_STOP.includes(status); + + return ( + +
+ {CAN_START.includes(status) && ( + + )} + {CAN_PAUSE.includes(status) && ( + + )} + {CAN_DRAIN.includes(status) && ( + + )} + {CAN_STOP.includes(status) && ( + + )} + {noActionAvailable &&

Nenhuma ação de ciclo de vida disponível neste status.

} +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/app/app/discador/campanhas/[id]/page.tsx b/apps/frontend/src/app/app/discador/campanhas/[id]/page.tsx new file mode 100644 index 0000000..77b61f7 --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/[id]/page.tsx @@ -0,0 +1,123 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ChevronLeft } from "lucide-react"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { InstrumentTile } from "@/components/ui/instrument-tile"; +import { formatDate, formatDuration, formatPercent } from "@/lib/format"; +import { DAYS_OF_WEEK_LABELS, type Campaign, type CampaignStats, type QueueOption, type TrunkOption } from "@/lib/campaign-types"; +import { LifecycleActions } from "./lifecycle-actions"; +import { LeadsPanel } from "./leads-panel"; +import { DeleteCampaignAction } from "./delete-action"; + +interface Lead { + id: string; + name: string | null; + phoneNormalized: string; + status: string; +} + +export default async function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const session = await requireSession(); + + let campaign: Campaign; + try { + campaign = await apiFetch(`/campaigns/${id}`, session.accessToken); + } catch (err) { + if (err instanceof ApiError && err.status === 404) notFound(); + throw err; + } + + const [stats, queues, trunks, leads] = await Promise.all([ + apiFetch(`/campaigns/${id}/stats`, session.accessToken), + apiFetch("/queues", session.accessToken), + apiFetch("/trunks", session.accessToken), + apiFetch(`/campaigns/${id}/leads`, session.accessToken), + ]); + + const queueName = queues.find((q) => q.id === campaign.queueId)?.name ?? campaign.queueId; + const trunkName = trunks.find((t) => t.id === campaign.trunkId)?.name ?? campaign.trunkId; + + return ( +
+ + + Campanhas + + +
+

{campaign.name}

+ +
+ {campaign.description &&

{campaign.description}

} + + + +
+ 0} /> + + + +
+ + + +
+ + + ` : ""}`.trim() : "—"} /> + + + + + + + + + 0 ? campaign.daysOfWeek.map((d) => DAYS_OF_WEEK_LABELS[d]).join(", ") : "todos"} /> + + + + + + +
+
+ + + + + +
+ +
+
+
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/frontend/src/app/app/discador/campanhas/actions.ts b/apps/frontend/src/app/app/discador/campanhas/actions.ts new file mode 100644 index 0000000..8c33f7f --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/actions.ts @@ -0,0 +1,136 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { Campaign, LeadImportSummary } from "@/lib/campaign-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON — usa a mensagem crua + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreateCampaignInput { + name: string; + description?: string; + queueId: string; + trunkId: string; + callerIdName?: string; + callerIdNumber?: string; + timezone?: string; + startDate?: string; + endDate?: string; + daysOfWeek?: number[]; + startTime?: string; + endTime?: string; + maxCps?: number; + maxConcurrentCalls?: number; + pacingInitial?: number; + pacingMin?: number; + pacingMax?: number; + targetAbandonRate?: number; + ringTimeout?: number; + maxAttempts?: number; + recordingEnabled?: boolean; + avmdEnabled?: boolean; + aiTranscriptionEnabled?: boolean; + aiAnalysisEnabled?: boolean; + /** CSV cru (com cabeçalho, agente.md secao 69) — quando presente, é + * importado logo após criar a campanha, na mesma ação. */ + leadsCsv?: string; +} + +export async function createCampaign( + input: CreateCampaignInput, +): Promise<{ ok: true; campaign: Campaign; leadImport: LeadImportSummary | null } | { ok: false; error: string }> { + const session = await requireSession(); + const { leadsCsv, ...campaignInput } = input; + + let campaign: Campaign; + try { + campaign = await apiFetch("/campaigns", session.accessToken, { + method: "POST", + body: JSON.stringify(campaignInput), + }); + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } + + let leadImport: LeadImportSummary | null = null; + if (leadsCsv && leadsCsv.trim()) { + try { + leadImport = await apiFetch(`/campaigns/${campaign.id}/leads/import`, session.accessToken, { + method: "POST", + body: JSON.stringify({ csv: leadsCsv }), + }); + } catch (err) { + // A campanha já existe — não desfaz a criação por causa do CSV, + // só reporta que a importação falhou (o usuário pode tentar de novo + // na tela da campanha). + revalidatePath("/app/discador/campanhas"); + return { ok: false, error: `Campanha criada, mas a importação de leads falhou: ${extractErrorMessage(err)}` }; + } + } + + revalidatePath("/app/discador/campanhas"); + return { ok: true, campaign, leadImport }; +} + +async function transition(id: string, action: "start" | "pause" | "drain" | "stop"): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/campaigns/${id}/${action}`, session.accessToken, { method: "POST" }); + revalidatePath("/app/discador/campanhas"); + revalidatePath(`/app/discador/campanhas/${id}`); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function startCampaign(id: string) { + return transition(id, "start"); +} +export async function pauseCampaign(id: string) { + return transition(id, "pause"); +} +export async function drainCampaign(id: string) { + return transition(id, "drain"); +} +export async function stopCampaign(id: string) { + return transition(id, "stop"); +} + +export async function deleteCampaign(id: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/campaigns/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/discador/campanhas"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function importLeads(id: string, csv: string): Promise<{ ok: true; summary: LeadImportSummary } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const summary = await apiFetch(`/campaigns/${id}/leads/import`, session.accessToken, { + method: "POST", + body: JSON.stringify({ csv }), + }); + revalidatePath(`/app/discador/campanhas/${id}`); + return { ok: true, summary }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/discador/campanhas/campanhas-view.tsx b/apps/frontend/src/app/app/discador/campanhas/campanhas-view.tsx new file mode 100644 index 0000000..6a309a3 --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/campanhas-view.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { ArrowUpDown, Megaphone, Plus, Search, TriangleAlert } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, Select } from "@/components/ui/input"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatDate } from "@/lib/format"; +import type { Campaign, CampaignStatus } from "@/lib/campaign-types"; + +const STATUS_FILTERS: Array<{ value: CampaignStatus | "ALL"; label: string }> = [ + { value: "ALL", label: "Todos os status" }, + { value: "DRAFT", label: "Rascunho" }, + { value: "READY", label: "Pronta" }, + { value: "WAITING_SCHEDULE", label: "Aguardando horário" }, + { value: "RUNNING", label: "Em execução" }, + { value: "PAUSED", label: "Pausada" }, + { value: "DRAINING", label: "Drenando" }, + { value: "STOPPED", label: "Parada" }, + { value: "COMPLETED", label: "Concluída" }, + { value: "ERROR", label: "Erro" }, +]; + +export function CampanhasView({ + campaigns, + queueNames, + trunkNames, + canCreate, +}: { + campaigns: Campaign[]; + queueNames: Record; + trunkNames: Record; + canCreate: boolean; +}) { + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("ALL"); + const [sortDesc, setSortDesc] = useState(true); + + const rows = useMemo(() => { + const q = query.toLowerCase(); + const filtered = campaigns.filter((c) => (status === "ALL" || c.status === status) && c.name.toLowerCase().includes(q)); + return filtered.sort((a, b) => (sortDesc ? b.updatedAt.localeCompare(a.updatedAt) : a.updatedAt.localeCompare(b.updatedAt))); + }, [campaigns, query, status, sortDesc]); + + return ( +
+
+
+

Campanhas

+

+ Discador preditivo deste tenant (agente.md secao 63-86) — cada campanha origina chamadas contra uma fila + e um tronco específicos, respeitando CPS/concorrência configurados. +

+
+ {canCreate ? ( + + ) : ( +
+ + Crie uma fila e um tronco antes de criar a primeira campanha. +
+ )} +
+ + + + +
+
+
+ + setQuery(e.target.value)} placeholder="Buscar por nome…" className="pl-8" aria-label="Buscar campanha" /> +
+ +
+ +
+ + {rows.length === 0 ? ( + 0 ? "Nenhuma campanha bate com esse filtro" : "Nenhuma campanha cadastrada ainda"} + description={ + campaigns.length > 0 + ? "Tente outra busca ou status." + : canCreate + ? "Crie a primeira campanha deste tenant." + : "Crie uma fila (Call Center > Filas) e um tronco (Telefonia > Troncos) primeiro." + } + action={ + campaigns.length === 0 && + canCreate && ( + + ) + } + /> + ) : ( + + + + + + + + + + + + {rows.map((c) => ( + + + + + + + + ))} + +
NomeFilaTroncoStatus setSortDesc((d) => !d)} aria-sort={sortDesc ? "descending" : "ascending"}> + Atualizada +
+ + + {c.name} + + {queueNames[c.queueId] ?? "—"}{trunkNames[c.trunkId] ?? "—"} + + {formatDate(c.updatedAt)}
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/app/app/discador/campanhas/new/page.tsx b/apps/frontend/src/app/app/discador/campanhas/new/page.tsx new file mode 100644 index 0000000..3ac3a22 --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/new/page.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { ChevronLeft, TriangleAlert } from "lucide-react"; +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { QueueOption, TrunkOption } from "@/lib/campaign-types"; +import { CampaignWizard } from "./wizard"; + +export default async function NewCampaignPage() { + const session = await requireSession(); + const [queues, trunks] = await Promise.all([ + apiFetch("/queues", session.accessToken), + apiFetch("/trunks", session.accessToken), + ]); + + return ( +
+ + + Campanhas + + + {queues.length === 0 || trunks.length === 0 ? ( +
+ +
+

Faltam pré-requisitos pra criar uma campanha

+

+ {queues.length === 0 && trunks.length === 0 + ? "Este tenant ainda não tem nenhuma fila nem nenhum tronco." + : queues.length === 0 + ? "Este tenant ainda não tem nenhuma fila." + : "Este tenant ainda não tem nenhum tronco."}{" "} + Uma campanha sempre origina chamadas contra uma fila e um tronco específicos (agente.md secao 63). +

+
+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/frontend/src/app/app/discador/campanhas/new/wizard.tsx b/apps/frontend/src/app/app/discador/campanhas/new/wizard.tsx new file mode 100644 index 0000000..c6834ac --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/new/wizard.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { useState, useTransition, type ChangeEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Check, ChevronLeft, ChevronRight, FileUp, Upload } from "lucide-react"; +import { Panel } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, Select, FieldLabel } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { formatPercent } from "@/lib/format"; +import { DAYS_OF_WEEK_LABELS, type QueueOption, type TrunkOption } from "@/lib/campaign-types"; +import { createCampaign } from "../actions"; + +const STEP_LABELS = ["Geral", "Telefonia", "Discagem", "Horários", "Gravação e IA", "Leads", "Revisão"] as const; + +interface WizardState { + name: string; + description: string; + queueId: string; + trunkId: string; + callerIdName: string; + callerIdNumber: string; + maxCps: string; + maxConcurrentCalls: string; + pacingInitial: string; + pacingMin: string; + pacingMax: string; + targetAbandonRatePercent: string; + ringTimeout: string; + maxAttempts: string; + timezone: string; + startDate: string; + endDate: string; + daysOfWeek: number[]; + startTime: string; + endTime: string; + recordingEnabled: boolean; + avmdEnabled: boolean; + aiTranscriptionEnabled: boolean; + aiAnalysisEnabled: boolean; + leadsCsv: string; + leadsFileName: string; +} + +const INITIAL_STATE: WizardState = { + name: "", + description: "", + queueId: "", + trunkId: "", + callerIdName: "", + callerIdNumber: "", + maxCps: "", + maxConcurrentCalls: "", + pacingInitial: "1", + pacingMin: "1", + pacingMax: "3", + targetAbandonRatePercent: "3", + ringTimeout: "30", + maxAttempts: "3", + timezone: "America/Sao_Paulo", + startDate: "", + endDate: "", + daysOfWeek: [1, 2, 3, 4, 5], + startTime: "", + endTime: "", + recordingEnabled: false, + avmdEnabled: false, + aiTranscriptionEnabled: false, + aiAnalysisEnabled: false, + leadsCsv: "", + leadsFileName: "", +}; + +function numOrUndef(value: string): number | undefined { + if (value.trim() === "") return undefined; + const n = Number(value); + return Number.isNaN(n) ? undefined : n; +} + +function validateStep(step: number, s: WizardState): string | null { + if (step === 0 && !s.name.trim()) return "Dê um nome à campanha."; + if (step === 1 && (!s.queueId || !s.trunkId)) return "Escolha uma fila e um tronco."; + if (step === 2) { + const min = numOrUndef(s.pacingMin); + const max = numOrUndef(s.pacingMax); + if (min !== undefined && max !== undefined && min > max) return "Pacing mínimo não pode ser maior que o máximo."; + } + if (step === 3 && s.startDate && s.endDate && s.startDate > s.endDate) return "Data final não pode ser antes da inicial."; + return null; +} + +export function CampaignWizard({ queues, trunks }: { queues: QueueOption[]; trunks: TrunkOption[] }) { + const router = useRouter(); + const [step, setStep] = useState(0); + const [state, setState] = useState(INITIAL_STATE); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function patch(partial: Partial) { + setState((s) => ({ ...s, ...partial })); + } + + function goNext() { + const validationError = validateStep(step, state); + if (validationError) { + setError(validationError); + return; + } + setError(null); + setStep((s) => Math.min(s + 1, STEP_LABELS.length - 1)); + } + + function goBack() { + setError(null); + setStep((s) => Math.max(s - 1, 0)); + } + + function onSubmit() { + setError(null); + startTransition(async () => { + const result = await createCampaign({ + name: state.name.trim(), + description: state.description.trim() || undefined, + queueId: state.queueId, + trunkId: state.trunkId, + callerIdName: state.callerIdName.trim() || undefined, + callerIdNumber: state.callerIdNumber.trim() || undefined, + timezone: state.timezone.trim() || undefined, + startDate: state.startDate || undefined, + endDate: state.endDate || undefined, + daysOfWeek: state.daysOfWeek.length > 0 ? state.daysOfWeek : undefined, + startTime: state.startTime || undefined, + endTime: state.endTime || undefined, + maxCps: numOrUndef(state.maxCps), + maxConcurrentCalls: numOrUndef(state.maxConcurrentCalls), + pacingInitial: numOrUndef(state.pacingInitial), + pacingMin: numOrUndef(state.pacingMin), + pacingMax: numOrUndef(state.pacingMax), + targetAbandonRate: numOrUndef(state.targetAbandonRatePercent) !== undefined ? Number(state.targetAbandonRatePercent) / 100 : undefined, + ringTimeout: numOrUndef(state.ringTimeout), + maxAttempts: numOrUndef(state.maxAttempts), + recordingEnabled: state.recordingEnabled, + avmdEnabled: state.avmdEnabled, + aiTranscriptionEnabled: state.aiTranscriptionEnabled, + aiAnalysisEnabled: state.aiAnalysisEnabled, + leadsCsv: state.leadsCsv || undefined, + }); + if (!result.ok) { + setError(result.error); + return; + } + router.push(`/app/discador/campanhas/${result.campaign.id}`); + router.refresh(); + }); + } + + return ( +
+
+

Nova campanha

+

7 passos, agente.md secao 170 — nada é criado até a revisão final.

+
+ + + + + {step === 0 && } + {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } + {step === 5 && } + {step === 6 && } + + + {error && ( +

+ {error} +

+ )} + +
+ + {step < STEP_LABELS.length - 1 ? ( + + ) : ( + + )} +
+
+ ); +} + +function Stepper({ current }: { current: number }) { + return ( +
    + {STEP_LABELS.map((label, i) => ( +
  1. + + {i < current ? : {i + 1}} + {label} + +
  2. + ))} +
+ ); +} + +interface StepProps { + state: WizardState; + patch: (partial: Partial) => void; + disabled: boolean; +} + +function StepGeneral({ state, patch, disabled }: StepProps) { + return ( +
+
+ Nome + patch({ name: e.target.value })} placeholder="Ex.: Cobrança — Julho" disabled={disabled} /> +
+
+ Descrição (opcional) + patch({ description: e.target.value })} disabled={disabled} /> +
+
+ ); +} + +function StepTelephony({ state, patch, queues, trunks, disabled }: StepProps & { queues: QueueOption[]; trunks: TrunkOption[] }) { + return ( +
+
+
+ Fila + +
+
+ Tronco + +
+
+
+
+ Caller ID — nome (opcional) + patch({ callerIdName: e.target.value })} disabled={disabled} /> +
+
+ Caller ID — número (opcional) + patch({ callerIdNumber: e.target.value.replace(/[^0-9]/g, "") })} + className="font-mono" + disabled={disabled} + /> +
+
+
+ ); +} + +function StepDialing({ state, patch, disabled }: StepProps) { + return ( +
+
+
+ CPS máximo (opcional) + patch({ maxCps: e.target.value })} disabled={disabled} /> +
+
+ Chamadas simultâneas (opcional) + patch({ maxConcurrentCalls: e.target.value })} disabled={disabled} /> +
+
+ Timeout de toque (s) + patch({ ringTimeout: e.target.value })} disabled={disabled} /> +
+
+
+
+ Pacing inicial + patch({ pacingInitial: e.target.value })} disabled={disabled} /> +
+
+ Pacing mínimo + patch({ pacingMin: e.target.value })} disabled={disabled} /> +
+
+ Pacing máximo + patch({ pacingMax: e.target.value })} disabled={disabled} /> +
+
+
+
+ Meta de abandono (%) + patch({ targetAbandonRatePercent: e.target.value })} disabled={disabled} /> +
+
+ Tentativas máximas por lead + patch({ maxAttempts: e.target.value })} disabled={disabled} /> +
+
+
+ ); +} + +function StepSchedule({ state, patch, disabled }: StepProps) { + function toggleDay(day: number) { + patch({ daysOfWeek: state.daysOfWeek.includes(day) ? state.daysOfWeek.filter((d) => d !== day) : [...state.daysOfWeek, day].sort() }); + } + + return ( +
+
+ Fuso horário + patch({ timezone: e.target.value })} disabled={disabled} /> +
+
+
+ Data inicial (opcional) + patch({ startDate: e.target.value })} disabled={disabled} /> +
+
+ Data final (opcional) + patch({ endDate: e.target.value })} disabled={disabled} /> +
+
+
+
+ Horário inicial (opcional) + patch({ startTime: e.target.value })} disabled={disabled} /> +
+
+ Horário final (opcional) + patch({ endTime: e.target.value })} disabled={disabled} /> +
+
+
+ Dias da semana +
+ {Object.entries(DAYS_OF_WEEK_LABELS).map(([day, label]) => { + const active = state.daysOfWeek.includes(Number(day)); + return ( + + ); + })} +
+
+
+ ); +} + +function StepRecordingAI({ state, patch, disabled }: StepProps) { + const options: Array<{ key: keyof WizardState; label: string; description: string }> = [ + { key: "recordingEnabled", label: "Gravar chamadas", description: "Grava as duas pernas em estéreo (agente.md secao 90-94)." }, + { key: "avmdEnabled", label: "Detecção de secretária eletrônica (AVMD)", description: "Tenta identificar caixa postal antes de bridgear com um agente." }, + { key: "aiTranscriptionEnabled", label: "Transcrição por IA", description: "Depende de gravação habilitada e do plano do tenant permitir IA." }, + { key: "aiAnalysisEnabled", label: "Análise de chamada por IA", description: "Sentimento, tópicos, objeções — roda depois da transcrição." }, + ]; + + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function StepLeads({ state, patch, disabled }: StepProps) { + function onFile(e: ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => patch({ leadsCsv: String(reader.result ?? ""), leadsFileName: file.name }); + reader.readAsText(file); + } + + const lineCount = state.leadsCsv ? state.leadsCsv.trim().split("\n").length : 0; + + return ( +
+

+ Formato mínimo (agente.md secao 69): CSV com cabeçalho, colunas nome,telefone. Pode + pular esta etapa e importar leads depois, na página da campanha. +

+ + {state.leadsCsv && ( + + )} +
+ ); +} + +function StepReview({ state, queues, trunks }: { state: WizardState; queues: QueueOption[]; trunks: TrunkOption[] }) { + const queueName = queues.find((q) => q.id === state.queueId)?.name ?? "—"; + const trunkName = trunks.find((t) => t.id === state.trunkId)?.name ?? "—"; + const abandonRate = numOrUndef(state.targetAbandonRatePercent); + + const rows: Array<[string, string]> = [ + ["Nome", state.name || "—"], + ["Descrição", state.description || "—"], + ["Fila", queueName], + ["Tronco", trunkName], + ["Caller ID", state.callerIdName || state.callerIdNumber ? `${state.callerIdName} ${state.callerIdNumber ? `<${state.callerIdNumber}>` : ""}`.trim() : "—"], + ["CPS máximo", state.maxCps || "sem limite próprio"], + ["Chamadas simultâneas", state.maxConcurrentCalls || "sem limite próprio"], + ["Pacing", `${state.pacingInitial} (min ${state.pacingMin}, max ${state.pacingMax})`], + ["Meta de abandono", abandonRate !== undefined ? formatPercent(abandonRate / 100) : "—"], + ["Timeout de toque", `${state.ringTimeout}s`], + ["Tentativas máximas", state.maxAttempts], + ["Fuso horário", state.timezone], + ["Período", state.startDate || state.endDate ? `${state.startDate || "sem início"} até ${state.endDate || "sem fim"}` : "sem restrição de data"], + ["Dias da semana", state.daysOfWeek.map((d) => DAYS_OF_WEEK_LABELS[d]).join(", ") || "todos"], + ["Horário", state.startTime || state.endTime ? `${state.startTime || "00:00"} – ${state.endTime || "23:59"}` : "o dia inteiro"], + ["Gravação", state.recordingEnabled ? "habilitada" : "desabilitada"], + ["AVMD", state.avmdEnabled ? "habilitado" : "desabilitado"], + ["Transcrição IA", state.aiTranscriptionEnabled ? "habilitada" : "desabilitada"], + ["Análise IA", state.aiAnalysisEnabled ? "habilitada" : "desabilitada"], + ["Leads", state.leadsFileName ? `${state.leadsFileName} (${state.leadsCsv.trim().split("\n").length} linhas)` : "nenhum arquivo — importa depois"], + ]; + + return ( +
+
+ + Confira antes de criar — nada foi salvo ainda. +
+
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ); +} diff --git a/apps/frontend/src/app/app/discador/campanhas/page.tsx b/apps/frontend/src/app/app/discador/campanhas/page.tsx new file mode 100644 index 0000000..4385397 --- /dev/null +++ b/apps/frontend/src/app/app/discador/campanhas/page.tsx @@ -0,0 +1,25 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Campaign, QueueOption, TrunkOption } from "@/lib/campaign-types"; +import { CampanhasView } from "./campanhas-view"; + +export default async function CampanhasPage() { + const session = await requireSession(); + const [campaigns, queues, trunks] = await Promise.all([ + apiFetch("/campaigns", session.accessToken), + apiFetch("/queues", session.accessToken), + apiFetch("/trunks", session.accessToken), + ]); + + const queueNames = Object.fromEntries(queues.map((q) => [q.id, q.name])); + const trunkNames = Object.fromEntries(trunks.map((t) => [t.id, t.name])); + + return ( + 0 && trunks.length > 0} + /> + ); +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 01dc90f..0648e52 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -11,14 +11,14 @@ import { } from "lucide-react"; import type { NavSection } from "../shell/nav-types"; -/** IA fixa do menu Tenant (agente.md secao 169) — "Dashboard" (PHASE 23) e - * "Telefonia > Ramais" (PHASE 25) têm página construída até agora, o - * resto existe pra provar a arquitetura completa (Product Principle #5 - * em PRODUCT.md), renderizado como indisponível em vez de virar link - * morto. "Itens sem permissão não aparecem" (secao 169) ainda não é - * aplicado aqui — este primeiro corte mostra a IA inteira pra qualquer - * usuário tenant autenticado; filtrar por permission real fica pra - * quando mais telas existirem pra testar contra. */ +/** IA fixa do menu Tenant (agente.md secao 169) — "Dashboard" (PHASE 23), + * "Telefonia > Ramais" (PHASE 25) e "Discador > Campanhas" (PHASE 26) têm + * página construída até agora, o resto existe pra provar a arquitetura + * completa (Product Principle #5 em PRODUCT.md), renderizado como + * indisponível em vez de virar link morto. "Itens sem permissão não + * aparecem" (secao 169) ainda não é aplicado aqui — este primeiro corte + * mostra a IA inteira pra qualquer usuário tenant autenticado; filtrar por + * permission real fica pra quando mais telas existirem pra testar contra. */ export const TENANT_NAV: NavSection[] = [ { label: "Dashboard", @@ -30,7 +30,11 @@ export const TENANT_NAV: NavSection[] = [ label: "Discador", icon: PhoneOutgoing, children: [ - { label: "Campanhas" }, + { + label: "Campanhas", + href: "/app/discador/campanhas", + description: "Discador preditivo — campanhas deste tenant", + }, { label: "Leads" }, { label: "Importações" }, { label: "Callbacks" }, diff --git a/apps/frontend/src/components/ui/status-badge.tsx b/apps/frontend/src/components/ui/status-badge.tsx index 739082b..a5208a2 100644 --- a/apps/frontend/src/components/ui/status-badge.tsx +++ b/apps/frontend/src/components/ui/status-badge.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"; const STATUS_CONFIG: Record = { DRAFT: { label: "Rascunho", color: "status-gray", shape: "ring" }, READY: { label: "Pronta", color: "status-blue", shape: "ring" }, + WAITING_SCHEDULE: { label: "Aguardando horário", color: "status-blue", shape: "square" }, RUNNING: { label: "Em execução", color: "status-green", shape: "dot" }, PAUSED: { label: "Pausada", color: "status-yellow", shape: "square" }, DRAINING: { label: "Drenando", color: "status-orange", shape: "square" }, diff --git a/apps/frontend/src/lib/campaign-types.ts b/apps/frontend/src/lib/campaign-types.ts new file mode 100644 index 0000000..3b34ab8 --- /dev/null +++ b/apps/frontend/src/lib/campaign-types.ts @@ -0,0 +1,75 @@ +export type CampaignStatus = "DRAFT" | "READY" | "WAITING_SCHEDULE" | "RUNNING" | "PAUSED" | "DRAINING" | "STOPPED" | "COMPLETED" | "ERROR"; + +export interface Campaign { + id: string; + tenantId: string; + name: string; + description: string | null; + queueId: string; + trunkId: string; + callerIdName: string | null; + callerIdNumber: string | null; + timezone: string; + startDate: string | null; + endDate: string | null; + daysOfWeek: number[]; + startTime: string | null; + endTime: string | null; + maxCps: number | null; + maxConcurrentCalls: number | null; + pacingInitial: number; + pacingMin: number; + pacingMax: number; + targetAbandonRate: number; + ringTimeout: number; + maxAttempts: number; + recordingEnabled: boolean; + avmdEnabled: boolean; + aiTranscriptionEnabled: boolean; + aiAnalysisEnabled: boolean; + status: CampaignStatus; + createdAt: string; + updatedAt: string; +} + +export interface CampaignStats { + status: CampaignStatus; + stats: { + answerProbability: number | null; + averageAnswerDelay: number | null; + averageTalkTime: number | null; + abandonRate: number | null; + pacingFactor: number; + }; + agentsByState: Record; + callsInFlight: number; +} + +export interface LeadImportSummary { + total: number; + valid: number; + invalid: number; + duplicates: number; + imported: number; + suppressed: number; +} + +export interface QueueOption { + id: string; + name: string; +} + +export interface TrunkOption { + id: string; + name: string; +} + +export const DAYS_OF_WEEK_LABELS: Record = { + 1: "Seg", + 2: "Ter", + 3: "Qua", + 4: "Qui", + 5: "Sex", + 6: "Sáb", + 7: "Dom", +};