Sistema de Suporte , Documentação
This commit is contained in:
801
src/features/agenda/components/dev/AgendaDevDocs.vue
Normal file
801
src/features/agenda/components/dev/AgendaDevDocs.vue
Normal 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><script setup></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 (0–6), 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>{ valid: boolean, tenant_id: uuid }</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>{ token: text, expires_at: timestamptz, session_id: uuid }</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>{ old_rule_id: uuid, new_rule_id: uuid }</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 { logAPI, logError, logPerf, logAgenda } from '@/support/supportLogger'
|
||||||
|
|
||||||
|
async function loadEvents (ownerId, range) {
|
||||||
|
const end = logPerf('useAgendaEvents', 'loadEvents')
|
||||||
|
logAPI('useAgendaEvents', 'loadEvents start', { ownerId, range })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.select('*')
|
||||||
|
.eq('owner_id', ownerId)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
end({ count: data.length })
|
||||||
|
logAgenda('useAgendaEvents', 'eventos carregados', { count: data.length })
|
||||||
|
return data
|
||||||
|
} catch (e) {
|
||||||
|
logError('useAgendaEvents', 'loadEvents ERRO', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}</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> só é 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>
|
||||||
@@ -1,55 +1,88 @@
|
|||||||
<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 -->
|
|
||||||
<div class="support-banner__filters">
|
<!-- Toolbar: filtros + busca -->
|
||||||
<button
|
<div class="support-banner__toolbar">
|
||||||
v-for="lvl in levels"
|
<div class="support-banner__filters">
|
||||||
:key="lvl.value"
|
<button
|
||||||
class="support-banner__filter-btn"
|
v-for="lvl in levels"
|
||||||
:class="{ 'support-banner__filter-btn--active': activeLevel === lvl.value }"
|
:key="lvl.value"
|
||||||
@click="activeLevel = activeLevel === lvl.value ? null : lvl.value"
|
class="support-banner__filter-btn"
|
||||||
>
|
:class="{ 'support-banner__filter-btn--active': activeLevel === lvl.value }"
|
||||||
{{ lvl.label }}
|
@click="activeLevel = activeLevel === lvl.value ? null : lvl.value"
|
||||||
<span class="support-banner__filter-count">
|
>
|
||||||
{{ countByLevel(lvl.value) }}
|
{{ lvl.label }}
|
||||||
</span>
|
<span class="support-banner__filter-count">{{ countByLevel(lvl.value) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Lista de logs -->
|
<!-- 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
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,61 +355,116 @@ 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; }
|
||||||
|
|
||||||
.support-banner__log-entry--error { border-left: 3px solid #ef4444; }
|
.support-banner__log-entry--error { border-left: 3px solid #ef4444; }
|
||||||
.support-banner__log-entry--api { border-left: 3px solid #3b82f6; }
|
.support-banner__log-entry--api { border-left: 3px solid #3b82f6; }
|
||||||
.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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (1–120, default 60)
|
* @param {number} ttlMinutes - TTL em minutos (1–120, 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,237 +1,60 @@
|
|||||||
<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 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Card: Gerar nova sessão -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div class="card">
|
|
||||||
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<i class="pi pi-plus-circle text-primary" />
|
|
||||||
Nova Sessão 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>
|
|
||||||
<Select
|
|
||||||
v-model="selectedTenantId"
|
|
||||||
:options="tenants"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
placeholder="Buscar tenant..."
|
|
||||||
filter
|
|
||||||
:loading="loadingTenants"
|
|
||||||
class="w-full"
|
|
||||||
empty-filter-message="Nenhum tenant encontrado"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<Select
|
|
||||||
v-model="ttlMinutes"
|
|
||||||
:options="ttlOptions"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botão -->
|
|
||||||
<Button
|
|
||||||
label="Ativar Modo Suporte"
|
|
||||||
icon="pi pi-shield"
|
|
||||||
severity="warning"
|
|
||||||
:loading="creating"
|
|
||||||
:disabled="!selectedTenantId"
|
|
||||||
class="w-full"
|
|
||||||
@click="handleCreate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Card: URL Gerada -->
|
|
||||||
<div class="card">
|
|
||||||
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<i class="pi pi-link text-primary" />
|
|
||||||
URL de Suporte Gerada
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div v-if="generatedUrl" class="flex flex-col gap-3">
|
|
||||||
<!-- URL -->
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Expira em -->
|
|
||||||
<div class="flex items-center gap-2 text-sm text-surface-500">
|
|
||||||
<i class="pi pi-clock text-orange-500" />
|
|
||||||
<span>Expira em: <strong class="text-surface-700 dark:text-surface-300">{{ expiresLabel }}</strong></span>
|
|
||||||
</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 -->
|
|
||||||
<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.
|
|
||||||
</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" />
|
|
||||||
<span class="text-sm">Nenhuma sessão gerada ainda</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sessões ativas -->
|
|
||||||
<div class="card mt-6">
|
|
||||||
<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
|
|
||||||
</h2>
|
|
||||||
<Button
|
|
||||||
icon="pi pi-refresh"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
size="small"
|
|
||||||
:loading="loadingSessions"
|
|
||||||
@click="loadActiveSessions"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
:value="activeSessions"
|
|
||||||
:loading="loadingSessions"
|
|
||||||
empty-message="Nenhuma sessão ativa no momento"
|
|
||||||
size="small"
|
|
||||||
striped-rows
|
|
||||||
>
|
|
||||||
<Column field="tenant_id" header="Tenant ID">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="font-mono text-xs">{{ data.tenant_id }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column header="Token">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="font-mono text-xs">{{ data.token.slice(0, 16) }}…</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column header="Expira em">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
|
|
||||||
{{ formatExpires(data.expires_at) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column header="Criada">
|
|
||||||
<template #body="{ data }">
|
|
||||||
{{ formatDate(data.created_at) }}
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column header="Ações">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
icon="pi pi-copy"
|
|
||||||
size="small"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
v-tooltip.top="'Copiar URL'"
|
|
||||||
@click="copySessionUrl(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>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import { supabase } from '@/lib/supabase/client'
|
import { supabase } from '@/lib/supabase/client'
|
||||||
import {
|
import {
|
||||||
createSupportSession,
|
createSupportSession,
|
||||||
listActiveSupportSessions,
|
listActiveSupportSessions,
|
||||||
|
listSessionHistory,
|
||||||
revokeSupportSession,
|
revokeSupportSession,
|
||||||
buildSupportUrl,
|
buildSupportUrl,
|
||||||
} from '@/support/supportSessionService'
|
} from '@/support/supportSessionService'
|
||||||
|
|
||||||
|
const TAG = '[SaasSupportPage]'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
// ── Estado ─────────────────────────────────────────────────────────────────
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
const activeTab = ref(0)
|
||||||
|
|
||||||
|
// ── Estado — Nova Sessão ──────────────────────────────────────────────────────
|
||||||
const selectedTenantId = ref(null)
|
const selectedTenantId = ref(null)
|
||||||
const ttlMinutes = ref(60)
|
const ttlMinutes = ref(60)
|
||||||
|
const sessionNote = ref('')
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const loadingTenants = ref(false)
|
const generatedUrl = ref(null)
|
||||||
const loadingSessions = ref(false)
|
const generatedData = ref(null)
|
||||||
const revokingToken = ref(null)
|
|
||||||
|
// ── Estado — Listas ───────────────────────────────────────────────────────────
|
||||||
|
const loadingTenants = ref(false)
|
||||||
|
const loadingSessions = ref(false)
|
||||||
|
const loadingHistory = ref(false)
|
||||||
|
const revokingToken = ref(null)
|
||||||
|
|
||||||
const tenants = ref([])
|
const tenants = ref([])
|
||||||
|
const tenantMap = ref({})
|
||||||
const activeSessions = ref([])
|
const activeSessions = ref([])
|
||||||
|
const sessionHistory = ref([])
|
||||||
|
|
||||||
const generatedUrl = ref(null)
|
// ── TTL Options ───────────────────────────────────────────────────────────────
|
||||||
const generatedData = ref(null) // { token, expires_at }
|
|
||||||
|
|
||||||
// ── Opções de TTL ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ttlOptions = [
|
const ttlOptions = [
|
||||||
|
{ label: '15 minutos', value: 15 },
|
||||||
{ label: '30 minutos', value: 30 },
|
{ label: '30 minutos', value: 30 },
|
||||||
{ label: '60 minutos', value: 60 },
|
{ label: '1 hora', value: 60 },
|
||||||
{ label: '2 horas', value: 120 },
|
{ label: '2 horas', value: 120 },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── Computed ───────────────────────────────────────────────────────────────
|
// ── 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(() => {
|
const expiresLabel = computed(() => {
|
||||||
if (!generatedData.value?.expires_at) return ''
|
if (!generatedData.value?.expires_at) return ''
|
||||||
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
|
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
|
||||||
@@ -243,17 +66,20 @@ const tokenPreview = computed(() => {
|
|||||||
return `${t.slice(0, 8)}…${t.slice(-8)}`
|
return `${t.slice(0, 8)}…${t.slice(-8)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
const activeSessionCount = computed(() => activeSessions.value.length)
|
||||||
|
|
||||||
onMounted(() => {
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
loadTenants()
|
onMounted(async () => {
|
||||||
loadActiveSessions()
|
console.log(`${TAG} montado`)
|
||||||
|
await loadTenants()
|
||||||
|
await loadActiveSessions()
|
||||||
|
startTick()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Métodos ────────────────────────────────────────────────────────────────
|
// ── Tenants ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadTenants () {
|
async function loadTenants () {
|
||||||
loadingTenants.value = true
|
loadingTenants.value = true
|
||||||
|
console.log(`${TAG} loadTenants`)
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('tenants')
|
.from('tenants')
|
||||||
@@ -262,56 +88,83 @@ async function loadTenants () {
|
|||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
tenants.value = (data || []).map(t => ({
|
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,
|
value: t.id,
|
||||||
label: `${t.name} (${t.kind ?? 'tenant'})`,
|
label: `${t.name} (${t.kind ?? 'tenant'})`,
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(`${TAG} loadTenants ERRO`, e)
|
||||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
loadingTenants.value = false
|
loadingTenants.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sessões ativas ─────────────────────────────────────────────────────────────
|
||||||
async function loadActiveSessions () {
|
async function loadActiveSessions () {
|
||||||
loadingSessions.value = true
|
loadingSessions.value = true
|
||||||
|
console.log(`${TAG} loadActiveSessions`)
|
||||||
try {
|
try {
|
||||||
activeSessions.value = await listActiveSupportSessions()
|
activeSessions.value = await listActiveSupportSessions()
|
||||||
|
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(`${TAG} loadActiveSessions ERRO`, e)
|
||||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
loadingSessions.value = false
|
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 () {
|
async function handleCreate () {
|
||||||
if (!selectedTenantId.value) return
|
if (!selectedTenantId.value) return
|
||||||
creating.value = true
|
creating.value = true
|
||||||
generatedUrl.value = null
|
generatedUrl.value = null
|
||||||
generatedData.value = null
|
generatedData.value = null
|
||||||
|
|
||||||
|
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value)
|
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value)
|
||||||
generatedData.value = result
|
generatedData.value = result
|
||||||
generatedUrl.value = buildSupportUrl(result.token)
|
generatedUrl.value = buildSupportUrl(result.token)
|
||||||
|
|
||||||
toast.add({
|
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0,8)}…`, expires_at: result.expires_at })
|
||||||
severity: 'success',
|
|
||||||
summary: 'Sessão criada',
|
|
||||||
detail: 'URL de suporte gerada com sucesso.',
|
|
||||||
life: 4000,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 })
|
||||||
await loadActiveSessions()
|
await loadActiveSessions()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(`${TAG} handleCreate ERRO`, e)
|
||||||
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
|
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false
|
creating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Revogar ───────────────────────────────────────────────────────────────────
|
||||||
async function handleRevoke (token) {
|
async function handleRevoke (token) {
|
||||||
revokingToken.value = token
|
revokingToken.value = token
|
||||||
|
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}…`)
|
||||||
try {
|
try {
|
||||||
await revokeSupportSession(token)
|
await revokeSupportSession(token)
|
||||||
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
|
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
|
||||||
@@ -320,45 +173,369 @@ async function handleRevoke (token) {
|
|||||||
generatedData.value = null
|
generatedData.value = null
|
||||||
}
|
}
|
||||||
await loadActiveSessions()
|
await loadActiveSessions()
|
||||||
|
if (sessionHistory.value.length) await loadHistory()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(`${TAG} handleRevoke ERRO`, e)
|
||||||
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
|
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
revokingToken.value = null
|
revokingToken.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyUrl () {
|
// ── Copiar ────────────────────────────────────────────────────────────────────
|
||||||
if (!generatedUrl.value) return
|
function copyUrl (url) {
|
||||||
navigator.clipboard.writeText(generatedUrl.value)
|
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 })
|
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
function copySessionUrl (token) {
|
// ── Tab change ────────────────────────────────────────────────────────────────
|
||||||
const url = buildSupportUrl(token)
|
function onTabChange (e) {
|
||||||
navigator.clipboard.writeText(url)
|
const idx = e.index ?? e
|
||||||
toast.add({ severity: 'info', summary: 'Copiado!', life: 2000 })
|
activeTab.value = idx
|
||||||
|
console.log(`${TAG} tab mudou para ${idx}`)
|
||||||
|
if (idx === 2 && sessionHistory.value.length === 0) loadHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Formatação ─────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function tenantName (id) {
|
||||||
|
return tenantMap.value[id] || id
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate (iso) {
|
function formatDate (iso) {
|
||||||
if (!iso) return '-'
|
if (!iso) return '-'
|
||||||
return new Date(iso).toLocaleString('pt-BR')
|
return new Date(iso).toLocaleString('pt-BR')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExpires (iso) {
|
function remainingLabel (iso) {
|
||||||
|
_now.value // dependência reativa
|
||||||
if (!iso) return '-'
|
if (!iso) return '-'
|
||||||
const d = new Date(iso)
|
const diff = new Date(iso) - Date.now()
|
||||||
const now = new Date()
|
if (diff <= 0) return 'Expirada'
|
||||||
const diffMin = Math.round((d - now) / 60000)
|
const min = Math.floor(diff / 60000)
|
||||||
if (diffMin < 0) return 'Expirada'
|
const h = Math.floor(min / 60)
|
||||||
if (diffMin < 60) return `em ${diffMin} min`
|
const m = min % 60
|
||||||
return new Date(iso).toLocaleString('pt-BR')
|
if (h > 0) return `${h}h ${m}min`
|
||||||
|
return `${min} min`
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExpiringSoon (iso) {
|
function isExpiringSoon (iso) {
|
||||||
if (!iso) return false
|
if (!iso) return false
|
||||||
const diffMin = (new Date(iso) - new Date()) / 60000
|
const diff = (new Date(iso) - Date.now()) / 60000
|
||||||
return diffMin > 0 && diffMin < 15
|
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>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="saas-support p-4 md:p-6">
|
||||||
|
<Toast />
|
||||||
|
|
||||||
|
<!-- Cabeçalho -->
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
<!-- 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 flex items-center gap-2 m-0 mb-4">
|
||||||
|
<i class="pi pi-plus-circle text-primary" />
|
||||||
|
Configurar acesso de suporte
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-sm font-medium">Selecionar Cliente (Tenant)</label>
|
||||||
|
<Select
|
||||||
|
v-model="selectedTenantId"
|
||||||
|
:options="tenants"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
placeholder="Buscar tenant..."
|
||||||
|
filter
|
||||||
|
:loading="loadingTenants"
|
||||||
|
class="w-full"
|
||||||
|
empty-filter-message="Nenhum tenant encontrado"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-sm font-medium">Duração do Acesso</label>
|
||||||
|
<Select
|
||||||
|
v-model="ttlMinutes"
|
||||||
|
:options="ttlOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
severity="warning"
|
||||||
|
:loading="creating"
|
||||||
|
:disabled="!selectedTenantId"
|
||||||
|
class="w-full"
|
||||||
|
@click="handleCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL Gerada -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
|
||||||
|
<i class="pi pi-link text-primary" />
|
||||||
|
URL Gerada
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div v-if="generatedUrl" class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<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(generatedUrl)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<i class="pi pi-clock text-orange-500" />
|
||||||
|
<span class="text-surface-500">Expira em:</span>
|
||||||
|
<strong>{{ expiresLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
|
||||||
|
<i class="pi pi-key" />
|
||||||
|
<span>{{ tokenPreview }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 monitorar os logs da agenda em tempo real.
|
||||||
|
</Message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ── 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-circle-fill text-green-500 text-xs" />
|
||||||
|
Sessões em vigor
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
:loading="loadingSessions"
|
||||||
|
label="Atualizar"
|
||||||
|
@click="loadActiveSessions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="activeSessions"
|
||||||
|
:loading="loadingSessions"
|
||||||
|
empty-message="Nenhuma sessão ativa no momento"
|
||||||
|
size="small"
|
||||||
|
striped-rows
|
||||||
|
>
|
||||||
|
<Column header="Tenant" style="min-width: 200px">
|
||||||
|
<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="Restante" style="min-width: 100px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
|
||||||
|
{{ remainingLabel(data.expires_at) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Criada em">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-sm text-surface-500">{{ formatDate(data.created_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="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-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
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"
|
||||||
|
outlined
|
||||||
|
v-tooltip.top="'Revogar'"
|
||||||
|
:loading="revokingToken === data.token"
|
||||||
|
@click="handleRevoke(data.token)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
</TabView>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user