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