feat(frontend): Discador > Campanhas — wizard de 7 passos, ciclo de vida, leads
Tela completa de campanhas do discador preditivo: listagem com busca/
filtro/status, wizard de criacao em 7 passos exatamente como a
especificacao nomeia (Geral -> Telefonia -> Discagem -> Horarios ->
Gravacao e IA -> Leads -> Revisao, agente.md secao 170) — nada e criado
ate confirmar na revisao, upload de CSV de leads na mesma acao de
criacao — e detalhe com acoes de ciclo de vida (iniciar/pausar/drenar/
parar, cada botao so aparece quando a transicao e valida pro status
atual), pacing ao vivo, previa de leads com import adicional, e remover.
Corrige de quebra um erro real de build: Server Actions exportadas como
arrow function que so repassam argumentos pra outra funcao quebram
("Server Actions must be async functions") — precisam ser declaradas
como async function de verdade.
Testado ponta a ponta com fila+tronco reais: os 7 passos preenchidos e
revisados, campanha criada com CSV de 3 leads importado, ciclo de vida
completo start->pause->drain->stop->delete via API, e confirmado que uma
campanha iniciada de verdade e pega pelo PredictiveDialerEngine real
rodando em Docker (stats deixam de ser null depois de alguns segundos).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
56
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
|
a PHASE 14, mas a UI não mostra consumo/limite antes de tentar
|
||||||
criar — o erro 403 apareceria só depois do submit
|
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)
|
Security, Tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
|
After Width: | Height: | Size: 118 KiB |
BIN
apps/frontend/.impeccable/review/campanhas-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/frontend/.impeccable/review/campanhas-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/frontend/.impeccable/review/campanhas-with-data-mobile.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/frontend/.impeccable/review/wizard-step1-desktop.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/frontend/.impeccable/review/wizard-step2-desktop.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/frontend/.impeccable/review/wizard-step4-desktop.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/frontend/.impeccable/review/wizard-step6-leads-desktop.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
apps/frontend/.impeccable/review/wizard-step7-review-desktop.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
@@ -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<string | null>(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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button type="button" variant="destructive" onClick={onClick} disabled={pending}>
|
||||||
|
<Trash2 className="h-4 w-4" aria-hidden />
|
||||||
|
{pending ? "Removendo…" : confirming ? `Confirmar remoção de "${campaignName}"` : "Remover campanha"}
|
||||||
|
</Button>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [summary, setSummary] = useState<LeadImportSummary | null>(null);
|
||||||
|
|
||||||
|
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title="Leads" description={`${leads.length} lead(s) carregado(s) nesta prévia (a lista completa vive em Discador > Leads)`} />
|
||||||
|
|
||||||
|
<div className="border-b border-border px-5 py-4">
|
||||||
|
<label
|
||||||
|
className={`flex cursor-pointer items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border px-4 py-3 text-center text-sm text-muted-foreground transition-colors hover:border-primary/40 hover:bg-muted/40 ${pending ? "pointer-events-none opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<FileUp className="h-4 w-4" aria-hidden />
|
||||||
|
{pending ? "Importando…" : "Importar leads de um CSV (nome,telefone)"}
|
||||||
|
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFile} className="sr-only" disabled={pending} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-status-green/30 bg-status-green/10 px-3 py-2 text-sm text-foreground">
|
||||||
|
<Upload className="h-4 w-4 shrink-0 text-status-green" aria-hidden />
|
||||||
|
{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`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leads.length === 0 ? (
|
||||||
|
<EmptyState title="Nenhum lead carregado ainda" description="Importe um CSV com nome e telefone pra começar." />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<TR>
|
||||||
|
<TH>Nome</TH>
|
||||||
|
<TH>Telefone</TH>
|
||||||
|
<TH>Status</TH>
|
||||||
|
</TR>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{leads.slice(0, PREVIEW_LIMIT).map((lead) => (
|
||||||
|
<TR key={lead.id}>
|
||||||
|
<TD>{lead.name || "—"}</TD>
|
||||||
|
<TD className="font-mono tabular-nums">{lead.phoneNormalized}</TD>
|
||||||
|
<TD>
|
||||||
|
<Pill>{lead.status}</Pill>
|
||||||
|
</TD>
|
||||||
|
</TR>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
{leads.length > PREVIEW_LIMIT && (
|
||||||
|
<p className="border-t border-border px-5 py-2.5 text-xs text-muted-foreground">
|
||||||
|
Mostrando {PREVIEW_LIMIT} de {leads.length} leads.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{CAN_START.includes(status) && (
|
||||||
|
<Button type="button" size="sm" onClick={() => run(startCampaign)} disabled={pending}>
|
||||||
|
<Play className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Iniciar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{CAN_PAUSE.includes(status) && (
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => run(pauseCampaign)} disabled={pending}>
|
||||||
|
<Pause className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Pausar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{CAN_DRAIN.includes(status) && (
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => run(drainCampaign)} disabled={pending}>
|
||||||
|
<Waves className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Drenar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{CAN_STOP.includes(status) && (
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => run(stopCampaign)} disabled={pending}>
|
||||||
|
<Square className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Parar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{noActionAvailable && <p className="text-sm text-muted-foreground">Nenhuma ação de ciclo de vida disponível neste status.</p>}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
apps/frontend/src/app/app/discador/campanhas/[id]/page.tsx
Normal file
@@ -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<Campaign>(`/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<CampaignStats>(`/campaigns/${id}/stats`, session.accessToken),
|
||||||
|
apiFetch<QueueOption[]>("/queues", session.accessToken),
|
||||||
|
apiFetch<TrunkOption[]>("/trunks", session.accessToken),
|
||||||
|
apiFetch<Lead[]>(`/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 (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-5">
|
||||||
|
<Link
|
||||||
|
href="/app/discador/campanhas"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden />
|
||||||
|
Campanhas
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">{campaign.name}</h1>
|
||||||
|
<StatusBadge status={campaign.status} />
|
||||||
|
</div>
|
||||||
|
{campaign.description && <p className="-mt-3 text-sm text-muted-foreground">{campaign.description}</p>}
|
||||||
|
|
||||||
|
<LifecycleActions campaignId={campaign.id} status={campaign.status} />
|
||||||
|
|
||||||
|
<section aria-label="Pacing ao vivo" className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<InstrumentTile label="Chamadas em curso" value={stats.callsInFlight} live={stats.callsInFlight > 0} />
|
||||||
|
<InstrumentTile
|
||||||
|
label="Prob. de atender"
|
||||||
|
value={stats.stats.answerProbability !== null ? formatPercent(stats.stats.answerProbability) : null}
|
||||||
|
pending="Sem tentativas registradas ainda."
|
||||||
|
/>
|
||||||
|
<InstrumentTile
|
||||||
|
label="TMA"
|
||||||
|
value={stats.stats.averageTalkTime !== null ? formatDuration(stats.stats.averageTalkTime) : null}
|
||||||
|
pending="Sem chamadas atendidas ainda."
|
||||||
|
/>
|
||||||
|
<InstrumentTile label="Fator de pacing" value={stats.stats.pacingFactor.toFixed(2)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title="Configuração" />
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 p-5 sm:grid-cols-2">
|
||||||
|
<Field label="Fila" value={queueName} />
|
||||||
|
<Field label="Tronco" value={trunkName} />
|
||||||
|
<Field label="Caller ID" value={campaign.callerIdName || campaign.callerIdNumber ? `${campaign.callerIdName ?? ""} ${campaign.callerIdNumber ? `<${campaign.callerIdNumber}>` : ""}`.trim() : "—"} />
|
||||||
|
<Field label="Fuso horário" value={campaign.timezone} />
|
||||||
|
<Field label="CPS máximo" value={campaign.maxCps ? String(campaign.maxCps) : "sem limite próprio"} />
|
||||||
|
<Field label="Chamadas simultâneas" value={campaign.maxConcurrentCalls ? String(campaign.maxConcurrentCalls) : "sem limite próprio"} />
|
||||||
|
<Field label="Pacing" value={`${campaign.pacingInitial} (min ${campaign.pacingMin}, max ${campaign.pacingMax})`} />
|
||||||
|
<Field label="Meta de abandono" value={formatPercent(campaign.targetAbandonRate)} />
|
||||||
|
<Field label="Timeout de toque" value={`${campaign.ringTimeout}s`} />
|
||||||
|
<Field label="Tentativas máximas" value={String(campaign.maxAttempts)} />
|
||||||
|
<Field
|
||||||
|
label="Período"
|
||||||
|
value={campaign.startDate || campaign.endDate ? `${campaign.startDate ? formatDate(campaign.startDate) : "sem início"} até ${campaign.endDate ? formatDate(campaign.endDate) : "sem fim"}` : "sem restrição de data"}
|
||||||
|
/>
|
||||||
|
<Field label="Dias da semana" value={campaign.daysOfWeek.length > 0 ? campaign.daysOfWeek.map((d) => DAYS_OF_WEEK_LABELS[d]).join(", ") : "todos"} />
|
||||||
|
<Field label="Horário" value={campaign.startTime || campaign.endTime ? `${campaign.startTime ?? "00:00"} – ${campaign.endTime ?? "23:59"}` : "o dia inteiro"} />
|
||||||
|
<Field label="Gravação" value={campaign.recordingEnabled ? "habilitada" : "desabilitada"} />
|
||||||
|
<Field label="AVMD" value={campaign.avmdEnabled ? "habilitado" : "desabilitado"} />
|
||||||
|
<Field label="Transcrição IA" value={campaign.aiTranscriptionEnabled ? "habilitada" : "desabilitada"} />
|
||||||
|
<Field label="Análise IA" value={campaign.aiAnalysisEnabled ? "habilitada" : "desabilitada"} />
|
||||||
|
<Field label="Criada em" value={formatDate(campaign.createdAt)} />
|
||||||
|
</dl>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<LeadsPanel campaignId={campaign.id} leads={leads} status={campaign.status} />
|
||||||
|
|
||||||
|
<Panel className="border-destructive/30">
|
||||||
|
<PanelHeader title="Zona de risco" description="Só é possível remover uma campanha parada (nunca em execução ou drenando)." />
|
||||||
|
<div className="p-5">
|
||||||
|
<DeleteCampaignAction campaignId={campaign.id} campaignName={campaign.name} />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-foreground">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
apps/frontend/src/app/app/discador/campanhas/actions.ts
Normal file
@@ -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<Campaign>("/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<LeadImportSummary>(`/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<void>(`/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<LeadImportSummary>(`/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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
165
apps/frontend/src/app/app/discador/campanhas/campanhas-view.tsx
Normal file
@@ -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<string, string>;
|
||||||
|
trunkNames: Record<string, string>;
|
||||||
|
canCreate: boolean;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState<CampaignStatus | "ALL">("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 (
|
||||||
|
<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">Campanhas</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canCreate ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/app/discador/campanhas/new">
|
||||||
|
<Plus className="h-4 w-4" aria-hidden />
|
||||||
|
Nova campanha
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-status-yellow/40 bg-status-yellow/10 px-3 py-2 text-xs text-foreground">
|
||||||
|
<TriangleAlert className="h-4 w-4 shrink-0 text-status-yellow" aria-hidden />
|
||||||
|
Crie uma fila e um tronco antes de criar a primeira campanha.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title="Campanhas cadastradas" description={`${campaigns.length} campanha(s) neste tenant`} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<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 por nome…" className="pl-8" aria-label="Buscar campanha" />
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value as CampaignStatus | "ALL")}
|
||||||
|
className="w-auto"
|
||||||
|
aria-label="Filtrar por status"
|
||||||
|
>
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSortDesc((d) => !d)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{sortDesc ? "Mais recentes primeiro" : "Mais antigas primeiro"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={campaigns.length > 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 && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/app/discador/campanhas/new">
|
||||||
|
<Plus className="h-4 w-4" aria-hidden /> Criar a primeira campanha
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<TR>
|
||||||
|
<TH>Nome</TH>
|
||||||
|
<TH>Fila</TH>
|
||||||
|
<TH>Tronco</TH>
|
||||||
|
<TH>Status</TH>
|
||||||
|
<TH onClick={() => setSortDesc((d) => !d)} aria-sort={sortDesc ? "descending" : "ascending"}>
|
||||||
|
Atualizada
|
||||||
|
</TH>
|
||||||
|
</TR>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{rows.map((c) => (
|
||||||
|
<TR key={c.id}>
|
||||||
|
<TD>
|
||||||
|
<Link
|
||||||
|
href={`/app/discador/campanhas/${c.id}`}
|
||||||
|
className="flex items-center gap-2 font-medium text-foreground underline-offset-4 hover:text-primary hover:underline focus-visible:underline"
|
||||||
|
>
|
||||||
|
<Megaphone className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
{c.name}
|
||||||
|
</Link>
|
||||||
|
</TD>
|
||||||
|
<TD className="text-muted-foreground">{queueNames[c.queueId] ?? "—"}</TD>
|
||||||
|
<TD className="text-muted-foreground">{trunkNames[c.trunkId] ?? "—"}</TD>
|
||||||
|
<TD>
|
||||||
|
<StatusBadge status={c.status} />
|
||||||
|
</TD>
|
||||||
|
<TD className="text-muted-foreground">{formatDate(c.updatedAt)}</TD>
|
||||||
|
</TR>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/frontend/src/app/app/discador/campanhas/new/page.tsx
Normal file
@@ -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<QueueOption[]>("/queues", session.accessToken),
|
||||||
|
apiFetch<TrunkOption[]>("/trunks", session.accessToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-5">
|
||||||
|
<Link
|
||||||
|
href="/app/discador/campanhas"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden />
|
||||||
|
Campanhas
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{queues.length === 0 || trunks.length === 0 ? (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-status-yellow/40 bg-status-yellow/10 p-5">
|
||||||
|
<TriangleAlert className="mt-0.5 h-5 w-5 shrink-0 text-status-yellow" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Faltam pré-requisitos pra criar uma campanha</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{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).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CampaignWizard queues={queues} trunks={trunks} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
508
apps/frontend/src/app/app/discador/campanhas/new/wizard.tsx
Normal file
@@ -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<WizardState>(INITIAL_STATE);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function patch(partial: Partial<WizardState>) {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Nova campanha</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">7 passos, agente.md secao 170 — nada é criado até a revisão final.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stepper current={step} />
|
||||||
|
|
||||||
|
<Panel className="p-5">
|
||||||
|
{step === 0 && <StepGeneral state={state} patch={patch} disabled={pending} />}
|
||||||
|
{step === 1 && <StepTelephony state={state} patch={patch} queues={queues} trunks={trunks} disabled={pending} />}
|
||||||
|
{step === 2 && <StepDialing state={state} patch={patch} disabled={pending} />}
|
||||||
|
{step === 3 && <StepSchedule state={state} patch={patch} disabled={pending} />}
|
||||||
|
{step === 4 && <StepRecordingAI state={state} patch={patch} disabled={pending} />}
|
||||||
|
{step === 5 && <StepLeads state={state} patch={patch} disabled={pending} />}
|
||||||
|
{step === 6 && <StepReview state={state} queues={queues} trunks={trunks} />}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{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-between gap-3">
|
||||||
|
<Button type="button" variant="outline" onClick={goBack} disabled={step === 0 || pending}>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden />
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
{step < STEP_LABELS.length - 1 ? (
|
||||||
|
<Button type="button" onClick={goNext} disabled={pending}>
|
||||||
|
Continuar
|
||||||
|
<ChevronRight className="h-4 w-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" onClick={onSubmit} disabled={pending}>
|
||||||
|
{pending ? "Criando…" : "Criar campanha"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stepper({ current }: { current: number }) {
|
||||||
|
return (
|
||||||
|
<ol className="flex flex-wrap gap-2" aria-label="Passos da criação de campanha">
|
||||||
|
{STEP_LABELS.map((label, i) => (
|
||||||
|
<li key={label} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-current={i === current ? "step" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
i === current
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: i < current
|
||||||
|
? "border-status-green/40 bg-status-green/10 text-status-green"
|
||||||
|
: "border-border text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i < current ? <Check className="h-3 w-3" aria-hidden /> : <span className="font-mono tabular-nums">{i + 1}</span>}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepProps {
|
||||||
|
state: WizardState;
|
||||||
|
patch: (partial: Partial<WizardState>) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepGeneral({ state, patch, disabled }: StepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-name">Nome</FieldLabel>
|
||||||
|
<Input id="c-name" value={state.name} onChange={(e) => patch({ name: e.target.value })} placeholder="Ex.: Cobrança — Julho" disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-desc">Descrição (opcional)</FieldLabel>
|
||||||
|
<Input id="c-desc" value={state.description} onChange={(e) => patch({ description: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepTelephony({ state, patch, queues, trunks, disabled }: StepProps & { queues: QueueOption[]; trunks: TrunkOption[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-queue">Fila</FieldLabel>
|
||||||
|
<Select id="c-queue" value={state.queueId} onChange={(e) => patch({ queueId: e.target.value })} disabled={disabled}>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{queues.map((q) => (
|
||||||
|
<option key={q.id} value={q.id}>
|
||||||
|
{q.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-trunk">Tronco</FieldLabel>
|
||||||
|
<Select id="c-trunk" value={state.trunkId} onChange={(e) => patch({ trunkId: e.target.value })} disabled={disabled}>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{trunks.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-cid-name">Caller ID — nome (opcional)</FieldLabel>
|
||||||
|
<Input id="c-cid-name" value={state.callerIdName} onChange={(e) => patch({ callerIdName: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-cid-number">Caller ID — número (opcional)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="c-cid-number"
|
||||||
|
value={state.callerIdNumber}
|
||||||
|
onChange={(e) => patch({ callerIdNumber: e.target.value.replace(/[^0-9]/g, "") })}
|
||||||
|
className="font-mono"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepDialing({ state, patch, disabled }: StepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-cps">CPS máximo (opcional)</FieldLabel>
|
||||||
|
<Input id="c-cps" type="number" min={1} value={state.maxCps} onChange={(e) => patch({ maxCps: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-concurrent">Chamadas simultâneas (opcional)</FieldLabel>
|
||||||
|
<Input id="c-concurrent" type="number" min={1} value={state.maxConcurrentCalls} onChange={(e) => patch({ maxConcurrentCalls: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-ring">Timeout de toque (s)</FieldLabel>
|
||||||
|
<Input id="c-ring" type="number" min={5} max={120} value={state.ringTimeout} onChange={(e) => patch({ ringTimeout: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-pacing-initial">Pacing inicial</FieldLabel>
|
||||||
|
<Input id="c-pacing-initial" type="number" step="0.1" min={0.1} max={10} value={state.pacingInitial} onChange={(e) => patch({ pacingInitial: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-pacing-min">Pacing mínimo</FieldLabel>
|
||||||
|
<Input id="c-pacing-min" type="number" step="0.1" min={0.1} max={10} value={state.pacingMin} onChange={(e) => patch({ pacingMin: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-pacing-max">Pacing máximo</FieldLabel>
|
||||||
|
<Input id="c-pacing-max" type="number" step="0.1" min={0.1} max={10} value={state.pacingMax} onChange={(e) => patch({ pacingMax: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-abandon">Meta de abandono (%)</FieldLabel>
|
||||||
|
<Input id="c-abandon" type="number" step="0.1" min={0} max={100} value={state.targetAbandonRatePercent} onChange={(e) => patch({ targetAbandonRatePercent: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-attempts">Tentativas máximas por lead</FieldLabel>
|
||||||
|
<Input id="c-attempts" type="number" min={1} max={20} value={state.maxAttempts} onChange={(e) => patch({ maxAttempts: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-tz">Fuso horário</FieldLabel>
|
||||||
|
<Input id="c-tz" value={state.timezone} onChange={(e) => patch({ timezone: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-start-date">Data inicial (opcional)</FieldLabel>
|
||||||
|
<Input id="c-start-date" type="date" value={state.startDate} onChange={(e) => patch({ startDate: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-end-date">Data final (opcional)</FieldLabel>
|
||||||
|
<Input id="c-end-date" type="date" value={state.endDate} onChange={(e) => patch({ endDate: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-start-time">Horário inicial (opcional)</FieldLabel>
|
||||||
|
<Input id="c-start-time" type="time" value={state.startTime} onChange={(e) => patch({ startTime: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="c-end-time">Horário final (opcional)</FieldLabel>
|
||||||
|
<Input id="c-end-time" type="time" value={state.endTime} onChange={(e) => patch({ endTime: e.target.value })} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Dias da semana</FieldLabel>
|
||||||
|
<div className="flex flex-wrap gap-2" role="group" aria-label="Dias da semana">
|
||||||
|
{Object.entries(DAYS_OF_WEEK_LABELS).map(([day, label]) => {
|
||||||
|
const active = state.daysOfWeek.includes(Number(day));
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(Number(day))}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<label key={opt.key} className="flex items-start gap-3 rounded-md border border-border p-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(state[opt.key])}
|
||||||
|
onChange={(e) => patch({ [opt.key]: e.target.checked } as Partial<WizardState>)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-foreground">{opt.label}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{opt.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepLeads({ state, patch, disabled }: StepProps) {
|
||||||
|
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Formato mínimo (agente.md secao 69): CSV com cabeçalho, colunas <code className="font-mono">nome,telefone</code>. Pode
|
||||||
|
pular esta etapa e importar leads depois, na página da campanha.
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border px-6 py-10 text-center transition-colors hover:border-primary/40 hover:bg-muted/40",
|
||||||
|
disabled && "pointer-events-none opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileUp className="h-6 w-6 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="text-sm font-medium text-foreground">{state.leadsFileName || "Escolher arquivo CSV"}</span>
|
||||||
|
{state.leadsFileName && <span className="text-xs text-muted-foreground">{lineCount} linha(s) detectada(s)</span>}
|
||||||
|
<input type="file" accept=".csv,text/csv" onChange={onFile} className="sr-only" disabled={disabled} />
|
||||||
|
</label>
|
||||||
|
{state.leadsCsv && (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => patch({ leadsCsv: "", leadsFileName: "" })} disabled={disabled}>
|
||||||
|
Remover arquivo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<Upload className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
Confira antes de criar — nada foi salvo ainda.
|
||||||
|
</div>
|
||||||
|
<dl className="divide-y divide-border">
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<div key={label} className="flex items-center justify-between gap-4 py-2 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-right font-medium text-foreground">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/frontend/src/app/app/discador/campanhas/page.tsx
Normal file
@@ -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<Campaign[]>("/campaigns", session.accessToken),
|
||||||
|
apiFetch<QueueOption[]>("/queues", session.accessToken),
|
||||||
|
apiFetch<TrunkOption[]>("/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 (
|
||||||
|
<CampanhasView
|
||||||
|
campaigns={campaigns}
|
||||||
|
queueNames={queueNames}
|
||||||
|
trunkNames={trunkNames}
|
||||||
|
canCreate={queues.length > 0 && trunks.length > 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,14 +11,14 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { NavSection } from "../shell/nav-types";
|
import type { NavSection } from "../shell/nav-types";
|
||||||
|
|
||||||
/** IA fixa do menu Tenant (agente.md secao 169) — "Dashboard" (PHASE 23) e
|
/** IA fixa do menu Tenant (agente.md secao 169) — "Dashboard" (PHASE 23),
|
||||||
* "Telefonia > Ramais" (PHASE 25) têm página construída até agora, o
|
* "Telefonia > Ramais" (PHASE 25) e "Discador > Campanhas" (PHASE 26) têm
|
||||||
* resto existe pra provar a arquitetura completa (Product Principle #5
|
* página construída até agora, o resto existe pra provar a arquitetura
|
||||||
* em PRODUCT.md), renderizado como indisponível em vez de virar link
|
* completa (Product Principle #5 em PRODUCT.md), renderizado como
|
||||||
* morto. "Itens sem permissão não aparecem" (secao 169) ainda não é
|
* indisponível em vez de virar link morto. "Itens sem permissão não
|
||||||
* aplicado aqui — este primeiro corte mostra a IA inteira pra qualquer
|
* aparecem" (secao 169) ainda não é aplicado aqui — este primeiro corte
|
||||||
* usuário tenant autenticado; filtrar por permission real fica pra
|
* mostra a IA inteira pra qualquer usuário tenant autenticado; filtrar por
|
||||||
* quando mais telas existirem pra testar contra. */
|
* permission real fica pra quando mais telas existirem pra testar contra. */
|
||||||
export const TENANT_NAV: NavSection[] = [
|
export const TENANT_NAV: NavSection[] = [
|
||||||
{
|
{
|
||||||
label: "Dashboard",
|
label: "Dashboard",
|
||||||
@@ -30,7 +30,11 @@ export const TENANT_NAV: NavSection[] = [
|
|||||||
label: "Discador",
|
label: "Discador",
|
||||||
icon: PhoneOutgoing,
|
icon: PhoneOutgoing,
|
||||||
children: [
|
children: [
|
||||||
{ label: "Campanhas" },
|
{
|
||||||
|
label: "Campanhas",
|
||||||
|
href: "/app/discador/campanhas",
|
||||||
|
description: "Discador preditivo — campanhas deste tenant",
|
||||||
|
},
|
||||||
{ label: "Leads" },
|
{ label: "Leads" },
|
||||||
{ label: "Importações" },
|
{ label: "Importações" },
|
||||||
{ label: "Callbacks" },
|
{ label: "Callbacks" },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; shape: "dot" | "ring" | "square" }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; shape: "dot" | "ring" | "square" }> = {
|
||||||
DRAFT: { label: "Rascunho", color: "status-gray", shape: "ring" },
|
DRAFT: { label: "Rascunho", color: "status-gray", shape: "ring" },
|
||||||
READY: { label: "Pronta", color: "status-blue", 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" },
|
RUNNING: { label: "Em execução", color: "status-green", shape: "dot" },
|
||||||
PAUSED: { label: "Pausada", color: "status-yellow", shape: "square" },
|
PAUSED: { label: "Pausada", color: "status-yellow", shape: "square" },
|
||||||
DRAINING: { label: "Drenando", color: "status-orange", shape: "square" },
|
DRAINING: { label: "Drenando", color: "status-orange", shape: "square" },
|
||||||
|
|||||||
75
apps/frontend/src/lib/campaign-types.ts
Normal file
@@ -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<string, number>;
|
||||||
|
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<number, string> = {
|
||||||
|
1: "Seg",
|
||||||
|
2: "Ter",
|
||||||
|
3: "Qua",
|
||||||
|
4: "Qui",
|
||||||
|
5: "Sex",
|
||||||
|
6: "Sáb",
|
||||||
|
7: "Dom",
|
||||||
|
};
|
||||||