Baseline commit of the existing Maquina Comercial Pessoal project

Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
This commit is contained in:
Gustavo
2026-07-31 10:54:07 -03:00
parent 6cd336a41e
commit d330bb5ff0
132 changed files with 29969 additions and 0 deletions
+753
View File
@@ -0,0 +1,753 @@
/* Máquina Comercial Pessoal - interface principal */
:root {
--page: #0b0c10;
--page-2: #0d0e13;
--surface: #16171d;
--surface-2: #1d1f27;
--ink: #f4f4f6;
--ink-2: #a4a6b0;
--muted: #6c6e79;
--grid: #2a2c35;
--border: rgba(255, 255, 255, 0.08);
--accent: #6a63f6;
--accent-light: #9d97ff;
--accent-2: #4f8cff;
--accent-ink: #ffffff;
--bar: #6a63f6;
--good: #22c55e;
--good-text: #4ade80;
--warning: #f5a524;
--critical: #fb5b7c;
--critical-bg: rgba(251, 91, 124, 0.12);
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.3);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
color: var(--ink);
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 34rem),
radial-gradient(circle at 86% -6%, color-mix(in srgb, var(--accent-2) 12%, transparent), transparent 30rem),
linear-gradient(180deg, var(--page), var(--page-2));
}
button, input, select, textarea { font: inherit; }
button { color: inherit; }
/* topo */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) auto auto auto auto;
align-items: center;
gap: 12px;
padding: 12px 22px;
background: color-mix(in srgb, var(--surface) 88%, transparent);
border-bottom: 1px solid var(--border);
box-shadow: 0 8px 30px rgba(22, 32, 51, 0.04);
backdrop-filter: blur(14px);
}
.brand { display: flex; align-items: center; gap: 10px; min-width: max-content; }
.brand-mark { height: 32px; width: 32px; display: grid; place-items: center; border-radius: 9px; }
.brand-mark-lg { height: 62px; width: 62px; border-radius: 17px; font-size: 21px; }
.brand-monogram { background: linear-gradient(135deg, var(--accent), var(--accent-light)); color: #fff; font-weight: 900; letter-spacing: -0.06em; box-shadow: var(--shadow-soft); }
.brand-name { font-weight: 800; letter-spacing: -0.02em; color: var(--ink); }
.tabs {
display: inline-flex;
gap: 6px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-2);
width: fit-content;
}
.tab {
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink-2);
cursor: pointer;
font-weight: 750;
padding: 8px 14px;
transition: 0.18s ease;
}
.tab:hover { color: var(--ink); background: color-mix(in srgb, var(--accent) 9%, transparent); }
.tab.active { background: var(--ink); color: var(--page); box-shadow: var(--shadow-soft); }
main { max-width: 1320px; margin: 0 auto; padding: 24px 22px 70px; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* layout */
.captura-wrap { max-width: 820px; margin: 0 auto; }
.card {
position: relative;
overflow: hidden;
background: color-mix(in srgb, var(--surface) 96%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
padding: 20px;
margin-bottom: 18px;
}
.card h2 { margin: 0 0 14px; font-size: 18px; letter-spacing: -0.02em; }
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
/* formularios */
.field { margin-bottom: 18px; }
.field > label:first-child,
.field > summary,
label.settings-title {
display: block;
margin-bottom: 7px;
color: var(--ink);
font-weight: 800;
letter-spacing: -0.01em;
}
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.row.space-between { justify-content: space-between; }
.row.wrap { flex-wrap: wrap; }
label { color: var(--ink-2); }
label > input,
label > select,
label > textarea { display: block; margin-top: 5px; }
.radio {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: var(--ink-2);
cursor: pointer;
user-select: none;
}
input[type="radio"], input[type="checkbox"] { accent-color: var(--accent); }
select, input, textarea {
width: auto;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--grid);
border-radius: 12px;
padding: 10px 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
textarea { width: 100%; resize: vertical; min-height: 84px; }
select:hover, input:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--grid)); }
select:focus, input:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent);
}
input[type="number"] { width: 96px; }
details summary { cursor: pointer; }
/* botoes */
.btn, .btn-ghost, .btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
border-radius: 12px;
padding: 9px 15px;
border: 1px solid var(--grid);
background: var(--surface);
color: var(--ink);
cursor: pointer;
font-weight: 800;
text-decoration: none;
transition: 0.18s ease;
}
.btn:hover, .btn-ghost:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--shadow-soft); }
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent-light)); border-color: transparent; color: var(--accent-ink); }
.btn-primary:hover { filter: saturate(1.08); }
.btn-lg { width: 100%; min-height: 48px; font-size: 15px; }
.btn-sm { min-height: 32px; padding: 6px 10px; font-size: 12.5px; }
.btn-ghost { border-color: transparent; background: transparent; color: var(--ink-2); }
.btn-danger { color: var(--critical); border-color: color-mix(in srgb, var(--critical) 45%, var(--grid)); background: color-mix(in srgb, var(--critical) 6%, transparent); }
.btn-danger:hover { background: var(--critical); color: #fff; border-color: var(--critical); }
button:disabled { opacity: 0.55; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
.loading::before {
content: "";
display: inline-block;
width: 13px;
height: 13px;
flex-shrink: 0;
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
border-top-color: transparent;
animation: btn-spin 0.6s linear infinite;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.loading::before { animation: none; }
}
/* configuracoes */
#modal-settings { max-width: 900px; }
#modal-settings .modal-body { max-height: min(86vh, 900px); overflow-y: auto; }
.settings-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: start;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.settings-header h2 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.03em; }
.settings-header p { margin: 0; }
#outreach-status { cursor: default; white-space: nowrap; }
.settings-card,
.settings-section {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.34) inset;
margin-bottom: 12px;
}
.settings-card { padding: 16px; }
.settings-principal {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--surface)), var(--surface-2));
}
.settings-controls { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; justify-content: flex-end; }
.whatsapp-connection-card {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 300px);
gap: 22px;
align-items: center;
border-color: color-mix(in srgb, var(--good) 42%, var(--border));
background: linear-gradient(135deg, color-mix(in srgb, var(--good) 10%, var(--surface)), var(--surface-2));
}
.connection-copy p { margin: 6px 0; }
.connection-number { color: var(--good-text); font-weight: 800; }
.connection-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.qr-panel { min-height: 190px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px dashed var(--grid); border-radius: var(--radius); background: var(--surface); text-align: center; }
#whatsapp-qr { display: block; width: 100%; max-width: 280px; height: auto; border-radius: 10px; }
.connection-state.state-ready { color: var(--good-text); border-color: color-mix(in srgb, var(--good) 35%, var(--border)); background: color-mix(in srgb, var(--good) 12%, var(--surface)); }
.connection-state.state-qr_required,
.connection-state.state-starting,
.connection-state.state-authenticating,
.connection-state.state-reconnecting { border-color: color-mix(in srgb, var(--warning) 38%, var(--border)); background: color-mix(in srgb, var(--warning) 13%, var(--surface)); }
.connection-state.state-error,
.connection-state.state-disconnected { color: var(--critical); border-color: color-mix(in srgb, var(--critical) 32%, var(--border)); background: var(--critical-bg); }
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
border: 1px solid var(--grid);
border-radius: 999px;
padding: 8px 13px;
background: var(--surface);
color: var(--ink);
font-weight: 800;
}
.settings-section { padding: 0 16px 16px; }
.settings-section > summary {
position: relative;
list-style: none;
padding: 15px 30px 15px 0;
color: var(--ink);
font-weight: 850;
letter-spacing: -0.01em;
}
.settings-section > summary::-webkit-details-marker { display: none; }
.settings-section > summary::after {
content: "+";
position: absolute;
right: 0;
top: 13px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
color: var(--ink-2);
background: var(--surface);
border: 1px solid var(--grid);
}
.settings-section[open] > summary::after { content: "-"; }
.settings-section > .muted { margin-top: -4px; }
.settings-section .lista { margin-top: 10px; }
.settings-section input[type="text"],
.settings-section input[type="password"],
.settings-section textarea,
.settings-section select { min-width: 220px; }
.manual-send-card {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.manual-send-card .full { grid-column: 1 / -1; }
.manual-lead-context { padding: 10px 12px; border-radius: 12px; background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--border); }
.manual-channel {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.manual-channel .radio {
border: 1px solid var(--grid);
border-radius: 999px;
padding: 8px 12px;
background: var(--surface-2);
font-weight: 750;
}
#manual-oficial-campos {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
#manual-oficial-campos.hidden { display: none !important; }
#manual-template-nome { min-width: 0; width: 100%; }
.template-vars {
padding: 12px;
border: 1px dashed var(--grid);
border-radius: var(--radius);
background: var(--surface-2);
}
.template-vars > label { color: var(--ink); font-weight: 800; }
.template-vars p { margin: 2px 0 10px; }
.variable-helper {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
}
.variable-helper select, .variable-picker { min-width: min(100%, 320px); }
.template-vars-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.template-vars-grid label:last-child { grid-column: 1 / -1; }
.template-preview {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
color: var(--ink-2);
white-space: pre-wrap;
}
.manual-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 4px;
}
#manual-status:not(:empty) {
display: block;
width: 100%;
padding: 10px 12px;
border-radius: 12px;
background: var(--surface-2);
border: 1px solid var(--border);
}
#manual-status:not(:empty)::first-letter { font-weight: 900; }
#manual-status:not(:empty) { color: var(--ink-2); }
#manual-status.status-ok { color: var(--good-text); background: color-mix(in srgb, var(--good) 10%, var(--surface)); border-color: color-mix(in srgb, var(--good) 28%, var(--border)); }
#manual-status.status-error { color: var(--critical); background: var(--critical-bg); border-color: color-mix(in srgb, var(--critical) 30%, var(--border)); }
#btn-toggle-outreach-log { justify-self: start; margin-top: 2px; }
.outreach-template { flex-direction: column; align-items: stretch; gap: 8px; }
.outreach-template .row.space-between { align-items: center; }
.outreach-template textarea { width: 100%; margin-top: 2px; }
.outreach-template-oficial { display: flex; flex-direction: column; gap: 8px; }
.outreach-template-vars { margin-top: 2px; }
.outreach-template-vars .template-vars-grid { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.outreach-template-vars .template-vars-grid label:last-child { grid-column: auto; }
.outreach-template-link { color: var(--accent-2); font-size: 12.5px; font-weight: 750; text-decoration: none; }
.outreach-template-link:hover { text-decoration: underline; }
.outreach-template-aviso { color: var(--critical); font-size: 12.5px; margin: 0; }
#outreach-limite { width: 82px; }
.opt-out-form { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1fr) auto; gap: 10px; align-items: end; }
.opt-out-form input { width: 100%; min-width: 0 !important; }
.opt-out-item .btn-danger { margin-left: auto; }
.zona-risco { border-color: color-mix(in srgb, var(--critical) 45%, var(--border)); background: color-mix(in srgb, var(--critical) 5%, var(--surface)); }
.zona-risco summary { color: var(--critical); }
.template-sync-box {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--grid);
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.45fr);
gap: 12px;
}
.template-sync-box .row { grid-column: 1 / -1; }
.template-sync-inline {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin: 0 0 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
/* login */
.login-body {
display: flex;
min-height: 100vh;
background:
radial-gradient(circle at 18% 12%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 28rem),
radial-gradient(circle at 82% 4%, color-mix(in srgb, var(--accent-2) 18%, transparent), transparent 26rem),
linear-gradient(135deg, var(--page), var(--page-2));
}
.login-wrap { margin: auto; width: 100%; max-width: 410px; padding: 22px; }
.login-card { display: flex; flex-direction: column; gap: 14px; padding: 28px; }
.login-brand { justify-content: center; margin-bottom: 6px; }
.login-erro { color: var(--critical); min-height: 1em; margin: 0; }
.erro { color: var(--critical); margin: 4px 0 0; font-weight: 750; }
.login-card .field input { width: 100%; }
/* chips, avisos e listas */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink-2);
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
font-weight: 800;
}
.chip.ok { background: color-mix(in srgb, var(--good) 12%, var(--surface)); color: var(--good-text); border-color: color-mix(in srgb, var(--good) 26%, var(--border)); }
.chip.warn { background: color-mix(in srgb, var(--warning) 14%, var(--surface)); color: var(--ink); border-color: color-mix(in srgb, var(--warning) 32%, var(--border)); }
.notice {
margin-top: 12px;
padding: 14px;
border: 1px solid color-mix(in srgb, var(--warning) 42%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--warning) 10%, var(--surface));
}
.notice p { margin: 0 0 10px; }
.lista { display: flex; flex-direction: column; gap: 8px; }
.lista .item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 11px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.lista .item .sub { color: var(--muted); font-size: 12px; }
/* pickers */
.picker { margin-top: 10px; }
.picker input[type="search"], .cnae-avulso input[type="search"] { width: 100%; margin-bottom: 8px; }
.sugestoes { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto; }
.sugestoes button {
text-align: left;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--ink);
cursor: pointer;
padding: 8px 10px;
}
.sugestoes button:hover { background: var(--surface-2); }
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 8px 0 12px; }
.chips .chip { cursor: pointer; }
.chips .chip::after { content: " x"; color: var(--muted); }
.chips .chip:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); }
.nichos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 4px 12px;
max-height: 320px;
overflow-y: auto;
margin-top: 10px;
}
.nichos-grid label { display: flex; gap: 7px; align-items: center; padding: 6px 7px; border-radius: 10px; cursor: pointer; }
.nichos-grid label:hover { background: var(--surface-2); }
.cnae-avulso { margin-top: 14px; border-top: 1px dashed var(--grid); padding-top: 12px; }
.cnae-avulso summary { color: var(--ink-2); font-size: 13px; font-weight: 800; }
.cnae-avulso-hint { margin: 7px 0 9px; font-size: 12.5px; }
.cnae-avulso .sugestoes button { display: flex; justify-content: space-between; gap: 8px; }
.cnae-avulso .sugestoes .cod { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; }
.fontes { display: flex; flex-direction: column; gap: 8px; }
.fonte {
display: flex;
gap: 11px;
align-items: flex-start;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 11px 12px;
background: var(--surface);
cursor: pointer;
}
.fonte:hover { border-color: var(--accent); box-shadow: var(--shadow-soft); }
.fonte input { margin-top: 3px; }
.fonte .desc { color: var(--ink-2); font-size: 12.5px; }
.fonte.indisponivel { opacity: 0.58; }
/* progresso */
.progress-bar { height: 10px; border-radius: 999px; background: var(--grid); overflow: hidden; margin: 12px 0 9px; }
.progress-fill { height: 100%; width: 3%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 999px; transition: width 0.4s; }
.progress-msg { color: var(--ink-2); margin: 4px 0 11px; min-height: 1.4em; }
.stat-row { display: flex; gap: 18px; margin-bottom: 12px; flex-wrap: wrap; }
.stat-row .mini { display: flex; flex-direction: column; min-width: 96px; }
.stat-row .mini b { font-size: 23px; letter-spacing: -0.03em; }
.stat-row .mini span { color: var(--muted); font-size: 12px; }
.log {
max-height: 170px;
overflow-y: auto;
white-space: pre-wrap;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #0f172a;
color: #dbeafe;
font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
}
/* tratamento */
.filtros-card { padding: 14px; }
.filtros { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 9px; align-items: center; }
.filtros select, .filtros input { width: 100%; min-width: 0; }
#f-busca { grid-column: span 2; }
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 18px; }
.tile {
position: relative;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-soft);
}
.tile::after {
content: "";
position: absolute;
inset: auto -20px -28px auto;
width: 96px;
height: 96px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.tile .valor { font-size: 29px; font-weight: 900; letter-spacing: -0.04em; }
.tile .rotulo { color: var(--ink-2); font-size: 12.5px; font-weight: 750; margin-top: 2px; }
.tile .detalhe { color: var(--muted); font-size: 12px; }
.tile.t-good .valor { color: var(--good-text); }
.tile.tile-wide { grid-column: span 2; }
.meta-input { width: 78px; text-align: right; padding: 5px 7px; }
.ranking, .funil { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
.rank-row, .funil-etapa {
display: grid;
grid-template-columns: 150px 1fr 70px;
align-items: center;
gap: 9px;
padding: 4px 6px;
border-radius: 10px;
}
.funil-etapa { grid-template-columns: 190px 1fr 70px; }
.rank-row { cursor: pointer; }
.rank-row:hover, .oportunidade-clicavel:hover { background: var(--surface-2); }
.rank-row.ativo, .oportunidade-clicavel.ativo { background: color-mix(in srgb, var(--accent) 13%, var(--surface)); font-weight: 800; }
.rank-row.nao-clicavel { cursor: default; }
.rank-row.nao-clicavel:hover { background: transparent; }
.funil-etapa.pendente { opacity: 0.43; }
.rank-label, .funil-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.rank-track, .funil-barra { height: 12px; overflow: hidden; border-radius: 999px; background: var(--grid); }
.rank-fill, .funil-fill { height: 100%; min-width: 3px; border-radius: 999px; background: linear-gradient(90deg, var(--bar), var(--accent-light)); }
.rank-value, .funil-valor { color: var(--ink-2); text-align: right; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.oportunidade-clicavel { cursor: pointer; border-radius: 12px; padding: 8px 10px; }
/* tabela */
.table-wrap { overflow-x: auto; margin: 0 -6px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; }
th, td { text-align: left; white-space: nowrap; padding: 10px 12px; border-bottom: 1px solid var(--grid); }
th {
color: var(--muted);
background: var(--surface);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.acao-col {
position: sticky;
left: 0;
z-index: 3;
width: 58px;
min-width: 58px;
text-align: center;
background: var(--surface);
box-shadow: 10px 0 18px -18px rgba(22, 32, 51, 0.8);
}
td.acao-col { padding-left: 8px; padding-right: 8px; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
tbody tr:hover .acao-col { background: color-mix(in srgb, var(--accent) 6%, var(--surface)); }
.badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 850; white-space: nowrap; }
.badge.excelente { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good-text); }
.badge.bom { background: color-mix(in srgb, var(--good) 12%, var(--warning) 8%); color: var(--good-text); }
.badge.medio { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); }
.badge.baixo { background: color-mix(in srgb, var(--warning) 16%, var(--critical) 10%); color: var(--critical); }
.badge.muito_baixo { background: color-mix(in srgb, var(--critical) 14%, transparent); color: var(--critical); }
.badge.prioridade-1 { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good-text); }
.badge.prioridade-2 { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); }
.badge.prioridade-3 { background: var(--grid); color: var(--ink-2); }
.badge.prioridade-4 { background: var(--grid); color: var(--muted); }
.produto-cell { font-size: 12.5px; }
.estrelas { color: var(--warning); letter-spacing: 1px; white-space: nowrap; }
.empresa-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
.whatsapp-action {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--good) 35%, var(--grid));
border-radius: 999px;
background: color-mix(in srgb, var(--good) 12%, var(--surface));
color: var(--good-text);
cursor: pointer;
font-size: 17px;
line-height: 1;
}
.whatsapp-action:hover { background: var(--good); color: #fff; }
.pontos-fortes, .score-breakdown { list-style: none; margin: 7px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.resumo-comercial, .score-breakdown-wrap { border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 14px; margin-top: 12px; background: var(--surface-2); }
.resumo-comercial h3 { margin: 0 0 8px; font-size: 14px; }
.score-breakdown li.nok { color: var(--muted); }
/* campanhas */
.campaign-cards { display: flex; flex-direction: column; gap: 12px; }
.campaign-card { margin-bottom: 0; }
.campaign-card {
border-left: 3px solid var(--muted);
border-radius: var(--radius);
}
.campaign-card:has(.chip.warn) { border-left-color: var(--critical); }
.campaign-card:has(.chip.ok) { border-left-color: var(--good); }
.campaign-card h3 { color: var(--ink); }
.campaign-details { font-size: 13px; color: var(--ink-2); }
.campaign-details > * { white-space: nowrap; }
.campaign-usage span { margin-right: 12px; }
.campaign-usage span,
.stat-row .mini b,
.tile .valor {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
.pause-reason { font-weight: 750; color: var(--critical); }
#campaign-queue .muted { padding: 8px 0; }
#metrics-filters { gap: 12px; align-items: end; margin-top: 8px; flex-wrap: wrap; }
#metrics-filters label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; color: var(--ink-2); }
#metrics-filters select { min-width: 140px; }
/* usuarios */
#lista-usuarios { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
#lista-usuarios > div { padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-2); }
/* modais e toast */
dialog {
width: calc(100vw - 40px);
max-width: 600px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow);
}
dialog::backdrop { background: rgba(8, 13, 22, 0.58); backdrop-filter: blur(4px); }
.modal-body { padding: 24px; }
.modal-body h2 { margin-top: 0; letter-spacing: -0.03em; }
#modal-historico { max-width: 680px; }
#modal-historico .lista { max-height: 60vh; overflow-y: auto; margin-top: 10px; }
.toast {
position: fixed;
left: 50%;
bottom: 24px;
z-index: 100;
max-width: min(86vw, 680px);
transform: translateX(-50%);
padding: 11px 16px;
border-radius: 999px;
background: var(--ink);
color: var(--page);
box-shadow: var(--shadow);
font-weight: 800;
}
.toast.sucesso { background: var(--surface-2); color: var(--good-text); border: 1px solid color-mix(in srgb, var(--good) 45%, transparent); }
.toast.erro { background: var(--surface-2); color: var(--critical); border: 1px solid color-mix(in srgb, var(--critical) 45%, transparent); }
.hidden { display: none !important; }
.muted { color: var(--muted); font-weight: 400; }
.detalhe-grid { display: grid; grid-template-columns: 150px 1fr; gap: 6px 14px; font-size: 13.5px; }
.detalhe-grid dt { color: var(--muted); }
.detalhe-grid dd { margin: 0; word-break: break-word; }
@media (max-width: 1100px) {
.grid-3 { grid-template-columns: 1fr 1fr; }
.topbar { grid-template-columns: 1fr auto auto auto; }
.tabs { grid-column: 1 / -1; order: 2; width: 100%; justify-content: center; }
#usuario-logado { justify-self: end; }
}
@media (max-width: 780px) {
main { padding: 16px 12px 50px; }
.topbar { padding: 10px 12px; grid-template-columns: 1fr auto auto; gap: 8px; }
.brand-name { display: none; }
#usuario-logado { display: none; }
.tabs { overflow-x: auto; justify-content: flex-start; }
.tab { white-space: nowrap; }
.grid-3 { grid-template-columns: 1fr; }
.card { border-radius: var(--radius); padding: 16px; }
.settings-header, .settings-principal, .whatsapp-connection-card { grid-template-columns: 1fr; }
.settings-controls { justify-content: flex-start; }
.manual-send-card, #manual-oficial-campos { grid-template-columns: 1fr; }
.settings-section input[type="text"],
.settings-section input[type="password"],
.settings-section textarea,
.settings-section select,
.manual-send-card input,
.manual-send-card select { width: 100%; min-width: 0; }
.manual-actions { align-items: stretch; flex-direction: column; }
.manual-actions .btn { width: 100%; }
.connection-actions > *, .opt-out-form .btn { width: 100%; }
.opt-out-form { grid-template-columns: 1fr; }
.qr-panel { min-height: 150px; }
#f-busca, .tile.tile-wide { grid-column: span 1; }
.campaign-details { flex-direction: column; align-items: flex-start; }
.campaign-actions { flex-direction: column; width: 100%; }
.campaign-actions .btn { width: 100%; }
#campaign-queue-table { min-width: 600px; }
#metrics-filters { flex-direction: column; align-items: stretch; }
#metrics-filters select { width: 100%; }
.rank-row, .funil-etapa { grid-template-columns: 1fr 90px; }
.rank-track, .funil-barra { grid-column: 1 / -1; order: 3; }
th { top: 107px; }
.modal-body { padding: 18px; }
}
+557
View File
@@ -0,0 +1,557 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Máquina Comercial Pessoal</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2216%22 fill=%22%23f06a25%22/><text x=%2232%22 y=%2241%22 text-anchor=%22middle%22 font-family=%22Arial%22 font-size=%2226%22 font-weight=%22700%22 fill=%22white%22>MC</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark brand-monogram" aria-hidden="true">MC</span>
<span class="brand-name">Máquina Comercial Pessoal</span>
</div>
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="captura" role="tab" aria-selected="true">📡 Capturar Leads</button>
<button class="tab" data-tab="tratamento" role="tab" aria-selected="false">📊 Tratar Leads</button>
<button class="tab" data-tab="campanhas" role="tab" aria-selected="false">📢 Campanhas</button>
</nav>
<span class="muted" id="usuario-logado"></span>
<button class="btn-ghost hidden" id="btn-usuarios" title="Usuários">👤 Usuários</button>
<button class="btn-ghost" id="btn-settings" title="Configurações">⚙️</button>
<button class="btn-ghost" id="btn-sair" title="Sair">🚪 Sair</button>
</header>
<main>
<!-- ==================== CAPTURA ==================== -->
<section id="tab-captura" class="tab-panel active">
<div class="captura-wrap">
<div class="card" id="captura-form">
<div class="row space-between">
<h2>Nova captura</h2>
<button class="btn-ghost" id="btn-ver-historico" type="button">🕓 Histórico de capturas</button>
</div>
<div class="field">
<label for="sel-uf">1. Estado (UF)</label>
<div class="row">
<select id="sel-uf"></select>
<span id="uf-status" class="chip"></span>
</div>
<div id="extracao-aviso" class="notice hidden">
<p>A base CNPJ de <strong id="extracao-uf"></strong> ainda não foi extraída neste computador.
O primeiro uso baixa cerca de <strong>6 GB</strong> da Receita Federal e depois extrai a UF. O tempo depende da internet e pode passar de uma hora; downloads interrompidos são retomados.</p>
<button class="btn" id="btn-extrair">Baixar e extrair base</button>
</div>
</div>
<div class="field">
<label>2. Cidades</label>
<div class="row">
<label class="radio"><input type="radio" name="cidades-modo" value="todas" checked> Todas as cidades da UF</label>
<label class="radio"><input type="radio" name="cidades-modo" value="escolher"> Escolher cidades</label>
</div>
<div id="cidades-picker" class="picker hidden">
<input type="search" id="busca-cidade" placeholder="Digite para buscar cidade...">
<div id="cidades-sugestoes" class="sugestoes"></div>
<div id="cidades-chips" class="chips"></div>
</div>
</div>
<div class="field">
<label>3. Nichos <span class="muted" id="nichos-contagem"></span></label>
<div class="row">
<label class="radio"><input type="radio" name="nichos-modo" value="todos" checked> Todos os nichos</label>
<label class="radio"><input type="radio" name="nichos-modo" value="escolher"> Escolher nichos</label>
</div>
<div id="nichos-picker" class="picker hidden">
<div class="row">
<input type="search" id="busca-nicho" placeholder="Filtrar nichos...">
<button class="btn-ghost" id="btn-nichos-todos" type="button">Marcar visíveis</button>
<button class="btn-ghost" id="btn-nichos-nenhum" type="button">Desmarcar todos</button>
</div>
<div id="nichos-grid" class="nichos-grid"></div>
</div>
<details class="cnae-avulso">
<summary>Não achou o nicho? Buscar qualquer atividade da Receita Federal (CNAE)</summary>
<p class="muted cnae-avulso-hint">Os 57 nichos acima já vêm com mensagem e interesse configurados. Um CNAE avulso é adicionado só para a busca — ele participa da captura normalmente.</p>
<input type="search" id="busca-cnae" placeholder="Ex.: salão de beleza, transportadora, escola...">
<div id="cnae-sugestoes" class="sugestoes"></div>
<div id="cnae-chips" class="chips"></div>
</details>
</div>
<div class="field">
<label>4. Fontes de busca</label>
<div id="fontes-lista" class="fontes"></div>
</div>
<details class="field">
<summary>Opções avançadas</summary>
<div class="row">
<label>Máx. por consulta (fontes de API)
<input type="number" id="inp-max" value="10" min="1" max="60">
</label>
<label>Limite total de consultas (0 = sem limite)
<input type="number" id="inp-query-limit" value="0" min="0">
</label>
</div>
</details>
<button class="btn btn-primary btn-lg" id="btn-capturar">🚀 Iniciar captura</button>
</div>
<div class="card hidden" id="progresso-card">
<div class="row space-between">
<h2 id="progresso-titulo">Captura em andamento</h2>
<button class="btn-danger" id="btn-cancelar">Cancelar</button>
</div>
<div class="progress-bar"><div class="progress-fill" id="progresso-fill"></div></div>
<p class="progress-msg" id="progresso-msg">iniciando...</p>
<div class="stat-row" id="progresso-stats"></div>
<div class="log" id="progresso-log"></div>
</div>
</div>
</section>
<!-- ==================== TRATAMENTO ==================== -->
<section id="tab-tratamento" class="tab-panel">
<div class="card filtros-card">
<div class="filtros" id="filtros">
<select id="f-uf" data-filtro="uf"><option value="">UF: todas</option></select>
<input list="dl-cidades" id="f-cidade" data-filtro="cidade" placeholder="Cidade: todas">
<datalist id="dl-cidades"></datalist>
<select id="f-segmento" data-filtro="segmento"><option value="">Nicho: todos</option></select>
<select id="f-classificacao" data-filtro="classificacao">
<option value="">Classificação: todas</option>
<option value="excelente">🟢 Excelente</option>
<option value="bom">🟢 Bom</option>
<option value="medio">🟡 Médio</option>
<option value="baixo">🟠 Baixo</option>
<option value="muito_baixo">🔴 Muito Baixo</option>
</select>
<select id="f-prioridade" data-filtro="prioridade">
<option value="">Prioridade: todas</option>
<option value="1">P1 — atacar agora</option>
<option value="2">P2 — esta semana</option>
<option value="3">P3 — quando sobrar</option>
<option value="4">P4 — campanhas específicas</option>
</select>
<select id="f-produto" data-filtro="produto"><option value="">Produto: todos</option></select>
<select id="f-interesse" data-filtro="interesse"><option value="">Interesse: todos</option></select>
<select id="f-telefone" data-filtro="telefone_valido">
<option value="">Telefone: todos</option>
<option value="sim">Com telefone</option>
<option value="nao">Sem telefone</option>
</select>
<select id="f-website" data-filtro="website">
<option value="">Website: todos</option>
<option value="sim">Com website</option>
<option value="nao">Sem website</option>
</select>
<select id="f-email" data-filtro="email">
<option value="">E-mail: todos</option>
<option value="sim">Com e-mail</option>
<option value="nao">Sem e-mail</option>
</select>
<select id="f-exportado" data-filtro="exportado">
<option value="">Exportação: todos</option>
<option value="nao">Ainda não exportados</option>
<option value="sim">Já exportados</option>
</select>
<input type="search" id="f-busca" data-filtro="q" placeholder="🔎 empresa, CNPJ ou telefone">
<button class="btn-ghost" id="btn-limpar-filtros">Limpar</button>
</div>
</div>
<div class="stat-tiles" id="stat-tiles"></div>
<div class="grid-3">
<div class="card">
<div class="row space-between">
<h2>Top cidades</h2>
</div>
<div id="rank-cidades" class="ranking"></div>
</div>
<div class="card">
<div class="row space-between">
<h2>Top nichos</h2>
</div>
<div id="rank-segmentos" class="ranking"></div>
</div>
<div class="card">
<div class="row space-between">
<h2 id="rank-produtos-titulo">Top produto recomendado</h2>
</div>
<div id="rank-produtos" class="ranking"></div>
</div>
</div>
<div class="grid-3">
<div class="card" id="card-oportunidades-dia"></div>
<div class="card" id="card-maior-oportunidade"></div>
<div class="card" id="card-evolucao-diaria"></div>
</div>
<div class="card" id="card-funil"></div>
<div class="card">
<div class="row space-between wrap">
<h2>Leads <span class="muted" id="leads-total"></span></h2>
<button class="btn btn-primary" id="btn-exportar">📤 Gerar relatório / exportação com este filtro</button>
</div>
<div class="table-wrap">
<table id="tabela-leads">
<thead>
<tr>
<th class="acao-col">Ação</th><th>Empresa</th><th>Telefone</th><th>Status WhatsApp</th><th>Cidade</th><th>Nicho</th>
<th>Prioridade</th><th class="num">Geral</th>
<th>Classificação</th><th>Produto</th><th>Exportado</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="row space-between">
<button class="btn-ghost" id="pg-prev">← Anterior</button>
<span class="muted" id="pg-info"></span>
<button class="btn-ghost" id="pg-next">Próxima →</button>
</div>
</div>
<div class="card">
<h2>Exportações geradas</h2>
<div id="historico-exports" class="lista"></div>
</div>
</section>
<!-- ==================== CAMPANHAS ==================== -->
<section id="tab-campanhas" class="tab-panel">
<div class="card">
<div class="row space-between">
<h2>Campanhas</h2>
</div>
<div id="campaign-cards" class="campaign-cards"></div>
</div>
<div class="card" id="card-queue-preview">
<div class="row space-between">
<h2>Fila do dia</h2>
</div>
<div id="campaign-queue">
<p class="muted">Selecione uma campanha e clique em "Preparar fila" para gerar itens.</p>
</div>
</div>
<div class="card">
<div class="row space-between">
<h2>Respostas pendentes</h2>
</div>
<div id="pending-replies" class="lista">
<p class="muted">Nenhuma resposta pendente.</p>
</div>
</div>
<div class="card">
<div class="row space-between">
<h2>Classificação de estágio</h2>
</div>
<div id="stage-classification">
<p class="muted">Classifique leads que responderam para acompanhar o funil comercial.</p>
</div>
</div>
<div class="card">
<div class="row space-between">
<h2>Agendar demonstração / teste</h2>
<button class="btn btn-primary btn-sm" id="btn-schedule-appointment" type="button">Agendar</button>
</div>
<div id="appointment-form">
<p class="muted">Após classificar um lead, use o botão "Classificar" para agendar demonstração ou teste.</p>
</div>
</div>
<div class="card">
<div class="row space-between">
<h2>Métricas</h2>
</div>
<div id="metrics-filters" class="row"></div>
<div id="campaign-metrics" style="margin-top:10px">
<p class="muted">Selecione uma campanha e prepare a fila para ver métricas.</p>
</div>
</div>
</section>
</main>
<!-- Modal de exportação -->
<dialog id="modal-export">
<form method="dialog" class="modal-body" id="form-export">
<h2>Gerar relatório / exportação</h2>
<p class="muted" id="export-recap"></p>
<div id="export-atalhos" class="chips"></div>
<div class="field">
<label>Tipo de arquivo</label>
<label class="radio"><input type="radio" name="export-tipo" value="excel" checked>
<strong>CSV + XLS</strong> — relatório completo com os filtros atuais</label>
</div>
<label class="radio"><input type="checkbox" id="export-marcar" checked>
Marcar estes leads como exportados (não aparecem de novo no filtro "ainda não exportados")</label>
<div class="row space-between">
<button class="btn-ghost" value="cancel">Cancelar</button>
<button class="btn btn-primary" id="btn-confirmar-export" value="default">Gerar arquivos</button>
</div>
</form>
</dialog>
<!-- Modal de histórico de capturas -->
<dialog id="modal-historico">
<div class="modal-body">
<div class="row space-between">
<h2>Últimas capturas</h2>
<button class="btn-ghost" id="btn-fechar-historico" type="button">Fechar</button>
</div>
<div id="historico-capturas" class="lista"></div>
</div>
</dialog>
<!-- Modal de configurações -->
<dialog id="modal-settings">
<form method="dialog" class="modal-body">
<div class="settings-header">
<div>
<h2>Configurações</h2>
<p class="muted">Conexão local, mensagens, limites e lista de bloqueio.</p>
</div>
<span class="chip" id="outreach-status"></span>
</div>
<section class="settings-card whatsapp-connection-card">
<div class="connection-copy">
<div class="row">
<label class="settings-title">Conexão WhatsApp</label>
<span class="chip connection-state" id="whatsapp-state" role="status" aria-live="polite">Consultando...</span>
</div>
<p class="muted">Conexão local via dispositivo vinculado. A automação inicia desligada.</p>
<p id="whatsapp-number" class="connection-number hidden"></p>
<p id="whatsapp-error" class="erro hidden"></p>
<div class="connection-actions admin-only">
<button class="btn btn-primary" id="btn-whatsapp-connect" type="button">Conectar / gerar QR</button>
<button class="btn-ghost" id="btn-whatsapp-refresh-qr" type="button">Atualizar QR</button>
<button class="btn-ghost" id="btn-whatsapp-disconnect" type="button">Desconectar</button>
<button class="btn-danger" id="btn-whatsapp-logout" type="button">Sair e esquecer sessão</button>
</div>
</div>
<div class="qr-panel">
<img id="whatsapp-qr" class="hidden" alt="QR code para vincular o WhatsApp">
<span id="whatsapp-qr-placeholder" class="muted">O QR aparecerá aqui quando solicitado.</span>
<span id="whatsapp-qr-expiry" class="muted"></span>
</div>
</section>
<section class="settings-card settings-principal admin-only">
<div>
<label class="settings-title">Envio WhatsApp</label>
<p class="muted">O limite vale para envios manuais e automáticos feitos hoje.</p>
</div>
<div class="settings-controls">
<label class="toggle"><input type="checkbox" id="outreach-ativo"> Automação ativa</label>
<label>Limite por dia <input type="number" id="outreach-limite" min="1"></label>
</div>
</section>
<details class="settings-section" open>
<summary>Enviar mensagem manual</summary>
<p class="muted">Revise o número, a mensagem e o contexto real do lead antes de enviar.</p>
<div class="manual-send-card">
<label class="full">Número do WhatsApp
<input type="text" id="manual-numero" placeholder="5511999999999" inputmode="numeric">
</label>
<label class="full">Mensagem
<textarea id="manual-mensagem" rows="3" placeholder="Digite uma mensagem pessoal e respeitosa..."></textarea>
</label>
<div class="manual-lead-context full" id="manual-lead-context">Envio avulso, sem lead selecionado.</div>
<button class="btn-ghost btn-sm" id="btn-limpar-lead-manual" type="button">Limpar contexto do lead</button>
<div class="manual-actions full">
<span class="muted" id="manual-status"></span>
<button class="btn btn-primary" id="btn-enviar-manual" type="button">Enviar mensagem</button>
</div>
<button class="btn-ghost btn-sm" id="btn-toggle-outreach-log" type="button">Ver log de envios</button>
<div id="outreach-ultimos" class="lista hidden full"></div>
</div>
</details>
<details class="settings-section">
<summary>Alterar minha senha</summary>
<p class="muted">Disponível para qualquer usuário autenticado. A nova senha deve ter ao menos 8 caracteres.</p>
<div class="opt-out-form">
<label>Senha atual <input type="password" id="senha-atual" autocomplete="current-password"></label>
<label>Nova senha <input type="password" id="senha-nova" minlength="8" autocomplete="new-password"></label>
<label>Confirmar nova senha <input type="password" id="senha-confirmacao" minlength="8" autocomplete="new-password"></label>
<button class="btn btn-primary" id="btn-alterar-senha" type="button">Alterar senha</button>
</div>
<p id="senha-status" class="muted"></p>
</details>
<details class="settings-section admin-only">
<summary>Templates automáticos</summary>
<p class="muted">Somente mensagem e estado ativo. Os templates aparecem após configurar seus serviços.</p>
<div id="outreach-templates" class="lista"></div>
<div class="row space-between">
<span></span>
<button class="btn-ghost" id="btn-salvar-outreach" type="button">Salvar templates</button>
</div>
</details>
<details class="settings-section admin-only">
<summary>Lista de bloqueio / opt-out</summary>
<p class="muted">Números nesta lista nunca recebem envios manuais ou automáticos.</p>
<div class="opt-out-form">
<label>Número <input type="text" id="optout-numero" placeholder="5511999999999" inputmode="numeric"></label>
<label>Motivo <input type="text" id="optout-motivo" placeholder="Pedido do contato"></label>
<button class="btn btn-primary" id="btn-optout-add" type="button">Adicionar</button>
</div>
<p id="optout-status" class="muted"></p>
<div id="optout-list" class="lista"></div>
</details>
<details class="settings-section zona-risco admin-only">
<summary>Zona de risco</summary>
<p class="muted">Apaga permanentemente todos os leads, exportações, capturas e extrações. Configurações e nichos não são afetados.</p>
<button class="btn-danger" id="btn-limpar-base" type="button">🗑️ Limpar toda a base de leads</button>
</details>
<div class="row space-between">
<button class="btn-ghost" value="cancel">Fechar</button>
<button class="btn btn-primary admin-only" id="btn-salvar-settings" value="default">Salvar</button>
</div>
</form>
</dialog>
<!-- Modal de confirmacao da limpeza total da base -->
<dialog id="modal-reset-base">
<div class="modal-body">
<h2>Limpar toda a base de leads</h2>
<p class="muted">Esta ação é <strong>irreversível</strong>: todos os leads, exportações, capturas e extrações serão apagados. Digite a senha de segurança para confirmar.</p>
<div class="field">
<label>Senha de segurança <input type="password" id="reset-base-senha" autocomplete="off"></label>
</div>
<p class="muted erro hidden" id="reset-base-erro"></p>
<div class="row space-between">
<button class="btn-ghost" id="btn-cancelar-reset-base" type="button">Cancelar</button>
<button class="btn-danger" id="btn-confirmar-reset-base" type="button">Confirmar limpeza</button>
</div>
</div>
</dialog>
<!-- Modal de usuarios (somente admin) -->
<dialog id="modal-usuarios">
<div class="modal-body">
<div class="row space-between">
<h2>Usuários</h2>
<button class="btn-ghost" id="btn-fechar-usuarios" type="button">Fechar</button>
</div>
<div id="lista-usuarios" class="lista"></div>
<form class="field" id="form-novo-usuario">
<label>Novo usuário</label>
<div class="row">
<input type="text" id="novo-usuario-username" placeholder="usuário" minlength="3" required>
<input type="password" id="novo-usuario-password" placeholder="senha" minlength="8" required>
<select id="novo-usuario-role">
<option value="comercial">Comercial</option>
<option value="admin">Administrador</option>
</select>
<button class="btn btn-primary" type="submit">Criar</button>
</div>
</form>
</div>
</dialog>
<!-- Modal detalhe do lead -->
<dialog id="modal-lead">
<div class="modal-body" id="lead-detalhe"></div>
</dialog>
<!-- Modal inicio de campanha -->
<dialog id="modal-campaign-start">
<div class="modal-body">
<h2>Iniciar campanha</h2>
<p class="muted">Confirma o início da <strong class="campaign-start-name">campanha</strong>?</p>
<p class="muted">A automação começará a enviar mensagens dentro da janela configurada.</p>
<p class="muted erro hidden" id="modal-campaign-start-erro"></p>
<div class="row space-between">
<button class="btn-ghost" onclick="this.closest('dialog').close()">Cancelar</button>
<button class="btn btn-primary btn-confirm-start" id="btn-confirm-start">Iniciar</button>
</div>
</div>
</dialog>
<!-- Modal classificacao de estagio -->
<dialog id="modal-campaign-stage">
<div class="modal-body">
<h2>Classificar lead</h2>
<div class="field">
<label for="inp-stage">Estágio</label>
<select id="inp-stage"></select>
</div>
<p class="muted erro hidden" id="modal-campaign-stage-erro"></p>
<div class="row space-between">
<button class="btn-ghost" onclick="this.closest('dialog').close()">Cancelar</button>
<button class="btn btn-primary btn-save-stage" id="btn-save-stage">Salvar</button>
</div>
</div>
</dialog>
<!-- Modal agendamento -->
<dialog id="modal-campaign-appointment">
<div class="modal-body">
<h2>Agendar demonstração / teste</h2>
<div class="field">
<label for="inp-appointment-lead-id">ID do lead</label>
<input type="number" id="inp-appointment-lead-id" min="1">
</div>
<div class="field">
<label for="inp-appointment-kind">Tipo</label>
<select id="inp-appointment-kind">
<option value="demonstracao">Demonstração</option>
<option value="teste_g_delivery">Teste G-Delivery</option>
</select>
</div>
<div class="field">
<label for="inp-appointment-participant">Participante</label>
<input type="text" id="inp-appointment-participant" placeholder="Nome do contato">
</div>
<div class="field">
<label for="inp-appointment-company">Empresa</label>
<input type="text" id="inp-appointment-company" placeholder="Nome da empresa">
</div>
<div class="row">
<div class="field">
<label for="inp-appointment-day">Dia preferido</label>
<input type="date" id="inp-appointment-day">
</div>
<div class="field">
<label for="inp-appointment-time">Horário preferido</label>
<input type="time" id="inp-appointment-time">
</div>
</div>
<p class="muted erro hidden" id="modal-campaign-appointment-erro"></p>
<div class="row space-between">
<button class="btn-ghost" onclick="this.closest('dialog').close()">Cancelar</button>
<button class="btn btn-primary btn-confirm-appointment" id="btn-confirm-appointment">Agendar</button>
</div>
</div>
</dialog>
<div id="toast" class="toast hidden"></div>
<script src="/js/api.js"></script>
<script src="/js/captura.js"></script>
<script src="/js/tratamento.js"></script>
<script src="/js/campanhas.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
/* Cliente da API local + utilitários compartilhados. */
// 401 fora da tela de login = sessao expirada/inexistente: manda pro login.
function redirecionaSeSessaoExpirou(path, status) {
if (status === 401 && path !== '/api/auth/login' && !location.pathname.endsWith('/login.html')) {
window.location.href = '/login.html';
}
}
const API = {
async get(path) {
const res = await fetch(path);
const data = await res.json();
if (!res.ok) {
redirecionaSeSessaoExpirou(path, res.status);
throw new Error(data.erro || `HTTP ${res.status}`);
}
return data;
},
async send(method, path, body) {
const res = await fetch(path, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});
const data = await res.json();
if (!res.ok) {
redirecionaSeSessaoExpirou(path, res.status);
const error = new Error(data.erro || `HTTP ${res.status}`);
error.data = data;
error.status = res.status;
throw error;
}
return data;
},
post(path, body) { return this.send('POST', path, body); },
put(path, body) { return this.send('PUT', path, body); },
delete(path, body) { return this.send('DELETE', path, body); },
// Acompanha um job via SSE; handlers: {progress, log, done, error}
watchJob(jobId, handlers) {
const source = new EventSource(`/api/jobs/${jobId}/events`);
const close = () => source.close();
source.addEventListener('progress', (e) => handlers.progress?.(JSON.parse(e.data)));
source.addEventListener('log', (e) => handlers.log?.(JSON.parse(e.data)));
source.addEventListener('done', (e) => {
handlers.done?.(JSON.parse(e.data));
close();
});
source.addEventListener('error', (e) => {
if (e.data) {
handlers.error?.(JSON.parse(e.data));
close();
}
// sem e.data = queda de conexão; EventSource tenta reconectar sozinho
});
return close;
}
};
const fmt = new Intl.NumberFormat('pt-BR');
function el(id) { return document.getElementById(id); }
function h(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else if (value !== null && value !== undefined) node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child === null || child === undefined) continue;
node.append(child.nodeType ? child : document.createTextNode(child));
}
return node;
}
let toastTimer = null;
// Aviso: um <dialog> aberto via showModal() fica acima de qualquer div normal
// (top layer do navegador, z-index nao ajuda — e testamos que nem a Popover
// API resolve, o dialog continua por cima). Por isso o toast NAO deve ser a
// unica forma de mostrar erro de uma acao disparada de dentro de um modal
// aberto: use tambem uma mensagem inline no proprio modal nesses casos.
function toast(message, ms = 3500) {
const box = el('toast');
box.textContent = message;
box.classList.remove('hidden', 'erro', 'sucesso');
box.classList.add(/^erro/i.test(message) ? 'erro' : 'sucesso');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
}
// Envolve uma acao assincrona disparada por um botao: desabilita + mostra
// spinner (classe .loading, ver app.css) enquanto `fn` esta em andamento,
// restaura no final independente de sucesso ou erro. Se o botao ja estava
// desabilitado/carregando (chamada reentrante), nao mexe no estado dele —
// so quem entrou "limpo" e responsavel por restaurar.
async function comCarregando(button, fn) {
if (!button) return fn();
const jaCarregando = button.disabled;
if (!jaCarregando) { button.disabled = true; button.classList.add('loading'); }
try {
return await fn();
} finally {
if (!jaCarregando) { button.disabled = false; button.classList.remove('loading'); }
}
}
function dataBr(iso) {
if (!iso) return '—';
const d = new Date(iso.includes('T') || iso.includes(' ') ? iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z') : iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
+430
View File
@@ -0,0 +1,430 @@
/* Shell da Máquina Comercial Pessoal: sessão, abas, WhatsApp e configurações. */
(async function init() {
let usuarioAtual;
try {
({ user: usuarioAtual } = await API.get('/api/auth/me'));
} catch {
window.location.href = '/login.html';
return;
}
const isAdmin = usuarioAtual.role === 'admin';
let manualLeadContext = null;
let whatsappPollTimer = null;
let whatsappGeneration = 0;
let whatsappPollBusyGeneration = null;
let lastQrExpiry = null;
const pollingStates = new Set(['STARTING', 'QR_REQUIRED', 'AUTHENTICATING', 'RECONNECTING']);
const stateLabels = {
STOPPED: 'Parado', STARTING: 'Iniciando', QR_REQUIRED: 'QR necessário',
AUTHENTICATING: 'Autenticando', READY: 'Conectado', RECONNECTING: 'Reconectando',
DISCONNECTED: 'Desconectado', ERROR: 'Erro'
};
el('usuario-logado').textContent = `${usuarioAtual.username} (${isAdmin ? 'admin' : 'comercial'})`;
el('btn-usuarios').classList.toggle('hidden', !isAdmin);
for (const control of document.querySelectorAll('.admin-only')) control.classList.toggle('hidden', !isAdmin);
el('btn-sair').addEventListener('click', async () => {
const button = el('btn-sair');
await comCarregando(button, async () => {
await API.post('/api/auth/logout');
window.location.href = '/login.html';
});
});
async function carregarListaUsuarios() {
const { users } = await API.get('/api/users');
el('lista-usuarios').replaceChildren(...users.map((user) => h('div', { class: 'row space-between' },
h('span', {}, user.username),
h('span', { class: 'muted' }, user.role === 'admin' ? 'Administrador' : 'Comercial'))));
}
el('btn-usuarios').addEventListener('click', async () => {
const button = el('btn-usuarios');
el('modal-usuarios').showModal();
await comCarregando(button, async () => {
try { await carregarListaUsuarios(); } catch (error) { toast(`Erro: ${error.message}`); }
});
});
el('btn-fechar-usuarios').addEventListener('click', () => el('modal-usuarios').close());
el('form-novo-usuario').addEventListener('submit', async (event) => {
event.preventDefault();
const form = el('form-novo-usuario');
const button = form.querySelector('button[type=submit]');
await comCarregando(button, async () => {
try {
await API.post('/api/users', {
username: el('novo-usuario-username').value,
password: el('novo-usuario-password').value,
role: el('novo-usuario-role').value
});
el('novo-usuario-username').value = '';
el('novo-usuario-password').value = '';
await carregarListaUsuarios();
toast('Usuário criado');
} catch (error) { toast(`Erro: ${error.message}`); }
});
});
for (const tab of document.querySelectorAll('.tab')) {
tab.addEventListener('click', () => {
for (const item of document.querySelectorAll('.tab')) {
item.classList.toggle('active', item === tab);
item.setAttribute('aria-selected', item === tab ? 'true' : 'false');
}
for (const panel of document.querySelectorAll('.tab-panel')) {
panel.classList.toggle('active', panel.id === `tab-${tab.dataset.tab}`);
}
if (tab.dataset.tab === 'tratamento') Tratamento.ativar();
if (tab.dataset.tab === 'campanhas') Campanhas.ativar();
});
}
function stopWhatsAppPolling() {
clearTimeout(whatsappPollTimer);
whatsappPollTimer = null;
}
function ownsWhatsAppGeneration(generation) {
return generation === whatsappGeneration && el('modal-settings').open;
}
function nextWhatsAppGeneration() {
stopWhatsAppPolling();
whatsappGeneration += 1;
return whatsappGeneration;
}
function scheduleWhatsAppPolling(state, generation) {
stopWhatsAppPolling();
if (!ownsWhatsAppGeneration(generation)) return;
const delay = pollingStates.has(state) ? 1000 : 5000;
whatsappPollTimer = setTimeout(() => refreshWhatsAppStatus(true, generation), delay);
}
function renderWhatsAppStatus(status, generation) {
if (!ownsWhatsAppGeneration(generation)) return false;
const state = status?.state || 'STOPPED';
const stateEl = el('whatsapp-state');
stateEl.textContent = stateLabels[state] || state;
stateEl.className = `chip connection-state state-${state.toLowerCase()}`;
const connectedNumber = status?.number || status?.numero || status?.phone || status?.connectedNumber;
el('whatsapp-number').textContent = connectedNumber ? `Número conectado: ${connectedNumber}` : '';
el('whatsapp-number').classList.toggle('hidden', !connectedNumber);
el('whatsapp-error').textContent = status?.error || status?.erro || '';
el('whatsapp-error').classList.toggle('hidden', !(status?.error || status?.erro));
if (state !== 'QR_REQUIRED') hideQr();
return true;
}
function hideQr() {
el('whatsapp-qr').removeAttribute('src');
el('whatsapp-qr').classList.add('hidden');
el('whatsapp-qr-placeholder').classList.remove('hidden');
el('whatsapp-qr-expiry').textContent = '';
lastQrExpiry = null;
}
async function loadQr(generation) {
if (!isAdmin || !ownsWhatsAppGeneration(generation)) return;
try {
const { qr, expiresAt } = await API.get('/api/whatsapp/qr');
if (!ownsWhatsAppGeneration(generation)) return;
if (expiresAt !== lastQrExpiry || !el('whatsapp-qr').getAttribute('src')) {
el('whatsapp-qr').src = qr;
lastQrExpiry = expiresAt;
}
el('whatsapp-qr').classList.remove('hidden');
el('whatsapp-qr-placeholder').classList.add('hidden');
el('whatsapp-qr-expiry').textContent = expiresAt ? `Expira em ${dataBr(expiresAt)}` : '';
} catch (error) {
if (!ownsWhatsAppGeneration(generation)) return;
el('whatsapp-qr-expiry').textContent = error.message;
}
}
async function refreshWhatsAppStatus(fromPoll = false, generation = whatsappGeneration) {
if (!ownsWhatsAppGeneration(generation) || whatsappPollBusyGeneration === generation) return;
whatsappPollBusyGeneration = generation;
try {
const status = await API.get('/api/whatsapp/status');
if (!renderWhatsAppStatus(status, generation)) return;
if (status.state === 'QR_REQUIRED') await loadQr(generation);
scheduleWhatsAppPolling(status.state || 'STOPPED', generation);
} catch (error) {
if (!renderWhatsAppStatus({ state: 'ERROR', error: error.message }, generation)) return;
if (!fromPoll) toast(`WhatsApp: ${error.message}`);
scheduleWhatsAppPolling('ERROR', generation);
} finally {
if (whatsappPollBusyGeneration === generation) whatsappPollBusyGeneration = null;
}
}
async function whatsappAction(path) {
const generation = nextWhatsAppGeneration();
try {
const result = await API.post(path);
if (!ownsWhatsAppGeneration(generation)) return;
const status = result.status || await API.get('/api/whatsapp/status');
if (!renderWhatsAppStatus(status, generation)) return;
if (status.state === 'QR_REQUIRED') await loadQr(generation);
scheduleWhatsAppPolling(status.state || 'STOPPED', generation);
} catch (error) {
if (!ownsWhatsAppGeneration(generation)) return;
toast(`WhatsApp: ${error.message}`);
await refreshWhatsAppStatus(false, generation);
}
}
el('btn-whatsapp-connect').addEventListener('click', () => comCarregando(el('btn-whatsapp-connect'), () => whatsappAction('/api/whatsapp/connect')));
el('btn-whatsapp-refresh-qr').addEventListener('click', () => comCarregando(el('btn-whatsapp-refresh-qr'), () => whatsappAction('/api/whatsapp/refresh-qr')));
el('btn-whatsapp-disconnect').addEventListener('click', () => comCarregando(el('btn-whatsapp-disconnect'), () => whatsappAction('/api/whatsapp/disconnect')));
el('btn-whatsapp-logout').addEventListener('click', async () => {
if (!window.confirm('Sair do WhatsApp e apagar a sessão vinculada neste computador?')) return;
await comCarregando(el('btn-whatsapp-logout'), () => whatsappAction('/api/whatsapp/logout'));
});
function renderLeadContext() {
if (!manualLeadContext) {
el('manual-lead-context').textContent = 'Envio avulso, sem lead selecionado.';
return;
}
const lead = manualLeadContext;
el('manual-lead-context').textContent = [
`Lead: ${lead.empresa || lead.legal_name || 'Empresa não informada'}`,
lead.cidade ? `${lead.cidade}${lead.uf ? `/${lead.uf}` : ''}` : '',
lead.segmento_label || lead.segmento || '',
lead.cnpj || ''
].filter(Boolean).join(' · ');
}
window.MaquinaSelecionarLeadManual = (lead) => {
manualLeadContext = lead || null;
renderLeadContext();
};
window.MaquinaLimparLeadManual = () => {
manualLeadContext = null;
renderLeadContext();
};
el('btn-limpar-lead-manual').addEventListener('click', window.MaquinaLimparLeadManual);
async function carregarOutreach() {
const [{ settings }, { templates }, status] = await Promise.all([
API.get('/api/settings'), API.get('/api/outreach/templates'), API.get('/api/outreach/status')
]);
el('outreach-ativo').checked = settings['outreach.ativo'] === 'true';
el('outreach-limite').value = settings['outreach.mensagensPorDia'] || 10;
el('outreach-status').textContent = `Consumidos do limite hoje: ${status.enviadosHoje}/${status.mensagensPorDia}`;
if (!templates.length) {
el('outreach-templates').replaceChildren(h('p', { class: 'muted' },
'Nenhum template disponível. Eles aparecerão após configurar seus serviços.'));
} else {
el('outreach-templates').replaceChildren(...templates.map((template) => h('div', {
class: 'item outreach-template', 'data-produto': template.produto
},
h('div', { class: 'row space-between' },
h('strong', {}, template.produto),
h('label', { class: 'radio' },
h('input', { type: 'checkbox', class: 'outreach-template-ativo', checked: template.ativo ? '' : null }), ' Ativo')),
h('textarea', { class: 'outreach-template-mensagem', rows: 3 }, template.mensagem || ''))));
}
const sendStatusLabels = { pendente: 'Preparando', aceito: 'Aguardando confirmação', enviado: 'Enviado ao servidor', entregue: 'Entregue', lido: 'Lido', desconhecido: 'Resultado desconhecido', erro: 'Falhou' };
el('outreach-ultimos').replaceChildren(...status.ultimos.map((send) => h('div', { class: 'item' },
h('div', {}, `${sendStatusLabels[send.status] || send.status} · ${send.numero || send.empresa || 'número não informado'}`),
h('div', { class: 'sub' }, `${dataBr(send.sent_at)}${send.erro ? ` · ${send.erro}` : ''}`))));
if (!status.ultimos.length) el('outreach-ultimos').replaceChildren(h('p', { class: 'muted' }, 'Nenhum envio ainda.'));
}
async function carregarOptOuts() {
const { optOuts } = await API.get('/api/outreach/opt-outs');
const list = el('optout-list');
if (!optOuts.length) {
list.replaceChildren(h('p', { class: 'muted' }, 'Nenhum número bloqueado.'));
return;
}
list.replaceChildren(...optOuts.map((item) => {
let rowButton;
rowButton = h('button', {
class: 'btn-danger btn-sm', type: 'button',
onclick: () => comCarregando(rowButton, () => removerOptOut(item.numero))
}, 'Remover');
return h('div', { class: 'item opt-out-item' },
h('div', {}, h('strong', {}, item.numero),
h('div', { class: 'sub' }, `${item.motivo || 'Sem motivo'} · ${dataBr(item.created_at)}`)),
rowButton);
}));
}
async function removerOptOut(numero) {
try {
await API.delete(`/api/outreach/opt-outs/${encodeURIComponent(numero)}`);
el('optout-status').textContent = 'Número removido da lista.';
await carregarOptOuts();
} catch (error) { el('optout-status').textContent = `Erro: ${error.message}`; }
}
el('btn-optout-add').addEventListener('click', async () => {
const button = el('btn-optout-add');
await comCarregando(button, async () => {
try {
await API.post('/api/outreach/opt-outs', {
numero: el('optout-numero').value.trim(), motivo: el('optout-motivo').value.trim()
});
el('optout-numero').value = '';
el('optout-motivo').value = '';
el('optout-status').textContent = 'Número adicionado à lista.';
await carregarOptOuts();
} catch (error) { el('optout-status').textContent = `Erro: ${error.message}`; }
});
});
el('btn-settings').addEventListener('click', async () => {
const button = el('btn-settings');
el('modal-settings').showModal();
const generation = nextWhatsAppGeneration();
await comCarregando(button, async () => {
try {
await Promise.all([carregarOutreach(), carregarOptOuts(), refreshWhatsAppStatus(false, generation)]);
} catch (error) { toast(`Erro ao carregar configurações: ${error.message}`); }
});
});
el('btn-enviar-manual').addEventListener('click', async () => {
const button = el('btn-enviar-manual');
if (button.disabled) return;
button.disabled = true;
const statusEl = el('manual-status');
statusEl.classList.remove('status-ok', 'status-error');
statusEl.textContent = 'Enviando...';
try {
const result = await API.post('/api/outreach/enviar-manual', {
numero: el('manual-numero').value.trim(),
mensagem: el('manual-mensagem').value,
leadId: manualLeadContext?.id || null,
lead: manualLeadContext
});
const uncertain = result.status === 'resultado_desconhecido' || result.status === 'persistencia_incerta';
const warning = Boolean(result.aviso);
statusEl.textContent = uncertain || warning
? result.aviso
: result.ok
? `Aceito pelo WhatsApp; aguardando confirmação${result.messageId ? ` · ID ${result.messageId}` : ''}`
: result.erro;
statusEl.classList.toggle('status-ok', Boolean(result.ok) && !warning);
statusEl.classList.toggle('status-error', !result.ok || warning);
await carregarOutreach();
} catch (error) {
statusEl.textContent = `Erro: ${error.message}`;
statusEl.classList.add('status-error');
} finally {
button.disabled = false;
}
});
el('btn-toggle-outreach-log').addEventListener('click', () => {
const hidden = el('outreach-ultimos').classList.toggle('hidden');
el('btn-toggle-outreach-log').textContent = hidden ? 'Ver log de envios' : 'Ocultar log de envios';
});
el('btn-salvar-outreach').addEventListener('click', async () => {
const button = el('btn-salvar-outreach');
await comCarregando(button, async () => {
try {
for (const item of el('outreach-templates').querySelectorAll('.outreach-template')) {
await API.put(`/api/outreach/templates/${encodeURIComponent(item.dataset.produto)}`, {
mensagem: item.querySelector('.outreach-template-mensagem').value,
ativo: item.querySelector('.outreach-template-ativo').checked
});
}
await API.put('/api/settings', {
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
'outreach.mensagensPorDia': el('outreach-limite').value
});
await carregarOutreach();
toast('Templates salvos');
} catch (error) { toast(`Erro: ${error.message}`); }
});
});
el('btn-alterar-senha').addEventListener('click', async () => {
const button = el('btn-alterar-senha');
const status = el('senha-status');
const novaSenha = el('senha-nova').value;
if (novaSenha !== el('senha-confirmacao').value) {
status.textContent = 'A confirmação não corresponde à nova senha.';
return;
}
await comCarregando(button, async () => {
try {
const result = await API.put('/api/auth/password', { senhaAtual: el('senha-atual').value, novaSenha });
el('senha-atual').value = '';
el('senha-nova').value = '';
el('senha-confirmacao').value = '';
status.textContent = 'Senha alterada. Entre novamente.';
if (result.relogin) window.location.href = '/login.html';
} catch (error) {
status.textContent = `Erro: ${error.message}`;
}
});
});
el('btn-salvar-settings').addEventListener('click', async (event) => {
event.preventDefault();
const button = el('btn-salvar-settings');
await comCarregando(button, async () => {
try {
await API.put('/api/settings', {
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
'outreach.mensagensPorDia': el('outreach-limite').value
});
stopWhatsAppPolling();
el('modal-settings').close();
toast('Configurações salvas');
} catch (error) { toast(`Erro: ${error.message}`); }
});
});
el('modal-settings').addEventListener('close', nextWhatsAppGeneration);
el('btn-limpar-base').addEventListener('click', () => {
el('reset-base-senha').value = '';
el('reset-base-erro').classList.add('hidden');
stopWhatsAppPolling();
el('modal-settings').close();
el('modal-reset-base').showModal();
});
el('btn-cancelar-reset-base').addEventListener('click', () => el('modal-reset-base').close());
el('btn-confirmar-reset-base').addEventListener('click', async () => {
const button = el('btn-confirmar-reset-base');
const erroEl = el('reset-base-erro');
const senha = el('reset-base-senha').value;
if (!senha) {
erroEl.textContent = 'Informe a senha de segurança';
erroEl.classList.remove('hidden');
return;
}
await comCarregando(button, async () => {
try {
await API.post('/api/admin/reset', { senha });
el('modal-reset-base').close();
toast('Base de leads limpa com sucesso. Recarregando...');
setTimeout(() => window.location.reload(), 1200);
} catch (error) {
erroEl.textContent = `Erro: ${error.message}`;
erroEl.classList.remove('hidden');
}
});
});
for (const dialog of document.querySelectorAll('dialog')) {
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
}
renderLeadContext();
Tratamento.bindEvents();
Campanhas.bindEvents();
try { await Captura.init(); } catch (error) { toast(`Erro ao carregar: ${error.message}`, 8000); }
})();
+414
View File
@@ -0,0 +1,414 @@
window.campanhasInit = window.campanhasInit || (() => {
let carregado = false;
let refreshTimer = null;
let selectedCampaignId = null;
let currentQueue = [];
let startCampaignId = null;
let stageLeadId = null;
const STATUS_LABELS = {
draft: 'Rascunho', paused: 'Pausada', ready: 'Pronta',
running: 'Ativa', completed: 'Concluída'
};
const QUEUE_STATUS_LABELS = {
preview: 'Pré-visualização', approved: 'Aprovado', scheduled: 'Agendado',
reserved: 'Reservado', sent: 'Enviado', unknown: 'Resultado desconhecido',
error: 'Falhou', cancelled: 'Cancelado'
};
const REMOVABLE_QUEUE_STATUSES = new Set(['preview', 'approved', 'scheduled']);
// Cada valor precisa existir em CAMPAIGN_STAGES (src/outreach/campaign-catalog.mjs) —
// o backend rejeita qualquer outro com 400. De propósito NÃO inclui os estágios
// automáticos do pipeline anteriores a uma resposta (fila_preparada,
// aprovado_para_envio, enviado): este dialogo so abre para leads que ja
// responderam (stage 'respondeu'), entao esses 3 nunca se aplicam aqui.
const STAGE_OPTIONS = [
['respondeu', 'Respondeu'],
['interessado_gti_vendas', 'Interessado — GTI Vendas'],
['interessado_g_delivery', 'Interessado — G-Delivery'],
['demonstracao_agendada', 'Demonstração agendada'],
['teste_g_delivery_iniciado', 'Teste G-Delivery iniciado'],
['venda_fechada', 'Venda fechada'],
['sem_interesse', 'Sem interesse'],
['opt_out', 'Opt-out']
];
function hojeLocalYMD() {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${month}-${day}`;
}
function modalErro(id, message) {
const erroEl = el(id);
if (!erroEl) return;
erroEl.textContent = message || '';
erroEl.classList.toggle('hidden', !message);
}
function invalidar() {
carregado = false;
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
if (document.querySelector('#tab-campanhas.active')) ativar();
}, 800);
}
async function ativar() {
if (carregado) return;
carregado = true;
await Promise.all([carregarCampanhas(), carregarReplies(), carregarStageClassification(), carregarMetrics()]);
}
async function carregarCampanhas() {
const { campaigns } = await API.get('/api/campaigns');
el('campaign-cards').replaceChildren(...campaigns.map(renderCard));
}
function renderCard(c) {
const statusClass = c.status === 'running' ? 'chip ok' : c.status === 'paused' ? 'chip warn' : 'chip';
const actions = [];
if (c.status === 'ready' || c.status === 'paused') {
actions.push(h('button', { class: 'btn btn-primary btn-sm', id: `btn-preview-${c.id}`, onclick: () => prepararFila(c.id) }, 'Preparar fila'));
}
if (c.status === 'ready' || c.status === 'paused') {
actions.push(h('button', { class: 'btn btn-primary btn-sm', id: `btn-start-${c.id}`, onclick: () => iniciarCampanha(c.id) }, 'Iniciar'));
}
if (c.status === 'running') {
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-pause-${c.id}`, onclick: () => pausarCampanha(c.id) }, 'Pausar'));
}
if (c.status === 'running' || c.status === 'ready' || c.status === 'paused') {
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-queue-${c.id}`, onclick: () => carregarFila(c.id) }, 'Ver fila'));
}
if (c.week_number < 4) {
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-advance-week-${c.id}`, onclick: () => avancarSemana(c.id, c.week_number) }, 'Avançar semana'));
}
return h('div', { class: 'card campaign-card', 'data-campaign-id': c.id },
h('div', { class: 'row space-between' },
h('h3', { style: 'margin:0;font-size:16px' }, c.name),
h('span', { class: statusClass }, STATUS_LABELS[c.status] || c.status)
),
h('div', { class: 'campaign-details row', style: 'margin-top:8px' },
h('span', { class: 'campaign-week' }, `Semana ${c.week_number}`),
h('span', { class: 'campaign-limit' }, `Limite: ${c.approved_daily_limit}/dia`),
c.pause_reason ? h('span', { class: 'pause-reason muted', style: 'color:var(--critical);font-weight:750' }, `Pausado: ${c.pause_reason}`) : null
),
h('div', { class: 'campaign-usage row', style: 'margin-top:6px;font-size:12.5px;color:var(--ink-2)' },
h('span', { class: 'manual-usage' }, `Manual: ${c.usage?.manual?.used || 0}/${c.usage?.manual?.limit || 0}`),
h('span', { class: 'auto-usage' }, `Automático: ${c.usage?.campaign?.used || 0}/${c.approved_daily_limit}`)
),
actions.length ? h('div', { class: 'campaign-actions row', style: 'margin-top:8px' }, ...actions) : null
);
}
async function prepararFila(campaignId) {
const button = el(`btn-preview-${campaignId}`);
return comCarregando(button, async () => {
try {
const result = await API.post(`/api/campaigns/${campaignId}/prepare`);
toast(`Preparados: ${result.summary.previewed} fila, ${result.summary.checked} verificados, ${result.summary.skipped} pulados`);
await carregarFila(campaignId);
} catch (error) { toast(`Erro: ${error.message}`); }
});
}
async function carregarFila(campaignId) {
const button = el(`btn-queue-${campaignId}`);
return comCarregando(button, async () => {
try {
selectedCampaignId = campaignId;
// Sem filtro de status: uma campanha em andamento ja tem itens em scheduled/sent/etc,
// nao so preview, e o operador precisa poder ver o que esta acontecendo com eles.
const { queue } = await API.get(`/api/campaigns/${campaignId}/queue`);
currentQueue = queue || [];
const container = el('campaign-queue');
if (!queue || !queue.length) {
container.replaceChildren(h('p', { class: 'muted' }, 'Fila vazia. Clique em "Preparar fila" para gerar itens.'));
return;
}
const previewCount = queue.filter((r) => r.status === 'preview').length;
container.replaceChildren(
h('div', { class: 'table-wrap' },
h('table', { id: 'campaign-queue-table' },
h('thead', {},
h('tr', {},
h('th', {}, 'Remover'), h('th', {}, 'Empresa'),
h('th', {}, 'Mensagem'), h('th', {}, 'Segmento'),
h('th', {}, 'Status'), h('th', {}, 'Motivo de seleção')
)
),
h('tbody', {}, ...queue.map((row) =>
h('tr', { class: 'queue-row', 'data-queue-id': row.id },
h('td', {}, REMOVABLE_QUEUE_STATUSES.has(row.status) ? h('button', {
class: 'btn-danger btn-sm btn-remove-queue',
id: `btn-remove-queue-${row.id}`,
'data-queue-id': row.id,
onclick: () => removerItemFila(row.id, campaignId)
}, 'Remover') : null),
h('td', {}, row.empresa || '—'),
h('td', { style: 'white-space:normal;max-width:400px' }, row.rendered_message || '—'),
h('td', {}, row.segmento || '—'),
h('td', {}, QUEUE_STATUS_LABELS[row.status] || row.status),
h('td', { style: 'font-size:12px;color:var(--muted)' }, row.selection_reason || '—')
)
))
)
),
h('div', { class: 'row space-between', style: 'margin-top:12px' },
previewCount
? h('button', { class: 'btn btn-primary', id: 'btn-approve', onclick: () => aprovarFila(campaignId) },
`Aprovar fila (${previewCount} itens)`)
: null,
h('span', { class: 'muted', id: 'queue-count' }, `${queue.length} itens na fila`)
)
);
} catch (error) { toast(`Erro ao carregar fila: ${error.message}`); }
});
}
async function removerItemFila(queueId, campaignId) {
const button = el(`btn-remove-queue-${queueId}`);
return comCarregando(button, async () => {
try {
await API.delete(`/api/campaigns/${campaignId}/queue/${queueId}`);
toast('Item removido da fila');
await carregarFila(campaignId);
} catch (error) { toast(`Erro: ${error.message}`); }
});
}
async function aprovarFila(campaignId) {
const queueIds = currentQueue.filter((row) => row.status === 'preview').map((row) => row.id);
if (!queueIds.length) { toast('Nenhum item na fila para aprovar'); return; }
const button = el('btn-approve');
return comCarregando(button, async () => {
try {
const result = await API.post(`/api/campaigns/${campaignId}/approve`, { queueIds, date: hojeLocalYMD() });
toast(`Fila aprovada: ${result.scheduled || 0} itens agendados`);
await carregarFila(campaignId);
await carregarCampanhas();
} catch (error) { toast(`Erro: ${error.message}`); }
});
}
function iniciarCampanha(campaignId) {
startCampaignId = campaignId;
modalErro('modal-campaign-start-erro', '');
el('modal-campaign-start').showModal();
}
function pausarCampanha(campaignId) {
const reason = window.prompt('Motivo da pausa:');
if (!reason || !reason.trim()) return;
const button = el(`btn-pause-${campaignId}`);
comCarregando(button, () => API.post(`/api/campaigns/${campaignId}/pause`, { reason: reason.trim() })
.then(() => { toast('Campanha pausada'); carregarCampanhas(); })
.catch((error) => toast(`Erro: ${error.message}`)));
}
function avancarSemana(campaignId, currentWeek) {
const nextWeek = currentWeek + 1;
if (!window.confirm(`Avançar de semana ${currentWeek} para semana ${nextWeek}? O limite diário aprovado muda para o novo patamar.`)) return;
const button = el(`btn-advance-week-${campaignId}`);
comCarregando(button, () => API.put(`/api/campaigns/${campaignId}/week`, { week: nextWeek })
.then(() => { toast(`Campanha avançou para semana ${nextWeek}`); carregarCampanhas(); })
.catch((error) => toast(`Erro: ${error.message}`)));
}
async function carregarReplies() {
try {
const campaigns = await API.get('/api/campaigns').then((r) => r.campaigns).catch(() => []);
const campaignNames = new Map(campaigns.map((c) => [c.id, c.name]));
const { pending } = await API.get('/api/campaigns/replies/pending');
const container = el('pending-replies');
if (!pending || !pending.length) {
container.replaceChildren(h('p', { class: 'muted' }, 'Nenhuma resposta pendente.'));
return;
}
container.replaceChildren(...pending.map((r) =>
h('div', { class: 'item' },
h('div', {},
h('strong', {}, r.empresa || r.whatsapp || '—'),
h('div', { class: 'sub' }, `${campaignNames.get(r.campaign_id) || ''} · ${dataBr(r.updated_at)}`)
),
h('div', { style: 'font-size:12.5px;color:var(--ink-2);flex:1' }, r.note || ''),
h('button', {
class: 'btn-ghost btn-sm', onclick: () => abrirStageDialog(r.lead_id)
}, 'Classificar')
)
));
} catch { /* silêncio */ }
}
function abrirStageDialog(leadId) {
stageLeadId = leadId;
const inp = el('inp-stage');
if (inp) {
inp.replaceChildren(...STAGE_OPTIONS.map(([value, label]) => h('option', { value }, label)));
inp.value = 'respondeu';
}
modalErro('modal-campaign-stage-erro', '');
el('modal-campaign-stage').showModal();
}
async function carregarStageClassification() {
el('stage-classification').replaceChildren(
h('p', { class: 'muted' }, 'Use a seção de respostas pendentes para classificar leads por estágio.')
);
}
async function carregarMetrics() {
try {
const campaigns = await API.get('/api/campaigns').then((r) => r.campaigns).catch(() => []);
const filters = el('metrics-filters');
const display = el('campaign-metrics');
if (!campaigns.length) {
filters.replaceChildren(h('p', { class: 'muted' }, 'Sem campanhas para métricas.'));
display.replaceChildren();
return;
}
const segmentos = [...new Set(campaigns.flatMap((c) => c.segments || []))];
const variants = ['A', 'B', 'C'];
filters.replaceChildren(
h('label', {}, 'Segmento',
h('select', { id: 'filtro-metrics-segmento', onchange: () => carregarMetricsData() },
h('option', { value: '' }, 'Todos'),
...segmentos.map((s) => h('option', { value: s }, s))
)
),
h('label', {}, 'Variante',
h('select', { id: 'filtro-metrics-variant', onchange: () => carregarMetricsData() },
h('option', { value: '' }, 'Todas'),
...variants.map((v) => h('option', { value: v }, v))
)
)
);
display.replaceChildren(h('p', { class: 'muted' }, 'Selecione uma campanha e clique em "Ver fila" para ver métricas detalhadas.'));
} catch { /* silêncio */ }
}
async function carregarMetricsData() {
if (!selectedCampaignId) return;
const segmento = (el('filtro-metrics-segmento') || {}).value || '';
const variant = (el('filtro-metrics-variant') || {}).value || '';
try {
const params = new URLSearchParams();
if (segmento) params.set('segmento', segmento);
if (variant) params.set('variant', variant);
const { metrics } = await API.get(`/api/campaigns/${selectedCampaignId}/metrics?${params}`);
// Node.replaceChildren() stringifies non-Node args (null -> the text "null"),
// unlike h() which filters null children. Build the list and drop nulls first.
el('campaign-metrics').replaceChildren(
...[
h('div', { class: 'stat-row' },
h('div', { class: 'mini' },
h('b', {}, String(metrics.previewed || 0)),
h('span', {}, 'Visualizados')
),
h('div', { class: 'mini' },
h('b', {}, String(metrics.approved || 0)),
h('span', {}, 'Aprovados')
),
h('div', { class: 'mini' },
h('b', {}, String(metrics.sent || 0)),
h('span', {}, 'Enviados')
),
h('div', { class: 'mini' },
h('b', {}, String(metrics.replies || 0)),
h('span', {}, 'Responderam')
)
),
metrics.firstResponseTimeAvg != null
? h('p', { class: 'muted' }, `Tempo médio de primeira resposta: ${Math.round(metrics.firstResponseTimeAvg)} min`)
: null
].filter(Boolean)
);
} catch { /* silêncio */ }
}
// Todos os listeners abaixo sao anexados UMA VEZ, aqui, sobre botoes fixos e
// compartilhados do HTML (os modais nao sao recriados por campanha/lead).
// Anexar de novo a cada abertura de modal (como antes) acumula handlers com
// closures antigas e dispara acoes duplicadas/erradas ao confirmar.
function bindEvents() {
const confirmStartBtn = el('btn-confirm-start');
if (confirmStartBtn) {
confirmStartBtn.addEventListener('click', async () => {
if (!startCampaignId) return;
await comCarregando(confirmStartBtn, async () => {
try {
await API.post(`/api/campaigns/${startCampaignId}/start`);
el('modal-campaign-start').close();
toast('Campanha iniciada');
await carregarCampanhas();
} catch (error) {
toast(`Erro: ${error.message}`);
modalErro('modal-campaign-start-erro', error.message);
}
});
});
}
const saveStageBtn = el('btn-save-stage');
if (saveStageBtn) {
saveStageBtn.addEventListener('click', async () => {
if (!stageLeadId) return;
const stage = (el('inp-stage') || {}).value;
if (!stage) return;
await comCarregando(saveStageBtn, async () => {
try {
await API.put(`/api/campaigns/leads/${stageLeadId}/stage`, { stage });
el('modal-campaign-stage').close();
toast('Estágio atualizado');
carregarReplies();
} catch (error) {
toast(`Erro: ${error.message}`);
modalErro('modal-campaign-stage-erro', error.message);
}
});
});
}
const scheduleBtn = el('btn-schedule-appointment');
if (scheduleBtn) {
scheduleBtn.addEventListener('click', () => {
modalErro('modal-campaign-appointment-erro', '');
el('modal-campaign-appointment').showModal();
});
}
const confirmAppointmentBtn = el('btn-confirm-appointment');
if (confirmAppointmentBtn) {
confirmAppointmentBtn.addEventListener('click', async () => {
const leadId = Number((el('inp-appointment-lead-id') || {}).value || 0);
const kind = (el('inp-appointment-kind') || {}).value || 'demonstracao';
const participant = (el('inp-appointment-participant') || {}).value || '';
const company = (el('inp-appointment-company') || {}).value || '';
const day = (el('inp-appointment-day') || {}).value || '';
const time = (el('inp-appointment-time') || {}).value || '';
if (!leadId) { toast('Selecione um lead'); return; }
await comCarregando(confirmAppointmentBtn, async () => {
try {
await API.post(`/api/campaigns/leads/${leadId}/appointments`, { kind, participantName: participant, companyName: company, preferredDay: day, preferredTime: time });
el('modal-campaign-appointment').close();
toast('Agendamento salvo');
} catch (error) {
toast(`Erro: ${error.message}`);
modalErro('modal-campaign-appointment-erro', error.message);
}
});
});
}
}
return { ativar, invalidar, bindEvents };
})();
window.Campanhas = window.campanhasInit;
+407
View File
@@ -0,0 +1,407 @@
/* Aba Captura: wizard UF → cidades → nichos → fontes → rodar, com progresso SSE. */
const Captura = (() => {
let ufs = [];
let municipios = []; // da UF selecionada
let segments = [];
let sources = [];
const cidadesEscolhidas = new Set();
const nichosEscolhidos = new Set();
const cnaesAvulsos = new Map(); // codigo -> descricao
let stopWatcher = null;
// ---------- carregamento ----------
async function init() {
const [ufsData, segData, srcData] = await Promise.all([
API.get('/api/meta/ufs'),
API.get('/api/meta/segments'),
API.get('/api/meta/sources')
]);
ufs = ufsData.ufs;
segments = segData.segments;
sources = srcData.sources;
renderUfs();
renderNichos();
renderFontes();
bindEvents();
await onUfChange();
await carregarHistorico();
await retomarJobAtivo();
}
function renderUfs() {
const sel = el('sel-uf');
sel.replaceChildren(...ufs.map((u) =>
h('option', { value: u.uf, selected: u.uf === 'BA' ? '' : null },
`${u.uf} — ${u.nome}${u.extraida ? ' ✓' : ''}`)
));
}
function renderNichos(filtro = '') {
const grid = el('nichos-grid');
const termo = filtro.toLowerCase();
grid.replaceChildren(...segments
.filter((s) => !termo || s.label.toLowerCase().includes(termo))
.map((s) => h('label', {},
h('input', {
type: 'checkbox', value: s.name,
checked: nichosEscolhidos.has(s.name) ? '' : null,
onchange: (e) => {
if (e.target.checked) nichosEscolhidos.add(s.name);
else nichosEscolhidos.delete(s.name);
atualizarContagem();
}
}),
s.label
)));
el('nichos-contagem').textContent = `(${segments.length} disponíveis)`;
}
function atualizarContagem() {
const modo = document.querySelector('input[name="nichos-modo"]:checked').value;
el('nichos-contagem').textContent = modo === 'todos'
? `(${segments.length} disponíveis)`
: `(${nichosEscolhidos.size} de ${segments.length} selecionados)`;
}
function renderFontes() {
const box = el('fontes-lista');
box.replaceChildren(...sources.map((s) => {
const input = h('input', {
type: 'checkbox', value: s.id,
checked: s.id === 'cnpj-base' ? '' : null,
disabled: s.disponivel ? null : ''
});
return h('label', { class: `fonte${s.disponivel ? '' : ' indisponivel'}` },
input,
h('div', {},
h('div', {}, h('strong', {}, s.label), s.recomendada ? ' ⭐' : '', s.gratuita ? '' : ' 💰'),
h('div', { class: 'desc' }, s.descricao + (s.disponivel ? '' : ` — ${s.motivo}`))
)
);
}));
}
// ---------- UF / extração ----------
function ufAtual() {
return ufs.find((u) => u.uf === el('sel-uf').value);
}
async function onUfChange() {
const u = ufAtual();
const status = el('uf-status');
if (u.extraindo) {
status.textContent = '⏳ extraindo base...';
status.className = 'chip warn';
} else if (u.extraida) {
status.textContent = '✓ base CNPJ pronta';
status.className = 'chip ok';
} else {
status.textContent = 'base CNPJ não extraída';
status.className = 'chip warn';
}
el('extracao-aviso').classList.toggle('hidden', u.extraida || u.extraindo);
el('extracao-uf').textContent = `${u.uf} — ${u.nome}`;
cidadesEscolhidas.clear();
renderChips();
const data = await API.get(`/api/meta/municipios?uf=${u.uf}`);
municipios = data.municipios;
}
async function extrairBase() {
const u = ufAtual();
try {
const { jobId } = await comCarregando(el('btn-extrair'), () => API.post('/api/extractions', { uf: u.uf }));
toast(`Download e extração de ${u.uf} iniciados — a primeira execução usa cerca de 6 GB`);
acompanharJob(jobId, `Baixando e extraindo base CNPJ de ${u.uf}`);
u.extraindo = true;
await onUfChange();
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
// ---------- cidades ----------
function renderChips() {
el('cidades-chips').replaceChildren(...[...cidadesEscolhidas].map((nome) =>
h('span', {
class: 'chip', title: 'remover',
onclick: () => { cidadesEscolhidas.delete(nome); renderChips(); }
}, nome)
));
}
function buscarCidade(termo) {
const box = el('cidades-sugestoes');
if (!termo || termo.length < 2) {
box.replaceChildren();
return;
}
const t = termo.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const achados = municipios
.filter((m) => m.nome.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').includes(t))
.slice(0, 12);
box.replaceChildren(...achados.map((m) =>
h('button', {
type: 'button',
onclick: () => {
cidadesEscolhidas.add(m.nome);
renderChips();
el('busca-cidade').value = '';
box.replaceChildren();
}
}, m.nome)
));
}
// ---------- CNAE avulso (qualquer atividade da Receita, alem dos 57 nichos) ----------
function renderCnaeChips() {
el('cnae-chips').replaceChildren(...[...cnaesAvulsos].map(([codigo, descricao]) =>
h('span', {
class: 'chip', title: 'remover',
onclick: () => { cnaesAvulsos.delete(codigo); renderCnaeChips(); }
}, descricao)
));
}
let buscaCnaeTimer = null;
function buscarCnae(termo) {
clearTimeout(buscaCnaeTimer);
const box = el('cnae-sugestoes');
if (!termo || termo.trim().length < 2) {
box.replaceChildren();
return;
}
buscaCnaeTimer = setTimeout(async () => {
const { cnaes } = await API.get(`/api/meta/cnaes?q=${encodeURIComponent(termo)}`);
box.replaceChildren(...cnaes.map((c) =>
h('button', {
type: 'button',
onclick: () => {
cnaesAvulsos.set(c.codigo, c.descricao);
renderCnaeChips();
el('busca-cnae').value = '';
box.replaceChildren();
}
}, h('span', {}, c.descricao), h('span', { class: 'cod' }, c.codigo))
));
if (!cnaes.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma atividade encontrada'));
}
}, 250);
}
// ---------- captura ----------
function montarPayload() {
const fontes = [...document.querySelectorAll('#fontes-lista input:checked')].map((i) => i.value);
const cidadesModo = document.querySelector('input[name="cidades-modo"]:checked').value;
const nichosModo = document.querySelector('input[name="nichos-modo"]:checked').value;
const cnaeIds = [...cnaesAvulsos.keys()].map((codigo) => `cnae:${codigo}`);
return {
sources: fontes,
uf: el('sel-uf').value,
cities: cidadesModo === 'todas' ? ['all'] : [...cidadesEscolhidas],
segments: [...(nichosModo === 'todos' ? ['all'] : [...nichosEscolhidos]), ...cnaeIds],
limits: {
maxPerQuery: Number(el('inp-max').value) || 10,
queryLimit: Number(el('inp-query-limit').value) || 0
}
};
}
async function iniciarCaptura() {
const payload = montarPayload();
if (!payload.sources.length) {
toast('Selecione pelo menos uma fonte de busca');
return;
}
if (payload.cities.length === 0) {
toast('Escolha pelo menos uma cidade (ou marque "todas")');
return;
}
if (payload.segments.length === 0) {
toast('Escolha pelo menos um nicho (ou marque "todos")');
return;
}
try {
const { jobId } = await comCarregando(el('btn-capturar'), () => API.post('/api/capture/runs', payload));
acompanharJob(jobId, 'Captura em andamento');
} catch (error) {
if (error.data?.precisaExtracao) {
el('extracao-aviso').classList.remove('hidden');
toast(error.message);
} else {
toast(`Erro: ${error.message}`);
}
}
}
// ---------- progresso ----------
function acompanharJob(jobId, titulo) {
const card = el('progresso-card');
card.classList.remove('hidden');
card.dataset.jobId = jobId;
el('progresso-titulo').textContent = titulo;
el('progresso-msg').textContent = 'iniciando...';
el('progresso-log').textContent = '';
el('progresso-fill').style.width = '3%';
el('progresso-stats').replaceChildren();
el('btn-cancelar').disabled = false;
el('btn-capturar').disabled = true;
if (stopWatcher) stopWatcher();
stopWatcher = API.watchJob(jobId, {
progress(p) {
if (p.message) el('progresso-msg').textContent = p.message;
if (typeof p.pct === 'number') el('progresso-fill').style.width = `${Math.max(3, p.pct)}%`;
if (p.counts) {
el('progresso-fill').style.width = '50%';
el('progresso-stats').replaceChildren(
mini(p.counts.raw, 'encontrados'),
mini(p.counts.new, 'novos'),
mini(p.counts.dup, 'já capturados')
);
}
},
log(l) {
const box = el('progresso-log');
box.textContent += `${l.message}\n`;
box.scrollTop = box.scrollHeight;
},
done(d) {
el('progresso-fill').style.width = '100%';
const s = d.summary || {};
el('progresso-msg').textContent = s.cancelled
? 'Cancelado.'
: s.totalRows !== undefined
? `✅ Base extraída: ${fmt.format(s.totalRows)} empresas (${fmt.format(s.withPhone)} com telefone)`
: `✅ Concluído: ${fmt.format(s.new ?? 0)} leads novos (${fmt.format(s.dup ?? 0)} já capturados antes)`;
finalizar();
},
error(e) {
el('progresso-msg').textContent = `❌ Erro: ${e.message}`;
finalizar();
}
});
function mini(valor, rotulo) {
return h('div', { class: 'mini' }, h('b', {}, fmt.format(valor ?? 0)), h('span', {}, rotulo));
}
async function finalizar() {
el('btn-cancelar').disabled = true;
el('btn-capturar').disabled = false;
await Promise.all([carregarHistorico(), recarregarUfs()]);
window.Tratamento?.invalidar();
}
}
async function recarregarUfs() {
ufs = (await API.get('/api/meta/ufs')).ufs;
const atual = el('sel-uf').value;
renderUfs();
el('sel-uf').value = atual;
await onUfChange();
}
async function cancelarJob() {
try {
await comCarregando(el('btn-cancelar'), async () => {
const jobId = el('progresso-card').dataset.jobId;
const ativos = (await API.get('/api/jobs/active')).jobs;
const job = ativos.find((j) => j.jobId === jobId);
if (!job) {
toast('Nenhum job ativo para cancelar');
return;
}
const rota = job.type === 'extracao'
? `/api/extractions/${job.meta.extractionId}/cancel`
: `/api/capture/runs/${job.meta.runId}/cancel`;
await API.post(rota, {});
toast('Cancelamento solicitado — aguardando o processo parar...');
});
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
async function retomarJobAtivo() {
const ativos = (await API.get('/api/jobs/active')).jobs;
if (!ativos.length) return;
const job = ativos[0];
acompanharJob(job.jobId, job.type === 'extracao'
? `Baixando e extraindo base CNPJ de ${job.meta.uf}`
: 'Captura em andamento');
}
// ---------- histórico ----------
async function carregarHistorico() {
const { runs } = await API.get('/api/capture/runs?limit=8');
el('btn-ver-historico').textContent = runs.length ? `🕓 Histórico de capturas (${runs.length})` : '🕓 Histórico de capturas';
const box = el('historico-capturas');
if (!runs.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma captura ainda. Configure ao lado e clique em Iniciar.'));
return;
}
const statusLabel = {
done: '✅ concluída', running: '⏳ rodando', pending: '⏳ na fila',
cancelled: '⚪ cancelada', error: '❌ erro'
};
box.replaceChildren(...runs.map((r) => h('div', { class: 'item' },
h('div', {},
h('div', {}, h('strong', {}, `#${r.id} · ${(r.params.sources || []).join(', ')} · ${r.params.uf || ''}`)),
h('div', { class: 'sub' },
`${dataBr(r.created_at)} · ${statusLabel[r.status] || r.status}`,
r.error ? ` · ${r.error.slice(0, 80)}` : '')
),
h('div', { class: 'sub' },
r.status === 'done' ? `${fmt.format(r.new_count)} novos / ${fmt.format(r.dup_count)} repetidos` : '')
)));
}
// ---------- eventos ----------
function bindEvents() {
el('sel-uf').addEventListener('change', onUfChange);
el('btn-extrair').addEventListener('click', extrairBase);
el('btn-capturar').addEventListener('click', iniciarCaptura);
el('btn-cancelar').addEventListener('click', cancelarJob);
el('btn-ver-historico').addEventListener('click', () => el('modal-historico').showModal());
el('btn-fechar-historico').addEventListener('click', () => el('modal-historico').close());
for (const radio of document.querySelectorAll('input[name="cidades-modo"]')) {
radio.addEventListener('change', () => {
el('cidades-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked);
});
}
for (const radio of document.querySelectorAll('input[name="nichos-modo"]')) {
radio.addEventListener('change', () => {
el('nichos-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked);
atualizarContagem();
});
}
el('busca-cidade').addEventListener('input', (e) => buscarCidade(e.target.value));
el('busca-nicho').addEventListener('input', (e) => renderNichos(e.target.value));
el('busca-cnae').addEventListener('input', (e) => buscarCnae(e.target.value));
el('btn-nichos-todos').addEventListener('click', () => {
for (const input of document.querySelectorAll('#nichos-grid input')) {
input.checked = true;
nichosEscolhidos.add(input.value);
}
atualizarContagem();
});
el('btn-nichos-nenhum').addEventListener('click', () => {
nichosEscolhidos.clear();
renderNichos(el('busca-nicho').value);
atualizarContagem();
});
}
return { init };
})();
+16
View File
@@ -0,0 +1,16 @@
/* Tela de login: autentica e redireciona para o app. */
el('form-login').addEventListener('submit', async (event) => {
event.preventDefault();
const erroBox = el('login-erro');
erroBox.textContent = '';
try {
await API.post('/api/auth/login', {
username: el('login-username').value,
password: el('login-password').value
});
window.location.href = '/';
} catch (error) {
erroBox.textContent = error.message;
}
});
+676
View File
@@ -0,0 +1,676 @@
/* Aba Tratamento: dashboard, navegador de leads com filtros e exportações. */
const Tratamento = (() => {
let page = 1;
let totalPages = 1;
let carregado = false;
let refreshTimer = null;
const CLASSIFICACAO_LABELS = {
excelente: 'Excelente', bom: 'Bom', medio: 'Médio', baixo: 'Baixo', muito_baixo: 'Muito Baixo'
};
const PRIORIDADE_LABELS = {
1: 'P1 — atacar imediatamente', 2: 'P2 — trabalhar durante a semana',
3: 'P3 — quando não houver prioritários', 4: 'P4 — só em campanhas específicas'
};
const WHATSAPP_STATUS_LABELS = {
nao_consultado: 'Não consultado',
confirmado: 'Confirmado',
nao_cadastrado: 'Não cadastrado',
erro_temporario: 'Erro temporário'
};
function estrelas(n) {
const cheias = Math.max(0, Math.min(5, Number(n) || 0));
return '★'.repeat(cheias) + '☆'.repeat(5 - cheias);
}
// ---------- filtros ----------
function filtroAtual() {
const filtro = {};
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
const value = input.value.trim();
if (value) filtro[input.dataset.filtro] = value;
}
return filtro;
}
function filtroQuery(extra = {}) {
return new URLSearchParams({ ...filtroAtual(), ...extra }).toString();
}
// Clique nos cards/barras do dashboard: aplica o filtro somando aos ja
// ativos; clicar de novo no mesmo valor remove so aquele filtro.
function toggleFiltro(inputId, valor) {
const input = el(inputId);
const novo = String(valor ?? '');
input.value = input.value === novo ? '' : novo;
atualizar();
}
function descreverFiltro() {
const f = filtroAtual();
const partes = [];
if (f.uf) partes.push(`UF ${f.uf}`);
if (f.cidade) partes.push(f.cidade);
if (f.segmento) partes.push(el('f-segmento').selectedOptions[0]?.textContent || f.segmento);
if (f.classificacao) partes.push(CLASSIFICACAO_LABELS[f.classificacao] || f.classificacao);
if (f.prioridade) partes.push(`P${f.prioridade}`);
if (f.produto) partes.push(`produto: ${f.produto}`);
if (f.interesse) partes.push(f.interesse);
if (f.telefone_valido) partes.push(f.telefone_valido === 'sim' ? 'com telefone' : 'sem telefone');
if (f.website) partes.push(f.website === 'sim' ? 'com website' : 'sem website');
if (f.email) partes.push(f.email === 'sim' ? 'com e-mail' : 'sem e-mail');
if (f.exportado) partes.push(f.exportado === 'nao' ? 'ainda não exportados' : 'já exportados');
if (f.q) partes.push(`busca "${f.q}"`);
return partes.length ? partes.join(' · ') : 'todos os leads';
}
// ---------- carregamento ----------
async function atualizar() {
page = 1;
const [summary, evolutionRes, oportunidadeRes] = await Promise.all([
API.get(`/api/dashboard/summary?${filtroQuery()}`),
API.get(`/api/dashboard/evolution?dias=14&${filtroQuery()}`),
API.get(`/api/dashboard/oportunidade?${filtroQuery()}`)
]);
await Promise.all([carregarTiles(summary), carregarRankings(summary), carregarFunil(summary), carregarTabela()]);
carregarEvolucaoDiaria(evolutionRes.evolution);
carregarMaiorOportunidade(oportunidadeRes.oportunidade);
}
function invalidar() {
carregado = false;
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
if (document.querySelector('#tab-tratamento.active')) ativar();
}, 800);
}
async function ativar() {
if (carregado) return;
carregado = true;
await Promise.all([carregarFacets(), atualizar(), carregarExports()]);
}
async function carregarFacets() {
const facets = await API.get('/api/leads/facets');
const fill = (select, valores, prefixo, formatar = (v) => v) => {
const atual = select.value;
const primeira = select.options[0];
select.replaceChildren(primeira, ...valores.map((item) =>
h('option', { value: item.value }, `${formatar(item.value)} (${fmt.format(item.total)})`)
));
select.value = atual;
};
fill(el('f-uf'), facets.ufs);
fill(el('f-interesse'), facets.interesses);
fill(el('f-produto'), facets.produtos);
const segSelect = el('f-segmento');
const atual = segSelect.value;
segSelect.replaceChildren(segSelect.options[0], ...facets.segmentos.map((s) =>
h('option', { value: s.value }, `${s.value.replace(/_/g, ' ')} (${fmt.format(s.total)})`)
));
segSelect.value = atual;
el('dl-cidades').replaceChildren(...facets.cidades.map((c) => h('option', { value: c.value })));
}
async function salvarMetaDiaria(valor) {
const meta = Math.max(1, Number(valor) || 100);
await API.put('/api/settings', { meta_diaria: meta });
const summary = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
carregarTiles(summary);
}
async function carregarTiles(s) {
const pct = (n) => s.total ? ` (${((n / s.total) * 100).toFixed(1)}%)` : '';
el('stat-tiles').replaceChildren(
tile(s.total, 'Leads no filtro', ''),
tile(s.com_whatsapp, 'Com telefone', pct(s.com_whatsapp), 't-good'),
tile(s.p1, '🎯 Potencial Comercial Imediato', `${s.potencial_pct}% da base — atacar agora`, 't-good'),
tile(s.p4, '📦 P4 — campanhas específicas', pct(s.p4))
);
el('stat-tiles').append(
cardCobertura(s),
cardPrioridades(s),
cardMeta(s)
);
function tile(valor, rotulo, detalhe, cls = '') {
return h('div', { class: `tile ${cls}` },
h('div', { class: 'valor' }, fmt.format(valor)),
h('div', { class: 'rotulo' }, rotulo),
h('div', { class: 'detalhe' }, detalhe)
);
}
function cardCobertura(s) {
const cidadeAtiva = filtroAtual().cidade;
const titulo = cidadeAtiva ? `Cobertura em ${cidadeAtiva}` : 'Base Comercial — cobertura';
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'rotulo' }, titulo),
h('div', { class: 'progress-bar' },
h('div', { class: 'progress-fill', style: `width:${s.cobertura_pct}%` })),
h('div', { class: 'detalhe' },
`${s.cobertura_pct}% trabalhada · ${fmt.format(s.exportados)} exportadas · ${fmt.format(s.disponiveis)} disponíveis`)
);
}
function cardPrioridades(s) {
const ativa = filtroAtual().prioridade;
const linhas = [
['P1', s.p1, s.p1_pct], ['P2', s.p2, s.p2_pct], ['P3', s.p3, s.p3_pct], ['P4', s.p4, s.p4_pct]
];
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'rotulo' }, 'Distribuição por prioridade'),
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...linhas.map(([label, total, p], i) =>
h('div', {
class: `rank-row${ativa === String(i + 1) ? ' ativo' : ''}`,
title: `${label}: ${fmt.format(total)} leads — clique para filtrar`,
onclick: () => toggleFiltro('f-prioridade', i + 1)
},
h('div', { class: 'rank-label' }, label),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
h('div', { class: 'rank-value' }, `${p}%`)
)))
);
}
function cardMeta(s) {
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'row space-between' },
h('div', { class: 'rotulo' }, '🎯 Meta diária'),
h('input', {
type: 'number', min: '1', value: String(s.meta_diaria), class: 'meta-input',
onchange: (e) => salvarMetaDiaria(e.target.value)
})
),
h('div', { class: 'progress-bar' },
h('div', { class: 'progress-fill', style: `width:${Math.min(100, s.meta_pct)}%` })),
h('div', { class: 'detalhe' },
`${fmt.format(s.hoje_exportados)} de ${fmt.format(s.meta_diaria)} hoje (${s.meta_pct}%) · ~${fmt.format(s.dias_estimados)} dias úteis restantes no ritmo atual`)
);
}
}
async function carregarRankings(summary) {
const cidadeAtiva = filtroAtual().cidade;
el('rank-produtos-titulo').textContent = cidadeAtiva ? `Produtos recomendados em ${cidadeAtiva}` : 'Top produto recomendado';
const [cidades, segmentos, produtos] = await Promise.all([
API.get(`/api/dashboard/rankings?by=cidade&limit=10&${filtroQuery()}`),
API.get(`/api/dashboard/rankings?by=segmento&limit=10&${filtroQuery()}`),
API.get(`/api/dashboard/rankings?by=produto&limit=10&${filtroQuery()}`)
]);
const f = filtroAtual();
const segmentoValuePorLabel = (label) => {
const opts = [...el('f-segmento').options];
const porValor = opts.find((o) => o.value && o.value.replace(/_/g, ' ').toLowerCase() === label.toLowerCase());
const porTexto = opts.find((o) => o.textContent.toLowerCase().startsWith(label.toLowerCase()));
return (porValor || porTexto)?.value || '';
};
renderRanking(el('rank-cidades'), cidades.ranking,
(label) => toggleFiltro('f-cidade', label),
null,
(label) => f.cidade === label);
renderRanking(el('rank-segmentos'), segmentos.ranking,
(label) => toggleFiltro('f-segmento', segmentoValuePorLabel(label)),
null,
(label) => !!f.segmento && f.segmento === segmentoValuePorLabel(label));
renderRanking(el('rank-produtos'), produtos.ranking,
(label) => toggleFiltro('f-produto', label),
summary.total,
(label) => f.produto === label);
carregarOportunidadesDoDia(produtos.ranking, summary.total);
}
function carregarOportunidadesDoDia(produtosRanking, totalGeral) {
const top3 = produtosRanking.slice(0, 3);
if (!top3.length) {
el('card-oportunidades-dia').replaceChildren(
h('h2', {}, '🔥 Oportunidades do Dia'),
h('div', { class: 'muted' }, 'Sem dados para este filtro.')
);
return;
}
const produtoAtivo = filtroAtual().produto;
el('card-oportunidades-dia').replaceChildren(
h('h2', {}, '🔥 Oportunidades do Dia'),
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...top3.map((r) => {
const p = totalGeral ? Math.round((r.total / totalGeral) * 100) : 0;
return h('div', {
class: `rank-row${produtoAtivo === r.label ? ' ativo' : ''}`,
title: `${r.label}: ${fmt.format(r.total)} leads — clique para filtrar`,
onclick: () => toggleFiltro('f-produto', r.label)
},
h('div', { class: 'rank-label' }, r.label),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
h('div', { class: 'rank-value' }, `${p}%`)
);
}))
);
}
function dataBrCurta(diaIso) {
const [, mes, dia] = diaIso.split('-');
return `${dia}/${mes}`;
}
function carregarEvolucaoDiaria(evolution) {
if (!evolution.length) {
el('card-evolucao-diaria').replaceChildren(
h('h2', {}, '📈 Evolução Diária'),
h('div', { class: 'muted' }, 'Nenhuma exportação registrada ainda.')
);
return;
}
const max = Math.max(...evolution.map((d) => d.total));
el('card-evolucao-diaria').replaceChildren(
h('h2', {}, '📈 Evolução Diária'),
h('div', { class: 'muted', style: 'margin:2px 0 8px' }, 'Exportações por dia — única atividade com data registrada hoje.'),
h('div', { class: 'ranking' }, ...evolution.map((d) => h('div', { class: 'rank-row nao-clicavel' },
h('div', { class: 'rank-label' }, dataBrCurta(d.dia)),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (d.total / max) * 100)}%` })),
h('div', { class: 'rank-value' }, fmt.format(d.total))
)))
);
}
function carregarMaiorOportunidade(oportunidade) {
if (!oportunidade) {
el('card-maior-oportunidade').replaceChildren(
h('h2', {}, '🏆 Maior Oportunidade'),
h('div', { class: 'muted' }, 'Sem dados suficientes para este filtro.')
);
return;
}
const f = filtroAtual();
const jaAplicado = f.cidade === oportunidade.cidade && f.produto === oportunidade.produto;
el('card-maior-oportunidade').replaceChildren(
h('h2', {}, '🏆 Maior Oportunidade'),
h('div', {
class: `row space-between oportunidade-clicavel${jaAplicado ? ' ativo' : ''}`,
style: 'margin-top:6px',
title: jaAplicado ? 'Clique para remover o filtro cidade + produto' : 'Clique para filtrar por esta cidade + produto',
onclick: () => {
el('f-cidade').value = jaAplicado ? '' : oportunidade.cidade;
el('f-produto').value = jaAplicado ? '' : oportunidade.produto;
atualizar();
}
},
h('div', {}, h('div', { class: 'rotulo' }, 'Cidade'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.cidade)),
h('div', {}, h('div', { class: 'rotulo' }, 'Produto'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.produto)),
h('div', {}, h('div', { class: 'rotulo' }, 'Empresas'), h('div', { class: 'valor', style: 'font-size:20px' }, fmt.format(oportunidade.total)))
)
);
}
function renderRanking(box, ranking, onClick, totalGeral, isAtivo = () => false) {
if (!ranking.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Sem dados para este filtro.'));
return;
}
const max = ranking[0].total;
box.replaceChildren(...ranking.map((r) => {
const pctGeral = totalGeral ? ` · ${Math.round((r.total / totalGeral) * 100)}%` : '';
const ativo = isAtivo(r.label);
return h('div', {
class: `rank-row${ativo ? ' ativo' : ''}`,
title: `${r.label}: ${fmt.format(r.total)} leads (${fmt.format(r.p1)} prioridade 1) — ${ativo ? 'clique para remover o filtro' : 'clique para filtrar'}`,
onclick: () => onClick(r.label)
},
h('div', { class: 'rank-label' }, r.label),
h('div', { class: 'rank-track' },
h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (r.total / max) * 100)}%` })),
h('div', { class: 'rank-value' }, `${fmt.format(r.total)}${pctGeral}`)
);
}));
}
const FUNIL_ETAPAS = [
{ key: 'total', label: 'Leads capturados' },
{ key: 'disponiveis', label: 'Leads disponíveis' },
{ key: 'exportados', label: 'Leads exportados' },
{ key: null, label: 'Empresas contatadas' },
{ key: null, label: 'Empresas interessadas' },
{ key: null, label: 'Propostas enviadas' },
{ key: null, label: 'Clientes convertidos' }
];
function carregarFunil(s) {
const base = s.total || 1;
el('card-funil').replaceChildren(
h('h2', {}, 'Evolução Comercial'),
h('div', { class: 'funil' }, ...FUNIL_ETAPAS.map((etapa) => {
const pendente = etapa.key === null;
const valor = pendente ? null : s[etapa.key];
const largura = pendente ? 0 : Math.max(1.5, (valor / base) * 100);
return h('div', { class: `funil-etapa${pendente ? ' pendente' : ''}`,
title: pendente ? 'Aguardando integração com CRM' : '' },
h('div', { class: 'funil-label' }, etapa.label),
h('div', { class: 'funil-barra' }, h('div', { class: 'funil-fill', style: `width:${largura}%` })),
h('div', { class: 'funil-valor' }, pendente ? '—' : fmt.format(valor))
);
}))
);
}
async function carregarTabela() {
const data = await API.get(`/api/leads?${filtroQuery({ page, pageSize: 50 })}`);
totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
el('leads-total').textContent = `— ${fmt.format(data.total)} encontrados`;
el('pg-info').textContent = `página ${data.page} de ${fmt.format(totalPages)}`;
el('pg-prev').disabled = page <= 1;
el('pg-next').disabled = page >= totalPages;
const tbody = document.querySelector('#tabela-leads tbody');
if (!data.rows.length) {
tbody.replaceChildren(h('tr', {}, h('td', { colspan: '11', class: 'muted' }, 'Nenhum lead com este filtro.')));
return;
}
const abrirWhatsAppLead = async (event, lead) => {
event.stopPropagation();
if (!lead.whatsapp) {
toast('Este lead não tem telefone cadastrado');
return;
}
let leadCompleto = lead;
await comCarregando(event.currentTarget, async () => {
try {
leadCompleto = await API.get(`/api/leads/${lead.id}`);
} catch {
leadCompleto = lead;
}
});
const settingsButton = el('btn-settings');
settingsButton.click();
setTimeout(() => {
window.MaquinaSelecionarLeadManual?.(leadCompleto);
el('manual-numero').value = leadCompleto.whatsapp || lead.whatsapp;
const empresa = leadCompleto.empresa || lead.empresa;
const cidade = leadCompleto.cidade || lead.cidade;
el('manual-mensagem').value = `Olá! Encontrei a ${empresa}${cidade ? ` em ${cidade}` : ''} e gostaria de apresentar meu serviço. Podemos conversar?`;
el('manual-status').textContent = '';
}, 250);
};
tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) },
h('td', { class: 'acao-col' }, h('button', {
class: 'whatsapp-action',
title: 'Enviar WhatsApp para este lead',
onclick: (event) => abrirWhatsAppLead(event, lead)
}, '☎')),
h('td', { class: 'empresa-cell', title: lead.legal_name || lead.empresa }, lead.empresa),
h('td', {}, lead.whatsapp || '—'),
h('td', {}, whatsappStatusCell(lead)),
h('td', {}, lead.cidade),
h('td', {}, lead.segmento_label || lead.segmento),
h('td', {}, h('span', { class: `badge prioridade-${lead.prioridade}` }, `P${lead.prioridade}`)),
h('td', { class: 'num' }, String(lead.score)),
h('td', {}, h('span', { class: `badge ${lead.classificacao}` }, CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao)),
h('td', { class: 'produto-cell' }, produtoCell(lead.produto_principal, lead.afinidade)),
h('td', {}, lead.exported_at ? '📤 sim' : '—')
)));
}
const AFINIDADE_TABELA_LABELS = { alta: 'Alta aderência', media: 'Média aderência', baixa: 'Baixa aderência' };
function whatsappCheckedAt(value) {
if (!value) return '';
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`;
return Number.isNaN(Date.parse(normalized)) ? '' : dataBr(value);
}
function whatsappStatusCell(lead) {
const status = lead.whatsapp_status || 'nao_consultado';
const detalhes = [];
if (lead.whatsapp_numero_resolvido) detalhes.push(`Número confirmado: ${lead.whatsapp_numero_resolvido}`);
const checkedAt = whatsappCheckedAt(lead.whatsapp_checked_at);
if (checkedAt) detalhes.push(`Consultado em ${checkedAt}`);
return h('div', { class: 'whatsapp-verification' },
h('span', { class: `badge whatsapp-${status}` }, WHATSAPP_STATUS_LABELS[status] || status),
detalhes.length ? h('div', { class: 'sub' }, detalhes.join(' · ')) : null);
}
function produtoCell(produto, afinidade) {
if (!produto) return '—';
const label = AFINIDADE_TABELA_LABELS[afinidade];
return label ? `${produto} · ${label}` : produto;
}
const OBSERVACAO_POR_PRIORIDADE = {
1: 'Empresa apta para abordagem imediata.',
2: 'Aguardar a janela de trabalho desta semana.',
3: 'Abordar quando não houver leads de prioridade mais alta.',
4: 'Reservar para campanhas específicas — baixa prioridade comercial.'
};
const AFINIDADE_LABELS = { alta: 'nicho de alta aderência', media: 'nicho de média aderência', baixa: 'nicho de baixa aderência' };
function montarResumoComercial(lead) {
const potencial = Math.max(1, Math.min(5, Math.round((lead.score_comercial || 0) / 20)));
const pontosFortes = [];
if (lead.telefone_valido) pontosFortes.push('Telefone válido');
if (lead.cnpj_valido) pontosFortes.push('CNPJ válido');
if (lead.website) pontosFortes.push('Website');
if (lead.email) pontosFortes.push('E-mail cadastrado');
if (lead.afinidade) pontosFortes.push(AFINIDADE_LABELS[lead.afinidade] || 'nicho identificado');
return h('div', { class: 'resumo-comercial' },
h('h3', {}, 'Resumo comercial'),
h('div', { class: 'row space-between' },
h('span', { class: 'estrelas', title: `Potencial comercial: ${potencial}/5` }, estrelas(potencial)),
h('span', { class: `badge prioridade-${lead.prioridade}` }, PRIORIDADE_LABELS[lead.prioridade] || `P${lead.prioridade}`)
),
lead.produtos && lead.produtos.length
? h('div', {}, h('div', { class: 'muted', style: 'margin:8px 0 4px' }, 'Produtos recomendados'),
...lead.produtos.map((p) => h('div', {}, h('span', { class: 'estrelas' }, estrelas(p.estrelas)), ` ${p.nome}`)))
: h('div', { class: 'muted', style: 'margin-top:8px' }, 'Nenhum produto sugerido para este nicho.'),
pontosFortes.length
? h('ul', { class: 'pontos-fortes', style: 'margin-top:10px' }, ...pontosFortes.map((p) => h('li', {}, `✓ ${p}`)))
: null,
h('p', { class: 'muted', style: 'margin:10px 0 0' }, OBSERVACAO_POR_PRIORIDADE[lead.prioridade] || '')
);
}
function montarScoreBreakdown(lead) {
const nichoPontos = lead.afinidade === 'alta' ? '+40' : lead.afinidade === 'baixa' ? '+20' : '+30';
const qualidade = [
[lead.telefone_valido, 'Telefone válido', '+30'],
[lead.cnpj_valido, 'CNPJ válido', '+20'],
[lead.empresa_valida, 'Empresa válida', '+20'],
[!!lead.cidade, 'Cidade identificada', '+5'],
[!!lead.segmento, 'Nicho identificado', '+10'],
[!!lead.cnae, 'CNAE identificado', '+10'],
[!!lead.website, 'Website informado', '+5']
];
const comercial = [
[true, `Nicho ${AFINIDADE_TABELA_LABELS[lead.afinidade] || 'de aderência não identificada'}`, nichoPontos],
[!!lead.website, 'Website informado', '+15'],
[!!lead.email, 'E-mail informado', '+10'],
[lead.telefone_valido, 'Telefone válido', '+15'],
[lead.cnpj_valido, 'CNPJ válido', '+20']
];
const bloco = (itens, titulo, valorTotal) => h('div', {},
h('div', { class: 'row space-between', style: 'margin-top:10px' },
h('strong', {}, titulo), h('span', {}, String(valorTotal))),
h('ul', { class: 'score-breakdown' }, ...itens.map(([ok, label, pontos]) =>
h('li', { class: ok ? 'ok' : 'nok' }, `${ok ? '✓' : '✗'} ${label} (${pontos})`)))
);
return h('div', { class: 'score-breakdown-wrap' },
bloco(qualidade, 'Score Qualidade', lead.score_qualidade),
bloco(comercial, 'Score Comercial', lead.score_comercial)
);
}
async function abrirLead(id) {
const lead = await API.get(`/api/leads/${id}`);
const linha = (dt, dd) => [h('dt', {}, dt), h('dd', {}, dd || '—')];
el('lead-detalhe').replaceChildren(
h('h2', {}, lead.empresa),
montarResumoComercial(lead),
montarScoreBreakdown(lead),
h('dl', { class: 'detalhe-grid', style: 'margin-top:14px' },
linha('Razão social', lead.legal_name),
linha('Telefone', lead.whatsapp),
linha('Status WhatsApp', WHATSAPP_STATUS_LABELS[lead.whatsapp_status] || lead.whatsapp_status || 'Não consultado'),
linha('Número WhatsApp confirmado', lead.whatsapp_numero_resolvido),
linha('WhatsApp consultado em', whatsappCheckedAt(lead.whatsapp_checked_at)),
linha('Cidade/UF', `${lead.cidade}/${lead.uf}`),
linha('Endereço', lead.endereco),
linha('Nicho', lead.segmento_label || lead.segmento),
linha('Interesse', lead.interesse),
linha('CNPJ', lead.cnpj),
linha('CNAE', lead.cnae),
linha('Score geral', `${lead.score} (${CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao})`),
linha('Dores', (lead.pains || []).join(', ')),
linha('Origem', lead.origem),
linha('Capturado em', dataBr(lead.captured_at || lead.created_at)),
linha('Exportado em', lead.exported_at ? dataBr(lead.exported_at) : 'ainda não'),
linha('Exportações', lead.exports.length
? lead.exports.map((e) => `#${e.id} ${e.tipo}`).join(', ')
: 'nenhuma')
),
h('div', { class: 'row space-between', style: 'margin-top:14px' },
h('button', { class: 'btn-ghost', onclick: () => el('modal-lead').close() }, 'Fechar')
)
);
el('modal-lead').showModal();
}
// ---------- exportação ----------
const EXPORT_ATALHOS = [
{ label: 'Só P1', set: () => { el('f-prioridade').value = '1'; } },
{ label: 'Não exportados', set: () => { el('f-exportado').value = 'nao'; } }
];
async function atualizarRecapExport() {
const { total } = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
el('export-recap').textContent = `${fmt.format(total)} leads · ${descreverFiltro()}`;
return total;
}
function renderAtalhosExport() {
el('export-atalhos').replaceChildren(...EXPORT_ATALHOS.map((atalho) => {
const botao = h('button', {
type: 'button', class: 'chip', onclick: async () => {
await comCarregando(botao, async () => {
atalho.set();
await atualizar();
await atualizarRecapExport();
});
}
}, atalho.label);
return botao;
}));
}
async function abrirModalExport(event) {
const botao = event?.currentTarget || el('btn-exportar');
await comCarregando(botao, async () => {
const total = await atualizarRecapExport();
if (!total) {
toast('Nenhum lead com este filtro — ajuste os filtros antes de exportar');
return;
}
renderAtalhosExport();
el('modal-export').showModal();
});
}
async function confirmarExport(event) {
event.preventDefault();
const tipo = document.querySelector('input[name="export-tipo"]:checked').value;
const body = {
tipo,
filtro: filtroAtual(),
params: { marcarExportado: el('export-marcar').checked }
};
try {
const { jobId, totalEstimado } = await comCarregando(el('btn-confirmar-export'), () => API.post('/api/exports', body));
el('modal-export').close();
toast(`Gerando arquivos para ${fmt.format(totalEstimado)} leads...`);
API.watchJob(jobId, {
done() {
toast('✅ Exportação pronta! Baixe os arquivos na lista abaixo.');
carregarExports();
atualizar();
},
error(e) {
toast(`❌ Erro na exportação: ${e.message}`);
carregarExports();
}
});
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
async function carregarExports() {
const { exports } = await API.get('/api/exports?limit=12');
const box = el('historico-exports');
if (!exports.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma exportação gerada ainda.'));
return;
}
const tipoLabel = { excel: 'Planilha CSV + XLS', legado: 'Importação antiga' };
box.replaceChildren(...exports.map((e) => h('div', { class: 'item' },
h('div', {},
h('div', {}, h('strong', {}, `#${e.id} · ${tipoLabel[e.tipo] || e.tipo}`),
` · ${fmt.format(e.total_leads)} leads`),
h('div', { class: 'sub' }, `${dataBr(e.created_at)} · ${e.status}${e.error ? ` · ${e.error.slice(0, 60)}` : ''}`)
),
h('div', { class: 'row' }, ...(e.status === 'done' && e.tipo !== 'legado'
? e.arquivos.map((nome) =>
h('a', { class: 'btn-ghost', href: `/api/exports/${e.id}/download?arquivo=${encodeURIComponent(nome)}` },
`⬇ ${nome.split('.').pop().toUpperCase()}`))
: []))
)));
}
// ---------- eventos ----------
function bindEvents() {
let debounce = null;
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
clearTimeout(debounce);
debounce = setTimeout(atualizar, input.tagName === 'SELECT' ? 0 : 350);
});
}
el('btn-limpar-filtros').addEventListener('click', (event) => {
comCarregando(event.currentTarget, async () => {
for (const input of document.querySelectorAll('#filtros [data-filtro]')) input.value = '';
await atualizar();
});
});
// pg-prev/pg-next: nao usam comCarregando de proposito. carregarTabela() ja
// controla o atributo disabled destes botoes conforme a pagina atual
// (limite inicio/fim); o finally do comCarregando reabilitaria o botao
// incondicionalmente ao final, conflitando com esse controle de borda.
el('pg-prev').addEventListener('click', () => { page -= 1; carregarTabela(); });
el('pg-next').addEventListener('click', () => { page += 1; carregarTabela(); });
el('btn-exportar').addEventListener('click', abrirModalExport);
el('form-export').addEventListener('submit', confirmarExport);
}
return { ativar, invalidar, bindEvents };
})();
window.Tratamento = Tratamento;
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Máquina Comercial Pessoal - Login</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2216%22 fill=%22%23f06a25%22/><text x=%2232%22 y=%2241%22 text-anchor=%22middle%22 font-family=%22Arial%22 font-size=%2226%22 font-weight=%22700%22 fill=%22white%22>MC</text></svg>">
</head>
<body class="login-body">
<main class="login-wrap">
<form class="card login-card" id="form-login">
<div class="brand login-brand">
<span class="brand-mark brand-mark-lg brand-monogram" aria-hidden="true">MC</span>
<span class="brand-name">Máquina Comercial Pessoal</span>
</div>
<div class="field">
<label for="login-username">Usuário</label>
<input type="text" id="login-username" autocomplete="username" required>
</div>
<div class="field">
<label for="login-password">Senha</label>
<input type="password" id="login-password" autocomplete="current-password" required>
</div>
<p class="muted login-erro" id="login-erro"></p>
<button class="btn btn-primary btn-lg" type="submit">Entrar</button>
</form>
</main>
<div id="toast" class="toast hidden"></div>
<script src="/js/api.js"></script>
<script src="/js/login.js"></script>
</body>
</html>