Files
agenciapsilmno/src/layout/ConfiguracoesPage.vue
T
Leonardo c2c42a1620 3.7 Bot auto-triagem WhatsApp: config por tenant + hook nos inbound
Bot que coleta nome, motivo de busca e preferências ANTES do paciente
entrar no fluxo humano. Terapeuta abre a conversa e já encontra
resumo em conversation_notes.

Banco (migration 20260423000007):
- conversation_bots: config 1 por tenant. enabled, greeting/closing
  messages, steps (JSONB array de {prompt, variable, type}), trigger_mode
  (new_contact | all_unassigned | keyword), trigger_keywords[],
  idle_timeout_minutes, respect_optout.
  Defaults vêm com 4 perguntas úteis: nome, motivo, modalidade,
  horário preferido.
- conversation_bot_sessions: estado por thread. current_step,
  collected_data JSONB, status (active | completed | abandoned_idle |
  abandoned_manual | opted_out). UNIQUE parcial garante 1 ativa por
  (tenant, thread).
- RLS: leitura tenant/saas_admin, escrita admins (config) + service_role
  (sessions, só edge altera).

Shared (_shared/whatsapp-hooks.ts):
- maybeProcessBot: carrega config, busca sessão ativa, avança step
  com resposta, envia próxima pergunta via SendFn. Ao esgotar steps,
  envia closing + cria conversation_notes com resumo das variáveis
  coletadas. Se humano assume (conversation_assignments preenchido),
  sessão marca 'abandoned_manual' e bot sai.
- Trigger modes:
  - 'new_contact' (default): só inicia pra thread sem histórico bot
    E sem paciente vinculado (lead real).
  - 'all_unassigned': qualquer thread sem assignee.
  - 'keyword': matched contra lista; normalizeForMatch já existe.

Integração nos inbound (ambos providers):
- evolution-whatsapp-inbound: chama maybeProcessBot após opt-in/opt-out,
  ANTES do auto-reply. Se bot processou, skip auto-reply (senão duas
  respostas sobrepostas).
- twilio-whatsapp-inbound: idem, usando makeTwilioCreditedSendFn pra
  cobrar crédito de cada mensagem enviada pelo bot.

UI (/configuracoes/conversas-bots):
- Toggle enabled + Select trigger_mode + (se keyword) chips de keywords.
- Textareas greeting/closing.
- Editor de steps: reordenar (up/down), remover, add, editor com prompt
  e variable (regex /^[a-z_][a-z0-9_]*$/).
- Botão "Padrão" restaura mensagens/steps default.
- InputNumber idle_timeout + toggle respect_optout.
- Card inferior: últimas 30 sessões (7 dias) com status, contato,
  nome coletado (primeiro campo), progresso (step X/N), início.
- Entrada na landing de configurações + rota /configuracoes/conversas-bots.

Caveat conhecido: a resolução de conversation_notes.created_by usa
o primeiro admin ativo do tenant (pickAnyAdmin). Pra uma v2 seria
ideal ter um user "bot" sintético dedicado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:54:53 -03:00

556 lines
23 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/ConfiguracoesPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useLayout } from '@/layout/composables/layout';
const route = useRoute();
const router = useRouter();
const showMenu = ref(false);
const asideOpen = ref(false);
// ── Layout-aware left position (igual ao TherapistDashboard) ──────────────
const { effectiveVariant, layoutState, layoutConfig, isMobile, railPanelPushesLayout } = useLayout();
const isMobileLayout = computed(() => isMobile.value);
const asideLeft = computed(() => {
if (isMobileLayout.value) return undefined;
if (effectiveVariant.value !== 'rail') {
const isStaticActive = layoutConfig.menuMode === 'static' && !layoutState.staticMenuInactive;
return isStaticActive ? '20rem' : '0';
}
return railPanelPushesLayout.value ? 'calc(60px + 260px)' : '60px';
});
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _observer = null;
const grupos = [
{
key: 'agenda',
label: 'Agenda',
desc: 'Horários, bloqueios e agendador público para pacientes.',
icon: 'pi pi-calendar',
items: [
{
key: 'agenda',
label: 'Agenda',
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
icon: 'pi pi-calendar',
to: '/configuracoes/agenda'
},
{
key: 'bloqueios',
label: 'Bloqueios',
desc: 'Feriados nacionais, municipais e períodos bloqueados.',
icon: 'pi pi-ban',
to: '/configuracoes/bloqueios'
},
{
key: 'agendador',
label: 'Agendador Online',
desc: 'Link público para pacientes solicitarem horários.',
icon: 'pi pi-calendar-clock',
to: '/configuracoes/agendador'
}
]
},
{
key: 'financeiro',
label: 'Financeiro',
desc: 'Formas de pagamento, valores, descontos e convênios.',
icon: 'pi pi-wallet',
items: [
{
key: 'pagamento',
label: 'Pagamento',
desc: 'Formas de pagamento: Pix, depósito, dinheiro, cartão, convênio.',
icon: 'pi pi-wallet',
to: '/configuracoes/pagamento'
},
{
key: 'precificacao',
label: 'Precificação',
desc: 'Valor padrão da sessão e preços por tipo de compromisso.',
icon: 'pi pi-tag',
to: '/configuracoes/precificacao'
},
{
key: 'descontos',
label: 'Descontos por Paciente',
desc: 'Descontos recorrentes aplicados automaticamente.',
icon: 'pi pi-percentage',
to: '/configuracoes/descontos'
},
{
key: 'excecoes-financeiras',
label: 'Exceções Financeiras',
desc: 'O que cobrar em faltas, cancelamentos e situações excepcionais.',
icon: 'pi pi-exclamation-triangle',
to: '/configuracoes/excecoes-financeiras'
},
{
key: 'convenios',
label: 'Convênios',
desc: 'Cadastre os convênios que você atende e seus valores.',
icon: 'pi pi-id-card',
to: '/configuracoes/convenios'
}
]
},
{
key: 'whatsapp',
label: 'WhatsApp & Conversas',
desc: 'Canal, tags, auto-reply, lembretes e créditos de mensagens.',
icon: 'pi pi-whatsapp',
items: [
{
key: 'whatsapp',
label: 'Canal WhatsApp',
desc: 'Escolha o canal (oficial AgenciaPSI ou pessoal) e configure a integração.',
icon: 'pi pi-whatsapp',
to: '/configuracoes/whatsapp',
aliases: ['/configuracoes/whatsapp-pessoal', '/configuracoes/whatsapp-oficial']
},
{
key: 'whatsapp-templates',
label: 'Templates WhatsApp',
desc: 'Personalize os textos enviados por WhatsApp ou volte ao padrão da plataforma.',
icon: 'pi pi-file-edit',
to: '/configuracoes/whatsapp-templates'
},
{
key: 'conversas-tags',
label: 'Tags de Conversa',
desc: 'Etiquetas custom pra classificar threads no CRM (urgente, remarcação, etc).',
icon: 'pi pi-tag',
to: '/configuracoes/conversas-tags'
},
{
key: 'conversas-autoreply',
label: 'Auto-reply WhatsApp',
desc: 'Resposta automática quando paciente escreve fora do horário de atendimento.',
icon: 'pi pi-reply',
to: '/configuracoes/conversas-autoreply'
},
{
key: 'conversas-optouts',
label: 'Opt-outs (LGPD)',
desc: 'Números que pediram pra não receber mensagens automáticas. Direito de oposição LGPD.',
icon: 'pi pi-ban',
to: '/configuracoes/conversas-optouts'
},
{
key: 'conversas-sla',
label: 'SLA de resposta',
desc: 'Tempo máximo pra responder mensagens de pacientes. Alerta quando estourar.',
icon: 'pi pi-stopwatch',
to: '/configuracoes/conversas-sla'
},
{
key: 'conversas-bots',
label: 'Bot de triagem',
desc: 'Coleta nome e motivo do paciente via WhatsApp antes de entrar no fluxo humano.',
icon: 'pi pi-android',
to: '/configuracoes/conversas-bots'
},
{
key: 'lembretes-sessao',
label: 'Lembretes de Sessão',
desc: 'WhatsApp automático 24h e 2h antes das sessões agendadas.',
icon: 'pi pi-bell',
to: '/configuracoes/lembretes-sessao'
},
{
key: 'creditos-whatsapp',
label: 'Créditos WhatsApp',
desc: 'Compre pacotes de mensagens, veja saldo e extrato.',
icon: 'pi pi-credit-card',
to: '/configuracoes/creditos-whatsapp'
}
]
},
{
key: 'comunicacao',
label: 'Comunicação',
desc: 'SMS e templates de e-mail enviados aos pacientes.',
icon: 'pi pi-send',
items: [
{
key: 'sms',
label: 'SMS',
desc: 'Gerencie créditos SMS e personalize as mensagens enviadas.',
icon: 'pi pi-comment',
to: '/configuracoes/sms'
},
{
key: 'email-templates',
label: 'Templates de E-mail',
desc: 'Personalize os e-mails enviados aos pacientes.',
icon: 'pi pi-envelope',
to: '/configuracoes/email-templates'
}
]
},
{
key: 'plataforma',
label: 'Empresa & Plataforma',
desc: 'Dados da empresa, recursos extras e registro de auditoria.',
icon: 'pi pi-building',
items: [
{
key: 'empresa',
label: 'Minha Empresa',
desc: 'CNPJ, endereço, logomarca e redes sociais.',
icon: 'pi pi-building',
to: '/configuracoes/empresa'
},
{
key: 'recursos-extras',
label: 'Recursos Extras',
desc: 'Amplíe as funcionalidades com recursos adicionais e créditos.',
icon: 'pi pi-box',
to: '/configuracoes/recursos-extras'
},
{
key: 'auditoria',
label: 'Auditoria',
desc: 'Registro imutável de operações (LGPD Art. 37).',
icon: 'pi pi-shield',
to: '/configuracoes/auditoria'
}
]
}
];
// Flatten pra cálculos de item ativo (mesma lógica de antes)
const secoesFlat = computed(() => grupos.flatMap((g) => g.items));
const activeTo = computed(() => {
const p = route.path || '';
const hit = [...secoesFlat.value]
.sort((a, b) => b.to.length - a.to.length)
.find((s) => p === s.to || p.startsWith(s.to + '/') || (s.aliases || []).includes(p));
return hit?.to || '/configuracoes/agenda';
});
const activeSecao = computed(() => secoesFlat.value.find((s) => s.to === activeTo.value));
// Grupo que contém a seção ativa (pra auto-expandir no accordion)
const activeGrupoKey = computed(() => grupos.find((g) => g.items.some((i) => i.to === activeTo.value))?.key || grupos[0].key);
// Accordion multi-open: começa com o grupo ativo aberto
const openGroups = ref([activeGrupoKey.value]);
// Quando a rota mudar e o grupo ativo não estiver aberto, abre ele (sem fechar os outros)
watch(activeGrupoKey, (k) => {
if (k && !openGroups.value.includes(k)) {
openGroups.value = [...openGroups.value, k];
}
});
function ir(to) {
if (!to) return;
if (route.path !== to) router.push(to);
asideOpen.value = false;
}
onMounted(() => {
requestAnimationFrame(() => {
showMenu.value = true;
});
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
_observer = new IntersectionObserver(
([entry]) => {
headerStuck.value = !entry.isIntersecting;
},
{ threshold: 0, rootMargin }
);
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
});
onBeforeUnmount(() => {
_observer?.disconnect();
});
</script>
<template>
<div class="flex min-h-screen bg-[var(--surface-ground)]">
<!-- Overlay mobile -->
<div v-if="asideOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="asideOpen = false" />
<!-- Aside drawer -->
<aside
class="cfg-aside-drawer flex flex-col overflow-y-auto shrink-0 bg-[var(--surface-card)] border-r border-[var(--surface-border)]"
:class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'"
:style="{ left: asideLeft }"
>
<!-- Cabeçalho da aside (sticky igual à sidebar principal) -->
<div class="cfg-aside-header sticky top-0 z-10 flex items-center gap-2 px-4 h-[50px] border-b border-[var(--surface-border)] shrink-0 bg-[var(--surface-card)]">
<div class="grid place-items-center w-8 h-8 rounded-md bg-indigo-500/[0.12] text-[var(--p-primary-500,#6366f1)] shrink-0">
<i class="pi pi-cog text-sm" />
</div>
<span class="text-sm font-bold text-[var(--text-color)] tracking-tight">Configurações</span>
</div>
<!-- Label seções -->
<div class="flex items-center gap-1.5 px-4 pt-3 pb-1.5 text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">
<i class="pi pi-list text-[0.65rem]" />
<span>Seções</span>
</div>
<!-- Accordion de grupos -->
<div v-if="showMenu" class="cfg-menu-accordion px-2 pb-3">
<Accordion v-model:value="openGroups" multiple>
<AccordionPanel v-for="g in grupos" :key="g.key" :value="g.key">
<AccordionHeader class="cfg-group-header">
<div class="flex items-center gap-2.5 w-full min-w-0">
<div class="cfg-group-icon grid place-items-center w-9 h-9 rounded-md shrink-0 bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] text-[var(--primary-color,#6366f1)]">
<i :class="g.icon" class="text-[0.95rem]" />
</div>
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<span class="text-[0.9rem] font-bold leading-5 text-[var(--text-color)] truncate">{{ g.label }}</span>
<span class="text-[0.8rem] leading-[1.15rem] text-[var(--text-color-secondary)] opacity-75 whitespace-normal break-words">{{ g.desc }}</span>
</div>
<Badge :value="g.items.length" severity="contrast" class="cfg-group-badge shrink-0" />
</div>
</AccordionHeader>
<AccordionContent>
<div class="cfg-nav-list flex flex-col gap-2 py-1">
<button
v-for="s in g.items"
:key="s.key"
class="cfg-nav-item flex items-center gap-3 p-3 cursor-pointer w-full text-left rounded-[6px] border bg-[var(--surface-card)] transition-colors duration-[120ms]"
:class="activeTo === s.to ? 'cfg-nav-item--active' : 'border-[var(--surface-border)]'"
@click="ir(s.to)"
>
<div
class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0 transition-colors duration-[120ms]"
:class="activeTo === s.to
? 'bg-[color-mix(in_srgb,var(--primary-color)_15%,transparent)] text-[var(--primary-color,#6366f1)]'
: 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
>
<i :class="s.icon" class="text-lg" />
</div>
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<span
class="font-semibold leading-5 truncate"
:class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'"
>{{ s.label }}</span>
<span class="text-sm leading-[1.15rem] text-[var(--text-color-secondary)] whitespace-normal break-words">{{ s.desc }}</span>
</div>
</button>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
</aside>
<!-- Área principal -->
<div class="flex-1 min-w-0 xl:pl-[320px]">
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero compacto -->
<div
ref="headerEl"
class="sticky top-[var(--layout-sticky-top,56px)] z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 my-3"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-300/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/[0.12] text-[var(--p-primary-500,#6366f1)]">
<i class="pi pi-cog text-base" />
</div>
<div class="min-w-0">
<!-- Título: Configurações · Seção -->
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-1.5 flex-wrap">
<span>Configurações</span>
<template v-if="activeSecao">
<span class="text-[var(--text-color-secondary)] opacity-40 font-normal">·</span>
<span>{{ activeSecao.label }}</span>
</template>
</div>
<!-- Subtítulo: ícone + descrição da seção -->
<div class="text-xs text-[var(--text-color-secondary)] flex items-center gap-1 mt-px">
<template v-if="activeSecao">
<i :class="activeSecao.icon" class="opacity-50 shrink-0" />
<span>{{ activeSecao.desc }}</span>
</template>
<span v-else class="opacity-60">Configurações gerais do sistema</span>
</div>
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-2 ml-auto shrink-0">
<!-- Slot de ações específicas da sub-página (via Teleport) -->
<div id="cfg-page-actions" class="flex items-center gap-1.5"></div>
<!-- Toggle aside mobile/tablet apenas -->
<button
class="xl:hidden inline-flex items-center gap-1.5 h-9 px-3 rounded-full border border-[var(--surface-border)] bg-transparent text-xs font-semibold text-[var(--text-color)] cursor-pointer hover:bg-[var(--surface-hover)] transition-colors duration-150"
@click="asideOpen = !asideOpen"
>
<i class="pi pi-bars text-[0.75rem]" />
<span class="hidden sm:inline">Menu desta seção</span>
</button>
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Voltar'" @click="router.back()" />
</div>
</div>
</div>
<!-- Conteúdo da seção -->
<div class="px-3 md:px-4 pb-5">
<router-view />
</div>
</div>
</div>
</template>
<style scoped>
/* Aside drawer — comportamento responsivo */
.cfg-aside-drawer {
position: fixed;
top: calc(56px + var(--notice-banner-height, 0px));
left: 0;
height: calc(100dvh - 56px - var(--notice-banner-height, 0px));
width: min(320px, 90vw);
z-index: 40;
overflow-y: auto;
transition:
transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
visibility 0.25s;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
@media (min-width: 1280px) {
.cfg-aside-drawer {
position: fixed;
top: calc(56px + var(--notice-banner-height, 0px));
height: calc(100vh - 56px - var(--notice-banner-height, 0px));
width: 320px;
transform: none;
visibility: visible;
box-shadow: none;
z-index: auto;
overflow-y: auto;
}
}
/* ── Lista de subitens (cards) ──────────────────────────────── */
.cfg-nav-list {
margin: 0.15rem 0.25rem 0.35rem 1.5rem;
}
/* ── Hover e estado ativo dos cards ─────────────────────────── */
.cfg-nav-item {
transition:
background-color 150ms ease,
border-color 150ms ease;
}
.cfg-nav-item:hover {
background: var(--surface-hover) !important;
border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)) !important;
}
.cfg-nav-item--active {
background: color-mix(in srgb, var(--primary-color) 6%, var(--surface-card));
border-color: var(--primary-color, #6366f1) !important;
}
/* ── Accordion do menu — compacto, sem bordas grossas ───────────── */
.cfg-menu-accordion :deep(.p-accordion) {
display: flex;
flex-direction: column;
gap: 2px;
}
.cfg-menu-accordion :deep(.p-accordionpanel) {
border: none;
background: transparent;
}
.cfg-menu-accordion :deep(.p-accordionheader) {
padding: 0.5rem 0.5rem;
border-radius: 8px;
background: transparent;
border: none;
color: var(--text-color);
transition: background-color 120ms ease;
min-height: 52px;
}
.cfg-menu-accordion :deep(.p-accordionheader:hover) {
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card)) !important;
color: var(--text-color) !important;
}
.cfg-menu-accordion :deep(.p-accordionheader:focus-visible) {
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary-color) 30%, transparent);
}
.cfg-menu-accordion :deep(.p-accordionheader-toggle-icon) {
color: var(--text-color-secondary);
opacity: 0.55;
font-size: 0.7rem;
margin-left: 0.25rem;
transition: color 150ms ease, opacity 150ms ease, transform 150ms ease;
}
.cfg-menu-accordion :deep(.p-accordionheader:hover) .p-accordionheader-toggle-icon {
color: var(--primary-color, #6366f1);
opacity: 0.8;
}
/* Ícone do grupo ganha anel primary no hover (igual aos subitens) */
.cfg-group-icon {
transition: box-shadow 150ms ease, background-color 150ms ease;
}
.cfg-menu-accordion :deep(.p-accordionheader:hover) .cfg-group-icon {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 25%, transparent);
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
}
/* Badge circular do contador de itens (igual ao p-badge-circle do pasteds.txt) */
.cfg-group-badge :deep(.p-badge),
.cfg-group-badge.p-badge {
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.35rem;
font-size: 0.68rem;
font-weight: 700;
border-radius: 9999px;
line-height: 1.25rem;
}
.cfg-menu-accordion :deep(.p-accordioncontent) {
border: none;
background: transparent;
}
.cfg-menu-accordion :deep(.p-accordioncontent-content) {
padding: 0.15rem 0.2rem 0.35rem;
background: transparent;
border: none;
}
</style>