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>
This commit is contained in:
Leonardo
2026-04-23 07:05:24 -03:00
parent 037ba3721f
commit 2644e60bb6
191 changed files with 38629 additions and 3756 deletions
+325 -136
View File
@@ -15,7 +15,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useLayout } from '@/layout/composables/layout';
@@ -43,128 +43,215 @@ const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _observer = null;
const secoes = [
const grupos = [
{
key: 'agenda',
label: 'Agenda',
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
desc: 'Horários, bloqueios e agendador público para pacientes.',
icon: 'pi pi-calendar',
to: '/configuracoes/agenda',
tags: ['Horários', 'Exceções', 'Duração']
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: 'bloqueios',
label: 'Bloqueios',
desc: 'Feriados nacionais, municipais e períodos bloqueados.',
icon: 'pi pi-ban',
to: '/configuracoes/bloqueios',
tags: ['Feriados', 'Períodos', 'Recorrentes']
},
{
key: 'agendador',
label: 'Agendador Online',
desc: 'Link público para pacientes solicitarem horários.',
icon: 'pi pi-calendar-clock',
to: '/configuracoes/agendador',
tags: ['PRO', 'Link', 'Pix', 'LGPD']
},
{
key: 'pagamento',
label: 'Pagamento',
desc: 'Formas de pagamento: Pix, depósito, dinheiro, cartão, convênio.',
key: 'financeiro',
label: 'Financeiro',
desc: 'Formas de pagamento, valores, descontos e convênios.',
icon: 'pi pi-wallet',
to: '/configuracoes/pagamento',
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
},
{
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',
tags: ['Valores', 'Sessão', 'Compromisso']
},
{
key: 'descontos',
label: 'Descontos por Paciente',
desc: 'Descontos recorrentes aplicados automaticamente.',
icon: 'pi pi-percentage',
to: '/configuracoes/descontos',
tags: ['Desconto', 'Paciente', 'Automático']
},
{
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',
tags: ['Falta', 'Cancelamento', 'Cobrança']
},
{
key: 'convenios',
label: 'Convênios',
desc: 'Cadastre os convênios que você atende e seus valores.',
icon: 'pi pi-id-card',
to: '/configuracoes/convenios',
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
},
{
key: 'empresa',
label: 'Minha Empresa',
desc: 'CNPJ, endereço, logomarca e redes sociais.',
icon: 'pi pi-building',
to: '/configuracoes/empresa',
tags: ['CNPJ', 'Endereço', 'Logo']
},
{
key: 'email-templates',
label: 'Templates de E-mail',
desc: 'Personalize os e-mails enviados aos pacientes.',
icon: 'pi pi-envelope',
to: '/configuracoes/email-templates',
tags: ['E-mail', 'Notificações', 'Personalizar']
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',
desc: 'Configure a integração WhatsApp e personalize as mensagens.',
label: 'WhatsApp & Conversas',
desc: 'Canal, tags, auto-reply, lembretes e créditos de mensagens.',
icon: 'pi pi-whatsapp',
to: '/configuracoes/whatsapp',
tags: ['WhatsApp', 'Mensagens', 'Notificações']
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: 'whatsapp-twilio',
label: 'WhatsApp Oficial',
desc: 'Número WhatsApp Business exclusivo via Twilio.',
icon: 'pi pi-whatsapp',
to: '/configuracoes/whatsapp-twilio',
tags: ['WhatsApp', 'Twilio', 'Número Exclusivo']
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: 'sms',
label: 'SMS',
desc: 'Gerencie créditos SMS e personalize as mensagens enviadas.',
icon: 'pi pi-comment',
to: '/configuracoes/sms',
tags: ['SMS', 'Créditos', 'Mensagens']
},
{
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',
tags: ['Add-ons', 'Créditos', 'Extra']
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 = [...secoes].sort((a, b) => b.to.length - a.to.length).find((s) => p === s.to || p.startsWith(s.to + '/'));
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(() => secoes.find((s) => s.to === activeTo.value));
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;
@@ -203,8 +290,8 @@ onBeforeUnmount(() => {
:class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'"
:style="{ left: asideLeft }"
>
<!-- Cabeçalho da aside -->
<div class="flex items-center gap-2 px-4 py-3.5 border-b border-[var(--surface-border)] shrink-0">
<!-- 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>
@@ -217,38 +304,64 @@ onBeforeUnmount(() => {
<span>Seções</span>
</div>
<!-- Itens de navegação -->
<TransitionGroup name="menu" tag="div" class="flex flex-col gap-0 px-2 pb-3">
<button
v-for="(s, i) in showMenu ? secoes : []"
:key="s.key"
:style="{ transitionDelay: `${i * 40}ms` }"
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer w-full text-left transition-colors duration-[120ms] hover:bg-[var(--surface-hover)]"
:class="activeTo === s.to ? 'cfg-nav-item--active' : ''"
@click="ir(s.to)"
>
<i
:class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-100' : 'text-[var(--text-color-secondary)] opacity-60']"
class="text-[0.85rem] shrink-0 w-4 text-center"
/>
<div class="flex-1 min-w-0 flex flex-col gap-px">
<span class="text-[0.88rem] font-semibold truncate" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'">{{ s.label }}</span>
<span class="text-[0.75rem] text-[var(--text-color-secondary)] opacity-70 truncate">{{ s.desc }}</span>
</div>
<i class="pi pi-chevron-right text-[0.6rem] shrink-0" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-60' : 'text-[var(--text-color-secondary)] opacity-30'" />
</button>
</TransitionGroup>
<!-- 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-[272px]">
<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 mb-3"
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 -->
@@ -285,6 +398,9 @@ onBeforeUnmount(() => {
<!-- 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"
@@ -313,7 +429,7 @@ onBeforeUnmount(() => {
top: calc(56px + var(--notice-banner-height, 0px));
left: 0;
height: calc(100dvh - 56px - var(--notice-banner-height, 0px));
width: min(272px, 85vw);
width: min(320px, 90vw);
z-index: 40;
overflow-y: auto;
transition:
@@ -326,7 +442,7 @@ onBeforeUnmount(() => {
position: fixed;
top: calc(56px + var(--notice-banner-height, 0px));
height: calc(100vh - 56px - var(--notice-banner-height, 0px));
width: 272px;
width: 320px;
transform: none;
visibility: visible;
box-shadow: none;
@@ -335,18 +451,91 @@ onBeforeUnmount(() => {
}
}
.cfg-nav-item--active {
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
/* ── Lista de subitens (cards) ──────────────────────────────── */
.cfg-nav-list {
margin: 0.15rem 0.25rem 0.35rem 1.5rem;
}
/* TransitionGroup menu */
.menu-enter-active {
/* ── Hover e estado ativo dos cards ─────────────────────────── */
.cfg-nav-item {
transition:
opacity 0.2s ease,
transform 0.2s ease;
background-color 150ms ease,
border-color 150ms ease;
}
.menu-enter-from {
opacity: 0;
transform: translateX(-6px);
.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>