Files
agenciapsilmno/src/layout/ConfiguracoesPage.vue
T
Leonardo 2644e60bb6 CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos
Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.

═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════

3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.

3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).

3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).

3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.

3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".

═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════

- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
  Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
  1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
  whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
  reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
  envolve envio em dedução atômica + rollback). Consumido por Evolution E
  Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
  MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand

═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════

Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
  transactions, packages, purchases) + RPCs add_whatsapp_credits e
  deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
  de 11 ou 14 dígitos)

Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
  QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
  quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance

Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
  utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
  persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
  error.context.json()

Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
  - Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
    toggle is_active inline, dialog de edição com validação
  - Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
    add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
    das últimas 20 transações topup/adjustment/refund

═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════

2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)

2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha

═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════

5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords

═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════

- contact_types + contact_phones (entity_type + entity_id) — migration
  20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
  remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
  unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
  legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
  e cria/atualiza phone como WhatsApp vinculado

═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════

- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
  .dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
  (fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
  error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
  Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
  sino + popup + browser notification)

═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════

20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj

═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════

Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound

═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════

Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits

Stores: conversationDrawerStore

Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor

Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)

Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados

═══════════════════════════════════════════════════════════════════════════
NOTAS

- Após subir, rodar supabase functions serve --no-verify-jwt
  --env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
  providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos

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

542 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: '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>