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
@@ -0,0 +1,569 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { formatDistanceToNow, format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { supabase } from '@/lib/supabase/client';
import { useNotificationStore } from '@/stores/notificationStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { useTenantStore } from '@/stores/tenantStore';
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const notifStore = useNotificationStore();
const conversationDrawer = useConversationDrawerStore();
const tenantStore = useTenantStore();
// ─── State ───────────────────────────────────────────
const ownerId = ref(null);
const items = ref([]);
const loading = ref(true);
const search = ref('');
const filter = ref('all'); // all | unread | read | archived
const typeFilter = ref(null);
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _headerObserver = null;
// ─── Type map ────────────────────────────────────────
const typeMap = {
new_scheduling: { icon: 'pi-inbox', color: 'text-red-500', border: 'border-red-500', label: 'Agendamento' },
new_patient: { icon: 'pi-user-plus', color: 'text-sky-500', border: 'border-sky-500', label: 'Novo paciente' },
recurrence_alert: { icon: 'pi-refresh', color: 'text-amber-500', border: 'border-amber-500', label: 'Recorrência' },
session_status: { icon: 'pi-calendar-times', color: 'text-orange-500', border: 'border-orange-500', label: 'Sessão' },
inbound_message: { icon: 'pi-whatsapp', color: 'text-emerald-500', border: 'border-emerald-500', label: 'Mensagem' }
};
function metaFor(item) {
return typeMap[item.type] || { icon: 'pi-bell', color: 'text-gray-400', border: 'border-gray-300', label: item.type };
}
// ─── Filters + search ────────────────────────────────
const quickStats = computed(() => {
const total = items.value.length;
const unread = items.value.filter((n) => !n.read_at && !n.archived).length;
const read = items.value.filter((n) => n.read_at && !n.archived).length;
const archived = items.value.filter((n) => n.archived).length;
return [
{ label: 'Total', value: total, key: 'all', cls: '' },
{ label: 'Não lidas', value: unread, key: 'unread', cls: unread > 0 ? 'qs-warn' : '' },
{ label: 'Lidas', value: read, key: 'read', cls: '' },
{ label: 'Arquivadas', value: archived, key: 'archived', cls: '' }
];
});
const filteredItems = computed(() => {
const q = search.value.trim().toLowerCase();
return items.value.filter((n) => {
if (filter.value === 'unread' && (n.read_at || n.archived)) return false;
if (filter.value === 'read' && (!n.read_at || n.archived)) return false;
if (filter.value === 'archived' && !n.archived) return false;
if (filter.value === 'all' && n.archived) return false;
if (typeFilter.value && n.type !== typeFilter.value) return false;
if (q) {
const title = (n.payload?.title || '').toLowerCase();
const detail = (n.payload?.detail || '').toLowerCase();
if (!title.includes(q) && !detail.includes(q)) return false;
}
return true;
});
});
const hasActiveFilters = computed(() => filter.value !== 'all' || !!typeFilter.value || !!search.value.trim());
function clearFilters() {
filter.value = 'all';
typeFilter.value = null;
search.value = '';
}
// ─── Data loading ────────────────────────────────────
async function load() {
loading.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
ownerId.value = authData?.user?.id || null;
if (!ownerId.value) {
loading.value = false;
return;
}
const { data, error } = await supabase.from('notifications').select('*').eq('owner_id', ownerId.value).order('created_at', { ascending: false }).limit(500);
if (error) throw error;
items.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar notificações.', life: 4500 });
} finally {
loading.value = false;
}
}
// ─── Actions ─────────────────────────────────────────
async function markRead(id) {
const now = new Date().toISOString();
const { error } = await supabase.from('notifications').update({ read_at: now }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
const item = items.value.find((n) => n.id === id);
if (item) item.read_at = now;
// Sync no store (drawer)
const storeItem = notifStore.items.find((n) => n.id === id);
if (storeItem) storeItem.read_at = now;
}
async function markUnread(id) {
const { error } = await supabase.from('notifications').update({ read_at: null }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
const item = items.value.find((n) => n.id === id);
if (item) item.read_at = null;
const storeItem = notifStore.items.find((n) => n.id === id);
if (storeItem) storeItem.read_at = null;
}
async function archive(id) {
const { error } = await supabase.from('notifications').update({ archived: true }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
const item = items.value.find((n) => n.id === id);
if (item) item.archived = true;
// Remove do store do drawer (que só lista não-arquivadas)
notifStore.items = notifStore.items.filter((n) => n.id !== id);
}
async function unarchive(id) {
const { error } = await supabase.from('notifications').update({ archived: false }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
const item = items.value.find((n) => n.id === id);
if (item) item.archived = false;
// Readiciona no store do drawer
if (item && !notifStore.items.find((n) => n.id === id)) {
notifStore.items.unshift({ ...item });
}
}
function confirmRemove(id) {
confirm.require({
message: 'Remover esta notificação permanentemente? Essa ação não pode ser desfeita.',
header: 'Remover notificação',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: () => remove(id)
});
}
async function remove(id) {
const { error } = await supabase.from('notifications').delete().eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
items.value = items.value.filter((n) => n.id !== id);
notifStore.items = notifStore.items.filter((n) => n.id !== id);
toast.add({ severity: 'success', summary: 'Removida', life: 2500 });
}
async function markAllRead() {
const unreadIds = items.value.filter((n) => !n.read_at && !n.archived).map((n) => n.id);
if (!unreadIds.length) return;
const now = new Date().toISOString();
const { error } = await supabase.from('notifications').update({ read_at: now }).in('id', unreadIds);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
items.value.forEach((n) => {
if (unreadIds.includes(n.id)) n.read_at = now;
});
notifStore.items.forEach((n) => {
if (unreadIds.includes(n.id)) n.read_at = now;
});
toast.add({ severity: 'success', summary: `${unreadIds.length} marcada${unreadIds.length > 1 ? 's' : ''} como lida${unreadIds.length > 1 ? 's' : ''}`, life: 2500 });
}
// ─── Row actions ─────────────────────────────────────
function handleRowClick(n) {
if (n.type === 'inbound_message') {
const payload = n.payload || {};
if (payload.patient_id) {
conversationDrawer.openForPatient(payload.patient_id);
} else if (payload.from_number) {
conversationDrawer.openForThread({
thread_key: `anon:${payload.from_number}`,
tenant_id: tenantStore.activeTenantId,
patient_id: null,
patient_name: null,
contact_number: payload.from_number,
channel: payload.channel || 'whatsapp',
message_count: 1,
unread_count: 1,
kanban_status: 'awaiting_us',
last_message_at: new Date().toISOString()
});
}
if (!n.read_at) markRead(n.id);
return;
}
const deeplink = n.payload?.deeplink;
if (deeplink) {
if (!n.read_at) markRead(n.id);
router.push(deeplink);
} else if (!n.read_at) {
markRead(n.id);
}
}
// ─── Helpers ─────────────────────────────────────────
function timeAgo(iso) {
try {
return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ptBR });
} catch {
return '—';
}
}
function fullDate(iso) {
try {
return format(new Date(iso), "dd 'de' MMMM 'às' HH:mm", { locale: ptBR });
} catch {
return '';
}
}
function initials(item) {
return item.payload?.avatar_initials || '?';
}
// ─── Lifecycle ───────────────────────────────────────
onMounted(() => {
load();
if (headerSentinelRef.value) {
_headerObserver = new IntersectionObserver(
([entry]) => {
headerStuck.value = !entry.isIntersecting;
},
{ threshold: 0 }
);
_headerObserver.observe(headerSentinelRef.value);
}
});
onBeforeUnmount(() => {
_headerObserver?.disconnect();
});
</script>
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<section
ref="headerEl"
class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- 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-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.07]" />
</div>
<div class="relative z-1 flex items-center gap-3">
<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/10 text-indigo-500">
<i class="pi pi-bell text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Notificações</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Histórico completo leia, arquive ou remova</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0 ml-auto">
<Button
v-if="quickStats[1].value > 0"
label="Marcar todas lidas"
icon="pi pi-check-circle"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="loading"
@click="markAllRead"
/>
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
</div>
</div>
</section>
<!-- Quick-stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 px-3 md:px-4 mb-3">
<template v-if="loading">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="rounded-md" />
</template>
<template v-else>
<div
v-for="s in quickStats"
:key="s.key"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="[
filter === s.key
? 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)] bg-[var(--surface-card,#fff)]'
: s.cls === 'qs-warn'
? 'border-amber-500/25 bg-amber-500/5 hover:border-amber-500/40'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] hover:border-indigo-300/50'
]"
@click="filter = s.key"
>
<div class="text-[1.35rem] font-bold leading-none" :class="s.cls === 'qs-warn' ? 'text-amber-500' : 'text-[var(--text-color)]'">{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
</div>
</template>
</div>
<!-- Toolbar: busca + type filter + clear -->
<div class="px-3 md:px-4 mb-3 flex flex-col md:flex-row items-stretch md:items-center gap-2">
<IconField class="flex-1">
<InputIcon class="pi pi-search" />
<InputText v-model="search" placeholder="Buscar por título ou descrição…" class="w-full" />
</IconField>
<Select
v-model="typeFilter"
:options="[
{ label: 'Todos os tipos', value: null },
{ label: 'Agendamento', value: 'new_scheduling' },
{ label: 'Novo paciente', value: 'new_patient' },
{ label: 'Recorrência', value: 'recurrence_alert' },
{ label: 'Sessão', value: 'session_status' },
{ label: 'Mensagem', value: 'inbound_message' }
]"
optionLabel="label"
optionValue="value"
placeholder="Tipo"
class="md:w-[200px]"
/>
<Button v-if="hasActiveFilters" label="Limpar filtros" icon="pi pi-filter-slash" severity="secondary" outlined size="small" class="rounded-full" @click="clearFilters" />
</div>
<!-- Lista -->
<div class="px-3 md:px-4 pb-5">
<!-- Skeleton list -->
<div v-if="loading" class="flex flex-col gap-2">
<Skeleton v-for="n in 8" :key="n" height="5rem" class="rounded-md" />
</div>
<!-- Empty -->
<div v-else-if="!filteredItems.length" class="flex flex-col items-center justify-center gap-2 py-12 text-center">
<i class="pi pi-bell-slash text-4xl text-[var(--text-color-secondary)] opacity-40" />
<div class="text-[1rem] font-semibold text-[var(--text-color)]">{{ hasActiveFilters ? 'Nada encontrado' : 'Nenhuma notificação ainda' }}</div>
<div class="text-sm text-[var(--text-color-secondary)]">{{ hasActiveFilters ? 'Ajuste os filtros ou limpe-os para ver tudo.' : 'Quando algo acontecer, você será avisado aqui.' }}</div>
</div>
<!-- Items -->
<div v-else class="flex flex-col gap-2">
<div
v-for="n in filteredItems"
:key="n.id"
class="notif-row"
:class="[metaFor(n).border, !n.read_at && !n.archived ? 'notif-row--unread' : '', n.archived ? 'notif-row--archived' : '']"
role="button"
tabindex="0"
@click="handleRowClick(n)"
@keydown.enter="handleRowClick(n)"
>
<div class="notif-row__icon" :class="metaFor(n).color">
<i :class="['pi', metaFor(n).icon]" />
</div>
<div class="notif-row__avatar">{{ initials(n) }}</div>
<div class="notif-row__body">
<div class="flex items-center gap-2 flex-wrap">
<span class="notif-row__title">{{ n.payload?.title || '(sem título)' }}</span>
<span class="notif-row__type-pill">{{ metaFor(n).label }}</span>
<span v-if="n.archived" class="notif-row__type-pill notif-row__type-pill--muted">Arquivada</span>
</div>
<div class="notif-row__detail">{{ n.payload?.detail || '—' }}</div>
<div class="notif-row__time" :title="fullDate(n.created_at)">{{ timeAgo(n.created_at) }}</div>
</div>
<div class="notif-row__actions" @click.stop>
<button v-if="!n.read_at && !n.archived" class="notif-row__btn" v-tooltip.top="'Marcar como lida'" @click="markRead(n.id)">
<i class="pi pi-check" />
</button>
<button v-else-if="n.read_at && !n.archived" class="notif-row__btn" v-tooltip.top="'Marcar como não lida'" @click="markUnread(n.id)">
<i class="pi pi-envelope" />
</button>
<button v-if="!n.archived" class="notif-row__btn" v-tooltip.top="'Arquivar'" @click="archive(n.id)">
<i class="pi pi-inbox" />
</button>
<button v-else class="notif-row__btn" v-tooltip.top="'Desarquivar'" @click="unarchive(n.id)">
<i class="pi pi-undo" />
</button>
<button class="notif-row__btn notif-row__btn--danger" v-tooltip.top="'Remover'" @click="confirmRemove(n.id)">
<i class="pi pi-trash" />
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.notif-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-left-width: 3px;
border-left-style: solid;
border-top: 1px solid var(--surface-border);
border-right: 1px solid var(--surface-border);
border-bottom: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
cursor: pointer;
transition:
background 0.15s,
box-shadow 0.15s,
border-color 0.15s;
}
.notif-row:hover {
background: var(--surface-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.notif-row--unread {
background: color-mix(in srgb, var(--primary-color) 5%, var(--surface-card));
}
.notif-row--unread:hover {
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
}
.notif-row--archived {
opacity: 0.7;
}
.notif-row__icon {
flex-shrink: 0;
padding-top: 0.2rem;
font-size: 1rem;
}
.notif-row__avatar {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #38bdf8);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
display: flex;
align-items: center;
justify-content: center;
}
.notif-row__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.notif-row__title {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.notif-row__type-pill {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-color) 6%, transparent);
color: var(--text-color-secondary);
font-size: 0.65rem;
font-weight: 600;
white-space: nowrap;
}
.notif-row__type-pill--muted {
background: color-mix(in srgb, var(--text-color-secondary) 12%, transparent);
}
.notif-row__detail {
font-size: 0.82rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-width: 100%;
}
.notif-row__time {
font-size: 0.72rem;
color: var(--text-color-secondary);
opacity: 0.7;
}
.notif-row__actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
}
.notif-row:hover .notif-row__actions,
.notif-row:focus-within .notif-row__actions {
opacity: 1;
}
.notif-row__btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
font-size: 0.82rem;
transition:
background 0.15s,
color 0.15s;
}
.notif-row__btn:hover {
background: var(--surface-border);
color: var(--text-color);
}
.notif-row__btn--danger:hover {
background: color-mix(in srgb, #ef4444 12%, transparent);
color: #ef4444;
}
</style>
+3 -3
View File
@@ -244,7 +244,7 @@ onMounted(loadSessions);
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
@@ -313,7 +313,7 @@ onMounted(loadSessions);
<template v-else>
<!-- QUICK-STATS clicáveis -->
<div class="flex flex-wrap gap-2">
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2">
<div
v-for="s in quickStats"
:key="s.label"
@@ -335,7 +335,7 @@ onMounted(loadSessions);
@click="s.filter !== null ? toggleFiltroTabela(s.filter) : null"
>
<div class="text-[1.35rem] font-bold leading-none" :class="s.valCls">{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
</div>
</div>
+65 -139
View File
@@ -790,45 +790,40 @@ onMounted(async () => {
</div>
<!-- Skeleton -->
<template v-if="loading">
<div class="grid grid-cols-7 mb-0.5">
<Skeleton v-for="n in 7" :key="n" height="14px" class="mx-0.5" />
</div>
<div class="grid grid-cols-7 gap-px mt-1">
<Skeleton v-for="n in 35" :key="n" height="22px" class="rounded" />
</div>
</template>
<Skeleton v-if="loading" width="100%" height="180px" border-radius="6px" />
<template v-else>
<div class="grid grid-cols-7 mb-0.5">
<span v-for="d in diasSemana" :key="d" class="text-center text-xs font-bold text-[var(--text-color-secondary,#94a3b8)] py-0.5">{{ d }}</span>
</div>
<div class="grid grid-cols-7 gap-px">
<button
v-for="cell in calCells"
:key="cell.key"
class="relative aspect-square flex items-center justify-center text-xs font-medium rounded border-none cursor-pointer transition-colors duration-100"
:class="{
'bg-[var(--primary-color,#6366f1)] text-white font-bold rounded-md': cell.isToday,
'text-[var(--text-color-secondary,#cbd5e1)] cursor-default': cell.isOther,
'bg-indigo-500/10 text-[var(--primary-color,#6366f1)] font-bold': cell.day === selectedDay && !cell.isToday,
'text-[var(--text-color,#1e293b)] hover:bg-[var(--surface-hover,#f1f5f9)]': !cell.isToday && !cell.isOther
}"
@click="cell.day && onCalDayClick($event, cell.day)"
>
<span>{{ cell.day }}</span>
<span
v-if="cell.count"
class="absolute bottom-px right-0.5 w-1 h-1 rounded-full"
<Transition name="fade-up" appear>
<div v-if="!loading">
<div class="grid grid-cols-7 mb-0.5">
<span v-for="d in diasSemana" :key="d" class="text-center text-xs font-bold text-[var(--text-color-secondary,#94a3b8)] py-0.5">{{ d }}</span>
</div>
<div class="grid grid-cols-7 gap-px">
<button
v-for="cell in calCells"
:key="cell.key"
class="relative aspect-square flex items-center justify-center text-xs font-medium rounded border-none cursor-pointer transition-colors duration-100"
:class="{
'bg-red-500': cell.urgency === 'urg-alta',
'bg-amber-500': cell.urgency === 'urg-media',
'bg-green-500': cell.urgency === 'urg-baixa'
'bg-[var(--primary-color,#6366f1)] text-white font-bold rounded-md': cell.isToday,
'text-[var(--text-color-secondary,#cbd5e1)] cursor-default': cell.isOther,
'bg-indigo-500/10 text-[var(--primary-color,#6366f1)] font-bold': cell.day === selectedDay && !cell.isToday,
'text-[var(--text-color,#1e293b)] hover:bg-[var(--surface-hover,#f1f5f9)]': !cell.isToday && !cell.isOther
}"
/>
</button>
@click="cell.day && onCalDayClick($event, cell.day)"
>
<span>{{ cell.day }}</span>
<span
v-if="cell.count"
class="absolute bottom-px right-0.5 w-1 h-1 rounded-full"
:class="{
'bg-red-500': cell.urgency === 'urg-alta',
'bg-amber-500': cell.urgency === 'urg-media',
'bg-green-500': cell.urgency === 'urg-baixa'
}"
/>
</button>
</div>
</div>
</template>
</Transition>
</div>
<!-- Eventos do dia -->
@@ -838,23 +833,10 @@ onMounted(async () => {
</div>
<!-- Skeleton -->
<template v-if="loading">
<div class="flex flex-col gap-2">
<div v-for="n in 3" :key="n" class="aside-ev aside-ev--skeleton">
<div class="flex flex-col gap-1 min-w-[36px] items-end">
<Skeleton width="32px" height="10px" />
<Skeleton width="24px" height="8px" />
</div>
<Skeleton shape="square" size="28px" border-radius="4px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton :width="n === 1 ? '75%' : n === 2 ? '60%' : '70%'" height="10px" />
<Skeleton width="40%" height="8px" />
</div>
</div>
</div>
</template>
<Skeleton v-if="loading" width="100%" height="160px" border-radius="6px" />
<div v-else class="flex flex-col gap-2 max-h-[260px] overflow-y-auto pr-0.5">
<Transition name="fade-up" appear>
<div v-if="!loading" class="flex flex-col gap-2 max-h-[260px] overflow-y-auto pr-0.5">
<div
v-for="ev in eventosDoDia"
:key="ev.id"
@@ -883,6 +865,7 @@ onMounted(async () => {
</div>
<div v-if="!eventosDoDia.length" class="flex items-center gap-2 py-3 text-[var(--text-color-secondary)] text-sm"><i class="pi pi-sun" /><span>Sem compromissos</span></div>
</div>
</Transition>
</div>
<!-- Recorrências ativas -->
@@ -893,20 +876,10 @@ onMounted(async () => {
</div>
<!-- Skeleton -->
<template v-if="loading">
<div class="flex flex-col gap-2">
<div v-for="n in 4" :key="n" class="aside-rec aside-rec--skeleton">
<Skeleton shape="square" size="30px" border-radius="4px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton :width="n % 2 === 0 ? '65%' : '50%'" height="10px" />
<Skeleton width="75%" height="8px" />
</div>
<Skeleton width="28px" height="10px" />
</div>
</div>
</template>
<Skeleton v-if="loading" width="100%" height="180px" border-radius="6px" />
<div v-else class="flex flex-col gap-1.5 max-h-[210px] overflow-y-auto pr-0.5">
<Transition name="fade-up" appear>
<div v-if="!loading" class="flex flex-col gap-1.5 max-h-[210px] overflow-y-auto pr-0.5">
<div v-for="r in recorrencias" :key="r.id" class="aside-rec" @click="openRecMenu($event, r)">
<Avatar :label="r.initials" shape="square" size="normal" class="shrink-0" />
<div class="flex-1 min-w-0">
@@ -917,6 +890,7 @@ onMounted(async () => {
</div>
<div v-if="!recorrencias.length" class="flex items-center gap-2 py-3 text-[var(--text-color-secondary)] text-sm"><i class="pi pi-info-circle" /><span>Nenhuma recorrência ativa</span></div>
</div>
</Transition>
</div>
</aside>
@@ -925,27 +899,12 @@ onMounted(async () => {
-->
<div ref="dashHeroSentinelRef" class="h-px" />
<main class="flex-1 min-w-0 p-4 xl:p-[1.125rem_1.375rem] flex flex-col gap-4 overflow-y-auto" :style="{ marginLeft: isMobileLayout ? undefined : '272px' }">
<main class="flex-1 min-w-0 py-4 xl:py-[1.125rem] px-3 md:px-4 flex flex-col gap-4 overflow-y-auto" :style="{ marginLeft: isMobileLayout ? undefined : '272px' }">
<!-- Hero Header -->
<!-- Skeleton hero -->
<section v-if="loading" class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2.5">
<div class="flex items-center gap-3 mb-3">
<Skeleton width="40px" height="40px" border-radius="8px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton width="12rem" height="14px" />
<Skeleton width="18rem" height="11px" />
</div>
<Skeleton width="36px" height="36px" border-radius="999px" />
<Skeleton width="36px" height="36px" border-radius="999px" />
</div>
<div class="flex flex-wrap gap-2.5">
<div v-for="n in 4" :key="n" class="flex flex-col gap-1.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] flex-1 min-w-[90px]">
<Skeleton width="2rem" height="20px" />
<Skeleton width="4rem" height="10px" />
</div>
</div>
</section>
<Skeleton v-if="loading" width="100%" height="140px" border-radius="6px" />
<Transition name="fade-up" appear>
<section v-if="!loading" class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2.5" :class="{ 'rounded-tl-none rounded-tr-none': heroStuck }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-72 h-72 -top-16 -right-12 rounded-full blur-[70px] bg-indigo-500/10" />
@@ -974,7 +933,7 @@ onMounted(async () => {
<!-- Quick stats -->
<div class="relative z-1 mt-2">
<div class="flex flex-wrap gap-2.5">
<div class="grid grid-cols-2 md:grid-cols-4 gap-2.5">
<div
v-for="s in quickStats"
:key="s.label"
@@ -995,11 +954,12 @@ onMounted(async () => {
>
{{ s.value }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75">{{ s.label }}</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
</div>
</div>
</div>
</section>
</Transition>
<!-- Toggle aside mobile -->
<button
@@ -1015,14 +975,9 @@ onMounted(async () => {
</button>
<!-- Linha do tempo -->
<section v-if="loading" class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] p-2.5">
<div class="flex items-center justify-between mb-2.5">
<Skeleton width="12rem" height="12px" />
<Skeleton width="6rem" height="12px" />
</div>
<Skeleton width="100%" height="40px" border-radius="6px" class="mt-2.5" />
</section>
<Skeleton v-if="loading" width="100%" height="110px" border-radius="6px" />
<Transition name="fade-up" appear>
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5 shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
<div class="flex items-center justify-between mb-2.5">
<div class="flex items-center gap-2.5">
@@ -1073,43 +1028,25 @@ onMounted(async () => {
</div>
</div>
</section>
</Transition>
<!-- Cards de notificação -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-3.5">
<!-- SKELETON dos cards -->
<template v-if="loading">
<div v-for="n in 4" :key="n" class="flex flex-col bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)]">
<!-- header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)] bg-[var(--surface-ground,#f8fafc)]">
<Skeleton width="32px" height="32px" border-radius="6px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton :width="n % 2 === 0 ? '9rem' : '7rem'" height="12px" />
<Skeleton :width="n % 3 === 0 ? '13rem' : '10rem'" height="10px" />
</div>
</div>
<!-- body -->
<div class="flex-1 flex flex-col gap-2 px-3.5 py-3 min-h-[72px]">
<div v-for="i in 2" :key="i" class="flex items-center gap-2">
<Skeleton shape="circle" size="26px" />
<div class="flex flex-col gap-1 flex-1">
<Skeleton :width="i === 1 ? '65%' : '50%'" height="10px" />
<Skeleton width="75%" height="9px" />
</div>
</div>
</div>
<!-- footer -->
<div class="px-3.5 py-2 border-t border-[var(--surface-border,#f1f5f9)]">
<Skeleton width="5rem" height="10px" />
</div>
</div>
<Skeleton v-for="n in 4" :key="n" width="100%" height="180px" border-radius="6px" />
<!-- frase durante carregamento -->
<div class="lg:col-span-2">
<AppLoadingPhrases action="Carregando seu dashboard..." containerClass="py-8" />
</div>
</template>
<!-- Wrapper único para stagger via anim-child (duration cobre o delay do último card) -->
<Transition name="fade-up" appear :duration="700">
<div v-if="!loading" class="contents">
<!-- Agendador Online -->
<div v-if="!loading" id="card-agendador" class="dash-card rounded-md" :class="{ '': solicitacoesPendentes > 0 }">
<div id="card-agendador" class="anim-child [--delay:0ms] dash-card rounded-md" :class="{ '': solicitacoesPendentes > 0 }">
<div class="dash-card__head gap-2.5 p-2.5">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-inbox text-lg" />
@@ -1146,7 +1083,7 @@ onMounted(async () => {
</div>
<!-- Cadastros externos -->
<div v-if="!loading" id="card-cadastros" class="dash-card">
<div id="card-cadastros" class="anim-child [--delay:80ms] dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9">
<i class="pi pi-user-plus text-lg" />
@@ -1181,7 +1118,7 @@ onMounted(async () => {
</div>
<!-- Recorrências com alerta -->
<div v-if="!loading" id="card-recorrencias" class="dash-card">
<div id="card-recorrencias" class="anim-child [--delay:160ms] dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #f59e0b 15%, transparent); color: #f59e0b">
<i class="pi pi-refresh text-lg" />
@@ -1217,7 +1154,7 @@ onMounted(async () => {
</div>
<!-- Radar da semana -->
<div v-if="!loading" id="card-radar" class="dash-card">
<div id="card-radar" class="anim-child [--delay:240ms] dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1">
<i class="pi pi-chart-pie text-lg" />
@@ -1255,29 +1192,15 @@ onMounted(async () => {
</div>
</div>
</div>
</div><!-- /contents wrapper -->
</Transition>
</section>
<!-- Compromissos especiais -->
<section v-if="loading" class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-[1.125rem] py-3.5">
<div class="flex items-center justify-between mb-2.5">
<Skeleton width="10rem" height="12px" />
<Skeleton width="5rem" height="12px" />
</div>
<div class="flex flex-col gap-1.5">
<div v-for="n in 3" :key="n" class="flex items-center gap-2.5 px-2.5 py-2 rounded-md bg-[var(--surface-ground)]">
<Skeleton width="3px" height="28px" border-radius="4px" />
<div class="flex flex-col gap-1 flex-1">
<Skeleton :width="n === 1 ? '12rem' : n === 2 ? '9rem' : '11rem'" height="11px" />
<Skeleton width="6rem" height="10px" />
</div>
<div class="flex flex-col items-end gap-1">
<Skeleton width="4rem" height="11px" />
<Skeleton width="5rem" height="10px" />
</div>
</div>
</div>
</section>
<Skeleton v-if="loading" width="100%" height="180px" border-radius="6px" />
<Transition name="fade-up" appear>
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)]"><i class="pi pi-briefcase" /> Compromissos especiais (Em breve)</div>
@@ -1322,8 +1245,11 @@ onMounted(async () => {
</div>
</div>
</section>
</Transition>
<LoadedPhraseBlock v-if="!loading" />
<Transition name="fade-up" appear>
<LoadedPhraseBlock v-if="!loading" />
</Transition>
</main>
<!-- Menus de contexto (fora do aside para evitar visibility:hidden) -->