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> <template>
<!-- Renderiza apenas se modo suporte estiver ativo -->
<Teleport to="body"> <Teleport to="body">
<Transition name="support-slide"> <Transition name="support-slide">
<div v-if="store.isActive" class="support-banner"> <div v-if="store.isActive" class="support-banner">
<!-- Barra superior fixa -->
<!-- Barra superior -->
<div class="support-banner__bar"> <div class="support-banner__bar">
<div class="support-banner__bar-left"> <div class="support-banner__bar-left">
<span class="support-banner__pulse" /> <span class="support-banner__pulse" />
<strong>MODO SUPORTE ATIVO</strong> <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>
<div class="support-banner__bar-right"> <div class="support-banner__bar-right">
<button class="support-banner__toggle" @click="panelOpen = !panelOpen"> <button class="support-banner__toggle" @click="panelOpen = !panelOpen">
<i :class="panelOpen ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" /> <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"> <span v-if="store.errorLogs.length" class="support-banner__err-badge">
{{ store.errorLogs.length }} erro(s) {{ store.errorLogs.length }} erro(s)
</span> </span>
</button> </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" /> <i class="pi pi-trash" />
</button> </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" /> <i class="pi pi-times" />
</button> </button>
</div> </div>
</div> </div>
<!-- Painel expansível de logs --> <!-- Painel de logs -->
<div v-if="panelOpen" class="support-banner__panel"> <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"> <div class="support-banner__filters">
<button <button
v-for="lvl in levels" v-for="lvl in levels"
@@ -39,17 +53,36 @@
@click="activeLevel = activeLevel === lvl.value ? null : lvl.value" @click="activeLevel = activeLevel === lvl.value ? null : lvl.value"
> >
{{ lvl.label }} {{ lvl.label }}
<span class="support-banner__filter-count"> <span class="support-banner__filter-count">{{ countByLevel(lvl.value) }}</span>
{{ countByLevel(lvl.value) }}
</span>
</button> </button>
</div> </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 ref="logListRef" class="support-banner__logs">
<div v-if="filteredLogs.length === 0" class="support-banner__empty"> <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>
<div <div
v-for="log in filteredLogs" v-for="log in filteredLogs"
:key="log.id" :key="log.id"
@@ -59,14 +92,12 @@
<span class="support-banner__log-time">{{ formatTime(log.timestamp) }}</span> <span class="support-banner__log-time">{{ formatTime(log.timestamp) }}</span>
<span class="support-banner__log-level">{{ log.level }}</span> <span class="support-banner__log-level">{{ log.level }}</span>
<span class="support-banner__log-source">[{{ log.source }}]</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 <button
v-if="log.data" v-if="log.data"
class="support-banner__log-expand" class="support-banner__log-expand"
@click="toggleData(log.id)" @click="toggleData(log.id)"
> >{ }</button>
{ }
</button>
<pre <pre
v-if="log.data && expandedIds.has(log.id)" v-if="log.data && expandedIds.has(log.id)"
class="support-banner__log-data" class="support-banner__log-data"
@@ -74,42 +105,67 @@
</div> </div>
</div> </div>
<!-- Rodapé do painel --> <!-- Rodapé -->
<div class="support-banner__footer"> <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>·</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> </div>
</div> </div>
</Transition> </Transition>
<!-- Dialog de documentação técnica -->
<AgendaDevDocs v-model:visible="docsVisible" />
</Teleport> </Teleport>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useSupportDebugStore } from '@/support/supportDebugStore' import { useSupportDebugStore } from '@/support/supportDebugStore'
import AgendaDevDocs from '@/features/agenda/components/dev/AgendaDevDocs.vue'
const docsVisible = ref(false)
const store = useSupportDebugStore() const store = useSupportDebugStore()
const panelOpen = ref(false) const panelOpen = ref(false)
const activeLevel = ref(null) const activeLevel = ref(null)
const searchQuery = ref('')
const expandedIds = ref(new Set()) const expandedIds = ref(new Set())
const logListRef = ref(null) const logListRef = ref(null)
const levels = [ const levels = [
{ label: 'Eventos', value: 'event' }, { label: 'Eventos', value: 'event' },
{ label: 'API', value: 'api' }, { label: 'API', value: 'api' },
{ label: 'Agenda', value: 'agenda' },
{ label: 'Recorrência', value: 'recurrence' }, { 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: 'Guard', value: 'guard' },
{ label: 'Perf', value: 'perf' }, { label: 'Perf', value: 'perf' },
{ label: 'Erros', value: 'error' }, { label: 'Erros', value: 'error' },
] ]
const filteredLogs = computed(() => { const filteredLogs = computed(() => {
const all = store.recentLogs let all = store.recentLogs
if (!activeLevel.value) return all if (activeLevel.value) all = all.filter(l => l.level === activeLevel.value)
return 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) { function countByLevel (level) {
@@ -124,7 +180,37 @@ function toggleData (id) {
} }
function formatTime (iso) { 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> </script>
@@ -139,13 +225,14 @@ function formatTime (iso) {
font-size: 12px; font-size: 12px;
} }
/* ── Barra ───────────────────────────────────────────────── */
.support-banner__bar { .support-banner__bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: #b45309; background: #92400e;
color: #fff; color: #fff;
padding: 6px 16px; padding: 5px 14px;
gap: 12px; gap: 12px;
} }
@@ -155,38 +242,51 @@ function formatTime (iso) {
gap: 10px; gap: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
min-width: 0;
overflow: hidden;
} }
.support-banner__bar-right { .support-banner__bar-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
flex-shrink: 0;
} }
.support-banner__pulse { .support-banner__pulse {
display: inline-block; display: inline-block;
width: 8px; width: 7px;
height: 8px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: #fcd34d; background: #fcd34d;
flex-shrink: 0;
animation: pulse 1.4s ease-in-out infinite; animation: pulse 1.4s ease-in-out infinite;
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); } 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 { .support-banner__tenant {
font-weight: 400; font-weight: 400;
opacity: 0.75; opacity: 0.7;
font-size: 11px; font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 260px;
} }
.support-banner__toggle, .support-banner__count {
.support-banner__clear, font-weight: 400;
.support-banner__close { opacity: 0.55;
background: rgba(255,255,255,0.15); font-size: 10px;
flex-shrink: 0;
}
.support-banner__toggle {
background: rgba(255,255,255,0.14);
border: none; border: none;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
@@ -198,36 +298,56 @@ function formatTime (iso) {
gap: 5px; gap: 5px;
transition: background 0.15s; transition: background 0.15s;
} }
.support-banner__toggle:hover, .support-banner__toggle:hover { background: rgba(255,255,255,0.26); }
.support-banner__clear:hover,
.support-banner__close:hover { .support-banner__icon-btn {
background: rgba(255,255,255,0.28); 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 { .support-banner__err-badge {
background: #ef4444; background: #ef4444;
color: #fff; color: #fff;
border-radius: 10px; border-radius: 10px;
padding: 0 7px; padding: 0 6px;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
} }
/* Painel */ /* ── Painel ──────────────────────────────────────────────── */
.support-banner__panel { .support-banner__panel {
background: #0f172a; background: #0f172a;
border-top: 2px solid #b45309; border-top: 2px solid #92400e;
display: flex; display: flex;
flex-direction: column; 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 { .support-banner__filters {
display: flex; display: flex;
gap: 6px; gap: 4px;
padding: 8px 12px; flex-wrap: wrap;
border-bottom: 1px solid #1e293b; flex: 1;
flex-shrink: 0;
} }
.support-banner__filter-btn { .support-banner__filter-btn {
@@ -235,46 +355,96 @@ function formatTime (iso) {
border: 1px solid #334155; border: 1px solid #334155;
color: #94a3b8; color: #94a3b8;
border-radius: 4px; border-radius: 4px;
padding: 2px 10px; padding: 2px 9px;
font-size: 11px; font-size: 11px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 4px;
transition: all 0.15s; transition: all 0.12s;
white-space: nowrap;
} }
.support-banner__filter-btn--active { .support-banner__filter-btn--active {
background: #b45309; background: #92400e;
border-color: #b45309; border-color: #92400e;
color: #fff; color: #fff;
} }
.support-banner__filter-count { .support-banner__filter-count {
background: rgba(255,255,255,0.15); background: rgba(255,255,255,0.13);
border-radius: 8px; border-radius: 8px;
padding: 0 5px; padding: 0 4px;
font-size: 10px; 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 { .support-banner__logs {
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
padding: 4px 0; padding: 2px 0;
scrollbar-width: thin;
scrollbar-color: #1e293b transparent;
} }
.support-banner__empty { .support-banner__empty {
color: #475569; color: #475569;
text-align: center; text-align: center;
padding: 20px; padding: 20px;
font-style: italic;
} }
.support-banner__log-entry { .support-banner__log-entry {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: baseline;
gap: 6px; gap: 5px;
padding: 3px 12px; padding: 2px 12px;
border-bottom: 1px solid #1e293b; border-bottom: 1px solid #0f1829;
transition: background 0.1s; transition: background 0.1s;
} }
.support-banner__log-entry:hover { background: #1e293b; } .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--recurrence { border-left: 3px solid #8b5cf6; }
.support-banner__log-entry--guard { border-left: 3px solid #10b981; } .support-banner__log-entry--guard { border-left: 3px solid #10b981; }
.support-banner__log-entry--perf { border-left: 3px solid #f59e0b; } .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-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-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 { .support-banner__log-expand {
background: #1e293b; background: #1e293b;
@@ -300,16 +475,17 @@ function formatTime (iso) {
cursor: pointer; cursor: pointer;
font-size: 10px; font-size: 10px;
font-family: monospace; font-family: monospace;
flex-shrink: 0;
} }
.support-banner__log-expand:hover { color: #e2e8f0; } .support-banner__log-expand:hover { color: #e2e8f0; }
.support-banner__log-data { .support-banner__log-data {
width: 100%; width: 100%;
margin: 4px 0 0; margin: 3px 0 2px;
background: #0f172a; background: #020617;
border: 1px solid #1e293b; border: 1px solid #1e293b;
color: #94a3b8; color: #94a3b8;
padding: 8px; padding: 7px;
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 10px;
overflow-x: auto; overflow-x: auto;
@@ -317,17 +493,26 @@ function formatTime (iso) {
word-break: break-all; 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 { .support-banner__footer {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding: 4px 12px; padding: 4px 12px;
color: #475569; color: #334155;
font-size: 10px; font-size: 10px;
border-top: 1px solid #1e293b; border-top: 1px solid #1e293b;
flex-shrink: 0; flex-shrink: 0;
} }
/* Transition */ /* ── Transition ──────────────────────────────────────────── */
.support-slide-enter-active, .support-slide-enter-active,
.support-slide-leave-active { transition: transform 0.25s ease; } .support-slide-leave-active { transition: transform 0.25s ease; }
.support-slide-enter-from, .support-slide-enter-from,

View File

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

View File

@@ -6,52 +6,112 @@
* Usado apenas pelo painel do admin — nunca pelo terapeuta/paciente. * Usado apenas pelo painel do admin — nunca pelo terapeuta/paciente.
* *
* Fluxo: * Fluxo:
* 1. Admin seleciona tenant * 1. Admin seleciona tenant + TTL + nota opcional
* 2. createSession(tenantId) → { token, expires_at } * 2. createSupportSession(tenantId, ttlMinutes, note) → { token, expires_at }
* 3. Admin recebe URL pronta para copiar * 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' import { supabase } from '@/lib/supabase/client'
const TAG = '[supportSessionService]'
/** /**
* Cria uma sessão de suporte para um tenant. * Cria uma sessão de suporte para um tenant.
* Requer: usuário autenticado com role saas_admin (validado no RPC). * Requer: usuário autenticado com role saas_admin (validado no RPC).
* *
* @param {string} tenantId - UUID do tenant a ser depurado * @param {string} tenantId - UUID do tenant a ser depurado
* @param {number} ttlMinutes - TTL em minutos (1120, default 60) * @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 }} * @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.') if (!tenantId) throw new Error('tenant_id é obrigatório.')
console.log(`${TAG} createSupportSession`, { tenantId, ttlMinutes, note: note || '(sem nota)' })
const { data, error } = await supabase const { data, error } = await supabase
.rpc('create_support_session', { .rpc('create_support_session', {
p_tenant_id: tenantId, p_tenant_id: tenantId,
p_ttl_minutes: ttlMinutes, 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.') 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 return data
} }
/** /**
* Lista sessões de suporte ativas do admin logado. * Lista sessões de suporte ATIVAS (não expiradas).
* Retorna somente sessões não expiradas.
* *
* @returns {Array} * @returns {Array}
*/ */
export async function listActiveSupportSessions () { export async function listActiveSupportSessions () {
console.log(`${TAG} listActiveSupportSessions`)
const { data, error } = await supabase const { data, error } = await supabase
.from('support_sessions') .from('support_sessions')
.select('id, tenant_id, token, expires_at, created_at') .select('id, tenant_id, token, expires_at, created_at')
.gt('expires_at', new Date().toISOString()) .gt('expires_at', new Date().toISOString())
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
if (error) throw error if (error) {
return data || [] 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) { export async function revokeSupportSession (token) {
if (!token) throw new Error('Token é obrigatório.') if (!token) throw new Error('Token é obrigatório.')
console.log(`${TAG} revokeSupportSession token=${token.slice(0, 8)}`)
const { data, error } = await supabase const { data, error } = await supabase
.rpc('revoke_support_session', { p_token: token }) .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 return !!data
} }
@@ -81,3 +148,13 @@ export function buildSupportUrl (token, basePath = '/therapist/agenda') {
const origin = window.location.origin const origin = window.location.origin
return `${origin}${basePath}?support=${token}` 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> <template>
<div class="saas-support p-4 md:p-6"> <div class="saas-support p-4 md:p-6">
<Toast /> <Toast />
<!-- Cabeçalho --> <!-- 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"> <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" /> <i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
</div> </div>
<div> <div class="flex-1">
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0 m-0">Suporte Técnico</h1> <h1 class="text-xl font-bold 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> <p class="text-sm text-surface-500 m-0">Gere e gerencie links seguros de acesso em modo debug</p>
</div> </div>
<Tag
v-if="activeSessionCount > 0"
:value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`"
severity="warning"
/>
</div> </div>
<!-- Card: Gerar nova sessão --> <!-- Tabs -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <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"> <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" /> <i class="pi pi-plus-circle text-primary" />
Nova Sessão de Suporte Configurar acesso de suporte
</h2> </h2>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<!-- Seleção de tenant -->
<div class="flex flex-col gap-1"> <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 <Select
v-model="selectedTenantId" v-model="selectedTenantId"
:options="tenants" :options="tenants"
@@ -38,9 +289,8 @@
/> />
</div> </div>
<!-- TTL -->
<div class="flex flex-col gap-1"> <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 <Select
v-model="ttlMinutes" v-model="ttlMinutes"
:options="ttlOptions" :options="ttlOptions"
@@ -50,7 +300,18 @@
/> />
</div> </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 <Button
label="Ativar Modo Suporte" label="Ativar Modo Suporte"
icon="pi pi-shield" icon="pi pi-shield"
@@ -63,65 +324,65 @@
</div> </div>
</div> </div>
<!-- Card: URL Gerada --> <!-- URL Gerada -->
<div class="card"> <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" /> <i class="pi pi-link text-primary" />
URL de Suporte Gerada URL Gerada
</h2> </h2>
<div v-if="generatedUrl" class="flex flex-col gap-3"> <div v-if="generatedUrl" class="flex flex-col gap-4">
<!-- URL -->
<div class="flex flex-col gap-1"> <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"> <div class="flex gap-2">
<InputText <InputText :value="generatedUrl" readonly class="flex-1 font-mono text-xs" />
:value="generatedUrl" <Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
readonly
class="flex-1 font-mono text-xs"
/>
<Button
icon="pi pi-copy"
severity="secondary"
outlined
v-tooltip.top="'Copiar URL'"
@click="copyUrl"
/>
</div> </div>
</div> </div>
<!-- Expira em --> <div class="flex items-center gap-2 text-sm">
<div class="flex items-center gap-2 text-sm text-surface-500">
<i class="pi pi-clock text-orange-500" /> <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> </div>
<!-- Token (reduzido) -->
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono"> <div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
<i class="pi pi-key" /> <i class="pi pi-key" />
<span>{{ tokenPreview }}</span> <span>{{ tokenPreview }}</span>
</div> </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"> <Message severity="info" :closable="false" class="text-sm">
Envie este link ao terapeuta ou acesse diretamente para ver os logs da agenda. Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.
O link expira automaticamente.
</Message> </Message>
</div> </div>
<div v-else class="flex flex-col items-center justify-center py-10 text-surface-400 gap-2"> <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-30" /> <i class="pi pi-shield text-4xl opacity-25" />
<span class="text-sm">Nenhuma sessão gerada ainda</span> <span class="text-sm">Nenhuma sessão gerada ainda</span>
</div> </div>
</div> </div>
</div> </div>
</TabPanel>
<!-- Sessões ativas --> <!-- Tab 1: Sessões Ativas -->
<div class="card mt-6"> <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"> <div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0"> <h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-list text-primary" /> <i class="pi pi-circle-fill text-green-500 text-xs" />
Sessões Ativas Sessões em vigor
</h2> </h2>
<Button <Button
icon="pi pi-refresh" icon="pi pi-refresh"
@@ -129,6 +390,7 @@
outlined outlined
size="small" size="small"
:loading="loadingSessions" :loading="loadingSessions"
label="Atualizar"
@click="loadActiveSessions" @click="loadActiveSessions"
/> />
</div> </div>
@@ -140,44 +402,126 @@
size="small" size="small"
striped-rows striped-rows
> >
<Column field="tenant_id" header="Tenant ID"> <Column header="Tenant" style="min-width: 200px">
<template #body="{ data }"> <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> </template>
</Column> </Column>
<Column header="Token"> <Column header="Token">
<template #body="{ data }"> <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> </template>
</Column> </Column>
<Column header="Expira em"> <Column header="Restante" style="min-width: 100px">
<template #body="{ data }"> <template #body="{ data }">
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''"> <span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
{{ formatExpires(data.expires_at) }} {{ remainingLabel(data.expires_at) }}
</span> </span>
</template> </template>
</Column> </Column>
<Column header="Criada"> <Column header="Criada em">
<template #body="{ data }"> <template #body="{ data }">
{{ formatDate(data.created_at) }} <span class="text-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
</template> </template>
</Column> </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 }"> <template #body="{ data }">
<div class="flex gap-2"> <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 <Button
icon="pi pi-copy" icon="pi pi-refresh"
size="small"
severity="secondary" severity="secondary"
outlined outlined
v-tooltip.top="'Copiar URL'" size="small"
@click="copySessionUrl(data.token)" :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 <Button
v-if="!data._expired"
icon="pi pi-trash" icon="pi pi-trash"
size="small" size="small"
severity="danger" severity="danger"
@@ -186,179 +530,12 @@
:loading="revokingToken === data.token" :loading="revokingToken === data.token"
@click="handleRevoke(data.token)" @click="handleRevoke(data.token)"
/> />
</div>
</template> </template>
</Column> </Column>
</DataTable> </DataTable>
</div> </div>
</TabPanel>
</TabView>
</div> </div>
</template> </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>