Sistema de Suporte , Documentação

This commit is contained in:
Leonardo
2026-03-16 09:41:18 -03:00
parent f66f6f3fde
commit 84d65e49c0
5 changed files with 1615 additions and 355 deletions

View File

@@ -0,0 +1,801 @@
<!-- src/features/agenda/components/dev/AgendaDevDocs.vue
Documentação técnica da Agenda exibida apenas em modo suporte/dev.
Acessível via SupportDebugBanner botão "Docs". -->
<script setup>
import { ref } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible'])
function close () { emit('update:visible', false) }
const activeTab = ref(0)
</script>
<template>
<Dialog
:visible="visible"
@update:visible="emit('update:visible', $event)"
modal
header="📘 Documentação Técnica — Agenda"
:style="{ width: '860px', maxWidth: '96vw' }"
:pt="{ content: { style: 'padding: 0' } }"
>
<TabView v-model:activeIndex="activeTab" class="dev-docs">
<!-- Tab 0: Visão Geral -->
<TabPanel header="Visão Geral">
<div class="dd-section">
<div class="dd-alert">
Este painel é visível <strong>apenas em modo suporte SaaS</strong> ativo.
Nunca é exibido em produção sem um token válido.
</div>
<h3 class="dd-h3">Propósito</h3>
<p class="dd-p">
A <strong>Agenda</strong> é o núcleo do sistema. Permite ao terapeuta gerenciar
sessões, bloqueios, recorrências e compromissos determinísticos tudo integrado
ao sistema de precificação, convênios e agendamento online.
</p>
<h3 class="dd-h3">Stack técnica</h3>
<div class="dd-grid-2">
<div class="dd-card">
<div class="dd-card-title">Frontend</div>
<ul class="dd-list">
<li><span class="dd-tag blue">Vue 3</span> Composition API + <code>&lt;script setup&gt;</code></li>
<li><span class="dd-tag purple">FullCalendar</span> timeGrid / dayGrid + interaction plugin</li>
<li><span class="dd-tag green">PrimeVue</span> Dialog, DataTable, Select, Toast</li>
<li><span class="dd-tag orange">Pinia</span> tenantStore, entitlementsStore, menuStore</li>
</ul>
</div>
<div class="dd-card">
<div class="dd-card-title">Backend</div>
<ul class="dd-list">
<li><span class="dd-tag teal">Supabase</span> PostgreSQL + Row Level Security</li>
<li><span class="dd-tag red">RPCs</span> Funções PL/pgSQL para operações críticas</li>
<li><span class="dd-tag yellow">Realtime</span> Não utilizado na agenda (pull-based)</li>
</ul>
</div>
</div>
<h3 class="dd-h3">Arquitetura em camadas</h3>
<div class="dd-layers">
<div class="dd-layer dd-layer--page">
<strong>Pages</strong>
<span>AgendaTerapeutaPage · AgendaClinicaPage · CompromissosDeterminados</span>
</div>
<div class="dd-layer-arrow"></div>
<div class="dd-layer dd-layer--composable">
<strong>Composables</strong>
<span>useAgendaSettings · useAgendaEvents · useRecurrence · useCommitmentServices · useDeterminedCommitments</span>
</div>
<div class="dd-layer-arrow"></div>
<div class="dd-layer dd-layer--service">
<strong>Services / Repositories</strong>
<span>agendaRepository · agendaClinicRepository · agendaMappers</span>
</div>
<div class="dd-layer-arrow"></div>
<div class="dd-layer dd-layer--db">
<strong>Supabase</strong>
<span>Tables + RPCs + RLS Policies</span>
</div>
</div>
<h3 class="dd-h3">Rotas registradas</h3>
<table class="dd-table">
<thead><tr><th>Path</th><th>Name</th><th>Área</th></tr></thead>
<tbody>
<tr><td><code>/therapist/agenda</code></td><td>therapist-agenda</td><td>Terapeuta</td></tr>
<tr><td><code>/therapist/agenda/recorrencias</code></td><td>therapist-agenda-recorrencias</td><td>Terapeuta</td></tr>
<tr><td><code>/therapist/agenda/compromissos</code></td><td>therapist-agenda-compromissos</td><td>Terapeuta</td></tr>
<tr><td><code>/therapist/agendamentos-recebidos</code></td><td>therapist-agendamentos-recebidos</td><td>Terapeuta</td></tr>
<tr><td><code>/admin/agenda/clinica</code></td><td>admin-agenda-clinica</td><td>Clínica</td></tr>
<tr><td><code>/admin/agenda/compromissos</code></td><td>admin-agenda-compromissos</td><td>Clínica</td></tr>
<tr><td><code>/agendar/:slug</code></td><td>agendador.publico</td><td>Público</td></tr>
</tbody>
</table>
</div>
</TabPanel>
<!-- Tab 1: Tabelas -->
<TabPanel header="Tabelas">
<div class="dd-section">
<p class="dd-p">Todas as tabelas usam <strong>Row Level Security (RLS)</strong> habilitada.</p>
<h3 class="dd-h3">Core</h3>
<table class="dd-table">
<thead><tr><th>Tabela</th><th>Propósito</th><th>Colunas-chave</th></tr></thead>
<tbody>
<tr>
<td><code>agenda_configuracoes</code></td>
<td>Configurações da agenda por owner (terapeuta ou clínica)</td>
<td>owner_id, tenant_id, slot_duration_minutes, start_time, end_time, days_of_week</td>
</tr>
<tr>
<td><code>agenda_eventos</code></td>
<td>Eventos individuais (sessões, bloqueios avulsos)</td>
<td>id, owner_id, tenant_id, patient_id, starts_at, ends_at, status, recurrence_rule_id, tipo</td>
</tr>
<tr>
<td><code>agenda_bloqueios</code></td>
<td>Períodos bloqueados na agenda</td>
<td>owner_id, starts_at, ends_at, motivo, recorrente</td>
</tr>
<tr>
<td><code>agenda_regras_semanais</code></td>
<td>Horários de trabalho semanais por dia</td>
<td>owner_id, day_of_week (06), start_time, end_time</td>
</tr>
</tbody>
</table>
<h3 class="dd-h3">Recorrência</h3>
<table class="dd-table">
<thead><tr><th>Tabela</th><th>Propósito</th><th>Colunas-chave</th></tr></thead>
<tbody>
<tr>
<td><code>recurrence_rules</code></td>
<td>Regra mãe de uma série recorrente</td>
<td>id, owner_id, patient_id, frequency, interval, start_date, end_date, days_of_week, status</td>
</tr>
<tr>
<td><code>recurrence_exceptions</code></td>
<td>Exceções a uma regra (cancelamentos, alterações pontuais)</td>
<td>rule_id, original_date, action (skip | reschedule), new_starts_at</td>
</tr>
<tr>
<td><code>recurrence_rule_services</code></td>
<td>Serviços-template associados a uma regra recorrente</td>
<td>rule_id, service_id, quantity, unit_price</td>
</tr>
</tbody>
</table>
<h3 class="dd-h3">Compromissos e Serviços</h3>
<table class="dd-table">
<thead><tr><th>Tabela</th><th>Propósito</th><th>Colunas-chave</th></tr></thead>
<tbody>
<tr>
<td><code>determined_commitments</code></td>
<td>Tipos de compromisso determinístico (ex: Avaliação, Supervisão)</td>
<td>id, owner_id, tenant_id, name, color, duration_minutes</td>
</tr>
<tr>
<td><code>determined_commitment_fields</code></td>
<td>Campos customizados por tipo de compromisso</td>
<td>commitment_id, field_key, label, type, required</td>
</tr>
<tr>
<td><code>commitment_services</code></td>
<td>Serviços vinculados a um evento específico</td>
<td>evento_id, service_id, quantity, unit_price, insurance_plan_id</td>
</tr>
<tr>
<td><code>services</code></td>
<td>Catálogo de serviços do terapeuta/clínica</td>
<td>id, owner_id, tenant_id, name, default_price, active</td>
</tr>
<tr>
<td><code>professional_pricing</code></td>
<td>Precificação por tipo de compromisso e profissional</td>
<td>owner_id, commitment_type_id, price, insurance_plan_id</td>
</tr>
<tr>
<td><code>insurance_plans</code></td>
<td>Convênios cadastrados</td>
<td>id, owner_id, name, active, notes</td>
</tr>
<tr>
<td><code>insurance_plan_services</code></td>
<td>Serviços cobertos por um convênio</td>
<td>plan_id, service_id, covered_price</td>
</tr>
<tr>
<td><code>patient_discounts</code></td>
<td>Descontos individuais por paciente</td>
<td>owner_id, patient_id, discount_type (pct | fixed), value</td>
</tr>
<tr>
<td><code>financial_exceptions</code></td>
<td>Exceções financeiras globais ou por owner</td>
<td>id, owner_id, scope, rule_key, value</td>
</tr>
</tbody>
</table>
<h3 class="dd-h3">Suporte técnico</h3>
<table class="dd-table">
<thead><tr><th>Tabela</th><th>Propósito</th><th>Colunas-chave</th></tr></thead>
<tbody>
<tr>
<td><code>support_sessions</code></td>
<td>Tokens de acesso temporário gerados pelo SaaS admin</td>
<td>id, tenant_id, token, expires_at, created_at</td>
</tr>
</tbody>
</table>
</div>
</TabPanel>
<!-- Tab 2: Composables -->
<TabPanel header="Composables">
<div class="dd-section">
<p class="dd-p">
Todos os composables ficam em <code>src/features/agenda/composables/</code>.
Cada um é responsável por um domínio isolado.
</p>
<table class="dd-table">
<thead>
<tr><th>Composable</th><th>Responsabilidade</th><th>Retorna / Expõe</th></tr>
</thead>
<tbody>
<tr>
<td><code>useAgendaSettings</code></td>
<td>Carrega e persiste as configurações da agenda (horários, slots)</td>
<td>settings, saveSettings, loading</td>
</tr>
<tr>
<td><code>useAgendaEvents</code></td>
<td>CRUD de eventos em <code>agenda_eventos</code>; mapeia para FullCalendar</td>
<td>events, createEvent, updateEvent, deleteEvent, loadEvents</td>
</tr>
<tr>
<td><code>useRecurrence</code></td>
<td>Geração dinâmica de ocorrências a partir de <code>recurrence_rules</code>; split e cancel de série</td>
<td>rules, loadRules, createRule, splitAt, cancelFrom, exceptions</td>
</tr>
<tr>
<td><code>useCommitmentServices</code></td>
<td>CRUD dos serviços vinculados a um evento (<code>commitment_services</code>)</td>
<td>services, addService, removeService, updateService</td>
</tr>
<tr>
<td><code>useDeterminedCommitments</code></td>
<td>Carrega tipos de compromisso determinístico do tenant</td>
<td>commitments, loading, loadCommitments</td>
</tr>
<tr>
<td><code>useAgendaClinicStaff</code></td>
<td>Carrega membros do staff da clínica via <code>v_tenant_staff</code></td>
<td>staff, loadStaff, loading</td>
</tr>
<tr>
<td><code>useAgendaClinicEvents</code></td>
<td>CRUD de eventos da clínica (visão multi-profissional)</td>
<td>events, loadEvents, createEvent, updateEvent, deleteEvent</td>
</tr>
<tr>
<td><code>useServices</code></td>
<td>CRUD do catálogo de serviços do profissional</td>
<td>services, addService, editService, deleteService</td>
</tr>
<tr>
<td><code>useProfessionalPricing</code></td>
<td>Precificação por tipo de compromisso e convênio</td>
<td>pricing, loadPricing, savePricing</td>
</tr>
<tr>
<td><code>useInsurancePlans</code></td>
<td>CRUD de convênios e seus serviços cobertos</td>
<td>plans, addPlan, editPlan, deletePlan, planServices</td>
</tr>
<tr>
<td><code>usePatientDiscounts</code></td>
<td>Descontos individuais por paciente</td>
<td>discounts, addDiscount, removeDiscount</td>
</tr>
<tr>
<td><code>useFinancialExceptions</code></td>
<td>Exceções financeiras globais ou por owner</td>
<td>exceptions, saveException, deleteException</td>
</tr>
<tr>
<td><code>useFeriados</code></td>
<td>Carrega feriados nacionais/estaduais para bloquear slots</td>
<td>feriados, isHoliday, loadFeriados</td>
</tr>
</tbody>
</table>
<h3 class="dd-h3">Services / Repositories</h3>
<table class="dd-table">
<thead><tr><th>Arquivo</th><th>Propósito</th></tr></thead>
<tbody>
<tr>
<td><code>agendaRepository.js</code></td>
<td>Queries Supabase para terapeuta eventos, bloqueios, configurações</td>
</tr>
<tr>
<td><code>agendaClinicRepository.js</code></td>
<td>Queries Supabase para clínica eventos multi-profissional, staff</td>
</tr>
<tr>
<td><code>agendaMappers.js</code></td>
<td>
Converte objetos do banco para formato FullCalendar.<br>
<code>mapAgendaEventosToCalendarEvents()</code> · <code>buildWeeklyBreakBackgroundEvents()</code> · <code>minutesToDuration()</code>
</td>
</tr>
</tbody>
</table>
</div>
</TabPanel>
<!-- Tab 3: RPCs -->
<TabPanel header="RPCs">
<div class="dd-section">
<p class="dd-p">
Funções PL/pgSQL executadas via <code>supabase.rpc()</code>.
Todas validam permissões server-side sem bypass pelo frontend.
</p>
<div class="dd-rpc-card">
<div class="dd-rpc-name">validate_support_session</div>
<div class="dd-rpc-desc">Valida um token de suporte e retorna o tenant associado. Único ponto de ativação do modo debug.</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Parâmetros</span>
<code>p_token TEXT</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Retorna</span>
<code>&#123; valid: boolean, tenant_id: uuid &#125;</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Chamado em</span>
<code>supportDebugStore.validateAndActivate(token)</code>
</div>
</div>
<div class="dd-rpc-card">
<div class="dd-rpc-name">create_support_session</div>
<div class="dd-rpc-desc">Cria uma sessão de suporte com TTL. Requer role saas_admin. Valida e insere em <code>support_sessions</code>.</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Parâmetros</span>
<code>p_tenant_id UUID, p_ttl_minutes INT</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Retorna</span>
<code>&#123; token: text, expires_at: timestamptz, session_id: uuid &#125;</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Chamado em</span>
<code>supportSessionService.createSupportSession()</code>
</div>
</div>
<div class="dd-rpc-card">
<div class="dd-rpc-name">revoke_support_session</div>
<div class="dd-rpc-desc">Invalida imediatamente um token de suporte, definindo <code>expires_at = now()</code>.</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Parâmetros</span>
<code>p_token TEXT</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Retorna</span>
<code>boolean</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Chamado em</span>
<code>supportSessionService.revokeSupportSession()</code>
</div>
</div>
<div class="dd-rpc-card">
<div class="dd-rpc-name">split_recurrence_at</div>
<div class="dd-rpc-desc">
Divide uma série recorrente em dois: encerra a original na data informada e cria uma nova regra a partir daí.
Preserva exceções anteriores à divisão na regra original.
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Parâmetros</span>
<code>p_rule_id UUID, p_split_date DATE</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Retorna</span>
<code>&#123; old_rule_id: uuid, new_rule_id: uuid &#125;</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Chamado em</span>
<code>useRecurrence.splitAt(ruleId, date)</code>
</div>
</div>
<div class="dd-rpc-card">
<div class="dd-rpc-name">cancel_recurrence_from</div>
<div class="dd-rpc-desc">
Cancela todas as ocorrências de uma série a partir de uma data.
Insere exceções do tipo <code>skip</code> para cada ocorrência futura e marca a regra como <code>cancelled</code>.
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Parâmetros</span>
<code>p_rule_id UUID, p_from_date DATE</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Retorna</span>
<code>boolean</code>
</div>
<div class="dd-rpc-row">
<span class="dd-rpc-label">Chamado em</span>
<code>useRecurrence.cancelFrom(ruleId, date)</code>
</div>
</div>
</div>
</TabPanel>
<!-- Tab 4: Fluxos -->
<TabPanel header="Fluxos">
<div class="dd-section">
<h3 class="dd-h3">1 Criar evento simples</h3>
<ol class="dd-steps">
<li>Usuário clica num slot do FullCalendar evento <code>dateClick</code></li>
<li><code>AgendaTerapeutaPage</code> abre <code>AgendaEventDialog</code> com horário pré-preenchido</li>
<li>Usuário preenche paciente, tipo, serviços confirma</li>
<li><code>useAgendaEvents.createEvent()</code> INSERT em <code>agenda_eventos</code></li>
<li>Retorno <code>mapAgendaEventosToCalendarEvents()</code> evento adicionado ao FullCalendar</li>
</ol>
<h3 class="dd-h3">2 Criar recorrência</h3>
<ol class="dd-steps">
<li>No dialog do evento, usuário ativa "Repetir" e configura frequência/dias</li>
<li><code>useRecurrence.createRule(payload)</code> INSERT em <code>recurrence_rules</code></li>
<li>Opcionalmente, serviços-template são inseridos em <code>recurrence_rule_services</code></li>
<li>Frontend gera ocorrências client-side a partir da regra (sem tabela de ocorrências)</li>
<li>Ocorrências são renderizadas como eventos "virtuais" no FullCalendar</li>
</ol>
<h3 class="dd-h3">3 Editar ocorrência única de uma série</h3>
<ol class="dd-steps">
<li>Usuário clica em um evento recorrente dialog pergunta: "Esta ocorrência" ou "Todas a partir daqui"</li>
<li><strong>Esta ocorrência:</strong> INSERT em <code>recurrence_exceptions</code> com <code>action = reschedule</code></li>
<li><strong>A partir daqui:</strong> chama RPC <code>split_recurrence_at</code> cria nova regra para o trecho futuro</li>
<li>FullCalendar re-renderiza com as exceções aplicadas</li>
</ol>
<h3 class="dd-h3">4 Cancelar série recorrente</h3>
<ol class="dd-steps">
<li>Usuário abre evento recorrente "Cancelar a partir de..."</li>
<li>RPC <code>cancel_recurrence_from(ruleId, fromDate)</code> é chamado</li>
<li>Regra recebe <code>status = cancelled</code>; exceções <code>skip</code> são inseridas</li>
<li>Composable invalida cache local e recarrega regras</li>
</ol>
<h3 class="dd-h3">5 Ativar modo suporte técnico</h3>
<ol class="dd-steps">
<li>SaaS admin acessa <code>/saas/support</code> e gera um token com TTL</li>
<li>Admin envia URL: <code>/therapist/agenda?support=TOKEN</code> para o terapeuta</li>
<li>Terapeuta abre a URL; <code>AgendaTerapeutaPage._initSupportMode()</code> detecta query param</li>
<li>RPC <code>validate_support_session(token)</code> é chamado valida e retorna <code>tenant_id</code></li>
<li><code>supportDebugStore.isActive = true</code> <code>SupportDebugBanner</code> aparece via Teleport</li>
<li>Todos os <code>logEvent / logAPI / logRecurrence</code> passam a ser registrados no painel</li>
<li>Token expira automaticamente após o TTL sem ação do admin necessária</li>
</ol>
<h3 class="dd-h3">6 Precificação de uma sessão</h3>
<ol class="dd-steps">
<li>Ao abrir/criar um evento, <code>useCommitmentServices</code> carrega serviços do evento</li>
<li><code>useProfessionalPricing</code> cruza tipo de compromisso × profissional × convênio</li>
<li>Se paciente tem convênio ativo, <code>useInsurancePlans</code> verifica cobertura</li>
<li>Se paciente tem desconto, <code>usePatientDiscounts</code> aplica sobre o valor base</li>
<li>Valor final é salvo em <code>commitment_services</code> no evento</li>
</ol>
</div>
</TabPanel>
<!-- Tab 5: Suporte -->
<TabPanel header="Sistema de Suporte">
<div class="dd-section">
<h3 class="dd-h3">Níveis de log disponíveis</h3>
<table class="dd-table">
<thead><tr><th>Nível</th><th>Helper</th><th>Quando usar</th><th>Cor no painel</th></tr></thead>
<tbody>
<tr><td><code>event</code></td><td><code>logEvent(source, msg, data)</code></td><td>Lifecycle, mudanças de estado gerais</td><td>Cinza</td></tr>
<tr><td><code>api</code></td><td><code>logAPI(source, msg, data)</code></td><td>Queries Supabase (antes e depois)</td><td>Azul</td></tr>
<tr><td><code>agenda</code></td><td><code>logAgenda(source, msg, data)</code></td><td>Eventos, slots, calendar renders</td><td>Roxo claro</td></tr>
<tr><td><code>recurrence</code></td><td><code>logRecurrence(msg, data)</code></td><td>Geração e manipulação de regras</td><td>Roxo</td></tr>
<tr><td><code>tenant</code></td><td><code>logTenant(source, msg, data)</code></td><td>Carregamento de sessão, role, tenant ativo</td><td>Ciano</td></tr>
<tr><td><code>menu</code></td><td><code>logMenu(msg, data)</code></td><td>Build e reset do menuStore</td><td>Verde limão</td></tr>
<tr><td><code>profile</code></td><td><code>logProfile(msg, data)</code></td><td>Perfil: carga e save de settings</td><td>Rosa</td></tr>
<tr><td><code>auth</code></td><td><code>logAuth(msg, data)</code></td><td>Login, logout, refresh, MFA</td><td>Laranja</td></tr>
<tr><td><code>guard</code></td><td><code>logGuard(msg, data)</code></td><td>Navegação via router guards</td><td>Verde</td></tr>
<tr><td><code>perf</code></td><td><code>logPerf(source, label)</code></td><td>Medição de performance (retorna end())</td><td>Amarelo</td></tr>
<tr><td><code>error</code></td><td><code>logError(source, msg, err)</code></td><td>Erros capturados em catch</td><td>Vermelho</td></tr>
</tbody>
</table>
<h3 class="dd-h3">Como adicionar logs a um composable</h3>
<pre class="dd-code">import &#123; logAPI, logError, logPerf, logAgenda &#125; from '@/support/supportLogger'
async function loadEvents (ownerId, range) &#123;
const end = logPerf('useAgendaEvents', 'loadEvents')
logAPI('useAgendaEvents', 'loadEvents start', &#123; ownerId, range &#125;)
try &#123;
const &#123; data, error &#125; = await supabase
.from('agenda_eventos')
.select('*')
.eq('owner_id', ownerId)
if (error) throw error
end(&#123; count: data.length &#125;)
logAgenda('useAgendaEvents', 'eventos carregados', &#123; count: data.length &#125;)
return data
&#125; catch (e) &#123;
logError('useAgendaEvents', 'loadEvents ERRO', e)
throw e
&#125;
&#125;</pre>
<h3 class="dd-h3">Segurança do modo suporte</h3>
<ul class="dd-list dd-list--spaced">
<li>Token validado <strong>server-side</strong> via RPC sem bypass pelo frontend possível</li>
<li>Token tem TTL máximo de 2 horas; expira automaticamente no banco</li>
<li>Apenas usuários com role <code>saas_admin</code> podem criar tokens (validado no RPC)</li>
<li>Revogar um token invalida imediatamente (<code>expires_at = now()</code>)</li>
<li><code>SupportDebugBanner</code> é renderizado quando <code>supportStore.isActive === true</code></li>
<li>Em produção sem token válido, zero overhead todos os <code>log*()</code> retornam imediatamente</li>
</ul>
</div>
</TabPanel>
</TabView>
</Dialog>
</template>
<style scoped>
.dev-docs {
font-size: 0.85rem;
}
.dd-section {
padding: 1.25rem 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Alert */
.dd-alert {
background: color-mix(in srgb, var(--yellow-500) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--yellow-500) 35%, transparent);
border-left: 4px solid var(--yellow-500);
border-radius: 8px;
padding: 0.6rem 1rem;
font-size: 0.82rem;
color: var(--text-color);
}
/* Typography */
.dd-h3 {
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-color-secondary);
opacity: 0.6;
margin: 0.5rem 0 0;
}
.dd-p {
margin: 0;
color: var(--text-color-secondary);
line-height: 1.6;
}
/* Tags */
.dd-tag {
display: inline-block;
padding: 1px 7px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 700;
margin-right: 4px;
}
.dd-tag.blue { background: #dbeafe; color: #1d4ed8; }
.dd-tag.purple { background: #ede9fe; color: #6d28d9; }
.dd-tag.green { background: #dcfce7; color: #15803d; }
.dd-tag.orange { background: #ffedd5; color: #c2410c; }
.dd-tag.teal { background: #ccfbf1; color: #0f766e; }
.dd-tag.red { background: #fee2e2; color: #b91c1c; }
.dd-tag.yellow { background: #fef9c3; color: #a16207; }
/* Dark mode tags */
:global(.app-dark) .dd-tag.blue { background: #1e3a5f; color: #93c5fd; }
:global(.app-dark) .dd-tag.purple { background: #2e1065; color: #c4b5fd; }
:global(.app-dark) .dd-tag.green { background: #14532d; color: #86efac; }
:global(.app-dark) .dd-tag.orange { background: #431407; color: #fdba74; }
:global(.app-dark) .dd-tag.teal { background: #042f2e; color: #5eead4; }
:global(.app-dark) .dd-tag.red { background: #450a0a; color: #fca5a5; }
:global(.app-dark) .dd-tag.yellow { background: #422006; color: #fde68a; }
/* Grid 2 col */
.dd-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 600px) {
.dd-grid-2 { grid-template-columns: 1fr; }
}
/* Cards */
.dd-card {
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.dd-card-title {
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-color-secondary);
margin-bottom: 0.5rem;
}
/* Lists */
.dd-list {
margin: 0;
padding-left: 1.2rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
color: var(--text-color-secondary);
}
.dd-list--spaced { gap: 0.5rem; }
/* Table */
.dd-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.dd-table th {
text-align: left;
padding: 0.45rem 0.75rem;
background: var(--surface-ground);
border-bottom: 2px solid var(--surface-border);
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-color-secondary);
white-space: nowrap;
}
.dd-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--surface-border);
vertical-align: top;
color: var(--text-color);
line-height: 1.5;
}
.dd-table tr:hover td {
background: var(--surface-ground);
}
.dd-table code {
font-size: 0.78rem;
background: var(--surface-ground);
padding: 1px 5px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
}
/* Layers */
.dd-layers {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0;
}
.dd-layer {
display: flex;
align-items: baseline;
gap: 1rem;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.82rem;
}
.dd-layer strong {
flex-shrink: 0;
min-width: 120px;
font-weight: 700;
}
.dd-layer span {
color: var(--text-color-secondary);
font-size: 0.78rem;
}
.dd-layer--page { background: color-mix(in srgb, var(--primary-color) 10%, transparent); }
.dd-layer--composable { background: color-mix(in srgb, var(--purple-500, #8b5cf6) 10%, transparent); }
.dd-layer--service { background: color-mix(in srgb, var(--teal-500, #14b8a6) 10%, transparent); }
.dd-layer--db { background: color-mix(in srgb, var(--orange-500, #f97316) 10%, transparent); }
.dd-layer-arrow {
text-align: center;
color: var(--text-color-secondary);
opacity: 0.4;
line-height: 1.4;
font-size: 0.75rem;
}
/* RPC cards */
.dd-rpc-card {
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-left: 4px solid var(--primary-color);
border-radius: 8px;
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dd-rpc-name {
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
font-size: 0.88rem;
color: var(--primary-color);
}
.dd-rpc-desc {
color: var(--text-color-secondary);
font-size: 0.8rem;
line-height: 1.5;
}
.dd-rpc-row {
display: flex;
align-items: baseline;
gap: 0.75rem;
font-size: 0.78rem;
}
.dd-rpc-label {
font-weight: 700;
min-width: 90px;
color: var(--text-color-secondary);
flex-shrink: 0;
}
.dd-rpc-row code {
font-family: 'JetBrains Mono', monospace;
background: var(--surface-card);
padding: 1px 6px;
border-radius: 4px;
font-size: 0.77rem;
}
/* Steps */
.dd-steps {
margin: 0;
padding-left: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
color: var(--text-color-secondary);
line-height: 1.5;
}
.dd-steps code {
background: var(--surface-ground);
padding: 1px 5px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.77rem;
}
/* Code block */
.dd-code {
background: #0f172a;
color: #cbd5e1;
border-radius: 8px;
padding: 1rem 1.1rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.75rem;
overflow-x: auto;
white-space: pre;
line-height: 1.6;
margin: 0;
}
</style>

View File

@@ -1,35 +1,49 @@
<template>
<!-- Renderiza apenas se modo suporte estiver ativo -->
<Teleport to="body">
<Transition name="support-slide">
<div v-if="store.isActive" class="support-banner">
<!-- Barra superior fixa -->
<!-- Barra superior -->
<div class="support-banner__bar">
<div class="support-banner__bar-left">
<span class="support-banner__pulse" />
<strong>MODO SUPORTE ATIVO</strong>
<span class="support-banner__tenant">tenant: {{ store.tenantId }}</span>
<span class="support-banner__tenant" :title="store.tenantId">
tenant: {{ store.tenantId }}
</span>
<span class="support-banner__count">
{{ store.logs.length }} log{{ store.logs.length !== 1 ? 's' : '' }}
</span>
</div>
<div class="support-banner__bar-right">
<button class="support-banner__toggle" @click="panelOpen = !panelOpen">
<i :class="panelOpen ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" />
{{ panelOpen ? 'Ocultar Logs' : 'Ver Logs' }}
{{ panelOpen ? 'Ocultar' : 'Ver Logs' }}
<span v-if="store.errorLogs.length" class="support-banner__err-badge">
{{ store.errorLogs.length }} erro(s)
</span>
</button>
<button class="support-banner__clear" title="Limpar logs" @click="store.clearLogs()">
<button class="support-banner__icon-btn" title="Documentação técnica" @click="docsVisible = true">
<i class="pi pi-book" />
</button>
<button class="support-banner__icon-btn" title="Exportar logs (JSON)" @click="exportLogs">
<i class="pi pi-download" />
</button>
<button class="support-banner__icon-btn" title="Limpar logs" @click="store.clearLogs()">
<i class="pi pi-trash" />
</button>
<button class="support-banner__close" title="Desativar suporte" @click="store.deactivate()">
<button class="support-banner__icon-btn" title="Desativar suporte" @click="store.deactivate()">
<i class="pi pi-times" />
</button>
</div>
</div>
<!-- Painel expansível de logs -->
<!-- Painel de logs -->
<div v-if="panelOpen" class="support-banner__panel">
<!-- Filtros de nível -->
<!-- Toolbar: filtros + busca -->
<div class="support-banner__toolbar">
<div class="support-banner__filters">
<button
v-for="lvl in levels"
@@ -39,17 +53,36 @@
@click="activeLevel = activeLevel === lvl.value ? null : lvl.value"
>
{{ lvl.label }}
<span class="support-banner__filter-count">
{{ countByLevel(lvl.value) }}
</span>
<span class="support-banner__filter-count">{{ countByLevel(lvl.value) }}</span>
</button>
</div>
<!-- Lista de logs -->
<div class="support-banner__search-wrap">
<i class="pi pi-search support-banner__search-icon" />
<input
v-model="searchQuery"
type="text"
placeholder="Buscar nos logs…"
class="support-banner__search"
/>
<button
v-if="searchQuery"
class="support-banner__search-clear"
@click="searchQuery = ''"
title="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
</div>
<!-- Lista -->
<div ref="logListRef" class="support-banner__logs">
<div v-if="filteredLogs.length === 0" class="support-banner__empty">
Nenhum log capturado ainda. Os eventos da agenda aparecerão aqui.
<span v-if="searchQuery">Nenhum resultado para "{{ searchQuery }}"</span>
<span v-else>Nenhum log capturado ainda. Os eventos aparecerão aqui.</span>
</div>
<div
v-for="log in filteredLogs"
:key="log.id"
@@ -59,14 +92,12 @@
<span class="support-banner__log-time">{{ formatTime(log.timestamp) }}</span>
<span class="support-banner__log-level">{{ log.level }}</span>
<span class="support-banner__log-source">[{{ log.source }}]</span>
<span class="support-banner__log-msg">{{ log.message }}</span>
<span class="support-banner__log-msg" v-html="highlightSearch(log.message)" />
<button
v-if="log.data"
class="support-banner__log-expand"
@click="toggleData(log.id)"
>
{ }
</button>
>{ }</button>
<pre
v-if="log.data && expandedIds.has(log.id)"
class="support-banner__log-data"
@@ -74,42 +105,67 @@
</div>
</div>
<!-- Rodapé do painel -->
<!-- Rodapé -->
<div class="support-banner__footer">
<span>{{ store.logs.length }} entrada(s) total</span>
<span>{{ filteredLogs.length }} / {{ store.logs.length }} entrada(s)</span>
<span>·</span>
<span>{{ store.errorLogs.length }} erro(s)</span>
<span :class="store.errorLogs.length ? 'text-red-400' : ''">
{{ store.errorLogs.length }} erro(s)
</span>
<span v-if="searchQuery">· filtro: "{{ searchQuery }}"</span>
</div>
</div>
</div>
</Transition>
<!-- Dialog de documentação técnica -->
<AgendaDevDocs v-model:visible="docsVisible" />
</Teleport>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useSupportDebugStore } from '@/support/supportDebugStore'
import AgendaDevDocs from '@/features/agenda/components/dev/AgendaDevDocs.vue'
const docsVisible = ref(false)
const store = useSupportDebugStore()
const panelOpen = ref(false)
const activeLevel = ref(null)
const searchQuery = ref('')
const expandedIds = ref(new Set())
const logListRef = ref(null)
const levels = [
{ label: 'Eventos', value: 'event' },
{ label: 'API', value: 'api' },
{ label: 'Agenda', value: 'agenda' },
{ label: 'Recorrência', value: 'recurrence' },
{ label: 'Tenant', value: 'tenant' },
{ label: 'Menu', value: 'menu' },
{ label: 'Perfil', value: 'profile' },
{ label: 'Auth', value: 'auth' },
{ label: 'Guard', value: 'guard' },
{ label: 'Perf', value: 'perf' },
{ label: 'Erros', value: 'error' },
]
const filteredLogs = computed(() => {
const all = store.recentLogs
if (!activeLevel.value) return all
return all.filter(l => l.level === activeLevel.value)
let all = store.recentLogs
if (activeLevel.value) all = all.filter(l => l.level === activeLevel.value)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
all = all.filter(l =>
l.message?.toLowerCase().includes(q) ||
l.source?.toLowerCase().includes(q) ||
(l.data ? JSON.stringify(l.data).toLowerCase().includes(q) : false)
)
}
return all
})
function countByLevel (level) {
@@ -124,7 +180,37 @@ function toggleData (id) {
}
function formatTime (iso) {
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
return new Date(iso).toLocaleTimeString('pt-BR', {
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
}
function highlightSearch (text) {
if (!searchQuery.value.trim() || !text) return escHtml(text || '')
const q = searchQuery.value.trim()
const re = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return escHtml(text).replace(re, '<mark class="sbh">$1</mark>')
}
function escHtml (s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function exportLogs () {
const blob = new Blob(
[JSON.stringify(store.logs, null, 2)],
{ type: 'application/json' }
)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `support-logs-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
console.log('[SupportDebugBanner] logs exportados:', store.logs.length)
}
</script>
@@ -139,13 +225,14 @@ function formatTime (iso) {
font-size: 12px;
}
/* ── Barra ───────────────────────────────────────────────── */
.support-banner__bar {
display: flex;
align-items: center;
justify-content: space-between;
background: #b45309;
background: #92400e;
color: #fff;
padding: 6px 16px;
padding: 5px 14px;
gap: 12px;
}
@@ -155,38 +242,51 @@ function formatTime (iso) {
gap: 10px;
font-weight: 600;
letter-spacing: 0.05em;
min-width: 0;
overflow: hidden;
}
.support-banner__bar-right {
display: flex;
align-items: center;
gap: 6px;
gap: 5px;
flex-shrink: 0;
}
.support-banner__pulse {
display: inline-block;
width: 8px;
height: 8px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #fcd34d;
flex-shrink: 0;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.3); }
50% { opacity: 0.5; transform: scale(1.4); }
}
.support-banner__tenant {
font-weight: 400;
opacity: 0.75;
opacity: 0.7;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 260px;
}
.support-banner__toggle,
.support-banner__clear,
.support-banner__close {
background: rgba(255,255,255,0.15);
.support-banner__count {
font-weight: 400;
opacity: 0.55;
font-size: 10px;
flex-shrink: 0;
}
.support-banner__toggle {
background: rgba(255,255,255,0.14);
border: none;
color: #fff;
cursor: pointer;
@@ -198,36 +298,56 @@ function formatTime (iso) {
gap: 5px;
transition: background 0.15s;
}
.support-banner__toggle:hover,
.support-banner__clear:hover,
.support-banner__close:hover {
background: rgba(255,255,255,0.28);
.support-banner__toggle:hover { background: rgba(255,255,255,0.26); }
.support-banner__icon-btn {
width: 26px;
height: 26px;
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
cursor: pointer;
border-radius: 4px;
display: grid;
place-items: center;
transition: background 0.15s;
}
.support-banner__icon-btn:hover { background: rgba(255,255,255,0.25); }
.support-banner__err-badge {
background: #ef4444;
color: #fff;
border-radius: 10px;
padding: 0 7px;
padding: 0 6px;
font-size: 10px;
font-weight: 700;
}
/* Painel */
/* ── Painel ──────────────────────────────────────────────── */
.support-banner__panel {
background: #0f172a;
border-top: 2px solid #b45309;
border-top: 2px solid #92400e;
display: flex;
flex-direction: column;
max-height: 360px;
max-height: 380px;
}
/* ── Toolbar ─────────────────────────────────────────────── */
.support-banner__toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-bottom: 1px solid #1e293b;
flex-shrink: 0;
flex-wrap: wrap;
}
.support-banner__filters {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid #1e293b;
flex-shrink: 0;
gap: 4px;
flex-wrap: wrap;
flex: 1;
}
.support-banner__filter-btn {
@@ -235,46 +355,96 @@ function formatTime (iso) {
border: 1px solid #334155;
color: #94a3b8;
border-radius: 4px;
padding: 2px 10px;
padding: 2px 9px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.15s;
gap: 4px;
transition: all 0.12s;
white-space: nowrap;
}
.support-banner__filter-btn--active {
background: #b45309;
border-color: #b45309;
background: #92400e;
border-color: #92400e;
color: #fff;
}
.support-banner__filter-count {
background: rgba(255,255,255,0.15);
background: rgba(255,255,255,0.13);
border-radius: 8px;
padding: 0 5px;
padding: 0 4px;
font-size: 10px;
}
/* ── Search ──────────────────────────────────────────────── */
.support-banner__search-wrap {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
}
.support-banner__search-icon {
position: absolute;
left: 7px;
color: #475569;
font-size: 11px;
pointer-events: none;
}
.support-banner__search {
background: #1e293b;
border: 1px solid #334155;
border-radius: 4px;
color: #e2e8f0;
font-size: 11px;
font-family: inherit;
padding: 3px 24px 3px 24px;
width: 180px;
outline: none;
transition: border-color 0.15s;
}
.support-banner__search:focus { border-color: #92400e; }
.support-banner__search::placeholder { color: #475569; }
.support-banner__search-clear {
position: absolute;
right: 5px;
background: none;
border: none;
color: #475569;
cursor: pointer;
font-size: 10px;
display: grid;
place-items: center;
padding: 2px;
}
.support-banner__search-clear:hover { color: #94a3b8; }
/* ── Logs list ───────────────────────────────────────────── */
.support-banner__logs {
overflow-y: auto;
flex: 1;
padding: 4px 0;
padding: 2px 0;
scrollbar-width: thin;
scrollbar-color: #1e293b transparent;
}
.support-banner__empty {
color: #475569;
text-align: center;
padding: 20px;
font-style: italic;
}
.support-banner__log-entry {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
padding: 3px 12px;
border-bottom: 1px solid #1e293b;
gap: 5px;
padding: 2px 12px;
border-bottom: 1px solid #0f1829;
transition: background 0.1s;
}
.support-banner__log-entry:hover { background: #1e293b; }
@@ -284,12 +454,17 @@ function formatTime (iso) {
.support-banner__log-entry--recurrence { border-left: 3px solid #8b5cf6; }
.support-banner__log-entry--guard { border-left: 3px solid #10b981; }
.support-banner__log-entry--perf { border-left: 3px solid #f59e0b; }
.support-banner__log-entry--event { border-left: 3px solid #64748b; }
.support-banner__log-entry--tenant { border-left: 3px solid #06b6d4; }
.support-banner__log-entry--menu { border-left: 3px solid #84cc16; }
.support-banner__log-entry--profile { border-left: 3px solid #ec4899; }
.support-banner__log-entry--auth { border-left: 3px solid #f97316; }
.support-banner__log-entry--agenda { border-left: 3px solid #a78bfa; }
.support-banner__log-entry--event { border-left: 3px solid #475569; }
.support-banner__log-time { color: #475569; font-size: 10px; flex-shrink: 0; }
.support-banner__log-time { color: #334155; font-size: 10px; flex-shrink: 0; }
.support-banner__log-level { color: #f59e0b; font-size: 10px; font-weight: 700; text-transform: uppercase; flex-shrink: 0; }
.support-banner__log-source { color: #7c3aed; font-size: 10px; flex-shrink: 0; }
.support-banner__log-msg { color: #e2e8f0; flex: 1; }
.support-banner__log-msg { color: #cbd5e1; flex: 1; }
.support-banner__log-expand {
background: #1e293b;
@@ -300,16 +475,17 @@ function formatTime (iso) {
cursor: pointer;
font-size: 10px;
font-family: monospace;
flex-shrink: 0;
}
.support-banner__log-expand:hover { color: #e2e8f0; }
.support-banner__log-data {
width: 100%;
margin: 4px 0 0;
background: #0f172a;
margin: 3px 0 2px;
background: #020617;
border: 1px solid #1e293b;
color: #94a3b8;
padding: 8px;
padding: 7px;
border-radius: 4px;
font-size: 10px;
overflow-x: auto;
@@ -317,17 +493,26 @@ function formatTime (iso) {
word-break: break-all;
}
/* mark de busca */
:deep(.sbh) {
background: rgba(250, 204, 21, 0.3);
color: #fde68a;
border-radius: 2px;
padding: 0 1px;
}
/* ── Footer ──────────────────────────────────────────────── */
.support-banner__footer {
display: flex;
gap: 8px;
padding: 4px 12px;
color: #475569;
color: #334155;
font-size: 10px;
border-top: 1px solid #1e293b;
flex-shrink: 0;
}
/* Transition */
/* ── Transition ──────────────────────────────────────────── */
.support-slide-enter-active,
.support-slide-leave-active { transition: transform 0.25s ease; }
.support-slide-enter-from,

View File

@@ -11,8 +11,8 @@
* Usar sempre este módulo para logs de diagnóstico.
*
* Uso:
* import { logEvent, logAPI, logError, logRecurrence } from '@/support/supportLogger'
* logEvent('useRecurrence', 'loadRules', { ownerId, startISO })
* import { logEvent, logAPI, logError, logTenant } from '@/support/supportLogger'
* logTenant('loadSessionAndTenant', { tenantId, role })
*/
import { useSupportDebugStore } from './supportDebugStore'
@@ -26,6 +26,11 @@ export const LOG_LEVEL = {
RECURRENCE: 'recurrence',
GUARD: 'guard',
PERF: 'perf',
TENANT: 'tenant',
MENU: 'menu',
PROFILE: 'profile',
AUTH: 'auth',
AGENDA: 'agenda',
}
// ─── Função base ─────────────────────────────────────────────────────────────
@@ -47,7 +52,6 @@ function _log (level, source, message, data = null) {
store.addLog(entry)
// Agrupa no console para não poluir — só visível quando debug ativo
const prefix = `[${level.toUpperCase()}][${source}]`
if (level === LOG_LEVEL.ERROR) {
console.error(prefix, message, data ?? '')
@@ -58,24 +62,17 @@ function _log (level, source, message, data = null) {
// ─── API pública ─────────────────────────────────────────────────────────────
/**
* Log de evento geral (lifecycle, state changes)
* Substitui console.log genérico dos composables
*/
/** Log de evento geral (lifecycle, state changes) */
export function logEvent (source, message, data = null) {
_log(LOG_LEVEL.EVENT, source, message, data)
}
/**
* Log de chamada de API (Supabase queries)
*/
/** Log de chamada de API (Supabase queries) */
export function logAPI (source, message, data = null) {
_log(LOG_LEVEL.API, source, message, data)
}
/**
* Log de erro capturado
*/
/** Log de erro capturado */
export function logError (source, message, error = null) {
const data = error
? { message: error?.message, code: error?.code, details: error?.details }
@@ -83,25 +80,48 @@ export function logError (source, message, error = null) {
_log(LOG_LEVEL.ERROR, source, message, data)
}
/**
* Log específico do sistema de recorrência
* Substitui os console.log de useRecurrence
*/
/** Log específico do sistema de recorrência */
export function logRecurrence (message, data = null) {
_log(LOG_LEVEL.RECURRENCE, 'useRecurrence', message, data)
}
/**
* Log de navegação/guard do router
* Substitui console.time/timeLog/timeEnd de guards.js
*/
/** Log de navegação/guard do router */
export function logGuard (message, data = null) {
_log(LOG_LEVEL.GUARD, 'router.guard', message, data)
}
/** Log de tenant: carregamento de sessão, troca de role, tenant ativo */
export function logTenant (source, message, data = null) {
_log(LOG_LEVEL.TENANT, source, message, data)
}
/** Log do sistema de menu: build, reset, model changes */
export function logMenu (message, data = null) {
_log(LOG_LEVEL.MENU, 'menuStore', message, data)
}
/** Log da página de perfil: carregamento, salvamento de settings */
export function logProfile (message, data = null) {
_log(LOG_LEVEL.PROFILE, 'ProfilePage', message, data)
}
/** Log de autenticação: login, logout, refresh, MFA */
export function logAuth (message, data = null) {
_log(LOG_LEVEL.AUTH, 'auth', message, data)
}
/** Log da agenda: eventos, slots, appointments, recurrence */
export function logAgenda (source, message, data = null) {
_log(LOG_LEVEL.AGENDA, source, message, data)
}
/**
* Log de performance (substitui console.time)
* Retorna uma função que finaliza a medição
* Log de performance — retorna função que finaliza a medição.
*
* Uso:
* const end = logPerf('useAgenda', 'loadEvents')
* ...await work...
* end({ count: events.length })
*/
export function logPerf (source, label) {
let store

View File

@@ -6,52 +6,112 @@
* Usado apenas pelo painel do admin — nunca pelo terapeuta/paciente.
*
* Fluxo:
* 1. Admin seleciona tenant
* 2. createSession(tenantId) → { token, expires_at }
* 1. Admin seleciona tenant + TTL + nota opcional
* 2. createSupportSession(tenantId, ttlMinutes, note) → { token, expires_at }
* 3. Admin recebe URL pronta para copiar
* 4. Admin pode listar sessões ativas e revogar
* 4. Admin pode listar sessões ativas, histórico e revogar
*/
import { supabase } from '@/lib/supabase/client'
const TAG = '[supportSessionService]'
/**
* Cria uma sessão de suporte para um tenant.
* Requer: usuário autenticado com role saas_admin (validado no RPC).
*
* @param {string} tenantId - UUID do tenant a ser depurado
* @param {number} ttlMinutes - TTL em minutos (1120, default 60)
* @param {string} [note] - Nota opcional sobre o motivo do suporte
* @returns {{ token: string, expires_at: string, session_id: string }}
*/
export async function createSupportSession (tenantId, ttlMinutes = 60) {
export async function createSupportSession (tenantId, ttlMinutes = 60, note = '') {
if (!tenantId) throw new Error('tenant_id é obrigatório.')
console.log(`${TAG} createSupportSession`, { tenantId, ttlMinutes, note: note || '(sem nota)' })
const { data, error } = await supabase
.rpc('create_support_session', {
p_tenant_id: tenantId,
p_ttl_minutes: ttlMinutes,
})
if (error) throw error
if (error) {
console.error(`${TAG} createSupportSession ERRO`, error)
throw error
}
if (!data?.token) throw new Error('Resposta inválida do servidor.')
// Salva nota localmente associada ao session_id (banco não tem coluna note)
if (note?.trim()) {
try {
const notes = JSON.parse(sessionStorage.getItem('support_notes') || '{}')
notes[data.session_id || data.token] = note.trim()
sessionStorage.setItem('support_notes', JSON.stringify(notes))
} catch {}
}
console.log(`${TAG} sessão criada`, { token: `${data.token.slice(0, 8)}`, expires_at: data.expires_at })
return data
}
/**
* Lista sessões de suporte ativas do admin logado.
* Retorna somente sessões não expiradas.
* Lista sessões de suporte ATIVAS (não expiradas).
*
* @returns {Array}
*/
export async function listActiveSupportSessions () {
console.log(`${TAG} listActiveSupportSessions`)
const { data, error } = await supabase
.from('support_sessions')
.select('id, tenant_id, token, expires_at, created_at')
.gt('expires_at', new Date().toISOString())
.order('created_at', { ascending: false })
if (error) throw error
return data || []
if (error) {
console.error(`${TAG} listActiveSupportSessions ERRO`, error)
throw error
}
const sessions = data || []
console.log(`${TAG} ${sessions.length} sessão(ões) ativa(s)`)
// Enriquece com notas salvas localmente
const notes = _loadNotes()
return sessions.map(s => ({ ...s, _note: notes[s.id] || notes[s.token] || '' }))
}
/**
* Lista histórico de sessões (ativas + expiradas), limitado às últimas N.
*
* @param {number} limit - quantidade máxima (default 50)
* @returns {Array}
*/
export async function listSessionHistory (limit = 50) {
console.log(`${TAG} listSessionHistory limit=${limit}`)
const { data, error } = await supabase
.from('support_sessions')
.select('id, tenant_id, token, expires_at, created_at')
.order('created_at', { ascending: false })
.limit(limit)
if (error) {
console.error(`${TAG} listSessionHistory ERRO`, error)
throw error
}
const sessions = data || []
console.log(`${TAG} histórico: ${sessions.length} sessão(ões)`)
const notes = _loadNotes()
const now = new Date()
return sessions.map(s => ({
...s,
_note: notes[s.id] || notes[s.token] || '',
_expired: new Date(s.expires_at) < now,
}))
}
/**
@@ -63,10 +123,17 @@ export async function listActiveSupportSessions () {
export async function revokeSupportSession (token) {
if (!token) throw new Error('Token é obrigatório.')
console.log(`${TAG} revokeSupportSession token=${token.slice(0, 8)}`)
const { data, error } = await supabase
.rpc('revoke_support_session', { p_token: token })
if (error) throw error
if (error) {
console.error(`${TAG} revokeSupportSession ERRO`, error)
throw error
}
console.log(`${TAG} sessão revogada:`, !!data)
return !!data
}
@@ -81,3 +148,13 @@ export function buildSupportUrl (token, basePath = '/therapist/agenda') {
const origin = window.location.origin
return `${origin}${basePath}?support=${token}`
}
// ─── Helpers internos ─────────────────────────────────────────────────────────
function _loadNotes () {
try {
return JSON.parse(sessionStorage.getItem('support_notes') || '{}')
} catch {
return {}
}
}

View File

@@ -1,30 +1,281 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import {
createSupportSession,
listActiveSupportSessions,
listSessionHistory,
revokeSupportSession,
buildSupportUrl,
} from '@/support/supportSessionService'
const TAG = '[SaasSupportPage]'
const toast = useToast()
// ── Tabs ──────────────────────────────────────────────────────────────────────
const activeTab = ref(0)
// ── Estado — Nova Sessão ──────────────────────────────────────────────────────
const selectedTenantId = ref(null)
const ttlMinutes = ref(60)
const sessionNote = ref('')
const creating = ref(false)
const generatedUrl = ref(null)
const generatedData = ref(null)
// ── Estado — Listas ───────────────────────────────────────────────────────────
const loadingTenants = ref(false)
const loadingSessions = ref(false)
const loadingHistory = ref(false)
const revokingToken = ref(null)
const tenants = ref([])
const tenantMap = ref({})
const activeSessions = ref([])
const sessionHistory = ref([])
// ── TTL Options ───────────────────────────────────────────────────────────────
const ttlOptions = [
{ label: '15 minutos', value: 15 },
{ label: '30 minutos', value: 30 },
{ label: '1 hora', value: 60 },
{ label: '2 horas', value: 120 },
]
// ── Countdown tick ────────────────────────────────────────────────────────────
const _now = ref(Date.now())
let _tickTimer = null
function startTick () {
if (_tickTimer) return
_tickTimer = setInterval(() => { _now.value = Date.now() }, 10_000)
}
onBeforeUnmount(() => { if (_tickTimer) clearInterval(_tickTimer) })
// ── Computed ──────────────────────────────────────────────────────────────────
const expiresLabel = computed(() => {
if (!generatedData.value?.expires_at) return ''
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
})
const tokenPreview = computed(() => {
if (!generatedData.value?.token) return ''
const t = generatedData.value.token
return `${t.slice(0, 8)}${t.slice(-8)}`
})
const activeSessionCount = computed(() => activeSessions.value.length)
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
console.log(`${TAG} montado`)
await loadTenants()
await loadActiveSessions()
startTick()
})
// ── Tenants ───────────────────────────────────────────────────────────────────
async function loadTenants () {
loadingTenants.value = true
console.log(`${TAG} loadTenants`)
try {
const { data, error } = await supabase
.from('tenants')
.select('id, name, kind')
.order('name', { ascending: true })
if (error) throw error
const list = data || []
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`)
tenantMap.value = Object.fromEntries(list.map(t => [t.id, t.name || t.id]))
tenants.value = list.map(t => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`,
}))
} catch (e) {
console.error(`${TAG} loadTenants ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingTenants.value = false
}
}
// ── Sessões ativas ─────────────────────────────────────────────────────────────
async function loadActiveSessions () {
loadingSessions.value = true
console.log(`${TAG} loadActiveSessions`)
try {
activeSessions.value = await listActiveSupportSessions()
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`)
} catch (e) {
console.error(`${TAG} loadActiveSessions ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingSessions.value = false
}
}
// ── Histórico ─────────────────────────────────────────────────────────────────
async function loadHistory () {
if (loadingHistory.value) return
loadingHistory.value = true
console.log(`${TAG} loadHistory`)
try {
sessionHistory.value = await listSessionHistory(100)
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`)
} catch (e) {
console.error(`${TAG} loadHistory ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingHistory.value = false
}
}
// ── Criar sessão ──────────────────────────────────────────────────────────────
async function handleCreate () {
if (!selectedTenantId.value) return
creating.value = true
generatedUrl.value = null
generatedData.value = null
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' })
try {
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value)
generatedData.value = result
generatedUrl.value = buildSupportUrl(result.token)
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0,8)}`, expires_at: result.expires_at })
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 })
await loadActiveSessions()
} catch (e) {
console.error(`${TAG} handleCreate ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
} finally {
creating.value = false
}
}
// ── Revogar ───────────────────────────────────────────────────────────────────
async function handleRevoke (token) {
revokingToken.value = token
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}`)
try {
await revokeSupportSession(token)
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
if (generatedData.value?.token === token) {
generatedUrl.value = null
generatedData.value = null
}
await loadActiveSessions()
if (sessionHistory.value.length) await loadHistory()
} catch (e) {
console.error(`${TAG} handleRevoke ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
} finally {
revokingToken.value = null
}
}
// ── Copiar ────────────────────────────────────────────────────────────────────
function copyUrl (url) {
if (!url) return
navigator.clipboard.writeText(url)
console.log(`${TAG} URL copiada`)
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
}
// ── Tab change ────────────────────────────────────────────────────────────────
function onTabChange (e) {
const idx = e.index ?? e
activeTab.value = idx
console.log(`${TAG} tab mudou para ${idx}`)
if (idx === 2 && sessionHistory.value.length === 0) loadHistory()
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function tenantName (id) {
return tenantMap.value[id] || id
}
function formatDate (iso) {
if (!iso) return '-'
return new Date(iso).toLocaleString('pt-BR')
}
function remainingLabel (iso) {
_now.value // dependência reativa
if (!iso) return '-'
const diff = new Date(iso) - Date.now()
if (diff <= 0) return 'Expirada'
const min = Math.floor(diff / 60000)
const h = Math.floor(min / 60)
const m = min % 60
if (h > 0) return `${h}h ${m}min`
return `${min} min`
}
function isExpiringSoon (iso) {
if (!iso) return false
const diff = (new Date(iso) - Date.now()) / 60000
return diff > 0 && diff < 15
}
function sessionStatusSeverity (session) {
if (session._expired) return 'danger'
if (isExpiringSoon(session.expires_at)) return 'warning'
return 'success'
}
function sessionStatusLabel (session) {
if (session._expired) return 'Expirada'
if (isExpiringSoon(session.expires_at)) return 'Expirando'
return 'Ativa'
}
</script>
<template>
<div class="saas-support p-4 md:p-6">
<Toast />
<!-- Cabeçalho -->
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center gap-3 mb-5">
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30">
<i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
</div>
<div>
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0 m-0">Suporte Técnico</h1>
<p class="text-sm text-surface-500 m-0">Gere links seguros para acessar a agenda de um cliente em modo debug</p>
<div class="flex-1">
<h1 class="text-xl font-bold m-0">Suporte Técnico</h1>
<p class="text-sm text-surface-500 m-0">Gere e gerencie links seguros de acesso em modo debug</p>
</div>
<Tag
v-if="activeSessionCount > 0"
:value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`"
severity="warning"
/>
</div>
<!-- Card: Gerar nova sessão -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
<!-- Tab 0: Nova Sessão -->
<TabPanel header="Nova Sessão">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
<!-- Formulário -->
<div class="card">
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
<i class="pi pi-plus-circle text-primary" />
Nova Sessão de Suporte
Configurar acesso de suporte
</h2>
<div class="flex flex-col gap-4">
<!-- Seleção de tenant -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Selecionar Cliente (Tenant)</label>
<label class="text-sm font-medium">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
@@ -38,9 +289,8 @@
/>
</div>
<!-- TTL -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Duração do Acesso</label>
<label class="text-sm font-medium">Duração do Acesso</label>
<Select
v-model="ttlMinutes"
:options="ttlOptions"
@@ -50,7 +300,18 @@
/>
</div>
<!-- Botão -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">
Nota / Motivo
<span class="text-surface-400 font-normal">(opcional)</span>
</label>
<InputText
v-model="sessionNote"
placeholder="Ex: cliente reportou erro na agenda de recorrência"
class="w-full"
/>
</div>
<Button
label="Ativar Modo Suporte"
icon="pi pi-shield"
@@ -63,65 +324,65 @@
</div>
</div>
<!-- Card: URL Gerada -->
<!-- URL Gerada -->
<div class="card">
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
<i class="pi pi-link text-primary" />
URL de Suporte Gerada
URL Gerada
</h2>
<div v-if="generatedUrl" class="flex flex-col gap-3">
<!-- URL -->
<div v-if="generatedUrl" class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Link de Acesso</label>
<label class="text-sm font-medium">Link de Acesso</label>
<div class="flex gap-2">
<InputText
:value="generatedUrl"
readonly
class="flex-1 font-mono text-xs"
/>
<Button
icon="pi pi-copy"
severity="secondary"
outlined
v-tooltip.top="'Copiar URL'"
@click="copyUrl"
/>
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-xs" />
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
</div>
</div>
<!-- Expira em -->
<div class="flex items-center gap-2 text-sm text-surface-500">
<div class="flex items-center gap-2 text-sm">
<i class="pi pi-clock text-orange-500" />
<span>Expira em: <strong class="text-surface-700 dark:text-surface-300">{{ expiresLabel }}</strong></span>
<span class="text-surface-500">Expira em:</span>
<strong>{{ expiresLabel }}</strong>
</div>
<!-- Token (reduzido) -->
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<!-- Instruções -->
<div v-if="sessionNote" class="flex items-start gap-2 text-sm text-surface-500">
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
<span class="italic">{{ sessionNote }}</span>
</div>
<Message severity="info" :closable="false" class="text-sm">
Envie este link ao terapeuta ou acesse diretamente para ver os logs da agenda.
O link expira automaticamente.
Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-10 text-surface-400 gap-2">
<i class="pi pi-shield text-4xl opacity-30" />
<div v-else class="flex flex-col items-center justify-center py-12 text-surface-400 gap-3">
<i class="pi pi-shield text-4xl opacity-25" />
<span class="text-sm">Nenhuma sessão gerada ainda</span>
</div>
</div>
</div>
</TabPanel>
<!-- Sessões ativas -->
<div class="card mt-6">
<!-- Tab 1: Sessões Ativas -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
Sessões Ativas
<Badge v-if="activeSessionCount > 0" :value="String(activeSessionCount)" severity="warning" />
</span>
</template>
<div class="card mt-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-list text-primary" />
Sessões Ativas
<i class="pi pi-circle-fill text-green-500 text-xs" />
Sessões em vigor
</h2>
<Button
icon="pi pi-refresh"
@@ -129,6 +390,7 @@
outlined
size="small"
:loading="loadingSessions"
label="Atualizar"
@click="loadActiveSessions"
/>
</div>
@@ -140,44 +402,126 @@
size="small"
striped-rows
>
<Column field="tenant_id" header="Tenant ID">
<Column header="Tenant" style="min-width: 200px">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.tenant_id }}</span>
<div class="flex flex-col gap-0.5">
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.token.slice(0, 16) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Expira em">
<Column header="Restante" style="min-width: 100px">
<template #body="{ data }">
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
{{ formatExpires(data.expires_at) }}
{{ remainingLabel(data.expires_at) }}
</span>
</template>
</Column>
<Column header="Criada">
<Column header="Criada em">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
<span class="text-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Ações">
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-xs italic text-surface-500">{{ data._note }}</span>
<span v-else class="text-xs text-surface-300"></span>
</template>
</Column>
<Column header="Ações" style="width: 110px">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-copy" size="small" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(buildSupportUrl(data.token))" />
<Button icon="pi pi-trash" size="small" severity="danger" outlined v-tooltip.top="'Revogar'" :loading="revokingToken === data.token" @click="handleRevoke(data.token)" />
</div>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 2: Histórico -->
<TabPanel header="Histórico">
<div class="card mt-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-history text-primary" />
Últimas 100 sessões
</h2>
<Button
icon="pi pi-copy"
size="small"
icon="pi pi-refresh"
severity="secondary"
outlined
v-tooltip.top="'Copiar URL'"
@click="copySessionUrl(data.token)"
size="small"
:loading="loadingHistory"
label="Carregar"
@click="loadHistory"
/>
</div>
<DataTable
:value="sessionHistory"
:loading="loadingHistory"
empty-message="Clique em Carregar para ver o histórico"
size="small"
striped-rows
paginator
:rows="20"
>
<Column header="Status" style="width: 110px">
<template #body="{ data }">
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" class="text-xs" />
</template>
</Column>
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-xs text-surface-400">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Criada em" sortable field="created_at">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Expirava em">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-xs italic text-surface-500">{{ data._note }}</span>
<span v-else class="text-xs text-surface-300"></span>
</template>
</Column>
<Column header="" style="width: 60px">
<template #body="{ data }">
<Button
v-if="!data._expired"
icon="pi pi-trash"
size="small"
severity="danger"
@@ -186,179 +530,12 @@
:loading="revokingToken === data.token"
@click="handleRevoke(data.token)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabView>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import {
createSupportSession,
listActiveSupportSessions,
revokeSupportSession,
buildSupportUrl,
} from '@/support/supportSessionService'
const toast = useToast()
// ── Estado ─────────────────────────────────────────────────────────────────
const selectedTenantId = ref(null)
const ttlMinutes = ref(60)
const creating = ref(false)
const loadingTenants = ref(false)
const loadingSessions = ref(false)
const revokingToken = ref(null)
const tenants = ref([])
const activeSessions = ref([])
const generatedUrl = ref(null)
const generatedData = ref(null) // { token, expires_at }
// ── Opções de TTL ──────────────────────────────────────────────────────────
const ttlOptions = [
{ label: '30 minutos', value: 30 },
{ label: '60 minutos', value: 60 },
{ label: '2 horas', value: 120 },
]
// ── Computed ───────────────────────────────────────────────────────────────
const expiresLabel = computed(() => {
if (!generatedData.value?.expires_at) return ''
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
})
const tokenPreview = computed(() => {
if (!generatedData.value?.token) return ''
const t = generatedData.value.token
return `${t.slice(0, 8)}${t.slice(-8)}`
})
// ── Lifecycle ──────────────────────────────────────────────────────────────
onMounted(() => {
loadTenants()
loadActiveSessions()
})
// ── Métodos ────────────────────────────────────────────────────────────────
async function loadTenants () {
loadingTenants.value = true
try {
const { data, error } = await supabase
.from('tenants')
.select('id, name, kind')
.order('name', { ascending: true })
if (error) throw error
tenants.value = (data || []).map(t => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`,
}))
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingTenants.value = false
}
}
async function loadActiveSessions () {
loadingSessions.value = true
try {
activeSessions.value = await listActiveSupportSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingSessions.value = false
}
}
async function handleCreate () {
if (!selectedTenantId.value) return
creating.value = true
generatedUrl.value = null
generatedData.value = null
try {
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value)
generatedData.value = result
generatedUrl.value = buildSupportUrl(result.token)
toast.add({
severity: 'success',
summary: 'Sessão criada',
detail: 'URL de suporte gerada com sucesso.',
life: 4000,
})
await loadActiveSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
} finally {
creating.value = false
}
}
async function handleRevoke (token) {
revokingToken.value = token
try {
await revokeSupportSession(token)
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
if (generatedData.value?.token === token) {
generatedUrl.value = null
generatedData.value = null
}
await loadActiveSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
} finally {
revokingToken.value = null
}
}
function copyUrl () {
if (!generatedUrl.value) return
navigator.clipboard.writeText(generatedUrl.value)
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
}
function copySessionUrl (token) {
const url = buildSupportUrl(token)
navigator.clipboard.writeText(url)
toast.add({ severity: 'info', summary: 'Copiado!', life: 2000 })
}
// ── Formatação ─────────────────────────────────────────────────────────────
function formatDate (iso) {
if (!iso) return '-'
return new Date(iso).toLocaleString('pt-BR')
}
function formatExpires (iso) {
if (!iso) return '-'
const d = new Date(iso)
const now = new Date()
const diffMin = Math.round((d - now) / 60000)
if (diffMin < 0) return 'Expirada'
if (diffMin < 60) return `em ${diffMin} min`
return new Date(iso).toLocaleString('pt-BR')
}
function isExpiringSoon (iso) {
if (!iso) return false
const diffMin = (new Date(iso) - new Date()) / 60000
return diffMin > 0 && diffMin < 15
}
</script>