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
This commit is contained in:
2026-08-29 10:55:08 -03:00
parent 34ef408f00
commit 927a2623c2
22 changed files with 1375 additions and 10 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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) };
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View File

@@ -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" },

View File

@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
const STATUS_CONFIG: Record<string, { label: string; color: string; shape: "dot" | "ring" | "square" }> = {
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" },

View 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",
};